From aba52eaf791abdb0c210d4f2fe6a5f36a09d732d Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:57:32 +0530 Subject: [PATCH] feat(local): set up a Claude plan from Settings and onboarding A "Use your Claude plan" row beside the ChatGPT one opens a dialog that reads Claude Code's state live and says what to do: install it, run claude and sign in, or update it, with Check again; once ready, Add Claude models asks for both of its hosts in one egress question and makes the connection. The egress prompt now asks about every host a refusal or askEgress names at once, wording a Claude plan's two by what each gets, which also makes ChatGPT's sign-in one question. Editing a Claude plan only renames it, chat names its failures in Claude's terms, and Electron opens Claude Code's install guide and no other page of that site. Every new string is in all ten languages. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/translate/glossary.md | 5 +- docs/architecture/egress.md | 3 +- docs/proposals/claude-plan/05-dialog.md | 32 ++- .../electron/src/main/external-url.test.ts | 11 + .../electron/src/main/external-url.ts | 7 +- .../frontend/src/components/ui/icons.tsx | 2 + .../src/features/chat/chat-error-text.ts | 12 + .../src/features/chat/model-issue-notice.tsx | 9 + .../frontend/src/features/chat/sse.ts | 2 + .../frontend/src/features/egress/api.ts | 94 ++++++- .../src/features/egress/ask-egress.ts | 2 + .../features/egress/egress-prompt.test.tsx | 91 +++++++ .../src/features/egress/egress-prompt.tsx | 32 ++- .../models/add-model/add-model-options.tsx | 2 + .../features/models/remote/connections/api.ts | 7 +- .../models/remote/connections/chatgpt/api.ts | 18 +- .../models/remote/connections/claude/api.ts | 67 +++++ .../connections/claude/claude-dialog.tsx | 251 ++++++++++++++++++ .../connections/claude/claude-option.test.tsx | 189 +++++++++++++ .../connections/claude/claude-option.tsx | 126 +++++++++ .../claude/claude-plan-edit.test.tsx | 66 +++++ .../connections/claude/claude-plan-edit.tsx | 98 +++++++ .../remote/connections/connection-form.tsx | 43 ++- .../models/remote/models/server-models.tsx | 21 +- .../onboarding/model-step/model-step.tsx | 9 + surfsense_local/frontend/translations/de.json | 36 +++ surfsense_local/frontend/translations/en.json | 36 +++ surfsense_local/frontend/translations/es.json | 36 +++ surfsense_local/frontend/translations/fr.json | 36 +++ surfsense_local/frontend/translations/hi.json | 36 +++ surfsense_local/frontend/translations/ja.json | 36 +++ surfsense_local/frontend/translations/ko.json | 36 +++ .../frontend/translations/pt-BR.json | 36 +++ surfsense_local/frontend/translations/ru.json | 36 +++ .../frontend/translations/zh-CN.json | 36 +++ 35 files changed, 1506 insertions(+), 53 deletions(-) create mode 100644 surfsense_local/frontend/src/features/models/remote/connections/claude/api.ts create mode 100644 surfsense_local/frontend/src/features/models/remote/connections/claude/claude-dialog.tsx create mode 100644 surfsense_local/frontend/src/features/models/remote/connections/claude/claude-option.test.tsx create mode 100644 surfsense_local/frontend/src/features/models/remote/connections/claude/claude-option.tsx create mode 100644 surfsense_local/frontend/src/features/models/remote/connections/claude/claude-plan-edit.test.tsx create mode 100644 surfsense_local/frontend/src/features/models/remote/connections/claude/claude-plan-edit.tsx diff --git a/.agents/skills/translate/glossary.md b/.agents/skills/translate/glossary.md index 684dc039e..11e530252 100644 --- a/.agents/skills/translate/glossary.md +++ b/.agents/skills/translate/glossary.md @@ -71,9 +71,10 @@ SurfSense, Studio, Markdown, llama.cpp, GGUF, Hugging Face, API, MCP, and every | session log | セッションログ | 세션 로그 | 会话日志 | सेशन लॉग | Sitzungsprotokoll | registro de la sesión | journal de session | log da sessão | журнал сеанса | | clipboard | クリップボード | 클립보드 | 剪贴板 | क्लिपबोर्ड | Zwischenablage | portapapeles | presse-papiers | área de transferência | буфер обмена | | embedding (the model that turns the library into vectors) | 埋め込み | 임베딩 | 嵌入 | एम्बेडिंग | Embedding | embedding | embedding | embedding | эмбеддинг | -| sign in (to a ChatGPT account) | サインイン | 로그인 | 登录 | साइन इन | anmelden | iniciar sesión | se connecter | entrar | войти | -| plan (a ChatGPT plan, Plus or Pro) | プラン | 플랜 | 套餐 | प्लान | Plan | plan | forfait | plano | план | +| sign in (to a ChatGPT account, or Claude Code to a Claude plan) | サインイン | 로그인 | 登录 | साइन इन | anmelden | iniciar sesión | se connecter | entrar | войти | +| plan (a ChatGPT plan, Plus or Pro; a Claude plan, Pro, Max or Team) | プラン | 플랜 | 套餐 | प्लान | Plan | plan | forfait | plano | план | | subscription (using a ChatGPT plan instead of a key) | サブスクリプション | 구독 | 订阅 | सब्सक्रिप्शन | Abo | suscripción | abonnement | assinatura | подписка | +| terminal (where the user runs `claude`, the command that stays in English) | ターミナル | 터미널 | 终端 | टर्मिनल | Terminal | terminal (una) | terminal | terminal | терминал | ## What the English means diff --git a/docs/architecture/egress.md b/docs/architecture/egress.md index 486b4e4d5..9420a691e 100644 --- a/docs/architecture/egress.md +++ b/docs/architecture/egress.md @@ -62,7 +62,7 @@ The GGUF download runs inside the API (`modules/llm/providers/llamacpp/download. ## Asking for consent -`request()` in `lib/api.ts` catches `egress_disabled` on a non-GET request and hands it to `EgressPrompt`, which shows that destination's copy. Allow enables the destination and the request is retried once; Cancel rethrows the refusal. The method stands in for "the user just did something", a `ponytail:` simplification, because reads also run unattended at boot and must never raise a dialog. +`request()` in `lib/api.ts` catches `egress_disabled` on a non-GET request and hands it to `EgressPrompt`, which shows that destination's copy. A refusal naming several hosts in `hosts` is one question: the copy names the others, and Allow enables every one of them before the request is retried once; Cancel rethrows the refusal. A Claude plan's refusal, `purpose: "claude_plan"`, says what each of its two hosts gets ([`features/egress/api.ts`](../../surfsense_local/frontend/src/features/egress/api.ts)). The method stands in for "the user just did something", a `ponytail:` simplification, because reads also run unattended at boot and must never raise a dialog. A refused GET therefore surfaces as an error without a prompt. @@ -79,6 +79,5 @@ Settings › Network lists the App updates row and every destination with its ho ## Known gaps -- `EgressPrompt` reads only `destination`, so a plugin with several hosts, such as a registry entry whose authorization server is on another host, is asked about one host at a time, though the refusal names them all in `hosts` ([plugins](plugins.md)). - Document scripts, Studio's Office formats and the agent's, run model-written code in the script runner's process, and that code can open connections of its own ([studio](studio.md#script-documents)). - The Office pack's download is allowed by its host, `downloadarchive.documentfoundation.org`, which redirects to one of TDF's mirrors; the mirror sees the user's IP address and is not asked about ([office pack](office-pack.md)). diff --git a/docs/proposals/claude-plan/05-dialog.md b/docs/proposals/claude-plan/05-dialog.md index 740c9486e..19886c5f6 100644 --- a/docs/proposals/claude-plan/05-dialog.md +++ b/docs/proposals/claude-plan/05-dialog.md @@ -10,31 +10,35 @@ Part of [Use your Claude plan](README.md). | File | Does | |---|---| -| `claude-option.tsx` | the row: title, one-line body, and once connected "Connected as … · Max plan" with **Show models** | -| `claude-dialog.tsx` | the nested dialog, one view per state | -| `use-claude-status.ts` | reads `GET /llm/connections/claude`; **Check again** reads it again | -| `api.ts` | the two routes ([`01-connection.md`](01-connection.md#routes)) | +| `claude-option.tsx` | the row: the Claude icon, title and one-line body; **Set up** opens the dialog, or once connected "Connected through Claude Code on this computer" with **Show models** | +| `claude-dialog.tsx` | the nested dialog, one view per state, read live each time it opens | +| `claude-plan-edit.tsx` | the connection form for a Claude plan: rename only, no URL, key or sign-in | +| `api.ts` | `GET /llm/connections/claude`, and connecting after one egress question for every host still off | -The row is mounted where the ChatGPT row is: [`add-model-options.tsx`](../../../surfsense_local/frontend/src/features/models/add-model/add-model-options.tsx) and [`model-step.tsx`](../../../surfsense_local/frontend/src/features/onboarding/model-step/model-step.tsx). The dialog uses the app's dialog component, nested in the open one, and the shared button; every string is a message, translated like the ChatGPT row's. +The row is mounted where the ChatGPT row is: [`add-model-options.tsx`](../../../surfsense_local/frontend/src/features/models/add-model/add-model-options.tsx) and [`model-step.tsx`](../../../surfsense_local/frontend/src/features/onboarding/model-step/model-step.tsx). The dialog uses the app's dialog component, nested in the open one, and the shared button; every string is a message, translated into every language in `LOCALES`. ## The views -The dialog opens on the status read when it opens. Every view has the same opening lines: SurfSense uses the user's Claude Pro, Max or Team plan through Claude Code on this computer; the sign-in stays in Claude Code and SurfSense never sees it; usage counts toward the plan, the same limits as Claude Code. +Every view opens with the same lines: SurfSense uses the user's Claude Pro, Max or Team plan through Claude Code on this computer; the sign-in stays in Claude Code and SurfSense never sees it; use counts toward the plan's limits, as it does in Claude Code. | State | The dialog shows | Actions | |---|---|---| -| `not_installed` | Step 1, install Claude Code; step 2, open a terminal, run `claude` and sign in | **Open install guide**, **Check again**, **Cancel** | -| `signed_out` | Claude Code is installed but not signed in; run `claude` in a terminal and sign in | **Check again**, **Cancel** | -| `too_old` | the version found and the lowest accepted | **Open update guide**, **Check again**, **Cancel** | -| `ready` | "Connected as · ", where Claude Code was found and its version, and that Claude models work in chat and in the agent | **Add Claude models**, **Cancel** | +| `not_installed` | step 1, install Claude Code; step 2, open a terminal, run `claude` and sign in | **Open install guide**, **Check again**, **Cancel** | +| `signed_out` | Claude Code is installed but not signed in to a plan; run `claude` in a terminal and sign in | **Check again**, **Cancel** | +| `too_old` | the version found, to update; or, when no version could be read, that SurfSense could not tell | **Check again**, **Cancel** | +| `ready` | "Connected as , ", and that Claude models then work in chat, titles and Studio | **Add Claude models**, **Cancel** | -**Check again** reads the status and moves to the view it returns. Nothing polls: nothing changes on SurfSense's side until the user acts in their terminal. +**Check again** reads the status again and moves to the view it returns. Nothing polls: nothing changes on SurfSense's side until the user acts in their terminal. -**Add Claude models** first asks about every host still off, `api.anthropic.com` for chats and `platform.claude.com` for the sign-in's refresh ([`01-connection.md`](01-connection.md#network)), in one egress prompt nested over the dialog ([egress](../../architecture/egress.md#asking-for-consent)), then posts the connection. Allow switches each host on as its own row; Cancel creates nothing. On success the dialog closes and the models appear in every picker. +**Open install guide** opens Anthropic's guide, `https://code.claude.com/docs/en/setup`, the one page on that site Electron's link allowlist lets through ([`external-url.ts`](../../../surfsense_local/electron/src/main/external-url.ts)). -The prompt asks about several hosts at once, which `EgressPrompt` cannot yet: it reads only `destination`, and asks about one host at a time though the refusal names them all ([egress](../../architecture/egress.md#known-gaps)). It is taught to read `hosts` and to word each by `purpose: "claude_plan"`: the API host gets what the model is sent, chats and sources; the sign-in host gets the sign-in's refresh, never a chat. That closes the gap for ChatGPT's sign-in and plugins with several hosts too. In Settings › Network the sign-in host's row says that turning it off stops Claude once the sign-in expires. +**Add Claude models** first asks about every host still off, `api.anthropic.com` for chats and `platform.claude.com` for the sign-in's refresh ([`01-connection.md`](01-connection.md#network)), in one egress prompt nested over the dialog ([egress](../../architecture/egress.md#asking-for-consent)), then posts the connection. Allow switches each host on as its own row; Cancel creates nothing and the dialog says which host is needed. On success the dialog closes and the models appear in every picker. -**Opened again** once connected, the dialog shows the `ready` view with **Remove** in place of **Add Claude models**, or the state that now applies, such as `signed_out` after the user signed out in Claude Code. +The egress prompt asks about several hosts in one question: a refusal's `hosts` and an `askEgress` call's `also` are named in it, and Allow switches each on. A Claude plan's question, `purpose: "claude_plan"`, says what each host gets: the API host what the model is sent, chats and sources; the sign-in host only the sign-in's refresh, and that turning it off stops Claude once the sign-in expires. Other questions naming several hosts, a ChatGPT sign-in's two and a plugin's, add "Allowing also lets SurfSense reach …". In Settings › Network the sign-in host's row is labelled "Claude Code sign-in". + +**Editing** a Claude plan from the connection list opens `claude-plan-edit.tsx`: the name is editable, the provider is shown fixed as "Claude plan", and it says that signing in, out or switching accounts happens with `claude` in a terminal. Removing it is the connection list's own delete, as for any connection. + +**When chat fails** on a Claude plan, it says so in Claude's terms, not ChatGPT's: `claude_sign_in` asks to run `claude` and sign in, `claude_limit` says the Claude plan's limit is reached, and the model check's `claude_sign_in_required` says Claude Code is missing or signed out. ## Names diff --git a/surfsense_local/electron/src/main/external-url.test.ts b/surfsense_local/electron/src/main/external-url.test.ts index b2bbde18c..f9ee092b4 100644 --- a/surfsense_local/electron/src/main/external-url.test.ts +++ b/surfsense_local/electron/src/main/external-url.test.ts @@ -138,3 +138,14 @@ test("a link the user confirmed opens on any https host, and nothing else", () = assert.equal(confirmedExternalUrl("https://me:pw@app.notion.com/"), null) // pragma: allowlist secret assert.equal(confirmedExternalUrl("not a url"), null) }) + +test("opens Claude Code's install guide and nothing else on its docs site", () => { + assert.equal( + allowedExternalUrl("https://code.claude.com/docs/en/setup"), + "https://code.claude.com/docs/en/setup" + ) + assert.equal(allowedExternalUrl("https://code.claude.com/docs/en/overview"), null) + assert.equal(allowedExternalUrl("https://code.claude.com/docs/en/setup?x=1"), null) + assert.equal(allowedExternalUrl("http://code.claude.com/docs/en/setup"), null) + assert.equal(allowedExternalUrl("https://claude.ai/"), null) +}) diff --git a/surfsense_local/electron/src/main/external-url.ts b/surfsense_local/electron/src/main/external-url.ts index 2c6fa29f2..5ea0d263a 100644 --- a/surfsense_local/electron/src/main/external-url.ts +++ b/surfsense_local/electron/src/main/external-url.ts @@ -1,8 +1,8 @@ // The only links the app hands to the OS browser: the SurfSense site, the // project's own GitHub repository, its Discord invite, an OpenRouter model's // page, where OpenRouter names a speech model's voices, and OpenAI's sign-in -// page for a ChatGPT subscription, and a plugin provider's sign-in page that -// returns to SurfSense. Anything else is dropped so a crafted link in content +// page for a ChatGPT subscription, Claude Code's install guide for a Claude +// plan, and a plugin provider's sign-in page that returns to SurfSense. Anything else is dropped so a crafted link in content // cannot open arbitrary pages. const SITE_HOSTS = new Set(["surfsense.com", "www.surfsense.com"]) const REPO_PATH = "/MODSetter/SurfSense" @@ -11,6 +11,8 @@ const DISCORD_INVITE = "/ejRNvftDp9" // opens OpenRouter's own site; no other host or deeper path gets through. const OPENROUTER_MODEL = /^\/[\w.-]+\/[\w.:-]+$/ const OPENAI_AUTHORIZE = "/api/accounts/authorize" +// A Claude plan runs the user's own Claude Code, which SurfSense never installs. +const CLAUDE_CODE_SETUP = "https://code.claude.com/docs/en/setup" // Only a sign-in that returns its code to the API's own loopback listener; // one naming any other redirect would hand the code to someone else. @@ -74,6 +76,7 @@ export function allowedExternalUrl(url: string): string | null { ) { return parsed.href } + if (parsed.href === CLAUDE_CODE_SETUP) return parsed.href if (isLoopbackSignIn(parsed)) return parsed.href if (isPluginSignIn(parsed)) return parsed.href return null diff --git a/surfsense_local/frontend/src/components/ui/icons.tsx b/surfsense_local/frontend/src/components/ui/icons.tsx index 169b658ee..2be6bddd1 100644 --- a/surfsense_local/frontend/src/components/ui/icons.tsx +++ b/surfsense_local/frontend/src/components/ui/icons.tsx @@ -21,6 +21,7 @@ import { ChartHistogramIcon as ChartHistogramIconData, Chat01Icon as Chat01IconData, ChatGptIcon as ChatGptIconData, + ClaudeIcon as ClaudeIconData, CheckIcon as CheckIconData, CheckmarkBadge02Icon as CheckmarkBadge02IconData, CheckmarkCircle02Icon, @@ -144,6 +145,7 @@ export const Cards01Icon = createIcon(Cards01IconData) export const ChartHistogramIcon = createIcon(ChartHistogramIconData) export const Chat01Icon = createIcon(Chat01IconData) export const ChatGptIcon = createIcon(ChatGptIconData) +export const ClaudeIcon = createIcon(ClaudeIconData) export const CheckCircle2Icon = createIcon(CheckmarkCircle02Icon) export const CheckIcon = createIcon(CheckIconData) export const CheckmarkBadgeIcon = createIcon(CheckmarkBadge02IconData) diff --git a/surfsense_local/frontend/src/features/chat/chat-error-text.ts b/surfsense_local/frontend/src/features/chat/chat-error-text.ts index 66931cc7d..1de5bd019 100644 --- a/surfsense_local/frontend/src/features/chat/chat-error-text.ts +++ b/surfsense_local/frontend/src/features/chat/chat-error-text.ts @@ -50,6 +50,18 @@ const chatErrorText: Record string> = { defaultMessage: "Your ChatGPT plan’s usage limit is reached. It resets on its own; check your usage in ChatGPT’s settings.", }), + claude_sign_in: () => + intl.formatMessage({ + id: "chat_error_claude_sign_in", + defaultMessage: + "Claude Code on this computer needs to sign in again: run claude in a terminal and sign in.", + }), + claude_limit: () => + intl.formatMessage({ + id: "chat_error_claude_limit", + defaultMessage: + "Your Claude plan’s usage limit is reached. It resets on its own.", + }), network: () => intl.formatMessage({ id: "chat_error_network", diff --git a/surfsense_local/frontend/src/features/chat/model-issue-notice.tsx b/surfsense_local/frontend/src/features/chat/model-issue-notice.tsx index 4a0eb785d..08e5d5299 100644 --- a/surfsense_local/frontend/src/features/chat/model-issue-notice.tsx +++ b/surfsense_local/frontend/src/features/chat/model-issue-notice.tsx @@ -23,6 +23,15 @@ function reason({ model, code, host }: ModelIssue): string { }, { model } ) + case "claude_sign_in_required": + return intl.formatMessage( + { + id: "chat_model_issue_claude_sign_in_body", + defaultMessage: + "Couldn’t use {model}: Claude Code on this computer isn’t installed or has to sign in again.", + }, + { model } + ) case "sign_in_required": return intl.formatMessage( { diff --git a/surfsense_local/frontend/src/features/chat/sse.ts b/surfsense_local/frontend/src/features/chat/sse.ts index 0465bb5b4..ad6e6e312 100644 --- a/surfsense_local/frontend/src/features/chat/sse.ts +++ b/surfsense_local/frontend/src/features/chat/sse.ts @@ -65,6 +65,8 @@ export type ChatErrorKind = | "context_too_long" | "subscription_sign_in" | "subscription_limit" + | "claude_sign_in" + | "claude_limit" | "runtime_busy" | "network" | "timeout" diff --git a/surfsense_local/frontend/src/features/egress/api.ts b/surfsense_local/frontend/src/features/egress/api.ts index 22d0899ce..b81c7d040 100644 --- a/surfsense_local/frontend/src/features/egress/api.ts +++ b/surfsense_local/frontend/src/features/egress/api.ts @@ -8,6 +8,12 @@ export type Destination = { last_call_at: string | null } +/** Switch on a question's destination and every other host it named. */ +export async function allowAll(destination: string, also: string[] = []) { + await setDestinationEnabled(destination, true) + for (const host of also) await setDestinationEnabled(`host:${host}`, true) +} + /** Search, model weights and image weights all reach this one host. */ export const HUGGINGFACE = "host:huggingface.co" @@ -28,11 +34,95 @@ export function setDestinationEnabled( }) } +// A Claude plan's two hosts: model calls, and where Claude Code refreshes its sign-in. +const CLAUDE_API_HOST = "api.anthropic.com" +const CLAUDE_SIGN_IN_HOST = "platform.claude.com" + +export type DestinationCopy = { + label: string + title: string + body: string + /** Whole sentences shown after the body, one paragraph each. */ + details: string[] +} + +/** + * How a destination is named in Settings › Network, and the question asked + * before allowing it. `also` names the other hosts asked about in the same + * question, which one Allow switches on together. + */ export function describeDestination( destination: string, host: string, - purpose?: string -) { + purpose?: string, + also: string[] = [] +): DestinationCopy { + if (purpose === "claude_plan" || host === CLAUDE_SIGN_IN_HOST) + return describeClaudePlan([host, ...also]) + const one = describeOne(destination, host, purpose) + return { + ...one, + details: also.length + ? [ + intl.formatMessage( + { + id: "egress_prompt_also_allows_body", + defaultMessage: "Allowing also lets SurfSense reach {hosts}.", + }, + { hosts: intl.formatList(also, { type: "conjunction" }) } + ), + ] + : [], + } +} + +// Each host's own sentence, since one gets chats and the other only a sign-in. +function describeClaudePlan(hosts: string[]): DestinationCopy { + const details = [] + if (hosts.includes(CLAUDE_API_HOST)) + details.push( + intl.formatMessage( + { + id: "egress_claude_api_host_body", + defaultMessage: + "{host} gets what you send the model: your prompts and excerpts of your documents.", + }, + { host: CLAUDE_API_HOST } + ) + ) + if (hosts.includes(CLAUDE_SIGN_IN_HOST)) + details.push( + intl.formatMessage( + { + id: "egress_claude_sign_in_host_body", + defaultMessage: + "{host} keeps Claude Code signed in. It gets no chats, and turning it off stops Claude once the sign-in expires.", + }, + { host: CLAUDE_SIGN_IN_HOST } + ) + ) + return { + label: + hosts[0] === CLAUDE_SIGN_IN_HOST + ? intl.formatMessage({ + id: "egress_claude_sign_in_label", + defaultMessage: "Claude Code sign-in", + }) + : hosts[0], + title: intl.formatMessage({ + id: "egress_claude_prompt_title", + defaultMessage: "Allow your Claude plan to reach Anthropic?", + }), + body: intl.formatMessage({ + id: "egress_claude_prompt_body", + defaultMessage: + "Claude Code on this computer reaches Anthropic only through SurfSense, and only the hosts you allow. You can turn each one off in Settings › Network.", + }), + details, + } +} + +function describeOne(destination: string, host: string, purpose?: string) { if (destination === "app_updates") { return { label: intl.formatMessage({ diff --git a/surfsense_local/frontend/src/features/egress/ask-egress.ts b/surfsense_local/frontend/src/features/egress/ask-egress.ts index 379dc1546..da4369b61 100644 --- a/surfsense_local/frontend/src/features/egress/ask-egress.ts +++ b/surfsense_local/frontend/src/features/egress/ask-egress.ts @@ -11,6 +11,8 @@ export type Pending = { host: string // What the call is for, when the host alone does not say: "plugin". purpose?: string + // The other hosts asked about in the same question; Allow switches them on too. + also?: string[] // What Allow does. A refused request enables the destination and is retried; // the updater talks to GitHub from Electron, so its consent is a pref there. allow: () => Promise diff --git a/surfsense_local/frontend/src/features/egress/egress-prompt.test.tsx b/surfsense_local/frontend/src/features/egress/egress-prompt.test.tsx index 48cffb57b..9d1d2241b 100644 --- a/surfsense_local/frontend/src/features/egress/egress-prompt.test.tsx +++ b/surfsense_local/frontend/src/features/egress/egress-prompt.test.tsx @@ -45,6 +45,97 @@ afterEach(() => { }) describe("egress prompt", () => { + it("asks once about every host a refusal names, and Allow switches on each", async () => { + const calls: string[] = [] + let allowed = false + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input) + calls.push(`${init?.method ?? "GET"} ${path}`) + if (path.startsWith("/egress/")) { + allowed = calls.filter((call) => call.startsWith("PUT")).length === 2 + return Response.json({}) + } + return allowed + ? Response.json({}) + : Response.json( + { + detail: { + code: "egress_disabled", + message: "off", + destination: "host:api.anthropic.com", + host: "api.anthropic.com", + hosts: ["api.anthropic.com", "platform.claude.com"], + purpose: "claude_plan", + }, + }, + { status: 403 } + ) + }) + ) + const user = userEvent.setup() + render() + + const pending = requestVoid("/llm/connections/claude", { method: "POST" }) + const prompt = await screen.findByRole("alertdialog", { + name: "Allow your Claude plan to reach Anthropic?", + }) + // Each host says what it gets: one the chats, the other only the sign-in. + expect(prompt.textContent).toContain( + "api.anthropic.com gets what you send the model" + ) + expect(prompt.textContent).toContain( + "platform.claude.com keeps Claude Code signed in. It gets no chats" + ) + await user.click(screen.getByRole("button", { name: "Allow" })) + + await expect(pending).resolves.toBeUndefined() + expect(calls).toEqual([ + "POST /llm/connections/claude", + "PUT /egress/host:api.anthropic.com", + "PUT /egress/host:platform.claude.com", + "POST /llm/connections/claude", + ]) + }) + + it("names the other hosts a plugin's refusal lists", async () => { + const user = userEvent.setup() + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json( + { + detail: { + code: "egress_disabled", + message: "off", + destination: "host:mcp.notes.example", + host: "mcp.notes.example", + hosts: ["mcp.notes.example", "auth.notes.example"], + purpose: "plugin", + }, + }, + { status: 403 } + ) + ) + ) + render() + + // Handled from the start: Cancel rejects it before the test awaits it. + const refused = requestVoid("/plugins/custom", { method: "POST" }).catch( + (error: unknown) => error + ) + const prompt = await screen.findByRole("alertdialog", { + name: "Allow connecting to mcp.notes.example?", + }) + + expect(prompt.textContent).toContain( + "Allowing also lets SurfSense reach auth.notes.example." + ) + await user.click(screen.getByRole("button", { name: "Cancel" })) + expect(await refused).toBeInstanceOf(Error) + }) + it("asks about the host and, once allowed, lets the call through", async () => { const calls = stubApi() const user = userEvent.setup() diff --git a/surfsense_local/frontend/src/features/egress/egress-prompt.tsx b/surfsense_local/frontend/src/features/egress/egress-prompt.tsx index a90c067da..58a2a1e03 100644 --- a/surfsense_local/frontend/src/features/egress/egress-prompt.tsx +++ b/surfsense_local/frontend/src/features/egress/egress-prompt.tsx @@ -15,11 +15,7 @@ import { Spinner } from "@/components/ui/spinner" import { intl } from "@/i18n/intl" import { type ApiError, setEgressPrompt } from "@/lib/api" -import { - describeDestination, - destinationsQueryKey, - setDestinationEnabled, -} from "./api" +import { allowAll, describeDestination, destinationsQueryKey } from "./api" import { advanceEgressQueue, askEgress, @@ -29,13 +25,21 @@ import { } from "./ask-egress" function requestFrom(error: ApiError): Omit { - const { destination, host, purpose } = error.detail + const { destination, host, hosts, purpose } = error.detail const key = typeof destination === "string" ? destination : "" + const first = typeof host === "string" ? host : "" + // Every host still to allow, the first named apart: one question for them all. + const also = Array.isArray(hosts) + ? hosts.filter( + (each): each is string => typeof each === "string" && each !== first + ) + : [] return { destination: key, - host: typeof host === "string" ? host : "", + host: first, purpose: typeof purpose === "string" ? purpose : undefined, - allow: () => setDestinationEnabled(key, true), + also, + allow: () => allowAll(key, also), } } @@ -65,7 +69,12 @@ export function EgressPrompt() { } const copy = question - ? describeDestination(question.destination, question.host, question.purpose) + ? describeDestination( + question.destination, + question.host, + question.purpose, + question.also + ) : null return ( {copy?.title} {copy?.body} + {copy?.details.map((detail) => ( +

+ {detail} +

+ ))} diff --git a/surfsense_local/frontend/src/features/models/add-model/add-model-options.tsx b/surfsense_local/frontend/src/features/models/add-model/add-model-options.tsx index 6097df42b..0a681aa22 100644 --- a/surfsense_local/frontend/src/features/models/add-model/add-model-options.tsx +++ b/surfsense_local/frontend/src/features/models/add-model/add-model-options.tsx @@ -7,6 +7,7 @@ import { intl } from "@/i18n/intl" import type { ModelType } from "../model-type" import type { Connection } from "../remote/connections/api" import { ChatGPTOption } from "../remote/connections/chatgpt/chatgpt-option" +import { ClaudeOption } from "../remote/connections/claude/claude-option" import { ServerCard } from "./server-card" /** @@ -32,6 +33,7 @@ export function AddModelOptions({ <> + ) : null} diff --git a/surfsense_local/frontend/src/features/models/remote/connections/api.ts b/surfsense_local/frontend/src/features/models/remote/connections/api.ts index 69bb68ee1..d5a4473af 100644 --- a/surfsense_local/frontend/src/features/models/remote/connections/api.ts +++ b/surfsense_local/frontend/src/features/models/remote/connections/api.ts @@ -9,8 +9,11 @@ export type Connection = { base_url: string catalog_provider: string has_api_key: boolean - /** `chatgpt` signs in with a ChatGPT account instead of a key. */ - auth_kind: "api_key" | "chatgpt" + /** + * `chatgpt` signs in with a ChatGPT account instead of a key; `claude_plan` + * runs the user's own Claude Code, which keeps the sign-in. + */ + auth_kind: "api_key" | "chatgpt" | "claude_plan" signed_in: boolean account_email: string | null /** The slots it can fill, decided by the backend; pickers only read it. */ diff --git a/surfsense_local/frontend/src/features/models/remote/connections/chatgpt/api.ts b/surfsense_local/frontend/src/features/models/remote/connections/chatgpt/api.ts index ceebf0111..b22c7c7de 100644 --- a/surfsense_local/frontend/src/features/models/remote/connections/chatgpt/api.ts +++ b/surfsense_local/frontend/src/features/models/remote/connections/chatgpt/api.ts @@ -1,5 +1,5 @@ -import { setDestinationEnabled } from "@/features/egress/api" import { askEgress } from "@/features/egress/ask-egress" +import { allowAll } from "@/features/egress/api" import { requestJson, requestVoid } from "@/lib/api" import type { ModelType } from "../../../model-type" @@ -42,20 +42,22 @@ export class HostDeclinedError extends Error { /** * Starts a sign-in once both hosts it reaches, OpenAI's sign-in and its API, - * are allowed. Asked one by one up front: a refused request asks about one host - * only, and could not tell a host the user declined from the next one. + * are allowed: asked up front, in one question for every host still off. */ export async function startSignIn( target: SignInTarget ): Promise { const { hosts } = await getSignInOption() - for (const { destination, host } of hosts) { + const [first, ...rest] = hosts + if (first) { + const also = rest.map(({ host }) => host) const allowed = await askEgress({ - destination, - host, - allow: () => setDestinationEnabled(destination, true), + destination: first.destination, + host: first.host, + also, + allow: () => allowAll(first.destination, also), }) - if (!allowed) throw new HostDeclinedError(host) + if (!allowed) throw new HostDeclinedError(first.host) } return requestJson("/llm/connections/chatgpt/sign-in", { method: "POST", diff --git a/surfsense_local/frontend/src/features/models/remote/connections/claude/api.ts b/surfsense_local/frontend/src/features/models/remote/connections/claude/api.ts new file mode 100644 index 000000000..c564f1df5 --- /dev/null +++ b/surfsense_local/frontend/src/features/models/remote/connections/claude/api.ts @@ -0,0 +1,67 @@ +import { askEgress } from "@/features/egress/ask-egress" +import { allowAll } from "@/features/egress/api" +import { requestJson } from "@/lib/api" + +import type { ModelType } from "../../../model-type" +import type { Connection } from "../api" + +/** What SurfSense found of Claude Code on this computer, read live. */ +export type ClaudeState = "not_installed" | "too_old" | "signed_out" | "ready" + +type RefusedHost = { destination: string; host: string } + +export type ClaudeOption = { + state: ClaudeState + version: string | null + email: string | null + /** As Claude Code reports it: `pro`, `max`, `team`. */ + plan: string | null + serves: ModelType[] + /** The hosts a Claude plan reaches that are still off, model calls first. */ + hosts: RefusedHost[] +} + +/** Anthropic's own install guide, the one outside page the dialog opens. */ +export const CLAUDE_INSTALL_URL = "https://code.claude.com/docs/en/setup" + +export const claudeOptionQueryKey = ["claude-plan-option"] as const + +export function getClaudeOption(signal?: AbortSignal): Promise { + return requestJson("/llm/connections/claude", { signal }) +} + +/** Nothing was made: the user kept a host the plan needs switched off. */ +export class ClaudeHostDeclinedError extends Error { + readonly host: string + + constructor(host: string) { + super(host) + this.name = "ClaudeHostDeclinedError" + this.host = host + } +} + +/** + * The Claude plan connection, once every host it reaches is allowed: asked + * up front, in one question, so a refusal never stops it half made. + */ +export async function connectClaude(label: string): Promise { + const { hosts } = await getClaudeOption() + const [first, ...rest] = hosts + if (first) { + const also = rest.map(({ host }) => host) + const allowed = await askEgress({ + destination: first.destination, + host: first.host, + purpose: "claude_plan", + also, + allow: () => allowAll(first.destination, also), + }) + if (!allowed) throw new ClaudeHostDeclinedError(first.host) + } + return requestJson("/llm/connections/claude", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label }), + }) +} diff --git a/surfsense_local/frontend/src/features/models/remote/connections/claude/claude-dialog.tsx b/surfsense_local/frontend/src/features/models/remote/connections/claude/claude-dialog.tsx new file mode 100644 index 000000000..f3ac52d4e --- /dev/null +++ b/surfsense_local/frontend/src/features/models/remote/connections/claude/claude-dialog.tsx @@ -0,0 +1,251 @@ +import { useState } from "react" +import { useQuery } from "@tanstack/react-query" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { ExternalLinkIcon } from "@/components/ui/icons" +import { Spinner } from "@/components/ui/spinner" +import { intl } from "@/i18n/intl" + +import type { Connection } from "../api" +import { + CLAUDE_INSTALL_URL, + ClaudeHostDeclinedError, + claudeOptionQueryKey, + connectClaude, + getClaudeOption, + type ClaudeOption, +} from "./api" + +function openInstallGuide() { + if (window.surfsense?.openExternal) + void window.surfsense.openExternal(CLAUDE_INSTALL_URL) + else window.open(CLAUDE_INSTALL_URL, "_blank", "noopener") +} + +/** What to do next, for each state Claude Code can be in. */ +function StateBody({ option }: { option: ClaudeOption }) { + switch (option.state) { + case "not_installed": + return ( +
    +
  1. + {intl.formatMessage({ + id: "models_claude_dialog_install_body", + defaultMessage: "Install Claude Code on this computer.", + })} +
  2. +
  3. + {intl.formatMessage({ + id: "models_claude_dialog_sign_in_body", + defaultMessage: + "Open a terminal, run claude, and sign in with your Claude account.", + })} +
  4. +
+ ) + case "signed_out": + return ( +

+ {intl.formatMessage({ + id: "models_claude_dialog_signed_out_body", + defaultMessage: + "Claude Code is installed but not signed in to a Claude plan. Open a terminal, run claude, and sign in.", + })} +

+ ) + case "too_old": + return ( +

+ {option.version + ? intl.formatMessage( + { + id: "models_claude_dialog_too_old_body", + defaultMessage: + "Claude Code {version} is too old for SurfSense. Update it, then check again.", + }, + { version: option.version } + ) + : intl.formatMessage({ + id: "models_claude_dialog_unreadable_version_body", + defaultMessage: + "SurfSense couldn’t read which Claude Code is installed. Update it, then check again.", + })} +

+ ) + case "ready": + return ( +

+ {intl.formatMessage( + { + id: "models_claude_dialog_ready_body", + defaultMessage: + "Connected as {email}, {plan, select, pro {Pro plan} max {Max plan} team {Team plan} other {Claude plan}}. Claude models then work in chat, titles and Studio.", + }, + { email: option.email ?? "", plan: option.plan ?? "other" } + )} +

+ ) + } +} + +/** + * The Claude plan, set up from the user's own Claude Code. SurfSense cannot + * sign anyone in to Claude, so this says what Claude Code needs and checks it + * again on request; nothing polls, since nothing changes until the user acts + * in their terminal. + */ +export function ClaudeDialog({ + open, + onOpenChange, + onConnected, +}: { + open: boolean + onOpenChange: (open: boolean) => void + onConnected: (connection: Connection) => void +}) { + const option = useQuery({ + queryKey: claudeOptionQueryKey, + queryFn: ({ signal }) => getClaudeOption(signal), + enabled: open, + // Read again on each opening: the user may have just signed in. + refetchOnMount: "always", + retry: false, + }) + const [connecting, setConnecting] = useState(false) + const [failure, setFailure] = useState(null) + + const connect = async () => { + setConnecting(true) + setFailure(null) + try { + const connection = await connectClaude( + intl.formatMessage({ + id: "models_claude_connection_label", + defaultMessage: "Claude plan", + }) + ) + onConnected(connection) + onOpenChange(false) + } catch (error) { + setFailure( + error instanceof ClaudeHostDeclinedError + ? intl.formatMessage( + { + id: "models_claude_dialog_host_declined_error", + defaultMessage: + "Your Claude plan needs {host}. Allow it to connect.", + }, + { host: error.host } + ) + : error instanceof Error + ? error.message + : null + ) + } finally { + setConnecting(false) + } + } + + const ready = option.data?.state === "ready" + return ( + !open && setFailure(null)} + > + + + + {intl.formatMessage({ + id: "models_claude_dialog_title", + defaultMessage: "Use your Claude plan", + })} + + + {intl.formatMessage({ + id: "models_claude_dialog_body", + defaultMessage: + "SurfSense uses your Claude Pro, Max or Team plan through Claude Code on this computer. Your sign-in stays in Claude Code, and SurfSense never sees it. Use counts toward your plan’s limits, as it does in Claude Code.", + })} + + + {option.isPending ? ( + + ) : option.data ? ( + + ) : ( +

+ {intl.formatMessage({ + id: "models_claude_dialog_check_error", + defaultMessage: "Couldn’t check Claude Code on this computer.", + })} +

+ )} + {failure ? ( +

+ {failure} +

+ ) : null} + + + {option.data?.state === "not_installed" ? ( + + ) : null} + {ready ? ( + + ) : ( + + )} + +
+
+ ) +} diff --git a/surfsense_local/frontend/src/features/models/remote/connections/claude/claude-option.test.tsx b/surfsense_local/frontend/src/features/models/remote/connections/claude/claude-option.test.tsx new file mode 100644 index 000000000..c0598aa09 --- /dev/null +++ b/surfsense_local/frontend/src/features/models/remote/connections/claude/claude-option.test.tsx @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { cleanup, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" + +import { render } from "@/test-utils" + +import type { Connection } from "../api" +import { CLAUDE_INSTALL_URL, type ClaudeOption as Option } from "./api" +import { ClaudeOption } from "./claude-option" + +const READY: Option = { + state: "ready", + version: "2.1.301", + email: "ada@example.com", + plan: "max", + serves: ["text_gen"], + hosts: [], +} + +const CONNECTED: Connection = { + id: 7, + label: "Claude plan", + provider: "openai_compatible", + base_url: "https://api.anthropic.com", + catalog_provider: "anthropic", + has_api_key: false, + auth_kind: "claude_plan", + signed_in: false, + account_email: null, + serves: ["text_gen"], + created_at: "2026-10-16T00:00:00", + updated_at: "2026-10-16T00:00:00", +} + +/** The API as the dialog sees it: each status read takes the next of `states`. */ +function serve(states: Partial