diff --git a/.agents/skills/translate/glossary.md b/.agents/skills/translate/glossary.md index 684dc039eb..11e5302528 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/adr/0057-a-claude-plan-runs-through-the-users-own-claude-code.md b/docs/adr/0057-a-claude-plan-runs-through-the-users-own-claude-code.md index d36274a97f..ced10a1e28 100644 --- a/docs/adr/0057-a-claude-plan-runs-through-the-users-own-claude-code.md +++ b/docs/adr/0057-a-claude-plan-runs-through-the-users-own-claude-code.md @@ -1,6 +1,6 @@ # ADR 0057: A Claude plan is reached through the user's own Claude Code, and SurfSense holds no Claude credential -- **Status:** Accepted +- **Status:** Accepted; its network decision superseded by [ADR 0061](0061-claude-code-reaches-anthropic-and-in-the-agent-the-turns-plugins.md) - **Date:** 2026-10-16 - **Supersedes:** [ADR 0038](0038-chatgpt-plans-sign-in-through-openai-not-codex.md) in part: its consequence that a Claude subscription is not offered. [ADR 0050](0050-a-model-answers-on-its-own-route-with-its-connections-credential.md) in part: that SurfSense owns a plan's sign-in for both engines, which now holds for the ChatGPT plan only - **Source:** [Use your Claude plan proposal, decisions and Anthropic's terms L59–84](https://github.com/MODSetter/SurfSense/blob/e1332966dd2ec2cfcf636331da6f770d836fcff1/docs/proposals/claude-plan/README.md#L59-L84) @@ -33,4 +33,4 @@ A Claude plan has no HTTP endpoint SurfSense may call, so opencode cannot reach ## Where the code stands -Not built. The work is laid out in the [Use your Claude plan](../proposals/claude-plan/README.md) proposal. +Built: chat, titles, Studio and Agentic mode ([Claude plan](../architecture/claude-plan.md)). It is untested on Windows and Linux on a real install. The contract tests against the real binary that the consequences name run only on a developer's machine, opted in; a Claude Code newer than the one they last passed on is named in the log, and the version SurfSense calls tested is raised only once they pass again ([Tests](../architecture/claude-plan.md#tests), [Known gaps](../architecture/claude-plan.md#known-gaps)). diff --git a/docs/adr/0060-one-consent-question-per-user-action.md b/docs/adr/0060-one-consent-question-per-user-action.md new file mode 100644 index 0000000000..b95f53d605 --- /dev/null +++ b/docs/adr/0060-one-consent-question-per-user-action.md @@ -0,0 +1,25 @@ +# ADR 0060: One consent question asks for every host a user action needs, including hosts its background work will reach + +- **Status:** Accepted +- **Date:** 2026-10-16 + +## Context + +Egress consent is per host ([ADR 0027](0027-egress-consent-per-host.md)) and asked just in time, on a user action, never on a background call ([ADR 0017](0017-egress-off-by-default.md)). Some actions need several hosts: a ChatGPT sign-in reaches OpenAI's sign-in host and its API; a plugin can sign in on a host of its own; a Claude plan's Claude Code calls `api.anthropic.com` and refreshes its sign-in on `platform.claude.com`. The prompt asked about one host at a time, so a user could allow one and cancel the next, leaving a connection that cannot work. + +A host one action needs is not always reached during it. Claude Code refreshes its sign-in in the background, whenever its token is near expiry, at the start of any run; the first run is the model list right after connecting. No dialog can be raised then, so a host left to be asked later is a refusal the user never sees: their Claude plan stops answering once its token expires. + +## Decision + +- One question per user action, naming every host that action may reach, including a host that work started by it reaches in the background, since that host can never be asked about later. +- A host reached only by a later, separate user action is asked about then, in its own question. +- One Allow switches every host the question named, in one request committed whole (`PUT /egress`): all are allowed or none is. +- Consent stays per host: each is its own row in Settings › Network, switched off alone, and checked on every call. +- The question says what each host receives when they differ: a Claude plan's API host gets what is sent to the model, its sign-in host only the sign-in's refresh. +- Only hosts SurfSense names are ever asked about. A host a program SurfSense runs tries on its own, such as a new one in a future Claude Code, is refused until a release adds it, reviewed and described, to that program's list. + +## Consequences + +- Connecting a Claude plan, signing in with ChatGPT, or connecting a plugin with its own sign-in host is one question. +- A user cannot allow part of what one action needs from the question itself; they can turn any host off afterwards in Settings › Network. +- A feature whose background work reaches a host its own action did not name fails closed: the host is refused, and the refusal is logged. diff --git a/docs/adr/0061-claude-code-reaches-anthropic-and-in-the-agent-the-turns-plugins.md b/docs/adr/0061-claude-code-reaches-anthropic-and-in-the-agent-the-turns-plugins.md new file mode 100644 index 0000000000..ff862fa330 --- /dev/null +++ b/docs/adr/0061-claude-code-reaches-anthropic-and-in-the-agent-the-turns-plugins.md @@ -0,0 +1,26 @@ +# ADR 0061: Claude Code reaches Anthropic's two hosts, and in the agent also the hosts of the turn's ready plugins + +- **Status:** Accepted +- **Date:** 2026-10-16 + +## Context + +[ADR 0057](0057-a-claude-plan-runs-through-the-users-own-claude-code.md) has Claude Code's traffic tunnel "only `api.anthropic.com`". Claude Code also refreshes its sign-in on `platform.claude.com`, so chat runs already allow both, each while the user allows it ([ADR 0060](0060-one-consent-question-per-user-action.md)). + +The agent gives Claude Code the thread's plugins. opencode connects to each plugin server itself, with its URL and sign-in header, through the plugin proxy, which passes only a ready plugin's hosts ([agent](../architecture/agent.md#network)). Measured on Claude Code 2.1.301: its MCP traffic to a remote plugin goes through `HTTPS_PROXY`, so a proxy limited to Anthropic refuses the plugin. + +The alternative was for SurfSense's tool endpoint to list and forward plugin tools, so Claude Code would reach only Anthropic. SurfSense has no code that forwards an MCP call; it would be a second MCP client beside opencode's, with its own sessions, timeouts and cancellation, to keep plugin tokens out of a process that opencode already gives them to. + +## Decision + +- A chat run's Claude Code reaches `api.anthropic.com` and `platform.claude.com`, each while its destination is on, and nothing else. A chat run is given no MCP server. +- An agent run's Claude Code reaches the same two hosts and the hosts of the ready plugins, by the check the plugin proxy applies to opencode. It connects to each plugin server itself, as opencode does. +- The two run kinds use separate proxies, so a chat run can never reach a plugin host. +- The MCP servers, with their sign-in headers, are passed to Claude Code in a file only the user can read, never on its command line. + +## Consequences + +- Claude Code reaches no host the user has not allowed for the same purpose in opencode. +- Claude Code holds the sign-in headers of the plugins it is given for the life of its process, as opencode does. +- A change to how the plugin proxy decides readiness applies to both engines. +- This supersedes ADR 0057's "tunnels only `api.anthropic.com`". diff --git a/docs/adr/README.md b/docs/adr/README.md index 6590436506..5cc089bfd4 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -84,6 +84,8 @@ The Date is the day the decision was first written down. Source links are permal | 0054 | [SurfSense's own plugins, free and paid, run on one plugin host, with the registry served beside it](0054-surfsenses-plugins-run-on-one-plugin-host.md) | Accepted | | 0055 | [Verified is a maintainer's judgement, and CI contacts each server a pull request adds](0055-verified-is-a-maintainers-judgement-and-ci-contacts-each-server.md) | Accepted | | 0056 | [Speech recognition runs on a second audio.cpp server that only the API calls](0056-speech-recognition-runs-on-a-second-audiocpp-server.md) | Accepted | -| 0057 | [A Claude plan is reached through the user's own Claude Code, and SurfSense holds no Claude credential](0057-a-claude-plan-runs-through-the-users-own-claude-code.md) | Accepted | +| 0057 | [A Claude plan is reached through the user's own Claude Code, and SurfSense holds no Claude credential](0057-a-claude-plan-runs-through-the-users-own-claude-code.md) | Accepted, its network decision superseded by 0061 | | 0058 | [The bundled embedder runs at FP32 on x86-64, upcast in memory, and its vector space is still the pinned FP16 file](0058-the-bundled-embedder-runs-at-fp32-on-x86-64.md) | Accepted | | 0059 | [A born-digital PDF is read from its own text layer, and Docling reads only a PDF with a page that needs it](0059-a-born-digital-pdf-is-read-from-its-text-layer.md) | Accepted | +| 0060 | [One consent question asks for every host a user action needs, including hosts its background work will reach](0060-one-consent-question-per-user-action.md) | Accepted | +| 0061 | [Claude Code reaches Anthropic's two hosts, and in the agent also the hosts of the turn's ready plugins](0061-claude-code-reaches-anthropic-and-in-the-agent-the-turns-plugins.md) | Accepted | diff --git a/docs/architecture/agent.md b/docs/architecture/agent.md index de475f08a7..1ee0009d87 100644 --- a/docs/architecture/agent.md +++ b/docs/architecture/agent.md @@ -22,16 +22,21 @@ A chat thread can be the agent's. opencode, started by Electron, then answers it | Plugins | `plugin_tools/`, `plugin_proxy/` | the connected plugins each turn gives opencode, a rule for each of their tools, and the proxy opencode reaches their hosts through ([plugins](#plugins)) | | Client | `opencode_client/` | the only code that knows opencode 1.x's HTTP API | | Threads | `agent_threads/`, and the chat routes that hand over to it | opening, sending to, reading and deleting an agent thread ([chat](chat.md#agent-threads)) | +| Engines | `engines/` | the one interface the chat, permission, step and workspace routes call for an agent thread: open it, run a turn, list it, answer a permission, read a step whole, forget it or its workspace. Two engines: opencode, and for a thread on a Claude plan model the user's Claude Code (`engines/claude_code/`, [Claude plan agent](claude-plan.md#agentic-mode)). `thread_engine()` picks by the thread's `agent_engine`, and a new thread's by the selected model's connection | | Ticked sources | `agent_threads/turn_sources.py`, `agent_threads/scope_note.py`, `tool_endpoint/turn_scope.py` | the sources a thread may use, mirrored into its folder, told to the model and held to by the tools | | Screen | `frontend/src/features/agent/` | a reply's steps, the versions it made, the sources a turn works from, and the approvals asked on them | ## Which threads get it -A new thread is the agent's when it is opened in Agentic mode: the mode the user picked in the composer, or with none picked the selected model's default, Agentic for a model measured at or near the bar on a remote host and Basic (Q&A) otherwise ([model capabilities](model-capabilities.md#the-modes)). No measured score keeps a model out of Agentic. Only technical gates do: no opencode staged; a stated no on tool calls, which is the remote catalog's `tool_call: false` under the connection's catalog provider or a local model's template without `supports_tool_calls` in llama-server; or a stated window under 32,768 tokens. A catalog that says nothing of tool calls, and a runtime that cannot be read, let a model in, with the untested warning. A local model's template and window are read only when a thread asks for Agentic, since reading them loads the model. `SURFSENSE_LOCAL_AGENT_UNTESTED_MODELS=1`, set in the environment Electron starts the API from, makes Agentic the default for every model the user has not picked a mode for and lifts the window floor; a stated no on tool calls, or no opencode, still keeps Agentic out ([`engine_choice.py`](../../surfsense_local/backend/modules/agent/engine_choice.py)). Agentic asked for and gated is refused with `409` and its code; asked for while opencode does not become ready, `503` `agent_unavailable`; in both, no thread is made. A thread that asked for nothing opens as a chat then. A thread keeps its engine; its row stores the opencode session that holds its turns ([data model](data-model.md)). Once the selected model changes to one a technical gate keeps out, a turn sent to the thread is refused with `409` before any prompt is sent: the user chooses another model or starts a new chat. The screen says the thread can't continue with that model, in the user's language, with "Start a new chat" in place of Retry, as for a legacy thread ([`unsupported-model.ts`](../../surfsense_local/frontend/src/features/agent/unsupported-model.ts)). +A new thread is the agent's when it is opened in Agentic mode: the mode the user picked in the composer, or with none picked the selected model's default, Agentic for a model measured at or near the bar on a remote host and Basic (Q&A) otherwise ([model capabilities](model-capabilities.md#the-modes)). No measured score keeps a model out of Agentic. Only technical gates do: no opencode staged, unless the model is a Claude plan's, whose agent is the user's Claude Code; a stated no on tool calls, which is the remote catalog's `tool_call: false` under the connection's catalog provider or a local model's template without `supports_tool_calls` in llama-server; or a stated window under 32,768 tokens. A catalog that says nothing of tool calls, and a runtime that cannot be read, let a model in, with the untested warning. A local model's template and window are read only when a thread asks for Agentic, since reading them loads the model. `SURFSENSE_LOCAL_AGENT_UNTESTED_MODELS=1`, set in the environment Electron starts the API from, makes Agentic the default for every model the user has not picked a mode for and lifts the window floor; a stated no on tool calls, or no opencode, still keeps Agentic out ([`engine_choice.py`](../../surfsense_local/backend/modules/agent/engine_choice.py)). Agentic asked for and gated is refused with `409` and its code; asked for while opencode does not become ready, `503` `agent_unavailable`; in both, no thread is made. A thread that asked for nothing opens as a chat then. A thread runs on the engine its `agent_engine` names, which moves to the other when the selected model belongs to it ([Two engines](#two-engines)); its row stores each engine's session ([data model](data-model.md)). Once the selected model changes to one a technical gate keeps out, a turn sent to the thread is refused with `409` before any prompt is sent: the user chooses another model or starts a new chat. The screen says the thread can't continue with that model, in the user's language, with "Start a new chat" in place of Retry, as for a legacy thread ([`unsupported-model.ts`](../../surfsense_local/frontend/src/features/agent/unsupported-model.ts)). + +## Two engines + +A thread runs on the engine its turns live in, `agent_engine` on the thread: opencode for every model but a Claude plan's, which runs on the user's own Claude Code ([Claude plan agent](claude-plan.md#agentic-mode)). Before each turn the selected model's connection names the engine. When it is the other one, that engine resumes its own session, or opens one in the thread's folder, and the turn carries a note of the turns it did not see, beside the user's words as the scope note is ([handover](claude-plan.md#switching-engines)). Reading the thread merges both sessions' turns by time, so it reads as one thread. ## From a thread to a running opencode -1. **Electron, at boot.** Where an opencode is staged, Electron picks its port, a random password and a random Word snapshot key, passes them to the API alone, and removes any `agent/opencode.json` the last run left. opencode does not start yet. Where none is staged, Electron passes nothing, and the API mounts none of the routes opencode calls or that answer it: the model endpoint, the tool endpoint, the permission answer and the Word snapshot routes. +1. **Electron, at boot.** Where an opencode is staged, Electron picks its port, a random password and a random Word snapshot key, passes them to the API alone, and removes any `agent/opencode.json` the last run left. opencode does not start yet. Where none is staged, Electron passes nothing, and the API mounts none of the routes only opencode calls or that answer it: the model endpoint and the Word snapshot routes. The tool endpoint, the permission answer and the step routes serve a Claude Code thread too, so they are always mounted, behind the launch key each API process mints. 2. **The API, when a thread first needs the agent.** `ready_opencode()` reads the selected model's window: what llama-server loaded a local model with, what a ChatGPT plan states for its model, or the `context` the remote catalog records, else 32,768. It reads the route the model answers on. It reads whether the model reads images ([Image input](#image-input)). It writes `/agent/opencode.json`, readable by this user only, and only when its contents change. When Electron gave up on opencode after the file's last write, a turn or a new session touches the file instead, since an unchanged file would never earn opencode another start. Reading a thread back does not, so the read that follows a failed turn cannot start it a second time. 3. **Electron, on that file.** Checked every 2 seconds: opencode starts once the file exists and stops when it goes. A rewrite does not restart it. A crash restarts it after 10 seconds, then 20 ([`opencode-restarts.ts`](../../surfsense_local/electron/src/main/sidecars/opencode-restarts.ts)), a run of 30 seconds or more counting as no failure. After three quick exits in a row Electron stops, writes the reason to `opencode-failed.json` in the agent folder and raises the "The agent stopped" toast once; the next write of the configuration earns one more start. A child that cannot be spawned at all counts as an exit. 4. **The API, until opencode serves it.** opencode reads its configuration per folder, the first time the folder is used, and keeps what it read. So the API checks the running server's version is `1.18.34`, reads `GET /config`, and calls `POST /global/dispose` once if what is loaded is not the provider package, launch key, window and image input just written. It returns when they match, or after 60 seconds with "not ready", or at once with Electron's reason when `opencode-failed.json` is newer than the configuration it just wrote ([`opencode_failure.py`](../../surfsense_local/backend/modules/agent/opencode_failure.py)). Every call has a timeout, because opencode accepts a connection a moment before it answers it. @@ -239,6 +244,8 @@ A connected plugin is an MCP server opencode calls itself, with SurfSense decidi 4. **The servers.** The plugins the gateway calls ready, with their tools up to 24, the plugins connected first winning, are given to the thread's opencode under their ids, all at once while the check runs, each within 25 seconds, so a server that hangs is named unreachable alone and the turn waits for the slowest rather than the sum. A plugin the check made ready or not ready is given or taken back once it ends. Each goes with its sign-in as an `Authorization` header, a pasted key or an OAuth access token from its encrypted credential, or the license key for SurfSense's own ([plugins](plugins.md#surfsenses-own-plugins)), and opencode's own OAuth off, with the 200-second call timeout SurfSense's own tools get, or 300 seconds on SurfSense's plugin host, where a scrape can run minutes ([`give_plugins.py`](../../surfsense_local/backend/modules/agent/plugin_tools/give_plugins.py)). A plugin on SurfSense's plugin host also gets `X-SurfSense-Calls`, a token for the thread derived from the install's secret, so Stop can end its calls there: the host keeps no MCP session, so opencode's own cancel cannot reach them, and Stop calls the host's `POST /cancel` with the token and the license ([`stop_plugin_calls.py`](../../surfsense_local/backend/modules/agent/plugin_tools/stop_plugin_calls.py), [plugin host](plugin-host.md#stop)). It also gets `x-surfsense-text-only: 1`, so the host puts a call's items in the text, the only part opencode reads. When opencode gives up on such a call, its step ending in `MCP error -32001`, the app calls the host's `POST /cancel` as Stop does, once no other call of the turn runs there, since the host ends all of the thread's calls at once. No other server is sent the token or the header ([`turn_plugins.py`](../../surfsense_local/backend/modules/agent/plugin_tools/turn_plugins.py)). A server opencode already holds connected, with the same URL and headers, is not given again, since that opens a new MCP session and a stateful plugin loses its state; a refreshed token changes the header, so its plugin is given again. Only a digest of what was given is kept ([`given_servers.py`](../../surfsense_local/backend/modules/agent/plugin_tools/given_servers.py)). A held server no longer ready, disconnected, switched off, removed from the registry, signed out, or a third-party plugin once third-party plugins are refused, is disconnected, and with it the key opencode held. 5. **The rules.** Once the check ends, so a tool it switched off is denied before the model is asked, the session's permission rules are replaced (`PATCH /session/{id}`), even when a plugin could not be given: `_*: deny` for every plugin server opencode holds, then `allow` or `ask` for each ready tool from the gateway's approval rules. When they cannot be set while opencode holds any plugin server, the turn fails rather than run under the last turn's rules. A denied tool is left out of what the model is offered, and an `ask` reaches the thread as a `permission-request` naming the tool, `notes_count` ([approvals](#approvals)). opencode names a tool `_`, turning anything but letters, digits, `_` and `-` into `_`; a custom plugin is never given the id `surfsense`, SurfSense's own server. +**When a plugin counts as unavailable, on either engine.** Only from that engine's settled answer for the turn, within the same 25 seconds ([`connect_limit.py`](../../surfsense_local/backend/modules/agent/plugin_tools/connect_limit.py)): opencode's, once the add call for the plugin returns; Claude Code's, from the turn's `init` message, which it sends once each plugin has connected or failed within its `MCP_TIMEOUT`, set to the same 25 seconds. Asked any earlier, Claude Code reports every plugin `pending`. Either way the reasons are the same three, unreachable, a sign-in, or the license ([`unavailable_plugin.py`](../../surfsense_local/backend/modules/agent/plugin_tools/unavailable_plugin.py)). + **`@` mentions.** Typing `@` in an Agentic composer lists the ready plugins, each named with its host, through assistant-ui's composer trigger popover ([`plugin-mentions.tsx`](../../surfsense_local/frontend/src/features/plugins/in-thread/plugin-mentions.tsx)); with none ready it says why: none connected, third-party plugins refused, or all switched off, which it says to turn on from **+ › Plugins** ([plugins](plugins.md#settings--plugins)). Picking one inserts `@notes`, the plugin's id: the composer is a textarea, so the library's `:plugin[label]{name=id}` would show as written. A plugin is named rather than a tool, since the user thinks in apps and the agent picks a plugin's tools better from their descriptions. Before the turn, each `@` in the message that is a ready plugin's is the user choosing it ([`mentions.py`](../../surfsense_local/backend/modules/agent/plugin_tools/mentions.py)): the turn gives every ready tool of it, even past the 24, and a second synthetic note beside the scope note says "The user asked you to use the Notes plugin for this." The rest of the turn's tools stay offered, a tool switched off stays hidden, a plugin that is not ready changes nothing, and approval is unchanged. Reading a thread back drops every synthetic part from the user's words, so neither note shows. A ready plugin opencode then reports as `failed`, or as needing sign-in, and an OAuth plugin whose refresh was refused, is named in `agent-plugins-unavailable`, `{plugins: [{title, reason}]}`, `reason` `unreachable`, `sign_in`, or `license` for a paid plugin, whose refusal is its license rather than a sign-in, and the turn goes on without it. The reply shows "Couldn’t reach Notes", "Sign in to Notes again" or "Amazon needs a SurfSense license" above its steps ([`plugins-unavailable.tsx`](../../surfsense_local/frontend/src/features/agent/plugins-unavailable.tsx)); the notice is live only, and a reopened thread does not show it. A server that fails to connect, or takes over 25 seconds, is named `unreachable`, and the turn goes on with the others. diff --git a/docs/architecture/chat.md b/docs/architecture/chat.md index 15f6b6b7a3..629a2a7f81 100644 --- a/docs/architecture/chat.md +++ b/docs/architecture/chat.md @@ -98,6 +98,7 @@ The list handed to the generator is `[system, *history within budget, user]`. | `prompt-progress` | per batch of prompt the local runtime reads, before the first token; no other endpoint sends it | `processed` and `total`, in tokens it had left to read | | `reasoning` | per chunk of a thinking model's trace, before its answer | `text` | | `reasoning-end` | once, when the answer starts or the stream ends, if the model reasoned | `duration_ms`, from the trace's first chunk | +| `plan-extra-usage` | once per reply a plan reports its limits for, and again if they change; only a Claude plan does ([Claude plan](claude-plan.md#launching-a-run)). The composer's notice follows the last one, across threads | `using`, whether extra usage pays past the plan's limit; `resets_at`, Unix seconds or null | | `delta` | per token chunk of the answer | `text` | | `error` | on a generator failure | `kind`, `message`, `provider` | | `citations` | once, if the reply cited anything | the cited passages, in first-cited order | @@ -105,7 +106,7 @@ The list handed to the generator is `[system, *history within budget, user]`. - Every frame is `id: \ndata: {json}\n\n`, numbered from 1 within its run, and the stream ends with `data: [DONE]\n\n` so a client can tell completion from a dropped connection. The response sends `Cache-Control: no-cache` and `X-Accel-Buffering: no` so nothing buffers it into one late blob. - The trace comes from the provider's `chat_deltas()`, which marks each chunk as answer or reasoning ([`connections.md`](connections.md#runtime)). It never reaches `resolve_citations()`, so a `[n]` inside it is neither rewritten nor stored as a citation. -- Errors are sorted by exception type, HTTP status and provider, and for a 400 by the body's `error.type` ([`errors.py`](../../surfsense_local/backend/modules/chat/errors.py)), into `provider_auth`, `provider_not_found`, `provider_rate_limited`, `provider_unavailable`, `model_cannot_run`, `context_too_long`, `subscription_sign_in`, `subscription_limit`, `runtime_busy`, `network`, `timeout` and `unknown`, each with a plain-language English message. `runtime_busy` is the local runtime's shared cache running out of room under replies running together, which llama.cpp reports as a `500` like a model that failed to load; admission makes it rare, and it offers Retry ([admission](local-models/admission.md)). The two `subscription_` kinds come from a ChatGPT connection, in a chat or an agent thread: its account has to sign in again, or its plan's usage limit is reached, which no retry fixes ([`chatgpt-subscription.md`](chatgpt-subscription.md)). A subscription's own `429` or 5xx is retried twice first, after the wait it asks for and at most a minute. The frontend shows its own translated text per kind ([`chat-error-text.ts`](../../surfsense_local/frontend/src/features/chat/chat-error-text.ts)); a pre-stream `unknown` failure keeps an informative error message inside a translated sentence, and an unrecognized future kind falls back to the backend's message. +- Errors are sorted by exception type, HTTP status and provider, and for a 400 by the body's `error.type` ([`errors.py`](../../surfsense_local/backend/modules/chat/errors.py)), into `provider_auth`, `provider_not_found`, `provider_rate_limited`, `provider_unavailable`, `model_cannot_run`, `context_too_long`, `subscription_sign_in`, `subscription_limit`, `claude_sign_in`, `claude_limit`, `egress_disabled`, `runtime_busy`, `network`, `timeout` and `unknown`, each with a plain-language English message. `runtime_busy` is the local runtime's shared cache running out of room under replies running together, which llama.cpp reports as a `500` like a model that failed to load; admission makes it rare, and it offers Retry ([admission](local-models/admission.md)). The two `subscription_` kinds come from a ChatGPT connection, in a chat or an agent thread: its account has to sign in again, or its plan's usage limit is reached, which no retry fixes ([`chatgpt-subscription.md`](chatgpt-subscription.md)). The two `claude_` kinds are a Claude plan's, sorted before them since they are the same errors' subclasses: Claude Code on this computer has to be installed or signed in again, in a terminal rather than in Model setup, or the plan's usage limit is reached. `egress_disabled` is a host turned off in Settings › Network while a reply runs, as a Claude Code run sees it; refused before a reply, the same refusal is a `403` the egress prompt answers ([egress](egress.md#asking-for-consent)). A subscription's own `429` or 5xx is retried twice first, after the wait it asks for and at most a minute. The frontend shows its own translated text per kind ([`chat-error-text.ts`](../../surfsense_local/frontend/src/features/chat/chat-error-text.ts)); a pre-stream `unknown` failure keeps an informative error message inside a translated sentence, and an unrecognized future kind falls back to the backend's message. ## Runs @@ -265,7 +266,7 @@ Interface strings are `dictation__`, and a server code is `dic ## Agent threads -How the agent runs is in [agent](agent.md). A thread opened with `mode: "agentic"` is the agent's, and one opened with `mode: "basic"` the chat's; with no `mode` it takes the selected model's default ([model capabilities](model-capabilities.md#the-modes), [agent](agent.md#which-threads-get-it)). Agentic asked for and kept out by a technical gate is refused with `409`, `detail.code` `no_model`, `agent_not_installed`, `tool_calls_unsupported` or `window_below_floor`, and no thread is made. Opening an agent thread starts opencode if it is not running, waits until it serves SurfSense's configuration, and opens an opencode session under the thread's title; the session's id is stored on the thread, and `ThreadRead` reports `uses_agent`. When opencode does not become ready, a thread that asked for Agentic is removed and the request answers `503`, `detail.code` `agent_unavailable`; one that asked for nothing opens as a chat. A mode sent with `remember: true`, which the composer sends only for a mode the user picked, is remembered as the model's default. A thread keeps the engine it was opened with. +How the agent runs is in [agent](agent.md). A thread opened with `mode: "agentic"` is the agent's, and one opened with `mode: "basic"` the chat's; with no `mode` it takes the selected model's default ([model capabilities](model-capabilities.md#the-modes), [agent](agent.md#which-threads-get-it)). Agentic asked for and kept out by a technical gate is refused with `409`, `detail.code` `no_model`, `agent_not_installed`, `tool_calls_unsupported` or `window_below_floor`, and no thread is made. Opening an agent thread starts opencode if it is not running, waits until it serves SurfSense's configuration, and opens an opencode session under the thread's title; the session's id and the engine, `agent_engine` `opencode`, are stored on the thread, and `ThreadRead` reports `uses_agent`, true when the thread has an engine. When opencode does not become ready, a thread that asked for Agentic is removed and the request answers `503`, `detail.code` `agent_unavailable`; one that asked for nothing opens as a chat. A mode sent with `remember: true`, which the composer sends only for a mode the user picked, is remembered as the model's default. Which engine runs a thread's turns, and how it moves when the selected model changes, is in [agent](agent.md#two-engines). The same routes then reach the agent ([`modules/agent/agent_threads/`](../../surfsense_local/backend/modules/agent/agent_threads/)): diff --git a/docs/architecture/chatgpt-subscription.md b/docs/architecture/chatgpt-subscription.md index 4bbad03645..a1032961df 100644 --- a/docs/architecture/chatgpt-subscription.md +++ b/docs/architecture/chatgpt-subscription.md @@ -22,7 +22,7 @@ Every URL is in one file, [`endpoints.py`](../../surfsense_local/backend/modules ## Signing in -1. The renderer reads `GET /llm/connections/chatgpt`: `serves`, the slots a ChatGPT connection fills, and `hosts`, the hosts the sign-in reaches that egress has not allowed. It asks about each host in turn. A refused request asks about one host only and cannot tell a declined host from the next one, so the question is put up front ([`egress.md`](egress.md)). +1. The renderer reads `GET /llm/connections/chatgpt`: `serves`, the slots a ChatGPT connection fills, and `hosts`, the hosts the sign-in reaches that egress has not allowed. It asks about every one of them in one question, which Allow switches on together, each still its own host in Settings › Network. The question is put up front, so a sign-in never starts with a host it needs still off ([`egress.md`](egress.md#asking-for-consent)). 2. `POST /llm/connections/chatgpt/sign-in` with `{label}` for a new connection or `{connection_id}` to sign one in again. It checks the hosts again, a free label (`409`) or a ChatGPT connection (`404`), then opens a loopback listener on `127.0.0.1` at a random port and answers `201 {flow_id, authorize_url}`. OpenAI allows any port as long as scheme, host and path (`/callback`) match. 3. The authorize URL asks for `client_id=dynamic_agent_client`, so OpenAI issues a client for this user on first sign-in, or, for a connection that still holds a sign-in, its issued client with `id_token_hint`, OpenAI's returning-user path. It goes with `agent_name_hint=SurfSense`, an `ext_agent_host_id`, scopes `openid profile email offline_access resource.invoke chatgpt.tokens.use.direct`, `resource=https://api.openai.com/v1`, `state`, `nonce` and an S256 PKCE challenge. The host id is `urn:uuid:` over an HMAC of the install secret, so it is stable for the install and stored nowhere ([`host_id.py`](../../surfsense_local/backend/modules/llm/subscriptions/chatgpt/host_id.py)). 4. The renderer opens it through Electron, which allows `auth.openai.com/api/accounts/authorize` only with a `redirect_uri` of `http://127.0.0.1:/callback`, so a crafted link cannot send the code anywhere else. diff --git a/docs/architecture/claude-plan.md b/docs/architecture/claude-plan.md new file mode 100644 index 0000000000..ad43c044f2 --- /dev/null +++ b/docs/architecture/claude-plan.md @@ -0,0 +1,176 @@ +# Claude plan + +A Claude Pro, Max or Team subscriber picks **Use your Claude plan** and gets Claude's models in chat, titles, Studio and Agentic mode, paid for by their plan. SurfSense never signs in to Claude and holds no Claude credential: it drives the user's own installed, unmodified Claude Code through Anthropic's [Agent SDK](https://code.claude.com/docs/en/agent-sdk/overview), and Claude Code keeps the sign-in. SurfSense ships no copy of Claude Code. Chat runs Claude Code with every tool off; Agentic mode runs it as a second engine beside opencode, with SurfSense's tools, rules, frames and approvals. + +Every behaviour of Claude Code stated here was measured on 16 Oct 2026 on macOS arm64, against Claude Code `2.1.301` (native installer) on a Max plan, with the Python `claude-agent-sdk` `0.2.168`. Windows and Linux are untested on a real install (Known gaps). + +**Code:** [`modules/llm/subscriptions/claude/`](../../surfsense_local/backend/modules/llm/subscriptions/claude/), [`modules/agent/engines/`](../../surfsense_local/backend/modules/agent/engines/), [`frontend/src/features/models/remote/connections/claude/`](../../surfsense_local/frontend/src/features/models/remote/connections/claude/) +**Decisions:** [ADR 0057](../adr/0057-a-claude-plan-runs-through-the-users-own-claude-code.md), [ADR 0061](../adr/0061-claude-code-reaches-anthropic-and-in-the-agent-the-turns-plugins.md), [ADR 0060](../adr/0060-one-consent-question-per-user-action.md), [ADR 0052](../adr/0052-the-chat-model-never-calls-tools.md), [ADR 0027](../adr/0027-egress-consent-per-host.md) + +## How the user sees it + +1. In **Settings › Add model** or onboarding's model step, **Use your Claude plan** sits beside **Use your ChatGPT plan**, offered wherever a plan fills the slot: text generation only. **Set up** opens a nested dialog. +2. The dialog shows what SurfSense found: Claude Code not installed, too old, signed out, or ready with the account's email and plan, and the one thing to do. Not installed, it lists installing, signing in with `claude` in a terminal, and coming back to press **Check again**, with **Open install guide**. Signing in happens in Claude Code, never in SurfSense. +3. **Add Claude models** asks for `api.anthropic.com` and `platform.claude.com` in one egress question, then lists Claude Code's models in every picker. +4. A Claude plan model then works in Basic and Agentic threads like any other; the user never picks an engine, and can switch a thread to another model and back. + +## Finding Claude Code + +[`locate.py`](../../surfsense_local/backend/modules/llm/subscriptions/claude/locate.py) returns the first `claude` that can run: each directory on the API's own `PATH`, then where Claude Code's installers put it, which a GUI app's `PATH` usually lacks: + +| System | Searched after `PATH` | +|---|---| +| macOS, Linux | `~/.local/bin/claude` (native installer), `~/.claude/local/claude`, `~/.npm-global/bin/claude`, `~/.volta/bin/claude`, the newest `~/.nvm/versions/node/*/bin/claude`, `/opt/homebrew/bin/claude`, `/usr/local/bin/claude`, `/home/linuxbrew/.linuxbrew/bin/claude`, `/usr/bin/claude` | +| Windows | `%USERPROFILE%\.local\bin\claude.exe`, `%LOCALAPPDATA%\Microsoft\WinGet\Links\claude.exe`, `%APPDATA%\npm\node_modules\@anthropic-ai\claude-code\bin\claude.exe` | + +On Windows only a `claude.exe` counts, since the SDK refuses npm's `claude.cmd`; elsewhere a file without the executable bit is passed over. The binary is found again before each run. + +## Status + +[`status.py`](../../surfsense_local/backend/modules/llm/subscriptions/claude/status.py) runs the binary twice, each with a 15-second timeout, the [launch environment](#launching-a-run) and its proxy, and on Windows `CREATE_NO_WINDOW`. A run that fails, times out or prints something unreadable counts as no answer. + +| State | When | The dialog says | +|---|---|---| +| `not_installed` | no binary found | install Claude Code, sign in, come back and check again | +| `too_old` | `claude --version` below `MIN_VERSION`, 2.1.300, or unreadable | update Claude Code | +| `signed_out` | `claude auth status` reports `loggedIn: false`, or an `authMethod` other than `claude.ai`, since a Console login is not a plan | run `claude` and sign in | +| `ready` | signed in to a plan | connected as `email`, `subscriptionType` | + +Nothing Claude Code reports is stored: the path, version, account and model list are read live, since a stored copy would go stale the moment the user signs out or switches account. Nothing polls. + +After connecting, the chat model's check, run at startup, when the model changes, when Settings closes and after egress is allowed, lists the plan's models, and a Claude Code below `MIN_VERSION` answers `409` `claude_too_old` there: the composer's notice says "Couldn’t use {model}: Claude Code is too old. Update it.", before anything is sent. The version is read once per binary as installed, by path and modified time ([`installed_version.py`](../../surfsense_local/backend/modules/llm/subscriptions/claude/installed_version.py)). Unlike the dialog, a version it cannot read is let through, so an answer not yet seen on another system never blocks a plan that may work. + +Every run asks the same gatekeeper first ([`usable_claude.py`](../../surfsense_local/backend/modules/llm/subscriptions/claude/usable_claude.py)): a chat, title or Studio run when its model is resolved, an Agentic turn beside its egress check, before anything streams. A Claude Code gone or too old refuses the send with `409` `claude_sign_in_required` or `claude_too_old`, answered app-wide; nothing is launched and no reply is made. The frontend puts the message back in the composer and checks the chat model again, so its notice says why at once ([`claude-code-refusal.ts`](../../surfsense_local/frontend/src/features/chat/claude-code-refusal.ts)). It costs a file `stat` per run, as the version is read again only once the binary changes. + +## The connection + +A `provider_connections` row with `auth_kind = 'claude_plan'` (revision `0031`, which rebuilt the table from its stored definition, keeping remote selections aside and putting them back), `provider = 'openai_compatible'`, `base_url = 'https://api.anthropic.com'` and `catalog_provider = 'anthropic'`. It holds no credential: both ciphertext columns stay `NULL`. One per install; it serves `text_gen` only ([`serves.py`](../../surfsense_local/backend/modules/llm/connections/serves.py)), which the agent answers with too. Deleting it deletes the row and forgets `host:platform.claude.com`; Claude Code's sign-in is untouched. + +| Route | Does | +|---|---| +| `GET /llm/connections/claude` | `serves` and the `hosts` still to allow. It runs no Claude Code, so a picker shows the row at once | +| `GET /llm/connections/claude/status` | Claude Code's state, read live by running it. Only the dialog asks | +| `POST /llm/connections/claude` | creates the connection: `409` when one exists, `422` with the state when not `ready`, `403 egress_disabled` naming every host still off with `purpose: "claude_plan"` | + +Renaming and deleting go through the connection routes; renaming is all a plan allows. The model list is `get_server_info()["models"]`, read live, each listed once by its full id (`resolvedModel`, such as `claude-sonnet-5-5`) so the remote manifest describes its window, images, tool calls and structured output as for any Anthropic model; `default` is dropped. With Claude Code gone since connecting, it answers `409` `claude_sign_in_required`. + +## Launching a run + +[`launch.py`](../../surfsense_local/backend/modules/llm/subscriptions/claude/launch.py) is the only code that builds `ClaudeAgentOptions`. Callers pass what differs; these they cannot set: + +| Option | Value | Why | +|---|---|---| +| `cli_path` | the located binary | the SDK otherwise runs a bundled copy first | +| `setting_sources` | `[]` | the user's `~/.claude` settings, hooks, `CLAUDE.md`, skills and plugins never apply | +| `strict_mcp_config` | `True` | only the MCP servers SurfSense passes | +| `verbatim_prompts` | `True` | `@path` and `/command` in a message are text | +| `permission_mode` | `"default"` | 2.1.301 defaults to `auto`, which approves tools before `can_use_tool` runs | +| `env` | `ANTHROPIC_API_KEY=""`, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`, the proxy, `NO_PROXY=127.0.0.1,localhost` | a key in the API's environment would replace the plan; no telemetry hosts | + +`CLAUDE_CONFIG_DIR` is not set: Claude Code's sign-in lives in the user's own config folder, and a separate one signs out. Errors are read by [`errors.py`](../../surfsense_local/backend/modules/llm/subscriptions/claude/errors.py): `authentication_failed` or a missing binary is `ClaudeSignInRequiredError`, chat kind `claude_sign_in`; a `RateLimitEvent` the plan refused with no extra usage paying is `ClaudePlanLimitError` with its reset time, kind `claude_limit`; an egress refusal during a reply is `egress_disabled`; any other failed result is `ClaudeRunError`, kind `unknown`. Claude Code sends a `RateLimitEvent` with each reply. Read from the code of Claude Code 2.1.301, which builds it from Anthropic's `anthropic-ratelimit-unified-*` headers: past the plan's limit its `status` is `rejected`, and `isUsingOverage` (extra usage `allowed`) or `overageInUse` says extra usage, billed apart from the plan, pays, and Claude Code goes on answering; only `rejected` with neither ends the reply. SurfSense applies the same rule. Within the plan was seen on a live account; extra usage was not, as it is off for the account measured (`org_level_disabled`). Chat and Agentic replies pass it on once in a `plan-extra-usage` frame, `{using, resets_at}` ([`extra_usage.py`](../../surfsense_local/backend/modules/llm/subscriptions/claude/extra_usage.py)), and while the last says `using` and a Claude plan model is selected, the composer's notice says "Your plan’s limit is reached, so replies use extra usage until Thu 2:00 PM", before the next message costs extra; it goes when a reply comes back within the plan or at `resets_at`, and gives way to a model issue, which blocks sending ([`extra-usage/`](../../surfsense_local/frontend/src/features/chat/extra-usage/)). + +**Packaging.** The SDK's wheels carry Claude Code, 241 MB, in `_bundled/`. `[tool.uv] no-binary-package = ["claude-agent-sdk"]` builds it from source, which carries none; [`test_sdk_install.py`](../../surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_sdk_install.py) fails if `_bundled` holds anything, and the frozen-bundle test fails on any `claude` file. The SDK is pinned exactly. + +## Network + +Claude Code makes its own HTTPS calls, which no `egress.require()` sees, so every run's `HTTPS_PROXY` is a proxy the API serves on a loopback port from its lifespan. A run refuses to start without one (`ProxyNotServingError`). + +| Host | Reached for | Destination | +|---|---|---| +| `api.anthropic.com` | every model call and the model list | `host:api.anthropic.com` | +| `platform.claude.com` | refreshing the sign-in, the binary's `TOKEN_URL` | `host:platform.claude.com` | + +- **Chat and status checks** use [`proxy.py`](../../surfsense_local/backend/modules/llm/subscriptions/claude/proxy.py): the two hosts above, each only while allowed, and nothing else ([`proxy_hosts.py`](../../surfsense_local/backend/modules/llm/subscriptions/claude/proxy_hosts.py)). +- **Agent runs** use [`agent_proxy.py`](../../surfsense_local/backend/modules/agent/engines/claude_code/agent_proxy.py): the same two, and the hosts of the ready plugins by the check the plugin proxy applies to opencode ([ADR 0061](../adr/0061-claude-code-reaches-anthropic-and-in-the-agent-the-turns-plugins.md)). A chat run is given no MCP server, so it can never reach a plugin. + +Each host is its own row in Settings › Network (the sign-in host labelled "Claude Code sign-in"). Both are asked for in one question ([egress](egress.md)), and every check that admits a Claude plan's call requires both, since with the sign-in host off chat keeps answering until the token expires. A refused tunnel makes Claude Code retry up to ten times as `api_retry`; on the first, the run reads the destinations again and ends with `egress_disabled` when one is off. Each refused host is logged as Claude Code's (`refused Claude Code's CONNECT host:443`). + +## Chat + +[`chat.py`](../../surfsense_local/backend/modules/llm/subscriptions/claude/chat.py), `ClaudePlanProvider`, implements the `Generator` protocol, so chat, titles and Studio use a Claude plan model as any other, in the API process only ([ADR 0048](../adr/0048-the-api-is-the-only-path-to-a-text-model.md)). + +- `tools=[]` ([ADR 0052](../adr/0052-the-chat-model-never-calls-tools.md)) and `max_turns=1`; three with a JSON schema, which maps to `output_format` and comes back as `ResultMessage.structured_output` through Claude Code's own `StructuredOutput` tool. 11 of 11 measured outputs validated. +- SurfSense's system prompt replaces Claude Code's; `--no-session-persistence`; an empty working folder under `/claude/chat`. +- Thinking streams only with `display: "summarized"`; `max_tokens` and `temperature` are dropped, since Claude Code exposes neither. +- **History.** A new or changed conversation is sent as text with the last message after it; the same conversation one message longer sends only the new message to a warm client ([`chat_prompt.py`](../../surfsense_local/backend/modules/llm/subscriptions/claude/chat_prompt.py)). The newest message's images go as image blocks. +- **Warm clients** ([`chat_runs.py`](../../surfsense_local/backend/modules/llm/subscriptions/claude/chat_runs.py)): per conversation, at most four, closed after five minutes idle, never while answering, all closed when the API stops. Measured: 0.9 s to the first token warm, 2.3 s cold. + +## Agentic mode + +### Two engines + +The chat, permission, step and workspace routes reach the agent only through [`AgentEngine`](../../surfsense_local/backend/modules/agent/engines/agent_engine.py), picked by [`thread_engine.py`](../../surfsense_local/backend/modules/agent/engines/thread_engine.py): opencode ([`opencode_engine.py`](../../surfsense_local/backend/modules/agent/engines/opencode_engine.py), today's code unchanged) or Claude Code ([`claude_code/engine.py`](../../surfsense_local/backend/modules/agent/engines/claude_code/engine.py)). A thread records its engine in `chat_threads.agent_engine` (revision `0032`, every earlier agent thread backfilled to `opencode`), and the selected model's connection decides each turn's: `claude_plan` runs on Claude Code. A Claude plan model opens Agentic threads without opencode installed ([`new_chat.py`](../../surfsense_local/backend/modules/llm/capability/modes/new_chat.py)). The tool endpoint, permission answers and step routes are mounted whether or not opencode is, behind the launch key ([agent](agent.md#from-a-thread-to-a-running-opencode)). + +### A turn + +Prepared as opencode's is: the folder synced, the scope note, sign-ins refreshed, the license checked, changed tools checked, the turn's plugins and rules ([agent](agent.md#plugins)). Then the thread's Claude Code runs it ([`turn.py`](../../surfsense_local/backend/modules/agent/engines/claude_code/turn.py)) with ([`agent_options.py`](../../surfsense_local/backend/modules/agent/engines/claude_code/agent_options.py)): + +| Option | Value | +|---|---| +| `cwd` | the thread's folder | +| `tools` | `Read`, `Glob`, `Grep`, `Write`, `Edit`, `Skill`. No Bash ([ADR 0039](../adr/0039-document-scripts-run-without-approval.md)), web, subagents or notebooks | +| `mcp_servers` | a file only the user can read ([`mcp_config.py`](../../surfsense_local/backend/modules/agent/engines/claude_code/mcp_config.py)), removed with the client: `surfsense`, the thread's tool endpoint with the launch key, and each ready plugin with its sign-in header. Given as a dict, the SDK would put the headers on the command line | +| `disallowed_tools` | each given plugin's tools with no rule, so the model is never offered them | +| `plugins`, `skills` | SurfSense's skills as a local plugin, `surfsense`, rebuilt at start with SurfSense's tools named as Claude Code lists them ([`skills_plugin.py`](../../surfsense_local/backend/modules/agent/engines/claude_code/skills_plugin.py)) | +| `system_prompt` | the agent prompt, renamed the same way ([`tool_names.py`](../../surfsense_local/backend/modules/agent/engines/claude_code/tool_names.py)) | +| `hooks`, `can_use_tool` | [the gate](#the-gate) | +| `session_id` or `resume` | the thread's `claude_session_id`, chosen by SurfSense when the thread first needs Claude Code | +| `session_store` | SurfSense's copy of the transcript ([Sessions](#sessions)) | +| `env` | `MCP_TIMEOUT` 25 s, the limit both engines give a plugin to connect; `MCP_TOOL_TIMEOUT` 300 s; `MAX_MCP_OUTPUT_TOKENS` 100,000 | + +The user's words go as the last text block of the user message, with SurfSense's notes (handover, scope, mentioned plugins) as blocks before it, and the message's uuid chosen by SurfSense, so the live frames and the thread read back use one id. + +**Clients** ([`agent_runs.py`](../../surfsense_local/backend/modules/agent/engines/claude_code/agent_runs.py)). One Claude Code per thread, kept between turns while a digest of its model, thinking switch, servers, hidden tools and skills matches; otherwise it is closed and the session resumed with the new ones. Claude Code lists a server's tools once, when it connects, so a plugin whose offered tools SurfSense recorded otherwise since (a new input schema, say) is connected again with `reconnect_mcp_server`, as opencode's is given again; one that will not reconnect closes the client. Measured: with a schema changed and no reconnect, Claude Code never listed the tools again; `reconnect_mcp_server` returned in 0.07 s with the new schema listed. Closed after five minutes idle, when the thread is deleted, and when the API stops. + +**Plugins that cannot be used** ([`plugin_statuses.py`](../../surfsense_local/backend/modules/agent/engines/claude_code/plugin_statuses.py)) are read from the turn's `init` message, which Claude Code holds until each server has connected or failed within `MCP_TIMEOUT`. Asked earlier, every server is `pending`. `failed` or still `pending` is unreachable, `needs-auth` a sign-in or, for SurfSense's own plugins, the license: the same reasons, from the same 25-second limit, as opencode's turn ([agent](agent.md#plugins)). Measured: a plugin taking about 4.5 s was `pending` at 0.6 s and `connected` in `init` at 5.2 s. + +**Large results.** A tool result over `MAX_MCP_OUTPUT_TOKENS` is refused; one larger than Claude Code gives whole (an 80.7 KB one was) is saved under `/projects///tool-results/` with a 2 KB preview and the path, which the gate lets the model read, as opencode's rules leave its tool-output folder open. + +### The gate + +[`gate.py`](../../surfsense_local/backend/modules/agent/engines/claude_code/gate.py) applies opencode's rules ([`opencode_config.py`](../../surfsense_local/backend/modules/agent/opencode_config.py) and the per-session plugin rules) to Claude Code's tool names. A path is resolved against the thread's folder with links followed before it is compared. + +| Tool | Rule | +|---|---| +| `Read`, `Glob`, `Grep` | inside the thread's folder, the skills folder, and this session's saved results ([`saved_results.py`](../../surfsense_local/backend/modules/agent/engines/claude_code/saved_results.py)); nothing else, another session's results and Claude Code's settings included | +| `Write`, `Edit` | under `outputs/` only, never `AGENTS.md`, `CLAUDE.md`, `CONTEXT.md`, `.claude/` or `.opencode/` | +| `Skill` | a `surfsense:` skill only. Claude Code refuses its own skills first: `init` lists 17 of them beside SurfSense's, but measured, the model named only SurfSense's when asked, and a call to `dataviz` with every call allowed was refused as "not in this session's skills allowlist", which `skills` sets. `--disable-slash-commands` was rejected: it removes the Skill tool, SurfSense's skills with it | +| `mcp__surfsense__*` | allowed; each tool refuses for itself what a turn may not do | +| `mcp____` | the gateway's `allow` or `ask`; any other is refused | +| anything else | refused, with a reason the model reads | + +A `PreToolUse` hook decides at once, since a hook has a timeout ([`turn_link.py`](../../surfsense_local/backend/modules/agent/engines/claude_code/turn_link.py)); for `ask`, Claude Code then calls `can_use_tool`, which applies the same rules, sends a `permission-request` frame on the call's step, sets the run to `needs-approval`, and waits for `once` or `reject` from the permission route ([`approvals.py`](../../surfsense_local/backend/modules/agent/engines/claude_code/approvals.py)). Never "always" ([ADR 0028](../adr/0028-model-written-code-runs-with-approval.md)). Measured: an answer 65 s after the ask let the call run; a rejected call never reached the server; in `auto` mode a delete ran without `can_use_tool`, which is why `default` is forced. + +### Frames and the thread read back + +Claude Code's messages are read as opencode's events ([`opencode_shape.py`](../../surfsense_local/backend/modules/agent/engines/claude_code/opencode_shape.py)) and its transcript as opencode's messages ([`transcript_shape.py`](../../surfsense_local/backend/modules/agent/engines/claude_code/transcript_shape.py)), with tools named and their inputs keyed as opencode's are (`read`, `surfsense_render_document`, `_`, `filePath`). So `TurnFrames`, `step_of`, citations and `thread_turns` serve both engines, and the frontend shows them alike: streamed text, reasoning, steps, approvals, versions made, citations. The reply is stated from what streamed, since the transcript copy lands in batches about 100 ms apart. A compaction's boundary and summary never reach the reply; measured mid-turn, 69,221 tokens became 5,130 and the turn went on. + +**Stop** drops every waiting approval, interrupts Claude Code, and reads the interrupted turn to its end within 10 seconds, so its result never answers the next message; past that the client is closed. The reply is recorded `stopped`. **Endings** opencode keeps in its session's metadata are kept for Claude Code in `claude_reply_endings`: `interrupted` from the turn's start, replaced by `stopped` or the error, removed when the reply completes ([`endings.py`](../../surfsense_local/backend/modules/agent/engines/claude_code/endings.py)). + +### Sessions + +Claude Code writes a session's transcript under `~/.claude/projects/`, which is the user's, not SurfSense's: it deletes transcripts older than `cleanupPeriodDays`, 30 by default, whenever it starts, and the user may clear it or move machines. So [`session_store.py`](../../surfsense_local/backend/modules/agent/engines/claude_code/session_store.py), the SDK's `SessionStore`, copies each line into `claude_session_entries`, each kept once by its uuid. Reading the thread, a step's whole result and Save to Sources read SurfSense's copy; resuming loads it. So Claude Code's transcript is deleted after each turn ([`session_files.py`](../../surfsense_local/backend/modules/agent/engines/claude_code/session_files.py)), and its `/` folder kept, since a later turn may read a result saved there. Measured: deleted between turns, a running Claude Code went on, writing a new file with only the next turn's lines while the store kept every line, and a relaunch resumed from the store alone. `--no-session-persistence`, which chat runs use, was rejected here: it writes no transcript but mirrors none to the store either, so the next turn found no session. Deleting the thread, or its workspace, closes its Claude Code, deletes SurfSense's copy with the row, and deletes Claude Code's transcript and session folder, whichever engine the thread is on now; a thread that switched engines has its opencode session deleted too. The SDK's `delete_session` is not used, since it stops at a missing transcript and leaves the folder. + +A session is resumed only under the Claude account it was made under. `chat_threads.claude_account` keeps a digest of the email and organization `claude auth status` reports, not the email itself; each turn reads it again (0.8 s, measured). Another account signed in since gets the thread a new session, told the whole thread as a [handover](#switching-engines) note, and Claude Code's transcript of the old one is deleted; SurfSense's copy stays, and the thread reads back every session it had. A session made before its account was read is taken as the one signed in now. + +### Switching engines + +When the selected model moves a thread to the other engine ([`take_over.py`](../../surfsense_local/backend/modules/agent/engines/handover/take_over.py)), the engine taking over resumes its own session, or opens one in the thread's folder, and its turn carries a note of the turns it did not see ([`note.py`](../../surfsense_local/backend/modules/agent/engines/handover/note.py)): each user message, each reply, each tool call with its output cut to 2,000 characters, and the files now in `outputs/`. Reasoning is left out, and so is the old text of a file rewritten since. The note is fitted to 24,000 characters, about 6,000 tokens: older tool outputs go first, then older replies are cut to their first paragraph, then the oldest turns become one line, the newest kept whole. It goes beside the user's words, tagged ``, as opencode's synthetic part or Claude Code's leading block, so each engine's session keeps it and reading the thread leaves it out. A Claude session renewed for another account takes over the same way, with every turn of the thread as the note. Reading a thread merges both sessions' turns by time ([`both_sessions.py`](../../surfsense_local/backend/modules/agent/engines/both_sessions.py)). An engine the thread left that cannot answer now, such as opencode not running, is left out of the read with a warning in the log, rather than the thread failing to open; the engine running the thread is never left out. One engine per thread was rejected: a Claude plan model would be the only one a user could not switch to mid-thread. An engine resumes its own session however often the thread left it: that session holds its own turns whole, tool outputs included, and the note adds the rest, so a fresh session told only the note would know less. + +## The dialog + +[`claude-option.tsx`](../../surfsense_local/frontend/src/features/models/remote/connections/claude/claude-option.tsx) is the row, [`claude-dialog.tsx`](../../surfsense_local/frontend/src/features/models/remote/connections/claude/claude-dialog.tsx) the nested dialog, one view per state, read live each time it opens and on **Check again**, and [`claude-plan-edit.tsx`](../../surfsense_local/frontend/src/features/models/remote/connections/claude/claude-plan-edit.tsx) the rename-only edit form. **Open install guide** opens `https://code.claude.com/docs/en/setup`, the one page of that site [`external-url.ts`](../../surfsense_local/electron/src/main/external-url.ts) lets through. The egress question for a Claude plan, `purpose: "claude_plan"`, says what each host gets: the API host the prompts and document excerpts, the sign-in host only the sign-in's refresh. Testing a model on a plan says it counts toward the plan's limits. The interface says "Claude plan" and names Claude Code only as the program the user installs ([branding](https://code.claude.com/docs/en/agent-sdk/overview#branding-guidelines)). + +## Anthropic's terms + +The maintainers own this decision ([ADR 0057](../adr/0057-a-claude-plan-runs-through-the-users-own-claude-code.md)). On 16 Oct 2026, Anthropic's [legal and compliance](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use) page forbids a third-party Claude.ai login and intermediating its credentials, and does not prevent "an end user from signing in to the unmodified Claude Code binary with their own Claude subscription"; [Use the Claude Agent SDK with your Claude plan](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan) says users can use the SDK and third-party apps with their subscription limits. Anthropic changed this policy four times in 2026 and may enforce it "without prior notice", so removing the feature is deleting the `claude_plan` connection kind and the Claude engine, nothing else. + +## Tests + +Stand-ins replay what the measured Claude Code sent: a fake `claude` for status ([`claude_stand_in.py`](../../surfsense_local/backend/tests/claude_stand_in.py)), scripted chat runs, and a scripted agent run that meets the turn's own hook and `can_use_tool` and mirrors its transcript ([`claude_agent_stand_in.py`](../../surfsense_local/backend/tests/claude_agent_stand_in.py)). [`test_both_engines.py`](../../surfsense_local/backend/tests/integration/agent/claude_code/test_both_engines.py) runs the same scenarios through opencode and the stand-in (an answer, a write allowed and one refused, a plugin call approved, Stop) and compares what the chat shows and reads back, ids, step titles and tool outputs aside. Tests under the `claude_code` marker run the developer's own Claude Code on their plan, opted into with `SURFSENSE_CLAUDE_CODE_TESTS=1`; CI never selects them. Every run reads the installed version, and one newer than `NEWEST_TESTED`, `2.1.301`, is named once in the log as possibly breaking what this page measured ([`tested_version.py`](../../surfsense_local/backend/modules/llm/subscriptions/claude/tested_version.py)). `NEWEST_TESTED` is raised only once the `claude_code` tests pass on the newer Claude Code; all 18 passed on `2.1.301` on 17 Oct 2026. + +## Known gaps + +- Windows and Linux are untested on a real install: whether `claude auth status` prints the same JSON, whether `HTTPS_PROXY` is honoured the same way, and WinGet's `Links` folder. +- A thread read while the engine it left cannot answer shows without that engine's turns, and nothing on screen says some are missing. +- A Claude Code update that changes what this page measured is caught only when someone runs the `claude_code` tests on it; until then, only the log warning says it is untested. diff --git a/docs/architecture/connections.md b/docs/architecture/connections.md index b34a650b3c..158ebd7942 100644 --- a/docs/architecture/connections.md +++ b/docs/architecture/connections.md @@ -41,7 +41,7 @@ Out of scope: a standalone OpenRouter provider or its legacy Chat Completions im | `catalog_provider` | the remote manifest provider this reaches, such as `openai` or `neon`, or `custom` for an endpoint the manifest does not list; stored as chosen, never read from the URL | | `base_url` | the exact API root, normally ending in `/v1`, stored without a trailing slash | | `api_key_ciphertext` | the Fernet-encrypted key, nullable, never returned by any route | -| `auth_kind` | `api_key`, or `chatgpt` for a connection signed in with a ChatGPT account, enforced by a CHECK; added in place by `0023`, so existing rows read `api_key` | +| `auth_kind` | `api_key`, `chatgpt` for a connection signed in with a ChatGPT account, or `claude_plan` for one run through the user's own Claude Code ([Claude plan](claude-plan.md)), enforced by a CHECK; added in place by `0023`, so existing rows read `api_key`, and widened by `0031` | | `oauth_ciphertext`, `token_version` | a `chatgpt` row's encrypted token set and its refresh counter ([`chatgpt-subscription.md`](chatgpt-subscription.md)) | | `created_at`, `updated_at` | | diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index fa55d25830..12b28e9411 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -115,8 +115,10 @@ One `active` row, enforced in code rather than by a singleton constraint, becaus | Table | Columns | Notes | |---|---|---| -| `chat_threads` | `id`, `workspace_id`, `cloud_id`, `title`, `opencode_session_id`, `source_scope`, `history_start_message_id`, `created_at`, `updated_at` | `cloud_id` is the nullable, unique hosted thread id. `title` is nullable; the API defaults it to "New chat". `opencode_session_id`, from `0021`, is set on a thread the agent answers, whose turns live in that opencode session rather than in `chat_messages` ([`chat.md`](chat.md#agent-threads)). `source_scope`, JSON from `0024`, holds the sources ticked for the thread; null means every source ([`chat.md`](chat.md#source-scope)). `history_start_message_id`, from `0026`, is the oldest message the model is still sent once history was trimmed, and `NULL` until it first was ([`chat.md`](chat.md#message-assembly)) | +| `chat_threads` | `id`, `workspace_id`, `cloud_id`, `title`, `agent_engine`, `opencode_session_id`, `claude_session_id`, `claude_account`, `source_scope`, `history_start_message_id`, `created_at`, `updated_at` | `cloud_id` is the nullable, unique hosted thread id. `title` is nullable; the API defaults it to "New chat". `agent_engine`, from `0032`, is `opencode` or `claude_code` on a thread the agent answers, whose turns live in that engine's session rather than in `chat_messages`, and `NULL` on a chat thread; `0032` set `opencode` on every thread that had an opencode session ([`chat.md`](chat.md#agent-threads)). `opencode_session_id`, from `0021`, is the thread's opencode session once opencode has run it, and `claude_session_id`, from `0032`, its Claude Code session once Claude Code has, with `claude_account` a digest of the Claude account that session runs under ([Claude plan](claude-plan.md#sessions)). `source_scope`, JSON from `0024`, holds the sources ticked for the thread; null means every source ([`chat.md`](chat.md#source-scope)). `history_start_message_id`, from `0026`, is the oldest message the model is still sent once history was trimmed, and `NULL` until it first was ([`chat.md`](chat.md#message-assembly)) | | `chat_messages` | `id`, `chat_thread_id`, `role`, `content`, `created_at`, `completed_at` | `role` is `user`, `assistant` or `system`; `completed_at` arrived in `0002` | +| `claude_session_entries` | `id`, `thread_id`, `session_id`, `uuid`, `entry` | From `0032`. SurfSense's copy of a thread's Claude Code transcript, one row per line in the order written, since Claude Code deletes its own after 30 days. `(session_id, uuid)` is unique, so a batch the SDK retries is kept once; a line without a `uuid` is always kept. Deleted with the thread ([Claude plan](claude-plan.md#sessions)) | +| `claude_reply_endings` | `thread_id`, `reply_id`, `ending` | From `0032`. How a Claude Code reply that did not finish ended, stopped, failed or interrupted, which opencode keeps in its session's metadata instead. Deleted with the thread | `content` is JSON: `{"text"}` for a user turn, plus `images: [{"key", "mime", "size_bytes", "sha256"}]` when it carried any, and `source_scope` with `resolved: {"count", "ids_sha256"}` when it was grounded on a scope, and `{"text", "citations"}` for an assistant turn, whose text carries `[citation:]` markers. The server reads `text` to build the model's history, and a user turn's `images` for the newest turn that carried some; the citations are for the UI. Imported user turns also carry an empty `citations` list. Messages are indexed on `(chat_thread_id, created_at)` and cascade with their thread. Visibility, authorship, cloning, turn ids, token usage and LangGraph checkpoints are left out. See [`chat.md`](chat.md). @@ -140,7 +142,7 @@ An artifact's searchable body is a `Document` with `document_type = ARTIFACT`; ` | Table | Columns | Notes | |---|---|---| -| `provider_connections` | `id`, `label`, `provider`, `base_url`, `catalog_provider`, `api_key_ciphertext`, timestamps, and since `0023` `auth_kind`, `oauth_ciphertext` and `token_version` | `label` unique case-insensitively; `provider` must be `openai_compatible`; `catalog_provider` is a remote manifest provider id or `custom`, since `0014`; the key is Fernet ciphertext since `0007`; since `0023`, `auth_kind` is `api_key` or `chatgpt`, `oauth_ciphertext` the Fernet-encrypted token set of a `chatgpt` row (`NULL` when signed out), and `token_version` the counter that keeps refreshes from racing ([`chatgpt-subscription.md`](chatgpt-subscription.md)) | +| `provider_connections` | `id`, `label`, `provider`, `base_url`, `catalog_provider`, `api_key_ciphertext`, timestamps, and since `0023` `auth_kind`, `oauth_ciphertext` and `token_version` | `label` unique case-insensitively; `provider` must be `openai_compatible`; `catalog_provider` is a remote manifest provider id or `custom`, since `0014`; the key is Fernet ciphertext since `0007`; since `0023`, `auth_kind` is `api_key` or `chatgpt`, and since `0031` also `claude_plan`, which holds no credential since Claude Code keeps the sign-in ([Claude plan](claude-plan.md#the-connection)); `oauth_ciphertext` is the Fernet-encrypted token set of a `chatgpt` row (`NULL` when signed out), and `token_version` the counter that keeps refreshes from racing ([`chatgpt-subscription.md`](chatgpt-subscription.md)) | | `selected_models` | `model_type`, `provider`, `connection_id`, `name`, `params_b`, `vendor`, `line`, `settings`, `updated_at` | one row per model type: `text_gen`, `image_gen`, `image_edit`, `video_gen`, `audio_gen` or `speech_to_text`; `settings` is JSON for what the user set that no endpoint states, keyed by the slice that owns each entry (a server voice model's `voices`, the speech model's `transcription` language), and cleared when the slot takes another model, all but `chat_modes`: the mode last picked for each model, keyed `//`, which stays so a model's pick comes back with it ([model capabilities](model-capabilities.md#the-modes)) | | `onboarding_completion` | `id`, `completed_at` | a singleton (`CHECK id = 1`) whose presence means onboarding is done | diff --git a/docs/architecture/egress.md b/docs/architecture/egress.md index 486b4e4d5f..3b29a5abd4 100644 --- a/docs/architecture/egress.md +++ b/docs/architecture/egress.md @@ -16,7 +16,7 @@ Nothing is meant to leave the machine until the user allows where it goes. Befor There is one row per host by construction: the key is the hostname, so a destination cannot exist twice under two names, and the user is asked about the host rather than about each errand sent to it. `host:huggingface.co` and `host:plugins.surfsense.com` are built in (`BUILT_IN` in `modules/egress/service.py`), so they are listed before anything names them. Its dialog names every errand, search first because it is the widest: searching sends what the user types, as they type it; downloading sends the name of the model they chose; both send their IP address, and neither sends chats or documents. -State is one table, `egress_destinations`: a row per destination with `enabled`, false by default, and `last_call_at`, which every allowed call stamps. `GET /egress` lists the built-in destinations, one `host:` row per non-loopback host among the stored connections, off when never allowed, and every other `host:` row, such as a host an image endpoint named. `PUT /egress/{destination}` turns one on or off, and answers 422 for anything that is not a `host:` name. Deleting the last connection to a host deletes that host's row (`forget_if_unused()`), so a later connection there is asked about again; a host another connection still uses keeps its grant, and a built-in destination is never dropped. +State is one table, `egress_destinations`: a row per destination with `enabled`, false by default, and `last_call_at`, which every allowed call stamps. `GET /egress` lists the built-in destinations, one `host:` row per non-loopback host among the stored connections, off when never allowed, and every other `host:` row, such as a host an image endpoint named. `PUT /egress` turns the destinations it lists on or off, all or none: every one is checked before any is written, the request's one transaction commits them together, and anything that is not a `host:` name or built-in destination answers 422 with nothing changed. Settings › Network sends a list of one; a consent question sends every host it named ([`modules/egress/router.py`](../../surfsense_local/backend/modules/egress/router.py)). Deleting the last connection to a host deletes that host's row (`forget_if_unused()`), so a later connection there is asked about again; a host another connection still uses keeps its grant, and a built-in destination is never dropped. `host_destination()` returns no destination for `localhost` or a loopback address, so a model server on the same machine needs no consent and has no row. A server elsewhere on the LAN is a destination like any other. @@ -58,14 +58,16 @@ The GGUF download runs inside the API (`modules/llm/providers/llamacpp/download. - Links. The main process refuses new windows and `https:` navigations inside the app, and hands a URL on `surfsense.com` or `www.surfsense.com`, under `github.com/MODSetter/SurfSense`, the project's Discord invite, an OpenRouter page shaped `//` (where OpenRouter names a speech model's voices), or a plugin's OAuth authorization page, recognised on any host by its query (an S256 PKCE code request with SurfSense's `state` and one of its two callbacks), to `shell.openExternal` (`electron/src/main/external-url.ts`); that request is the browser's. Two more reach the browser, each by its own bridge call and never by `window.open` or navigation: a link from content, a chat reply, a Studio document or a tool's result, that the user confirmed in the app's link popup ([`external-link-dialog.tsx`](../../surfsense_local/frontend/src/features/external-links/external-link-dialog.tsx)), which names its host, shows the whole address, and warns when the address carries more than 100 characters after its path, room for the conversation; any `https` page, never another scheme or an address with a user name or password (`confirmedExternalUrl`). And a listed plugin's homepage or privacy policy, opened on a click with no popup: the page names the plugin and the page, and the main process opens the address the registry lists for it, read from the app's API, never one the page passes (`electron/src/main/plugin-page-url.ts`). Report issue opens one such link, a GitHub bug form with the user's description in it; its session log goes to the clipboard, never into the link ([issue reports](issue-reports.md)). - Docling. Packaged Python sidecars run with `HF_HUB_OFFLINE=1` (`electron/src/main/sidecars/python.ts`) and parse with the bundled parser pack ([packaging](packaging.md)). [`test_parsing.py`](../../surfsense_local/backend/tests/integration/worker/test_parsing.py) converts a scanned PDF and images from the parser pack with every outbound connection refused and an empty Hugging Face cache; it runs from source and skips where the pack is not staged. - Spellchecking. Chromium fetches a Hunspell dictionary from Google's CDN (`redirector.gvt1.com/edgedl/chrome/dict/`) for each language in a session's spellchecker list, when the session is created. Measured on Electron 44.2.0 on Linux, `webPreferences.spellcheck: false` and `setSpellCheckerEnabled(false)` do not stop it, and `webRequest` never sees it. The main process empties the list on `session-created`, before the app is ready ([`spellcheck.ts`](../../surfsense_local/electron/src/main/spellcheck.ts)), and no request is made after that. So Windows and Linux have no spellchecking. macOS uses the OS spellchecker, downloads nothing and ignores the call, so it keeps working there. Not measured on Windows, which runs the same code. -- Licenses. The app verifies license files offline and never contacts Keygen ([license](license/app.md)); `PUT /egress/keygen` is refused as unknown. +- Licenses. The app verifies license files offline and never contacts Keygen ([license](license/app.md)); `keygen` is refused by `PUT /egress` as unknown. ## 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 in one `PUT /egress`, so a failed Allow enables none, 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. +One question covers one user action: it names every host that action may reach, including a host that background work started by it reaches, which no dialog could ask about later, and leaves a host only a later action reaches to that action's own question ([ADR 0060](../adr/0060-one-consent-question-per-user-action.md)). Only hosts SurfSense names are asked about; a host a program it runs tries on its own is refused. + Studio resolves its model in the worker, where no dialog can reach the user, so a job whose connection host is off fails with the refusal as its reason. Choosing a remote model already requires its host, so this happens only after the user turns the host off again. `askEgress()` in `features/egress/ask-egress.ts` raises the same dialog without a refused request, in two places: @@ -79,6 +81,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/architecture/overview.md b/docs/architecture/overview.md index e2d8acd900..0b55984036 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -114,7 +114,7 @@ The rules that keep the processes out of each other's way: - [`backend/modules/`](../../surfsense_local/backend/modules/): one folder per feature, holding whichever of its models, schemas, router and service it needs: `workspaces`, `documents`, `chunks`, `chat`, `artifacts` (Studio), `events`, `llm` (runtimes, catalog, connections, selection), `egress`, `agent`, `plugins`, `license`, `migration` (import) and `health`. `shared.db.import_models()` imports every model at startup, because relationships name their targets as strings. - [`backend/worker/`](../../surfsense_local/backend/worker/): `consumer.py` drains one queue per process, `jobs.py` holds status transitions and cancellation, `notify.py` the change notice, and `ingestion/` and `studio/` the two pipelines. - [`backend/shared/`](../../surfsense_local/backend/shared/): configuration, the engine and its pragmas, `upgrade_to_head()`, the two Huey queues, `retrieve()`, and the secret that encrypts stored keys. -- [`backend/alembic/`](../../surfsense_local/backend/alembic/): revisions `0001` to `0030`, all hand-written. `env.py` has no `target_metadata`, so autogenerate cannot run by accident. +- [`backend/alembic/`](../../surfsense_local/backend/alembic/): revisions `0001` to `0032`, all hand-written. `env.py` has no `target_metadata`, so autogenerate cannot run by accident. - [`frontend/src/features/`](../../surfsense_local/frontend/src/features/): `chat`, `agent`, `sources`, `studio`, `workspaces`, `dashboard`, `models`, `onboarding`, `settings`, `egress`, `plugins`, `license`, `migration`, `updates` and `feedback`. - [`electron/src/main/`](../../surfsense_local/electron/src/main/): `index.ts` (the window, IPC and the paths it hands to boot), `sidecars/` (boot, the supervisor, one spec per sidecar, and the image-model, preset, audio and agent watchers), `session-log/` (this run's output, for issue reports), `permissions/` (who may use the microphone), `microphone/` (the operating system's switch), `secret.ts`, `updater.ts` and `document-files.ts`. @@ -131,6 +131,7 @@ The rules that keep the processes out of each other's way: - [Studio](studio.md): artifact formats, jobs, viewers. - [Connections](connections.md): OpenAI-compatible endpoints and where keys live. - [ChatGPT subscription](chatgpt-subscription.md): a connection signed in with a ChatGPT account, answered through the Responses API. +- [Claude plan](claude-plan.md): a connection to the user's own Claude Code, answering chat and running Agentic threads as a second engine. - Local models: [runtime](local-models/runtime.md), [fit](local-models/fit.md), [admission and parallel slots](local-models/admission.md), [catalog](local-models/catalog.md), [selection and onboarding](local-models/selection.md). - [Localization](localization.md): the interface in ten languages. - [Egress](egress.md), [Office pack](office-pack.md), [import](import.md), [license in the app](license/app.md), [license portal](license/portal.md), [updates](updates.md), [about](about.md), [issue reports](issue-reports.md), [resource usage](resource-usage.md), [packaging](packaging.md), [sunset](sunset.md). diff --git a/docs/architecture/plugins.md b/docs/architecture/plugins.md index fda8fb2b51..3317b25e25 100644 --- a/docs/architecture/plugins.md +++ b/docs/architecture/plugins.md @@ -124,7 +124,7 @@ A plugin's server gets the arguments of the calls opencode makes to it, and noth [`plugins-settings.tsx`](../../surfsense_local/frontend/src/features/plugins/plugins-settings.tsx), under Settings, says plugins work in Agentic threads and has two tabs, **Yours** and **Browse**, with **Add custom plugin** beside them and nowhere else. It opens on Yours when a plugin is connected, otherwise on Browse, chosen once when the list first loads. - **Search**, beside the tabs, finds plugins in both at once ([`search/`](../../surfsense_local/frontend/src/features/plugins/search/)): a plugin matches when every word of the query is in its name, id, description, author or tool names, or, for a connected one, its host and tool titles. Results list **Yours**, the connected plugins in the same table, then **Available**, the first-party and registry plugins not connected, as one-line rows rather than Browse's cards: icon, name and description, with Connect; the registry is searched only while third-party plugins are allowed, and the results say so otherwise. While the query is not empty the tabs are held and the results replace them; Escape or the clear button gives them back. Results settle 200 ms after the last keystroke, with a skeleton of both sections until then. A result opens its details or its public page in place, and Back returns to the results. -- **Yours** ([`your-plugins.tsx`](../../surfsense_local/frontend/src/features/plugins/yours/your-plugins.tsx)) is a table of connected plugins, Plugin, Type and Status ([`connected-plugins-table.tsx`](../../surfsense_local/frontend/src/features/plugins/yours/connected-plugins-table.tsx)). Type is "By SurfSense", "Third-party" or "Custom" for a server added by its URL. Status is one state, the first that holds ([`plugin-status.ts`](../../surfsense_local/frontend/src/features/plugins/yours/plugin-status.ts)): "Disconnected", with **Connect** in the row ([`connect-plugin-button.tsx`](../../surfsense_local/frontend/src/features/plugins/details/connect-plugin-button.tsx)), "No longer listed", a failed check ("Couldn’t connect", "Signed out", "Needs a new key", or a refused license), "Third-party plugins are off", "Off", "New tools", then "On". Each row opens the plugin's details. With nothing connected it says so, with **Browse plugins** and **Add custom plugin** ([`no-plugins-connected.tsx`](../../surfsense_local/frontend/src/features/plugins/yours/no-plugins-connected.tsx)). +- **Yours** ([`your-plugins.tsx`](../../surfsense_local/frontend/src/features/plugins/yours/your-plugins.tsx)) is a table of connected plugins, Plugin, Type and Status ([`connected-plugins-table.tsx`](../../surfsense_local/frontend/src/features/plugins/yours/connected-plugins-table.tsx)). Type is "By SurfSense", "Third-party" or "Custom" for a server added by its URL. Status is one state, the first that holds ([`plugin-status.ts`](../../surfsense_local/frontend/src/features/plugins/yours/plugin-status.ts)): "Disconnected", with **Connect** in the row ([`connect-plugin-button.tsx`](../../surfsense_local/frontend/src/features/plugins/details/connect-plugin-button.tsx)), "No longer listed", a failed check ("Couldn’t connect", "Signed out", "Needs a new key", or a refused license), "Third-party plugins are off", "Off", then "On". "On" carries a "New tools" badge while a tool is new or changed. Each row opens the plugin's details. With nothing connected it says so, with **Browse plugins** and **Add custom plugin** ([`no-plugins-connected.tsx`](../../surfsense_local/frontend/src/features/plugins/yours/no-plugins-connected.tsx)). - **Browse** ([`browse-plugins.tsx`](../../surfsense_local/frontend/src/features/plugins/browse/browse-plugins.tsx)) holds **By SurfSense**, from the first-party list ([`first-party-plugins.tsx`](../../surfsense_local/frontend/src/features/plugins/browse/first-party-plugins.tsx)): each plugin with its logo, its description, "License required" where `access` is `license`, and **Connect**, or "Connected"; connecting ends in a toast wherever it starts, Browse, Add custom plugin, Connect on a disconnected plugin in Yours, a key or a sign-in: "{plugin} connected", including once an account's sign-in finishes in the browser, or "Couldn’t connect {plugin}" with the reason in the app's words and Report issue ([`connect-toasts.ts`](../../surfsense_local/frontend/src/features/plugins/connect-toasts.ts)). Then **Third-party** ([`third-party-plugins.tsx`](../../surfsense_local/frontend/src/features/plugins/browse/third-party-plugins.tsx)), its header holding **Allow third-party plugins**, off by default. Turning it on first asks, saying other people run those servers, what a tool is sent goes to its publisher, and SurfSense does not review changes to them; turning it off asks nothing. While it is off the section is one sentence; while it is on it holds the registry's list once they are allowed ([`registry-plugins.tsx`](../../surfsense_local/frontend/src/features/plugins/browse/registry-plugins.tsx)), dated "Updated" with `fetched_at`. While `plugins.surfsense.com` is off it says the list comes from there, with **Allow**, which raises the egress prompt on the click rather than at load. Each entry shows its icon or its name's first letter ([`plugin-icon.tsx`](../../surfsense_local/frontend/src/features/plugins/plugin-icon.tsx)), name with a Verified checkmark or a Community badge beside it, its description, "By" its author, an `external` entry's note, and **Connect**, or "Connected". A `token` entry's Connect opens a dialog for the key, labelled in its publisher's words ([`registry-key-dialog.tsx`](../../surfsense_local/frontend/src/features/plugins/browse/registry-key-dialog.tsx)). - **Icons.** Each of SurfSense's plugins and each registry plugin shows the icon beside its list; any other shows the first letter of its name ([`plugin-icon.tsx`](../../surfsense_local/frontend/src/features/plugins/plugin-icon.tsx)). - **Add custom plugin** opens a dialog with Name, Server URL and an optional Key, and switches to Yours once the plugin is connected; connecting from Browse stays on Browse. Each refusal is shown in the app's words by its `code` ([`refusal-message.ts`](../../surfsense_local/frontend/src/features/plugins/refusal-message.ts)); a host not allowed raises the egress prompt and retries once allowed. While third-party plugins are off, a custom plugin's details say "Not used until third-party plugins are allowed". diff --git a/docs/proposals/claude-plan/01-connection.md b/docs/proposals/claude-plan/01-connection.md deleted file mode 100644 index f1c0496401..0000000000 --- a/docs/proposals/claude-plan/01-connection.md +++ /dev/null @@ -1,105 +0,0 @@ -# The Claude plan connection - -> A `claude_plan` row in `provider_connections` holds no credential: it names the Claude Code binary SurfSense found and the account Claude Code reported. Every run of Claude Code, chat or agent, is launched by one function with one fixed set of options, so no caller can forget the path, the proxy or the isolation. - -Part of [Use your Claude plan](README.md). - -## Finding Claude Code - -`subscriptions/claude/locate.py` returns the first executable `claude` it finds: - -1. `shutil.which("claude")` on the API's own `PATH`. -2. The places installers put it, which a GUI app's `PATH` usually lacks: `~/.local/bin/claude` (native installer), `~/.claude/local/claude` (older local install), `/opt/homebrew/bin/claude`, `/usr/local/bin/claude`, `~/.npm-global/bin/claude`, and on Windows `%USERPROFILE%\.local\bin\claude.exe` and `%LOCALAPPDATA%\Programs\claude\claude.exe` (unverified, [open question](README.md#open-questions)). - -On Windows an npm `claude.cmd` shim is skipped for a native `claude.exe`, as the SDK's own search does. The path found is stored on the connection and checked again before each run; a binary that moved is looked for again before the run fails. - -## Status - -`subscriptions/claude/status.py` runs the binary twice, each with a 15-second timeout and the [launch environment](#launching-a-run): - -- `claude --version`, read as `major.minor.patch`. Below `MIN_VERSION`, the lowest version the contract test passed on, the state is `too_old`. -- `claude auth status`, which prints JSON. `loggedIn: false` is `signed_out`; `authMethod` other than `"claude.ai"` is `signed_out` too, since a Console login is not a plan. Otherwise the state is `ready`, with `email`, `orgName` and `subscriptionType`. - -| State | Meaning | Dialog says | -|---|---|---| -| `not_installed` | no binary found | install Claude Code | -| `too_old` | version below `MIN_VERSION` | update Claude Code | -| `signed_out` | no claude.ai login | run `claude` and sign in | -| `ready` | signed in to a plan | connected as `email` · plan | - -The status is read when the dialog opens, on **Check again**, and before each run that starts a new Claude Code process. Nothing polls. - -## The connection - -- `ProviderConnection.auth_kind` gains `'claude_plan'`. Revision `0031` widens the `CHECK` in [`models.py`](../../../surfsense_local/backend/modules/llm/models.py) and adds one nullable JSON column, `local_client`. `api_key_ciphertext` and `oauth_ciphertext` stay `NULL`, `provider` stays `openai_compatible` read as "remote connection" ([ADR 0038](../../adr/0038-chatgpt-plans-sign-in-through-openai-not-codex.md)), `base_url` is `https://api.anthropic.com`, `catalog_provider` is `anthropic`. -- `local_client` holds what was last read: the binary's path, its version, the account's email and plan, and the [model list](#models). It is a cache for the screen and the pickers; the status is read fresh before a run. -- [`serves.py`](../../../surfsense_local/backend/modules/llm/connections/serves.py) gains `"claude_plan": (TEXT_GEN,)` in step 2 of the [build order](README.md#build-order) and the agent's slot in step 4. -- One connection per install. A second **Use your Claude plan** opens the existing one. -- Deleting the connection deletes the row. Claude Code's login is untouched; signing out of Claude is the user's, in Claude Code. - -## Routes - -Mounted beside the ChatGPT routes, before the connections router: - -| Route | Does | -|---|---| -| `GET /llm/connections/claude` | the status, the `serves` a plan would fill, and `hosts` still to allow (`api.anthropic.com` until allowed) | -| `POST /llm/connections/claude/check` | reads the status again and, when a connection exists, updates its hints | -| `POST /llm/connections/claude` | creates the connection; `409` when one exists, `422` with the state when not `ready`; refused with `egress_disabled` until `host:api.anthropic.com` is on | - -Listing, renaming and deleting go through the existing connection routes. `_read` in the connections router reports the email and plan for a `claude_plan` row as it reports `signed_in` and `email` for a ChatGPT one. - -## Models - -`client.get_server_info()["models"]` lists what the account's Claude Code offers: `value` (`opus`, `sonnet`, `haiku`, `claude-fable-5-1[1m]`), `displayName`, `resolvedModel` and supported effort levels. `default` is dropped, since it only names another entry. The list is read when the connection is created and on **Check again**, and cached on the row; `connection_models` in [`listing.py`](../../../surfsense_local/backend/modules/llm/connections/listing.py) returns it as `DiscoveredModel`s with source `declared`. A model is selected by its `value`, the alias Claude Code resolves to the current release. - -## Launching a run - -`subscriptions/claude/launch.py` is the only code that builds `ClaudeAgentOptions`. Chat and the agent pass what differs (tools, MCP servers, hooks, prompt, `cwd`); `launch.py` adds, and callers cannot override: - -| Option | Value | Why | -|---|---|---| -| `cli_path` | the located binary | with none set, the SDK runs its bundled copy before `PATH` | -| `setting_sources` | `[]` | the user's `~/.claude` settings, hooks, CLAUDE.md, skills and plugins never apply | -| `strict_mcp_config` | `True` | only the MCP servers SurfSense passes | -| `verbatim_prompts` | `True` | `@path` and `/command` in a user's message are text, not a file read or a command | -| `permission_mode` | `"default"` | 2.1.301 defaults to `auto`, which approves tools before `can_use_tool` runs | -| `env["ANTHROPIC_API_KEY"]` | `""` | a key in the API's environment silently replaces the plan | -| `env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"]` | `"1"` | no telemetry or error-reporting hosts | -| `env["HTTPS_PROXY"]`, `env["HTTP_PROXY"]` | SurfSense's proxy | [Network](#network) | -| `env["NO_PROXY"]` | `127.0.0.1,localhost` | the agent's MCP servers on loopback | - -`CLAUDE_CONFIG_DIR` is not set: Claude Code's login lives under the user's own config directory, and a separate one signs out ([Measurements](README.md#measurements)). - -## Network - -Claude Code makes its own HTTPS calls, which no `egress.require()` in the API would see. So every run's proxy variables point at the API's [plugin proxy](../../../surfsense_local/backend/modules/agent/plugin_proxy/), which tunnels `CONNECT api.anthropic.com:443` only when `host:api.anthropic.com` is enabled and refuses every other host. Measured, Claude Code honours `HTTPS_PROXY` and, with non-essential traffic off, asks for nothing but `api.anthropic.com`. - -A refused tunnel does not fail the request: Claude Code retries up to 10 times, the first after about a second, reporting each as a `system` message of subtype `api_retry`. The proxy records each refusal against the run it was opened for, and on the first `api_retry` after one the run is interrupted and ends with `egress_disabled` naming `api.anthropic.com`, the same refusal every other model call gives ([egress](../../architecture/egress.md#asking-for-consent)). - -Electron starts the plugin proxy only where opencode is staged. Whether that becomes "always" or the chat path gets a proxy of its own is decided in step 2. - -## Errors - -`subscriptions/claude/errors.py` maps what Claude Code reports onto the existing provider errors in [`openai_responses/errors.py`](../../../surfsense_local/backend/modules/llm/providers/openai_responses/errors.py), which move to a provider-neutral module in the same change: - -| Claude Code reports | Raised | The user sees | -|---|---|---| -| `AssistantMessage.error == "authentication_failed"` | `SignInRequiredError` | `subscription_sign_in`: run `claude` and sign in | -| `RateLimitEvent` with `status` not `allowed` | `PlanLimitError`, with `resetsAt` | `subscription_limit`, with the reset time | -| binary missing or not executable at launch | `SignInRequiredError` with `reason: not_installed` | the connection's not-found state | -| version below `MIN_VERSION` at launch | the same, `reason: too_old` | update Claude Code | -| refused tunnel then `api_retry` | `EgressDeniedError` | the egress prompt | -| any other `ResultMessage.is_error` | `ProviderError` | `unknown` | - -[`chat/errors.py`](../../../surfsense_local/backend/modules/chat/errors.py), the model route's failure frames and the agent's error kinds already map the first two. - -## Packaging - -The Python `claude-agent-sdk` wheel carries Claude Code as `claude_agent_sdk/_bundled/claude`, 241 MB, and the SDK prefers it over `PATH` whenever `cli_path` is unset. SurfSense ships none: - -- [`bundling/api.spec`](../../../surfsense_local/backend/bundling/api.spec) excludes `claude_agent_sdk/_bundled`. -- A packaging test fails when any `claude` binary is inside the frozen API. -- `launch.py` refuses to build options without a located `cli_path`, so a development venv, which does carry `_bundled`, behaves as the packaged app does. - -`claude-agent-sdk` is pinned exactly in [`pyproject.toml`](../../../surfsense_local/backend/pyproject.toml): it and the user's Claude Code move independently, and the contract test runs the pinned SDK against the oldest and newest Claude Code accepted. diff --git a/docs/proposals/claude-plan/02-chat.md b/docs/proposals/claude-plan/02-chat.md deleted file mode 100644 index 69bfd7d63d..0000000000 --- a/docs/proposals/claude-plan/02-chat.md +++ /dev/null @@ -1,60 +0,0 @@ -# Chat on a Claude plan - -> `ClaudePlanProvider` implements the [`Generator`](../../../surfsense_local/backend/modules/llm/providers/protocols.py) protocol over Claude Code run with no tools and one turn per answer, so chat, titles and Studio use a Claude plan model exactly as they use any other. It runs only in the API process ([ADR 0048](../../adr/0048-the-api-is-the-only-path-to-a-text-model.md)); the workers reach it through `/internal/models/text/generate` as they reach every model. - -Part of [Use your Claude plan](README.md). Launch options, errors and network are in [`01-connection.md`](01-connection.md). - -## Wiring - -- [`text_generator.py`](../../../surfsense_local/backend/modules/llm/connections/text_generator.py): `connection_generator()` returns `ClaudePlanProvider(connection)` for `auth_kind == "claude_plan"`, beside the ChatGPT branch. -- [`listing.py`](../../../surfsense_local/backend/modules/llm/connections/listing.py): `connection_models()` returns the cached [model list](01-connection.md#models). -- [`call_route.py`](../../../surfsense_local/backend/modules/llm/connections/call_route.py) is not consulted: a Claude plan has no HTTP route of SurfSense's. - -## One answer - -`chat_deltas(model, messages, *, max_tokens, temperature, reasoning, json_schema, conversation)` launches through `launch.py` with: - -| Option | Value | -|---|---| -| `model` | the model's `value`, such as `sonnet` | -| `tools` | `[]` ([ADR 0052](../../adr/0052-the-chat-model-never-calls-tools.md)) | -| `max_turns` | `1` | -| `system_prompt` | the first `system` message, whole. Claude Code's own prompt is not sent: 652 tokens were cached on a first request, against Claude Code's tens of thousands | -| `include_partial_messages` | `True`, so text streams | -| `extra_args` | `{"no-session-persistence": None}`: nothing written under `~/.claude/projects` | -| `cwd` | an empty directory under SurfSense's data folder, so no project file is near | -| `thinking`, `effort` | `reasoning` on: `{"type": "adaptive", "display": "summarized"}`; off: no `display`, so thinking is omitted. Thinking streams only with `summarized` | - -Each `content_block_delta` of type `text_delta` becomes `Delta(text)`, and of type `thinking_delta` `Delta(text, reasoning=True)`. `chat()` drops the reasoning, as the ChatGPT provider does. The stream keeps the existing deadlines (`with_deadlines`): 300 seconds to the first token, 30 between. - -`max_tokens` and `temperature` are dropped: Claude Code exposes neither. `json_schema` maps to the SDK's `output_format`, which is untested and listed under [Open questions](#open-questions); until it is tested, a caller asking for a schema on a Claude plan model gets the same refusal a ChatGPT plan gave before [ADR 0050](../../adr/0050-a-model-answers-on-its-own-route-with-its-connections-credential.md). - -## History - -The protocol is stateless: every call carries the whole conversation. A Claude Code client, once open, keeps its own. Both are honoured: - -- **A new conversation, or one that changed.** The earlier turns go into the prompt as text, tagged by role, with the last user message after them. Measured: a remembered fact from two turns back answered correctly in 1.4 s. -- **The same conversation, one user message longer.** When `conversation` names an open client whose last call's messages are a prefix of this call's, only the new user message is sent. Measured: 0.9 s to the first token, against 2.3 s for a new client. - -Anything else (an edited message, a retry, a regenerated reply, a changed system prompt) closes that client and starts again from text. A digest of the messages last sent is kept with the client to tell these apart. - -## The client pool - -Clients are kept per `conversation` key, closed after five minutes idle, and capped at four; the least recently used goes first. Three conversations at once took 1.7 s wall. A call with no `conversation` (a title, a Studio step) opens a client, answers and closes it. The pool closes every client when the API stops. - -## The rest of the protocol - -| Member | Answer | -|---|---| -| `name` | `"claude_plan"` | -| `health()` | the [status](01-connection.md#status); `ready` is healthy | -| `models()` | the cached model list | -| `context_tokens(model)` | the remote manifest's window for `resolvedModel` under `anthropic`; a `[1m]` value is 1,000,000 | -| `sees_images(model)` | `None` until image input through the SDK is tested | -| `token_count(model, text)` | `None` | - -## Open questions - -- Structured output through `output_format`, which Studio's workflows need for a schema. -- Image input: whether a streamed user message with `image` content blocks reaches the model unchanged. -- Whether one warm client can answer an unrelated call in a new session (`query(session_id=…)`), which would let titles skip the cold start. diff --git a/docs/proposals/claude-plan/03-agent.md b/docs/proposals/claude-plan/03-agent.md deleted file mode 100644 index 17e2e4e6b5..0000000000 --- a/docs/proposals/claude-plan/03-agent.md +++ /dev/null @@ -1,108 +0,0 @@ -# The agent on a Claude plan - -> A Claude plan reaches the user only through Claude Code, and Claude Code runs its own agent loop: it cannot be one more model behind opencode's provider. So an agent turn on a Claude plan model runs Claude Code as a second engine, in the API process, behind an engine interface that opencode moves behind first. SurfSense keeps what makes the agent SurfSense's: the thread folder, its tools and plugins, its permission rules, its frames and its approval screen. - -Part of [Use your Claude plan](README.md). How opencode runs today is in [agent](../../architecture/agent.md). - -## Why not opencode - -opencode reaches a model through an HTTP provider, and every model reaches it through SurfSense's model endpoint ([agent](../../architecture/agent.md#the-model-endpoint)). A Claude plan has no HTTP endpoint SurfSense may call: the only way in is Claude Code. Community providers that wrap Claude Code for the AI SDK exist, but they ignore the caller's tools ("AI SDK tools passed to `generateText`/`streamText` via the `tools` option are ignored", [ai-sdk-provider-claude-code](https://github.com/ben-vargas/ai-sdk-provider-claude-code)) and leave permissions to Claude Code, so opencode would only display a loop it does not run. - -## The engine interface - -`modules/agent/engines/` gets a protocol with the five things the chat router asks of the agent today, and opencode's existing code becomes its first implementation with no change in behaviour (step 3 of the [build order](README.md#build-order)): - -| Method | opencode today | -|---|---| -| `open_session(thread)` | [`open_session.py`](../../../surfsense_local/backend/modules/agent/agent_threads/open_session.py) | -| `run_turn(thread, message, model, scope) -> frames` | `agent_turn` in [`turn.py`](../../../surfsense_local/backend/modules/agent/agent_threads/turn.py) | -| `thread_messages(thread)` | `agent_thread_messages` | -| `answer_permission(thread, request_id, verdict)` | the `/chat/threads/{id}/permissions/{request_id}` route | -| `stop(thread)`, `forget(thread)` | the run's abort and `forget_thread` | - -[`ChatThread`](../../../surfsense_local/backend/modules/chat/models.py) gains `agent_engine` (`opencode` or `claude_code`, `NULL` for a chat thread), `claude_session_id`, and `claude_account`, the Claude Code account that session was made under. `opencode_session_id` stays. A thread that has switched engines holds both ids ([`04-handover.md`](04-handover.md)). - -Before each turn, the selected model's connection picks the engine: `claude_plan` is `claude_code`, anything else `opencode`. When it differs from `agent_engine`, the handover runs and `agent_engine` changes. - -[`engine_choice.py`](../../../surfsense_local/backend/modules/agent/engine_choice.py): a Claude plan model passes the technical gates when its connection's status is `ready`. It needs no opencode staged, states tool calls, and every listed model's window is above 32,768 tokens. - -## A turn - -The thread's folder is synced to its sources and the scope note written exactly as for opencode ([agent](../../architecture/agent.md#the-ticked-sources)). Then `launch.py` starts, or resumes, a client with: - -| Option | Value | -|---|---| -| `model` | the model's `value` | -| `cwd` | the thread's working folder (`thread_working_dir`) | -| `tools` | `["Read", "Glob", "Grep", "Write", "Edit"]`. No `Bash` ([ADR 0039](../../adr/0039-document-scripts-run-without-approval.md)), no `WebFetch` or `WebSearch`, no `Task` subagents, no `Skill`, no `NotebookEdit` | -| `mcp_servers` | `surfsense`: the thread's [tool endpoint](../../../surfsense_local/backend/modules/agent/tool_endpoint/) as `{"type": "http", "url": …, "headers": {"Authorization": "Bearer "}}`; and each ready plugin under its id, with its sign-in header, as [`give_plugins.py`](../../../surfsense_local/backend/modules/agent/plugin_tools/give_plugins.py) builds them | -| `system_prompt` | SurfSense's agent prompt. The scope note and any handover note travel beside the user's message ([`04-handover.md`](04-handover.md#where-the-note-goes)) | -| `hooks` | `PreToolUse`: [the gate](#the-gate) | -| `can_use_tool` | the gate again | -| `thinking` | `{"type": "adaptive", "display": "summarized"}` when the thread's thinking switch is on | -| `include_partial_messages` | `True` | -| `session_store` | SurfSense's store ([Sessions](#sessions)) | -| `resume` | `claude_session_id`, after the first turn | - -The plugin steps before a turn (sign-ins, the license, changed tools, the servers) run unchanged ([agent](../../architecture/agent.md#plugins)); what they produce is passed as `mcp_servers` and to the gate, instead of to opencode's session. The proxy allows `api.anthropic.com` and each ready plugin's hosts, as `may_connect` already decides for opencode. - -Measured: Claude Code connected to an HTTP MCP endpoint behind a bearer key and every call carried it; the init message listed only the passed tools. - -## The gate - -Claude Code 2.1.301 starts in `auto` permission mode, and in `auto` it approved and ran an MCP `delete_note` without calling `can_use_tool`. `launch.py` therefore forces `permission_mode="default"`, and the decision lives in a `PreToolUse` hook, which ran before every call in every mode measured. `can_use_tool` applies the same rules, so a future mode that skips one still meets the other. - -The rules are opencode's ([`opencode_config.py`](../../../surfsense_local/backend/modules/agent/opencode_config.py) `_permission()`, and the per-session plugin rules), read from the same sources: - -| Tool | Rule | -|---|---| -| `Read`, `Glob`, `Grep` | allow inside the thread's folder and the skills folder; deny `AGENTS.md`, `CLAUDE.md`, `.opencode/` and anything outside | -| `Write`, `Edit` | allow under the thread's `outputs/`; deny everything else | -| `mcp__surfsense__*` | allow | -| `mcp____` | the gateway's `allow` or `ask` for a ready tool; deny a tool switched off, unreviewed or on a server not given this turn | -| anything else | deny | - -A path is resolved against `cwd` with symlinks followed before it is compared. Measured with these rules: `sources/plan.txt` read and `outputs/summary.md` written; `../escaped.txt` and `/tmp/escaped2.txt` refused; a `CLAUDE.md` placed in the folder not loaded, since `setting_sources` is `[]`. - -**Ask.** The hook emits a `permission-request` frame (`id`, `permission` as the tool's display name, `patterns`, `call_id` from the hook's `tool_use_id`), sets the run to `needs-approval`, and awaits the answer from `answer_permission`: `once` returns `permissionDecision: "allow"`, `reject` returns `deny` with "The user rejected this action." Never "always" ([ADR 0028](../../adr/0028-model-written-code-runs-with-approval.md)). Measured: a hook that awaited an answer 1 to 1.5 s later held the call, and a rejected call never reached the server. - -## Frames - -`engines/claude_code/frames.py` turns SDK messages into the frames [`turn_frames.py`](../../../surfsense_local/backend/modules/agent/agent_threads/turn_frames.py) emits for opencode, so the frontend changes nothing: - -| SDK message | Frame | -|---|---| -| `system` `init` | `accepted` | -| `content_block_delta` `text_delta` | `delta` | -| `content_block_delta` `thinking_delta`, then the block's stop | `reasoning`, `reasoning-end` | -| `content_block_start` `tool_use`, `AssistantMessage` `ToolUseBlock` | `agent-step`, `running`, with `call_id` | -| `UserMessage` `ToolResultBlock` | `agent-step`, `completed` or `error`, `output` up to 4,000 characters | -| the gate asking | `permission-request`; its answer `permission-replied` | -| `ResultMessage` success | `completed`, with the reply's text joined and citations resolved as for opencode | -| `ResultMessage` error, `AssistantMessage.error` | `error`, kind from [`01-connection.md`](01-connection.md#errors) | -| `RateLimitEvent` not `allowed` | `error`, `subscription_limit` | - -A tool's name maps back to what a step shows: `mcp__surfsense__render_document` is `surfsense_render_document`, and `mcp____` reads as the plugin's name and the tool's title, as `steps.py` does for opencode's `_`. - -## Stop - -Stop calls `interrupt()` and records `stopped` against the reply in SurfSense's store, as Stop notes itself in opencode's session metadata today ([chat](../../architecture/chat.md#how-an-agent-reply-ended)). Measured: `interrupt()` returned in 0.01 s, the turn ended `error_during_execution`, and the same client answered the next message. Quit closes every client. - -## Sessions - -Claude Code keeps a session's transcript as JSONL under `~/.claude/projects//.jsonl`. SurfSense mirrors it through the SDK's `SessionStore` protocol into its own table, `claude_session_entries` (session id, entry uuid, order, JSON), so: - -- **Listing** reads the thread from SurfSense's table, not from `~/.claude`: each user turn, then one reply with `content.text`, `content.steps` and `content.citations`, as opencode's listing returns. -- **Reopening** a thread resumes `claude_session_id` with the store. Measured: resuming from a store after the local file was deleted remembered the earlier turn. -- **Deleting** stops the run, deletes the thread's entries and its local transcript file. - -Persistence stays on for agent sessions: with `--no-session-persistence`, resume found no conversation even with a store. - -Claude Code compacts a full window itself. Its compaction boundary is folded into the turn it happened in, as [`compaction.py`](../../../surfsense_local/backend/modules/agent/agent_threads/compaction.py) folds opencode's; this is untested. - -## Open questions - -- SurfSense's skills (documents, data, PDF) reach opencode as a skills folder. Whether the SDK's `plugins` option with a local path carries them to Claude Code unchanged, or the engine gives their instructions in the system prompt. -- Claude Code's compaction message in a long thread, and whether its summary leaks into the reply. -- Images the agent is shown ([agent](../../architecture/agent.md#image-input)): `Read` of an image file, untested. -- Whether local transcripts are deleted once mirrored, or kept as Claude Code's own history. diff --git a/docs/proposals/claude-plan/04-handover.md b/docs/proposals/claude-plan/04-handover.md deleted file mode 100644 index 79fac17940..0000000000 --- a/docs/proposals/claude-plan/04-handover.md +++ /dev/null @@ -1,72 +0,0 @@ -# Switching engines inside a thread - -> An agent thread keeps working after a model change today because opencode holds its session for every model ([agent](../../architecture/agent.md#a-turn)). A Claude plan model runs on Claude Code, which holds a session of its own. When the selected model moves a thread from one engine to the other, the engine taking over is given a handover note: the thread's history since that engine last saw it, written as text and sent beside the user's message. The user switches models as freely as before. - -Part of [Use your Claude plan](README.md). [T3 Code](https://github.com/pingdotgg/t3code/blob/main/docs/orchestration-v2/provider-switching-and-context.md) and [OpenClaw](https://docs.openclaw.ai/gateway/cli-backends) solve the same problem the same way: resume the engine's own session, and tell it what happened while it was away. - -## The problem - -An agent thread has two memories. The screen shows the user's messages and the replies. The engine also remembers every tool it called, what came back, and what it read. That second memory lives in the engine's session: opencode's database, or Claude Code's transcript. A model on the other engine starts with neither, though the screen still shows everything. - -## Rejected: one engine per thread - -A thread could keep the engine it started with and refuse a model on the other one with "start a new thread", as [Zed](https://zed.dev/docs/ai/agent-panel) does for its external agents. It is simpler, but a Claude plan model would be the only model a user cannot switch to mid-thread, and a plan running out mid-task would strand the thread. - -## The session the new engine runs in - -Before the first turn on an engine, `engines/handover.py` picks one of three, in this order: - -| Case | Session | Note covers | -|---|---|---| -| The engine has never run this thread | new | every turn so far | -| The engine ran it before, and its session is fit to resume | the engine's own, resumed | only the turns since it was left | -| The engine ran it before, and its session is not fit | new | every turn so far | - -A session is **not fit** when: - -- **resuming fails**: opencode no longer has it, or Claude Code answers "No conversation found"; -- **the account changed**: the Claude Code account `claude auth status` reports now is not the one the session was made under, which is stored with `claude_session_id`. OpenClaw "drops stored CLI session reuse when the selected auth identity changes" for the same reason; -- **it is stale**: the thread has left that engine three times or more since its session last ran a turn, so its own memory is mostly notes. - -## The note - -Built from the engine being left: opencode's session read as the listing reads it, or SurfSense's mirrored Claude entries ([`03-agent.md`](03-agent.md#sessions)). It holds, oldest first: - -1. Each user message, as the user wrote it, without the scope note. -2. Each reply's text. -3. Each tool call on one line: its name as the step shows it, its input, and its output cut to 2,000 characters. -4. The files under the thread's `outputs/` now, which both engines share since both work in the same folder. - -Left out: - -- **Reasoning.** Neither engine's thinking is passed on. -- **Stale output.** A tool output that showed a file's contents is dropped when a later call rewrote that file; the note names the file, and the current version is on disk. - -**Too long.** The note is fitted to the new model's window less room for the turn: the newest turns kept whole, older tool outputs dropped first, then older replies cut to their first paragraph, then the oldest turns summarised in one line each. - -## Where the note goes - -Beside the user's message, never in a system prompt: opencode's is one configuration for every thread, and a resumed Claude Code session keeps the one it started with. - -- **opencode**: a second text part marked `synthetic` on `prompt_async`, the way the scope note travels ([agent](../../architecture/agent.md#the-ticked-sources)). -- **Claude Code**: a separate text block before the user's text in the same user message. - -Both start with the tag ``, so reading the thread back strips them from the user's text, as the scope note is stripped. - -Measured: Claude Code given a note of an opencode thread answered "Revenue grew 12% in Q3. The file saved was outputs/q3.md" without calling a tool. A resumed Claude Code session given a note as a separate block knew both what it had been told itself and what the note said, and its transcript kept the note as its own block. A Claude Code session read back gave every user and assistant message in order. - -## Keeping the note - -Each note is stored with the turn that used it: the turn's id, the engine it was for, the turns it covers, whether it went to a new or resumed session, and its text. A later note never edits an earlier one. This is what support reads when a model "forgot" something, and what a later "Continued from another model" marker in the thread would read. - -## Rejected: a history tool - -A `surfsense_thread_history` tool that returns earlier turns and full tool results on request, as OpenClaw's `sessions_history` does, is not built. What a note leaves out can already be had again: files from `outputs/`, a source by searching or reading it, a plugin's result by calling it. What cannot is a cut plugin result whose data changed, took minutes, or had side effects. If that shows up in use, the full output of each cut tool call is written to `handover/.txt` in the thread's folder and the note names the file: both engines already read the folder, so no new tool, scope rule or permission is needed. - -## What changes for the user - -Nothing to do. The first message after a switch takes a little longer and uses a little more of the plan, because the note is sent with it. The new engine knows a tool was called and roughly what it returned, not every byte; it may call it again. - -## The thread record - -`agent_engine` names the engine the thread runs on now. `opencode_session_id` and `claude_session_id` stay set once made, with the Claude account beside the latter. `thread_messages` merges both sessions' turns by time, so the screen shows one thread. diff --git a/docs/proposals/claude-plan/05-dialog.md b/docs/proposals/claude-plan/05-dialog.md deleted file mode 100644 index 157906737b..0000000000 --- a/docs/proposals/claude-plan/05-dialog.md +++ /dev/null @@ -1,39 +0,0 @@ -# The dialog - -> **Use your Claude plan** sits beside **Use your ChatGPT plan** in Settings › Add model and in onboarding's model step. Clicking it opens a nested dialog that explains the feature and walks the user to a ready connection. SurfSense cannot sign the user in, so the dialog's job is to say what Claude Code needs and to check it again on request. - -Part of [Use your Claude plan](README.md). - -## Where it lives - -`frontend/src/features/models/remote/connections/claude/`, a sibling of [`chatgpt/`](../../../surfsense_local/frontend/src/features/models/remote/connections/chatgpt/): - -| 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** posts `…/check` | -| `api.ts` | the three routes ([`01-connection.md`](01-connection.md#routes)) | - -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 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. - -| 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** | - -**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. - -**Add Claude models** posts the connection. Refused with `egress_disabled`, it raises the usual egress prompt for `api.anthropic.com` nested over the dialog ([egress](../../architecture/egress.md#asking-for-consent)), and posts again on Allow. 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. - -## Names - -The interface says "Claude plan" and "Use your Claude plan", and names Claude Code only as the program the user installs and signs in to. SurfSense's own feature is never called "Claude Code" ([branding](https://code.claude.com/docs/en/agent-sdk/overview#branding-guidelines)). diff --git a/docs/proposals/claude-plan/README.md b/docs/proposals/claude-plan/README.md deleted file mode 100644 index c65d9ee169..0000000000 --- a/docs/proposals/claude-plan/README.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -status: accepted -code: - - surfsense_local/backend/modules/llm/subscriptions/claude/ - - surfsense_local/backend/modules/llm/connections/ - - surfsense_local/backend/modules/llm/models.py - - surfsense_local/backend/modules/agent/engines/ - - surfsense_local/backend/modules/agent/agent_threads/ - - surfsense_local/backend/modules/agent/plugin_proxy/ - - surfsense_local/backend/modules/chat/ - - surfsense_local/backend/alembic/versions/ - - surfsense_local/backend/bundling/api.spec - - surfsense_local/backend/pyproject.toml - - surfsense_local/frontend/src/features/models/remote/connections/claude/ - - surfsense_local/frontend/src/features/models/add-model/ - - surfsense_local/frontend/src/features/onboarding/model-step/ ---- - -# Use your Claude plan - -> A Claude Pro, Max or Team subscriber picks **Use your Claude plan** and gets Claude's models in chat and in the agent, paid for by their plan. SurfSense never signs in to Claude and never holds a Claude credential: it drives the user's own installed, unmodified Claude Code through Anthropic's [Agent SDK](https://code.claude.com/docs/en/agent-sdk/overview), and Claude Code keeps the sign-in. SurfSense ships no copy of Claude Code. Chat runs Claude Code with every tool off. The agent runs it as a second engine beside opencode, with SurfSense's tools over MCP and SurfSense's permission rules enforced in a `PreToolUse` hook. - -This reverses one line of [ADR 0038](../../adr/0038-chatgpt-plans-sign-in-through-openai-not-codex.md), "A Claude subscription is not offered; Claude stays reachable with an API key", and adds an exception to [ADR 0050](../../adr/0050-a-model-answers-on-its-own-route-with-its-connections-credential.md), "SurfSense owns a plan's sign-in". [ADR 0057](../../adr/0057-a-claude-plan-runs-through-the-users-own-claude-code.md) records both ([Decisions](#decisions-this-records)). The ChatGPT plan it sits beside is in [ChatGPT subscription](../../architecture/chatgpt-subscription.md). - -Every behaviour of Claude Code stated here was measured on 16 Oct 2026 on macOS arm64, against Claude Code `2.1.301` (native installer) signed in to a Max plan, with the Python `claude-agent-sdk` `0.2.168` ([Measurements](#measurements)). Windows and Linux are untested and listed under [Open questions](#open-questions). - -## Parts - -| Part | File | Owns | -|---|---|---| -| **Connection** | [`01-connection.md`](01-connection.md) | finding Claude Code, its status, the `claude_plan` connection, how every run is launched, network, errors, packaging | -| **Chat** | [`02-chat.md`](02-chat.md) | a `Generator` over Claude Code with no tools | -| **Agent** | [`03-agent.md`](03-agent.md) | an engine interface, the Claude engine, the permission gate, frames, sessions | -| **Handover** | [`04-handover.md`](04-handover.md) | switching an agent thread between a Claude plan model and any other | -| **Dialog** | [`05-dialog.md`](05-dialog.md) | the nested dialog behind **Use your Claude plan** | - -## What changes - -| | Today | With this | -|---|---|---| -| Claude in SurfSense | an Anthropic API key connection only | also a `claude_plan` connection, billed to the user's plan | -| Credential SurfSense holds for it | the API key | none | -| Claude Code | never run | the user's own install, found on disk, run per conversation; never shipped, downloaded or updated by SurfSense | -| Chat engine | providers over HTTP | adds `ClaudePlanProvider`, Claude Code with `tools=[]` | -| Agent engines | opencode | opencode, and Claude Code for a thread on a Claude plan model | -| Engine choice | a thread is the agent's or the chat's | an agent thread also records which engine runs it, set by the selected model's connection | -| Model switch in an agent thread | opencode carries the session | the same, unless the switch crosses engines: then a handover note | -| Network | `host:api.anthropic.com` for a key connection | the same destination; Claude Code reaches it only through SurfSense's proxy | -| Installer | unchanged | unchanged: the SDK's bundled Claude Code (241 MB) is excluded | - -## How the user sees it - -1. In **Settings › Add model** or onboarding's model step, the user picks **Use your Claude plan**. A nested dialog opens ([`05-dialog.md`](05-dialog.md)). -2. The dialog shows what SurfSense found: Claude Code not installed, too old, signed out, or ready with the account's email and plan. Each state says the one thing to do, and **Check again** re-reads it. Signing in happens in the user's terminal, in Claude Code. -3. **Add Claude models** asks for `api.anthropic.com` through the usual egress prompt, closes the dialog, and lists Claude Code's models in every picker. -4. A Claude plan model then works in Basic and Agentic threads like any other model. The user never picks an engine. -5. A used-up plan, a signed-out Claude Code or a missing install shows the existing `subscription_limit` and `subscription_sign_in` notices, or the connection's not-found state. - -## Decisions this records - -| Decision | Choice | -|---|---| -| How SurfSense reaches a Claude plan | Through the user's installed Claude Code, via the Agent SDK. No OAuth client, token, `setup-token` or Keychain read of SurfSense's own. | -| Bundling | None. The SDK's `_bundled/claude` is excluded from the build, and every run names the user's binary in `cli_path` ([`01-connection.md`](01-connection.md#packaging)). | -| Where the sign-in lives | In Claude Code, in the user's own `~/.claude`. A separate `CLAUDE_CONFIG_DIR` loses it ([Measurements](#measurements)). | -| Chat | Claude Code with `tools=[]` and one turn, behind the `Generator` protocol. [ADR 0052](../../adr/0052-the-chat-model-never-calls-tools.md) holds. | -| Agent | A second engine beside opencode, behind a new engine interface. Claude Code's file tools on the thread folder; SurfSense's tools and the thread's plugins over MCP; Bash, web and subagents off. | -| Permissions | SurfSense's rules decide every tool call in a `PreToolUse` hook, with `can_use_tool` as a second gate and `permission_mode="default"` forced, since Claude Code 2.1.301 defaults to `auto` and then skips `can_use_tool` ([`03-agent.md`](03-agent.md#the-gate)). | -| Switching models mid-thread | Allowed. Crossing engines writes a handover note ([`04-handover.md`](04-handover.md)). One engine per thread was rejected. | -| Network | Claude Code's `HTTPS_PROXY` is SurfSense's proxy, which tunnels `api.anthropic.com` only, after `egress.require()`. Non-essential traffic is off. | -| Name | "Claude plan" and "Use your Claude plan". Never "Claude Code" in the interface ([branding](https://code.claude.com/docs/en/agent-sdk/overview#branding-guidelines)). | - -[ADR 0057](../../adr/0057-a-claude-plan-runs-through-the-users-own-claude-code.md) records: a Claude plan is offered through the user's own Claude Code and SurfSense holds no Claude credential, replacing ADR 0038's last consequence; and ADR 0050's "SurfSense owns a plan's sign-in for both engines" holds for the ChatGPT plan only. - -## Anthropic's terms - -The maintainers own this decision. What the sources say on 16 Oct 2026: - -- **Against a SurfSense sign-in.** [Legal and compliance](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use): "Anthropic does not permit third-party developers to offer Claude.ai login into their own applications, or to route requests through Free, Pro, or Max plan credentials on behalf of their users", and developers "may not collect, store, or intermediate Claude.ai credentials or session tokens". The [Agent SDK overview](https://code.claude.com/docs/en/agent-sdk/overview): "Unless previously approved, Anthropic does not allow third party developers to offer claude.ai login or rate limits for their products, including agents built on the Claude Agent SDK." -- **For the user's own Claude Code.** The same legal page does not prevent "an end user from signing in to the unmodified Claude Code binary with their own Claude subscription". [Use the Claude Agent SDK with your Claude plan](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan), updated 7 Oct 2026: users "can still use the Claude Agent SDK, `claude -p`, and third-party apps with your subscription limits". -- **Shipping Claude Code** in a product requires the Commercial Terms and an unmodified binary ([legal and compliance](https://code.claude.com/docs/en/legal-and-compliance#can-customers-offer-claude-code-in-their-products)). This proposal ships none. -- **Precedent.** [OpenClaw](https://docs.openclaw.ai/providers/anthropic) runs the user's installed Claude Code and "never reads, persists, refreshes, selects, or forwards the native login tokens"; this design follows it. opencode removed its own Claude sign-in "per legal requests" ([anomalyco/opencode#18186](https://github.com/anomalyco/opencode/pull/18186), merged 19 Mar 2026). Hermes Agent reads Claude Code's credentials and calls the API itself, and its docs say that path draws only on extra-usage credits, not the plan ([Hermes providers](https://hermes-agent.nousresearch.com/docs/integrations/providers)). - -Anthropic changed this policy in January, February, April and June 2026, and may enforce "without prior notice". The feature is built so that removing it is deleting the `claude_plan` connection kind and the Claude engine, nothing else. - -## Build order - -1. This proposal and [ADR 0057](../../adr/0057-a-claude-plan-runs-through-the-users-own-claude-code.md). -2. **Connection and chat**: [`01-connection.md`](01-connection.md), [`02-chat.md`](02-chat.md), [`05-dialog.md`](05-dialog.md). A Claude plan serves `text_gen` only until step 4. -3. **The engine interface**, with opencode moved behind it and no change in behaviour ([`03-agent.md`](03-agent.md#the-engine-interface)). -4. **The Claude engine and the handover** ([`03-agent.md`](03-agent.md), [`04-handover.md`](04-handover.md)); `serves` gains the agent. -5. Windows and Linux: install locations and the proxy. - -## Measurements - -Scripts kept out of the repo; each line is one run on the machine above. - -| What | Result | -|---|---| -| Finding `claude` with a GUI app's PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) | not on PATH; found at `~/.local/bin/claude`, the native installer's link | -| `claude auth status` | JSON: `loggedIn`, `authMethod: "claude.ai"`, `email`, `subscriptionType: "max"`; with an empty `CLAUDE_CONFIG_DIR`, `loggedIn: false` | -| `ANTHROPIC_API_KEY` set in the environment | Claude Code uses it in place of the plan, and its init message names that variable as the key's source | -| Separate `CLAUDE_CONFIG_DIR` | first request fails `authentication_failed` | -| `--no-session-persistence` | no transcript written under `~/.claude/projects` | -| `get_server_info()["models"]` | `default`, `opus`, `claude-fable-5-1[1m]`, `sonnet`, `haiku`, each with `displayName`, `resolvedModel`, effort levels | -| Chat, `tools=[]`, own `system_prompt` | init reports `tools: []`; the model follows SurfSense's prompt; 652 tokens cached on the first request | -| Latency (haiku) | connect 1.0 s; first token 2.3 s cold, 0.9 s on a running client; history-as-text turn 1.4 s | -| Three conversations at once | 1.7 s wall | -| Thinking | streams `thinking_delta` only with `display: "summarized"` | -| Signed out | `AssistantMessage.error == "authentication_failed"` within 0.04 s | -| Plan usage | `RateLimitEvent`: `status`, `resetsAt`, five-hour and seven-day utilisation | -| HTTP MCP with an `Authorization` header | connected; every call carried the key | -| Default permission mode | `auto` on 2.1.301 (the VS Code extension's 2.1.300 ran `default`); `can_use_tool` never called and a delete ran unapproved | -| `permission_mode="default"` | `can_use_tool` asked; delete refused | -| Waiting `PreToolUse` hook, mode left `auto` | delete refused, on haiku and sonnet | -| File tools confined by the hook | read `sources/` and write `outputs/` allowed; writes to `../` and `/tmp` refused; a `CLAUDE.md` in the folder not loaded | -| `interrupt()` | returns in 0.01 s; the turn ends `error_during_execution`; the client takes the next query | -| Resume by session id in a new client | remembers the earlier turn | -| Resume from a `SessionStore` after deleting the local transcript | works; with `--no-session-persistence` it does not | -| Proxy tunnelling only `api.anthropic.com` | works; with non-essential traffic off, nothing else is attempted (without it, `http-intake.logs.us5.datadoghq.com`) | -| Proxy refusing `api.anthropic.com` | no error: `system` `api_retry` messages, first after about 1 s, up to 10 attempts | - -## Out of scope - -A SurfSense sign-in to Claude, reading or storing Claude Code's credentials, `claude setup-token`, shipping or downloading Claude Code, Claude Code's own Bash, web tools, subagents or skills in the agent, and the API credits Max and Team plans include, which already work through an API key connection ([API credits](https://platform.claude.com/docs/en/about-claude/api-credits-for-subscribers)). - -## Open questions - -- Windows and Linux: where installers put `claude`, whether `HTTPS_PROXY` is honoured the same way, and `claude.exe` versus npm's `claude.cmd` shim. -- The lowest Claude Code version to accept, and how often a contract test against the real binary runs. -- Whether a plan limit reached with Claude Code's extra usage switched on should say so; `overageStatus` is in the `RateLimitEvent`. -- Whether transcripts the agent engine writes under `~/.claude/projects` should be deleted once mirrored ([`03-agent.md`](03-agent.md#sessions)). diff --git a/surfsense_local/backend/alembic/versions/0031_claude_plan_connection.py b/surfsense_local/backend/alembic/versions/0031_claude_plan_connection.py new file mode 100644 index 0000000000..b56c5bb187 --- /dev/null +++ b/surfsense_local/backend/alembic/versions/0031_claude_plan_connection.py @@ -0,0 +1,76 @@ +"""Let a connection be a Claude plan, run through the user's own Claude Code. + +The only change is `auth_kind` accepting `claude_plan`. A Claude plan stores no +credential, and what Claude Code reports about itself is read live, so no +column is added. + +SQLite cannot alter a CHECK, so the table is rebuilt from its own stored +definition with only the check changed. Dropping `provider_connections` +cascades through `selected_models.connection_id`, and foreign keys cannot be +switched off inside the migration's transaction, so every remote selection is +kept aside and put back. + +Revision ID: 0031 +Revises: 0030 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "0031" +down_revision: str | Sequence[str] | None = "0030" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_WITHOUT = "CHECK (auth_kind IN ('api_key', 'chatgpt'))" +_WITH = "CHECK (auth_kind IN ('api_key', 'chatgpt', 'claude_plan'))" + + +def _rebuild(check_from: str, check_to: str) -> None: + connection = op.get_bind() + definition = connection.execute( + sa.text( + "SELECT sql FROM sqlite_master " + "WHERE type = 'table' AND name = 'provider_connections'" + ) + ).scalar_one() + head = 'CREATE TABLE "provider_connections"' + if definition.count(check_from) != 1 or not definition.startswith(head): + raise RuntimeError("provider_connections is not the table 0031 expects") + rebuilt = definition.replace(check_from, check_to).replace( + head, 'CREATE TABLE "provider_connections_0031"', 1 + ) + + op.execute( + sa.text( + "CREATE TEMP TABLE selections_0031 AS " + "SELECT * FROM selected_models WHERE connection_id IS NOT NULL" + ) + ) + op.execute(sa.text(rebuilt)) + op.execute( + sa.text( + "INSERT INTO provider_connections_0031 SELECT * FROM provider_connections" + ) + ) + op.execute(sa.text("DROP TABLE provider_connections")) + op.execute( + sa.text("ALTER TABLE provider_connections_0031 RENAME TO provider_connections") + ) + op.execute(sa.text("INSERT INTO selected_models SELECT * FROM selections_0031")) + op.execute(sa.text("DROP TABLE selections_0031")) + + +def upgrade() -> None: + _rebuild(_WITHOUT, _WITH) + + +def downgrade() -> None: + # The old check cannot hold a Claude plan; its selections go with it. + op.execute( + sa.text("DELETE FROM provider_connections WHERE auth_kind = 'claude_plan'") + ) + _rebuild(_WITH, _WITHOUT) diff --git a/surfsense_local/backend/alembic/versions/0032_agent_thread_engine.py b/surfsense_local/backend/alembic/versions/0032_agent_thread_engine.py new file mode 100644 index 0000000000..4198e0960b --- /dev/null +++ b/surfsense_local/backend/alembic/versions/0032_agent_thread_engine.py @@ -0,0 +1,88 @@ +"""Record which engine runs each agent thread, and keep a Claude Code session's transcript and account. + +Plain ALTER TABLE: rebuilding chat_threads with foreign keys enabled would +cascade-delete its messages. Every agent thread so far ran on opencode. + +Revision ID: 0032 +Revises: 0031 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "0032" +down_revision: str | Sequence[str] | None = "0031" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE chat_threads ADD COLUMN agent_engine VARCHAR(11) " + "CONSTRAINT ck_chat_threads_agentenginekind " + "CHECK (agent_engine IN ('opencode', 'claude_code'))" + ) + op.execute( + "UPDATE chat_threads SET agent_engine = 'opencode' " + "WHERE opencode_session_id IS NOT NULL" + ) + op.add_column( + "chat_threads", sa.Column("claude_session_id", sa.String(), nullable=True) + ) + op.add_column( + "chat_threads", sa.Column("claude_account", sa.String(), nullable=True) + ) + op.create_table( + "claude_session_entries", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("thread_id", sa.Integer(), nullable=False), + sa.Column("session_id", sa.String(), nullable=False), + sa.Column("uuid", sa.String(), nullable=True), + sa.Column("entry", sa.JSON(), nullable=False), + sa.ForeignKeyConstraint( + ["thread_id"], + ["chat_threads.id"], + name=op.f("fk_claude_session_entries_thread_id_chat_threads"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_claude_session_entries")), + ) + op.create_index( + "claude_session_entries_session_uuid", + "claude_session_entries", + ["session_id", "uuid"], + unique=True, + ) + op.create_index( + "claude_session_entries_thread", "claude_session_entries", ["thread_id"] + ) + op.create_table( + "claude_reply_endings", + sa.Column("thread_id", sa.Integer(), nullable=False), + sa.Column("reply_id", sa.String(), nullable=False), + sa.Column("ending", sa.JSON(), nullable=False), + sa.ForeignKeyConstraint( + ["thread_id"], + ["chat_threads.id"], + name=op.f("fk_claude_reply_endings_thread_id_chat_threads"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint( + "thread_id", "reply_id", name=op.f("pk_claude_reply_endings") + ), + ) + + +def downgrade() -> None: + op.drop_table("claude_reply_endings") + op.drop_index("claude_session_entries_thread", table_name="claude_session_entries") + op.drop_index( + "claude_session_entries_session_uuid", table_name="claude_session_entries" + ) + op.drop_table("claude_session_entries") + op.drop_column("chat_threads", "claude_account") + op.drop_column("chat_threads", "claude_session_id") + op.drop_column("chat_threads", "agent_engine") diff --git a/surfsense_local/backend/api/main.py b/surfsense_local/backend/api/main.py index ab2ef36ca6..b5f4607db5 100644 --- a/surfsense_local/backend/api/main.py +++ b/surfsense_local/backend/api/main.py @@ -12,6 +12,8 @@ from api.config import Settings, get_settings from modules.agent.agent_threads.router import router as agent_threads_router +from modules.agent.engines.claude_code.agent_proxy import serving_agent_proxy +from modules.agent.engines.thread_engine import close_engines from modules.agent.launch_key import mint_launch_key from modules.agent.model_endpoint.responses_relay.router import ( router as agent_responses_router, @@ -51,6 +53,12 @@ from modules.llm.models import SelectedModel from modules.llm.residency import warm_selected from modules.llm.router import router as llm_router +from modules.llm.subscriptions.claude.errors import ( + ClaudeSignInRequiredError, + ClaudeTooOldError, +) +from modules.llm.subscriptions.claude.plan_generator import close_claude_runs +from modules.llm.subscriptions.claude.proxy import serving_claude_proxy from modules.llm.transcription.gate import TranscriptionGate from modules.llm.transcription.internal_router import ( router as transcription_windows_router, @@ -135,9 +143,15 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: name="catalog-warm", daemon=True, ).start() - async with _plugin_proxy(session_factory): + async with ( + _plugin_proxy(session_factory), + serving_claude_proxy(session_factory, get_settings().host), + serving_agent_proxy(session_factory, get_settings().host), + ): yield finally: + await close_claude_runs() + await close_engines() engine.dispose() @@ -246,21 +260,45 @@ def create_app() -> FastAPI: app.include_router(plugin_license_router) app.include_router(plugin_oauth_router) app.include_router(plugins_router) + # Whichever engine runs a thread calls its tools and is answered through these. + app.include_router(agent_tools_router) + app.include_router(agent_threads_router) + app.include_router(plugin_steps_router) # Routes only opencode calls, or that answer it, exist only beside one. if get_agent_settings().has_opencode(): app.include_router(agent_model_router) app.include_router(agent_responses_router) - app.include_router(agent_tools_router) - app.include_router(agent_threads_router) - app.include_router(plugin_steps_router) app.include_router(agent_previews_router) app.include_router(agent_preview_files_router) app.add_exception_handler(EgressDeniedError, egress_denied) app.add_exception_handler(EmbeddingNotChosenError, embedding_not_chosen) app.add_exception_handler(UnreadableSecretError, unreadable_secret) + app.add_exception_handler(ClaudeTooOldError, claude_too_old) + app.add_exception_handler(ClaudeSignInRequiredError, claude_sign_in_required) return app +def claude_too_old(_request: Request, error: ClaudeTooOldError) -> JSONResponse: + """Handled once, here: the model list, a chat and an agent turn refuse it alike, before anything runs. + + 409 like `unreadable_secret`: the request is fine; the user's Claude Code is not. + """ + return JSONResponse( + {"detail": {"code": "claude_too_old", "message": str(error)}}, + status.HTTP_409_CONFLICT, + ) + + +def claude_sign_in_required( + _request: Request, error: ClaudeSignInRequiredError +) -> JSONResponse: + """Claude Code gone before a run starts: the code the composer's model check already shows.""" + return JSONResponse( + {"detail": {"code": "claude_sign_in_required", "message": str(error)}}, + status.HTTP_409_CONFLICT, + ) + + def unreadable_secret(_request: Request, error: UnreadableSecretError) -> JSONResponse: """Handled once, here, because any route touching a stored key can hit it. diff --git a/surfsense_local/backend/modules/agent/agent_threads/router.py b/surfsense_local/backend/modules/agent/agent_threads/router.py index 66baef8bf7..54cde08fb2 100644 --- a/surfsense_local/backend/modules/agent/agent_threads/router.py +++ b/surfsense_local/backend/modules/agent/agent_threads/router.py @@ -1,13 +1,12 @@ from typing import Literal -import httpx from fastapi import APIRouter, HTTPException, Response, status from pydantic import BaseModel -from modules.agent.agent_threads.stored_step import stored_step -from modules.agent.opencode_runtime import AgentUnavailableError, connect_opencode +from api.dependencies import SessionDep +from modules.agent.engines.both_sessions import stored_step_of +from modules.agent.engines.thread_engine import every_engine, thread_engine from modules.chat.dependencies import ThreadDep -from shared.config import get_storage_settings router = APIRouter(tags=["agent"]) @@ -30,8 +29,11 @@ class StepResult(BaseModel): response_model=StepResult, summary="Read the whole result of an agent's step", ) -async def read_step_result(thread: ThreadDep, part_id: str) -> StepResult: - state = (await stored_step(thread, part_id)).get("state") or {} +async def read_step_result( + thread: ThreadDep, part_id: str, session: SessionDep +) -> StepResult: + step = await stored_step_of(session, thread, part_id, every_engine()) + state = step.get("state") or {} output, error = state.get("output"), state.get("error") return StepResult( output=None if output is None else str(output), @@ -47,27 +49,8 @@ async def read_step_result(thread: ThreadDep, part_id: str) -> StepResult: async def answer_permission( thread: ThreadDep, request_id: str, payload: PermissionReply ) -> Response: - """Pass the user's answer to the opencode session waiting on it.""" + """Pass the user's answer to the engine waiting on it.""" if not thread.uses_agent: raise HTTPException(status.HTTP_409_CONFLICT, "this thread is not the agent's") - try: - client = connect_opencode() - except AgentUnavailableError as error: - raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(error)) from error - folder = get_storage_settings().thread_working_dir(thread.workspace_id, thread.id) - async with client: - try: - await client.reply(folder, request_id, payload.reply) - except httpx.HTTPStatusError as error: - if error.response.status_code == status.HTTP_404_NOT_FOUND: - raise HTTPException( - status.HTTP_404_NOT_FOUND, "no such request is waiting" - ) from error - raise HTTPException( - status.HTTP_502_BAD_GATEWAY, f"opencode refused the answer: {error}" - ) from error - except httpx.HTTPError as error: - raise HTTPException( - status.HTTP_502_BAD_GATEWAY, f"opencode did not answer: {error}" - ) from error + await thread_engine(thread).answer_permission(thread, request_id, payload.reply) return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/surfsense_local/backend/modules/agent/agent_threads/turn.py b/surfsense_local/backend/modules/agent/agent_threads/turn.py index 64e65bd2bf..b526f78777 100644 --- a/surfsense_local/backend/modules/agent/agent_threads/turn.py +++ b/surfsense_local/backend/modules/agent/agent_threads/turn.py @@ -42,7 +42,6 @@ check_changed_tools, ) from modules.agent.plugin_tools.give_plugins import ( - UnavailablePlugin, give_plugins, servers, set_plugin_rules, @@ -59,6 +58,7 @@ ) from modules.agent.plugin_tools.thread_calls import thread_calls_token from modules.agent.plugin_tools.turn_plugins import TurnPlugin, turn_plugins +from modules.agent.plugin_tools.unavailable_plugin import UnavailablePlugin from modules.agent.thread_folder.sync import ThreadGoneError, sync_thread_folder from modules.agent.tool_endpoint.failed_renders import begin_turn from modules.agent.tool_endpoint.registration import SERVER, register_thread_tools @@ -92,6 +92,8 @@ class _Sending: text: str sources: TurnSources title: str | None + # What another engine did in this thread while opencode was away. + handover: str | None = None @dataclass(frozen=True) @@ -119,6 +121,7 @@ async def agent_turn( payload: MessageCreate, launch_key: str, place: RunPlace, + handover: str | None = None, ) -> StreamingResponse: """Start the turn as a run and follow it, or refuse before anything is sent.""" session_id = thread.opencode_session_id @@ -146,13 +149,13 @@ async def agent_turn( "The selected model cannot run the agent. Choose another model, " "or start a new chat to use this one.", ) - title = _first_title(thread, payload.text) + title = first_title(thread, payload.text) if title is not None: await transact(session, _rename, thread, title) except BaseException: await ready.client.close() raise - sending = _Sending(payload.text, sources, title) + sending = _Sending(payload.text, sources, title, handover) thread_id, workspace_id = thread.id, thread.workspace_id async def frames(run: Run) -> AsyncIterator[bytes]: @@ -285,6 +288,7 @@ async def _stream( notes=[ note for note in ( + sending.handover, sending.sources.note, mention_note(mentions), ) @@ -297,7 +301,7 @@ async def _stream( break continue for frame in turn.frames(event): - _follow_approvals(run, waiting, frame) + follow_approvals(run, waiting, frame) host_calls.follow(frame, plugins) if frame.get("artifact") is not None: # Whether it made the document, as the stored step says. @@ -521,7 +525,7 @@ async def _note_stop( logger.warning("could not note the stop of %s's reply", session_id) -def _follow_approvals(run: Run, waiting: set[str], frame: dict) -> None: +def follow_approvals(run: Run, waiting: set[str], frame: dict) -> None: """Say the run needs approval while any request waits, and running once none does.""" if frame["type"] == "permission-request": waiting.add(frame["id"]) @@ -569,7 +573,7 @@ async def _refuse_a_legacy_thread( raise HTTPException(status.HTTP_409_CONFLICT, LEGACY_TURN) -def _first_title(thread: ChatThread, text: str) -> str | None: +def first_title(thread: ChatThread, text: str) -> str | None: """A name for a thread still called "New chat", from its first message.""" if (thread.title or "").casefold() != "new chat": return None diff --git a/surfsense_local/backend/modules/agent/engines/__init__.py b/surfsense_local/backend/modules/agent/engines/__init__.py new file mode 100644 index 0000000000..5e2c7bc000 --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/__init__.py @@ -0,0 +1 @@ +"""The engines that run an agent thread's turns, behind one interface the routes call.""" diff --git a/surfsense_local/backend/modules/agent/engines/agent_engine.py b/surfsense_local/backend/modules/agent/engines/agent_engine.py new file mode 100644 index 0000000000..0c16e1ddec --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/agent_engine.py @@ -0,0 +1,71 @@ +"""What the routes ask of whichever engine runs an agent thread's turns.""" + +from pathlib import Path +from typing import Any, Literal, Protocol + +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from modules.agent.agent_threads.turn import RunPlace +from modules.chat.models import AgentEngineKind, ChatThread +from modules.chat.schemas import MessageCreate + + +class AgentEngine(Protocol): + """An engine refuses a request the way its route answers, with an HTTPException.""" + + kind: AgentEngineKind + + def has_session(self, thread: ChatThread) -> bool: + """Whether this engine has run, or was given, the thread.""" + ... + + async def ensure_session( + self, session: Session, thread: ChatThread, launch_key: str + ) -> None: + """Give a thread another engine opened a session of this engine's, in its own folder.""" + ... + + async def open_session( + self, session: Session, thread: ChatThread, launch_key: str + ) -> bool: + """Give a new thread to this engine; False when it cannot answer now, and the thread stays the chat's.""" + ... + + async def renew_session(self, session: Session, thread: ChatThread) -> bool: + """Start the thread a new session when its own may no longer be resumed; True when it did.""" + ... + + async def run_turn( + self, + session: Session, + thread: ChatThread, + payload: MessageCreate, + launch_key: str, + place: RunPlace, + handover: str | None = None, + ) -> StreamingResponse: + """`handover` is what the other engine did while this one was away.""" + ... + + async def thread_messages( + self, session: Session, thread: ChatThread, launch_key: str, answering: bool + ) -> list[dict[str, Any]]: ... + + async def answer_permission( + self, thread: ChatThread, request_id: str, reply: Literal["once", "reject"] + ) -> None: ... + + async def stored_step( + self, session: Session, thread: ChatThread, part_id: str + ) -> dict[str, Any]: + """A tool step whole, as the engine stored it: a turn's frames carry only its start.""" + ... + + async def forget_thread(self, thread: ChatThread) -> Path: + """Stop and delete the thread's session; returns its folder, removed once the row is gone.""" + ... + + def forget_workspace(self, session: Session, workspace_id: int) -> None: + """Every session of a workspace about to be deleted. Runs in a worker thread.""" + ... diff --git a/surfsense_local/backend/modules/agent/engines/both_sessions.py b/surfsense_local/backend/modules/agent/engines/both_sessions.py new file mode 100644 index 0000000000..2da9ff55fd --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/both_sessions.py @@ -0,0 +1,87 @@ +"""A thread that switched engines lives in both their sessions; read back, it is one thread. + +The engine the thread left may not answer now (opencode not running): its turns are +left out, with a warning, rather than the thread unreadable. The engine running the +thread is never skipped, since its turns are the latest. +""" + +import logging +from collections.abc import Sequence +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from modules.agent.engines.agent_engine import AgentEngine +from modules.chat.models import ChatThread + +logger = logging.getLogger(__name__) + + +async def thread_turns_of( + session: Session, + thread: ChatThread, + launch_key: str, + answering: bool, + engines: Sequence[AgentEngine], +) -> list[dict[str, Any]]: + """Every engine's turns, each with its reply, oldest first. + + Only the engine running the thread now can be writing its latest reply. + """ + pairs: list[list[dict[str, Any]]] = [] + for engine in engines: + if not engine.has_session(thread): + continue + current = engine.kind is thread.agent_engine + live = answering and current + try: + turns = await engine.thread_messages(session, thread, launch_key, live) + except HTTPException as refused: + if current: + raise + _left_out(engine, thread, refused) + continue + pairs += _pairs(turns) + pairs.sort(key=lambda pair: pair[0]["created_at"] or "") + return [turn for pair in pairs for turn in pair] + + +async def stored_step_of( + session: Session, + thread: ChatThread, + part_id: str, + engines: Sequence[AgentEngine], +) -> dict[str, Any]: + """The step from whichever engine's session holds it.""" + for engine in engines: + if not engine.has_session(thread): + continue + try: + return await engine.stored_step(session, thread, part_id) + except HTTPException as refused: + if refused.status_code == status.HTTP_404_NOT_FOUND: + continue + if engine.kind is thread.agent_engine: + raise + _left_out(engine, thread, refused) + raise HTTPException(status.HTTP_404_NOT_FOUND, "no such step in this thread") + + +def _left_out(engine: AgentEngine, thread: ChatThread, refused: HTTPException) -> None: + logger.warning( + "thread %s read without its %s session, which did not answer: %s", + thread.id, + engine.kind, + refused.detail, + ) + + +def _pairs(turns: list[dict[str, Any]]) -> list[list[dict[str, Any]]]: + pairs: list[list[dict[str, Any]]] = [] + for turn in turns: + if turn["role"] == "user" or not pairs: + pairs.append([turn]) + else: + pairs[-1].append(turn) + return pairs diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/__init__.py b/surfsense_local/backend/modules/agent/engines/claude_code/__init__.py new file mode 100644 index 0000000000..2102e31d4d --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/__init__.py @@ -0,0 +1 @@ +"""Claude Code as an agent engine, for a thread on a Claude plan model.""" diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/agent_options.py b/surfsense_local/backend/modules/agent/engines/claude_code/agent_options.py new file mode 100644 index 0000000000..07d0df6c21 --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/agent_options.py @@ -0,0 +1,79 @@ +"""What an agent run's Claude Code starts with (docs/architecture/claude-plan.md, A turn).""" + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from claude_agent_sdk import ClaudeAgentOptions, HookMatcher + +from modules.agent.engines.claude_code.skills_plugin import SkillsPlugin +from modules.agent.engines.claude_code.tool_names import for_claude +from modules.agent.engines.claude_code.turn_link import TurnLink +from modules.agent.opencode_config import agent_prompt +from modules.agent.plugin_tools.connect_limit import CONNECT_SECONDS +from modules.llm.subscriptions.claude.launch import launch_options + +# opencode's tools in Claude Code's names: no Bash (ADR 0039), web, subagents or notebooks. +TOOLS = ["Read", "Glob", "Grep", "Write", "Edit", "Skill"] +# Over Claude Code's 25,000, a result is refused as too large. One larger than +# Claude Code gives whole (an 80.7 KB one was) is still saved under the session, +# which the gate lets the model read, as opencode's tool-output folder. +MAX_RESULT_TOKENS = 100_000 +# A scrape on SurfSense's plugin host can run minutes; opencode allows it 300 s. +TOOL_CALL_MS = 300_000 +# The hook only decides, so this never runs out; an approval waits in can_use_tool. +HOOK_SECONDS = 30 + + +@dataclass(frozen=True) +class AgentLaunch: + binary: Path + proxy_url: str + folder: Path + model: str + session_id: str + # False for the session's first turn, which creates it under `session_id`. + resumed: bool + mcp_file: Path + disallowed: list[str] + skills: SkillsPlugin + link: TurnLink + store: Any + thinking: bool + + +def agent_options(launch: AgentLaunch) -> ClaudeAgentOptions: + session: dict[str, Any] = ( + {"resume": launch.session_id} + if launch.resumed + else {"session_id": launch.session_id} + ) + if launch.thinking: + # Without `summarized`, Claude Code omits the thinking text. + session["thinking"] = {"type": "adaptive", "display": "summarized"} + return launch_options( + launch.binary, + proxy_url=launch.proxy_url, + model=launch.model, + cwd=str(launch.folder), + tools=TOOLS, + mcp_servers=str(launch.mcp_file), + disallowed_tools=launch.disallowed, + plugins=[{"type": "local", "path": str(launch.skills.root)}], + skills=sorted(launch.skills.names), + system_prompt=for_claude(agent_prompt()), + hooks={ + "PreToolUse": [ + HookMatcher(hooks=[launch.link.pre_tool_use], timeout=HOOK_SECONDS) + ] + }, + can_use_tool=launch.link.can_use_tool, + include_partial_messages=True, + session_store=launch.store, + env={ + "MAX_MCP_OUTPUT_TOKENS": str(MAX_RESULT_TOKENS), + "MCP_TOOL_TIMEOUT": str(TOOL_CALL_MS), + "MCP_TIMEOUT": str(CONNECT_SECONDS * 1000), + }, + **session, + ) diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/agent_proxy.py b/surfsense_local/backend/modules/agent/engines/claude_code/agent_proxy.py new file mode 100644 index 0000000000..94ffc68c4a --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/agent_proxy.py @@ -0,0 +1,51 @@ +"""The proxy an agent run's Claude Code goes through: Anthropic's hosts and the ready plugins' (ADR 0061). + +Separate from a chat run's, which passes Anthropic's hosts alone, so a chat run +can never reach a plugin. Both checks are the ones already in use, not copies. +""" + +import contextlib +from collections.abc import AsyncIterator + +from sqlalchemy.orm import Session, sessionmaker + +from modules.agent.plugin_proxy.allowed_hosts import AllowedHosts +from modules.agent.plugin_proxy.server import serving_plugin_proxy +from modules.llm.subscriptions.claude.proxy import ProxyNotServingError +from modules.llm.subscriptions.claude.proxy_hosts import DEFAULT_HOSTS, ClaudeHosts + +_url: str | None = None + + +class AgentHosts(ClaudeHosts): + def _ready(self, session: Session) -> frozenset[tuple[str, int]]: + return ClaudeHosts._ready(self, session) | AllowedHosts._ready(self, session) + + +def required_agent_proxy_url() -> str: + """The proxy's address, or a refusal: Claude Code never runs around egress.""" + if _url is None: + raise ProxyNotServingError + return _url + + +@contextlib.asynccontextmanager +async def serving_agent_proxy( + sessions: sessionmaker[Session], + host: str, + *, + hosts: tuple[tuple[str, int], ...] = DEFAULT_HOSTS, +) -> AsyncIterator[int]: + global _url + async with serving_plugin_proxy( + sessions, + host, + 0, + allowed=AgentHosts(sessions, hosts), + client="Claude Code", + ) as port: + _url = f"http://{host}:{port}" + try: + yield port + finally: + _url = None diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/agent_runs.py b/surfsense_local/backend/modules/agent/engines/claude_code/agent_runs.py new file mode 100644 index 0000000000..1cef5f78d3 --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/agent_runs.py @@ -0,0 +1,119 @@ +"""One running Claude Code per agent thread, so the next turn skips the start-up. + +Its MCP servers, hidden tools and skills are fixed when it starts: a turn set +up otherwise, as its digest says, ends it and starts another, resuming the same +session. A server whose tools changed is only connected again, since Claude Code +lists a server's tools once it connects. Each holds a config file with sign-in +headers, removed with it. +""" + +import contextlib +import logging +import time +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from pathlib import Path + +from modules.agent.engines.claude_code.approvals import APPROVALS, Approvals +from modules.agent.engines.claude_code.turn_link import TurnLink +from modules.llm.subscriptions.claude.run import ClaudeRun + +logger = logging.getLogger(__name__) + +Start = Callable[[TurnLink], Awaitable[tuple[ClaudeRun, Path]]] + +IDLE_SECONDS = 300.0 + + +@dataclass +class HeldRun: + run: ClaudeRun + link: TurnLink + digest: str + mcp_file: Path + used_at: float + turning: bool = True + # A digest of each server's tools, as the run last connected it. + listings: dict[str, str] = field(default_factory=dict) + + +class AgentRuns: + def __init__( + self, + *, + approvals: Approvals = APPROVALS, + idle_seconds: float = IDLE_SECONDS, + clock: Callable[[], float] = time.monotonic, + ) -> None: + self._approvals = approvals + self._idle = idle_seconds + self._clock = clock + self._held: dict[int, HeldRun] = {} + + async def acquire( + self, + thread_id: int, + digest: str, + start: Start, + listings: dict[str, str] | None = None, + ) -> HeldRun: + """The thread's Claude Code for a turn set up as `digest` says, its servers' tools as `listings` say.""" + await self._end_idle() + listings = listings or {} + held = self._held.get(thread_id) + if held is not None and held.digest == digest: + held.turning = True + held.used_at = self._clock() + if await _reconnected(held, listings): + return held + await self.forget(thread_id) + link = TurnLink(thread_id, self._approvals) + run, mcp_file = await start(link) + held = HeldRun(run, link, digest, mcp_file, self._clock(), listings=listings) + self._held[thread_id] = held + return held + + def release(self, thread_id: int) -> None: + """The turn ended; the run waits for the next one until it idles out.""" + held = self._held.get(thread_id) + if held is not None: + held.turning = False + held.used_at = self._clock() + held.link.end() + + async def forget(self, thread_id: int) -> None: + held = self._held.pop(thread_id, None) + if held is not None: + await _end(held) + + async def close_all(self) -> None: + for thread_id in list(self._held): + await self.forget(thread_id) + + async def _end_idle(self) -> None: + now = self._clock() + for thread_id, held in list(self._held.items()): + if not held.turning and now - held.used_at > self._idle: + await self.forget(thread_id) + + +async def _reconnected(held: HeldRun, listings: dict[str, str]) -> bool: + """Connect again each server whose tools changed; False when one would not, and the run starts over.""" + for server, listing in listings.items(): + if held.listings.get(server) == listing: + continue + try: + await held.run.reconnect(server) + except Exception: + logger.warning("Claude Code did not reconnect %s", server, exc_info=True) + return False + held.listings[server] = listing + return True + + +async def _end(held: HeldRun) -> None: + held.link.end() + # A run that will not close cleanly is gone either way. + with contextlib.suppress(Exception): + await held.run.close() + held.mcp_file.unlink(missing_ok=True) diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/approvals.py b/surfsense_local/backend/modules/agent/engines/claude_code/approvals.py new file mode 100644 index 0000000000..cc9ca7b2bf --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/approvals.py @@ -0,0 +1,44 @@ +"""The approvals a Claude Code turn waits on, answered from the thread's approval card.""" + +import asyncio +import uuid +from typing import Literal + +Reply = Literal["once", "reject"] + + +class Approvals: + def __init__(self) -> None: + self._waiting: dict[int, dict[str, asyncio.Future[Reply]]] = {} + + def ask(self, thread_id: int) -> tuple[str, asyncio.Future[Reply]]: + """A new request, and the answer it will get.""" + request_id = f"perm_{uuid.uuid4().hex}" + answer: asyncio.Future[Reply] = asyncio.get_running_loop().create_future() + self._waiting.setdefault(thread_id, {})[request_id] = answer + answer.add_done_callback(lambda _: self._forget(thread_id, request_id)) + return request_id, answer + + def answer(self, thread_id: int, request_id: str, reply: Reply) -> bool: + """False when no such request waits, as for one already answered.""" + answer = self._waiting.get(thread_id, {}).get(request_id) + if answer is None or answer.done(): + return False + answer.set_result(reply) + return True + + def drop(self, thread_id: int) -> None: + """Refuse every request still waiting, as Stop does.""" + for answer in list(self._waiting.get(thread_id, {}).values()): + if not answer.done(): + answer.set_result("reject") + + def _forget(self, thread_id: int, request_id: str) -> None: + waiting = self._waiting.get(thread_id, {}) + waiting.pop(request_id, None) + if not waiting: + self._waiting.pop(thread_id, None) + + +# The API's one set, read by the turns and by the permission route. +APPROVALS = Approvals() diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/endings.py b/surfsense_local/backend/modules/agent/engines/claude_code/endings.py new file mode 100644 index 0000000000..ff365f27ac --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/endings.py @@ -0,0 +1,45 @@ +"""How a Claude Code reply ended when it did not finish, which only SurfSense knows.""" + +from typing import Any + +from sqlalchemy import delete, select +from sqlalchemy.dialects.sqlite import insert +from sqlalchemy.orm import Session + +from modules.agent.engines.claude_code.models import ClaudeReplyEnding + +# Kept from a turn's start, so a reply cut off by the app going away reads as such. +INTERRUPTED = {"type": "interrupted"} +STOPPED = {"type": "stopped"} + + +def record_ending( + session: Session, thread_id: int, reply_id: str, ending: dict[str, Any] +) -> None: + session.execute( + insert(ClaudeReplyEnding) + .values(thread_id=thread_id, reply_id=reply_id, ending=ending) + .on_conflict_do_update( + index_elements=["thread_id", "reply_id"], set_={"ending": ending} + ) + ) + + +def finished(session: Session, thread_id: int, reply_id: str) -> None: + """The reply completed: it keeps no ending.""" + session.execute( + delete(ClaudeReplyEnding).where( + ClaudeReplyEnding.thread_id == thread_id, + ClaudeReplyEnding.reply_id == reply_id, + ) + ) + + +def recorded_endings(session: Session, thread_id: int) -> dict[str, dict[str, Any]]: + """Every unfinished reply's ending, by reply id.""" + rows = session.execute( + select(ClaudeReplyEnding.reply_id, ClaudeReplyEnding.ending).where( + ClaudeReplyEnding.thread_id == thread_id + ) + ) + return dict(rows.tuples().all()) diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/engine.py b/surfsense_local/backend/modules/agent/engines/claude_code/engine.py new file mode 100644 index 0000000000..71e9721c3a --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/engine.py @@ -0,0 +1,257 @@ +"""Claude Code as an agent engine, for a thread on a Claude plan model. + +The thread's turns live in its Claude Code session, which SurfSense keeps a +copy of; read back, they are shaped as opencode's, so the thread shows both +engines' turns alike (docs/architecture/claude-plan.md). +""" + +import asyncio +import logging +import uuid +from pathlib import Path +from typing import Any, Literal + +from anyio import from_thread +from fastapi import HTTPException, status +from fastapi.responses import StreamingResponse +from sqlalchemy import select +from sqlalchemy.orm import Session +from starlette.concurrency import run_in_threadpool + +from api.dependencies import transact +from modules.agent.agent_threads.citations import load_citations, searched_chunks +from modules.agent.agent_threads.ready_renders import link_ready_renders +from modules.agent.agent_threads.replies import reply_id, thread_turns +from modules.agent.agent_threads.reply_ending import ABORTED +from modules.agent.agent_threads.scope_note import name_scopes +from modules.agent.agent_threads.turn import RunPlace +from modules.agent.engines.claude_code.agent_runs import AgentRuns +from modules.agent.engines.claude_code.approvals import APPROVALS +from modules.agent.engines.claude_code.endings import recorded_endings +from modules.agent.engines.claude_code.models import ClaudeSessionEntry +from modules.agent.engines.claude_code.session_files import delete_session_files +from modules.agent.engines.claude_code.skills_plugin import build_skills_plugin +from modules.agent.engines.claude_code.transcript_shape import opencode_messages +from modules.agent.engines.claude_code.turn import ClaudeSetup, claude_turn +from modules.agent.opencode_config import skills_folder +from modules.agent.thread_folder.layout import forget_thread_lock, fresh_thread_folder +from modules.chat.models import AgentEngineKind, ChatThread +from modules.chat.schemas import MessageCreate +from modules.llm.subscriptions.claude.locate import this_machines_claude +from modules.llm.subscriptions.claude.proxy import claude_proxy_url +from modules.llm.subscriptions.claude.status import signed_in_account +from shared.config import get_storage_settings + +logger = logging.getLogger(__name__) + + +class ClaudeCodeEngine: + kind = AgentEngineKind.CLAUDE_CODE + + def has_session(self, thread: ChatThread) -> bool: + return thread.claude_session_id is not None + + async def ensure_session( + self, session: Session, thread: ChatThread, launch_key: str + ) -> None: + if thread.claude_session_id is None: + await transact(session, _name_session, thread, str(uuid.uuid4())) + + async def renew_session(self, session: Session, thread: ChatThread) -> bool: + """Another Claude account signed in since the session was made gets a session of its own. + + A session made before its account was read is taken as the one signed in now. + """ + if thread.claude_session_id is None: + return False + account = await run_in_threadpool( + signed_in_account, this_machines_claude(), proxy_url=claude_proxy_url() + ) + if account is None or account == thread.claude_account: + return False + if thread.claude_account is None: + await transact(session, _record_account, thread, account) + return False + left = thread.claude_session_id + await self._runs.forget(thread.id) + await transact(session, _renew_session, thread, str(uuid.uuid4()), account) + # SurfSense's copy keeps its turns; Claude Code's own is not needed again. + await run_in_threadpool(delete_session_files, left) + return True + + def __init__(self) -> None: + self._runs = AgentRuns() + self._setup: ClaudeSetup | None = None + self._building = asyncio.Lock() + + async def _ready_setup(self) -> ClaudeSetup: + """Built once per run of the app, from the skills this version ships.""" + async with self._building: + if self._setup is None: + claude = get_storage_settings().data_dir / "claude" + skills = await run_in_threadpool( + build_skills_plugin, skills_folder(), claude / "skills-plugin" + ) + self._setup = ClaudeSetup(self._runs, skills, claude / "mcp") + return self._setup + + async def open_session( + self, session: Session, thread: ChatThread, launch_key: str + ) -> bool: + """Name the session the thread's turns will live in; Claude Code makes it on the first.""" + try: + await run_in_threadpool(fresh_thread_folder, thread.workspace_id, thread.id) + except OSError: + logger.warning( + "thread %s opens as a chat: its folder holds a deleted thread's files", + thread.id, + exc_info=True, + ) + return False + await transact(session, _record_session, thread, str(uuid.uuid4())) + return True + + async def run_turn( + self, + session: Session, + thread: ChatThread, + payload: MessageCreate, + launch_key: str, + place: RunPlace, + handover: str | None = None, + ) -> StreamingResponse: + setup = await self._ready_setup() + return await claude_turn( + session, thread, payload, launch_key, place, setup, handover + ) + + async def thread_messages( + self, session: Session, thread: ChatThread, launch_key: str, answering: bool + ) -> list[dict[str, Any]]: + messages = await self._messages(session, thread) + endings = await transact(session, recorded_endings, thread.id) + _mark_endings(messages, endings, answering) + citations = await transact( + session, load_citations, thread.workspace_id, searched_chunks(messages) + ) + turns = await run_in_threadpool( + thread_turns, messages, citations, endings, answering + ) + await transact(session, link_ready_renders, thread.workspace_id, turns) + await transact(session, name_scopes, thread.workspace_id, turns) + return turns + + async def answer_permission( + self, thread: ChatThread, request_id: str, reply: Literal["once", "reject"] + ) -> None: + if not APPROVALS.answer(thread.id, request_id, reply): + raise HTTPException(status.HTTP_404_NOT_FOUND, "no such request is waiting") + + async def stored_step( + self, session: Session, thread: ChatThread, part_id: str + ) -> dict[str, Any]: + for message in await self._messages(session, thread): + for part in message["parts"]: + if part.get("id") == part_id and part.get("type") == "tool": + return part + raise HTTPException(status.HTTP_404_NOT_FOUND, "no such step in this thread") + + async def forget_thread(self, thread: ChatThread) -> Path: + """End its Claude Code and delete Claude Code's own transcript; SurfSense's goes with the row.""" + folder = get_storage_settings().thread_working_dir( + thread.workspace_id, thread.id + ) + await self._forget(thread.id, thread.claude_session_id, folder) + return folder + + def forget_workspace(self, session: Session, workspace_id: int) -> None: + threads = session.execute( + select(ChatThread.id, ChatThread.claude_session_id).where( + ChatThread.workspace_id == workspace_id, + # Not only the threads on Claude Code now: one that left keeps its session. + ChatThread.claude_session_id.is_not(None), + ) + ).all() + storage = get_storage_settings() + for thread_id, session_id in threads: + folder = storage.thread_working_dir(workspace_id, thread_id) + from_thread.run(self._forget, thread_id, session_id, folder) + + async def close(self) -> None: + """Quit: end every running Claude Code.""" + await self._runs.close_all() + + async def _forget( + self, thread_id: int, session_id: str | None, folder: Path + ) -> None: + APPROVALS.drop(thread_id) + await self._runs.forget(thread_id) + forget_thread_lock(folder) + if session_id is not None: + await run_in_threadpool(delete_session_files, session_id) + + async def _messages( + self, session: Session, thread: ChatThread + ) -> list[dict[str, Any]]: + if thread.claude_session_id is None: + raise HTTPException( + status.HTTP_409_CONFLICT, "this thread is not the agent's" + ) + entries = await transact(session, _entries, thread.id) + return opencode_messages(entries) + + +def _record_session(session: Session, thread: ChatThread, session_id: str) -> None: + """Reloaded here, off the event loop: the write moves `updated_at`, which the response reads.""" + thread.agent_engine = AgentEngineKind.CLAUDE_CODE + thread.claude_session_id = session_id + session.flush() + session.refresh(thread) + + +def _name_session(session: Session, thread: ChatThread, session_id: str) -> None: + thread.claude_session_id = session_id + session.flush() + session.refresh(thread) + + +def _record_account(session: Session, thread: ChatThread, account: str) -> None: + thread.claude_account = account + + +def _renew_session( + session: Session, thread: ChatThread, session_id: str, account: str +) -> None: + thread.claude_session_id = session_id + thread.claude_account = account + session.flush() + session.refresh(thread) + + +def _entries(session: Session, thread_id: int) -> list[dict[str, Any]]: + """Every session the thread had in Claude Code, one per account, in the order written.""" + return list( + session.scalars( + select(ClaudeSessionEntry.entry) + .where(ClaudeSessionEntry.thread_id == thread_id) + .order_by(ClaudeSessionEntry.id) + ) + ) + + +def _mark_endings( + messages: list[dict[str, Any]], endings: dict[str, dict[str, Any]], answering: bool +) -> None: + """A reply SurfSense noted as unfinished reads as opencode's aborted one does. + + The reply still being written is left alone: its ending is noted from its start. + """ + last_steps: dict[str, dict[str, Any]] = {} + for message in messages: + if message["info"]["role"] == "assistant": + last_steps[message["info"]["parentID"]] = message + openers = [m["info"]["id"] for m in messages if m["info"]["role"] == "user"] + live = openers[-1] if answering and openers else None + for opener, step in last_steps.items(): + if opener != live and reply_id(opener) in endings: + step["info"]["error"] = {"name": ABORTED} diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/gate.py b/surfsense_local/backend/modules/agent/engines/claude_code/gate.py new file mode 100644 index 0000000000..74c67092f9 --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/gate.py @@ -0,0 +1,108 @@ +"""What Claude Code may do in a thread: opencode's permission rules, applied to Claude Code's tool names. + +docs/architecture/claude-plan.md, The gate. +""" + +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path, PurePath +from typing import Any, Literal + +from modules.agent.engines.claude_code.saved_results import is_saved_result + +Action = Literal["allow", "ask", "deny"] + +_READING = {"Read": "file_path", "Glob": "path", "Grep": "path"} +_WRITING = frozenset({"Write", "Edit"}) +# Read as instructions by an agent; casefolded, as macOS matches names. +_INSTRUCTION_FILES = frozenset({"agents.md", "claude.md", "context.md"}) +_INSTRUCTION_FOLDERS = frozenset({".claude", ".opencode"}) +# SurfSense's own tools, on the thread's tool endpoint. +_SURFSENSE_TOOLS = "mcp__surfsense__" +_GLOB_CHARACTERS = frozenset("*?[{") +_OUTSIDE = "Only files in this thread's folder can be read." +_NOT_OUTPUTS = "Files can be written only under this thread's outputs/ folder." + + +@dataclass(frozen=True) +class Verdict: + action: Action + # Given to the model when a call is refused, so it can work around it. + reason: str = "" + + +@dataclass(frozen=True) +class Gate: + """The rules for one thread's turn: its folder, the skills folder a skill may + point into, SurfSense's skills, and each given plugin tool's rule.""" + + folder: Path + skills: Path + skill_names: frozenset[str] = frozenset() + # By Claude Code's name for the tool; a plugin tool absent here is denied. + plugin_rules: Mapping[str, Action] = field(default_factory=dict) + # Where Claude Code saved this session's long tool results, readable alone. + claude_home: Path | None = None + session_id: str | None = None + + def decide(self, tool: str, tool_input: dict[str, Any]) -> Verdict: + if tool in _READING: + return self._read(tool, tool_input) + if tool in _WRITING: + return self._write(tool_input.get("file_path") or "") + if tool == "Skill": + if tool_input.get("skill") in self.skill_names: + return Verdict("allow") + return Verdict("deny", "Only SurfSense's skills can be used here.") + if tool.startswith(_SURFSENSE_TOOLS): + return Verdict("allow") + if tool in self.plugin_rules: + return Verdict(self.plugin_rules[tool]) + return Verdict("deny", f"{tool} is not available in SurfSense.") + + def _read(self, tool: str, tool_input: dict[str, Any]) -> Verdict: + base = self._resolve(tool_input.get(_READING[tool]) or ".") + if not self._readable(base): + return Verdict("deny", _OUTSIDE) + if tool == "Glob" and not self._pattern_stays_in( + base, tool_input.get("pattern") or "" + ): + return Verdict("deny", _OUTSIDE) + return Verdict("allow") + + def _write(self, path: str) -> Verdict: + target = self._resolve(path) + outputs = (self.folder / "outputs").resolve() + if not target.is_relative_to(outputs): + return Verdict("deny", _NOT_OUTPUTS) + names = {part.casefold() for part in target.relative_to(outputs).parts} + if names & _INSTRUCTION_FOLDERS or target.name.casefold() in _INSTRUCTION_FILES: + return Verdict("deny", f"{target.name} cannot be written in SurfSense.") + return Verdict("allow") + + def _resolve(self, path: str) -> Path: + """Relative to the thread's folder, as Claude Code's working folder; links followed.""" + return (self.folder / path).resolve() + + def _readable(self, path: Path) -> bool: + if path.is_relative_to(self.folder.resolve()) or path.is_relative_to( + self.skills.resolve() + ): + return True + return ( + self.claude_home is not None + and self.session_id is not None + and is_saved_result(path, self.claude_home, self.session_id) + ) + + def _pattern_stays_in(self, base: Path, pattern: str) -> bool: + """The part of a pattern before its first wildcard names a folder, which must be readable.""" + parts = PurePath(pattern).parts + if ".." in parts: + return False + literal = [] + for part in parts: + if _GLOB_CHARACTERS & set(part): + break + literal.append(part) + return self._readable((base / Path(*literal)).resolve()) if literal else True diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/mcp_config.py b/surfsense_local/backend/modules/agent/engines/claude_code/mcp_config.py new file mode 100644 index 0000000000..c551fc8aab --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/mcp_config.py @@ -0,0 +1,21 @@ +"""The MCP servers given to Claude Code, written where only the user can read them. + +Passed as a dict, the SDK puts them on Claude Code's command line, where any +process on the machine can read the sign-in headers. +""" + +import json +import os +import tempfile +from pathlib import Path +from typing import Any + + +def write_mcp_config(directory: Path, servers: dict[str, Any]) -> Path: + """A new file for one client; the caller removes it when the client closes.""" + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + # mkstemp makes the file 0600 and never reuses a name. + descriptor, name = tempfile.mkstemp(prefix="mcp-", suffix=".json", dir=directory) + with os.fdopen(descriptor, "w", encoding="utf-8") as file: + json.dump({"mcpServers": servers}, file) + return Path(name) diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/models.py b/surfsense_local/backend/modules/agent/engines/claude_code/models.py new file mode 100644 index 0000000000..6f23fbfa70 --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/models.py @@ -0,0 +1,43 @@ +"""SurfSense's copy of a thread's Claude Code session, and how its replies ended. + +Claude Code deletes its own transcripts after 30 days, and the user owns +~/.claude, so the thread lives here (docs/architecture/claude-plan.md, Sessions). +""" + +from typing import Any + +from sqlalchemy import JSON, ForeignKey, Index +from sqlalchemy.orm import Mapped, mapped_column + +from shared.db import Base + + +class ClaudeSessionEntry(Base): + """One transcript line, in the order Claude Code wrote it.""" + + __tablename__ = "claude_session_entries" + __table_args__ = ( + # A retried batch lands again; an entry without a uuid is always kept. + Index("claude_session_entries_session_uuid", "session_id", "uuid", unique=True), + Index("claude_session_entries_thread", "thread_id"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + thread_id: Mapped[int] = mapped_column( + ForeignKey("chat_threads.id", ondelete="CASCADE") + ) + session_id: Mapped[str] + uuid: Mapped[str | None] + entry: Mapped[dict[str, Any]] = mapped_column(JSON) + + +class ClaudeReplyEnding(Base): + """A reply that did not finish: stopped, failed, or cut off when the app went away.""" + + __tablename__ = "claude_reply_endings" + + thread_id: Mapped[int] = mapped_column( + ForeignKey("chat_threads.id", ondelete="CASCADE"), primary_key=True + ) + reply_id: Mapped[str] = mapped_column(primary_key=True) + ending: Mapped[dict[str, Any]] = mapped_column(JSON) diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/opencode_shape.py b/surfsense_local/backend/modules/agent/engines/claude_code/opencode_shape.py new file mode 100644 index 0000000000..b50a01dca1 --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/opencode_shape.py @@ -0,0 +1,227 @@ +"""Claude Code's messages as opencode's events, so one turn reader serves both engines. + +Each API message is an assistant message, as opencode starts one per step; text +and thinking stream from the raw events, a tool call's input comes with its +finished block and its result with the next user message. +""" + +import time +from typing import Any + +from claude_agent_sdk import ( + AssistantMessage, + ResultMessage, + StreamEvent, + TextBlock, + ThinkingBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) + +from modules.agent.engines.claude_code.tool_names import step_input, step_tool +from modules.agent.engines.claude_code.tool_result import result_text +from modules.agent.opencode_client import Event + +_KINDS = {"text": "text", "thinking": "reasoning"} + + +def _now_ms() -> int: + return time.time_ns() // 1_000_000 + + +class OpencodeShape: + """One Claude Code session's turn, read in order.""" + + def __init__(self, session_id: str) -> None: + self._session = session_id + self._message = "" + self._announced: set[str] = set() + # Text and reasoning parts by stream position, and in the order they began, + # so a finished block finds the part its deltas went to. + self._by_index: dict[tuple[str, int], dict[str, Any]] = {} + self._begun: dict[tuple[str, str], list[dict[str, Any]]] = {} + self._finished: dict[tuple[str, str], int] = {} + self._tools: dict[str, dict[str, Any]] = {} + # The turn as opencode stores one, so its reply is stated from what streamed. + self._messages: list[dict[str, Any]] = [] + self._by_id: dict[str, dict[str, Any]] = {} + self._user = "" + + def user(self, message_id: str, created_ms: int) -> list[Event]: + """The turn's own user message, which opens the reply.""" + self._user = message_id + info = {"id": message_id, "role": "user", "time": {"created": created_ms}} + self._messages.append({"info": info, "parts": []}) + return [self._event("message.updated", info=info)] + + def messages(self) -> list[dict[str, Any]]: + """The turn read so far, as opencode's messages.""" + return self._messages + + def read(self, message: Any) -> list[Event]: + if getattr(message, "parent_tool_use_id", None): + return [] # a subagent's, and SurfSense gives none + if isinstance(message, StreamEvent): + return self._stream(message.event) + if isinstance(message, AssistantMessage): + return self._assistant(message) + if isinstance(message, UserMessage): + return self._results(message) + if isinstance(message, ResultMessage): + if self._message in self._by_id: + self._by_id[self._message]["info"]["time"]["completed"] = _now_ms() + return [self._event("session.idle")] + return [] + + def _stream(self, event: dict[str, Any]) -> list[Event]: + kind = event.get("type") + if kind == "message_start": + return self._announce(event["message"]["id"]) + index = event.get("index", -1) + if kind == "content_block_start": + return self._block_started(index, event["content_block"]) + part = self._by_index.get((self._message, index)) + if part is None: + return [] + if kind == "content_block_delta": + delta = event["delta"] + text = delta.get("text") or delta.get("thinking") + if delta.get("type") not in {"text_delta", "thinking_delta"} or not text: + return [] + part["text"] += text + return [ + self._event( + "message.part.delta", partID=part["id"], field="text", delta=text + ) + ] + if kind == "content_block_stop" and part["type"] == "reasoning": + part["time"]["end"] = _now_ms() + return [self._part(part)] + return [] + + def _block_started(self, index: int, block: dict[str, Any]) -> list[Event]: + if block.get("type") == "tool_use": + return [self._tool(block["id"], block["name"], {}, "pending")] + kind = _KINDS.get(block.get("type", "")) + if kind is None: + return [] + part = self._new_part(kind, f"{self._message}:{index}") + self._by_index[(self._message, index)] = part + return [self._part(part)] + + def _assistant(self, message: AssistantMessage) -> list[Event]: + events = self._announce(message.message_id or message.uuid or self._message) + for block in message.content: + if isinstance(block, ToolUseBlock): + tool_input = step_input(block.name, block.input) + events.append(self._tool(block.id, block.name, tool_input, "running")) + elif isinstance(block, TextBlock): + events += self._whole("text", block.text) + elif isinstance(block, ThinkingBlock): + events += self._whole("reasoning", block.thinking) + return events + + def _whole(self, kind: str, text: str) -> list[Event]: + """A finished block: the part its deltas went to, given its whole text, or a new part.""" + key = (self._message, kind) + seen = self._finished.get(key, 0) + self._finished[key] = seen + 1 + begun = self._begun.get(key, []) + if seen < len(begun): + part = begun[seen] + else: + part = self._new_part(kind, f"{self._message}:{kind}:{seen}") + if kind == "reasoning": + part["time"]["end"] = part["time"]["start"] + part["text"] = text + return [self._part(part)] + + def _results(self, message: UserMessage) -> list[Event]: + if isinstance(message.content, str): + return [] + events = [] + for block in message.content: + if not isinstance(block, ToolResultBlock): + continue + part = self._tools.get(block.tool_use_id) + if part is None: + continue + text = result_text(block.content) + state = part["state"] + if block.is_error: + part["state"] = { + "status": "error", + "input": state["input"], + "error": text, + } + else: + part["state"] = { + "status": "completed", + "input": state["input"], + "output": text, + } + events.append(self._part(part)) + return events + + def _tool( + self, call_id: str, name: str, tool_input: dict[str, Any], status: str + ) -> Event: + part = self._tools.get(call_id) + if part is None: + part = { + "id": call_id, + "messageID": self._message, + "type": "tool", + "callID": call_id, + "tool": step_tool(name), + } + self._tools[call_id] = part + self._add(part) + part["state"] = {"status": status, "input": tool_input} + return self._part(part) + + def _announce(self, message_id: str) -> list[Event]: + self._message = message_id + if message_id in self._announced: + return [] + self._announced.add(message_id) + message = { + "info": { + "id": message_id, + "role": "assistant", + "parentID": self._user, + "time": {"created": _now_ms()}, + }, + "parts": [], + } + self._by_id[message_id] = message + self._messages.append(message) + return [ + self._event("message.updated", info={"id": message_id, "role": "assistant"}) + ] + + def _new_part(self, kind: str, part_id: str) -> dict[str, Any]: + part: dict[str, Any] = { + "id": part_id, + "messageID": self._message, + "type": kind, + "text": "", + } + if kind == "reasoning": + part["time"] = {"start": _now_ms()} + self._begun.setdefault((self._message, kind), []).append(part) + self._add(part) + return part + + def _add(self, part: dict[str, Any]) -> None: + message = self._by_id.get(self._message) + if message is not None: + message["parts"].append(part) + message["info"]["time"]["completed"] = _now_ms() + + def _part(self, part: dict[str, Any]) -> Event: + return self._event("message.part.updated", part=dict(part)) + + def _event(self, kind: str, **properties: Any) -> Event: + return Event(type=kind, properties={"sessionID": self._session, **properties}) diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/plugin_statuses.py b/surfsense_local/backend/modules/agent/engines/claude_code/plugin_statuses.py new file mode 100644 index 0000000000..ab2f06139a --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/plugin_statuses.py @@ -0,0 +1,35 @@ +"""The plugins a Claude Code turn cannot use, from the statuses its `init` message reports. + +Claude Code connects its MCP servers in the background and holds a turn's +`init` until each has connected or failed, within its MCP timeout, the shared +CONNECT_SECONDS: read there, a status is settled, as opencode's is once the +API's add call returns. Asked earlier, every server is still `pending`. +""" + +from typing import Any + +from modules.agent.plugin_tools.turn_plugins import TurnPlugin +from modules.agent.plugin_tools.unavailable_plugin import UnavailablePlugin + +# Usable, or turned off on purpose; neither is the user's to fix. +_FINE = {"connected", "disabled"} + + +def unavailable_plugins( + servers: list[dict[str, Any]], plugins: list[TurnPlugin] +) -> list[UnavailablePlugin]: + """Each given plugin that is not connected, named as opencode's turn names it.""" + by_name = {plugin.name: plugin for plugin in plugins} + unavailable = [] + for server in servers: + plugin = by_name.get(server.get("name", "")) + status = server.get("status") + if plugin is None or status in _FINE: + continue + if status == "needs-auth": + reason = "license" if plugin.licensed else "sign_in" + else: + # Failed, or still connecting past the timeout, as opencode's add times out. + reason = "unreachable" + unavailable.append(UnavailablePlugin(plugin.title, reason)) + return unavailable diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/saved_results.py b/surfsense_local/backend/modules/agent/engines/claude_code/saved_results.py new file mode 100644 index 0000000000..32c2f580de --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/saved_results.py @@ -0,0 +1,28 @@ +"""Where Claude Code saves a tool result too large to give the model whole. + +It writes the result under `/projects///tool-results/` +and gives the model a preview and that path, as opencode keeps long output in +its tool-output folder. Only the turn's own session is ever read from there. +""" + +import os +from pathlib import Path + +_RESULTS = "tool-results" + + +def claude_home() -> Path: + """Claude Code's config folder, as it finds it itself.""" + configured = os.environ.get("CLAUDE_CONFIG_DIR") + return Path(configured) if configured else Path.home() / ".claude" + + +def is_saved_result(path: Path, home: Path, session_id: str) -> bool: + """Whether `path`, already resolved, is in this session's saved results.""" + projects = (home / "projects").resolve() + if not path.is_relative_to(projects): + return False + parts = path.relative_to(projects).parts + # //tool-results[/...]: the project key is Claude Code's + # spelling of the working folder, so the session id is what is checked. + return len(parts) >= 3 and parts[1] == session_id and parts[2] == _RESULTS diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/session_files.py b/surfsense_local/backend/modules/agent/engines/claude_code/session_files.py new file mode 100644 index 0000000000..ba23bba846 --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/session_files.py @@ -0,0 +1,25 @@ +"""Claude Code's own files for a thread's session, under `/projects//`. + +SurfSense's copy is the thread, so Claude Code's transcript is deleted after each +turn: measured, a running Claude Code went on and a resumed one loaded SurfSense's +copy alone. Its `/` folder stays until the thread goes, since a later +turn may read a result saved there. `delete_session` is not used: it gives up when +the transcript is already gone, leaving that folder behind. +""" + +import shutil + +from modules.agent.engines.claude_code.saved_results import claude_home + + +def drop_transcript(session_id: str) -> None: + """After a turn: the transcript Claude Code wrote, never its saved results.""" + for transcript in (claude_home() / "projects").glob(f"*/{session_id}.jsonl"): + transcript.unlink(missing_ok=True) + + +def delete_session_files(session_id: str) -> None: + """With the thread, or a session left for another account: the transcript and its folder.""" + drop_transcript(session_id) + for folder in (claude_home() / "projects").glob(f"*/{session_id}"): + shutil.rmtree(folder, ignore_errors=True) diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/session_store.py b/surfsense_local/backend/modules/agent/engines/claude_code/session_store.py new file mode 100644 index 0000000000..a01f229ce2 --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/session_store.py @@ -0,0 +1,57 @@ +"""The SDK's SessionStore over SurfSense's database, for one thread's Claude Code session.""" + +from typing import Any + +from sqlalchemy import select +from sqlalchemy.dialects.sqlite import insert +from sqlalchemy.orm import Session, sessionmaker +from starlette.concurrency import run_in_threadpool + +from modules.agent.engines.claude_code.models import ClaudeSessionEntry + + +class ThreadSessionStore: + """Entries arrive in batches about every 100 ms while a turn runs; each is one short write.""" + + def __init__(self, sessions: sessionmaker[Session], thread_id: int) -> None: + self._sessions = sessions + self._thread_id = thread_id + + async def append(self, key: dict[str, Any], entries: list[dict[str, Any]]) -> None: + # A subagent's transcript is never the thread's, and SurfSense gives none. + if key.get("subpath") or not entries: + return + await run_in_threadpool(self._append, key["session_id"], entries) + + async def load(self, key: dict[str, Any]) -> list[dict[str, Any]] | None: + if key.get("subpath"): + return None + entries = await run_in_threadpool(self._load, key["session_id"]) + return entries or None + + def _append(self, session_id: str, entries: list[dict[str, Any]]) -> None: + rows = [ + { + "thread_id": self._thread_id, + "session_id": session_id, + "uuid": entry.get("uuid"), + "entry": entry, + } + for entry in entries + ] + with self._sessions() as session: + # The uuid is the SDK's idempotency key: a retried batch may have landed. + session.execute( + insert(ClaudeSessionEntry).values(rows).on_conflict_do_nothing() + ) + session.commit() + + def _load(self, session_id: str) -> list[dict[str, Any]]: + with self._sessions() as session: + return list( + session.scalars( + select(ClaudeSessionEntry.entry) + .where(ClaudeSessionEntry.session_id == session_id) + .order_by(ClaudeSessionEntry.id) + ) + ) diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/skills_plugin.py b/surfsense_local/backend/modules/agent/engines/claude_code/skills_plugin.py new file mode 100644 index 0000000000..187a5e3fe1 --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/skills_plugin.py @@ -0,0 +1,46 @@ +"""SurfSense's skills as a Claude Code plugin, `surfsense`, naming tools as Claude Code lists them. + +The skills are written for opencode, which calls SurfSense's tools +`surfsense_`; Claude Code calls them `mcp__surfsense__`. +""" + +import json +import shutil +from dataclasses import dataclass +from pathlib import Path + +from modules.agent.engines.claude_code.tool_names import for_claude + +PLUGIN = "surfsense" + + +@dataclass(frozen=True) +class SkillsPlugin: + root: Path + # Where each skill's files are, which the gate lets Claude Code read. + skills: Path + # As the Skill tool names them: `surfsense:surfsense-documents`. + names: frozenset[str] + + +def build_skills_plugin(skills: Path, target: Path) -> SkillsPlugin: + """Rebuilt whole from the shipped skills; built at start, before any turn reads it.""" + fresh = target.with_name(f"{target.name}.new") + shutil.rmtree(fresh, ignore_errors=True) + shutil.copytree(skills, fresh / "skills") + for markdown in (fresh / "skills").rglob("*.md"): + markdown.write_text( + for_claude(markdown.read_text(encoding="utf-8")), encoding="utf-8" + ) + (fresh / ".claude-plugin").mkdir() + (fresh / ".claude-plugin" / "plugin.json").write_text( + json.dumps({"name": PLUGIN, "description": "SurfSense's skills"}) + ) + shutil.rmtree(target, ignore_errors=True) + fresh.rename(target) + names = frozenset( + f"{PLUGIN}:{skill.name}" + for skill in (target / "skills").iterdir() + if (skill / "SKILL.md").is_file() + ) + return SkillsPlugin(target, target / "skills", names) diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/tool_names.py b/surfsense_local/backend/modules/agent/engines/claude_code/tool_names.py new file mode 100644 index 0000000000..f52d474c7c --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/tool_names.py @@ -0,0 +1,57 @@ +"""Claude Code's names for tools and opencode's, so one thread shows both engines' steps alike.""" + +from typing import Any + +from modules.agent.tool_endpoint.offered_tools import TOOLS +from modules.agent.tool_endpoint.registration import SERVER + +_MCP = "mcp__" +_BUILT_IN = {"Read", "Glob", "Grep", "Write", "Edit", "Skill"} +# Claude Code's file tools name their inputs as opencode's do, but in snake case. +_INPUT_KEYS = { + "file_path": "filePath", + "old_string": "oldString", + "new_string": "newString", + "replace_all": "replaceAll", +} +# opencode's file tools as a prompt names them, quoted, and Claude Code's. +_FILE_TOOLS = {"`read`": "`Read`", "`grep`": "`Grep`", "`glob`": "`Glob`"} +# Longest first, so one name that begins another is never half replaced. +_SURFSENSE_NAMES = sorted(TOOLS, key=len, reverse=True) + + +def _clean(name: str) -> str: + """As both Claude Code and opencode spell a server's or a tool's name.""" + return "".join( + character + if character.isascii() and (character.isalnum() or character in "_-") + else "_" + for character in name + ) + + +def claude_tool(server: str, tool: str) -> str: + return f"{_MCP}{_clean(server)}__{_clean(tool)}" + + +def step_tool(name: str) -> str: + """The name opencode gives the same tool: `read`, `surfsense_render_document`, `_`.""" + if name.startswith(_MCP): + server, _, tool = name.removeprefix(_MCP).partition("__") + return f"{server}_{tool}" + return name.lower() + + +def step_input(name: str, given: dict[str, Any]) -> dict[str, Any]: + if name not in _BUILT_IN: + return given + return {_INPUT_KEYS.get(key, key): value for key, value in given.items()} + + +def for_claude(text: str) -> str: + """Instructions written for opencode, with SurfSense's tools named as Claude Code lists them.""" + for tool in _SURFSENSE_NAMES: + text = text.replace(f"{SERVER}_{tool}", claude_tool(SERVER, tool)) + for opencode, claude in _FILE_TOOLS.items(): + text = text.replace(opencode, claude) + return text diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/tool_result.py b/surfsense_local/backend/modules/agent/engines/claude_code/tool_result.py new file mode 100644 index 0000000000..89c576e575 --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/tool_result.py @@ -0,0 +1,12 @@ +"""What a tool call returned, as the text a step shows.""" + +from typing import Any + + +def result_text(content: str | list[dict[str, Any]] | None) -> str: + """An MCP result's text blocks, joined; its images are the model's alone.""" + if content is None: + return "" + if isinstance(content, str): + return content + return "\n".join(item["text"] for item in content if item.get("type") == "text") diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/transcript_shape.py b/surfsense_local/backend/modules/agent/engines/claude_code/transcript_shape.py new file mode 100644 index 0000000000..ee05221aec --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/transcript_shape.py @@ -0,0 +1,126 @@ +"""A Claude Code transcript as opencode's messages, so the thread reads both engines' turns alike. + +A user entry with words opens a turn; each API message of the reply is an +assistant message, as opencode's steps are, and a tool result completes the +call it answers. Claude Code's own entries are left out. +""" + +from datetime import datetime +from typing import Any + +from modules.agent.engines.claude_code.tool_names import step_input, step_tool +from modules.agent.engines.claude_code.tool_result import result_text + +# What Claude Code writes as a user message that the user never sent. +_NOT_THE_USERS = ("isCompactSummary", "isMeta", "isSidechain") +_INTERRUPTED = "[Request interrupted" + + +def opencode_messages(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: + messages: list[dict[str, Any]] = [] + by_id: dict[str, dict[str, Any]] = {} + calls: dict[str, dict[str, Any]] = {} + opener: str | None = None + for entry in entries: + if any(entry.get(flag) for flag in _NOT_THE_USERS): + continue + at = _ms(entry.get("timestamp")) + content = (entry.get("message") or {}).get("content") + if entry.get("type") == "user": + if isinstance(content, list) and _answers_calls(content): + _complete(content, calls) + continue + parts = _user_parts(content) + if not parts: + continue + opener = entry["uuid"] + messages.append( + { + "info": {"id": opener, "role": "user", "time": {"created": at}}, + "parts": parts, + } + ) + elif entry.get("type") == "assistant" and opener is not None: + message_id = entry["message"]["id"] + message = by_id.get(message_id) + if message is None: + message = { + "info": { + "id": message_id, + "role": "assistant", + "parentID": opener, + "time": {"created": at}, + }, + "parts": [], + } + by_id[message_id] = message + messages.append(message) + message["info"]["time"]["completed"] = at + for block in content or []: + part = _assistant_part(block, f"{message_id}:{len(message['parts'])}") + if part is None: + continue + part["messageID"] = message_id + message["parts"].append(part) + if part["type"] == "tool": + calls[part["callID"]] = part + return messages + + +def _answers_calls(content: list[dict[str, Any]]) -> bool: + return any(block.get("type") == "tool_result" for block in content) + + +def _complete(content: list[dict[str, Any]], calls: dict[str, dict[str, Any]]) -> None: + for block in content: + part = calls.get(block.get("tool_use_id", "")) + if part is None: + continue + text = result_text(block.get("content")) + given = part["state"]["input"] + if block.get("is_error"): + part["state"] = {"status": "error", "input": given, "error": text} + else: + part["state"] = {"status": "completed", "input": given, "output": text} + + +def _user_parts(content: Any) -> list[dict[str, Any]]: + """The user's words last, after SurfSense's notes, which go as blocks of their own.""" + if isinstance(content, str): + texts = [content] + else: + texts = [b["text"] for b in content or [] if b.get("type") == "text"] + if not texts or texts[-1].startswith(_INTERRUPTED): + return [] + *notes, words = texts + return [ + *({"type": "text", "text": note, "synthetic": True} for note in notes), + {"type": "text", "text": words}, + ] + + +def _assistant_part(block: dict[str, Any], part_id: str) -> dict[str, Any] | None: + kind = block.get("type") + if kind == "text": + return {"id": part_id, "type": "text", "text": block["text"]} + if kind == "thinking" and block.get("thinking"): + return {"id": part_id, "type": "reasoning", "text": block["thinking"]} + if kind == "tool_use": + return { + "id": block["id"], + "type": "tool", + "callID": block["id"], + "tool": step_tool(block["name"]), + # Running until its result is read: an unanswered call was cut short. + "state": { + "status": "running", + "input": step_input(block["name"], block["input"]), + }, + } + return None + + +def _ms(timestamp: str | None) -> int | None: + if timestamp is None: + return None + return int(datetime.fromisoformat(timestamp).timestamp() * 1000) diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/turn.py b/surfsense_local/backend/modules/agent/engines/claude_code/turn.py new file mode 100644 index 0000000000..6bf0e43695 --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/turn.py @@ -0,0 +1,592 @@ +"""One turn of an agent thread on Claude Code, run in the background in the chat's own frames. + +The turn is prepared as opencode's is (the folder synced, the plugins checked), +then the thread's Claude Code runs it. Its messages, and the approvals its +calls wait on, are read as opencode's events, so the thread shows the same +frames whichever engine answers (docs/architecture/claude-plan.md). +""" + +import asyncio +import hashlib +import json +import logging +import time +import uuid +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import anyio +from claude_agent_sdk import SystemMessage +from fastapi import HTTPException, status +from fastapi.responses import StreamingResponse +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker +from starlette.concurrency import run_in_threadpool + +from api.dependencies import transact +from modules.agent.agent_threads.citations import load_citations, searched_chunks +from modules.agent.agent_threads.ready_renders import link_live_render +from modules.agent.agent_threads.replies import reply_id, turn_reply +from modules.agent.agent_threads.turn import ( + RunPlace, + first_title, + follow_approvals, +) +from modules.agent.agent_threads.turn_frames import TurnFrames +from modules.agent.agent_threads.turn_sources import turn_sources +from modules.agent.engines.claude_code.agent_options import AgentLaunch, agent_options +from modules.agent.engines.claude_code.agent_proxy import required_agent_proxy_url +from modules.agent.engines.claude_code.agent_runs import AgentRuns, HeldRun +from modules.agent.engines.claude_code.approvals import APPROVALS +from modules.agent.engines.claude_code.endings import ( + INTERRUPTED, + STOPPED, + finished, + record_ending, +) +from modules.agent.engines.claude_code.gate import Gate +from modules.agent.engines.claude_code.mcp_config import write_mcp_config +from modules.agent.engines.claude_code.models import ClaudeSessionEntry +from modules.agent.engines.claude_code.opencode_shape import OpencodeShape +from modules.agent.engines.claude_code.plugin_statuses import unavailable_plugins +from modules.agent.engines.claude_code.saved_results import claude_home +from modules.agent.engines.claude_code.session_files import drop_transcript +from modules.agent.engines.claude_code.session_store import ThreadSessionStore +from modules.agent.engines.claude_code.skills_plugin import SkillsPlugin +from modules.agent.engines.claude_code.tool_names import claude_tool, for_claude +from modules.agent.engines.claude_code.turn_link import TurnLink +from modules.agent.opencode_client import Event +from modules.agent.plugin_tools.changed_tools import ( + check_after_turn, + check_changed_tools, +) +from modules.agent.plugin_tools.mentions import mention_note, mentioned_plugins +from modules.agent.plugin_tools.refreshed_sign_ins import refresh_sign_ins +from modules.agent.plugin_tools.stop_plugin_calls import HostCalls, stop_plugin_calls +from modules.agent.plugin_tools.thread_calls import thread_calls_token +from modules.agent.plugin_tools.turn_plugins import TurnPlugin, turn_plugins +from modules.agent.plugin_tools.unavailable_plugin import UnavailablePlugin +from modules.agent.thread_folder.sync import ThreadGoneError, sync_thread_folder +from modules.agent.tool_endpoint.failed_renders import begin_turn +from modules.agent.tool_endpoint.registration import SERVER, thread_tools_url +from modules.chat.errors import classify_chat_error +from modules.chat.models import ChatThread +from modules.chat.runs.notify import notify_run +from modules.chat.runs.run import Run +from modules.chat.runs.stream import event_stream +from modules.chat.schemas import MessageCreate +from modules.egress.service import EgressDeniedError +from modules.llm.model_type import ModelType +from modules.llm.models import SelectedModel +from modules.llm.subscriptions.claude.account import CLAUDE_PLAN +from modules.llm.subscriptions.claude.errors import ( + ClaudeSignInRequiredError, + failure_of, +) +from modules.llm.subscriptions.claude.extra_usage import plan_usage +from modules.llm.subscriptions.claude.hosts import claude_refusal, require_claude_hosts +from modules.llm.subscriptions.claude.run import ClaudeRun, open_run +from modules.llm.subscriptions.claude.usable_claude import usable_claude +from modules.llm.subscriptions.plan_usage_frame import plan_usage_frame +from modules.plugins.installed.models import PluginTool +from modules.plugins.license_verdict.license_refresh import refresh_license_verdict +from shared.config import get_storage_settings + +logger = logging.getLogger(__name__) + +PROVIDER = "claude_plan" +_DONE = b"data: [DONE]\n\n" +_END = object() +# Measured: interrupt() returned in 0.01 s and the turn's result came straight after. +END_SECONDS = 10.0 + + +@dataclass(frozen=True) +class ClaudeSetup: + """What every Claude Code turn shares: the running clients and SurfSense's skills.""" + + runs: AgentRuns + skills: SkillsPlugin + # Where the MCP config files go, readable by the user alone. + config_dir: Path + + +@dataclass(frozen=True) +class _Sending: + text: str + note: str | None + shown: dict[str, Any] | None + document_ids: list[int] + title: str | None + model: str + thinking: bool + # What opencode did in this thread while Claude Code was away. + handover: str | None = None + + +@dataclass +class _Tools: + plugins: list[TurnPlugin] = field(default_factory=list) + unavailable: list[UnavailablePlugin] = field(default_factory=list) + mentions: list[Any] = field(default_factory=list) + hidden: list[str] = field(default_factory=list) + # A digest of each plugin's offered tools, as SurfSense last listed them. + listings: dict[str, str] = field(default_factory=dict) + + +async def claude_turn( + session: Session, + thread: ChatThread, + payload: MessageCreate, + launch_key: str, + place: RunPlace, + setup: ClaudeSetup, + handover: str | None = None, +) -> StreamingResponse: + """Start the turn as a run and follow it, or refuse before anything is sent.""" + if thread.claude_session_id is None: + raise HTTPException(status.HTTP_409_CONFLICT, "this thread is not the agent's") + if payload.images: + raise HTTPException( + status.HTTP_409_CONFLICT, + "The agent does not read attached images yet. Send the text alone.", + ) + model = await transact(session, _claude_model) + if model is None: + raise HTTPException( + status.HTTP_409_CONFLICT, + "The selected model is not a Claude plan model. Choose one, or start a " + "new chat to use this one.", + ) + # Before anything runs, so the window asks for the hosts as it does for a chat. + await transact(session, require_claude_hosts) + # Found once, here, so an old Claude Code is refused before the turn starts. + binary = await run_in_threadpool(usable_claude) + sources = await transact(session, turn_sources, thread, payload) + title = first_title(thread, payload.text) + if title is not None: + await transact(session, _rename, thread, title) + sending = _Sending( + payload.text, + sources.note, + sources.shown, + list(sources.document_ids), + title, + model, + payload.thinking, + handover, + ) + thread_id, workspace_id = thread.id, thread.workspace_id + session_id = thread.claude_session_id + + async def frames(run: Run) -> AsyncIterator[bytes]: + with place.session_factory() as run_session: + async for frame in _stream( + run, + run_session, + place.session_factory, + (workspace_id, thread_id), + session_id, + launch_key, + sending, + setup, + binary, + ): + yield frame + + async def ended() -> None: + notify_run(place.broker, workspace_id, thread_id, "done") + + def moved(state: Any) -> None: + notify_run(place.broker, workspace_id, thread_id, state.state) + + run = place.runs.start(thread_id, frames, on_end=ended, on_state=moved) + notify_run(place.broker, workspace_id, thread_id, "running") + return event_stream(run.follow()) + + +async def _stream( + run: Run, + session: Session, + sessions: sessionmaker[Session], + where: tuple[int, int], + session_id: str, + launch_key: str, + sending: _Sending, + setup: ClaudeSetup, + binary: Path, +) -> AsyncIterator[bytes]: + """The turn's frames, from its sources prepared to the reply stated.""" + workspace_id, thread_id = where + folder = get_storage_settings().thread_working_dir(workspace_id, thread_id) + user_id = str(uuid.uuid4()) + reply = reply_id(user_id) + frames = TurnFrames(session_id) + shape = OpencodeShape(session_id) + waiting: set[str] = set() + host_calls = HostCalls() + tools = _Tools() + held: HeldRun | None = None + pump: asyncio.Task | None = None + # Said once per reply, and again only if it changes. + plan_said = None + try: + yield _frame({"type": "agent-preparing", "count": len(sending.document_ids)}) + thread = await transact(session, _reload, thread_id) + try: + if thread is None: + raise ThreadGoneError(thread_id) + prepared = asyncio.create_task( + _prepare_tools(sessions, thread_id, sending.text) + ) + await run_in_threadpool( + sync_thread_folder, session, thread, sending.document_ids + ) + tools = await prepared + except (ThreadGoneError, OSError) as error: + logger.warning( + "thread %s's sources were not prepared", thread_id, exc_info=True + ) + yield _frame(_not_prepared(error)) + yield _DONE + return + servers = _servers(workspace_id, thread_id, launch_key, tools.plugins) + gate = Gate( + folder=folder, + skills=setup.skills.skills, + skill_names=setup.skills.names, + plugin_rules={ + claude_tool(plugin.name, tool): rule + for plugin in tools.plugins + for tool, rule in plugin.tools.items() + }, + claude_home=claude_home(), + session_id=session_id, + ) + digest = _digest(sending, servers, tools.hidden, setup.skills) + proxy_url = required_agent_proxy_url() + store = ThreadSessionStore(sessions, thread_id) + resumed = await transact(session, _has_session, session_id) + + async def start(link: TurnLink) -> tuple[ClaudeRun, Path]: + mcp_file = write_mcp_config(setup.config_dir, servers) + try: + options = agent_options( + AgentLaunch( + binary=binary, + proxy_url=proxy_url, + folder=folder, + model=sending.model, + session_id=session_id, + resumed=resumed, + mcp_file=mcp_file, + disallowed=tools.hidden, + skills=setup.skills, + link=link, + store=store, + thinking=sending.thinking, + ) + ) + return await open_run(options), mcp_file + except BaseException: + mcp_file.unlink(missing_ok=True) + raise + + held = await setup.runs.acquire(thread_id, digest, start, tools.listings) + begin_turn(thread_id) + if sending.shown is not None: + yield _frame({"type": "agent-scope", "scope": sending.shown}) + await transact(session, record_ending, thread_id, reply, INTERRUPTED) + + events: asyncio.Queue[Any] = asyncio.Queue() + held.link.begin(gate, events, session_id) + pump = asyncio.create_task( + _pump(held.run, _blocks(sending, tools.mentions), user_id, events) + ) + for event in shape.user(user_id, int(time.time() * 1000)): + for frame in frames.frames(event): + yield _frame(frame) + if frame["type"] == "accepted" and sending.title is not None: + yield _frame( + {"type": "thread-title-update", "title": sending.title} + ) + + while (item := await events.get()) is not _END: + if isinstance(item, BaseException): + raise item + if isinstance(item, Event): + read = [item] + elif isinstance(item, SystemMessage) and item.subtype == "init": + # Sent once Claude Code's plugins have connected or failed: settled + # here, as opencode's are once their add returns, and not before. + unavailable = tools.unavailable + unavailable_plugins( + item.data.get("mcp_servers") or [], tools.plugins + ) + if unavailable: + yield _frame(_unavailable_frame(unavailable)) + continue + elif (usage := plan_usage(item)) is not None: + if usage != plan_said: + plan_said = usage + yield _frame(plan_usage_frame(usage)) + continue + else: + refusal = ( + await transact(session, claude_refusal) + if isinstance(item, SystemMessage) and item.subtype == "api_retry" + else None + ) + if (failure := failure_of(item, proxy_refusal=refusal)) is not None: + raise failure + read = shape.read(item) + for event in read: + for frame in frames.frames(event): + follow_approvals(run, waiting, frame) + host_calls.follow(frame, tools.plugins) + if frame.get("artifact") is not None: + await transact(session, link_live_render, workspace_id, frame) + yield _frame(frame) + + messages = shape.messages() + citations = await transact( + session, load_citations, workspace_id, searched_chunks(messages) + ) + stated = turn_reply(messages, user_id, citations) + await transact(session, finished, thread_id, reply) + if stated and stated["content"]["citations"]: + yield _frame({"type": "citations", "items": stated["content"]["citations"]}) + yield _frame( + { + "type": "completed", + "assistant_completed_at": stated["completed_at"] if stated else None, + "text": stated["content"]["text"] if stated else "", + } + ) + yield _DONE + check_after_turn(sessions) + # The headers are sent: only a frame can tell the chat the turn ended. + except Exception as failure: + if not isinstance(failure, EgressDeniedError | ClaudeSignInRequiredError): + logger.exception("Claude Code turn in thread %s failed", thread_id) + kind, message = classify_chat_error(failure, PROVIDER) + ending = {"type": "error", "kind": kind, "message": message} + await transact(session, record_ending, thread_id, reply, ending) + yield _frame( + {"type": "error", "kind": kind, "message": message, "provider": PROVIDER} + ) + yield _DONE + finally: + # Stop and quit cancel the run: the turn stops with it. + with anyio.CancelScope(shield=True): + stopped = run.stop_requested and not frames.finished + if stopped: + APPROVALS.drop(thread_id) + if pump is not None and not pump.done(): + await _finish_reading(held, pump, setup.runs, thread_id) + if stopped: + await transact(session, record_ending, thread_id, reply, STOPPED) + await stop_plugin_calls(tools.plugins) + else: + host_calls.end() + setup.runs.release(thread_id) + await run_in_threadpool(drop_transcript, session_id) + + +async def _pump( + run: ClaudeRun, + blocks: list[dict[str, Any]], + user_id: str, + events: asyncio.Queue[Any], +) -> None: + """Claude Code's messages into the turn's queue, beside the approvals its calls wait on.""" + try: + async for message in run.send(blocks, uuid=user_id): + await events.put(message) + except Exception as error: + await events.put(error) + finally: + await events.put(_END) + + +async def _prepare_tools( + sessions: sessionmaker[Session], thread_id: int, text: str +) -> _Tools: + """The ready plugins and their rules, as opencode's turn plans them, read once the check ends.""" + with sessions() as session: + signed_out = await refresh_sign_ins(session) + licensed_out = [ + UnavailablePlugin(title, "license") + for title in await refresh_license_verdict(session, if_stale=True) + ] + mentions = await transact(session, mentioned_plugins, text) + await check_changed_tools(sessions) + session.expire_all() + mentioned = frozenset(m.plugin_id for m in mentions) + plugins = await transact( + session, turn_plugins, mentioned, thread_calls_token(thread_id) + ) + hidden = await transact(session, _hidden_tools, plugins) + listings = await transact(session, _listings, plugins) + return _Tools(plugins, signed_out + licensed_out, mentions, hidden, listings) + + +def _hidden_tools(session: Session, plugins: list[TurnPlugin]) -> list[str]: + """Each given plugin's tools with no rule: switched off, unreviewed, or past the cap.""" + hidden = [] + for plugin in plugins: + for tool in session.scalars( + select(PluginTool.tool).where(PluginTool.plugin_id == plugin.name) + ): + if tool not in plugin.tools: + hidden.append(claude_tool(plugin.name, tool)) + return sorted(hidden) + + +def _listings(session: Session, plugins: list[TurnPlugin]) -> dict[str, str]: + """What the model is told of each plugin's offered tools; a change needs the plugin connected again.""" + listings = {} + for plugin in plugins: + tools = session.scalars( + select(PluginTool) + .where(PluginTool.plugin_id == plugin.name) + .where(PluginTool.tool.in_(plugin.tools)) + .order_by(PluginTool.tool) + ) + told = [ + [tool.tool, tool.title, tool.description, tool.input_schema] + for tool in tools + ] + listings[plugin.name] = hashlib.sha256( + json.dumps(told, sort_keys=True).encode() + ).hexdigest() + return listings + + +def _servers( + workspace_id: int, thread_id: int, launch_key: str, plugins: list[TurnPlugin] +) -> dict[str, Any]: + servers: dict[str, Any] = { + SERVER: { + "type": "http", + "url": thread_tools_url(workspace_id, thread_id), + "headers": {"Authorization": f"Bearer {launch_key}"}, + } + } + for plugin in plugins: + servers[plugin.name] = { + "type": "http", + "url": plugin.url, + "headers": plugin.headers, + } + return servers + + +def _blocks(sending: _Sending, mentions: list[Any]) -> list[dict[str, Any]]: + """SurfSense's notes first, each a block of its own, then the user's words.""" + notes = [ + note + for note in (sending.handover, sending.note, mention_note(mentions)) + if note is not None + ] + return [ + *({"type": "text", "text": for_claude(note)} for note in notes), + {"type": "text", "text": sending.text}, + ] + + +def _digest( + sending: _Sending, + servers: dict[str, Any], + hidden: list[str], + skills: SkillsPlugin, +) -> str: + """What a running Claude Code was started with; another turn set up alike reuses it.""" + given = { + "model": sending.model, + "thinking": sending.thinking, + "servers": servers, + "hidden": hidden, + "skills": sorted(skills.names), + } + return hashlib.sha256(json.dumps(given, sort_keys=True).encode()).hexdigest() + + +def _unavailable_frame(unavailable: list[UnavailablePlugin]) -> dict[str, Any]: + return { + "type": "agent-plugins-unavailable", + "plugins": [{"title": p.title, "reason": p.reason} for p in unavailable], + } + + +async def _finish_reading( + held: HeldRun | None, pump: asyncio.Task, runs: AgentRuns, thread_id: int +) -> None: + """Read the interrupted turn to its end, so its result is never the next turn's. + + Claude Code ends a turn it was interrupted in with a result of its own; one + left unread would answer the next message. A turn that will not end is let go. + """ + if held is not None: + await _interrupt(held.run) + try: + await asyncio.wait_for(asyncio.shield(pump), END_SECONDS) + except TimeoutError: + pump.cancel() + await runs.forget(thread_id) + + +async def _interrupt(run: ClaudeRun) -> None: + try: + await run.interrupt() + except Exception: + logger.warning("could not stop a Claude Code turn", exc_info=True) + + +def _claude_model(session: Session) -> str | None: + """The selected text model's name, when a Claude plan serves it.""" + selected = session.get(SelectedModel, ModelType.TEXT_GEN) + if selected is None or selected.connection is None: + return None + if selected.connection.auth_kind != CLAUDE_PLAN: + return None + return selected.name + + +def _has_session(session: Session, session_id: str) -> bool: + """Whether Claude Code has run this session before, so it resumes rather than starts it.""" + return ( + session.scalar( + select(ClaudeSessionEntry.id) + .where(ClaudeSessionEntry.session_id == session_id) + .limit(1) + ) + is not None + ) + + +def _not_prepared(error: Exception) -> dict: + message = ( + "This chat was deleted." + if isinstance(error, ThreadGoneError) + else f"The chat's sources could not be prepared: {error}" + ) + return { + "type": "error", + "kind": "unknown", + "message": message, + "provider": PROVIDER, + } + + +def _reload(session: Session, thread_id: int) -> ChatThread | None: + return session.get(ChatThread, thread_id) + + +def _rename(_session: Session, thread: ChatThread, title: str) -> None: + thread.title = title + + +def _frame(payload: dict) -> bytes: + return f"data: {json.dumps(payload)}\n\n".encode() diff --git a/surfsense_local/backend/modules/agent/engines/claude_code/turn_link.py b/surfsense_local/backend/modules/agent/engines/claude_code/turn_link.py new file mode 100644 index 0000000000..3fac741f1e --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/claude_code/turn_link.py @@ -0,0 +1,103 @@ +"""Where a running Claude Code's tool calls meet the current turn: its gate and its frames. + +One Claude Code runs a thread's turns one after another, and its hook and +permission callback are fixed when it starts, so each turn points them here. +The hook answers at once, as it has a timeout; an `ask` is waited on in +`can_use_tool`, which Claude Code calls next. +""" + +import asyncio +from typing import Any + +from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny + +from modules.agent.engines.claude_code.approvals import Approvals +from modules.agent.engines.claude_code.gate import Gate, Verdict +from modules.agent.engines.claude_code.tool_names import step_tool +from modules.agent.opencode_client import Event + +REJECTED = "The user rejected this action." + + +class TurnLink: + def __init__(self, thread_id: int, approvals: Approvals) -> None: + self._thread_id = thread_id + self._approvals = approvals + self._gate: Gate | None = None + self._events: asyncio.Queue[Any] | None = None + self._session_id = "" + + def begin(self, gate: Gate, events: asyncio.Queue[Any], session_id: str) -> None: + """The turn now running: the rules its calls meet, and where its approvals show.""" + self._gate = gate + self._events = events + self._session_id = session_id + + def end(self) -> None: + """Between turns every call is refused: nothing should be running.""" + self._gate = None + self._events = None + + def _decide(self, tool: str, tool_input: dict[str, Any]) -> Verdict: + if self._gate is None: + return Verdict("deny", "No turn is running.") + return self._gate.decide(tool, tool_input) + + async def pre_tool_use( + self, hook_input: dict[str, Any], tool_use_id: str | None, context: Any + ) -> dict[str, Any]: + verdict = self._decide( + hook_input["tool_name"], hook_input.get("tool_input") or {} + ) + output: dict[str, Any] = { + "hookEventName": "PreToolUse", + "permissionDecision": verdict.action, + } + if verdict.reason: + output["permissionDecisionReason"] = verdict.reason + return {"hookSpecificOutput": output} + + async def can_use_tool( + self, tool: str, tool_input: dict[str, Any], context: Any + ) -> PermissionResultAllow | PermissionResultDeny: + verdict = self._decide(tool, tool_input) + if verdict.action == "allow": + return PermissionResultAllow() + if verdict.action == "deny" or self._events is None: + return PermissionResultDeny(message=verdict.reason or REJECTED) + call_id = _tool_use_id(context) + request_id, answer = self._approvals.ask(self._thread_id) + events = self._events + await events.put( + Event( + type="permission.asked", + properties={ + "sessionID": self._session_id, + "id": request_id, + "permission": step_tool(tool), + "patterns": [], + "tool": {"callID": call_id}, + }, + ) + ) + reply = await answer + await events.put( + Event( + type="permission.replied", + properties={ + "sessionID": self._session_id, + "requestID": request_id, + "reply": reply, + }, + ) + ) + if reply == "once": + return PermissionResultAllow() + return PermissionResultDeny(message=REJECTED) + + +def _tool_use_id(context: Any) -> str | None: + """The call an approval holds back, as the SDK's context names it.""" + if isinstance(context, dict): + return context.get("tool_use_id") + return getattr(context, "tool_use_id", None) diff --git a/surfsense_local/backend/modules/agent/engines/handover/__init__.py b/surfsense_local/backend/modules/agent/engines/handover/__init__.py new file mode 100644 index 0000000000..46f56eae5e --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/handover/__init__.py @@ -0,0 +1 @@ +"""Moving an agent thread from one engine to the other when the selected model changes.""" diff --git a/surfsense_local/backend/modules/agent/engines/handover/note.py b/surfsense_local/backend/modules/agent/engines/handover/note.py new file mode 100644 index 0000000000..128287da4a --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/handover/note.py @@ -0,0 +1,110 @@ +"""The note an engine taking over a thread is given: the turns it did not see. + +docs/architecture/claude-plan.md, Switching engines. Fitted to a budget: the +newest turns stay whole, older tool outputs go first, then older replies are +cut to their first paragraph, then the oldest turns are one line each. +""" + +import json +from dataclasses import dataclass +from typing import Any + +TAG = "surfsense-handover" +OUTPUT_CHARS = 2000 +_INTRO = ( + "SurfSense adds this note to the user's message; never repeat or quote it. " + "Earlier in this thread another model answered these turns, which you did " + "not see. The files named are in the thread's folder as they are now." +) +_WRITES = {"write", "edit"} +_READS = {"read"} +# Each turn is shown fully, without tool outputs, with its reply cut, or as one line. +_LEVELS = 4 + + +@dataclass(frozen=True) +class _Turn: + text: str + reply: str + steps: list[dict[str, Any]] + + +def handover_note(turns: list[dict[str, Any]], outputs: list[str], budget: int) -> str: + pairs = _pairs(turns) + levels = [0] * len(pairs) + note = _render(pairs, levels, outputs) + for level in range(1, _LEVELS): + for index in range(len(pairs) - 1): + if len(note) <= budget: + return note + levels[index] = level + note = _render(pairs, levels, outputs) + while len(note) > budget and len(pairs) > 1: + pairs, levels = pairs[1:], levels[1:] + note = _render(pairs, levels, outputs) + if len(note) > budget: + closing = f"\n" + note = note[: budget - len(closing)] + closing + return note + + +def _pairs(turns: list[dict[str, Any]]) -> list[_Turn]: + pairs: list[_Turn] = [] + for turn in turns: + content = turn.get("content") or {} + if turn.get("role") == "user": + pairs.append(_Turn(content.get("text") or "", "", [])) + elif pairs: + last = pairs[-1] + pairs[-1] = _Turn( + last.text, + content.get("text") or "", + [dict(step) for step in content.get("steps") or []], + ) + _drop_stale_reads(pairs) + return pairs + + +def _drop_stale_reads(pairs: list[_Turn]) -> None: + """A read of a file rewritten later shows text that is no longer there.""" + later: set[str] = set() + for pair in reversed(pairs): + for step in reversed(pair.steps): + path = _path(step) + if step.get("tool") in _WRITES and path: + later.add(path) + elif step.get("tool") in _READS and path in later: + step["output"] = "" + + +def _path(step: dict[str, Any]) -> str | None: + given = step.get("input") or {} + return given.get("filePath") or given.get("path") + + +def _render(pairs: list[_Turn], levels: list[int], outputs: list[str]) -> str: + lines = [f"<{TAG}>", _INTRO] + for pair, level in zip(pairs, levels, strict=True): + if level == 3: + reply = pair.reply.split("\n", 1)[0][:200] + lines.append(f"User: {pair.text[:200]} / Assistant: {reply}") + continue + lines.append(f"User: {pair.text}") + for step in pair.steps: + lines.append(_step(step, with_output=level == 0)) + reply = pair.reply.split("\n\n", 1)[0] if level == 2 else pair.reply + lines.append(f"Assistant: {reply}") + if outputs: + lines.append("Files in outputs/ now: " + ", ".join(outputs)) + lines.append(f"") + return "\n".join(lines) + + +def _step(step: dict[str, Any], *, with_output: bool) -> str: + given = json.dumps(step.get("input") or {}, ensure_ascii=False) + line = f"- {step.get('tool')} {given}" + if step.get("error"): + return f"{line} failed: {str(step['error'])[:OUTPUT_CHARS]}" + if with_output and step.get("output"): + return f"{line} returned: {str(step['output'])[:OUTPUT_CHARS]}" + return line diff --git a/surfsense_local/backend/modules/agent/engines/handover/take_over.py b/surfsense_local/backend/modules/agent/engines/handover/take_over.py new file mode 100644 index 0000000000..d8522c7e2f --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/handover/take_over.py @@ -0,0 +1,99 @@ +"""The session a thread's next turn runs in, and the note of what that session missed. + +docs/architecture/claude-plan.md, Switching engines. Each engine keeps its own session and +resumes it; the note covers only the turns since it last answered, and goes +beside the user's message, so its session keeps it as it keeps the scope note. +A session the engine renewed missed the whole thread. +""" + +from pathlib import Path + +from fastapi import HTTPException +from sqlalchemy.orm import Session +from starlette.concurrency import run_in_threadpool + +from api.dependencies import transact +from modules.agent.engines.agent_engine import AgentEngine +from modules.agent.engines.both_sessions import thread_turns_of +from modules.agent.engines.handover.note import handover_note +from modules.agent.engines.thread_engine import every_engine, thread_engine +from modules.agent.thread_folder.layout import OUTPUTS +from modules.chat.models import ChatThread +from shared.config import get_storage_settings + +# About 6,000 tokens: room for the turn in the smallest window the agent runs in, 32,768. +NOTE_CHARS = 24_000 + + +async def take_over( + session: Session, thread: ChatThread, taking: AgentEngine, launch_key: str +) -> str | None: + """Run the thread's next turn on `taking`; the note for that turn, or None with nothing missed.""" + leaving = thread_engine(thread) + moving = leaving is not taking + seen = await _turns(session, thread, taking, launch_key) if moving else [] + await taking.ensure_session(session, thread, launch_key) + if await taking.renew_session(session, thread): + missed = await thread_turns_of( + session, thread, launch_key, False, every_engine() + ) + elif moving: + missed = await _turns(session, thread, leaving, launch_key) + since = _last_asked(seen) + if since is not None: + missed = _after(missed, since) + else: + return None + if moving: + await transact(session, _move, thread, taking) + if not missed: + return None + folder = get_storage_settings().thread_working_dir(thread.workspace_id, thread.id) + outputs = await run_in_threadpool(_outputs, folder) + return handover_note(missed, outputs, NOTE_CHARS) + + +async def _turns( + session: Session, thread: ChatThread, engine: AgentEngine, launch_key: str +) -> list[dict]: + """The engine's own turns; none when it never ran the thread or cannot be read now.""" + if not engine.has_session(thread): + return [] + try: + return await engine.thread_messages(session, thread, launch_key, False) + except HTTPException: + return [] + + +def _last_asked(turns: list[dict]) -> str | None: + asked = [t["created_at"] for t in turns if t["role"] == "user" and t["created_at"]] + return max(asked) if asked else None + + +def _after(turns: list[dict], since: str) -> list[dict]: + """The turns opened after `since`, each with its reply.""" + kept: list[dict] = [] + keeping = False + for turn in turns: + if turn["role"] == "user": + keeping = (turn["created_at"] or "") > since + if keeping: + kept.append(turn) + return kept + + +def _outputs(folder: Path) -> list[str]: + outputs = folder / OUTPUTS + if not outputs.is_dir(): + return [] + return sorted( + str(path.relative_to(folder).as_posix()) + for path in outputs.rglob("*") + if path.is_file() + ) + + +def _move(session: Session, thread: ChatThread, taking: AgentEngine) -> None: + thread.agent_engine = taking.kind + session.flush() + session.refresh(thread) diff --git a/surfsense_local/backend/modules/agent/engines/opencode_engine.py b/surfsense_local/backend/modules/agent/engines/opencode_engine.py new file mode 100644 index 0000000000..e1bcdb6774 --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/opencode_engine.py @@ -0,0 +1,142 @@ +"""opencode as an agent engine: the thread's turns live in an opencode session in its folder.""" + +from pathlib import Path +from typing import Any, Literal + +import httpx +from fastapi import HTTPException, status +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from api.dependencies import transact +from modules.agent.agent_threads import live_instances, stored_step +from modules.agent.agent_threads.forget_sessions import ( + forget_thread, + forget_workspace_sessions, +) +from modules.agent.agent_threads.open_session import open_agent_session +from modules.agent.agent_threads.thread_messages import agent_thread_messages +from modules.agent.agent_threads.turn import RunPlace, agent_turn +from modules.agent.opencode_client import OpencodeVersionError +from modules.agent.opencode_runtime import ( + AgentUnavailableError, + connect_opencode, + ready_opencode, +) +from modules.chat.models import AgentEngineKind, ChatThread +from modules.chat.schemas import MessageCreate +from shared.config import get_storage_settings + + +class OpencodeEngine: + kind = AgentEngineKind.OPENCODE + + def has_session(self, thread: ChatThread) -> bool: + return thread.opencode_session_id is not None + + async def ensure_session( + self, session: Session, thread: ChatThread, launch_key: str + ) -> None: + if thread.opencode_session_id is not None: + return + try: + ready = await ready_opencode(session, launch_key=launch_key) + except (AgentUnavailableError, OpencodeVersionError) as error: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, str(error) + ) from error + folder = get_storage_settings().thread_working_dir( + thread.workspace_id, thread.id + ) + try: + live_instances.instance_used(folder) + session_id = await ready.client.create_session( + folder, thread.title or "New chat" + ) + except httpx.HTTPError as error: + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, f"opencode did not answer: {error}" + ) from error + finally: + await ready.client.close() + await transact(session, _record_session, thread, session_id) + + async def renew_session(self, session: Session, thread: ChatThread) -> bool: + return False + + async def open_session( + self, session: Session, thread: ChatThread, launch_key: str + ) -> bool: + session_id = await open_agent_session(session, thread, launch_key) + if session_id is None: + return False + await transact(session, _record_session, thread, session_id) + return True + + async def run_turn( + self, + session: Session, + thread: ChatThread, + payload: MessageCreate, + launch_key: str, + place: RunPlace, + handover: str | None = None, + ) -> StreamingResponse: + return await agent_turn(session, thread, payload, launch_key, place, handover) + + async def thread_messages( + self, session: Session, thread: ChatThread, launch_key: str, answering: bool + ) -> list[dict[str, Any]]: + return await agent_thread_messages(session, thread, launch_key, answering) + + async def answer_permission( + self, thread: ChatThread, request_id: str, reply: Literal["once", "reject"] + ) -> None: + """Pass the user's answer to the opencode session waiting on it.""" + try: + client = connect_opencode() + except AgentUnavailableError as error: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, str(error) + ) from error + folder = get_storage_settings().thread_working_dir( + thread.workspace_id, thread.id + ) + async with client: + try: + await client.reply(folder, request_id, reply) + except httpx.HTTPStatusError as error: + if error.response.status_code == status.HTTP_404_NOT_FOUND: + raise HTTPException( + status.HTTP_404_NOT_FOUND, "no such request is waiting" + ) from error + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, f"opencode refused the answer: {error}" + ) from error + except httpx.HTTPError as error: + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, f"opencode did not answer: {error}" + ) from error + + async def stored_step( + self, session: Session, thread: ChatThread, part_id: str + ) -> dict[str, Any]: + return await stored_step.stored_step(thread, part_id) + + async def forget_thread(self, thread: ChatThread) -> Path: + if thread.opencode_session_id is None: + raise ValueError(f"thread {thread.id} is not the agent's") + return await forget_thread( + thread.workspace_id, thread.id, thread.opencode_session_id + ) + + def forget_workspace(self, session: Session, workspace_id: int) -> None: + forget_workspace_sessions(session, workspace_id) + + +def _record_session(session: Session, thread: ChatThread, session_id: str) -> None: + """Reloaded here, off the event loop: the write moves `updated_at`, which the response reads.""" + thread.agent_engine = AgentEngineKind.OPENCODE + thread.opencode_session_id = session_id + session.flush() + session.refresh(thread) diff --git a/surfsense_local/backend/modules/agent/engines/thread_engine.py b/surfsense_local/backend/modules/agent/engines/thread_engine.py new file mode 100644 index 0000000000..97e4be0462 --- /dev/null +++ b/surfsense_local/backend/modules/agent/engines/thread_engine.py @@ -0,0 +1,39 @@ +"""Which engine runs an agent thread's turns: the one its session lives in.""" + +from sqlalchemy.orm import Session + +from modules.agent.engines.agent_engine import AgentEngine +from modules.agent.engines.claude_code.engine import ClaudeCodeEngine +from modules.agent.engines.opencode_engine import OpencodeEngine +from modules.chat.models import AgentEngineKind, ChatThread +from modules.llm.model_type import ModelType +from modules.llm.models import SelectedModel +from modules.llm.subscriptions.claude.account import CLAUDE_PLAN + +_OPENCODE = OpencodeEngine() +_CLAUDE_CODE = ClaudeCodeEngine() + + +def thread_engine(thread: ChatThread) -> AgentEngine: + if thread.agent_engine is AgentEngineKind.CLAUDE_CODE: + return _CLAUDE_CODE + return _OPENCODE + + +def selected_engine(session: Session) -> AgentEngine: + """The engine the selected text model runs on: a Claude plan only through Claude Code.""" + selected = session.get(SelectedModel, ModelType.TEXT_GEN) + connection = selected.connection if selected is not None else None + if connection is not None and connection.auth_kind == CLAUDE_PLAN: + return _CLAUDE_CODE + return _OPENCODE + + +def every_engine() -> tuple[AgentEngine, ...]: + """For work that spans threads, such as deleting a workspace.""" + return (_OPENCODE, _CLAUDE_CODE) + + +async def close_engines() -> None: + """Quit: end every Claude Code a thread kept running.""" + await _CLAUDE_CODE.close() diff --git a/surfsense_local/backend/modules/agent/plugin_proxy/allowed_hosts.py b/surfsense_local/backend/modules/agent/plugin_proxy/allowed_hosts.py index 43ccfef1e8..004dffd53d 100644 --- a/surfsense_local/backend/modules/agent/plugin_proxy/allowed_hosts.py +++ b/surfsense_local/backend/modules/agent/plugin_proxy/allowed_hosts.py @@ -52,10 +52,14 @@ def read(self) -> frozenset[tuple[str, int]]: # Taken first, so a commit landing while this reads makes the result stale. generation = _generation with self._sessions() as session: - hosts = _ready_hosts(session) + hosts = self._ready(session) self._built = (generation, time.monotonic(), hosts) return hosts + def _ready(self, session: Session) -> frozenset[tuple[str, int]]: + """The hosts this proxy lets through, read from the database.""" + return _ready_hosts(session) + def call_to_record(self, host: str) -> str | None: """The destination to record as called, when its last record is old enough.""" destination = host_destination(f"https://{host}") diff --git a/surfsense_local/backend/modules/agent/plugin_proxy/server.py b/surfsense_local/backend/modules/agent/plugin_proxy/server.py index 4d5a5dafe4..7177a4c6d7 100644 --- a/surfsense_local/backend/modules/agent/plugin_proxy/server.py +++ b/surfsense_local/backend/modules/agent/plugin_proxy/server.py @@ -31,10 +31,18 @@ @contextlib.asynccontextmanager async def serving_plugin_proxy( - sessions: sessionmaker[Session], host: str, port: int + sessions: sessionmaker[Session], + host: str, + port: int, + allowed: AllowedHosts | None = None, + client: str = "opencode", ) -> AsyncIterator[int]: - """The proxy on `host`, yielding the port it listens on.""" - allowed = AllowedHosts(sessions) + """The proxy on `host`, yielding the port it listens on. + + `allowed` decides which hosts pass: a ready plugin's by default. `client` + names the program it serves in the log, so a refusal says whose it was. + """ + allowed = allowed if allowed is not None else AllowedHosts(sessions) # Last-call records in flight, held so none is collected before it ran. recording: set[asyncio.Task[None]] = set() @@ -42,7 +50,7 @@ async def handle( reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: try: - await _tunnel(allowed, recording, reader, writer) + await _tunnel(allowed, recording, reader, writer, client) except ( OSError, asyncio.IncompleteReadError, @@ -77,6 +85,7 @@ async def _tunnel( recording: set[asyncio.Task[None]], reader: asyncio.StreamReader, writer: asyncio.StreamWriter, + client: str, ) -> None: head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), _HEAD_SECONDS) method, target, *_ = head.split(b"\r\n", 1)[0].decode("latin-1").split(" ") @@ -94,13 +103,13 @@ async def _tunnel( ready = (host, int(port)) in hosts except Exception: logger.warning( - "could not check opencode's %s %s", method, target, exc_info=True + "could not check %s's %s %s", client, method, target, exc_info=True ) await _answer(writer, b"502 Bad Gateway") return checked = time.perf_counter() if not ready: - logger.info("refused opencode's %s %s", method, target) + logger.info("refused %s's %s %s", client, method, target) await _answer(writer, b"403 Forbidden") return try: @@ -108,9 +117,10 @@ async def _tunnel( _connect(host, int(port)), CONNECT_SECONDS ) # Any failure, not only the network's: a socket closed unanswered reads as a - # blip, and opencode marks the plugin failed without saying why. + # blip: opencode marks the plugin failed without saying why, and Claude Code + # retries it ten times. except Exception: - logger.warning("could not reach %s for opencode", target, exc_info=True) + logger.warning("could not reach %s for %s", target, client, exc_info=True) await _answer(writer, b"502 Bad Gateway") return connected = time.perf_counter() diff --git a/surfsense_local/backend/modules/agent/plugin_tools/connect_limit.py b/surfsense_local/backend/modules/agent/plugin_tools/connect_limit.py new file mode 100644 index 0000000000..b9bdce8e89 --- /dev/null +++ b/surfsense_local/backend/modules/agent/plugin_tools/connect_limit.py @@ -0,0 +1,8 @@ +"""How long an engine tries a plugin before the turn names it unreachable. + +The same for both engines, so a plugin is reported alike whichever runs the +thread. Under OpencodeClient's 30 s, so one server's hang fails its own add, +not the turn's. +""" + +CONNECT_SECONDS = 25 diff --git a/surfsense_local/backend/modules/agent/plugin_tools/give_plugins.py b/surfsense_local/backend/modules/agent/plugin_tools/give_plugins.py index f6e91f5e57..a8284d6a50 100644 --- a/surfsense_local/backend/modules/agent/plugin_tools/give_plugins.py +++ b/surfsense_local/backend/modules/agent/plugin_tools/give_plugins.py @@ -1,6 +1,6 @@ """Before a turn: the folder's opencode holds exactly the ready plugins, and the session's rules say what each tool may do. -The servers are given all at once, each within ADD_SECONDS, so a turn waits for the +The servers are given all at once, each within CONNECT_SECONDS, so a turn waits for the slowest rather than the sum, and a server that hangs is named unavailable alone. """ @@ -8,13 +8,14 @@ import logging from dataclasses import dataclass from pathlib import Path -from typing import Literal import httpx from modules.agent.opencode_client import OpencodeClient +from modules.agent.plugin_tools.connect_limit import CONNECT_SECONDS from modules.agent.plugin_tools.given_servers import digest, given_as, remember_given from modules.agent.plugin_tools.turn_plugins import TurnPlugin +from modules.agent.plugin_tools.unavailable_plugin import UnavailablePlugin from modules.agent.tool_endpoint.registration import ( PLUGIN_CALL_SECONDS, SERVER, @@ -24,14 +25,6 @@ logger = logging.getLogger(__name__) _SIGN_IN = {"needs_auth", "needs_client_registration"} -# Under OpencodeClient's 30 s, so one server's hang fails its own add, not the turn's. -ADD_SECONDS = 25 - - -@dataclass(frozen=True) -class UnavailablePlugin: - title: str - reason: Literal["unreachable", "sign_in", "license"] @dataclass(frozen=True) @@ -86,7 +79,7 @@ async def give(plugin: TurnPlugin) -> tuple[str, bool]: else TOOL_CALL_SECONDS ), ), - ADD_SECONDS, + CONNECT_SECONDS, ) except (httpx.HTTPError, TimeoutError): logger.warning( diff --git a/surfsense_local/backend/modules/agent/plugin_tools/refreshed_sign_ins.py b/surfsense_local/backend/modules/agent/plugin_tools/refreshed_sign_ins.py index 30147446bf..8baaffb04e 100644 --- a/surfsense_local/backend/modules/agent/plugin_tools/refreshed_sign_ins.py +++ b/surfsense_local/backend/modules/agent/plugin_tools/refreshed_sign_ins.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session from api.dependencies import transact -from modules.agent.plugin_tools.give_plugins import UnavailablePlugin +from modules.agent.plugin_tools.unavailable_plugin import UnavailablePlugin from modules.plugins.gateway.listing import list_tools from modules.plugins.installed.models import InstalledPlugin from modules.plugins.remote.sign_in.oauth.oauth_credential import OAUTH diff --git a/surfsense_local/backend/modules/agent/plugin_tools/router.py b/surfsense_local/backend/modules/agent/plugin_tools/router.py index ebebc2b6aa..ff748149b6 100644 --- a/surfsense_local/backend/modules/agent/plugin_tools/router.py +++ b/surfsense_local/backend/modules/agent/plugin_tools/router.py @@ -3,7 +3,8 @@ from api.dependencies import SessionDep, transact from api.notify import notify_document_updates -from modules.agent.agent_threads.stored_step import stored_step +from modules.agent.engines.both_sessions import stored_step_of +from modules.agent.engines.thread_engine import every_engine from modules.agent.plugin_tools.saved_results import save_result from modules.chat.dependencies import ThreadDep from modules.documents.tasks import ingest_document @@ -32,7 +33,7 @@ async def save_plugin_step( response: Response, ) -> SavedStep: """201 for a new note; 200 when the same call's note was updated.""" - part = await stored_step(thread, part_id) + part = await stored_step_of(session, thread, part_id, every_engine()) note, created = await transact(session, save_result, thread, part) ingest_document(note.id) notify_document_updates(broker, note) diff --git a/surfsense_local/backend/modules/agent/plugin_tools/turn_plugins.py b/surfsense_local/backend/modules/agent/plugin_tools/turn_plugins.py index 17c52c17aa..f9d941b349 100644 --- a/surfsense_local/backend/modules/agent/plugin_tools/turn_plugins.py +++ b/surfsense_local/backend/modules/agent/plugin_tools/turn_plugins.py @@ -29,6 +29,8 @@ class TurnPlugin: headers: dict[str, str] # `_` to `allow` or `ask`; every other tool of the plugin is denied. rules: dict[str, str] = field(default_factory=dict) + # The same rules by the tool's own name, for an engine that names tools otherwise. + tools: dict[str, str] = field(default_factory=dict) # On SurfSense's plugin host, whose calls run longer and which Stop can reach. on_plugin_host: bool = False # Signs in with the SurfSense license, so a refusal is the license, not a sign-in. @@ -79,4 +81,5 @@ def turn_plugins( rule = "ask" if needs_approval(tool) else "allow" name = opencode_tool_name(tool.plugin_id, tool.tool) plugins[tool.plugin_id].rules[name] = rule + plugins[tool.plugin_id].tools[tool.tool] = rule return list(plugins.values()) diff --git a/surfsense_local/backend/modules/agent/plugin_tools/unavailable_plugin.py b/surfsense_local/backend/modules/agent/plugin_tools/unavailable_plugin.py new file mode 100644 index 0000000000..78264ab316 --- /dev/null +++ b/surfsense_local/backend/modules/agent/plugin_tools/unavailable_plugin.py @@ -0,0 +1,10 @@ +"""A ready plugin a turn cannot use, and why, as either engine reports it.""" + +from dataclasses import dataclass +from typing import Literal + + +@dataclass(frozen=True) +class UnavailablePlugin: + title: str + reason: Literal["unreachable", "sign_in", "license"] diff --git a/surfsense_local/backend/modules/agent/thread_folder/retire_workspace_folder.py b/surfsense_local/backend/modules/agent/thread_folder/retire_workspace_folder.py index b4c83f223f..a74829bacf 100644 --- a/surfsense_local/backend/modules/agent/thread_folder/retire_workspace_folder.py +++ b/surfsense_local/backend/modules/agent/thread_folder/retire_workspace_folder.py @@ -40,7 +40,7 @@ def retire_once(session: Session, workspace_id: int) -> None: for thread_id in session.scalars( select(ChatThread.id).where( ChatThread.workspace_id == workspace_id, - ChatThread.opencode_session_id.is_not(None), + ChatThread.agent_engine.is_not(None), ) ) } diff --git a/surfsense_local/backend/modules/agent/thread_folder/sync.py b/surfsense_local/backend/modules/agent/thread_folder/sync.py index f2157d056b..4e4c94c6d1 100644 --- a/surfsense_local/backend/modules/agent/thread_folder/sync.py +++ b/surfsense_local/backend/modules/agent/thread_folder/sync.py @@ -86,12 +86,18 @@ def sync_thread_folder( def _require_same_thread(session: Session, thread: ChatThread) -> None: - """The thread still exists with its session: a deleted newest id is given out again.""" - session_id = session.scalar( - select(ChatThread.opencode_session_id).where(ChatThread.id == thread.id) - ) + """The thread still exists with its engine and session: a deleted newest id is given out again.""" + stored = session.execute( + select(ChatThread.agent_engine, ChatThread.opencode_session_id).where( + ChatThread.id == thread.id + ) + ).one_or_none() session.commit() - if session_id is None or session_id != thread.opencode_session_id: + if ( + stored is None + or stored.agent_engine is None + or tuple(stored) != (thread.agent_engine, thread.opencode_session_id) + ): raise ThreadGoneError(f"thread {thread.id} is no longer the agent's") diff --git a/surfsense_local/backend/modules/agent/tool_endpoint/messages.py b/surfsense_local/backend/modules/agent/tool_endpoint/messages.py index da4c23f85b..0c9ec4ae83 100644 --- a/surfsense_local/backend/modules/agent/tool_endpoint/messages.py +++ b/surfsense_local/backend/modules/agent/tool_endpoint/messages.py @@ -21,7 +21,7 @@ async def answer( ) -> dict[str, Any] | None: """The reply to one message; None for a notification, which gets none. - `scope` is read only for a tool call. + `scope` is read for a listing and a tool call. """ if "id" not in message: return None @@ -30,7 +30,7 @@ async def answer( if method == "initialize": return replies.result(message, initialized(params)) if method == "tools/list": - return replies.result(message, {"tools": offered_tools.listings()}) + return replies.result(message, {"tools": offered_tools.listings(await scope())}) if method == "tools/call": return await offered_tools.call(message, params, session, await scope()) if method == "ping": diff --git a/surfsense_local/backend/modules/agent/tool_endpoint/offered_tools.py b/surfsense_local/backend/modules/agent/tool_endpoint/offered_tools.py index 1b8c9c4926..9c5935f4c1 100644 --- a/surfsense_local/backend/modules/agent/tool_endpoint/offered_tools.py +++ b/surfsense_local/backend/modules/agent/tool_endpoint/offered_tools.py @@ -8,7 +8,6 @@ from starlette.concurrency import run_in_threadpool from api.dependencies import transact -from modules.agent.opencode_config import CONFIG_FILE, declares_image_input from modules.agent.tool_endpoint import replies from modules.agent.tool_endpoint.analyze_data import ANALYZE_DATA from modules.agent.tool_endpoint.convert_document import CONVERT_DOCUMENT @@ -29,7 +28,6 @@ ToolResult, ) from modules.agent.tool_endpoint.turn_scope import TurnScope -from shared.config import get_storage_settings logger = logging.getLogger(__name__) @@ -58,14 +56,14 @@ } -def listings() -> list[dict[str, Any]]: +def listings(scope: TurnScope) -> list[dict[str, Any]]: """What `tools/list` shows: each tool's name, description and input schema. A tool returning images is left out for a model that cannot see them. """ - sees = _model_sees_images() + reads = scope.reads_images() return [ - tool.listing for tool in TOOLS.values() if sees or not tool.needs_image_input + tool.listing for tool in TOOLS.values() if reads or not tool.needs_image_input ] @@ -82,7 +80,7 @@ async def call( try: # Even a tool needing no sources writes into, or reads for, its thread. scope.require_known() - if tool.needs_image_input and not _model_sees_images(): + if tool.needs_image_input and not scope.reads_images(): raise ToolCallError( "The selected model cannot read images, so this tool cannot show it " "anything. Work from the sources' text instead." @@ -92,10 +90,22 @@ async def call( else: made = await transact(session, tool.run, scope, arguments) except ToolCallError as refused: - return replies.result(message, _content(str(refused), (), is_error=True)) + return replies.result( + message, _content(str(refused), (), is_error=True, reads_images=False) + ) if isinstance(made, ToolResult): - return replies.result(message, _content(made.text, made.images, is_error=False)) - return replies.result(message, _content(made, (), is_error=False)) + return replies.result( + message, + _content( + made.text, + made.images, + is_error=False, + reads_images=scope.reads_images(), + ), + ) + return replies.result( + message, _content(made, (), is_error=False, reads_images=False) + ) def without_placeholder_ids( @@ -123,13 +133,8 @@ def without_placeholder_ids( } -def _model_sees_images() -> bool: - """Read from the configuration opencode runs with, as the previews are.""" - return declares_image_input(get_storage_settings().agent_dir / CONFIG_FILE) - - def _content( - text: str, images: tuple[InlineImage, ...], *, is_error: bool + text: str, images: tuple[InlineImage, ...], *, is_error: bool, reads_images: bool ) -> dict[str, Any]: """A tool result: one text item, which opencode passes to the model as it is, then the images: on /chat/completions opencode attaches them after the step, @@ -138,7 +143,7 @@ def _content( The model must still read images now: opencode turns each image sent to one that cannot into an error it is told to report. """ - if images and not _model_sees_images(): + if images and not reads_images: logger.warning( "dropped %d image(s) from a tool result: the model reads no images now", len(images), diff --git a/surfsense_local/backend/modules/agent/tool_endpoint/registration.py b/surfsense_local/backend/modules/agent/tool_endpoint/registration.py index fbc3955f96..a9ed0de19b 100644 --- a/surfsense_local/backend/modules/agent/tool_endpoint/registration.py +++ b/surfsense_local/backend/modules/agent/tool_endpoint/registration.py @@ -33,6 +33,15 @@ logger = logging.getLogger(__name__) +def thread_tools_url(workspace_id: int, thread_id: int) -> str: + """The address naming one thread, as an engine is given it.""" + api = get_settings() + return ( + f"http://{api.host}:{api.port}/agent/tools/workspaces/{workspace_id}" + f"/threads/{thread_id}" + ) + + async def register_thread_tools( client: OpencodeClient, folder: Path, @@ -46,11 +55,7 @@ async def register_thread_tools( `held` is the status opencode reports for them, if it holds them. A turn goes on without the tools if they cannot be given. """ - api = get_settings() - url = ( - f"http://{api.host}:{api.port}/agent/tools/workspaces/{workspace_id}" - f"/threads/{thread_id}" - ) + url = thread_tools_url(workspace_id, thread_id) headers = {"Authorization": f"Bearer {launch_key}"} as_given = server_digest(url, headers) if held == "connected" and given_as(folder, SERVER) == as_given: diff --git a/surfsense_local/backend/modules/agent/tool_endpoint/turn_scope.py b/surfsense_local/backend/modules/agent/tool_endpoint/turn_scope.py index c1c302699b..9c81c483e6 100644 --- a/surfsense_local/backend/modules/agent/tool_endpoint/turn_scope.py +++ b/surfsense_local/backend/modules/agent/tool_endpoint/turn_scope.py @@ -11,9 +11,10 @@ from sqlalchemy.orm import Session +from modules.agent.opencode_config import CONFIG_FILE, declares_image_input from modules.agent.thread_folder.layout import SOURCES from modules.agent.tool_endpoint.tool import ToolCallError -from modules.chat.models import ChatThread +from modules.chat.models import AgentEngineKind, ChatThread from modules.source_scope.resolve import resolve_scope from modules.source_scope.thread_scope import thread_scope from shared.config import get_storage_settings @@ -37,6 +38,15 @@ class TurnScope: document_ids: frozenset[int] # False when the address names no agent thread of the workspace. known: bool = True + # Every Claude model reads images; opencode's model is read again at each use, + # as the user can switch it while a tool runs. + on_claude_code: bool = False + + def reads_images(self) -> bool: + """Whether the thread's model reads images now, so a tool returning them is offered.""" + if self.on_claude_code: + return True + return declares_image_input(get_storage_settings().agent_dir / CONFIG_FILE) def require_known(self) -> None: """Refuse a call from an address naming no agent thread of the workspace.""" @@ -72,7 +82,13 @@ def thread_turn_scope(session: Session, workspace_id: int, thread_id: int) -> Tu if thread is None or thread.workspace_id != workspace_id or not thread.uses_agent: return TurnScope(workspace_id, thread_id, folder, frozenset(), known=False) resolved = resolve_scope(session, workspace_id, thread_scope(thread)) - return TurnScope(workspace_id, thread_id, folder, frozenset(resolved.ids)) + return TurnScope( + workspace_id, + thread_id, + folder, + frozenset(resolved.ids), + on_claude_code=thread.agent_engine is AgentEngineKind.CLAUDE_CODE, + ) def _what_is_selected(selected: frozenset[int]) -> str: diff --git a/surfsense_local/backend/modules/chat/errors.py b/surfsense_local/backend/modules/chat/errors.py index 41b40f3f66..d254525db1 100644 --- a/surfsense_local/backend/modules/chat/errors.py +++ b/surfsense_local/backend/modules/chat/errors.py @@ -2,7 +2,12 @@ import httpx +from modules.egress.service import EgressDeniedError from modules.llm.providers.openai_responses import PlanLimitError, SignInRequiredError +from modules.llm.subscriptions.claude.errors import ( + ClaudePlanLimitError, + ClaudeSignInRequiredError, +) from shared.secrets import UnreadableSecretError @@ -21,6 +26,9 @@ class ChatErrorKind(enum.StrEnum): CONTEXT_TOO_LONG = "context_too_long" SUBSCRIPTION_SIGN_IN = "subscription_sign_in" SUBSCRIPTION_LIMIT = "subscription_limit" + CLAUDE_SIGN_IN = "claude_sign_in" + CLAUDE_LIMIT = "claude_limit" + EGRESS_DISABLED = "egress_disabled" RUNTIME_BUSY = "runtime_busy" NETWORK = "network" TIMEOUT = "timeout" @@ -52,6 +60,17 @@ class ChatErrorKind(enum.StrEnum): "Your ChatGPT plan's usage limit is reached. " "It resets on its own; check your usage in ChatGPT's settings." ), + ChatErrorKind.CLAUDE_SIGN_IN: ( + "Claude Code on this computer needs to sign in again: run claude in a " + "terminal and sign in." + ), + ChatErrorKind.CLAUDE_LIMIT: ( + "Your Claude plan's usage limit is reached. It resets on its own." + ), + ChatErrorKind.EGRESS_DISABLED: ( + "A host this model needs was turned off in Settings > Network. " + "Allow it there, then try again." + ), ChatErrorKind.RUNTIME_BUSY: ( "The replies running together ran out of room in the local model's " "memory. Try again." @@ -104,6 +123,18 @@ def classify_chat_error(exc: Exception, provider: str) -> tuple[ChatErrorKind, s # key is unrecoverable once this install's secret changes, so the only # useful answer names the key rather than reporting a fault. return ChatErrorKind.PROVIDER_AUTH, _MESSAGES[ChatErrorKind.PROVIDER_AUTH] + # Refused before a reply, this is a 403 the egress prompt answers; refused + # during one, as a Claude Code run is when a host is turned off, it is here. + if isinstance(exc, EgressDeniedError): + kind = ChatErrorKind.EGRESS_DISABLED + return kind, _MESSAGES[kind] + # The Claude plan's first: they are the ChatGPT plan's errors too. + if isinstance(exc, ClaudeSignInRequiredError): + kind = ChatErrorKind.CLAUDE_SIGN_IN + return kind, _MESSAGES[kind] + if isinstance(exc, ClaudePlanLimitError): + kind = ChatErrorKind.CLAUDE_LIMIT + return kind, _MESSAGES[kind] if isinstance(exc, SignInRequiredError): kind = ChatErrorKind.SUBSCRIPTION_SIGN_IN return kind, _MESSAGES[kind] diff --git a/surfsense_local/backend/modules/chat/models.py b/surfsense_local/backend/modules/chat/models.py index 637fbe337b..76f1660fb3 100644 --- a/surfsense_local/backend/modules/chat/models.py +++ b/surfsense_local/backend/modules/chat/models.py @@ -15,6 +15,11 @@ class MessageRole(enum.StrEnum): SYSTEM = "system" +class AgentEngineKind(enum.StrEnum): + OPENCODE = "opencode" + CLAUDE_CODE = "claude_code" + + class ChatThread(Base): __tablename__ = "chat_threads" __table_args__ = ( @@ -29,9 +34,17 @@ class ChatThread(Base): # The hosted thread this one was imported from, so a re-import finds it. cloud_id: Mapped[int | None] title: Mapped[str | None] - # Set when the agent answers this thread: its turns live in that opencode - # session, not in chat_messages. Chosen when the thread is opened, and kept. + # The engine whose session holds this thread's turns, not chat_messages; + # None for a chat thread. Chosen when the thread is opened. + agent_engine: Mapped[AgentEngineKind | None] = mapped_column( + text_enum(AgentEngineKind) + ) + # The thread's opencode session, once opencode has run it. opencode_session_id: Mapped[str | None] + # The thread's Claude Code session, chosen when Claude Code first runs it. + claude_session_id: Mapped[str | None] + # A digest of the Claude account that session runs under: another account gets a new one. + claude_account: Mapped[str | None] # The sources the user ticked, resolved on the server per turn; None is all. source_scope: Mapped[dict[str, Any] | None] = mapped_column(JSON) # The oldest message the model is still sent once history was trimmed. Kept @@ -51,7 +64,7 @@ class ChatThread(Base): @property def uses_agent(self) -> bool: """Whether the agent answers this thread rather than the chat.""" - return self.opencode_session_id is not None + return self.agent_engine is not None class ChatMessage(Base): diff --git a/surfsense_local/backend/modules/chat/router.py b/surfsense_local/backend/modules/chat/router.py index 7080179014..006445c98d 100644 --- a/surfsense_local/backend/modules/chat/router.py +++ b/surfsense_local/backend/modules/chat/router.py @@ -16,16 +16,16 @@ from sqlalchemy.orm import Session from api.dependencies import SessionDep, transact -from modules.agent.agent_threads.forget_sessions import forget_thread -from modules.agent.agent_threads.open_session import open_agent_session -from modules.agent.agent_threads.thread_messages import agent_thread_messages -from modules.agent.agent_threads.turn import RunPlace, agent_turn +from modules.agent.agent_threads.turn import RunPlace from modules.agent.dependencies import LaunchKeyDep from modules.agent.engine_choice import ( AgenticRefusedError, new_thread_mode, remember_thread_mode, ) +from modules.agent.engines.both_sessions import thread_turns_of +from modules.agent.engines.handover.take_over import take_over +from modules.agent.engines.thread_engine import every_engine, selected_engine from modules.agent.thread_folder.layout import remove_thread_folder from modules.chat.budget import ( IMAGE_TOKENS, @@ -70,12 +70,13 @@ from modules.llm.capability.modes import ChatMode from modules.llm.providers import llamacpp from modules.llm.providers.protocols import Generator -from modules.llm.providers.types import Message +from modules.llm.providers.types import Message, PlanUsage from modules.llm.resolution import ( ModelResolutionError, ResolvedGeneration, resolve_generation, ) +from modules.llm.subscriptions.plan_usage_frame import plan_usage_frame from modules.source_scope.resolve import resolve_scope from modules.source_scope.schemas import SourceScope from modules.source_scope.thread_scope import store_thread_scope, thread_scope @@ -115,10 +116,9 @@ async def create_thread( session, _new_thread, workspace.id, payload.title, payload.source_scope ) if opening.mode is ChatMode.AGENTIC: - session_id = await open_agent_session(session, thread, launch_key) - if session_id is not None: - await transact(session, _give_to_agent, thread, session_id) - elif payload.mode is ChatMode.AGENTIC: + engine = await transact(session, selected_engine) + opened = await engine.open_session(session, thread, launch_key) + if not opened and payload.mode is ChatMode.AGENTIC: await transact(session, _delete_thread, thread) raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, @@ -182,7 +182,9 @@ async def list_messages( ) -> Sequence[ChatMessage] | list[dict]: if thread.uses_agent: answering = runs.get(thread.id) is not None - return await agent_thread_messages(session, thread, launch_key, answering) + return await thread_turns_of( + session, thread, launch_key, answering, every_engine() + ) return await transact(session, _stored_turns, thread) @@ -200,10 +202,10 @@ async def delete_thread( await run_in_threadpool(session.commit) await runs.stop(thread_id, STOP_SETTLE_SECONDS) folder = None - if thread.opencode_session_id is not None: - folder = await forget_thread( - workspace_id, thread_id, thread.opencode_session_id - ) + # A thread that switched engines has a session in each. + for engine in every_engine(): + if engine.has_session(thread): + folder = await engine.forget_thread(thread) await transact(session, _delete_thread, thread) store.remove_thread(workspace_id, thread_id) if folder is not None: @@ -262,7 +264,13 @@ async def send_message( place = RunPlace( runs, request.app.state.session_factory, request.app.state.broker ) - return await agent_turn(session, thread, payload, launch_key, place) + # The selected model decides the engine; crossing to the other, or to a + # new session of the same, hands over. + taking = await transact(session, selected_engine) + handover = await take_over(session, thread, taking, launch_key) + return await taking.run_turn( + session, thread, payload, launch_key, place, handover + ) resolved, history, hits, retried, scope_record, indexing = await transact( session, _ground, thread, payload ) @@ -427,6 +435,8 @@ def turn( ): run.set_state(RunState("queued", place)) run.set_state(RunState("running")) + # Said once per reply, and again only if it changes. + plan_said: PlanUsage | None = None async for delta in generator.chat_deltas( selected.name, prompt.messages, @@ -436,6 +446,11 @@ def turn( # An endpoint that routes by it keeps the thread on one cache. conversation=f"surfsense-thread-{thread.id}", ): + if delta.plan_usage is not None: + if delta.plan_usage != plan_said: + plan_said = delta.plan_usage + yield _frame(plan_usage_frame(plan_said)) + continue if delta.progress is not None: yield _frame( { @@ -647,17 +662,6 @@ def _new_thread( return thread -def _give_to_agent(session: Session, thread: ChatThread, session_id: str) -> None: - """Record the opencode session that will hold the thread's turns. - - Reloaded here, off the event loop: the write moves `updated_at`, which the - response reads. - """ - thread.opencode_session_id = session_id - session.flush() - session.refresh(thread) - - def _stored_turns(session: Session, thread: ChatThread) -> Sequence[ChatMessage]: """A chat thread's turns, oldest first.""" return session.scalars( diff --git a/surfsense_local/backend/modules/egress/router.py b/surfsense_local/backend/modules/egress/router.py index 048dfc1412..d1359dba3a 100644 --- a/surfsense_local/backend/modules/egress/router.py +++ b/surfsense_local/backend/modules/egress/router.py @@ -1,7 +1,7 @@ from datetime import datetime from fastapi import APIRouter, HTTPException, status -from pydantic import BaseModel +from pydantic import BaseModel, Field from api.dependencies import SessionDep from modules.egress.models import EgressDestination @@ -22,7 +22,10 @@ class DestinationRead(BaseModel): last_call_at: datetime | None -class DestinationWrite(BaseModel): +class DestinationsWrite(BaseModel): + """Every destination one answer covers: one question can name several hosts.""" + + destinations: list[str] = Field(min_length=1) enabled: bool @@ -41,13 +44,21 @@ def read_destinations(session: SessionDep) -> list[DestinationRead]: @router.put( - "/{destination}", response_model=DestinationRead, summary="Allow or refuse one" + "", response_model=list[DestinationRead], summary="Allow or refuse destinations" ) -def write_destination( - destination: str, payload: DestinationWrite, session: SessionDep -) -> DestinationRead: - if not is_destination(destination): +def write_destinations( + payload: DestinationsWrite, session: SessionDep +) -> list[DestinationRead]: + """All or nothing: every destination is checked before any is written, and the + request's one transaction commits them together, so a failed answer allows none. + """ + unknown = [d for d in payload.destinations if not is_destination(d)] + if unknown: raise HTTPException( - status.HTTP_422_UNPROCESSABLE_CONTENT, "unknown egress destination" + status.HTTP_422_UNPROCESSABLE_CONTENT, + f"unknown egress destination: {', '.join(unknown)}", ) - return _read(set_enabled(session, destination, payload.enabled)) + return [ + _read(set_enabled(session, destination, payload.enabled)) + for destination in dict.fromkeys(payload.destinations) + ] diff --git a/surfsense_local/backend/modules/llm/capability/modes/new_chat.py b/surfsense_local/backend/modules/llm/capability/modes/new_chat.py index 07f872cc50..846fec1d44 100644 --- a/surfsense_local/backend/modules/llm/capability/modes/new_chat.py +++ b/surfsense_local/backend/modules/llm/capability/modes/new_chat.py @@ -13,6 +13,7 @@ from modules.llm.capability.modes.remembered import remembered_mode from modules.llm.capability.resolve import Capability, Reason, capability_of from modules.llm.models import SelectedModel +from modules.llm.subscriptions.claude.account import CLAUDE_PLAN from shared.config import get_agent_settings __all__ = ["NewChatModes", "new_chat_modes"] @@ -36,9 +37,13 @@ def new_chat_modes(selected: SelectedModel, facts: ToolFacts) -> NewChatModes: settings = get_agent_settings() switch = settings.agent_untested_models capability = capability_of(selected.name, selected.connection) + # A Claude plan's agent is the user's Claude Code, not opencode. + on_claude_code = ( + selected.connection is not None and selected.connection.auth_kind == CLAUDE_PLAN + ) blocked = ( "agent_not_installed" - if not settings.has_opencode() + if not (on_claude_code or settings.has_opencode()) else gate_block(facts, window_floor=not switch) ) remembered = remembered_mode(selected) diff --git a/surfsense_local/backend/modules/llm/connections/listing.py b/surfsense_local/backend/modules/llm/connections/listing.py index 18c645537c..2928d2c120 100644 --- a/surfsense_local/backend/modules/llm/connections/listing.py +++ b/surfsense_local/backend/modules/llm/connections/listing.py @@ -1,16 +1,25 @@ +import asyncio + from sqlalchemy.orm import Session from modules.llm.connections.service import DiscoveredModel, discover_models from modules.llm.models import ProviderConnection from modules.llm.subscriptions.chatgpt.account import CHATGPT from modules.llm.subscriptions.chatgpt.plan_models import plan_models +from modules.llm.subscriptions.claude.account import CLAUDE_PLAN +from modules.llm.subscriptions.claude.models import claude_models +from modules.llm.subscriptions.claude.proxy import required_claude_proxy_url +from modules.llm.subscriptions.claude.usable_claude import usable_claude async def connection_models( session: Session, connection: ProviderConnection ) -> list[DiscoveredModel]: """What a connection offers: its plan's list when signed in with ChatGPT, - its server's `/models` otherwise.""" + Claude Code's list for a Claude plan, its server's `/models` otherwise.""" if connection.auth_kind == CHATGPT: return await plan_models(session.get_bind(), connection) + if connection.auth_kind == CLAUDE_PLAN: + binary = await asyncio.to_thread(usable_claude) + return await claude_models(binary, required_claude_proxy_url()) return await discover_models(connection) diff --git a/surfsense_local/backend/modules/llm/connections/router.py b/surfsense_local/backend/modules/llm/connections/router.py index ec208beacb..4b8c0f54a0 100644 --- a/surfsense_local/backend/modules/llm/connections/router.py +++ b/surfsense_local/backend/modules/llm/connections/router.py @@ -48,6 +48,9 @@ from modules.llm.subscriptions.chatgpt import revocation from modules.llm.subscriptions.chatgpt.account import CHATGPT from modules.llm.subscriptions.chatgpt.tokens import read_tokens +from modules.llm.subscriptions.claude.account import CLAUDE_PLAN +from modules.llm.subscriptions.claude.errors import ClaudeSignInRequiredError +from modules.llm.subscriptions.claude.hosts import SIGN_IN_HOST, require_claude_hosts router = APIRouter(prefix="/connections") @@ -63,7 +66,7 @@ CHAT_TEST_MAX_CHARS = 600 -def _read(connection: ProviderConnection) -> ConnectionRead: +def connection_read(connection: ProviderConnection) -> ConnectionRead: tokens = read_tokens(connection) if connection.auth_kind == CHATGPT else None return ConnectionRead( id=connection.id, @@ -135,6 +138,9 @@ def allowed_connection(session: Session, connection_id: int) -> ProviderConnecti """The stored connection, once egress to its host is allowed.""" connection = _stored(session, connection_id) egress.require(session, egress.host_destination(connection.base_url)) + if connection.auth_kind == CLAUDE_PLAN: + # Claude Code also refreshes its sign-in on a host of its own. + require_claude_hosts(session) return connection @@ -156,7 +162,7 @@ def _save(session: Session, connection: ProviderConnection) -> ConnectionRead: raise HTTPException( status.HTTP_409_CONFLICT, "a connection with this label already exists" ) from error - return _read(connection) + return connection_read(connection) async def _probe_or_reject( @@ -182,7 +188,7 @@ def list_connections(session: SessionDep) -> list[ConnectionRead]: connections = session.scalars( select(ProviderConnection).order_by(ProviderConnection.label) ).all() - return [_read(connection) for connection in connections] + return [connection_read(connection) for connection in connections] @router.post("", response_model=ConnectionRead, status_code=status.HTTP_201_CREATED) @@ -206,7 +212,7 @@ async def update_connection( connection_id: int, payload: ConnectionWrite, session: SessionDep ) -> ConnectionRead: connection = await transact(session, _stored, connection_id) - if connection.auth_kind == CHATGPT: + if connection.auth_kind in (CHATGPT, CLAUDE_PLAN): return await transact(session, _rename, connection, payload) label, base_url, submitted_key = _candidate(payload) api_key = ( @@ -224,7 +230,7 @@ async def update_connection( def _rename( session: Session, connection: ProviderConnection, payload: ConnectionWrite ) -> ConnectionRead: - """A ChatGPT connection's address and sign-in are the app's; only its name is the user's.""" + """A plan's address and sign-in are not the user's to edit; only its name is.""" label = payload.label.strip() if not label: raise HTTPException( @@ -248,6 +254,9 @@ def delete_connection(connection_id: int, session: SessionDep) -> Response: session.delete(connection) session.flush() egress.forget_if_unused(session, egress.host_destination(connection.base_url)) + if connection.auth_kind == CLAUDE_PLAN: + # No connection names the sign-in host, so nothing else would clear it. + egress.forget_if_unused(session, f"host:{SIGN_IN_HOST}") # Committed first: the delete stands even if OpenAI never answers. session.commit() if revoke: @@ -408,7 +417,13 @@ async def test_connection_speech( def _sign_in_required(error: SignInRequiredError) -> HTTPException: """409 like `unreadable_secret`: the request is fine, the stored sign-in is not.""" + # A Claude plan's sign-in is Claude Code's, fixed in a terminal. + code = ( + "claude_sign_in_required" + if isinstance(error, ClaudeSignInRequiredError) + else "sign_in_required" + ) return HTTPException( status.HTTP_409_CONFLICT, - detail={"code": "sign_in_required", "message": str(error)}, + detail={"code": code, "message": str(error)}, ) diff --git a/surfsense_local/backend/modules/llm/connections/serves.py b/surfsense_local/backend/modules/llm/connections/serves.py index 7edd389289..e78f884978 100644 --- a/surfsense_local/backend/modules/llm/connections/serves.py +++ b/surfsense_local/backend/modules/llm/connections/serves.py @@ -2,10 +2,12 @@ from modules.llm.models import ProviderConnection from modules.llm.selectable import REMOTE_SLOTS -# A ChatGPT plan's token reaches POST /v1/responses only: no images, speech, -# video or embeddings, whatever model is named. +# A ChatGPT plan's token reaches POST /v1/responses only, and a Claude plan +# reaches its models only through Claude Code: no images, speech, video or +# embeddings, whatever model is named. _SERVES_BY_AUTH: dict[str, tuple[ModelType, ...]] = { "chatgpt": (ModelType.TEXT_GEN,), + "claude_plan": (ModelType.TEXT_GEN,), } diff --git a/surfsense_local/backend/modules/llm/connections/text_generator.py b/surfsense_local/backend/modules/llm/connections/text_generator.py index e3d835b9d8..c7f420632e 100644 --- a/surfsense_local/backend/modules/llm/connections/text_generator.py +++ b/surfsense_local/backend/modules/llm/connections/text_generator.py @@ -7,6 +7,8 @@ from modules.llm.providers.protocols import Generator from modules.llm.subscriptions.chatgpt.account import CHATGPT from modules.llm.subscriptions.chatgpt.plan_models import plan_generator +from modules.llm.subscriptions.claude.account import CLAUDE_PLAN +from modules.llm.subscriptions.claude.plan_generator import claude_generator def connection_generator( @@ -20,6 +22,8 @@ def connection_generator( the route is the model's own, as its provider's manifest entry records it.""" if connection.auth_kind == CHATGPT: return plan_generator(engine, connection, reads_images=reads_images) + if connection.auth_kind == CLAUDE_PLAN: + return claude_generator(engine) if connection_route(connection, model) == "responses": return ResponsesChatProvider( connection.base_url, ApiKey(connection.api_key), reads_images=reads_images diff --git a/surfsense_local/backend/modules/llm/models.py b/surfsense_local/backend/modules/llm/models.py index 044d681ad3..778e2537b9 100644 --- a/surfsense_local/backend/modules/llm/models.py +++ b/surfsense_local/backend/modules/llm/models.py @@ -99,7 +99,9 @@ class ProviderConnection(Base): __tablename__ = "provider_connections" __table_args__ = ( CheckConstraint("provider = 'openai_compatible'", name="provider"), - CheckConstraint("auth_kind IN ('api_key', 'chatgpt')", name="auth_kind"), + CheckConstraint( + "auth_kind IN ('api_key', 'chatgpt', 'claude_plan')", name="auth_kind" + ), UniqueConstraint("label"), ) @@ -112,6 +114,7 @@ class ProviderConnection(Base): catalog_provider: Mapped[str] = mapped_column(server_default="custom") api_key_ciphertext: Mapped[bytes | None] # `chatgpt` signs in with a ChatGPT account: no key, OAuth tokens instead. + # `claude_plan` holds nothing: the user's own Claude Code keeps the sign-in. auth_kind: Mapped[str] = mapped_column(server_default="api_key") # Encrypted JSON of the token set; NULL on a `chatgpt` row means signed out. oauth_ciphertext: Mapped[bytes | None] diff --git a/surfsense_local/backend/modules/llm/providers/types.py b/surfsense_local/backend/modules/llm/providers/types.py index 54f664ab55..71b7ef3227 100644 --- a/surfsense_local/backend/modules/llm/providers/types.py +++ b/surfsense_local/backend/modules/llm/providers/types.py @@ -43,6 +43,15 @@ class PromptProgress: total: int +@dataclass(frozen=True) +class PlanUsage: + """Where a plan stands after a reply: past its limit on extra usage, or not, until `resets_at`.""" + + using_extra: bool + # Unix seconds; None when the plan did not say. + resets_at: int | None + + @dataclass(frozen=True) class Delta: """One streamed piece of a reply: answer text, the model's reasoning, or, @@ -51,6 +60,8 @@ class Delta: text: str reasoning: bool = False progress: PromptProgress | None = None + # With no text: where the plan answering stands, so the chat can say before the next reply. + plan_usage: PlanUsage | None = None @dataclass(frozen=True) diff --git a/surfsense_local/backend/modules/llm/resolution.py b/surfsense_local/backend/modules/llm/resolution.py index a41618d175..2719778fa3 100644 --- a/surfsense_local/backend/modules/llm/resolution.py +++ b/surfsense_local/backend/modules/llm/resolution.py @@ -25,6 +25,9 @@ from modules.llm.providers.protocols import Generator, ImageGenerator, TextToSpeech from modules.llm.providers.sdcpp import provider as sdcpp from modules.llm.providers.sdcpp.generator import LocalImageGenerator +from modules.llm.subscriptions.claude.account import CLAUDE_PLAN +from modules.llm.subscriptions.claude.hosts import require_claude_hosts +from modules.llm.subscriptions.claude.usable_claude import usable_claude class ModelResolutionError(RuntimeError): @@ -210,6 +213,11 @@ def _connection(session: Session, selected: SelectedModel) -> ProviderConnection """The selection's connection, once egress to its host is allowed.""" connection = stored_connection(session, selected) egress.require(session, egress.host_destination(connection.base_url)) + if connection.auth_kind == CLAUDE_PLAN: + # Claude Code also refreshes its sign-in on a host of its own. + require_claude_hosts(session) + # Before anything streams, so the send is refused and the composer says why. + usable_claude() return connection diff --git a/surfsense_local/backend/modules/llm/router.py b/surfsense_local/backend/modules/llm/router.py index 44f7e6178d..1760393b9c 100644 --- a/surfsense_local/backend/modules/llm/router.py +++ b/surfsense_local/backend/modules/llm/router.py @@ -50,6 +50,7 @@ from modules.llm.selectable import SLOTS, selectable_for from modules.llm.selection import choose_model, complete_onboarding from modules.llm.subscriptions.chatgpt.router import router as chatgpt_router +from modules.llm.subscriptions.claude.router import router as claude_router from modules.llm.transcription.language_router import router as language_router from modules.llm.transcription.router import router as transcription_router from modules.llm.voices.router import router as voices_router @@ -59,8 +60,10 @@ router.include_router(local_catalog_router) router.include_router(install_jobs_router) router.include_router(remote_catalog_router) -# Before the connections router, so `chatgpt` is never read as a connection id. +# Before the connections router, so `chatgpt` and `claude` are never read as +# connection ids. router.include_router(chatgpt_router) +router.include_router(claude_router) router.include_router(connections_router) router.include_router(voices_router) router.include_router(language_router) diff --git a/surfsense_local/backend/modules/llm/subscriptions/chatgpt/router.py b/surfsense_local/backend/modules/llm/subscriptions/chatgpt/router.py index 802962d0e3..2ef4509857 100644 --- a/surfsense_local/backend/modules/llm/subscriptions/chatgpt/router.py +++ b/surfsense_local/backend/modules/llm/subscriptions/chatgpt/router.py @@ -50,10 +50,10 @@ def _ready_to_sign_in(session: Session, payload: SignInWrite) -> None: @router.get("/chatgpt") def sign_in_option(session: SessionDep) -> SignInOption: """What a ChatGPT connection would serve, and the hosts still off, asked - about one by one before the sign-in starts. + about in one question before the sign-in starts. - `request` asks about one refused host per call, and cannot tell a host - the user declined from a second one, so the renderer asks from this list. + Asked up front from this list rather than after a refused request, so a + sign-in never starts with a host it needs still off. """ endpoints = get_endpoints() refused: list[SignInHost] = [] diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/__init__.py b/surfsense_local/backend/modules/llm/subscriptions/claude/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/account.py b/surfsense_local/backend/modules/llm/subscriptions/claude/account.py new file mode 100644 index 0000000000..0d6db0df27 --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/claude/account.py @@ -0,0 +1,36 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from modules.llm.models import ProviderConnection + +CLAUDE_PLAN = "claude_plan" +# Where every model call goes, so its egress is the one the plan asks for. +API_URL = "https://api.anthropic.com" +# The plan's models are Anthropic's, so the manifest's Anthropic entries describe them. +CATALOG_PROVIDER = "anthropic" + + +class ClaudePlanExistsError(Exception): + def __init__(self) -> None: + super().__init__("this install already has a Claude plan connection") + + +def add_claude_plan(session: Session, label: str) -> ProviderConnection: + """A connection holding no credential: the user's Claude Code keeps the sign-in. + + One per install, since Claude Code holds one sign-in. + """ + if session.scalar( + select(ProviderConnection.id).where(ProviderConnection.auth_kind == CLAUDE_PLAN) + ): + raise ClaudePlanExistsError + connection = ProviderConnection( + label=label, + provider="openai_compatible", + base_url=API_URL, + catalog_provider=CATALOG_PROVIDER, + auth_kind=CLAUDE_PLAN, + ) + session.add(connection) + session.flush() + return connection diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/chat.py b/surfsense_local/backend/modules/llm/subscriptions/claude/chat.py new file mode 100644 index 0000000000..0cb5fc5535 --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/claude/chat.py @@ -0,0 +1,192 @@ +import asyncio +import json +from collections.abc import AsyncIterator, Awaitable, Callable +from pathlib import Path + +from claude_agent_sdk import ResultMessage, StreamEvent, SystemMessage + +from modules.egress.service import EgressDeniedError +from modules.llm.catalog.remote.manifest.loader import remote_lookup +from modules.llm.catalog.remote.reads_images import remote_reads_images +from modules.llm.model_type import ModelType +from modules.llm.providers.openai_compatible.chat import ( + BETWEEN_TOKENS_SECONDS, + FIRST_TOKEN_SECONDS, +) +from modules.llm.providers.stream_deadline import with_deadlines +from modules.llm.providers.types import Delta, Message, Model +from modules.llm.subscriptions.claude.account import CATALOG_PROVIDER +from modules.llm.subscriptions.claude.chat_prompt import system_prompt +from modules.llm.subscriptions.claude.chat_runs import ChatRuns +from modules.llm.subscriptions.claude.errors import ( + failure_of, +) +from modules.llm.subscriptions.claude.extra_usage import plan_usage +from modules.llm.subscriptions.claude.launch import launch_options +from modules.llm.subscriptions.claude.locate import this_machines_claude +from modules.llm.subscriptions.claude.models import claude_models +from modules.llm.subscriptions.claude.run import ClaudeRun, Prompt +from modules.llm.subscriptions.claude.status import ClaudeState, read_status +from modules.llm.subscriptions.claude.usable_claude import usable_claude + +# Claude Code returns a schema's answer through a tool of its own, which takes +# a second turn; measured, one call in four used a third. +_TURNS_FOR_A_SCHEMA = 3 + +ProxyRefusal = Callable[[], Awaitable[EgressDeniedError | None]] + + +async def _no_refusal() -> EgressDeniedError | None: + return None + + +class ClaudePlanProvider: + """A Generator over the user's own Claude Code with every tool off (ADR 0052). + + A token cap and a temperature are accepted and dropped: Claude Code exposes + neither. + """ + + name = "claude_plan" + + def __init__( + self, + *, + runs: ChatRuns, + work_dir: Path, + find_claude: Callable[[], Path | None] = this_machines_claude, + proxy_url: Callable[[], str | None] = lambda: None, + proxy_refusal: ProxyRefusal = _no_refusal, + ) -> None: + self._runs = runs + # Empty, so no project file is near the run. + self._work_dir = work_dir + self._find_claude = find_claude + # Read when a run starts, so a missing proxy refuses that run, not the resolver. + self._proxy_url = proxy_url + # Asked only when Claude Code retries, whether a Claude host is off. + self._proxy_refusal = proxy_refusal + + def _binary(self) -> Path: + return usable_claude(self._find_claude) + + async def health(self) -> bool: + try: + proxy_url = self._proxy_url() + except RuntimeError: + return False + found = await asyncio.to_thread( + read_status, self._find_claude(), proxy_url=proxy_url + ) + return found.state is ClaudeState.READY + + async def models(self) -> list[Model]: + return [ + Model(m.name, installed=True, types=(ModelType.TEXT_GEN,)) + for m in await claude_models(self._binary(), self._proxy_url()) + ] + + async def context_tokens(self, model: str) -> int | None: + found = remote_lookup().classify(model, provider=CATALOG_PROVIDER) + return found.supports.context_window if found.supports else None + + async def sees_images(self, model: str) -> bool | None: + return remote_reads_images(model, CATALOG_PROVIDER) + + async def token_count(self, model: str, text: str) -> int | None: + return None + + async def chat( + self, + model: str, + messages: list[Message], + *, + max_tokens: int | None = None, + temperature: float | None = None, + reasoning: bool | None = None, + json_schema: dict | None = None, + conversation: str | None = None, + ) -> AsyncIterator[str]: + async for delta in self.chat_deltas( + model, + messages, + reasoning=reasoning, + json_schema=json_schema, + conversation=conversation, + ): + if not delta.reasoning and delta.plan_usage is None: + yield delta.text + + async def chat_deltas( + self, + model: str, + messages: list[Message], + *, + max_tokens: int | None = None, + temperature: float | None = None, + reasoning: bool | None = None, + json_schema: dict | None = None, + conversation: str | None = None, + ) -> AsyncIterator[Delta]: + options = launch_options( + self._binary(), + proxy_url=self._proxy_url(), + model=model, + tools=[], + max_turns=_TURNS_FOR_A_SCHEMA if json_schema is not None else 1, + system_prompt=system_prompt(messages), + include_partial_messages=True, + extra_args={"no-session-persistence": None}, + cwd=str(self._work_dir), + # Without `summarized`, Claude Code omits the thinking text. + thinking={"type": "adaptive", "display": "summarized"} + if reasoning + else None, + output_format={"type": "json_schema", "schema": json_schema} + if json_schema is not None + else None, + ) + settings = (model, bool(reasoning), json.dumps(json_schema, sort_keys=True)) + async with self._runs.turn(conversation, settings, options, messages) as ( + run, + prompt, + ): + async for delta in with_deadlines( + _answer( + run, + prompt, + structured=json_schema is not None, + proxy_refusal=self._proxy_refusal, + ), + first_item_seconds=FIRST_TOKEN_SECONDS, + between_items_seconds=BETWEEN_TOKENS_SECONDS, + subject="Claude Code", + ): + yield delta + + +async def _answer( + run: ClaudeRun, prompt: Prompt, *, structured: bool, proxy_refusal: ProxyRefusal +) -> AsyncIterator[Delta]: + async for message in run.send(prompt): + retrying = isinstance(message, SystemMessage) and message.subtype == "api_retry" + refusal = await proxy_refusal() if retrying else None + if (failure := failure_of(message, proxy_refusal=refusal)) is not None: + raise failure + if (usage := plan_usage(message)) is not None: + yield Delta("", plan_usage=usage) + continue + if isinstance(message, ResultMessage): + if structured: + yield Delta(json.dumps(message.structured_output)) + return + if structured or not isinstance(message, StreamEvent): + continue + event = message.event + if event.get("type") != "content_block_delta": + continue + delta = event.get("delta", {}) + if delta.get("type") == "text_delta": + yield Delta(delta["text"]) + elif delta.get("type") == "thinking_delta": + yield Delta(delta["thinking"], reasoning=True) diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/chat_prompt.py b/surfsense_local/backend/modules/llm/subscriptions/claude/chat_prompt.py new file mode 100644 index 0000000000..c7f4ae47f8 --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/claude/chat_prompt.py @@ -0,0 +1,79 @@ +"""What a chat run is sent: the whole conversation as text for a new run, only +the newest message for a run that already holds the rest.""" + +import base64 +from collections.abc import Sequence +from typing import Any + +from modules.llm.providers.types import Message +from modules.llm.subscriptions.claude.run import Prompt + + +def system_prompt(messages: Sequence[Message]) -> str | None: + """The first system message, given whole in place of Claude Code's own prompt.""" + if messages and messages[0].role == "system": + return messages[0].content + return None + + +def _turns(messages: Sequence[Message]) -> list[Message]: + return list(messages[1:] if system_prompt(messages) is not None else messages) + + +def _newest(message: Message) -> Prompt: + if not message.images: + return message.content + blocks: list[dict[str, Any]] = [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": image.mime, + "data": base64.b64encode(image.data).decode(), + }, + } + for image in message.images + ] + return [*blocks, {"type": "text", "text": message.content}] + + +def whole_conversation(messages: Sequence[Message]) -> Prompt: + """Earlier turns tagged by role, then the newest message on its own.""" + turns = _turns(messages) + *earlier, newest = turns + if not earlier: + return _newest(newest) + told = "\n".join(f"<{t.role}>{t.content}" for t in earlier) + preamble = ( + "\n" + f"{told}\n" + "\n" + "Continue this conversation: answer only the newest message.\n\n" + ) + newest_prompt = _newest(newest) + if isinstance(newest_prompt, str): + return preamble + newest_prompt + return [{"type": "text", "text": preamble}, *newest_prompt] + + +def newest_message(messages: Sequence[Message]) -> Prompt: + return _newest(_turns(messages)[-1]) + + +def continues(held: Sequence[Message], messages: Sequence[Message]) -> bool: + """Whether `messages` is `held`, the reply to it, and one new user message. + + The reply's text is not compared: the chat stores it after resolving + citations, so it differs from what the run wrote while meaning the same. + """ + if len(messages) != len(held) + 2: + return False + same_start = all( + (a.role, a.content, a.images) == (b.role, b.content, b.images) + for a, b in zip(held, messages, strict=False) + ) + return ( + same_start + and messages[len(held)].role == "assistant" + and messages[-1].role == "user" + ) diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/chat_runs.py b/surfsense_local/backend/modules/llm/subscriptions/claude/chat_runs.py new file mode 100644 index 0000000000..5b47f1ca50 --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/claude/chat_runs.py @@ -0,0 +1,131 @@ +"""Warm Claude Code runs, one per conversation, so the next message skips the +start-up: measured 0.9 s to the first token warm against 2.3 s cold.""" + +import asyncio +import contextlib +import time +from collections import OrderedDict +from collections.abc import AsyncIterator, Awaitable, Callable, Hashable, Sequence +from dataclasses import dataclass, field + +from claude_agent_sdk import ClaudeAgentOptions + +from modules.llm.providers.types import Message +from modules.llm.subscriptions.claude.chat_prompt import ( + continues, + newest_message, + whole_conversation, +) +from modules.llm.subscriptions.claude.run import ClaudeRun, Prompt + +Opener = Callable[[ClaudeAgentOptions], Awaitable[ClaudeRun]] + +# Each warm run is a Claude Code process holding memory; five minutes covers a +# reply being read and answered. +CAPACITY = 4 +IDLE_SECONDS = 300.0 + + +@dataclass +class _Warm: + run: ClaudeRun + settings: Hashable + held: tuple[Message, ...] = () + used_at: float = 0.0 + busy: asyncio.Lock = field(default_factory=asyncio.Lock) + + +class ChatRuns: + def __init__( + self, + opener: Opener, + *, + capacity: int = CAPACITY, + idle_seconds: float = IDLE_SECONDS, + clock: Callable[[], float] = time.monotonic, + ) -> None: + self._open = opener + self._capacity = capacity + self._idle = idle_seconds + self._clock = clock + self._warm: OrderedDict[str, _Warm] = OrderedDict() + + async def _close(self, run: ClaudeRun) -> None: + # A run that will not close cleanly is gone either way. + with contextlib.suppress(Exception): + await run.close() + + async def _close_idle(self) -> None: + now = self._clock() + for key, warm in list(self._warm.items()): + if now - warm.used_at > self._idle and not warm.busy.locked(): + del self._warm[key] + await self._close(warm.run) + + @contextlib.asynccontextmanager + async def turn( + self, + conversation: str | None, + settings: Hashable, + options: ClaudeAgentOptions, + messages: Sequence[Message], + ) -> AsyncIterator[tuple[ClaudeRun, Prompt]]: + """A run to answer `messages` and what to send it. + + `settings` names what the run was opened with (model, reasoning, schema): + a run opened otherwise is not reused. + """ + await self._close_idle() + warm = self._warm.get(conversation) if conversation else None + if ( + warm is not None + and not warm.busy.locked() + and warm.settings == settings + and continues(warm.held, messages) + ): + async with warm.busy: + self._warm.move_to_end(conversation) + try: + yield warm.run, newest_message(messages) + except BaseException: + del self._warm[conversation] + await self._close(warm.run) + raise + warm.held, warm.used_at = tuple(messages), self._clock() + return + + if warm is not None and not warm.busy.locked(): + del self._warm[conversation] + await self._close(warm.run) + run = await self._open(options) + if conversation is None or (warm is not None and warm.busy.locked()): + try: + yield run, whole_conversation(messages) + finally: + await self._close(run) + return + + fresh = _Warm(run, settings, used_at=self._clock()) + async with fresh.busy: + # Pooled before it answers, so a message sent meanwhile sees it busy. + self._warm[conversation] = fresh + await self._close_over_capacity() + try: + yield run, whole_conversation(messages) + except BaseException: + if self._warm.get(conversation) is fresh: + del self._warm[conversation] + await self._close(fresh.run) + raise + fresh.held, fresh.used_at = tuple(messages), self._clock() + + async def _close_over_capacity(self) -> None: + idle = [key for key, warm in self._warm.items() if not warm.busy.locked()] + while len(self._warm) > self._capacity and idle: + await self._close(self._warm.pop(idle.pop(0)).run) + + async def close_all(self) -> None: + """Every warm run, when the API stops.""" + while self._warm: + _, warm = self._warm.popitem() + await self._close(warm.run) diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/errors.py b/surfsense_local/backend/modules/llm/subscriptions/claude/errors.py new file mode 100644 index 0000000000..7c81147f17 --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/claude/errors.py @@ -0,0 +1,63 @@ +from datetime import UTC, datetime + +from claude_agent_sdk import ( + AssistantMessage, + RateLimitEvent, + ResultMessage, + SystemMessage, +) + +from modules.llm.providers.openai_responses.errors import ( + PlanLimitError, + SignInRequiredError, +) +from modules.llm.subscriptions.claude.extra_usage import refused + + +class ClaudeSignInRequiredError(SignInRequiredError): + """Claude Code is gone or signed out: fixed in a terminal, not in SurfSense.""" + + +class ClaudePlanLimitError(PlanLimitError): + """The Claude plan's usage limit is reached; it resets on its own.""" + + +class ClaudeTooOldError(Exception): + """The Claude Code found is older than MIN_VERSION: updated in a terminal, not in SurfSense.""" + + +class ClaudeRunError(Exception): + """Claude Code ended a run with an error that is not the plan's or the sign-in's.""" + + +def _limit_reached(resets_at: int | None) -> ClaudePlanLimitError: + if resets_at is None: + return ClaudePlanLimitError("the Claude plan's usage limit is reached") + when = datetime.fromtimestamp(resets_at, UTC).isoformat() + return ClaudePlanLimitError( + f"the Claude plan's usage limit is reached until {when}" + ) + + +def failure_of( + message: object, *, proxy_refusal: Exception | None = None +) -> Exception | None: + """The error a message from Claude Code means for SurfSense, if any. + + `proxy_refusal` is the egress refusal standing when Claude Code retries: + it retries a refused tunnel ten times instead of failing, so its first + retry with a Claude host off is that refusal. + """ + if isinstance(message, AssistantMessage): + if message.error == "authentication_failed": + return ClaudeSignInRequiredError("Claude Code has to sign in again") + if message.error == "rate_limit": + return _limit_reached(None) + if isinstance(message, RateLimitEvent) and refused(message.rate_limit_info): + return _limit_reached(message.rate_limit_info.resets_at) + if isinstance(message, SystemMessage) and message.subtype == "api_retry": + return proxy_refusal + if isinstance(message, ResultMessage) and message.is_error: + detail = "; ".join(message.errors or []) or message.subtype + return ClaudeRunError(f"Claude Code failed: {detail}") + return None diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/extra_usage.py b/surfsense_local/backend/modules/llm/subscriptions/claude/extra_usage.py new file mode 100644 index 0000000000..eb4087386f --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/claude/extra_usage.py @@ -0,0 +1,31 @@ +"""Where the Claude plan stands after a reply: within its limit, or past it on extra usage billed apart. + +Read from how Claude Code 2.1.301 builds its rate-limit event from Anthropic's +headers: past the plan's limit its `status` is `rejected`, and `isUsingOverage` +(the plan refused, extra usage allowed) or `overageInUse` (the +`anthropic-ratelimit-unified-overage-in-use` header) says extra usage pays, so +Claude Code goes on answering. Only within the plan was seen on a live account. +""" + +from claude_agent_sdk import RateLimitEvent, RateLimitInfo + +from modules.llm.providers.types import PlanUsage + + +def on_extra_usage(info: RateLimitInfo) -> bool: + return ( + info.raw.get("isUsingOverage") is True or info.raw.get("overageInUse") is True + ) + + +def refused(info: RateLimitInfo) -> bool: + """Claude Code's own test of a reply that cannot go on: the plan refused, and no extra usage pays.""" + return info.status == "rejected" and not on_extra_usage(info) + + +def plan_usage(message: object) -> PlanUsage | None: + """None for any other message, and for a refusal, which ends the reply as the plan's limit.""" + if not isinstance(message, RateLimitEvent) or refused(message.rate_limit_info): + return None + info = message.rate_limit_info + return PlanUsage(on_extra_usage(info), info.resets_at) diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/hosts.py b/surfsense_local/backend/modules/llm/subscriptions/claude/hosts.py new file mode 100644 index 0000000000..ec38bd7404 --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/claude/hosts.py @@ -0,0 +1,39 @@ +"""The hosts a Claude plan reaches, each its own egress destination (ADR 0027).""" + +from sqlalchemy.orm import Session + +from modules.egress.models import EgressDestination +from modules.egress.service import EgressDeniedError, host_destination + +# Every model call, and Claude Code's model list. +API_HOST = "api.anthropic.com" +# Where Claude Code refreshes its sign-in: `TOKEN_URL` in Claude Code 2.1.301. +SIGN_IN_HOST = "platform.claude.com" +CLAUDE_HOSTS = (API_HOST, SIGN_IN_HOST) +# The prompt words its question by it: one host gets chats, the other a sign-in. +PURPOSE = "claude_plan" + + +def hosts_still_off(session: Session) -> list[str]: + """The Claude hosts not allowed yet, model calls first; reads, never writes.""" + off = [] + for host in CLAUDE_HOSTS: + row = session.get(EgressDestination, host_destination(f"https://{host}")) + if row is None or not row.enabled: + off.append(host) + return off + + +def claude_refusal(session: Session) -> EgressDeniedError | None: + """The refusal naming every Claude host still to allow, or None.""" + off = hosts_still_off(session) + if not off: + return None + first = host_destination(f"https://{off[0]}") + assert first is not None + return EgressDeniedError(first, hosts=off, purpose=PURPOSE) + + +def require_claude_hosts(session: Session) -> None: + if (refusal := claude_refusal(session)) is not None: + raise refusal diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/installed_version.py b/surfsense_local/backend/modules/llm/subscriptions/claude/installed_version.py new file mode 100644 index 0000000000..8a6b39bb1b --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/claude/installed_version.py @@ -0,0 +1,43 @@ +"""The version of the Claude Code installed, read once per binary as installed. + +Read again only when the binary's path or modified time changes, so an update +or another `claude` found first is noticed without running it on every check. +""" + +import subprocess +import sys +from pathlib import Path + +# Without it, a windowless API flashes a console on Windows. +_NO_WINDOW = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0 +_read: dict[tuple[str, int], tuple[int, ...] | None] = {} + + +def installed_version(binary: Path) -> tuple[int, ...] | None: + """None when it cannot be run or prints no version this code knows.""" + try: + installed = (str(binary), binary.stat().st_mtime_ns) + except OSError: + return None + if installed not in _read: + _read[installed] = _version(binary) + return _read[installed] + + +def _version(binary: Path) -> tuple[int, ...] | None: + try: + printed = subprocess.run( + [str(binary), "--version"], + capture_output=True, + text=True, + timeout=15, + creationflags=_NO_WINDOW, + ).stdout + except (OSError, subprocess.TimeoutExpired): + return None + # Printed as "2.1.301 (Claude Code)". + first = printed.split(maxsplit=1)[0] if printed.strip() else "" + parts = first.split(".") + if not all(part.isdigit() for part in parts) or len(parts) != 3: + return None + return tuple(int(part) for part in parts) diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/launch.py b/surfsense_local/backend/modules/llm/subscriptions/claude/launch.py new file mode 100644 index 0000000000..5edf14012b --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/claude/launch.py @@ -0,0 +1,40 @@ +from pathlib import Path +from typing import Any + +from claude_agent_sdk import ClaudeAgentOptions + + +def launch_options( + binary: Path, *, proxy_url: str | None, **per_run: Any +) -> ClaudeAgentOptions: + """The only place options for a Claude Code run are built. + + `permission_mode` is fixed because Claude Code 2.1.301 defaults to `auto`, + which approves tools without asking SurfSense. + """ + fixed: dict[str, Any] = { + "cli_path": str(binary), + "setting_sources": [], + "strict_mcp_config": True, + "verbatim_prompts": True, + "permission_mode": "default", + } + if clash := sorted(fixed.keys() & per_run.keys()): + raise ValueError(f"a run cannot set {', '.join(clash)}") + env = {**per_run.pop("env", {}), **claude_env(proxy_url)} + return ClaudeAgentOptions(**fixed, env=env, **per_run) + + +def claude_env(proxy_url: str | None) -> dict[str, str]: + """What every Claude Code process gets, a run or the status check alike. + + A key in the API's environment would silently replace the plan. + """ + env = {"ANTHROPIC_API_KEY": "", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"} + if proxy_url is not None: + env |= { + "HTTPS_PROXY": proxy_url, + "HTTP_PROXY": proxy_url, + "NO_PROXY": "127.0.0.1,localhost", + } + return env diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/locate.py b/surfsense_local/backend/modules/llm/subscriptions/claude/locate.py new file mode 100644 index 0000000000..548310b3b3 --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/claude/locate.py @@ -0,0 +1,85 @@ +import os +import sys +from collections.abc import Iterator, Mapping +from pathlib import Path + +# Where Claude Code's installers put it. A GUI app's PATH usually has none of +# these on macOS and Linux, so they are searched after PATH. +_POSIX_UNDER_HOME = ( + ".local/bin/claude", + ".claude/local/claude", + ".npm-global/bin/claude", + ".volta/bin/claude", +) +_POSIX_SYSTEM_WIDE = ( + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + "/home/linuxbrew/.linuxbrew/bin/claude", + "/usr/bin/claude", +) + + +# The only Claude Code the Agent SDK runs on Windows: it refuses npm's `claude.cmd`, +# since cmd.exe re-parses its arguments (CVE-2024-27980). +WINDOWS_NAME = "claude.exe" + + +def _version_key(node_dir: Path) -> tuple[int, ...]: + """`v22.4.0` as (22, 4, 0); a folder that is not a version ranks below every one.""" + parts = node_dir.name.removeprefix("v").split(".") + if not all(part.isdecimal() for part in parts): + return () + return tuple(int(part) for part in parts) + + +def _posix_installed(home: Path) -> Iterator[Path]: + for relative in _POSIX_UNDER_HOME: + yield home / relative + nvm = sorted((home / ".nvm" / "versions" / "node").glob("*"), key=_version_key) + for node_dir in reversed(nvm): + yield node_dir / "bin" / "claude" + for absolute in _POSIX_SYSTEM_WIDE: + yield Path(absolute) + + +def _windows_installed(home: Path, env: Mapping[str, str]) -> Iterator[Path]: + yield home / ".local" / "bin" / "claude.exe" + if local_app_data := env.get("LOCALAPPDATA"): + yield Path(local_app_data) / "Microsoft" / "WinGet" / "Links" / "claude.exe" + if app_data := env.get("APPDATA"): + # npm's own claude.cmd launcher is skipped: the Agent SDK will not run it. + yield ( + Path(app_data) + / "npm" + / "node_modules" + / "@anthropic-ai" + / "claude-code" + / "bin" + / "claude.exe" + ) + + +def _runnable(candidate: Path, windows: bool) -> bool: + return candidate.is_file() and (windows or os.access(candidate, os.X_OK)) + + +def locate_claude( + *, + path_env: str | None, + home: Path, + windows: bool = sys.platform == "win32", + env: Mapping[str, str] = os.environ, +) -> Path | None: + """The user's own Claude Code; SurfSense ships none.""" + name = WINDOWS_NAME if windows else "claude" + on_path = (Path(d) / name for d in (path_env or "").split(os.pathsep) if d) + installed = _windows_installed(home, env) if windows else _posix_installed(home) + for candidate in (*on_path, *installed): + if _runnable(candidate, windows): + return candidate + return None + + +def this_machines_claude() -> Path | None: + """The Claude Code this process can run, found on its own PATH and home.""" + return locate_claude(path_env=os.environ.get("PATH"), home=Path.home()) diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/models.py b/surfsense_local/backend/modules/llm/subscriptions/claude/models.py new file mode 100644 index 0000000000..87d0fc4451 --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/claude/models.py @@ -0,0 +1,26 @@ +from pathlib import Path + +from modules.llm.connections.serves import served_by +from modules.llm.connections.service import DiscoveredModel +from modules.llm.subscriptions.claude.account import CLAUDE_PLAN +from modules.llm.subscriptions.claude.launch import launch_options +from modules.llm.subscriptions.claude.run import open_run + + +async def claude_models(binary: Path, proxy_url: str | None) -> list[DiscoveredModel]: + """What the account's Claude Code offers, each model once by its full id. + + The full id, not the alias Claude Code shows (`sonnet`), is what the + manifest describes, so its window and capabilities apply; Claude Code + accepts either. + """ + run = await open_run(launch_options(binary, proxy_url=proxy_url, tools=[])) + try: + listed = await run.models() + finally: + await run.close() + names = {m["resolvedModel"] for m in listed if m.get("resolvedModel")} + return [ + DiscoveredModel(name, served_by(CLAUDE_PLAN), "declared") + for name in sorted(names) + ] diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/plan_generator.py b/surfsense_local/backend/modules/llm/subscriptions/claude/plan_generator.py new file mode 100644 index 0000000000..bd7cc1b2da --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/claude/plan_generator.py @@ -0,0 +1,55 @@ +"""The Claude plan's generator, over one pool of warm runs for the API process. + +A generator is made per request, so the pool cannot live on it. Only the API +answers text (ADR 0048), so one pool covers chat, titles and Studio. +""" + +import asyncio + +from claude_agent_sdk import ClaudeAgentOptions +from sqlalchemy import Engine +from sqlalchemy.orm import Session + +from modules.egress.service import EgressDeniedError +from modules.llm.subscriptions.claude.chat import ClaudePlanProvider +from modules.llm.subscriptions.claude.chat_runs import ChatRuns +from modules.llm.subscriptions.claude.hosts import claude_refusal +from modules.llm.subscriptions.claude.proxy import required_claude_proxy_url +from modules.llm.subscriptions.claude.run import ClaudeRun, open_run +from shared.config import get_storage_settings + +_runs: ChatRuns | None = None + + +async def _open(options: ClaudeAgentOptions) -> ClaudeRun: + return await open_run(options) + + +def claude_generator(engine: Engine) -> ClaudePlanProvider: + global _runs + if _runs is None: + _runs = ChatRuns(_open) + work_dir = get_storage_settings().data_dir / "claude" / "chat" + work_dir.mkdir(parents=True, exist_ok=True) + + def refusal() -> EgressDeniedError | None: + with Session(engine) as session: + return claude_refusal(session) + + async def proxy_refusal() -> EgressDeniedError | None: + return await asyncio.to_thread(refusal) + + return ClaudePlanProvider( + runs=_runs, + work_dir=work_dir, + proxy_url=required_claude_proxy_url, + proxy_refusal=proxy_refusal, + ) + + +async def close_claude_runs() -> None: + """Every warm run, when the API stops: no Claude Code outlives it.""" + global _runs + if _runs is not None: + await _runs.close_all() + _runs = None diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/proxy.py b/surfsense_local/backend/modules/llm/subscriptions/claude/proxy.py new file mode 100644 index 0000000000..6f1ece1ccb --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/claude/proxy.py @@ -0,0 +1,55 @@ +"""The API's own proxy for Claude Code, on a port it picks itself. + +Not Electron's, as opencode's is: the API starts every Claude Code run, so it +can hand each one the address. Every run's traffic goes through it, so egress +consent holds for a program SurfSense does not control. +""" + +import contextlib +from collections.abc import AsyncIterator + +from sqlalchemy.orm import Session, sessionmaker + +from modules.agent.plugin_proxy.server import serving_plugin_proxy +from modules.llm.subscriptions.claude.proxy_hosts import DEFAULT_HOSTS, ClaudeHosts + +_url: str | None = None + + +class ProxyNotServingError(RuntimeError): + def __init__(self) -> None: + super().__init__("SurfSense's proxy for Claude Code is not running") + + +def claude_proxy_url() -> str | None: + """Where Claude Code runs send their traffic; None outside a serving API.""" + return _url + + +def required_claude_proxy_url() -> str: + """The proxy's address, or a refusal: Claude Code never runs around egress.""" + if _url is None: + raise ProxyNotServingError + return _url + + +@contextlib.asynccontextmanager +async def serving_claude_proxy( + sessions: sessionmaker[Session], + host: str, + *, + hosts: tuple[tuple[str, int], ...] = DEFAULT_HOSTS, +) -> AsyncIterator[int]: + global _url + async with serving_plugin_proxy( + sessions, + host, + 0, + allowed=ClaudeHosts(sessions, hosts), + client="Claude Code", + ) as port: + _url = f"http://{host}:{port}" + try: + yield port + finally: + _url = None diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/proxy_hosts.py b/surfsense_local/backend/modules/llm/subscriptions/claude/proxy_hosts.py new file mode 100644 index 0000000000..a3d4cb22f9 --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/claude/proxy_hosts.py @@ -0,0 +1,34 @@ +from sqlalchemy.orm import Session, sessionmaker + +from modules.agent.plugin_proxy.allowed_hosts import HTTPS_PORT, AllowedHosts +from modules.egress.models import EgressDestination +from modules.egress.service import host_destination +from modules.llm.subscriptions.claude.hosts import CLAUDE_HOSTS + +DEFAULT_HOSTS = tuple((host, HTTPS_PORT) for host in CLAUDE_HOSTS) + + +class ClaudeHosts(AllowedHosts): + """Anthropic's hosts, each only while its egress is on, and nothing else. + + Its own list, not the plugin proxy's: opencode must never reach Anthropic + directly (ADR 0050), and Claude Code must never reach a plugin. + """ + + def __init__( + self, + sessions: sessionmaker[Session], + hosts: tuple[tuple[str, int], ...] = DEFAULT_HOSTS, + ) -> None: + super().__init__(sessions) + self._hosts = hosts + + def _ready(self, session: Session) -> frozenset[tuple[str, int]]: + allowed = set() + for host, port in self._hosts: + destination = host_destination(f"https://{host}") + # A loopback host is not egress, as everywhere else. + row = session.get(EgressDestination, destination) if destination else None + if destination is None or (row is not None and row.enabled): + allowed.add((host, port)) + return frozenset(allowed) diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/router.py b/surfsense_local/backend/modules/llm/subscriptions/claude/router.py new file mode 100644 index 0000000000..dd471ca5c8 --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/claude/router.py @@ -0,0 +1,64 @@ +from fastapi import APIRouter, HTTPException, status + +from api.dependencies import SessionDep +from modules.llm.connections.router import connection_read +from modules.llm.connections.serves import served_by +from modules.llm.schemas import ConnectionRead +from modules.llm.subscriptions.claude import account +from modules.llm.subscriptions.claude.hosts import hosts_still_off, require_claude_hosts +from modules.llm.subscriptions.claude.locate import this_machines_claude +from modules.llm.subscriptions.claude.proxy import claude_proxy_url +from modules.llm.subscriptions.claude.schemas import ( + ClaudeConnectionWrite, + ClaudeHost, + ClaudeOption, + ClaudeStatusRead, +) +from modules.llm.subscriptions.claude.status import ClaudeState, read_status + +router = APIRouter(prefix="/connections") + + +@router.get("/claude") +def claude_option(session: SessionDep) -> ClaudeOption: + """What a picker needs to offer the plan, at once: it runs no Claude Code.""" + hosts = [ + ClaudeHost(destination=f"host:{host}", host=host) + for host in hosts_still_off(session) + ] + return ClaudeOption(serves=list(served_by(account.CLAUDE_PLAN)), hosts=hosts) + + +@router.get("/claude/status") +def claude_status() -> ClaudeStatusRead: + """Claude Code's state on this machine, read live, so signing in or out in a + terminal shows on the next look.""" + found = read_status(this_machines_claude(), proxy_url=claude_proxy_url()) + return ClaudeStatusRead( + state=found.state, version=found.version, email=found.email, plan=found.plan + ) + + +@router.post("/claude", status_code=status.HTTP_201_CREATED) +def connect_claude( + payload: ClaudeConnectionWrite, session: SessionDep +) -> ConnectionRead: + """A Claude plan connection, once Claude Code is signed in and Anthropic allowed.""" + label = payload.label.strip() + if not label: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, "label must not be empty" + ) + require_claude_hosts(session) + found = read_status(this_machines_claude(), proxy_url=claude_proxy_url()) + if found.state is not ClaudeState.READY: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + {"state": found.state.value, "message": "Claude Code is not ready"}, + ) + try: + connection = account.add_claude_plan(session, label) + except account.ClaudePlanExistsError as error: + raise HTTPException(status.HTTP_409_CONFLICT, str(error)) from error + session.commit() + return connection_read(connection) diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/run.py b/surfsense_local/backend/modules/llm/subscriptions/claude/run.py new file mode 100644 index 0000000000..f55ca113a1 --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/claude/run.py @@ -0,0 +1,84 @@ +import asyncio +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any, Protocol + +from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, Message + +from modules.llm.subscriptions.claude.tested_version import note_untested + +# A user message given as content blocks, such as a handover note beside the text. +Prompt = str | list[dict[str, Any]] + + +class ClaudeRun(Protocol): + """One running Claude Code. Everything else talks to Claude Code through this.""" + + def send( + self, prompt: Prompt, *, uuid: str | None = None + ) -> AsyncIterator[Message]: + """Send a user message and yield what follows, up to its `ResultMessage`. + + `uuid` names the message in the transcript, so a turn read back keeps the + id its live frames used. + """ + ... + + async def interrupt(self) -> None: ... + + async def models(self) -> list[dict[str, Any]]: ... + + async def reconnect(self, server: str) -> None: + """Connect an MCP server again, so its tools are listed anew.""" + ... + + async def close(self) -> None: ... + + +async def _as_user_message( + blocks: list[dict[str, Any]], uuid: str | None +) -> AsyncIterator[dict]: + message = { + "type": "user", + "message": {"role": "user", "content": blocks}, + "parent_tool_use_id": None, + } + yield message if uuid is None else {**message, "uuid": uuid} + + +class _SdkRun: + def __init__(self, client: ClaudeSDKClient) -> None: + self._client = client + + async def send( + self, prompt: Prompt, *, uuid: str | None = None + ) -> AsyncIterator[Message]: + if isinstance(prompt, str): + prompt = [{"type": "text", "text": prompt}] if uuid else prompt + await self._client.query( + prompt if isinstance(prompt, str) else _as_user_message(prompt, uuid) + ) + async for message in self._client.receive_response(): + yield message + + async def interrupt(self) -> None: + await self._client.interrupt() + + async def models(self) -> list[dict[str, Any]]: + info = await self._client.get_server_info() + return list((info or {}).get("models", [])) + + async def reconnect(self, server: str) -> None: + await self._client.reconnect_mcp_server(server) + + async def close(self) -> None: + await self._client.disconnect() + + +async def open_run(options: ClaudeAgentOptions) -> ClaudeRun: + """Start Claude Code with options from `launch_options`.""" + if options.cli_path is not None: + await asyncio.to_thread(note_untested, Path(options.cli_path)) + client = ClaudeSDKClient(options) + await client.connect() + return _SdkRun(client) diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/schemas.py b/surfsense_local/backend/modules/llm/subscriptions/claude/schemas.py new file mode 100644 index 0000000000..1223cf6fb1 --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/claude/schemas.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel, Field + +from modules.llm.model_type import ModelType +from modules.llm.subscriptions.claude.status import ClaudeState + + +class ClaudeHost(BaseModel): + """A host the plan reaches that egress has not allowed yet.""" + + destination: str + host: str + + +class ClaudeOption(BaseModel): + """Whether a slot offers a Claude plan, and the hosts still to allow; never runs Claude Code.""" + + # The slots a Claude plan fills, from the one rule every picker reads. + serves: list[ModelType] + hosts: list[ClaudeHost] + + +class ClaudeStatusRead(BaseModel): + """What the Claude plan dialog shows: Claude Code's state, read live.""" + + state: ClaudeState + version: str | None + email: str | None + plan: str | None + + +class ClaudeConnectionWrite(BaseModel): + label: str = Field(min_length=1, max_length=100) diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/status.py b/surfsense_local/backend/modules/llm/subscriptions/claude/status.py new file mode 100644 index 0000000000..2eacae1d72 --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/claude/status.py @@ -0,0 +1,103 @@ +import hashlib +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path + +from modules.llm.subscriptions.claude.launch import claude_env + +# The oldest Claude Code the Claude plan was measured against (16 Oct 2026). +MIN_VERSION = (2, 1, 300) +# A Console login bills an API account, not a plan. +_PLAN_LOGIN = "claude.ai" + + +class ClaudeState(StrEnum): + NOT_INSTALLED = "not_installed" + TOO_OLD = "too_old" + SIGNED_OUT = "signed_out" + READY = "ready" + + +@dataclass(frozen=True) +class ClaudeStatus: + state: ClaudeState + version: str | None = None + email: str | None = None + plan: str | None = None + + +# Without it, a windowless API flashes a console for every check on Windows. +_NO_WINDOW = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0 + + +def _run(binary: Path, *args: str, timeout: float, env: dict[str, str]) -> str: + """Its answer, or nothing when it fails to give one in time.""" + try: + return subprocess.run( + [str(binary), *args], + env=env, + capture_output=True, + text=True, + timeout=timeout, + creationflags=_NO_WINDOW, + ).stdout + except (OSError, subprocess.TimeoutExpired): + return "" + + +def _sign_in(text: str) -> dict: + try: + answer = json.loads(text) + except json.JSONDecodeError: + return {} + return answer if isinstance(answer, dict) else {} + + +def signed_in_account( + binary: Path | None, *, timeout: float = 15, proxy_url: str | None = None +) -> str | None: + """A digest of the plan account Claude Code is signed in to, or None when it reports none. + + A digest, since only a change matters and the email need not be kept. + """ + if binary is None: + return None + env = {**os.environ, **claude_env(proxy_url)} + auth = _sign_in(_run(binary, "auth", "status", timeout=timeout, env=env)) + if not auth.get("loggedIn") or auth.get("authMethod") != _PLAN_LOGIN: + return None + account = json.dumps([auth.get("email"), auth.get("orgId")]) + return hashlib.sha256(account.encode()).hexdigest() + + +def read_status( + binary: Path | None, *, timeout: float = 15, proxy_url: str | None = None +) -> ClaudeStatus: + """What the user's Claude Code reports about itself and its sign-in. + + Through SurfSense's proxy like every run: it contacts nothing today, and a + later Claude Code that does is held to egress consent all the same. + """ + if binary is None: + return ClaudeStatus(ClaudeState.NOT_INSTALLED) + env = {**os.environ, **claude_env(proxy_url)} + match = re.search( + r"(\d+)\.(\d+)\.(\d+)", _run(binary, "--version", timeout=timeout, env=env) + ) + version = match.group() if match else None + if match is None or tuple(int(n) for n in match.groups()) < MIN_VERSION: + return ClaudeStatus(ClaudeState.TOO_OLD, version=version) + auth = _sign_in(_run(binary, "auth", "status", timeout=timeout, env=env)) + if not auth.get("loggedIn") or auth.get("authMethod") != _PLAN_LOGIN: + return ClaudeStatus(ClaudeState.SIGNED_OUT, version=version) + return ClaudeStatus( + ClaudeState.READY, + version=version, + email=auth.get("email"), + plan=auth.get("subscriptionType"), + ) diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/tested_version.py b/surfsense_local/backend/modules/llm/subscriptions/claude/tested_version.py new file mode 100644 index 0000000000..d5ff0fcaaa --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/claude/tested_version.py @@ -0,0 +1,33 @@ +"""Which Claude Code SurfSense was last measured against, so a newer one is named in the log. + +What docs/architecture/claude-plan.md states of Claude Code was measured on one +version; a newer one may change it without notice, and nothing offline can tell. +""" + +import logging +from pathlib import Path + +from modules.llm.subscriptions.claude.installed_version import installed_version + +logger = logging.getLogger(__name__) + +# The newest Claude Code the `claude_code` tests passed on (17 Oct 2026): raise it only +# once they pass again, `SURFSENSE_CLAUDE_CODE_TESTS=1 uv run pytest -m claude_code`. +NEWEST_TESTED = (2, 1, 301) + +# Each version is named once: every run checks. +_noted: set[tuple[int, ...]] = set() + + +def note_untested(binary: Path) -> None: + """Warn once per version when it is newer than NEWEST_TESTED.""" + version = installed_version(binary) + if version is None or version <= NEWEST_TESTED or version in _noted: + return + _noted.add(version) + logger.warning( + "Claude Code %s is newer than %s, the newest SurfSense was tested on; " + "what docs/architecture/claude-plan.md measured may have changed", + ".".join(map(str, version)), + ".".join(map(str, NEWEST_TESTED)), + ) diff --git a/surfsense_local/backend/modules/llm/subscriptions/claude/usable_claude.py b/surfsense_local/backend/modules/llm/subscriptions/claude/usable_claude.py new file mode 100644 index 0000000000..acc4c62fcf --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/claude/usable_claude.py @@ -0,0 +1,31 @@ +"""The Claude Code a run may start: found, and not older than MIN_VERSION. + +Asked before every run and by the chat model's check alike, so the composer and a +send never disagree. A version it cannot read is let through: an answer not yet +seen on another system must not block a plan that may work. +""" + +from collections.abc import Callable +from pathlib import Path + +from modules.llm.subscriptions.claude.errors import ( + ClaudeSignInRequiredError, + ClaudeTooOldError, +) +from modules.llm.subscriptions.claude.installed_version import installed_version +from modules.llm.subscriptions.claude.locate import this_machines_claude +from modules.llm.subscriptions.claude.status import MIN_VERSION + + +def usable_claude(find: Callable[[], Path | None] = this_machines_claude) -> Path: + """Runs `claude --version` only when the binary changed since it was last read.""" + binary = find() + if binary is None: + raise ClaudeSignInRequiredError("Claude Code is not installed") + version = installed_version(binary) + if version is not None and version < MIN_VERSION: + raise ClaudeTooOldError( + f"Claude Code {'.'.join(map(str, version))} is older than " + f"{'.'.join(map(str, MIN_VERSION))}" + ) + return binary diff --git a/surfsense_local/backend/modules/llm/subscriptions/plan_usage_frame.py b/surfsense_local/backend/modules/llm/subscriptions/plan_usage_frame.py new file mode 100644 index 0000000000..362bf65604 --- /dev/null +++ b/surfsense_local/backend/modules/llm/subscriptions/plan_usage_frame.py @@ -0,0 +1,13 @@ +"""The chat frame that says where a plan stands after a reply, from a chat or an agent turn alike.""" + +from typing import Any + +from modules.llm.providers.types import PlanUsage + + +def plan_usage_frame(usage: PlanUsage) -> dict[str, Any]: + return { + "type": "plan-extra-usage", + "using": usage.using_extra, + "resets_at": usage.resets_at, + } diff --git a/surfsense_local/backend/modules/workspaces/router.py b/surfsense_local/backend/modules/workspaces/router.py index aca0e4cf34..d4740b53b0 100644 --- a/surfsense_local/backend/modules/workspaces/router.py +++ b/surfsense_local/backend/modules/workspaces/router.py @@ -5,7 +5,7 @@ from sqlalchemy import select from api.dependencies import SessionDep -from modules.agent.agent_threads.forget_sessions import forget_workspace_sessions +from modules.agent.engines.thread_engine import every_engine from modules.chat.runs.dependencies import ChatRunsDep from modules.chat.stop_workspace_replies import stop_workspace_replies from modules.plugins.bundles.stop_workspace_runs import stop_workspace_runs @@ -75,7 +75,8 @@ def delete_workspace( stop_workspace_replies(session, runs, workspace.id) stop_workspace_runs(session, workspace.id) - forget_workspace_sessions(session, workspace.id) + for engine in every_engine(): + engine.forget_workspace(session, workspace.id) session.delete(workspace) session.commit() diff --git a/surfsense_local/backend/pyproject.toml b/surfsense_local/backend/pyproject.toml index bd899ffd11..8ca9446ddf 100644 --- a/surfsense_local/backend/pyproject.toml +++ b/surfsense_local/backend/pyproject.toml @@ -65,6 +65,7 @@ dependencies = [ "xlsxwriter>=3.2.9", "lameenc>=1.8.4", "mcp>=2.3,<2.4", + "claude-agent-sdk==0.2.168", ] [dependency-groups] @@ -78,6 +79,12 @@ dev = [ # Docling brings torch, and PyPI's Linux torch brings 3GB of CUDA wheels that # an installer would carry to machines that cannot use them. Elsewhere the # published wheels are already CPU-only. + +[tool.uv] +# Built from its source package: the wheels carry a 241 MB Claude Code in +# `_bundled`, and SurfSense ships none (ADR 0057); every run uses the user's own. +no-binary-package = ["claude-agent-sdk"] + [tool.uv.sources] # The rules a plugin's manifest.json follows, shared with the plugin tooling. surfsense-plugin-manifest = { path = "../../plugins/bundles/core/manifest", editable = true } @@ -147,5 +154,6 @@ markers = [ "integration: tests that run against a real SQLite database built by migrations", "packaging: freezes or runs the binaries the app ships; slow, opt-in", "office: runs a real LibreOffice through the test seam (SURFSENSE_TEST_LIBREOFFICE, else a standard install path); skipped when none is found", + "claude_code: runs the user's own installed Claude Code on their Claude plan; skipped unless SURFSENSE_CLAUDE_CODE_TESTS=1 and Claude Code is signed in, uses the plan's limits", "live: calls a paid model (Claude Sonnet unless SURFSENSE_LIVE_MODEL says otherwise) through the real agent path; skipped unless SURFSENSE_LIVE_TESTS=1 and the provider's key are set, costs money", ] diff --git a/surfsense_local/backend/shared/db.py b/surfsense_local/backend/shared/db.py index 1c934dba1c..fe24b8e7eb 100644 --- a/surfsense_local/backend/shared/db.py +++ b/surfsense_local/backend/shared/db.py @@ -65,6 +65,7 @@ def import_models() -> None: A relationship names its target as a string, so an unimported model is a name that cannot resolve. """ + import modules.agent.engines.claude_code.models import modules.artifacts.models import modules.chat.models import modules.chunks.models diff --git a/surfsense_local/backend/tests/claude_agent_stand_in.py b/surfsense_local/backend/tests/claude_agent_stand_in.py new file mode 100644 index 0000000000..f7956c0f1d --- /dev/null +++ b/surfsense_local/backend/tests/claude_agent_stand_in.py @@ -0,0 +1,245 @@ +"""A Claude Code agent turn played from a script, as Claude Code 2.1.301 plays one. + +Each tool call meets the run's own PreToolUse hook and, on an `ask`, its +can_use_tool, as Claude Code calls them; each line is mirrored to the run's +session store, as the SDK mirrors the transcript. +""" + +import asyncio +import json +import uuid +from collections.abc import AsyncIterator +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + Message, + PermissionResultAllow, + ResultMessage, + SystemMessage, + TextBlock, + ToolPermissionContext, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) + +from tests.claude_run_stand_in import ScriptedRun, plan_limits + + +@dataclass(frozen=True) +class Say: + text: str + + +@dataclass(frozen=True) +class Call: + """A tool call and what the tool returns if the gate lets it run.""" + + tool: str + input: dict[str, Any] + result: str = "done" + + +@dataclass(frozen=True) +class Fail: + """Claude Code reports an error on an assistant message, as when signed out.""" + + error: str + + +@dataclass(frozen=True) +class Hang: + """Writes, then waits until the turn is interrupted.""" + + text: str + + +@dataclass(frozen=True) +class Limits: + """Claude Code reports where the plan stands: within its limit, or past it on extra usage.""" + + using_extra: bool + + +Step = Say | Call | Fail | Hang | Limits + + +class ScriptedAgentRun(ScriptedRun): + def __init__( + self, + options: ClaudeAgentOptions, + turns: list[list[Step]], + statuses: dict[str, str] | None = None, + ) -> None: + super().__init__() + self.options = options + self._turns = turns + # Each MCP server's settled status, as the turn's init reports it; connected unless named. + self._statuses = statuses or {} + self._interrupted = asyncio.Event() + # Set once a Hang step has written, so a test can stop the turn mid-reply. + self.hanging = asyncio.Event() + self._made = 0 + # Claude Code's ids are unique across sessions, so a thread's two sessions never share one. + self._run = uuid.uuid4().hex[:8] + # What Claude Code sent that nobody read yet, which the SDK keeps for the next read. + self._unread: list[Message] = [] + + async def interrupt(self) -> None: + """Claude Code ends the turn with a result, read or not.""" + self.interrupted = True + self._unread = [self._result(error=True)] + self._interrupted.set() + + async def send( + self, prompt: Any, *, uuid: str | None = None + ) -> AsyncIterator[Message]: + self.sent.append(prompt) + self.uuids.append(uuid) + for message in self._unread: + yield message + if isinstance(message, ResultMessage): + self._unread = [] + return + await self._mirror( + { + "type": "user", + "uuid": uuid, + "message": {"role": "user", "content": prompt}, + } + ) + yield self._init() + for step in self._turns.pop(0): + async for message in self._play(step): + yield message + if isinstance(message, ResultMessage): + return + yield self._result() + + async def _play(self, step: Step) -> AsyncIterator[Message]: + message_id = self._next("msg") + if isinstance(step, Limits): + yield plan_limits(using_extra=step.using_extra) + return + if isinstance(step, Say | Hang): + yield AssistantMessage( + [TextBlock(step.text)], "sonnet", message_id=message_id + ) + await self._mirror( + _assistant(message_id, {"type": "text", "text": step.text}) + ) + if isinstance(step, Hang): + self.hanging.set() + await self._interrupted.wait() + # Claude Code takes a moment to end the turn after an interrupt. + await asyncio.sleep(0.3) + ended, self._unread = self._unread, [] + yield ended[0] + elif isinstance(step, Fail): + yield AssistantMessage( + [], "sonnet", error=step.error, message_id=message_id + ) + else: + call_id = self._next("toolu") + block = { + "type": "tool_use", + "id": call_id, + "name": step.tool, + "input": step.input, + } + yield AssistantMessage( + [ToolUseBlock(call_id, step.tool, step.input)], + "sonnet", + message_id=message_id, + ) + await self._mirror(_assistant(message_id, block)) + refusal = await self._gate(step, call_id) + result = ToolResultBlock( + call_id, refusal or step.result, is_error=bool(refusal) + ) + yield UserMessage(content=[result]) + await self._mirror( + { + "type": "user", + "uuid": self._next("result"), + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": call_id, + "content": result.content, + "is_error": result.is_error, + } + ], + }, + } + ) + + async def _gate(self, step: Call, call_id: str) -> str | None: + """The refusal the model reads, or None when the call ran.""" + (matcher,) = self.options.hooks["PreToolUse"] + decided = await matcher.hooks[0]( + { + "hook_event_name": "PreToolUse", + "tool_name": step.tool, + "tool_input": step.input, + }, + call_id, + {}, + ) + verdict = decided["hookSpecificOutput"] + if verdict["permissionDecision"] == "allow": + return None + if verdict["permissionDecision"] == "deny": + return f"PreToolUse hook error: {verdict['permissionDecisionReason']}" + answer = await self.options.can_use_tool( + step.tool, step.input, ToolPermissionContext(tool_use_id=call_id) + ) + return None if isinstance(answer, PermissionResultAllow) else answer.message + + async def _mirror(self, entry: dict[str, Any]) -> None: + store = self.options.session_store + session_id = self.options.session_id or self.options.resume + entry = { + "isSidechain": False, + "timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + **entry, + } + await store.append({"project_key": "p", "session_id": session_id}, [entry]) + + def _init(self) -> SystemMessage: + """The turn's opening message, once every MCP server has connected or failed.""" + given = json.loads(Path(self.options.mcp_servers).read_text())["mcpServers"] + servers = [ + {"name": name, "status": self._statuses.get(name, "connected")} + for name in given + ] + return SystemMessage(subtype="init", data={"mcp_servers": servers}) + + def _next(self, kind: str) -> str: + self._made += 1 + return f"{kind}_{self._run}_{self._made}" + + def _result(self, *, error: bool = False) -> ResultMessage: + return ResultMessage( + subtype="error_during_execution" if error else "success", + duration_ms=1, + duration_api_ms=1, + is_error=error, + num_turns=1, + session_id=self.options.session_id or self.options.resume or "", + ) + + +def _assistant(message_id: str, block: dict[str, Any]) -> dict[str, Any]: + return { + "type": "assistant", + "uuid": f"{message_id}-{block['type']}", + "message": {"id": message_id, "role": "assistant", "content": [block]}, + } diff --git a/surfsense_local/backend/tests/claude_code/conftest.py b/surfsense_local/backend/tests/claude_code/conftest.py new file mode 100644 index 0000000000..ad4ea89de6 --- /dev/null +++ b/surfsense_local/backend/tests/claude_code/conftest.py @@ -0,0 +1,26 @@ +"""Tests that run the user's own Claude Code on their plan. + +Skipped unless SURFSENSE_CLAUDE_CODE_TESTS=1 and Claude Code is installed and +signed in, so CI and ordinary runs never touch anyone's plan. +""" + +import os +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from modules.llm.subscriptions.claude.locate import locate_claude +from modules.llm.subscriptions.claude.status import ClaudeState, read_status + + +@pytest.fixture(scope="session") +def claude_binary() -> Iterator[Path]: + """The signed-in Claude Code these tests run, or a skip.""" + if os.environ.get("SURFSENSE_CLAUDE_CODE_TESTS") != "1": + pytest.skip("set SURFSENSE_CLAUDE_CODE_TESTS=1 to run the real Claude Code") + binary = locate_claude(path_env=os.environ.get("PATH"), home=Path.home()) + if read_status(binary).state is not ClaudeState.READY: + pytest.skip("Claude Code is not installed and signed in to a plan") + assert binary is not None + yield binary diff --git a/surfsense_local/backend/tests/claude_code/connect_recorder.py b/surfsense_local/backend/tests/claude_code/connect_recorder.py new file mode 100644 index 0000000000..b5cb8235fc --- /dev/null +++ b/surfsense_local/backend/tests/claude_code/connect_recorder.py @@ -0,0 +1,60 @@ +"""A proxy that tunnels Anthropic's two hosts, refuses every other, and records each CONNECT.""" + +import asyncio +import contextlib +from collections.abc import AsyncIterator +from dataclasses import dataclass, field + +from modules.llm.subscriptions.claude.hosts import CLAUDE_HOSTS + + +@dataclass +class Recorded: + url: str = "" + connects: list[tuple[str, bool]] = field(default_factory=list) + + +async def _pipe(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + while data := await reader.read(65536): + writer.write(data) + await writer.drain() + except OSError: + pass + finally: + writer.close() + + +@contextlib.asynccontextmanager +async def recording_proxy() -> AsyncIterator[Recorded]: + """Serve it on a free loopback port for the length of the block.""" + recorded = Recorded() + + async def handle( + reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + request = await reader.readline() + while (await reader.readline()) not in (b"\r\n", b""): + pass + method, target, *_ = [*request.decode().split(), "", ""] + host, _, port = target.rpartition(":") + allowed = method == "CONNECT" and host in CLAUDE_HOSTS + recorded.connects.append((target, allowed)) + if not allowed: + writer.write(b"HTTP/1.1 403 Forbidden\r\n\r\n") + await writer.drain() + writer.close() + return + upstream_reader, upstream_writer = await asyncio.open_connection( + host, int(port) + ) + writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n") + await writer.drain() + await asyncio.gather( + _pipe(reader, upstream_writer), _pipe(upstream_reader, writer) + ) + + server = await asyncio.start_server(handle, "127.0.0.1", 0) + recorded.url = f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}" + async with server: + yield recorded diff --git a/surfsense_local/backend/tests/claude_code/test_agent_engine.py b/surfsense_local/backend/tests/claude_code/test_agent_engine.py new file mode 100644 index 0000000000..1d0dc289de --- /dev/null +++ b/surfsense_local/backend/tests/claude_code/test_agent_engine.py @@ -0,0 +1,348 @@ +"""What the Claude agent engine relies on, checked against the installed Claude Code. + +Claude Code updates itself, so each behaviour the engine assumes is a test here +(docs/architecture/claude-plan.md, Tests). +""" + +import asyncio +import json +import socket +import subprocess +import threading +import time +import uuid +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import pytest +import uvicorn +from claude_agent_sdk import ( + AssistantMessage, + SystemMessage, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) +from mcp.server.mcpserver import MCPServer + +from modules.agent.engines.claude_code.agent_options import AgentLaunch, agent_options +from modules.agent.engines.claude_code.approvals import Approvals +from modules.agent.engines.claude_code.gate import Gate +from modules.agent.engines.claude_code.mcp_config import write_mcp_config +from modules.agent.engines.claude_code.plugin_statuses import unavailable_plugins +from modules.agent.engines.claude_code.saved_results import claude_home +from modules.agent.engines.claude_code.skills_plugin import build_skills_plugin +from modules.agent.engines.claude_code.turn_link import TurnLink +from modules.agent.opencode_config import skills_folder +from modules.agent.plugin_tools.turn_plugins import TurnPlugin +from modules.llm.subscriptions.claude.run import open_run +from tests.claude_code.connect_recorder import recording_proxy + +pytestmark = pytest.mark.claude_code +SECRET = "plugin-sign-in-7731" +# Past the size Claude Code gives the model whole; it saved an 80.7 KB result to a file. +LONG_RESULT_CHARS = 120_000 +MARKER = "MARKER-4417" + + +@pytest.fixture(scope="module") +def notes_server() -> Iterator[str]: + """A plugin-like MCP server on loopback, which Claude Code reaches around the proxy.""" + server = MCPServer("notes") + + @server.tool() + def save_note(text: str) -> str: + """Save a note.""" + return f"saved {text}" + + @server.tool() + def long_page() -> str: + """Return one long page of the ledger.""" + return "ledger line\n" * (LONG_RESULT_CHARS // 12) + MARKER + + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + app = server.streamable_http_app(stateless_http=True, json_response=True) + runner = uvicorn.Server( + uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning") + ) + threading.Thread(target=runner.run, daemon=True).start() + while not runner.started: + time.sleep(0.05) + yield f"http://127.0.0.1:{port}/mcp" + runner.should_exit = True + + +def _setup( + binary: Path, + tmp_path: Path, + proxy_url: str, + servers: dict[str, Any], + *, + rules: dict[str, str] | None = None, + hidden: list[str] | None = None, +) -> tuple[AgentLaunch, Approvals, asyncio.Queue]: + folder = tmp_path / "thread" + (folder / "outputs").mkdir(parents=True) + (folder / "sources").mkdir() + skills = build_skills_plugin(skills_folder(), tmp_path / "skills-plugin") + approvals = Approvals() + link = TurnLink(7, approvals) + events: asyncio.Queue = asyncio.Queue() + session_id = str(uuid.uuid4()) + gate = Gate( + folder, + skills.skills, + skills.names, + rules or {}, + claude_home=claude_home(), + session_id=session_id, + ) + link.begin(gate, events, session_id) + launch = AgentLaunch( + binary=binary, + proxy_url=proxy_url, + folder=folder, + model="haiku", + session_id=session_id, + resumed=False, + mcp_file=write_mcp_config(tmp_path / "mcp", servers), + disallowed=hidden or [], + skills=skills, + link=link, + store=None, + thinking=False, + ) + return launch, approvals, events + + +async def _turn(launch: AgentLaunch, prompt: str) -> list[Any]: + run = await open_run(agent_options(launch)) + try: + return [message async for message in run.send(prompt)] + finally: + await run.close() + + +def _results(messages: list[Any]) -> dict[str, ToolResultBlock]: + """Each call's result, by the tool it called.""" + names = { + block.id: block.name + for message in messages + if isinstance(message, AssistantMessage) + for block in message.content + if isinstance(block, ToolUseBlock) + } + return { + names[block.tool_use_id]: block + for message in messages + if isinstance(message, UserMessage) and isinstance(message.content, list) + for block in message.content + if isinstance(block, ToolResultBlock) and block.tool_use_id in names + } + + +async def test_the_gate_decides_each_call_in_default_mode( + claude_binary: Path, tmp_path: Path +) -> None: + """A write under outputs/ runs; a read outside the thread is refused with the gate's reason.""" + async with recording_proxy() as proxy: + launch, _, _ = _setup(claude_binary, tmp_path, proxy.url, {}) + messages = await _turn( + launch, + "Do two things with your tools, then reply OK: write the text 'ok' to " + "outputs/done.md, and read the file /etc/hosts.", + ) + + assert (launch.folder / "outputs" / "done.md").read_text().strip() == "ok" + refused = _results(messages)["Read"] + assert refused.is_error + assert "thread's folder" in str(refused.content) + + +async def test_the_launch_hides_tools_keeps_headers_off_the_command_line_and_uses_the_proxy( + claude_binary: Path, tmp_path: Path, notes_server: str +) -> None: + """A denied tool is never offered; a remote plugin is reached through the proxy only.""" + servers = { + "notes": {"type": "http", "url": notes_server}, + "remote": { + "type": "http", + "url": "https://plugin.example.com/mcp", + "headers": {"Authorization": f"Bearer {SECRET}"}, + }, + } + async with recording_proxy() as proxy: + launch, _, _ = _setup( + claude_binary, + tmp_path, + proxy.url, + servers, + hidden=["mcp__notes__long_page"], + ) + run = await open_run(agent_options(launch)) + try: + processes = subprocess.run( + ["ps", "-axww", "-o", "args="], + capture_output=True, + text=True, + check=True, + ).stdout + messages = [m async for m in run.send("Reply OK.")] + finally: + await run.close() + + init = next( + m for m in messages if isinstance(m, SystemMessage) and m.subtype == "init" + ) + assert "mcp__notes__save_note" in init.data["tools"] + assert "mcp__notes__long_page" not in init.data["tools"] + assert "surfsense:surfsense-documents" in init.data["skills"] + assert SECRET not in processes + assert ("plugin.example.com:443", False) in proxy.connects + + +async def test_the_model_reads_a_long_result_claude_code_saved_for_it( + claude_binary: Path, tmp_path: Path, notes_server: str +) -> None: + """Too large to give whole, it is saved under the session; the gate lets the model read it.""" + async with recording_proxy() as proxy: + launch, _, _ = _setup( + claude_binary, + tmp_path, + proxy.url, + {"notes": {"type": "http", "url": notes_server}}, + rules={"mcp__notes__long_page": "allow"}, + ) + messages = await _turn( + launch, + "Call long_page once. Its output ends with a line that starts with " + "MARKER-. Find that line in the full output and reply with it alone.", + ) + + results = _results(messages) + assert "Full output saved to" in json.dumps( + results["mcp__notes__long_page"].content + ) + reads = [results[tool] for tool in ("Read", "Grep") if tool in results] + assert reads + assert not any(result.is_error for result in reads) + replies = [ + block.text + for message in messages + if isinstance(message, AssistantMessage) + for block in message.content + if hasattr(block, "text") + ] + assert MARKER in " ".join(replies) + + +async def test_an_approval_can_wait_longer_than_a_hooks_timeout( + claude_binary: Path, tmp_path: Path, notes_server: str +) -> None: + """The hook answers `ask` at once; can_use_tool waits for the user past 60 s.""" + async with recording_proxy() as proxy: + launch, approvals, events = _setup( + claude_binary, + tmp_path, + proxy.url, + {"notes": {"type": "http", "url": notes_server}}, + rules={"mcp__notes__save_note": "ask"}, + ) + + async def answer_late() -> None: + asked = await events.get() + await asyncio.sleep(65) + approvals.answer(7, asked.properties["id"], "once") + + answering = asyncio.create_task(answer_late()) + messages = await _turn( + launch, "Call save_note with the text 'q3' once, then reply OK." + ) + await answering + + saved = _results(messages)["mcp__notes__save_note"] + assert not saved.is_error + assert "saved q3" in json.dumps(saved.content) + + +async def test_after_an_interrupt_the_same_claude_code_answers_the_next_message( + claude_binary: Path, tmp_path: Path +) -> None: + """Read to its result, the interrupted turn leaves nothing for the next one to read.""" + async with recording_proxy() as proxy: + launch, _, _ = _setup(claude_binary, tmp_path, proxy.url, {}) + run = await open_run(agent_options(launch)) + try: + first = [] + async for message in run.send("Write a 600-word essay about rivers."): + first.append(message) + if isinstance(message, AssistantMessage) and len(first) > 2: + await run.interrupt() + second = [m async for m in run.send("Reply with exactly: AGAIN")] + finally: + await run.close() + + assert "AGAIN" in json.dumps( + [ + b.text + for m in second + if isinstance(m, AssistantMessage) + for b in m.content + if hasattr(b, "text") + ] + ) + + +@pytest.fixture(scope="module") +def slow_server() -> Iterator[str]: + """A plugin a few seconds away, as a remote one is: each request takes 1.5 s.""" + server = MCPServer("slow") + + @server.tool() + def ping() -> str: + """Answer pong.""" + return "pong" + + app = server.streamable_http_app(stateless_http=True, json_response=True) + + async def slow(scope: dict, receive: Any, send: Any) -> None: + if scope["type"] == "http": + await asyncio.sleep(1.5) + await app(scope, receive, send) + + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + runner = uvicorn.Server( + uvicorn.Config(slow, host="127.0.0.1", port=port, log_level="warning") + ) + threading.Thread(target=runner.run, daemon=True).start() + while not runner.started: + time.sleep(0.05) + yield f"http://127.0.0.1:{port}/mcp" + runner.should_exit = True + + +async def test_a_plugin_slow_to_connect_is_settled_in_the_turns_init( + claude_binary: Path, tmp_path: Path, slow_server: str +) -> None: + """Claude Code holds a turn's init until its plugins connect; the engine reads them there.""" + async with recording_proxy() as proxy: + launch, _, _ = _setup( + claude_binary, + tmp_path, + proxy.url, + {"slow": {"type": "http", "url": slow_server}}, + rules={"mcp__slow__ping": "allow"}, + ) + messages = await _turn(launch, "Reply OK.") + + init = next( + m for m in messages if isinstance(m, SystemMessage) and m.subtype == "init" + ) + plugin = TurnPlugin(name="slow", title="Slow", url=slow_server, headers={}) + assert unavailable_plugins(init.data["mcp_servers"], [plugin]) == [] + assert "mcp__slow__ping" in init.data["tools"] diff --git a/surfsense_local/backend/tests/claude_code/test_app_agent.py b/surfsense_local/backend/tests/claude_code/test_app_agent.py new file mode 100644 index 0000000000..2495ed6e46 --- /dev/null +++ b/surfsense_local/backend/tests/claude_code/test_app_agent.py @@ -0,0 +1,154 @@ +"""An Agentic thread answered by the developer's own Claude Code, through the app's API.""" + +import json +import socket +import threading +import time +from collections.abc import Iterator +from pathlib import Path + +import pytest +import uvicorn +from httpx import AsyncClient +from mcp.server.mcpserver import MCPServer +from sqlalchemy import Engine + +from modules.agent.engines.claude_code.agent_proxy import serving_agent_proxy +from modules.agent.engines.thread_engine import close_engines +from modules.llm.subscriptions.claude.proxy import serving_claude_proxy +from modules.plugins.gateway.source_tool import SourceTool +from modules.plugins.installed.custom_plugin import store_custom_plugin +from modules.plugins.installed.models import ToolApproval +from modules.plugins.installed.third_party import set_third_party_on +from modules.plugins.installed.tool_settings import set_tool +from shared.db import create_session_factory + +pytestmark = pytest.mark.claude_code +HAIKU = "claude-haiku-5-5" +# Past the size Claude Code gives the model whole: it is saved, and the end is only there. +MARKER = "MARKER-8812" +LONG_PAGE = "ledger line\n" * 10_000 + MARKER + + +@pytest.fixture +def ledger_plugin() -> Iterator[str]: + """A plugin on this computer whose one tool returns about 120 KB.""" + server = MCPServer("ledger") + + @server.tool() + def long_page() -> str: + """Return the whole ledger.""" + return LONG_PAGE + + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + app = server.streamable_http_app(stateless_http=True, json_response=True) + runner = uvicorn.Server( + uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning") + ) + threading.Thread(target=runner.run, daemon=True).start() + while not runner.started: + time.sleep(0.05) + yield f"http://127.0.0.1:{port}/mcp" + runner.should_exit = True + + +def _connect(engine: Engine, url: str) -> None: + """Connected and ready, its tool read-only and allowed without asking.""" + long_page = SourceTool( + name="long_page", + title=None, + description="Return the whole ledger.", + input_schema={"type": "object", "properties": {}}, + annotations={"readOnlyHint": True}, + ) + with create_session_factory(engine)() as session: + store_custom_plugin(session, "ledger", "Ledger", url, None, [long_page]) + set_third_party_on(session, True) + set_tool( + session, "ledger", "long_page", enabled=True, approval=ToolApproval.ALWAYS + ) + session.commit() + + +async def _send(client: AsyncClient, thread_id: int, text: str) -> list[dict]: + frames: list[dict] = [] + async with client.stream( + "POST", + f"/chat/threads/{thread_id}/messages", + json={"text": text}, + timeout=300, + ) as reply: + assert reply.status_code == 200, await reply.aread() + async for line in reply.aiter_lines(): + if line.startswith("data: ") and line != "data: [DONE]": + frames.append(json.loads(line[len("data: ") :])) + return frames + + +async def test_an_agentic_turn_reads_a_large_plugin_result_claude_code_saved( + client: AsyncClient, engine: Engine, claude_binary: Path, ledger_plugin: str +) -> None: + """The plugin answers, Claude Code saves its long result, and the model reads it + there, with no plugin named unreachable and no read refused.""" + _connect(engine, ledger_plugin) + sessions = create_session_factory(engine) + async with ( + serving_claude_proxy(sessions, "127.0.0.1"), + serving_agent_proxy(sessions, "127.0.0.1"), + ): + await client.put( + "/egress", + json={ + "destinations": ["host:api.anthropic.com", "host:platform.claude.com"], + "enabled": True, + }, + ) + connection = ( + await client.post("/llm/connections/claude", json={"label": "Claude"}) + ).json() + chosen = await client.put( + "/llm/selection/text_gen", + json={ + "provider": "openai_compatible", + "connection_id": connection["id"], + "name": HAIKU, + }, + ) + assert chosen.status_code == 200, chosen.text + workspace = (await client.post("/workspaces", json={"name": "w"})).json() + opened = await client.post( + f"/workspaces/{workspace['id']}/chat/threads", json={"mode": "agentic"} + ) + assert opened.status_code == 201, opened.text + try: + frames = await _send( + client, + opened.json()["id"], + "Call the ledger plugin's long_page tool once. Its output ends with a " + "line that starts with MARKER-. Find that line in the full output and " + "reply with it alone.", + ) + finally: + await close_engines() + + assert not [f for f in frames if f["type"] == "error"], frames + assert not [f for f in frames if f["type"] == "agent-plugins-unavailable"] + steps = { + f["tool"]: f + for f in frames + if f["type"] == "agent-step" and f["status"] in {"completed", "error"} + } + assert steps["ledger_long_page"]["status"] == "completed" + assert "Full output saved to" in steps["ledger_long_page"]["output"] + reads = [steps[tool] for tool in ("read", "grep") if tool in steps] + assert reads, steps + assert all(step["status"] == "completed" for step in reads), reads + assert any( + "/tool-results/" + in str(step["input"].get("filePath") or step["input"].get("path")) + for step in reads + ), reads + completed = next(f for f in frames if f["type"] == "completed") + assert MARKER in completed["text"] diff --git a/surfsense_local/backend/tests/claude_code/test_app_chat.py b/surfsense_local/backend/tests/claude_code/test_app_chat.py new file mode 100644 index 0000000000..159ac7d6d9 --- /dev/null +++ b/surfsense_local/backend/tests/claude_code/test_app_chat.py @@ -0,0 +1,84 @@ +"""A Basic chat answered by the developer's own Claude Code, through the app's API.""" + +import json +import logging +from pathlib import Path + +import pytest +from httpx import AsyncClient +from sqlalchemy import Engine + +from modules.llm.subscriptions.claude.plan_generator import close_claude_runs +from modules.llm.subscriptions.claude.proxy import serving_claude_proxy +from shared.db import create_session_factory + +pytestmark = pytest.mark.claude_code +HAIKU = "claude-haiku-5-5" + + +async def _send(client: AsyncClient, thread_id: int, text: str) -> list[dict]: + frames: list[dict] = [] + async with client.stream( + "POST", f"/chat/threads/{thread_id}/messages", json={"text": text} + ) as reply: + assert reply.status_code == 200 + async for line in reply.aiter_lines(): + if line.startswith("data: ") and line != "data: [DONE]": + frames.append(json.loads(line[len("data: ") :])) + return frames + + +@pytest.mark.usefixtures("real_model") +async def test_a_basic_chat_is_answered_by_this_machines_claude_code( + client: AsyncClient, + engine: Engine, + claude_binary: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Connect, choose a Claude plan model, and get two answers that remember, + with every connection Claude Code makes going through SurfSense's proxy.""" + caplog.set_level(logging.INFO) + async with serving_claude_proxy(create_session_factory(engine), "127.0.0.1"): + for host in ("api.anthropic.com", "platform.claude.com"): + await client.put( + "/egress", json={"destinations": [f"host:{host}"], "enabled": True} + ) + connection = ( + await client.post("/llm/connections/claude", json={"label": "Claude"}) + ).json() + chosen = await client.put( + "/llm/selection/text_gen", + json={ + "provider": "openai_compatible", + "connection_id": connection["id"], + "name": HAIKU, + }, + ) + assert chosen.status_code == 200, chosen.text + workspace = (await client.post("/workspaces", json={"name": "w"})).json() + thread_id = ( + await client.post( + f"/workspaces/{workspace['id']}/chat/threads", json={"mode": "basic"} + ) + ).json()["id"] + + try: + first = await _send( + client, thread_id, "My cat is called Biscuit. Reply in one word: ok." + ) + second = await _send(client, thread_id, "What is my cat called? One word.") + finally: + await close_claude_runs() + + assert not [f for f in first + second if f.get("type") == "error"], first + second + answer = "".join(f["text"] for f in second if f.get("type") == "delta") + assert "biscuit" in answer.lower() + # Each tunnel is logged with its host as the first argument. + tunnelled = { + record.args[0] + for record in caplog.records + if record.name == "modules.agent.plugin_proxy.server" + and record.msg.startswith("tunnel to %s:%s: checked") + } + assert tunnelled == {"api.anthropic.com"} + assert "refused" not in caplog.text diff --git a/surfsense_local/backend/tests/claude_code/test_chat.py b/surfsense_local/backend/tests/claude_code/test_chat.py new file mode 100644 index 0000000000..378e2e730e --- /dev/null +++ b/surfsense_local/backend/tests/claude_code/test_chat.py @@ -0,0 +1,101 @@ +"""Chat on a Claude plan against the developer's own signed-in Claude Code.""" + +import io +import json +from pathlib import Path + +import pytest +from PIL import Image as PILImage + +from modules.llm.providers.types import Image, Message +from modules.llm.subscriptions.claude.chat import ClaudePlanProvider +from modules.llm.subscriptions.claude.chat_runs import ChatRuns +from modules.llm.subscriptions.claude.run import open_run + +pytestmark = pytest.mark.claude_code +MODEL = "claude-haiku-5-5" +SYSTEM = Message("system", "You are SurfSense. Answer in as few words as possible.") + + +def _provider( + claude_binary: Path, tmp_path: Path, runs: ChatRuns +) -> ClaudePlanProvider: + return ClaudePlanProvider( + runs=runs, work_dir=tmp_path, find_claude=lambda: claude_binary + ) + + +async def _text(provider: ClaudePlanProvider, messages: list[Message], **kw) -> str: + return "".join([t async for t in provider.chat(MODEL, messages, **kw)]) + + +async def test_a_conversation_is_answered_and_remembered( + claude_binary: Path, tmp_path: Path +) -> None: + """The second message goes to the warm run, which still knows the first.""" + runs = ChatRuns(open_run) + provider = _provider(claude_binary, tmp_path, runs) + first = [SYSTEM, Message("user", "My cat is called Biscuit. Reply ok.")] + try: + reply = await _text(provider, first, conversation="t1") + name = await _text( + provider, + [ + *first, + Message("assistant", reply), + Message("user", "What is my cat called?"), + ], + conversation="t1", + ) + finally: + await runs.close_all() + + assert "biscuit" in name.lower() + + +async def test_a_schema_is_answered_as_json( + claude_binary: Path, tmp_path: Path +) -> None: + """What the chat router and Studio's workflows ask for.""" + runs = ChatRuns(open_run) + schema = { + "type": "object", + "additionalProperties": False, + "required": ["tool"], + "properties": { + "tool": {"type": "string", "enum": ["weather", "notes", "none"]} + }, + } + try: + text = await _text( + _provider(claude_binary, tmp_path, runs), + [SYSTEM, Message("user", "Pick the tool for: will it rain in Pune?")], + json_schema=schema, + ) + finally: + await runs.close_all() + + assert json.loads(text) == {"tool": "weather"} + + +async def test_an_image_reaches_the_model(claude_binary: Path, tmp_path: Path) -> None: + """Sent beside the message, not dropped.""" + picture = io.BytesIO() + PILImage.new("RGB", (64, 64), (220, 20, 20)).save(picture, "PNG") + runs = ChatRuns(open_run) + try: + text = await _text( + _provider(claude_binary, tmp_path, runs), + [ + SYSTEM, + Message( + "user", + "What single colour fills this image? One word.", + images=(Image("image/png", picture.getvalue()),), + ), + ], + ) + finally: + await runs.close_all() + + assert "red" in text.lower() diff --git a/surfsense_local/backend/tests/claude_code/test_connection.py b/surfsense_local/backend/tests/claude_code/test_connection.py new file mode 100644 index 0000000000..381af5d968 --- /dev/null +++ b/surfsense_local/backend/tests/claude_code/test_connection.py @@ -0,0 +1,40 @@ +"""The Claude plan connection against the developer's own signed-in Claude Code.""" + +from pathlib import Path + +import pytest +from httpx import AsyncClient +from sqlalchemy import Engine + +from modules.llm.subscriptions.claude.proxy import serving_claude_proxy +from shared.db import create_session_factory + +pytestmark = pytest.mark.claude_code + + +async def test_this_machines_claude_code_connects_and_lists_its_models( + client: AsyncClient, engine: Engine, claude_binary: Path +) -> None: + """End to end on a real install: ready, connected, and the plan's models listed.""" + found = (await client.get("/llm/connections/claude/status")).json() + assert found["state"] == "ready", found + assert found["email"] + assert found["plan"] + + for host in ("api.anthropic.com", "platform.claude.com"): + await client.put( + "/egress", json={"destinations": [f"host:{host}"], "enabled": True} + ) + connection = ( + await client.post("/llm/connections/claude", json={"label": "Claude"}) + ).json() + async with serving_claude_proxy(create_session_factory(engine), "127.0.0.1"): + models = ( + await client.get(f"/llm/connections/{connection['id']}/models") + ).json() + + names = {m["name"] for m in models} + assert {"claude-opus-5-5", "claude-sonnet-5-5"} <= names + sonnet = next(m for m in models if m["name"] == "claude-sonnet-5-5") + assert sonnet["selectable_for"] == ["text_gen"] + assert sonnet["capability_level"] is not None diff --git a/surfsense_local/backend/tests/claude_code/test_hosts.py b/surfsense_local/backend/tests/claude_code/test_hosts.py new file mode 100644 index 0000000000..0a7f705915 --- /dev/null +++ b/surfsense_local/backend/tests/claude_code/test_hosts.py @@ -0,0 +1,51 @@ +"""The hosts the installed Claude Code signs in through are ones SurfSense allows. + +A release that moved its sign-in refresh to a new host would be refused by the +Claude proxy, and users would be signed out once their token expired. This reads +where the binary says it refreshes, so that shows here first. +""" + +import mmap +import os +from pathlib import Path +from urllib.parse import urlsplit + +import pytest + +from modules.llm.subscriptions.claude.hosts import CLAUDE_HOSTS + +pytestmark = pytest.mark.claude_code +MARKER = b'TOKEN_URL:"' + + +def _token_hosts(binary: Path) -> set[str]: + """Every host a literal `TOKEN_URL:"https://…"` in the binary names.""" + hosts = set() + with ( + binary.open("rb") as file, + mmap.mmap(file.fileno(), 0, access=mmap.ACCESS_READ) as data, + ): + start = data.find(MARKER) + while start != -1: + begin = start + len(MARKER) + end = data.find(b'"', begin, begin + 200) + if end != -1: + url = data[begin:end].decode("ascii", errors="replace") + if url.startswith("https://"): + hosts.add(urlsplit(url).hostname or "") + start = data.find(MARKER, begin) + return hosts + + +def test_claude_code_refreshes_its_sign_in_on_an_allowed_host( + claude_binary: Path, +) -> None: + """Found on 2.1.301: platform.claude.com, which the Claude proxy lets through.""" + hosts = _token_hosts(Path(os.path.realpath(claude_binary))) + + assert hosts, ( + "no TOKEN_URL found: Claude Code changed how it names its sign-in host" + ) + assert hosts <= set(CLAUDE_HOSTS), ( + f"Claude Code signs in through hosts SurfSense does not allow: {hosts - set(CLAUDE_HOSTS)}" + ) diff --git a/surfsense_local/backend/tests/claude_code/test_run.py b/surfsense_local/backend/tests/claude_code/test_run.py new file mode 100644 index 0000000000..04019e7175 --- /dev/null +++ b/surfsense_local/backend/tests/claude_code/test_run.py @@ -0,0 +1,125 @@ +from pathlib import Path + +import pytest +from claude_agent_sdk import ResultMessage, StreamEvent + +from modules.llm.subscriptions.claude.launch import launch_options +from modules.llm.subscriptions.claude.run import open_run + +pytestmark = pytest.mark.claude_code + + +def _options(binary: Path, tmp_path: Path, **per_run: object): + return launch_options( + binary, + proxy_url=None, + model="haiku", + tools=[], + cwd=str(tmp_path), + include_partial_messages=True, + **per_run, + ) + + +def _text(messages: list) -> str: + return "".join( + m.event["delta"].get("text", "") + for m in messages + if isinstance(m, StreamEvent) and m.event.get("type") == "content_block_delta" + ) + + +async def test_a_run_answers_with_surfsenses_prompt( + claude_binary: Path, tmp_path: Path +) -> None: + """With every tool off, the model follows SurfSense's system prompt, not Claude Code's.""" + run = await open_run( + _options(claude_binary, tmp_path, system_prompt="Reply with exactly: SURFSENSE") + ) + try: + messages = [m async for m in run.send("Hello")] + finally: + await run.close() + + assert "SURFSENSE" in _text(messages) + assert isinstance(messages[-1], ResultMessage) + assert not messages[-1].is_error + + +async def test_a_schema_comes_back_as_structured_output( + claude_binary: Path, tmp_path: Path +) -> None: + """Claude Code returns a schema's answer through its own tool, so it needs turns.""" + schema = { + "type": "object", + "additionalProperties": False, + "required": ["city"], + "properties": {"city": {"type": "string"}}, + } + run = await open_run( + _options( + claude_binary, + tmp_path, + max_turns=3, + output_format={"type": "json_schema", "schema": schema}, + ) + ) + try: + messages = [m async for m in run.send("Which city: 'rain in Pune tomorrow'?")] + finally: + await run.close() + + assert messages[-1].structured_output == {"city": "Pune"} + + +async def test_a_stopped_run_answers_the_next_message( + claude_binary: Path, tmp_path: Path +) -> None: + """Stop ends the turn, not the run.""" + run = await open_run(_options(claude_binary, tmp_path)) + try: + stopped = [] + async for message in run.send("Count slowly from 1 to 300, one per line."): + stopped.append(message) + if len(stopped) == 10: + await run.interrupt() + after = [m async for m in run.send("Reply with exactly: AGAIN")] + finally: + await run.close() + + assert stopped[-1].is_error + assert "AGAIN" in _text(after) + + +async def test_a_later_run_resumes_a_session( + claude_binary: Path, tmp_path: Path +) -> None: + """A reopened agent thread continues the session it had.""" + first = await open_run(_options(claude_binary, tmp_path)) + try: + said = [m async for m in first.send("Remember the word KESTREL. Reply ok.")] + finally: + await first.close() + + later = await open_run( + _options(claude_binary, tmp_path, resume=said[-1].session_id) + ) + try: + answer = [m async for m in later.send("Which word did I ask you to remember?")] + finally: + await later.close() + + assert "KESTREL" in _text(answer).upper() + + +async def test_the_models_are_claude_codes_own_list( + claude_binary: Path, tmp_path: Path +) -> None: + """The picker lists what the account's Claude Code offers.""" + run = await open_run(_options(claude_binary, tmp_path)) + try: + models = await run.models() + finally: + await run.close() + + assert {"opus", "sonnet", "haiku"} <= {m["value"] for m in models} diff --git a/surfsense_local/backend/tests/claude_run_stand_in.py b/surfsense_local/backend/tests/claude_run_stand_in.py new file mode 100644 index 0000000000..c51cc4459d --- /dev/null +++ b/surfsense_local/backend/tests/claude_run_stand_in.py @@ -0,0 +1,151 @@ +"""A ClaudeRun that replays what a real Claude Code run sent, without starting one.""" + +from collections.abc import AsyncIterator, Callable, Sequence +from typing import Any + +from claude_agent_sdk import Message + +# What Claude Code 2.1.301 on a Max plan listed on 16 Oct 2026. +MAX_PLAN_MODELS: list[dict[str, Any]] = [ + { + "value": "default", + "resolvedModel": "claude-opus-5-5", + "displayName": "Default (recommended)", + }, + {"value": "opus", "resolvedModel": "claude-opus-5-5", "displayName": "Opus"}, + { + "value": "claude-fable-5-1[1m]", + "resolvedModel": "claude-fable-5-1", + "displayName": "Fable", + }, + {"value": "sonnet", "resolvedModel": "claude-sonnet-5-5", "displayName": "Sonnet"}, + {"value": "haiku", "resolvedModel": "claude-haiku-5-5", "displayName": "Haiku"}, +] + + +class ScriptedRun: + """Answers each `send` with the next scripted reply, and records what it was sent.""" + + def __init__( + self, + replies: Sequence[Sequence[Message]] = (), + models: list[dict[str, Any]] | None = None, + respond: Callable[[Any], Sequence[Message]] | None = None, + ) -> None: + self.replies = list(replies) + # Answers what the script does not, from what was sent. + self._respond = respond + self.sent: list[Any] = [] + self.uuids: list[str | None] = [] + self.interrupted = False + self.closed = False + # Each MCP server reconnected, by name, in order. + self.reconnected: list[str] = [] + self._models = MAX_PLAN_MODELS if models is None else models + + async def send( + self, prompt: Any, *, uuid: str | None = None + ) -> AsyncIterator[Message]: + """Yield the next scripted reply.""" + self.sent.append(prompt) + self.uuids.append(uuid) + reply = ( + self.replies.pop(0) + if self.replies or not self._respond + else self._respond(prompt) + ) + for message in reply: + yield message + + async def interrupt(self) -> None: + """Record that the turn was stopped.""" + self.interrupted = True + + async def models(self) -> list[dict[str, Any]]: + """The scripted model list.""" + return self._models + + async def reconnect(self, server: str) -> None: + """Record that a server was connected again.""" + self.reconnected.append(server) + + async def close(self) -> None: + """Record that the run was closed.""" + self.closed = True + + +def _event(event: dict[str, Any]) -> Message: + from claude_agent_sdk import StreamEvent + + return StreamEvent(uuid="e", session_id="s", event=event) + + +def done(*, structured_output: Any = None, is_error: bool = False) -> Message: + """The `ResultMessage` every reply ends with.""" + from claude_agent_sdk import ResultMessage + + return ResultMessage( + subtype="error_during_execution" if is_error else "success", + duration_ms=1, + duration_api_ms=1, + is_error=is_error, + num_turns=1, + session_id="s", + structured_output=structured_output, + ) + + +# When the plan's five-hour window measured on 17 Oct 2026 reset. +RESETS_AT = 1792232400 + + +def plan_limits(*, using_extra: bool) -> Message: + """Claude Code's rate-limit event, sent with each reply: within the plan, or past it on extra usage. + + Within the plan is as measured (extra usage off for the organization). Past it + is as Claude Code 2.1.301 builds the event from Anthropic's headers: the plan's + own status `rejected`, extra usage `allowed`, so `isUsingOverage`. + """ + from claude_agent_sdk import RateLimitEvent, RateLimitInfo + + status = "rejected" if using_extra else "allowed" + overage = "allowed" if using_extra else "rejected" + raw = { + "status": status, + "resetsAt": RESETS_AT, + "rateLimitType": "five_hour", + "overageStatus": overage, + "isUsingOverage": using_extra, + } + info = RateLimitInfo( + status=status, + resets_at=RESETS_AT, + rate_limit_type="five_hour", + overage_status=overage, + raw=raw, + ) + return RateLimitEvent(info, "limit", "s") + + +def answer(*chunks: str, thinking: Sequence[str] = ()) -> list[Message]: + """A reply streamed as Claude Code streams one: thinking first, then text.""" + events: list[Message] = [] + for piece in thinking: + events.append( + _event( + { + "type": "content_block_delta", + "delta": {"type": "thinking_delta", "thinking": piece}, + } + ) + ) + for piece in chunks: + events.append( + _event( + { + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": piece}, + } + ) + ) + return [*events, done()] diff --git a/surfsense_local/backend/tests/claude_stand_in.py b/surfsense_local/backend/tests/claude_stand_in.py new file mode 100644 index 0000000000..e81a0daa58 --- /dev/null +++ b/surfsense_local/backend/tests/claude_stand_in.py @@ -0,0 +1,73 @@ +"""A `claude` that answers `--version` and `auth status` as Claude Code 2.1.301 did. + +The answers come from a Python script, launched through `claude.cmd` on Windows +and a shell launcher elsewhere, so the same tests run on all three systems. +""" + +import json +import os +import shlex +import sys +from pathlib import Path + +SIGNED_IN = { + "loggedIn": True, + "authMethod": "claude.ai", + "apiProvider": "firstParty", + "email": "ada@example.com", + "orgId": "org-ada", + "orgName": "Ada's Org", + "subscriptionType": "max", +} +SIGNED_OUT = {"loggedIn": False, "authMethod": "none", "apiProvider": "firstParty"} + +_PROGRAM = """\ +import json, os, sys, time +if {env_log!r}: + with open({env_log!r}, "w") as log: + json.dump(dict(os.environ), log) +time.sleep({hang_seconds!r}) +if sys.argv[1:] == ["--version"]: + print({version!r}) +elif sys.argv[1:] == ["auth", "status"]: + print({auth!r}) +else: + sys.exit(2) +""" + + +def claude_stand_in( + directory: Path, + *, + version: str = "2.1.301 (Claude Code)", + auth: dict | str = SIGNED_IN, + hang_seconds: float = 0, + env_log: Path | None = None, +) -> Path: + """Write the stand-in into `directory`; a string `auth` is printed as is. + + With `env_log`, each run writes the environment it was started with there. + """ + program = directory / "claude_stand_in_program.py" + program.write_text( + _PROGRAM.format( + hang_seconds=hang_seconds, + env_log=str(env_log) if env_log else "", + version=version, + auth=auth if isinstance(auth, str) else json.dumps(auth), + ) + ) + launcher = directory / ("claude.cmd" if sys.platform == "win32" else "claude") + replaced = launcher.stat().st_mtime_ns if launcher.exists() else None + if sys.platform == "win32": + launcher.write_text(f'@"{sys.executable}" "{program}" %*\r\n') + else: + python, script = shlex.quote(sys.executable), shlex.quote(str(program)) + launcher.write_text(f'#!/bin/sh\nexec {python} {script} "$@"\n') + launcher.chmod(0o755) + if replaced is not None: + # Rewritten within milliseconds, and NTFS may keep the old time: a second + # on, so the version read per binary as installed sees another install. + later = max(launcher.stat().st_mtime_ns, replaced + 1_000_000_000) + os.utime(launcher, ns=(later, later)) + return launcher diff --git a/surfsense_local/backend/tests/integration/agent/claude_code/conftest.py b/surfsense_local/backend/tests/integration/agent/claude_code/conftest.py new file mode 100644 index 0000000000..d6dc15dbda --- /dev/null +++ b/surfsense_local/backend/tests/integration/agent/claude_code/conftest.py @@ -0,0 +1,53 @@ +"""A stand-in Claude Code for agent turns, and the fixtures shared with the Claude chat and proxy tests.""" + +import pytest +from claude_agent_sdk import ClaudeAgentOptions + +from modules.agent.engines import thread_engine +from modules.agent.engines.claude_code import agent_proxy +from modules.agent.engines.claude_code import turn as claude_turn +from modules.agent.engines.claude_code.engine import ClaudeCodeEngine +from tests.claude_agent_stand_in import ScriptedAgentRun, Step +from tests.integration.llm.claude.conftest import ( + FakeClaude, + claude_on_this_machine, + fake_claude, +) +from tests.integration.llm.claude.test_claude_proxy import echo + +__all__ = ["FakeClaude", "claude_on_this_machine", "echo", "fake_claude"] + + +class ClaudeAgent: + """Every agent run the API starts, playing `turns` in order, one per message sent.""" + + def __init__(self) -> None: + self.turns: list[list[Step]] = [] + self.started: list[ScriptedAgentRun] = [] + # Each MCP server's settled status, by name; connected unless named. + self.statuses: dict[str, str] = {} + + async def open(self, options: ClaudeAgentOptions) -> ScriptedAgentRun: + run = ScriptedAgentRun(options, self.turns, self.statuses) + self.started.append(run) + return run + + +@pytest.fixture +def claude_agent( + monkeypatch: pytest.MonkeyPatch, fake_claude: FakeClaude +) -> ClaudeAgent: + """Stand in for the user's Claude Code in agent turns; nothing leaves the machine.""" + agent = ClaudeAgent() + monkeypatch.setattr(claude_turn, "open_run", agent.open) + # A serving API's proxy address; scripted runs never connect to it. + monkeypatch.setattr(agent_proxy, "_url", "http://127.0.0.1:9") + + async def nothing_changed(*_args: object, **_kwargs: object) -> int: + return 0 + + # Checking plugins for changed tools reaches their servers. + monkeypatch.setattr(claude_turn, "check_changed_tools", nothing_changed) + # Fresh per test: its skills and config files live in this test's data folder. + monkeypatch.setattr(thread_engine, "_CLAUDE_CODE", ClaudeCodeEngine()) + return agent diff --git a/surfsense_local/backend/tests/integration/agent/claude_code/test_agent_proxy.py b/surfsense_local/backend/tests/integration/agent/claude_code/test_agent_proxy.py new file mode 100644 index 0000000000..bc78efa490 --- /dev/null +++ b/surfsense_local/backend/tests/integration/agent/claude_code/test_agent_proxy.py @@ -0,0 +1,74 @@ +"""An agent run's Claude Code reaches Anthropic and the turn's ready plugins, and nothing else (ADR 0061).""" + +import pytest +from sqlalchemy import Engine + +from modules.agent.engines.claude_code import agent_proxy +from modules.agent.engines.claude_code.agent_proxy import AgentHosts +from modules.egress.service import set_enabled +from modules.llm.subscriptions.claude import proxy as claude_proxy +from shared.db import create_session_factory +from tests.integration.agent.test_plugin_proxy import Echo, connect_plugin, through + +pytestmark = pytest.mark.integration + + +async def test_a_ready_plugins_host_is_tunnelled_to(engine: Engine, echo: Echo) -> None: + """Claude Code connects to the plugin itself, as opencode does.""" + connect_plugin(engine, f"https://localhost:{echo.port}/mcp") + + async with agent_proxy.serving_agent_proxy( + create_session_factory(engine), "127.0.0.1" + ) as port: + head, echoed = await through( + port, f"CONNECT localhost:{echo.port} HTTP/1.1\r\n\r\n".encode() + ) + + assert head.startswith(b"HTTP/1.1 200") + assert echoed == b"ping" + + +async def test_the_chat_proxy_still_refuses_a_plugins_host( + engine: Engine, + echo: Echo, +) -> None: + """A chat run is given no plugin, so its proxy passes none.""" + connect_plugin(engine, f"https://localhost:{echo.port}/mcp") + + async with claude_proxy.serving_claude_proxy( + create_session_factory(engine), "127.0.0.1" + ) as port: + head, _ = await through( + port, f"CONNECT localhost:{echo.port} HTTP/1.1\r\n\r\n".encode() + ) + + assert head.startswith(b"HTTP/1.1 403") + + +def test_anthropics_hosts_follow_the_users_consent(engine: Engine) -> None: + """The same consent as a chat run's: each host only while it is on.""" + hosts = AgentHosts(create_session_factory(engine)) + + assert hosts.read() == frozenset() + + with create_session_factory(engine)() as session: + set_enabled(session, "host:api.anthropic.com", True) + session.commit() + + assert hosts.read() == {("api.anthropic.com", 443)} + + +async def test_runs_know_the_agent_proxys_address_only_while_it_serves( + engine: Engine, +) -> None: + """An agent run never starts around egress.""" + with pytest.raises(claude_proxy.ProxyNotServingError): + agent_proxy.required_agent_proxy_url() + + async with agent_proxy.serving_agent_proxy( + create_session_factory(engine), "127.0.0.1" + ) as port: + assert agent_proxy.required_agent_proxy_url() == f"http://127.0.0.1:{port}" + + with pytest.raises(claude_proxy.ProxyNotServingError): + agent_proxy.required_agent_proxy_url() diff --git a/surfsense_local/backend/tests/integration/agent/claude_code/test_both_engines.py b/surfsense_local/backend/tests/integration/agent/claude_code/test_both_engines.py new file mode 100644 index 0000000000..01bff5db31 --- /dev/null +++ b/surfsense_local/backend/tests/integration/agent/claude_code/test_both_engines.py @@ -0,0 +1,228 @@ +"""The same scenarios through opencode and Claude Code stream the same frames and read back alike. + +The frontend has one renderer for both engines, so any drift between them is a +bug. Ids, step titles (never shown) and tool outputs (the Claude stand-in runs +no tool) are left out of the comparison; everything else must match. +""" + +import json +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import pytest + +from tests.claude_agent_stand_in import Call, Hang, Say, Step +from tests.integration.agent.claude_code.conftest import ClaudeAgent +from tests.integration.agent.claude_code.test_handover import _choose_claude +from tests.integration.agent.conftest import AgentAPI +from tests.integration.agent.test_agent_threads import open_thread, send, thread_folder +from tests.integration.agent.test_plugin_tools import connect_notes +from tests.mcp_test_server import notes_server, serving + +pytestmark = pytest.mark.integration + +OnFrame = Callable[[AgentAPI, int, dict], Awaitable[None]] + + +@dataclass(frozen=True) +class Scenario: + """One user message, scripted for each engine's model to answer the same way.""" + + text: str + opencode: Callable[[Path], list[tuple[str, str]]] + claude: Callable[[Path], list[Step]] + on_frame: OnFrame | None = None + + +def _call(name: str, **arguments: object) -> tuple[str, str]: + return ("call", json.dumps({"name": name, "arguments": arguments})) + + +async def _approve(api: AgentAPI, thread_id: int, frame: dict) -> None: + if frame["type"] == "permission-request": + await api.http.post( + f"/chat/threads/{thread_id}/permissions/{frame['id']}", + json={"reply": "once"}, + ) + + +async def _stop(api: AgentAPI, thread_id: int, frame: dict) -> None: + if frame["type"] == "delta": + await api.http.post(f"/chat/threads/{thread_id}/run/stop") + + +async def _run( + api: AgentAPI, thread_id: int, scenario: Scenario +) -> tuple[list[tuple], list[dict]]: + async def on_frame(frame: dict) -> None: + if scenario.on_frame is not None: + await scenario.on_frame(api, thread_id, frame) + + folder = thread_folder(api.workspace_id, thread_id) + frames = await send(api, thread_id, scenario.text, on_frame) + listed = (await api.http.get(f"/chat/threads/{thread_id}/messages")).json() + return _seen(frames, folder), _read_back(listed, folder) + + +async def _on_both( + api: AgentAPI, + claude_on_this_machine: Any, + claude_agent: ClaudeAgent, + scenario: Scenario, +) -> tuple[tuple, tuple]: + on_opencode = await open_thread(api, mode="agentic") + folder = thread_folder(api.workspace_id, on_opencode["id"]) + api.model.replies = scenario.opencode(folder) + opencode = await _run(api, on_opencode["id"], scenario) + api.model.release.set() + + await _choose_claude(api, claude_on_this_machine) + on_claude = await open_thread(api, mode="agentic") + folder = thread_folder(api.workspace_id, on_claude["id"]) + claude_agent.turns = [scenario.claude(folder)] + claude = await _run(api, on_claude["id"], scenario) + return opencode, claude + + +def _seen(frames: list[dict], folder: Path) -> list[tuple]: + """What the chat shows from a turn's frames, in order. + + The chat matches an approval to its step by call id, so opencode asking a + moment before its step moves is shown as Claude Code's step-then-ask is. + The run's state follows the approvals in code both engines share. + """ + seen: list[tuple] = [] + steps: set[str] = set() + early: dict[str, tuple] = {} + for frame in frames: + kind = frame["type"] + if kind == "run-state": + continue + if kind == "agent-step": + # opencode announces a call before its input has streamed; nothing shows it. + if frame["status"] != "pending": + seen.append(_step(frame, folder)) + steps.add(frame["call_id"]) + if frame["call_id"] in early: + seen.append(early.pop(frame["call_id"])) + elif kind == "permission-request" and frame["call_id"] not in steps: + early[frame["call_id"]] = (kind, frame["permission"]) + elif kind == "delta": + if seen and seen[-1][0] == "text": + seen[-1] = ("text", seen[-1][1] + frame["text"]) + else: + seen.append(("text", frame["text"])) + elif kind == "permission-request": + seen.append((kind, frame["permission"])) + elif kind == "completed": + seen.append((kind, frame["text"])) + elif kind == "error": + seen.append((kind, frame["kind"])) + else: + seen.append((kind,)) + return seen + + +def _read_back(listed: list[dict], folder: Path) -> list[dict]: + return [ + { + "role": turn["role"], + "text": turn["content"]["text"], + "steps": [_step(s, folder) for s in turn["content"].get("steps", [])], + "ending": turn["content"].get("ending"), + } + for turn in listed + ] + + +def _step(step: dict, folder: Path) -> tuple: + given = { + key: value.replace(str(folder), "") if isinstance(value, str) else value + for key, value in step["input"].items() + } + return ("step", step["tool"], step["status"], json.dumps(given, sort_keys=True)) + + +async def test_an_answer( + agent_api: AgentAPI, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """Streamed text and the stated reply.""" + scenario = Scenario( + "What grew?", + opencode=lambda _: [("text", "Q3 grew 12%.")], + claude=lambda _: [Say("Q3 grew 12%.")], + ) + + opencode, claude = await _on_both( + agent_api, claude_on_this_machine, claude_agent, scenario + ) + + assert claude == opencode + + +async def test_a_write_in_outputs_and_one_refused_outside( + agent_api: AgentAPI, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """The thread's write rules refuse the second, and the turn goes on.""" + scenario = Scenario( + "Write it down", + opencode=lambda folder: [ + _call("write", filePath=str(folder / "outputs/a.md"), content="A"), + _call("write", filePath=str(folder / "notes.md"), content="B"), + ("text", "Wrote it."), + ], + claude=lambda folder: [ + Call("Write", {"file_path": str(folder / "outputs/a.md"), "content": "A"}), + Call("Write", {"file_path": str(folder / "notes.md"), "content": "B"}), + Say("Wrote it."), + ], + ) + + opencode, claude = await _on_both( + agent_api, claude_on_this_machine, claude_agent, scenario + ) + + assert claude == opencode + + +async def test_a_plugin_call_the_user_allows( + agent_api: AgentAPI, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """A tool that says nothing of itself may destroy, so both engines ask first.""" + scenario = Scenario( + "Count", + opencode=lambda _: [_call("notes_count", text="a b c"), ("text", "Three.")], + claude=lambda _: [ + Call("mcp__notes__count", {"text": "a b c"}, '{"words": 3}'), + Say("Three."), + ], + on_frame=_approve, + ) + with serving(notes_server().streamable_http_app()) as base: + await connect_notes(agent_api, f"{base}/mcp") + + opencode, claude = await _on_both( + agent_api, claude_on_this_machine, claude_agent, scenario + ) + + assert claude == opencode + + +async def test_stop_mid_reply( + agent_api: AgentAPI, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """The reply keeps what streamed and reads back stopped.""" + scenario = Scenario( + "Think it over", + opencode=lambda _: [("stall", "Thinking about")], + claude=lambda _: [Hang("Thinking about")], + on_frame=_stop, + ) + + opencode, claude = await _on_both( + agent_api, claude_on_this_machine, claude_agent, scenario + ) + + assert claude == opencode diff --git a/surfsense_local/backend/tests/integration/agent/claude_code/test_claude_account.py b/surfsense_local/backend/tests/integration/agent/claude_code/test_claude_account.py new file mode 100644 index 0000000000..2a5c6785b1 --- /dev/null +++ b/surfsense_local/backend/tests/integration/agent/claude_code/test_claude_account.py @@ -0,0 +1,60 @@ +"""A thread's Claude session is resumed only under the Claude account that made it.""" + +import pytest + +from tests.claude_agent_stand_in import Say +from tests.claude_stand_in import SIGNED_IN +from tests.integration.agent.claude_code.conftest import ClaudeAgent +from tests.integration.agent.claude_code.test_handover import _choose_claude +from tests.integration.agent.conftest import AgentAPI +from tests.integration.agent.test_agent_threads import of_type, open_thread, send + +pytestmark = pytest.mark.integration +GRACE = {**SIGNED_IN, "email": "grace@example.com", "orgId": "org-grace"} + + +async def test_another_account_starts_a_new_session_told_the_whole_thread( + agent_api: AgentAPI, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """The new account's session is told every earlier turn, and the thread reads as one.""" + await _choose_claude(agent_api, claude_on_this_machine) + thread = await open_thread(agent_api, mode="agentic") + claude_agent.turns = [[Say("Q3 grew 12%.")], [Say("Yes, 12%.")]] + await send(agent_api, thread["id"], "What grew in Q3?") + + claude_on_this_machine(auth=GRACE) + frames = await send(agent_api, thread["id"], "Are you sure?") + + assert of_type(frames, "completed")[0]["text"] == "Yes, 12%." + first, second = claude_agent.started + assert second.options.resume is None + assert second.options.session_id != first.options.session_id + (sent,) = second.sent + note, words = sent[0]["text"], sent[-1]["text"] + assert note.startswith("") + assert "What grew in Q3?" in note + assert "Q3 grew 12%." in note + assert words == "Are you sure?" + listed = (await agent_api.http.get(f"/chat/threads/{thread['id']}/messages")).json() + assert [turn["content"]["text"] for turn in listed] == [ + "What grew in Q3?", + "Q3 grew 12%.", + "Are you sure?", + "Yes, 12%.", + ] + + +async def test_the_same_account_resumes_its_session( + agent_api: AgentAPI, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """Its running Claude Code answers the next turn, given no note.""" + await _choose_claude(agent_api, claude_on_this_machine) + thread = await open_thread(agent_api, mode="agentic") + claude_agent.turns = [[Say("One.")], [Say("Two.")]] + await send(agent_api, thread["id"], "First") + + await send(agent_api, thread["id"], "Second") + + (run,) = claude_agent.started + assert [blocks[-1]["text"] for blocks in run.sent] == ["First", "Second"] + assert all(len(blocks) == 1 for blocks in run.sent) diff --git a/surfsense_local/backend/tests/integration/agent/claude_code/test_claude_agent.py b/surfsense_local/backend/tests/integration/agent/claude_code/test_claude_agent.py new file mode 100644 index 0000000000..b5d71028ed --- /dev/null +++ b/surfsense_local/backend/tests/integration/agent/claude_code/test_claude_agent.py @@ -0,0 +1,554 @@ +"""An Agentic thread on a Claude plan model runs on the user's Claude Code, as opencode's do.""" + +import asyncio +import json +from pathlib import Path + +import pytest +from httpx import AsyncClient +from sqlalchemy import Engine + +from modules.chat.models import AgentEngineKind, ChatThread +from modules.plugins.installed.models import PluginTool +from shared.config import get_agent_settings, get_storage_settings +from shared.db import create_session_factory +from tests.claude_agent_stand_in import Call, Fail, Hang, Limits, Say +from tests.claude_run_stand_in import RESETS_AT +from tests.integration.agent.claude_code.conftest import ClaudeAgent +from tests.integration.agent.test_plugin_proxy import connect_plugin + +pytestmark = pytest.mark.integration +SONNET = "claude-sonnet-5-5" + + +async def _agentic_thread(client: AsyncClient, claude_on_this_machine) -> dict: + """A Claude plan model chosen, both its hosts allowed, and an Agentic thread opened.""" + claude_on_this_machine() + await client.put( + "/egress", + json={ + "destinations": ["host:api.anthropic.com", "host:platform.claude.com"], + "enabled": True, + }, + ) + connection = ( + await client.post("/llm/connections/claude", json={"label": "Claude"}) + ).json() + chosen = await client.put( + "/llm/selection/text_gen", + json={ + "provider": "openai_compatible", + "connection_id": connection["id"], + "name": SONNET, + }, + ) + assert chosen.status_code == 200, chosen.text + workspace = (await client.post("/workspaces", json={"name": "w"})).json() + opened = await client.post( + f"/workspaces/{workspace['id']}/chat/threads", json={"mode": "agentic"} + ) + assert opened.status_code == 201, opened.text + return opened.json() + + +async def _send(client: AsyncClient, thread_id: int, text: str, on_frame=None) -> list: + frames: list[dict] = [] + async with client.stream( + "POST", f"/chat/threads/{thread_id}/messages", json={"text": text} + ) as reply: + assert reply.status_code == 200, await reply.aread() + async for line in reply.aiter_lines(): + if not line.startswith("data: ") or line == "data: [DONE]": + continue + frame = json.loads(line.removeprefix("data: ")) + frames.append(frame) + if on_frame is not None: + await on_frame(frame) + return frames + + +def _of(frames: list[dict], kind: str) -> list[dict]: + return [frame for frame in frames if frame["type"] == kind] + + +async def test_an_agentic_thread_on_a_claude_plan_model_runs_on_claude_code( + client: AsyncClient, + engine: Engine, + claude_on_this_machine, + claude_agent: ClaudeAgent, +) -> None: + """No opencode is needed: the user's Claude Code is the agent.""" + thread = await _agentic_thread(client, claude_on_this_machine) + + assert thread["uses_agent"] is True + with create_session_factory(engine)() as session: + stored = session.get(ChatThread, thread["id"]) + assert stored.agent_engine is AgentEngineKind.CLAUDE_CODE + assert stored.claude_session_id is not None + + +async def test_a_turn_streams_its_steps_and_reply_as_an_opencode_turn_does( + client: AsyncClient, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """The same frames the thread already shows: accepted, steps, words, completed.""" + claude_agent.turns = [ + [ + Call("mcp__surfsense__search_sources", {"query": "Q3"}, "No passages."), + Call("Write", {"file_path": "outputs/q3.md", "content": "x"}), + Say("Revenue grew 12%."), + ] + ] + thread = await _agentic_thread(client, claude_on_this_machine) + + frames = await _send(client, thread["id"], "What grew in Q3?") + + assert frames[0]["type"] == "agent-preparing" + assert _of(frames, "accepted")[0]["user_message_id"] + ended = [s for s in _of(frames, "agent-step") if s["status"] == "completed"] + assert [s["tool"] for s in ended] == ["surfsense_search_sources", "write"] + assert ended[1]["input"]["filePath"] == "outputs/q3.md" + assert _of(frames, "completed")[0]["text"] == "Revenue grew 12%." + + +async def test_claude_code_works_in_the_threads_folder_with_surfsenses_tools( + client: AsyncClient, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """The tool endpoint is given with the launch key in a file, never on the command line.""" + claude_agent.turns = [[Say("Hi.")]] + thread = await _agentic_thread(client, claude_on_this_machine) + + await _send(client, thread["id"], "Hello") + + (run,) = claude_agent.started + folder = get_storage_settings().thread_working_dir( + thread["workspace_id"], thread["id"] + ) + assert run.options.cwd == str(folder) + assert run.options.model == SONNET + servers = json.loads(Path(run.options.mcp_servers).read_text())["mcpServers"] + assert servers["surfsense"]["url"].endswith( + f"/agent/tools/workspaces/{thread['workspace_id']}/threads/{thread['id']}" + ) + assert servers["surfsense"]["headers"]["Authorization"].startswith("Bearer ") + + +async def test_the_thread_reads_back_as_it_streamed( + client: AsyncClient, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """From SurfSense's copy of the session, under the ids the live frames used.""" + claude_agent.turns = [ + [Call("Glob", {"pattern": "sources/*"}, "a.md"), Say("Found a.md.")] + ] + thread = await _agentic_thread(client, claude_on_this_machine) + frames = await _send(client, thread["id"], "List the sources") + + listed = (await client.get(f"/chat/threads/{thread['id']}/messages")).json() + + accepted = _of(frames, "accepted")[0] + assert [turn["id"] for turn in listed] == [ + accepted["user_message_id"], + accepted["assistant_message_id"], + ] + assert listed[0]["content"]["text"] == "List the sources" + assert listed[1]["content"]["text"] == "Found a.md." + assert [step["tool"] for step in listed[1]["content"]["steps"]] == ["glob"] + + +async def test_the_next_turn_resumes_the_same_claude_code( + client: AsyncClient, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """Set up alike, the second turn reuses the running Claude Code and its session.""" + claude_agent.turns = [[Say("One.")], [Say("Two.")]] + thread = await _agentic_thread(client, claude_on_this_machine) + + await _send(client, thread["id"], "First") + await _send(client, thread["id"], "Second") + + (run,) = claude_agent.started + assert len(run.sent) == 2 + listed = (await client.get(f"/chat/threads/{thread['id']}/messages")).json() + assert [turn["content"]["text"] for turn in listed] == [ + "First", + "One.", + "Second", + "Two.", + ] + + +async def test_a_refused_call_is_an_error_step_and_the_turn_goes_on( + client: AsyncClient, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """The gate refuses outside the folder, as opencode's rules do, and says why.""" + claude_agent.turns = [ + [Call("Read", {"file_path": "/etc/hosts"}), Say("I can't read that.")] + ] + thread = await _agentic_thread(client, claude_on_this_machine) + + frames = await _send(client, thread["id"], "Read /etc/hosts") + + step = _of(frames, "agent-step")[-1] + assert step["status"] == "error" + assert "thread's folder" in step["error"] + assert _of(frames, "completed")[0]["text"] == "I can't read that." + + +async def test_a_signed_out_claude_code_shows_the_sign_in_notice( + client: AsyncClient, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """The same notice a chat on the plan shows, and the reply reads back failed.""" + claude_agent.turns = [[Fail("authentication_failed")]] + thread = await _agentic_thread(client, claude_on_this_machine) + + frames = await _send(client, thread["id"], "Hello") + + (error,) = _of(frames, "error") + assert error["kind"] == "claude_sign_in" + + +async def test_stop_interrupts_claude_code_and_the_reply_reads_back_stopped( + client: AsyncClient, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """After a reload the thread still says the user cut this reply short.""" + claude_agent.turns = [[Hang("Thinking about")]] + thread = await _agentic_thread(client, claude_on_this_machine) + + sending = asyncio.create_task(_send(client, thread["id"], "Go")) + while not claude_agent.started: + await asyncio.sleep(0.01) + await asyncio.wait_for(claude_agent.started[0].hanging.wait(), 5) + stopped = await client.post(f"/chat/threads/{thread['id']}/run/stop") + await asyncio.wait_for(sending, 5) + + assert stopped.status_code == 204 + assert claude_agent.started[0].interrupted + reply = (await client.get(f"/chat/threads/{thread['id']}/messages")).json()[-1] + assert reply["content"]["text"] == "Thinking about" + assert reply["content"]["ending"] == {"type": "stopped"} + + +async def test_deleting_the_thread_ends_its_claude_code( + client: AsyncClient, + engine: Engine, + claude_on_this_machine, + claude_agent: ClaudeAgent, +) -> None: + """Its running Claude Code, its config file and SurfSense's copy all go.""" + claude_agent.turns = [[Say("Hi.")]] + thread = await _agentic_thread(client, claude_on_this_machine) + await _send(client, thread["id"], "Hello") + (run,) = claude_agent.started + config = Path(run.options.mcp_servers) + + deleted = await client.delete(f"/chat/threads/{thread['id']}") + + assert deleted.status_code == 204 + assert run.closed + assert not config.exists() + + +async def test_a_plugin_call_waits_for_the_users_yes( + base_url: str, engine: Engine, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """A tool the gateway asks about holds Claude Code's call until Allow once arrives.""" + connect_plugin(engine, "https://localhost:1/mcp") + claude_agent.turns = [ + [Call("mcp__notes__search", {"query": "Q3"}, "Q3 notes."), Say("Found them.")] + ] + asked: list[dict] = [] + async with AsyncClient(base_url=base_url, timeout=30) as live: + thread = await _agentic_thread(live, claude_on_this_machine) + + async def approve(frame: dict) -> None: + if frame["type"] == "permission-request": + asked.append(frame) + reply = await live.post( + f"/chat/threads/{thread['id']}/permissions/{frame['id']}", + json={"reply": "once"}, + ) + assert reply.status_code == 204 + + frames = await _send(live, thread["id"], "Search my notes", approve) + + (request,) = asked + assert request["permission"] == "notes_search" + step = [s for s in _of(frames, "agent-step") if s["call_id"] == request["call_id"]] + assert step[-1]["status"] == "completed" + assert step[-1]["output"] == "Q3 notes." + assert _of(frames, "completed")[0]["text"] == "Found them." + + +async def test_a_rejected_plugin_call_never_runs( + base_url: str, engine: Engine, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """Reject reaches Claude Code as a refusal, and the step fails with it.""" + connect_plugin(engine, "https://localhost:1/mcp") + claude_agent.turns = [[Call("mcp__notes__search", {"query": "Q3"}), Say("Okay.")]] + async with AsyncClient(base_url=base_url, timeout=30) as live: + thread = await _agentic_thread(live, claude_on_this_machine) + + async def reject(frame: dict) -> None: + if frame["type"] == "permission-request": + await live.post( + f"/chat/threads/{thread['id']}/permissions/{frame['id']}", + json={"reply": "reject"}, + ) + + frames = await _send(live, thread["id"], "Search my notes", reject) + + assert _of(frames, "permission-replied")[0]["reply"] == "reject" + assert _of(frames, "agent-step")[-1]["error"] == "The user rejected this action." + + +async def test_a_steps_whole_result_is_read_from_surfsenses_copy( + client: AsyncClient, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """Show full result reads the step as stored, past what its frame carried.""" + long = "x" * 5000 + claude_agent.turns = [ + [Call("Read", {"file_path": "sources/a.md"}, long), Say("Read.")] + ] + thread = await _agentic_thread(client, claude_on_this_machine) + frames = await _send(client, thread["id"], "Read a.md") + step = _of(frames, "agent-step")[-1] + + whole = await client.get(f"/chat/threads/{thread['id']}/steps/{step['id']}/result") + + assert len(step["output"]) < len(long) + assert whole.status_code == 200 + assert whole.json() == {"output": long, "error": None} + + +async def test_after_stop_the_next_message_is_answered( + client: AsyncClient, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """The stopped turn's end is read, so it never stands in for the next turn's reply.""" + claude_agent.turns = [[Hang("Thinking about")], [Say("Fresh answer.")]] + thread = await _agentic_thread(client, claude_on_this_machine) + sending = asyncio.create_task(_send(client, thread["id"], "Go")) + while not claude_agent.started: + await asyncio.sleep(0.01) + await asyncio.wait_for(claude_agent.started[0].hanging.wait(), 5) + await client.post(f"/chat/threads/{thread['id']}/run/stop") + await asyncio.wait_for(sending, 5) + + frames = await _send(client, thread["id"], "Try again") + + assert _of(frames, "completed")[0]["text"] == "Fresh answer." + + +async def test_a_plugin_still_connecting_at_start_is_not_called_unreachable( + client: AsyncClient, + engine: Engine, + claude_on_this_machine, + claude_agent: ClaudeAgent, +) -> None: + """Claude Code connects plugins in the background; its turn's init says how they ended.""" + connect_plugin(engine, "https://localhost:1/mcp") + claude_agent.turns = [[Say("Done.")]] + thread = await _agentic_thread(client, claude_on_this_machine) + + frames = await _send(client, thread["id"], "Search my notes") + + assert _of(frames, "agent-plugins-unavailable") == [] + + +async def test_a_plugin_that_failed_to_connect_is_named( + client: AsyncClient, + engine: Engine, + claude_on_this_machine, + claude_agent: ClaudeAgent, +) -> None: + """As opencode's turn names one: the plugin, and why it cannot be used.""" + connect_plugin(engine, "https://localhost:1/mcp") + claude_agent.statuses = {"notes": "failed"} + claude_agent.turns = [[Say("Done.")]] + thread = await _agentic_thread(client, claude_on_this_machine) + + frames = await _send(client, thread["id"], "Search my notes") + + (notice,) = _of(frames, "agent-plugins-unavailable") + assert notice["plugins"] == [{"title": "Notes", "reason": "unreachable"}] + + +async def test_the_model_reads_a_long_result_claude_code_saved_for_it( + client: AsyncClient, + engine: Engine, + claude_on_this_machine, + claude_agent: ClaudeAgent, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A large plugin result is saved under the session; reading it is not refused.""" + home = tmp_path / "claude-home" + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(home)) + thread = await _agentic_thread(client, claude_on_this_machine) + with create_session_factory(engine)() as session: + session_id = session.get(ChatThread, thread["id"]).claude_session_id + saved = home / "projects" / "-threads" / session_id / "tool-results" / "toolu_9.txt" + claude_agent.turns = [ + [ + Call("Grep", {"pattern": "title", "path": str(saved)}, "title: Q3"), + Say("Q3."), + ] + ] + + frames = await _send(client, thread["id"], "Search the web") + + (step,) = _of(frames, "agent-step")[-1:] + assert step["status"] == "completed" + assert step["output"] == "title: Q3" + + +async def test_a_plugin_whose_tools_changed_is_reconnected_in_the_running_claude_code( + client: AsyncClient, + engine: Engine, + claude_on_this_machine, + claude_agent: ClaudeAgent, +) -> None: + """Claude Code lists a plugin's tools once it connects: a new input schema needs a new connection.""" + connect_plugin(engine, "https://localhost:1/mcp") + claude_agent.turns = [[Say("One.")], [Say("Two.")], [Say("Three.")]] + thread = await _agentic_thread(client, claude_on_this_machine) + await _send(client, thread["id"], "First") + await _send(client, thread["id"], "Second") + with create_session_factory(engine)() as session: + search = session.get_one(PluginTool, ("notes", "search")) + search.input_schema = { + "type": "object", + "properties": {"q": {"type": "string"}}, + } + session.commit() + + await _send(client, thread["id"], "Third") + + (run,) = claude_agent.started + assert run.reconnected == ["notes"] + + +def _claude_codes_files(home: Path, session_id: str) -> tuple[Path, Path]: + """A session's transcript and a saved result, where Claude Code writes them.""" + project = home / "projects" / "-threads-1" + saved = project / session_id / "tool-results" / "toolu_1.txt" + saved.parent.mkdir(parents=True) + saved.write_text("ledger") + transcript = project / f"{session_id}.jsonl" + transcript.write_text('{"type": "user"}\n') + return transcript, saved + + +async def test_after_a_turn_claude_codes_transcript_is_deleted_and_saved_results_kept( + client: AsyncClient, + engine: Engine, + claude_on_this_machine, + claude_agent: ClaudeAgent, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """SurfSense's copy is the thread; a later turn may still read an earlier saved result.""" + home = tmp_path / "claude-home" + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(home)) + claude_agent.turns = [[Say("Hi.")]] + thread = await _agentic_thread(client, claude_on_this_machine) + with create_session_factory(engine)() as session: + session_id = session.get_one(ChatThread, thread["id"]).claude_session_id + transcript, saved = _claude_codes_files(home, session_id) + + await _send(client, thread["id"], "Hello") + + assert not transcript.exists() + assert saved.read_text() == "ledger" + + +async def test_deleting_the_thread_deletes_claude_codes_saved_results( + client: AsyncClient, + engine: Engine, + claude_on_this_machine, + claude_agent: ClaudeAgent, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """With the transcript gone after each turn, the session's folder still goes with the thread.""" + home = tmp_path / "claude-home" + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(home)) + claude_agent.turns = [[Say("Hi.")]] + thread = await _agentic_thread(client, claude_on_this_machine) + with create_session_factory(engine)() as session: + session_id = session.get_one(ChatThread, thread["id"]).claude_session_id + _, saved = _claude_codes_files(home, session_id) + await _send(client, thread["id"], "Hello") + + deleted = await client.delete(f"/chat/threads/{thread['id']}") + + assert deleted.status_code == 204 + assert not saved.parent.parent.exists() + + +async def test_each_reply_says_whether_the_plan_is_on_extra_usage( + client: AsyncClient, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """Once per reply, so the composer can say so before the next one, and stop once back within the plan.""" + claude_agent.turns = [ + [Limits(using_extra=True), Say("Q3 grew."), Limits(using_extra=True)], + [Limits(using_extra=False), Say("Two.")], + ] + thread = await _agentic_thread(client, claude_on_this_machine) + + past = await _send(client, thread["id"], "What grew?") + within = await _send(client, thread["id"], "And then?") + + assert _of(past, "plan-extra-usage") == [ + {"type": "plan-extra-usage", "using": True, "resets_at": RESETS_AT} + ] + assert _of(past, "completed")[0]["text"] == "Q3 grew." + assert _of(within, "plan-extra-usage") == [ + {"type": "plan-extra-usage", "using": False, "resets_at": RESETS_AT} + ] + + +async def test_a_turn_on_a_claude_code_now_too_old_is_refused_before_it_runs( + client: AsyncClient, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """As a chat is: refused before anything starts, so the composer says why.""" + thread = await _agentic_thread(client, claude_on_this_machine) + claude_on_this_machine(version="2.1.200 (Claude Code)") + + reply = await client.post( + f"/chat/threads/{thread['id']}/messages", json={"text": "Hello"} + ) + + assert reply.status_code == 409 + assert reply.json()["detail"]["code"] == "claude_too_old" + assert claude_agent.started == [] + + +async def test_a_thread_that_left_opencode_reads_back_while_opencode_cannot_answer( + client: AsyncClient, + engine: Engine, + claude_on_this_machine, + claude_agent: ClaudeAgent, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Its opencode session is from before; opencode gone from this install must not keep it from opening.""" + claude_agent.turns = [ + [Call("Glob", {"pattern": "sources/*.md"}, "a.md"), Say("One.")] + ] + thread = await _agentic_thread(client, claude_on_this_machine) + frames = await _send(client, thread["id"], "List my sources") + (step,) = _of(frames, "agent-step")[-1:] + with create_session_factory(engine)() as session: + row = session.get_one(ChatThread, thread["id"]) + row.opencode_session_id = "ses_from_before" + session.commit() + # As on a machine without the agent installed: opencode refuses at once. + monkeypatch.setattr(get_agent_settings(), "opencode_url", None) + + listed = await client.get(f"/chat/threads/{thread['id']}/messages") + whole = await client.get(f"/chat/threads/{thread['id']}/steps/{step['id']}/result") + + assert listed.status_code == 200, listed.text + assert [turn["content"]["text"] for turn in listed.json()] == [ + "List my sources", + "One.", + ] + assert whole.status_code == 200, whole.text + assert whole.json()["output"] == "a.md" diff --git a/surfsense_local/backend/tests/integration/agent/claude_code/test_handover.py b/surfsense_local/backend/tests/integration/agent/claude_code/test_handover.py new file mode 100644 index 0000000000..9d6094d819 --- /dev/null +++ b/surfsense_local/backend/tests/integration/agent/claude_code/test_handover.py @@ -0,0 +1,198 @@ +"""A thread moves between opencode and Claude Code when the user changes models, and stays one thread.""" + +import json +from pathlib import Path + +import pytest +from claude_agent_sdk import get_session_info + +# Claude Code's spelling of a folder as its project's name, which the SDK keeps private. +from claude_agent_sdk._internal.sessions import _canonicalize_path, _sanitize_path +from sqlalchemy import select + +from modules.chat.models import ChatThread +from modules.llm.model_type import ModelType +from modules.llm.models import ProviderConnection, SelectedModel +from shared.config import get_storage_settings +from shared.db import create_db_engine, create_session_factory +from tests.claude_agent_stand_in import Say +from tests.integration.agent.claude_code.conftest import ClaudeAgent +from tests.integration.agent.conftest import AGENT_MODEL, AgentAPI +from tests.integration.agent.test_agent_threads import ( + of_type, + open_thread, + send, + thread_folder, +) + +pytestmark = pytest.mark.integration +SONNET = "claude-sonnet-5-5" + + +async def _choose_claude(api: AgentAPI, claude_on_this_machine) -> None: + claude_on_this_machine() + await api.http.put( + "/egress", + json={ + "destinations": ["host:api.anthropic.com", "host:platform.claude.com"], + "enabled": True, + }, + ) + connection = ( + await api.http.post("/llm/connections/claude", json={"label": "Claude"}) + ).json() + chosen = await api.http.put( + "/llm/selection/text_gen", + json={ + "provider": "openai_compatible", + "connection_id": connection["id"], + "name": SONNET, + }, + ) + assert chosen.status_code == 200, chosen.text + + +def _choose_opencodes_model() -> None: + """Back to the remote model the harness chose, as the harness chose it.""" + with create_session_factory( + create_db_engine(get_storage_settings().database_path) + )() as session: + remote = session.scalars( + select(ProviderConnection).where(ProviderConnection.label == "Remote") + ).one() + selected = session.get(SelectedModel, ModelType.TEXT_GEN) + selected.connection_id = remote.id + selected.name = AGENT_MODEL + session.commit() + + +async def test_switching_to_a_claude_plan_model_hands_the_thread_over( + agent_api: AgentAPI, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """Claude Code is told what opencode did, and the thread reads as one.""" + agent_api.model.replies = [("text", "Opencode found 12%.")] + thread = await open_thread(agent_api, mode="agentic") + await send(agent_api, thread["id"], "What grew in Q3?") + + await _choose_claude(agent_api, claude_on_this_machine) + claude_agent.turns = [[Say("Claude agrees: 12%.")]] + frames = await send(agent_api, thread["id"], "Are you sure?") + + assert of_type(frames, "completed")[0]["text"] == "Claude agrees: 12%." + (run,) = claude_agent.started + (sent,) = run.sent + note, words = sent[0]["text"], sent[-1]["text"] + assert note.startswith("") + assert "What grew in Q3?" in note + assert "Opencode found 12%." in note + assert words == "Are you sure?" + listed = (await agent_api.http.get(f"/chat/threads/{thread['id']}/messages")).json() + assert [turn["content"]["text"] for turn in listed] == [ + "What grew in Q3?", + "Opencode found 12%.", + "Are you sure?", + "Claude agrees: 12%.", + ] + + +async def test_switching_back_tells_opencode_only_what_it_missed( + agent_api: AgentAPI, claude_on_this_machine, claude_agent: ClaudeAgent +) -> None: + """opencode resumes its own session, given the Claude turns since its last.""" + agent_api.model.replies = [("text", "One."), ("text", "Three.")] + thread = await open_thread(agent_api, mode="agentic") + await send(agent_api, thread["id"], "First") + await _choose_claude(agent_api, claude_on_this_machine) + claude_agent.turns = [[Say("Two.")]] + await send(agent_api, thread["id"], "Second") + + _choose_opencodes_model() + frames = await send(agent_api, thread["id"], "Third") + + assert of_type(frames, "completed")[0]["text"] == "Three." + asked = json.dumps(agent_api.model.requests[-1]) + assert "" in asked + assert "Two." in asked + listed = (await agent_api.http.get(f"/chat/threads/{thread['id']}/messages")).json() + assert [turn["content"]["text"] for turn in listed] == [ + "First", + "One.", + "Second", + "Two.", + "Third", + "Three.", + ] + + +async def _left_claude( + api: AgentAPI, claude_on_this_machine, claude_agent: ClaudeAgent, home: Path +) -> tuple[dict, str, Path]: + """A thread Claude Code ran, with Claude Code's own transcript on disk, now on opencode.""" + await _choose_claude(api, claude_on_this_machine) + thread = await open_thread(api, mode="agentic") + claude_agent.turns = [[Say("Two.")]] + await send(api, thread["id"], "First") + with create_session_factory( + create_db_engine(get_storage_settings().database_path) + )() as session: + session_id = session.get_one(ChatThread, thread["id"]).claude_session_id + folder = thread_folder(api.workspace_id, thread["id"]) + # Where Claude Code writes it, which the stand-in does not. + project = home / "projects" / _sanitize_path(_canonicalize_path(str(folder))) + project.mkdir(parents=True) + line = { + "type": "user", + "uuid": "u1", + "sessionId": session_id, + "isSidechain": False, + "timestamp": "2026-10-17T09:00:00.000Z", + "message": {"role": "user", "content": "First"}, + } + (project / f"{session_id}.jsonl").write_text(json.dumps(line) + "\n") + assert get_session_info(session_id, directory=str(folder)) is not None + _choose_opencodes_model() + api.model.replies = [("text", "Three.")] + await send(api, thread["id"], "Second") + return thread, session_id, folder + + +async def test_deleting_a_thread_that_left_claude_code_deletes_its_claude_session( + agent_api: AgentAPI, + claude_on_this_machine, + claude_agent: ClaudeAgent, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Its Claude Code is ended and Claude Code's copy of the chat deleted, as opencode's is.""" + home = tmp_path / "claude-home" + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(home)) + thread, session_id, folder = await _left_claude( + agent_api, claude_on_this_machine, claude_agent, home + ) + + deleted = await agent_api.http.delete(f"/chat/threads/{thread['id']}") + + assert deleted.status_code == 204 + (run,) = claude_agent.started + assert run.closed + assert get_session_info(session_id, directory=str(folder)) is None + + +async def test_deleting_a_workspace_deletes_the_claude_session_of_a_thread_that_left( + agent_api: AgentAPI, + claude_on_this_machine, + claude_agent: ClaudeAgent, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Every thread Claude Code ran, not only those on it now.""" + home = tmp_path / "claude-home" + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(home)) + _, session_id, folder = await _left_claude( + agent_api, claude_on_this_machine, claude_agent, home + ) + + deleted = await agent_api.http.delete(f"/workspaces/{agent_api.workspace_id}") + + assert deleted.status_code == 204 + assert get_session_info(session_id, directory=str(folder)) is None diff --git a/surfsense_local/backend/tests/integration/agent/claude_code/test_session_store.py b/surfsense_local/backend/tests/integration/agent/claude_code/test_session_store.py new file mode 100644 index 0000000000..214acca7f6 --- /dev/null +++ b/surfsense_local/backend/tests/integration/agent/claude_code/test_session_store.py @@ -0,0 +1,56 @@ +"""SurfSense keeps a thread's Claude Code session, so resume and reading back never need ~/.claude.""" + +import pytest +from sqlalchemy import Engine + +from modules.agent.engines.claude_code.session_store import ThreadSessionStore +from modules.chat.models import AgentEngineKind, ChatThread +from modules.workspaces.models import Workspace +from shared.db import create_session_factory + +pytestmark = pytest.mark.integration +KEY = {"project_key": "-threads-1", "session_id": "s-1"} + + +def _thread(engine: Engine) -> int: + with create_session_factory(engine)() as session: + workspace = Workspace(name="W") + session.add(workspace) + session.flush() + thread = ChatThread( + workspace_id=workspace.id, + agent_engine=AgentEngineKind.CLAUDE_CODE, + claude_session_id="s-1", + ) + session.add(thread) + session.commit() + return thread.id + + +async def test_a_session_loads_back_as_it_was_written(engine: Engine) -> None: + """In order, each entry once, though a batch the SDK retried came twice.""" + store = ThreadSessionStore(create_session_factory(engine), _thread(engine)) + first = [{"type": "user", "uuid": "e-1"}, {"type": "queue-operation"}] + second = [{"type": "assistant", "uuid": "e-2", "message": {"id": "msg_1"}}] + + await store.append(KEY, first) + await store.append(KEY, second) + await store.append(KEY, second) + + assert await store.load(KEY) == [*first, *second] + + +async def test_a_session_never_written_loads_as_none(engine: Engine) -> None: + """The SDK then starts the session fresh rather than resuming an empty one.""" + store = ThreadSessionStore(create_session_factory(engine), _thread(engine)) + + assert await store.load({**KEY, "session_id": "s-2"}) is None + + +async def test_a_subagents_transcript_is_not_kept(engine: Engine) -> None: + """SurfSense gives Claude Code no subagents; one appearing is never the thread's.""" + store = ThreadSessionStore(create_session_factory(engine), _thread(engine)) + + await store.append({**KEY, "subpath": "subagents/agent-1"}, [{"uuid": "x"}]) + + assert await store.load(KEY) is None diff --git a/surfsense_local/backend/tests/integration/agent/test_routes_without_opencode.py b/surfsense_local/backend/tests/integration/agent/test_routes_without_opencode.py index 9bfd4bd79d..7d32260b80 100644 --- a/surfsense_local/backend/tests/integration/agent/test_routes_without_opencode.py +++ b/surfsense_local/backend/tests/integration/agent/test_routes_without_opencode.py @@ -1,4 +1,7 @@ -"""An API that Electron started without opencode serves none of opencode's routes.""" +"""An API that Electron started without opencode serves none of opencode's own routes. + +The tool endpoint serves a Claude Code thread too, so it stays, behind the launch key. +""" import pytest from httpx import ASGITransport, AsyncClient @@ -13,11 +16,6 @@ # Each with a body its route accepts, so only a missing route can answer 404. OPENCODE_ROUTES = [ ("/agent/model/v1/chat/completions", {"model": "m", "messages": []}), - ( - "/agent/tools/workspaces/1/threads/1", - {"jsonrpc": "2.0", "id": 1, "method": "ping"}, - ), - ("/chat/threads/1/permissions/request-1", {"reply": "once"}), ] @@ -38,3 +36,24 @@ async def test_an_api_without_opencode_has_none_of_its_routes( assert reply.status_code == 404 assert reply.json() == {"detail": "Not Found"} + + +async def test_without_opencode_the_tool_endpoint_still_wants_the_launch_key( + engine: Engine, monkeypatch: pytest.MonkeyPatch +) -> None: + """Loopback is open to every process; only an engine SurfSense launched holds the key.""" + monkeypatch.setattr(get_agent_settings(), "opencode_url", None) + monkeypatch.setattr(get_agent_settings(), "opencode_password", None) + app = create_app() + app.state.session_factory = create_session_factory(engine) + app.state.agent_launch_key = "the-launch-key" + + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + reply = await client.post( + "/agent/tools/workspaces/1/threads/1", + json={"jsonrpc": "2.0", "id": 1, "method": "ping"}, + ) + + assert reply.status_code == 401 diff --git a/surfsense_local/backend/tests/integration/agent/test_thread_folders.py b/surfsense_local/backend/tests/integration/agent/test_thread_folders.py index d69c85ffc9..07d01ed011 100644 --- a/surfsense_local/backend/tests/integration/agent/test_thread_folders.py +++ b/surfsense_local/backend/tests/integration/agent/test_thread_folders.py @@ -18,7 +18,7 @@ from modules.agent.thread_folder import sync as sync_module from modules.agent.thread_folder import text_cache from modules.agent.thread_folder.sync import ThreadGoneError, sync_thread_folder -from modules.chat.models import ChatThread +from modules.chat.models import AgentEngineKind, ChatThread from modules.documents.models import Document, DocumentStatus, DocumentType from modules.folders.models import Folder from modules.folders.names import name_key @@ -51,7 +51,10 @@ def workspace(session: Session) -> int: def agent_thread(session: Session, workspace_id: int) -> ChatThread: """A thread the agent answers, as opening one leaves it.""" thread = ChatThread( - workspace_id=workspace_id, title="New chat", opencode_session_id="ses_test" + workspace_id=workspace_id, + title="New chat", + agent_engine=AgentEngineKind.OPENCODE, + opencode_session_id="ses_test", ) session.add(thread) session.commit() @@ -641,7 +644,11 @@ def test_a_deleted_thread_is_not_synced_again( session.delete(thread) session.commit() if id_given_out_again: - next_thread = ChatThread(workspace_id=workspace_id, opencode_session_id="ses_2") + next_thread = ChatThread( + workspace_id=workspace_id, + agent_engine=AgentEngineKind.OPENCODE, + opencode_session_id="ses_2", + ) session.add(next_thread) session.commit() assert next_thread.id == thread.id diff --git a/surfsense_local/backend/tests/integration/agent/test_thread_modes.py b/surfsense_local/backend/tests/integration/agent/test_thread_modes.py index f5418a6885..88e4a8a243 100644 --- a/surfsense_local/backend/tests/integration/agent/test_thread_modes.py +++ b/surfsense_local/backend/tests/integration/agent/test_thread_modes.py @@ -6,7 +6,7 @@ from httpx import AsyncClient from sqlalchemy import Engine -from modules.chat import router as chat_router +from modules.agent.engines import opencode_engine from modules.chat.models import ChatThread from modules.llm.capability.modes import carried_to_next_model from modules.llm.model_type import ModelType @@ -103,7 +103,9 @@ async def another_model_while_starting(*_args: object) -> str: await asyncio.to_thread(select_another) return "ses_1" - monkeypatch.setattr(chat_router, "open_agent_session", another_model_while_starting) + monkeypatch.setattr( + opencode_engine, "open_agent_session", another_model_while_starting + ) status, thread = await _open(client, mode="agentic", remember=True) @@ -152,7 +154,7 @@ async def test_agentic_when_the_agent_does_not_start_is_a_503_and_no_thread( async def not_ready(*_args: object) -> None: return None - monkeypatch.setattr(chat_router, "open_agent_session", not_ready) + monkeypatch.setattr(opencode_engine, "open_agent_session", not_ready) _select(engine, "gpt-4o-mini") status, body = await _open(client, mode="agentic", remember=True) @@ -171,7 +173,7 @@ async def test_with_no_mode_a_model_whose_agent_does_not_start_opens_a_chat( async def not_ready(*_args: object) -> None: return None - monkeypatch.setattr(chat_router, "open_agent_session", not_ready) + monkeypatch.setattr(opencode_engine, "open_agent_session", not_ready) _select(engine, "claude-opus-5-5", "anthropic") status, thread = await _open(client) diff --git a/surfsense_local/backend/tests/integration/agent/test_tool_endpoint.py b/surfsense_local/backend/tests/integration/agent/test_tool_endpoint.py index fa8c28c16e..6553de9446 100644 --- a/surfsense_local/backend/tests/integration/agent/test_tool_endpoint.py +++ b/surfsense_local/backend/tests/integration/agent/test_tool_endpoint.py @@ -5,6 +5,7 @@ import pytest from sqlalchemy import Engine, select +from modules.chat.models import AgentEngineKind, ChatThread from modules.chunks.models import Chunk from modules.documents.models import Document, DocumentStatus, DocumentType from modules.llm.model_type import ModelType @@ -516,3 +517,23 @@ async def test_a_call_naming_nothing_says_what_to_name(tools: ToolEndpoint) -> N assert "quiz" in no_format["result"]["content"][0]["text"] assert no_sources["result"]["isError"] is True assert "sources/" in no_sources["result"]["content"][0]["text"] + + +async def test_a_claude_code_thread_is_offered_the_tools_that_show_images( + tools: ToolEndpoint, +) -> None: + """Every Claude model reads images; opencode's configuration describes opencode's model.""" + workspace_id = await tools.workspace() + with tools.sessions() as session: + thread = ChatThread( + workspace_id=workspace_id, + agent_engine=AgentEngineKind.CLAUDE_CODE, + claude_session_id="s-1", + ) + session.add(thread) + session.commit() + thread_id = thread.id + + reply = await tools.request(workspace_id, "tools/list", thread=thread_id) + + assert "source_pages" in {tool["name"] for tool in reply["result"]["tools"]} diff --git a/surfsense_local/backend/tests/integration/agent/test_turn_preparation.py b/surfsense_local/backend/tests/integration/agent/test_turn_preparation.py index e6ce02ffd9..6f4298fd03 100644 --- a/surfsense_local/backend/tests/integration/agent/test_turn_preparation.py +++ b/surfsense_local/backend/tests/integration/agent/test_turn_preparation.py @@ -52,7 +52,7 @@ async def test_plugins_are_given_at_once_and_one_that_hangs_is_named_alone( caplog: pytest.LogCaptureFixture, ) -> None: """Two slow servers connect side by side; a hung one costs the turn its own wait, not the others.""" - monkeypatch.setattr(give_plugins, "ADD_SECONDS", 4) + monkeypatch.setattr(give_plugins, "CONNECT_SECONDS", 4) caplog.set_level(logging.INFO, logger="modules.agent.agent_threads.turn") slow = [ Delaying(notes_server().streamable_http_app(), "initialize", 2) for _ in "ab" @@ -87,7 +87,7 @@ async def test_the_rules_are_set_even_when_a_plugin_cannot_be_given( agent_api: AgentAPI, monkeypatch: pytest.MonkeyPatch ) -> None: """A tool switched off since the last turn is not offered, though another plugin's connect fails.""" - monkeypatch.setattr(give_plugins, "ADD_SECONDS", 2) + monkeypatch.setattr(give_plugins, "CONNECT_SECONDS", 2) hung = Delaying(notes_server().streamable_http_app(), "initialize", 3600) with serving(notes_server().streamable_http_app()) as notes, serving(hung) as base: await connect_notes(agent_api, f"{notes}/mcp") diff --git a/surfsense_local/backend/tests/integration/agent/test_turn_sources.py b/surfsense_local/backend/tests/integration/agent/test_turn_sources.py index 6b8d36024c..92de9d236d 100644 --- a/surfsense_local/backend/tests/integration/agent/test_turn_sources.py +++ b/surfsense_local/backend/tests/integration/agent/test_turn_sources.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Session from modules.agent.agent_threads.turn_sources import turn_sources -from modules.chat.models import ChatThread +from modules.chat.models import AgentEngineKind, ChatThread from modules.chat.schemas import MessageCreate from modules.documents.models import Document, DocumentStatus, DocumentType from modules.folders.models import Folder @@ -37,7 +37,11 @@ def test_a_scope_past_a_thousand_sources_bounds_the_turn_whole(engine: Engine) - for n in range(1003) ] session.add_all(notes) - thread = ChatThread(workspace_id=workspace.id, opencode_session_id="ses_1") + thread = ChatThread( + workspace_id=workspace.id, + agent_engine=AgentEngineKind.OPENCODE, + opencode_session_id="ses_1", + ) session.add(thread) session.flush() unticked = notes[0].id @@ -76,7 +80,11 @@ def test_a_ticked_folder_still_being_read_is_not_called_nothing_selected( ) for n in range(2) ) - thread = ChatThread(workspace_id=workspace.id, opencode_session_id="ses_1") + thread = ChatThread( + workspace_id=workspace.id, + agent_engine=AgentEngineKind.OPENCODE, + opencode_session_id="ses_1", + ) session.add(thread) session.flush() scope = SourceScope(folder_ids=[library.id]) @@ -120,7 +128,11 @@ def _ready( def _agent_thread(session: Session, workspace_id: int) -> ChatThread: - thread = ChatThread(workspace_id=workspace_id, opencode_session_id="ses_1") + thread = ChatThread( + workspace_id=workspace_id, + agent_engine=AgentEngineKind.OPENCODE, + opencode_session_id="ses_1", + ) session.add(thread) session.flush() return thread diff --git a/surfsense_local/backend/tests/integration/agent/tool_endpoint_client.py b/surfsense_local/backend/tests/integration/agent/tool_endpoint_client.py index 3a732dfc89..42bf663391 100644 --- a/surfsense_local/backend/tests/integration/agent/tool_endpoint_client.py +++ b/surfsense_local/backend/tests/integration/agent/tool_endpoint_client.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import Session, sessionmaker from api.main import create_app -from modules.chat.models import ChatThread +from modules.chat.models import AgentEngineKind, ChatThread from modules.source_scope.schemas import SourceScope from shared.config import get_storage_settings from shared.db import create_session_factory @@ -48,6 +48,7 @@ def thread( thread = ChatThread( workspace_id=workspace_id, title="New chat", + agent_engine=AgentEngineKind.OPENCODE if agent else None, opencode_session_id="ses_test" if agent else None, source_scope=None if scope is None else scope.model_dump(), ) diff --git a/surfsense_local/backend/tests/integration/llm/chatgpt/test_sign_in_routes.py b/surfsense_local/backend/tests/integration/llm/chatgpt/test_sign_in_routes.py index 210f5a5789..a9ca1cd06a 100644 --- a/surfsense_local/backend/tests/integration/llm/chatgpt/test_sign_in_routes.py +++ b/surfsense_local/backend/tests/integration/llm/chatgpt/test_sign_in_routes.py @@ -487,7 +487,9 @@ async def test_the_sign_in_says_what_it_serves_and_names_hosts_until_allowed( monkeypatch.setattr(endpoints, "api_url", "https://api.example/v1") before = (await client.get("/llm/connections/chatgpt")).json() - await client.put("/egress/host:auth.example", json={"enabled": True}) + await client.put( + "/egress", json={"destinations": ["host:auth.example"], "enabled": True} + ) after = (await client.get("/llm/connections/chatgpt")).json() assert before == { diff --git a/surfsense_local/backend/tests/integration/llm/claude/conftest.py b/surfsense_local/backend/tests/integration/llm/claude/conftest.py new file mode 100644 index 0000000000..8f1037edda --- /dev/null +++ b/surfsense_local/backend/tests/integration/llm/claude/conftest.py @@ -0,0 +1,82 @@ +import sys +from collections.abc import Callable +from pathlib import Path + +import pytest + +from modules.llm.subscriptions.claude import locate, plan_generator +from modules.llm.subscriptions.claude import models as claude_models +from modules.llm.subscriptions.claude import proxy as claude_proxy +from tests.claude_run_stand_in import ScriptedRun, answer +from tests.claude_stand_in import claude_stand_in + +ClaudeOnThisMachine = Callable[..., Path | None] + + +@pytest.fixture +def claude_on_this_machine( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> ClaudeOnThisMachine: + """Put a stand-in Claude Code where the API looks, and nothing else. + + HOME is a fresh folder, so the developer's own Claude Code is never found. + Called with no arguments it installs a signed-in one; `installed=False` + leaves the machine without any. + """ + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + monkeypatch.setenv("PATH", str(bin_dir)) + if sys.platform == "win32": + # The stand-in is a batch file, which Windows runs as a process does; only + # the Agent SDK refuses one, and no test here starts it. + monkeypatch.setattr(locate, "WINDOWS_NAME", "claude.cmd") + + def install(*, installed: bool = True, **stand_in: object) -> Path | None: + return claude_stand_in(bin_dir, **stand_in) if installed else None + + return install + + +class FakeClaude: + """Every Claude Code run the API opens, scripted. + + `runs` are handed out in order; past them, a run answers a title request + with a title and anything else with the next of `replies`, so a chat and + the title generated beside it never take each other's answer. + """ + + def __init__(self) -> None: + self.runs: list[ScriptedRun] = [] + self.replies: list[list] = [] + self.opened: list[ScriptedRun] = [] + + def _respond(self, prompt: object) -> list: + text = prompt if isinstance(prompt, str) else prompt[-1]["text"] + if text.startswith("Write a 2-5 word"): + return answer("A title") + reply = self.replies.pop(0) + # A reply can act when it is sent, as something changing mid-run. + return reply() if callable(reply) else reply + + async def open(self, options: object) -> ScriptedRun: + run = self.runs.pop(0) if self.runs else ScriptedRun(respond=self._respond) + run.options = options + self.opened.append(run) + return run + + +@pytest.fixture +def fake_claude(monkeypatch: pytest.MonkeyPatch) -> FakeClaude: + """Stand in for every Claude Code run, model lists and chat alike.""" + fake = FakeClaude() + monkeypatch.setattr(claude_models, "open_run", fake.open) + monkeypatch.setattr(plan_generator, "open_run", fake.open) + # The API's one pool of warm chat runs, fresh for each test. + monkeypatch.setattr(plan_generator, "_runs", None) + # A serving API's proxy address; scripted runs never connect to it. + monkeypatch.setattr(claude_proxy, "_url", "http://127.0.0.1:9") + return fake diff --git a/surfsense_local/backend/tests/integration/llm/claude/test_claude_chat.py b/surfsense_local/backend/tests/integration/llm/claude/test_claude_chat.py new file mode 100644 index 0000000000..f839f10237 --- /dev/null +++ b/surfsense_local/backend/tests/integration/llm/claude/test_claude_chat.py @@ -0,0 +1,338 @@ +"""A Claude plan model answers the app's chat, its chat check, and every resolver.""" + +import json +import threading + +import pytest +from httpx import AsyncClient +from sqlalchemy import Engine + +from modules.llm.resolution import resolve_generation +from modules.llm.subscriptions.claude import plan_generator +from modules.llm.subscriptions.claude.chat import ClaudePlanProvider +from modules.llm.subscriptions.claude.proxy import serving_claude_proxy +from shared.db import create_session_factory +from tests.claude_run_stand_in import RESETS_AT, answer, plan_limits +from tests.claude_stand_in import SIGNED_OUT + +from .conftest import ClaudeOnThisMachine, FakeClaude + +pytestmark = pytest.mark.integration +SONNET = "claude-sonnet-5-5" + + +async def _choose_claude(client: AsyncClient, claude_on_this_machine) -> int: + claude_on_this_machine() + for host in ("api.anthropic.com", "platform.claude.com"): + await client.put( + "/egress", json={"destinations": [f"host:{host}"], "enabled": True} + ) + connection = ( + await client.post("/llm/connections/claude", json={"label": "Claude"}) + ).json() + chosen = await client.put( + "/llm/selection/text_gen", + json={ + "provider": "openai_compatible", + "connection_id": connection["id"], + "name": SONNET, + }, + ) + assert chosen.status_code == 200, chosen.text + return connection["id"] + + +async def _send(client: AsyncClient, thread_id: int, text: str) -> list[dict]: + frames: list[dict] = [] + async with client.stream( + "POST", f"/chat/threads/{thread_id}/messages", json={"text": text} + ) as reply: + assert reply.status_code == 200 + async for line in reply.aiter_lines(): + if line.startswith("data: ") and line != "data: [DONE]": + frames.append(json.loads(line[len("data: ") :])) + return frames + + +async def _basic_thread(client: AsyncClient) -> int: + workspace = (await client.post("/workspaces", json={"name": "w"})).json() + reply = await client.post( + f"/workspaces/{workspace['id']}/chat/threads", json={"mode": "basic"} + ) + assert reply.status_code in (200, 201), reply.text + return reply.json()["id"] + + +async def test_a_claude_plan_model_is_the_chat_models_generator( + client: AsyncClient, + engine: Engine, + claude_on_this_machine: ClaudeOnThisMachine, + fake_claude: FakeClaude, +) -> None: + """Resolution hands chat, titles and Studio the Claude plan's own generator.""" + await _choose_claude(client, claude_on_this_machine) + + with create_session_factory(engine)() as session: + resolved = resolve_generation(session) + + assert isinstance(resolved.generator, ClaudePlanProvider) + assert resolved.selection.name == SONNET + + +async def test_a_claude_plan_model_cannot_fill_another_slot( + client: AsyncClient, + claude_on_this_machine: ClaudeOnThisMachine, + fake_claude: FakeClaude, +) -> None: + """Claude Code answers text only, whatever model is named.""" + connection_id = await _choose_claude(client, claude_on_this_machine) + + refused = await client.put( + "/llm/selection/image_gen", + json={ + "provider": "openai_compatible", + "connection_id": connection_id, + "name": SONNET, + "allow_unlisted": True, + }, + ) + + assert refused.status_code == 422 + + +async def test_the_chat_check_answers_through_claude_code( + client: AsyncClient, + claude_on_this_machine: ClaudeOnThisMachine, + fake_claude: FakeClaude, +) -> None: + """Trying a model before choosing it runs it the way chat will.""" + connection_id = await _choose_claude(client, claude_on_this_machine) + fake_claude.replies.append(answer("Hi there.")) + + tested = await client.post( + f"/llm/connections/{connection_id}/chat-test", json={"model": SONNET} + ) + + assert tested.status_code == 200, tested.text + assert tested.json()["reply"] == "Hi there." + + +@pytest.mark.usefixtures("real_model") +async def test_a_basic_chat_streams_claudes_answer( + client: AsyncClient, + claude_on_this_machine: ClaudeOnThisMachine, + fake_claude: FakeClaude, +) -> None: + """The answer arrives as deltas and is stored as the reply.""" + await _choose_claude(client, claude_on_this_machine) + fake_claude.replies.append(answer("Paris is ", "the capital.")) + thread_id = await _basic_thread(client) + + frames = await _send(client, thread_id, "What is the capital of France?") + + streamed = "".join(f["text"] for f in frames if f.get("type") == "delta") + assert streamed == "Paris is the capital." + assert all(run.options.tools == [] for run in fake_claude.opened) + stored = (await client.get(f"/chat/threads/{thread_id}/messages")).json() + assert stored[-1]["content"]["text"] == "Paris is the capital." + titled = [f for f in frames if f.get("type") == "thread-title-update"] + assert titled and titled[0]["title"] == "A title" + + +@pytest.mark.usefixtures("real_model") +async def test_a_signed_out_claude_code_shows_the_sign_in_notice( + client: AsyncClient, + claude_on_this_machine: ClaudeOnThisMachine, + fake_claude: FakeClaude, +) -> None: + """Signed out after connecting: the chat offers the plan's own fix.""" + from claude_agent_sdk import AssistantMessage, TextBlock + + from tests.claude_run_stand_in import done + + await _choose_claude(client, claude_on_this_machine) + signed_out = AssistantMessage( + content=[TextBlock("Not logged in")], model="x", error="authentication_failed" + ) + fake_claude.replies.append([signed_out, done(is_error=True)]) + claude_on_this_machine(auth=SIGNED_OUT) + thread_id = await _basic_thread(client) + + frames = await _send(client, thread_id, "Hi") + + errors = [f for f in frames if f.get("type") == "error"] + assert errors and errors[0]["kind"] == "claude_sign_in" + + +@pytest.mark.usefixtures("real_model") +async def test_closing_the_api_closes_every_warm_run( + client: AsyncClient, + claude_on_this_machine: ClaudeOnThisMachine, + fake_claude: FakeClaude, +) -> None: + """No Claude Code process outlives the API.""" + await _choose_claude(client, claude_on_this_machine) + fake_claude.replies.append(answer("ok")) + await _send(client, await _basic_thread(client), "Hi") + + await plan_generator.close_claude_runs() + + assert all(run.closed for run in fake_claude.opened) + + +@pytest.mark.usefixtures("real_model") +async def test_every_claude_code_run_goes_through_the_proxy( + client: AsyncClient, + engine: Engine, + claude_on_this_machine: ClaudeOnThisMachine, + fake_claude: FakeClaude, +) -> None: + """The model list and the chat alike, so egress consent holds for Claude Code.""" + async with serving_claude_proxy( + create_session_factory(engine), "127.0.0.1" + ) as port: + await _choose_claude(client, claude_on_this_machine) + fake_claude.replies.append(answer("ok")) + await _send(client, await _basic_thread(client), "Hi") + + proxy = f"http://127.0.0.1:{port}" + assert fake_claude.opened + assert all(run.options.env["HTTPS_PROXY"] == proxy for run in fake_claude.opened) + + +@pytest.mark.usefixtures("real_model") +async def test_a_chat_with_a_claude_host_off_is_refused_before_claude_code_runs( + client: AsyncClient, + claude_on_this_machine: ClaudeOnThisMachine, + fake_claude: FakeClaude, +) -> None: + """Checked with the chat's own egress check, like any remote model.""" + await _choose_claude(client, claude_on_this_machine) + opened = len(fake_claude.opened) + await client.put( + "/egress", json={"destinations": ["host:platform.claude.com"], "enabled": False} + ) + thread_id = await _basic_thread(client) + + reply = await client.post( + f"/chat/threads/{thread_id}/messages", json={"text": "Hi"} + ) + + assert reply.status_code == 403 + assert reply.json()["detail"]["destination"] == "host:platform.claude.com" + assert len(fake_claude.opened) == opened + + +@pytest.mark.usefixtures("real_model") +async def test_a_chat_on_a_claude_code_now_too_old_is_refused_before_it_runs( + client: AsyncClient, + claude_on_this_machine: ClaudeOnThisMachine, + fake_claude: FakeClaude, +) -> None: + """Replaced by an older one mid-session: the send is refused, so the composer can say why.""" + await _choose_claude(client, claude_on_this_machine) + opened = len(fake_claude.opened) + claude_on_this_machine(version="2.1.200 (Claude Code)") + thread_id = await _basic_thread(client) + + reply = await client.post( + f"/chat/threads/{thread_id}/messages", json={"text": "Hi"} + ) + + assert reply.status_code == 409 + assert reply.json()["detail"]["code"] == "claude_too_old" + assert len(fake_claude.opened) == opened + + +@pytest.mark.usefixtures("real_model") +async def test_a_chat_after_claude_code_was_uninstalled_is_refused_before_it_runs( + client: AsyncClient, + claude_on_this_machine: ClaudeOnThisMachine, + fake_claude: FakeClaude, +) -> None: + """The composer's own reason, so it shows there as it does at startup.""" + installed = claude_on_this_machine() + await _choose_claude(client, lambda: installed) + installed.unlink() + thread_id = await _basic_thread(client) + + reply = await client.post( + f"/chat/threads/{thread_id}/messages", json={"text": "Hi"} + ) + + assert reply.status_code == 409 + assert reply.json()["detail"]["code"] == "claude_sign_in_required" + + +@pytest.mark.usefixtures("real_model") +async def test_no_claude_code_runs_without_the_proxy( + client: AsyncClient, + claude_on_this_machine: ClaudeOnThisMachine, + fake_claude: FakeClaude, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Fails closed: a Claude Code run that skipped the proxy would skip egress consent.""" + from modules.llm.subscriptions.claude import proxy as claude_proxy + + await _choose_claude(client, claude_on_this_machine) + monkeypatch.setattr(claude_proxy, "_url", None) + opened = len(fake_claude.opened) + + frames = await _send(client, await _basic_thread(client), "Hi") + + assert [f["type"] for f in frames if f.get("type") == "error"] == ["error"] + assert len(fake_claude.opened) == opened + + +@pytest.mark.usefixtures("real_model") +async def test_a_host_turned_off_during_a_reply_says_so( + client: AsyncClient, + engine: Engine, + claude_on_this_machine: ClaudeOnThisMachine, + fake_claude: FakeClaude, +) -> None: + """Checked before the reply and passed; switched off while Claude Code runs.""" + from claude_agent_sdk import SystemMessage + + from modules.egress.service import set_enabled + + await _choose_claude(client, claude_on_this_machine) + + def switch_off() -> None: + with create_session_factory(engine)() as session: + set_enabled(session, "host:api.anthropic.com", False) + session.commit() + + def switched_off_mid_run() -> list: + # Off the event loop, as Settings > Network's own request would be. + flip = threading.Thread(target=switch_off) + flip.start() + flip.join() + return [SystemMessage(subtype="api_retry", data={"attempt": 1}), *answer("x")] + + fake_claude.replies.append(switched_off_mid_run) + + frames = await _send(client, await _basic_thread(client), "Hi") + + errors = [f for f in frames if f.get("type") == "error"] + assert [e["kind"] for e in errors] == ["egress_disabled"] + + +@pytest.mark.usefixtures("real_model") +async def test_a_basic_chat_says_whether_the_plan_is_on_extra_usage( + client: AsyncClient, + claude_on_this_machine: ClaudeOnThisMachine, + fake_claude: FakeClaude, +) -> None: + """As an agent turn does: once per reply, beside an answer that goes on.""" + await _choose_claude(client, claude_on_this_machine) + past = plan_limits(using_extra=True) + fake_claude.replies.append([past, *answer("Paris."), past]) + thread_id = await _basic_thread(client) + + frames = await _send(client, thread_id, "What is the capital of France?") + + assert [f for f in frames if f["type"] == "plan-extra-usage"] == [ + {"type": "plan-extra-usage", "using": True, "resets_at": RESETS_AT} + ] + assert "".join(f["text"] for f in frames if f["type"] == "delta") == "Paris." diff --git a/surfsense_local/backend/tests/integration/llm/claude/test_claude_proxy.py b/surfsense_local/backend/tests/integration/llm/claude/test_claude_proxy.py new file mode 100644 index 0000000000..2453a115e7 --- /dev/null +++ b/surfsense_local/backend/tests/integration/llm/claude/test_claude_proxy.py @@ -0,0 +1,124 @@ +"""The proxy Claude Code's traffic goes through: Anthropic's two hosts, only while allowed.""" + +import asyncio +import logging +from collections.abc import AsyncIterator + +import pytest +import pytest_asyncio +from sqlalchemy import Engine + +from modules.egress.service import set_enabled +from modules.llm.subscriptions.claude import proxy as claude_proxy +from modules.llm.subscriptions.claude.proxy_hosts import ClaudeHosts +from shared.db import create_session_factory +from tests.integration.agent.test_plugin_proxy import Echo, through + +pytestmark = pytest.mark.integration + + +def _allow(engine: Engine, *hosts: str) -> None: + with create_session_factory(engine)() as session: + for host in hosts: + set_enabled(session, f"host:{host}", True) + session.commit() + + +@pytest_asyncio.fixture(loop_scope="function") +async def proxy(engine: Engine) -> AsyncIterator[int]: + """The Claude proxy as the API serves it, on a free loopback port.""" + async with claude_proxy.serving_claude_proxy( + create_session_factory(engine), "127.0.0.1" + ) as port: + yield port + + +@pytest_asyncio.fixture(loop_scope="function") +async def echo() -> AsyncIterator[Echo]: + """A server that sends back what it is sent, standing in for an allowed host.""" + state = Echo(port=0) + + async def answer( + reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + state.connections += 1 + writer.write(await reader.read(100)) + await writer.drain() + writer.close() + + server = await asyncio.start_server(answer, "127.0.0.1", 0) + state.port = server.sockets[0].getsockname()[1] + async with server: + yield state + + +@pytest.mark.parametrize("host", ["api.anthropic.com", "platform.claude.com"]) +async def test_a_claude_host_is_refused_until_allowed( + engine: Engine, proxy: int, host: str +) -> None: + """Refused before any connection is tried, so nothing leaves the machine.""" + head, _ = await through(proxy, f"CONNECT {host}:443 HTTP/1.1\r\n\r\n".encode()) + + assert head.startswith(b"HTTP/1.1 403") + + +async def test_no_other_host_is_ever_let_through(engine: Engine, proxy: int) -> None: + """Even one the user allowed for something else: Claude Code reaches Anthropic only.""" + _allow(engine, "huggingface.co") + + head, _ = await through(proxy, b"CONNECT huggingface.co:443 HTTP/1.1\r\n\r\n") + + assert head.startswith(b"HTTP/1.1 403") + + +def test_each_host_is_let_through_only_while_it_is_allowed(engine: Engine) -> None: + """Turning one off in Settings > Network closes that host alone.""" + hosts = ClaudeHosts(create_session_factory(engine)) + _allow(engine, "api.anthropic.com") + + assert hosts.read() == {("api.anthropic.com", 443)} + + _allow(engine, "platform.claude.com") + + assert hosts.read() == {("api.anthropic.com", 443), ("platform.claude.com", 443)} + + +async def test_an_allowed_host_is_tunnelled_to(engine: Engine, echo: Echo) -> None: + """Claude Code's TLS goes through untouched: SurfSense sees the host, never the content.""" + async with claude_proxy.serving_claude_proxy( + create_session_factory(engine), "127.0.0.1", hosts=(("localhost", echo.port),) + ) as port: + head, echoed = await through( + port, f"CONNECT localhost:{echo.port} HTTP/1.1\r\n\r\n".encode() + ) + + assert head.startswith(b"HTTP/1.1 200") + assert echoed == b"ping" + + +async def test_runs_know_the_proxys_address_only_while_it_serves( + engine: Engine, +) -> None: + """Every Claude Code run reads it; outside the API there is none.""" + assert claude_proxy.claude_proxy_url() is None + + async with claude_proxy.serving_claude_proxy( + create_session_factory(engine), "127.0.0.1" + ) as port: + assert claude_proxy.claude_proxy_url() == f"http://127.0.0.1:{port}" + + assert claude_proxy.claude_proxy_url() is None + + +async def test_a_refused_host_is_logged_as_claude_codes( + engine: Engine, proxy: int, caplog: pytest.LogCaptureFixture +) -> None: + """A host a future Claude Code tries on its own shows in the log at once.""" + caplog.set_level(logging.INFO) + + await through(proxy, b"CONNECT console.anthropic.com:443 HTTP/1.1\r\n\r\n") + + refused = [ + r.getMessage() for r in caplog.records if r.getMessage().startswith("refused") + ] + assert refused == ["refused Claude Code's CONNECT console.anthropic.com:443"] diff --git a/surfsense_local/backend/tests/integration/llm/claude/test_claude_routes.py b/surfsense_local/backend/tests/integration/llm/claude/test_claude_routes.py new file mode 100644 index 0000000000..0913b518b6 --- /dev/null +++ b/surfsense_local/backend/tests/integration/llm/claude/test_claude_routes.py @@ -0,0 +1,308 @@ +"""The Claude plan connection: Claude Code's state, and the connection made from it.""" + +import pytest +from httpx import AsyncClient + +from tests.claude_stand_in import SIGNED_OUT + +from .conftest import ClaudeOnThisMachine, FakeClaude + +pytestmark = pytest.mark.integration +ANTHROPIC = "host:api.anthropic.com" +SIGN_IN = "host:platform.claude.com" + + +async def _allow(client: AsyncClient, *destinations: str) -> None: + for destination in destinations: + reply = await client.put( + "/egress", json={"destinations": [destination], "enabled": True} + ) + assert reply.status_code == 200 + + +async def _allow_anthropic(client: AsyncClient) -> None: + await _allow(client, ANTHROPIC, SIGN_IN) + + +async def _connect(client: AsyncClient) -> dict: + await _allow_anthropic(client) + reply = await client.post("/llm/connections/claude", json={"label": "Claude"}) + assert reply.status_code == 201, reply.text + return reply.json() + + +async def test_the_option_is_offered_without_running_claude_code( + client: AsyncClient, claude_on_this_machine: ClaudeOnThisMachine, tmp_path +) -> None: + """The row shows at once, installed or not: what a plan fills is not Claude Code's to say.""" + runs = tmp_path / "claude-runs.json" + claude_on_this_machine(env_log=runs) + + option = (await client.get("/llm/connections/claude")).json() + + assert option == { + "serves": ["text_gen"], + "hosts": [ + {"destination": ANTHROPIC, "host": "api.anthropic.com"}, + {"destination": SIGN_IN, "host": "platform.claude.com"}, + ], + } + assert not runs.exists() + + +async def test_a_machine_without_claude_code_says_so( + client: AsyncClient, claude_on_this_machine: ClaudeOnThisMachine +) -> None: + """The dialog's first view: install Claude Code.""" + claude_on_this_machine(installed=False) + + found = (await client.get("/llm/connections/claude/status")).json() + + assert found["state"] == "not_installed" + + +async def test_a_signed_in_claude_code_is_ready_with_its_account( + client: AsyncClient, claude_on_this_machine: ClaudeOnThisMachine +) -> None: + """Read live each time, so signing in or out in a terminal shows on the next look.""" + claude_on_this_machine() + + found = (await client.get("/llm/connections/claude/status")).json() + + assert found == { + "state": "ready", + "version": "2.1.301", + "email": "ada@example.com", + "plan": "max", + } + + +@pytest.mark.parametrize( + ("stand_in", "state"), + [ + ({"auth": SIGNED_OUT}, "signed_out"), + ({"version": "2.0.12 (Claude Code)"}, "too_old"), + ], +) +async def test_a_claude_code_that_is_not_ready_says_what_to_do( + client: AsyncClient, + claude_on_this_machine: ClaudeOnThisMachine, + stand_in: dict, + state: str, +) -> None: + """Each state is one thing the user does in their terminal.""" + claude_on_this_machine(**stand_in) + + found = await client.get("/llm/connections/claude/status") + assert found.json()["state"] == state + + +async def test_an_allowed_host_is_not_asked_about_again( + client: AsyncClient, claude_on_this_machine: ClaudeOnThisMachine +) -> None: + """Once api.anthropic.com is on, the dialog has nothing left to ask.""" + claude_on_this_machine() + await _allow_anthropic(client) + + assert (await client.get("/llm/connections/claude")).json()["hosts"] == [] + + +async def test_a_ready_claude_code_makes_a_claude_plan_connection( + client: AsyncClient, claude_on_this_machine: ClaudeOnThisMachine +) -> None: + """It holds no credential: Claude Code keeps the sign-in.""" + claude_on_this_machine() + + connection = await _connect(client) + + assert connection["auth_kind"] == "claude_plan" + assert connection["base_url"] == "https://api.anthropic.com" + assert connection["catalog_provider"] == "anthropic" + assert connection["has_api_key"] is False + assert connection["serves"] == ["text_gen"] + + +async def test_connecting_asks_for_both_anthropic_hosts_first( + client: AsyncClient, claude_on_this_machine: ClaudeOnThisMachine +) -> None: + """Model calls and the sign-in's refresh go to two hosts, named in one refusal.""" + claude_on_this_machine() + + reply = await client.post("/llm/connections/claude", json={"label": "Claude"}) + + assert reply.status_code == 403 + detail = reply.json()["detail"] + assert detail["destination"] == ANTHROPIC + assert detail["hosts"] == ["api.anthropic.com", "platform.claude.com"] + assert detail["purpose"] == "claude_plan" + + +async def test_one_host_allowed_still_asks_for_the_other( + client: AsyncClient, claude_on_this_machine: ClaudeOnThisMachine +) -> None: + """Each host is its own consent; only the one still off is asked about.""" + claude_on_this_machine() + await _allow(client, ANTHROPIC) + + option = (await client.get("/llm/connections/claude")).json() + + assert option["hosts"] == [{"destination": SIGN_IN, "host": "platform.claude.com"}] + + +async def test_a_claude_code_that_is_not_ready_cannot_connect( + client: AsyncClient, claude_on_this_machine: ClaudeOnThisMachine +) -> None: + """Refused with the state, so the dialog can show what to do instead.""" + claude_on_this_machine(auth=SIGNED_OUT) + await _allow_anthropic(client) + + reply = await client.post("/llm/connections/claude", json={"label": "Claude"}) + + assert reply.status_code == 422 + assert reply.json()["detail"]["state"] == "signed_out" + + +async def test_there_is_one_claude_plan_per_install( + client: AsyncClient, claude_on_this_machine: ClaudeOnThisMachine +) -> None: + """Claude Code holds one sign-in, so a second connection would be the same plan.""" + claude_on_this_machine() + await _connect(client) + + reply = await client.post("/llm/connections/claude", json={"label": "Another"}) + + assert reply.status_code == 409 + + +async def test_the_connection_lists_claude_codes_models( + client: AsyncClient, + claude_on_this_machine: ClaudeOnThisMachine, + fake_claude: FakeClaude, +) -> None: + """Each model once, by its full id, so the manifest's capabilities apply to it.""" + claude_on_this_machine() + connection = await _connect(client) + + reply = await client.get(f"/llm/connections/{connection['id']}/models") + + assert reply.status_code == 200, reply.text + listed = {m["name"]: m for m in reply.json()} + assert sorted(listed) == [ + "claude-fable-5-1", + "claude-haiku-5-5", + "claude-opus-5-5", + "claude-sonnet-5-5", + ] + assert listed["claude-sonnet-5-5"]["selectable_for"] == ["text_gen"] + + +async def test_a_claude_plan_can_be_renamed_and_nothing_else( + client: AsyncClient, claude_on_this_machine: ClaudeOnThisMachine +) -> None: + """Its address and sign-in are Claude Code's; only the name is the user's.""" + claude_on_this_machine() + connection = await _connect(client) + + reply = await client.put( + f"/llm/connections/{connection['id']}", + json={ + "label": "My Claude", + "provider": "openai_compatible", + "base_url": "https://evil.example/v1", + "api_key": "sk-x", + }, + ) + + assert reply.status_code == 200, reply.text + assert reply.json()["label"] == "My Claude" + assert reply.json()["base_url"] == "https://api.anthropic.com" + assert reply.json()["has_api_key"] is False + + +async def test_a_claude_plan_can_be_removed( + client: AsyncClient, claude_on_this_machine: ClaudeOnThisMachine +) -> None: + """Removing it leaves Claude Code's own sign-in alone.""" + claude_on_this_machine() + connection = await _connect(client) + + reply = await client.delete(f"/llm/connections/{connection['id']}") + + assert reply.status_code == 204 + listed = (await client.get("/llm/connections")).json() + assert all(c["id"] != connection["id"] for c in listed) + + +async def test_a_plan_whose_claude_code_is_gone_asks_to_set_it_up_again( + client: AsyncClient, claude_on_this_machine: ClaudeOnThisMachine, tmp_path +) -> None: + """Uninstalled after connecting: the picker says why, rather than failing.""" + installed = claude_on_this_machine() + connection = await _connect(client) + installed.unlink() + + reply = await client.get(f"/llm/connections/{connection['id']}/models") + + assert reply.status_code == 409 + assert reply.json()["detail"]["code"] == "claude_sign_in_required" + + +async def test_a_plan_whose_claude_code_is_now_too_old_says_so( + client: AsyncClient, + claude_on_this_machine: ClaudeOnThisMachine, + fake_claude: FakeClaude, +) -> None: + """Replaced by an older one after connecting: the composer says to update before anything is sent.""" + claude_on_this_machine() + connection = await _connect(client) + claude_on_this_machine(version="2.1.200 (Claude Code)") + + reply = await client.get(f"/llm/connections/{connection['id']}/models") + + assert reply.status_code == 409 + assert reply.json()["detail"]["code"] == "claude_too_old" + + +async def test_a_claude_code_whose_version_cannot_be_read_still_lists( + client: AsyncClient, + claude_on_this_machine: ClaudeOnThisMachine, + fake_claude: FakeClaude, +) -> None: + """An answer not yet seen on another system never blocks a plan that may work.""" + claude_on_this_machine() + connection = await _connect(client) + claude_on_this_machine(version="Claude Code, nightly") + + reply = await client.get(f"/llm/connections/{connection['id']}/models") + + assert reply.status_code == 200, reply.text + + +async def test_the_model_list_needs_both_hosts( + client: AsyncClient, + claude_on_this_machine: ClaudeOnThisMachine, + fake_claude: FakeClaude, +) -> None: + """With the sign-in host off, the chat's model check asks for it at once, + rather than Claude Code failing hours later when its sign-in expires.""" + claude_on_this_machine() + connection = await _connect(client) + await client.put("/egress", json={"destinations": [SIGN_IN], "enabled": False}) + + reply = await client.get(f"/llm/connections/{connection['id']}/models") + + assert reply.status_code == 403 + assert reply.json()["detail"]["destination"] == SIGN_IN + + +async def test_removing_the_plan_forgets_its_sign_in_host( + client: AsyncClient, claude_on_this_machine: ClaudeOnThisMachine +) -> None: + """No connection names platform.claude.com, so nothing else would clear it.""" + claude_on_this_machine() + connection = await _connect(client) + + await client.delete(f"/llm/connections/{connection['id']}") + + listed = [row["destination"] for row in (await client.get("/egress")).json()] + assert SIGN_IN not in listed diff --git a/surfsense_local/backend/tests/integration/llm/test_egress.py b/surfsense_local/backend/tests/integration/llm/test_egress.py index e819cf5480..96240bbbba 100644 --- a/surfsense_local/backend/tests/integration/llm/test_egress.py +++ b/surfsense_local/backend/tests/integration/llm/test_egress.py @@ -74,8 +74,78 @@ async def test_loopback_connections_are_not_egress( async def test_unknown_destination_is_rejected(client: AsyncClient) -> None: """Only destinations the app contacts can be toggled.""" - reply = await client.put("/egress/keygen", json={"enabled": True}) + reply = await client.put( + "/egress", json={"destinations": ["keygen"], "enabled": True} + ) + assert reply.status_code == 422 + + +async def test_one_answer_allows_every_host_it_names(client: AsyncClient) -> None: + """Allow in one question switches on each host it named, each its own row.""" + reply = await client.put( + "/egress", + json={ + "destinations": ["host:api.anthropic.com", "host:platform.claude.com"], + "enabled": True, + }, + ) + + assert reply.status_code == 200, reply.text + assert {row["destination"]: row["enabled"] for row in reply.json()} == { + "host:api.anthropic.com": True, + "host:platform.claude.com": True, + } + listed = await _destinations(client) + assert listed["host:api.anthropic.com"]["enabled"] is True + assert listed["host:platform.claude.com"]["enabled"] is True + + +async def test_an_answer_naming_one_bad_destination_allows_none( + client: AsyncClient, +) -> None: + """All or nothing: a refused request switches no host, so what the user saw + fail is what is stored.""" + reply = await client.put( + "/egress", + json={"destinations": ["host:api.anthropic.com", "keygen"], "enabled": True}, + ) + assert reply.status_code == 422 + assert "host:api.anthropic.com" not in await _destinations(client) + + +async def test_an_empty_answer_is_refused(client: AsyncClient) -> None: + """There is nothing to allow, so it can only be a caller's mistake.""" + reply = await client.put("/egress", json={"destinations": [], "enabled": True}) + + assert reply.status_code == 422 + + +async def test_one_host_is_switched_off_alone(client: AsyncClient) -> None: + """Settings > Network turns one row off; the others stay as they were.""" + await client.put( + "/egress", + json={ + "destinations": ["host:api.anthropic.com", "host:platform.claude.com"], + "enabled": True, + }, + ) + + await client.put( + "/egress", + json={"destinations": ["host:platform.claude.com"], "enabled": False}, + ) + + listed = await _destinations(client) + assert listed["host:api.anthropic.com"]["enabled"] is True + assert listed["host:platform.claude.com"]["enabled"] is False + + +async def test_the_one_host_route_is_gone(client: AsyncClient) -> None: + """One way to record consent, so the two can never drift apart.""" + reply = await client.put("/egress/host:api.anthropic.com", json={"enabled": True}) + + assert reply.status_code in (404, 405) async def test_one_grant_covers_everything_sent_to_huggingface( @@ -112,7 +182,9 @@ def _store(engine: Engine, *connections: dict) -> None: async def _allow(client: AsyncClient, destination: str) -> None: - reply = await client.put(f"/egress/{destination}", json={"enabled": True}) + reply = await client.put( + "/egress", json={"destinations": [destination], "enabled": True} + ) assert reply.status_code == 200, reply.text diff --git a/surfsense_local/backend/tests/integration/runtime_packs/test_office_routes.py b/surfsense_local/backend/tests/integration/runtime_packs/test_office_routes.py index 1b37072d81..22d17aa5a9 100644 --- a/surfsense_local/backend/tests/integration/runtime_packs/test_office_routes.py +++ b/surfsense_local/backend/tests/integration/runtime_packs/test_office_routes.py @@ -149,7 +149,7 @@ async def test_once_allowed_it_downloads_unpacks_checks_and_is_installed( client: AsyncClient, office: Office ) -> None: """The pinned host first, then its mirror; the smoke runs before it is in place.""" - await client.put(f"/egress/{DESTINATION}", json={"enabled": True}) + await client.put("/egress", json={"destinations": [DESTINATION], "enabled": True}) started = await client.post("/runtime-packs/office/install") assert started.status_code == 202 @@ -176,7 +176,7 @@ async def test_a_failed_check_leaves_nothing_installed_and_says_why( from modules.runtime_packs.office.soffice.errors import OfficeFailed office.smoke_error = OfficeFailed("LibreOffice wrote no pdf") - await client.put(f"/egress/{DESTINATION}", json={"enabled": True}) + await client.put("/egress", json={"destinations": [DESTINATION], "enabled": True}) await client.post("/runtime-packs/office/install") status = await _settled(client) @@ -189,7 +189,7 @@ async def test_removing_it_deletes_the_pack( client: AsyncClient, office: Office ) -> None: """Back to not installed, with the version folder gone.""" - await client.put(f"/egress/{DESTINATION}", json={"enabled": True}) + await client.put("/egress", json={"destinations": [DESTINATION], "enabled": True}) await client.post("/runtime-packs/office/install") await _settled(client) @@ -203,7 +203,7 @@ async def test_installing_again_while_the_pack_is_installed_is_refused( client: AsyncClient, office: Office ) -> None: """A reinstall deleted the version folder in place, under a LibreOffice run using it.""" - await client.put(f"/egress/{DESTINATION}", json={"enabled": True}) + await client.put("/egress", json={"destinations": [DESTINATION], "enabled": True}) await client.post("/runtime-packs/office/install") await _settled(client) @@ -224,7 +224,7 @@ async def test_removing_a_pack_in_use_forgets_nothing( monkeypatch: pytest.MonkeyPatch, ) -> None: """Windows refuses the rename while LibreOffice runs: the confirmed install stays chosen too.""" - await client.put(f"/egress/{DESTINATION}", json={"enabled": True}) + await client.put("/egress", json={"destinations": [DESTINATION], "enabled": True}) await client.post("/runtime-packs/office/install") await _settled(client) _found_install(tmp_path / "found", "26.8") @@ -251,7 +251,7 @@ async def test_a_cancel_while_unpacking_waits_for_the_unpack_to_stop( ) -> None: """msiexec and tar run on after a cancel; a new install must not unpack beside them.""" office.unpack_gate = threading.Event() - await client.put(f"/egress/{DESTINATION}", json={"enabled": True}) + await client.put("/egress", json={"destinations": [DESTINATION], "enabled": True}) await client.post("/runtime-packs/office/install") await _reached(client, "unpacking") try: @@ -276,7 +276,7 @@ async def test_a_second_cancel_still_waits_for_the_unpack_to_stop( ) -> None: """Cancel clicked twice: the second deleted the staging folder under tar, which wrote it again.""" office.unpack_gate = threading.Event() - await client.put(f"/egress/{DESTINATION}", json={"enabled": True}) + await client.put("/egress", json={"destinations": [DESTINATION], "enabled": True}) await client.post("/runtime-packs/office/install") await _reached(client, "unpacking") try: @@ -309,7 +309,7 @@ def held(self: Path, target: Path) -> Path: return renamed(self, target) monkeypatch.setattr(Path, "rename", held) - await client.put(f"/egress/{DESTINATION}", json={"enabled": True}) + await client.put("/egress", json={"destinations": [DESTINATION], "enabled": True}) await client.post("/runtime-packs/office/install") status = await _settled(client) @@ -398,7 +398,7 @@ async def test_once_office_support_was_on_the_offer_never_shows_again( client: AsyncClient, office: Office ) -> None: """Removing it later is the user's choice, not a reason to offer it again.""" - await client.put(f"/egress/{DESTINATION}", json={"enabled": True}) + await client.put("/egress", json={"destinations": [DESTINATION], "enabled": True}) await client.post("/runtime-packs/office/install") await _settled(client) diff --git a/surfsense_local/backend/tests/integration/test_migration_0031.py b/surfsense_local/backend/tests/integration/test_migration_0031.py new file mode 100644 index 0000000000..fd2cdbce24 --- /dev/null +++ b/surfsense_local/backend/tests/integration/test_migration_0031.py @@ -0,0 +1,156 @@ +"""Revision 0031: a connection can be a Claude plan, run through the user's own Claude Code. + +The only schema change is the auth_kind check; a Claude plan holds no credential +and nothing Claude Code reports, which is read live. +""" + +from pathlib import Path + +import pytest +from alembic.config import Config +from sqlalchemy import Engine, text +from sqlalchemy.exc import IntegrityError + +from alembic import command +from shared.db import create_db_engine + +pytestmark = pytest.mark.integration + + +def _config(engine: Engine) -> Config: + config = Config() + config.set_main_option( + "script_location", str(Path(__file__).parents[2] / "alembic") + ) + config.attributes["engine"] = engine + return config + + +def _at(tmp_path: Path, revision: str) -> Engine: + engine = create_db_engine(tmp_path / "surfsense.db") + command.upgrade(_config(engine), revision) + return engine + + +def _signed_in_chatgpt_with_a_selection(engine: Engine) -> None: + with engine.begin() as connection: + connection.execute( + text( + "INSERT INTO provider_connections(id, label, provider, base_url, " + "catalog_provider, auth_kind, oauth_ciphertext, token_version) " + "VALUES (1, 'ChatGPT', 'openai_compatible', " + "'https://api.openai.com/v1', 'openai', 'chatgpt', x'00ff', 3)" + ) + ) + connection.execute( + text( + "INSERT INTO selected_models(model_type, provider, connection_id, " + "name, settings, updated_at) VALUES ('text_gen', " + "'openai_compatible', 1, 'gpt-5', '{\"chat_modes\": {}}', " + "'2026-10-01 09:00:00')" + ) + ) + + +def _connections(engine: Engine) -> list[tuple]: + with engine.connect() as connection: + return [ + tuple(row) + for row in connection.execute( + text( + "SELECT id, label, provider, base_url, catalog_provider, " + "api_key_ciphertext, auth_kind, oauth_ciphertext, " + "token_version, created_at, updated_at " + "FROM provider_connections ORDER BY id" + ) + ) + ] + + +def _selections(engine: Engine) -> list[tuple]: + with engine.connect() as connection: + return [ + tuple(row) + for row in connection.execute( + text("SELECT * FROM selected_models ORDER BY model_type") + ) + ] + + +def _add_claude_plan(engine: Engine, connection_id: int = 2) -> None: + with engine.begin() as connection: + connection.execute( + text( + "INSERT INTO provider_connections(id, label, provider, base_url, " + "catalog_provider, auth_kind) VALUES (:id, 'Claude', " + "'openai_compatible', 'https://api.anthropic.com', 'anthropic', " + "'claude_plan')" + ), + {"id": connection_id}, + ) + + +def test_a_connection_can_be_a_claude_plan(tmp_path: Path) -> None: + """It holds no credential: Claude Code keeps the sign-in.""" + engine = _at(tmp_path, "head") + + _add_claude_plan(engine) + + assert _connections(engine)[0][6] == "claude_plan" + + +def test_no_other_kind_is_let_in(tmp_path: Path) -> None: + """Widened by one kind, not opened.""" + engine = _at(tmp_path, "head") + + with ( + pytest.raises(IntegrityError), + engine.begin() as connection, + ): + connection.execute( + text( + "INSERT INTO provider_connections(label, provider, base_url, " + "auth_kind) VALUES ('x', 'openai_compatible', 'https://x', 'bogus')" + ) + ) + + +def test_the_rebuild_keeps_every_connection_and_its_selections(tmp_path: Path) -> None: + """Dropping the old table cascades to selected_models, so the rebuild must + put back every remote selection it took away.""" + engine = _at(tmp_path, "0030") + _signed_in_chatgpt_with_a_selection(engine) + connections, selections = _connections(engine), _selections(engine) + + command.upgrade(_config(engine), "head") + + assert _connections(engine) == connections + assert _selections(engine) == selections + assert len(selections) == 1 + + +def test_deleting_a_connection_still_clears_its_selections(tmp_path: Path) -> None: + """The rebuilt table keeps the cascade.""" + engine = _at(tmp_path, "0030") + _signed_in_chatgpt_with_a_selection(engine) + command.upgrade(_config(engine), "head") + + with engine.begin() as connection: + connection.execute(text("DELETE FROM provider_connections WHERE id = 1")) + + assert _selections(engine) == [] + + +def test_downgrading_drops_only_the_claude_plan(tmp_path: Path) -> None: + """The old check cannot hold it; every other connection and selection stays.""" + engine = _at(tmp_path, "0030") + _signed_in_chatgpt_with_a_selection(engine) + command.upgrade(_config(engine), "head") + _add_claude_plan(engine) + keep_connections = _connections(engine)[:1] + keep_selections = _selections(engine) + + command.downgrade(_config(engine), "0030") + + assert _connections(engine) == keep_connections + assert _selections(engine) == keep_selections diff --git a/surfsense_local/backend/tests/integration/test_migration_0032.py b/surfsense_local/backend/tests/integration/test_migration_0032.py new file mode 100644 index 0000000000..a19f92d96b --- /dev/null +++ b/surfsense_local/backend/tests/integration/test_migration_0032.py @@ -0,0 +1,120 @@ +"""Revision 0032: each agent thread records the engine that runs it.""" + +from pathlib import Path + +import pytest +from alembic.config import Config +from sqlalchemy import Engine, text +from sqlalchemy.exc import IntegrityError + +from alembic import command +from shared.db import create_db_engine + +pytestmark = pytest.mark.integration + + +def _config(engine: Engine) -> Config: + config = Config() + config.set_main_option( + "script_location", str(Path(__file__).parents[2] / "alembic") + ) + config.attributes["engine"] = engine + return config + + +def _upgraded_with_two_threads(tmp_path: Path) -> Engine: + """An agent thread and a chat thread, made before the revision.""" + engine = create_db_engine(tmp_path / "surfsense.db") + config = _config(engine) + command.upgrade(config, "0031") + with engine.begin() as connection: + connection.execute(text("INSERT INTO workspaces(id, name) VALUES (1, 'W')")) + connection.execute( + text( + "INSERT INTO chat_threads(id, workspace_id, title, opencode_session_id) " + "VALUES (1, 1, 'Agent', 'ses_1'), (2, 1, 'Chat', NULL)" + ) + ) + connection.execute( + text( + "INSERT INTO chat_messages(id, chat_thread_id, role, content) " + """VALUES (1, 2, 'user', '{"text": "kept"}')""" + ) + ) + command.upgrade(config, "head") + return engine + + +def test_agent_threads_are_opencodes_and_chat_threads_have_no_engine( + tmp_path: Path, +) -> None: + """Every agent thread so far ran on opencode; a chat thread keeps its messages.""" + engine = _upgraded_with_two_threads(tmp_path) + + with engine.connect() as connection: + engines = dict( + connection.execute(text("SELECT id, agent_engine FROM chat_threads")).all() + ) + messages = connection.execute( + text("SELECT count(*) FROM chat_messages WHERE chat_thread_id = 2") + ).scalar_one() + assert engines == {1: "opencode", 2: None} + assert messages == 1 + + +def test_an_engine_outside_the_two_is_refused(tmp_path: Path) -> None: + """A typo in code would otherwise route a thread to no engine at all.""" + engine = _upgraded_with_two_threads(tmp_path) + + with pytest.raises(IntegrityError), engine.begin() as connection: + connection.execute( + text("UPDATE chat_threads SET agent_engine = 'codex' WHERE id = 1") + ) + + +def test_a_threads_claude_transcript_and_endings_go_with_it(tmp_path: Path) -> None: + """Deleting the thread deletes SurfSense's copy of its Claude Code session.""" + engine = _upgraded_with_two_threads(tmp_path) + with engine.begin() as connection: + connection.execute( + text( + "UPDATE chat_threads SET agent_engine = 'claude_code', " + "claude_session_id = 's-1' WHERE id = 1" + ) + ) + connection.execute( + text( + "INSERT INTO claude_session_entries(thread_id, session_id, uuid, entry) " + """VALUES (1, 's-1', 'e-1', '{"type": "user"}')""" + ) + ) + connection.execute( + text( + "INSERT INTO claude_reply_endings(thread_id, reply_id, ending) " + """VALUES (1, 'e-1:reply', '{"type": "stopped"}')""" + ) + ) + + with engine.begin() as connection: + connection.execute(text("DELETE FROM chat_threads WHERE id = 1")) + + with engine.connect() as connection: + left = [ + connection.execute(text(f"SELECT count(*) FROM {table}")).scalar_one() + for table in ("claude_session_entries", "claude_reply_endings") + ] + assert left == [0, 0] + + +def test_an_entry_is_kept_once_per_session(tmp_path: Path) -> None: + """The SDK retries a batch that may have landed; its uuid makes the retry a no-op.""" + engine = _upgraded_with_two_threads(tmp_path) + insert = text( + "INSERT INTO claude_session_entries(thread_id, session_id, uuid, entry) " + """VALUES (1, 's-1', 'e-1', '{"type": "user"}')""" + ) + with engine.begin() as connection: + connection.execute(insert) + + with pytest.raises(IntegrityError), engine.begin() as connection: + connection.execute(insert) diff --git a/surfsense_local/backend/tests/packaging/test_real_binaries.py b/surfsense_local/backend/tests/packaging/test_real_binaries.py index 66c3afec14..af6c6009de 100644 --- a/surfsense_local/backend/tests/packaging/test_real_binaries.py +++ b/surfsense_local/backend/tests/packaging/test_real_binaries.py @@ -68,6 +68,16 @@ def _assert_capability_catalogue_shipped(binary: Path) -> None: assert json.loads(manifest.read_text())["providers"] +def _assert_no_claude_code_shipped(binary: Path) -> None: + """The SDK's own Claude Code stays out: every run uses the user's (ADR 0057).""" + shipped = [ + path + for path in binary.parent.rglob("*") + if path.name in ("claude", "claude.exe") or "_bundled" in path.parts + ] + assert shipped == [], f"Claude Code is in the bundle: {shipped}" + + def _assert_curated_manifest_shipped(binary: Path) -> None: """Studio finds its chosen image and audio models through the curated list; without it the frozen worker calls an installed model "not installed".""" @@ -99,6 +109,7 @@ def test_api_binary_answers_health(tmp_path: Path) -> None: """Freeze the API and assert the running binary serves /health.""" binary = _freeze("api.spec", tmp_path) _assert_capability_catalogue_shipped(binary) + _assert_no_claude_code_shipped(binary) # /health and /llm/catalog/local never touch retrieval, so an over-broad exclude in # api.spec would pass the checks below and only break on a user's first chat. diff --git a/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_agent_options.py b/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_agent_options.py new file mode 100644 index 0000000000..b0f65cc4cb --- /dev/null +++ b/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_agent_options.py @@ -0,0 +1,93 @@ +"""What an agent run's Claude Code starts with: the thread's folder, SurfSense's tools and rules.""" + +from pathlib import Path + +import pytest + +from modules.agent.engines.claude_code.agent_options import AgentLaunch, agent_options +from modules.agent.engines.claude_code.approvals import Approvals +from modules.agent.engines.claude_code.skills_plugin import SkillsPlugin +from modules.agent.engines.claude_code.turn_link import TurnLink +from modules.agent.plugin_tools.connect_limit import CONNECT_SECONDS + +pytestmark = pytest.mark.unit +BINARY = Path("/home/ada/.local/bin/claude") + + +def _launch(tmp_path: Path, *, resumed: bool = False) -> AgentLaunch: + return AgentLaunch( + binary=BINARY, + proxy_url="http://127.0.0.1:5000", + folder=tmp_path / "threads" / "7", + model="claude-sonnet-5-5", + session_id="s-1", + resumed=resumed, + mcp_file=tmp_path / "mcp-1.json", + disallowed=["mcp__notion__delete_page"], + skills=SkillsPlugin( + tmp_path / "plugin", + tmp_path / "plugin" / "skills", + frozenset({"surfsense:surfsense-documents"}), + ), + link=TurnLink(7, Approvals()), + store=None, + thinking=False, + ) + + +def test_claude_code_works_in_the_threads_folder_with_surfsenses_tools( + tmp_path: Path, +) -> None: + """No Bash, web or subagents: the tools opencode is allowed, and SurfSense's skills.""" + options = agent_options(_launch(tmp_path)) + + assert options.cwd == str(tmp_path / "threads" / "7") + assert options.model == "claude-sonnet-5-5" + assert options.tools == ["Read", "Glob", "Grep", "Write", "Edit", "Skill"] + assert options.mcp_servers == str(tmp_path / "mcp-1.json") + assert options.disallowed_tools == ["mcp__notion__delete_page"] + assert options.plugins == [{"type": "local", "path": str(tmp_path / "plugin")}] + assert options.skills == ["surfsense:surfsense-documents"] + + +def test_every_call_meets_surfsenses_gate_and_the_users_setup_never_applies( + tmp_path: Path, +) -> None: + """As every Claude Code run: no ~/.claude settings, and `default` permission mode.""" + options = agent_options(_launch(tmp_path)) + + assert options.setting_sources == [] + assert options.permission_mode == "default" + assert options.can_use_tool is not None + (matcher,) = options.hooks["PreToolUse"] + assert matcher.hooks + + +def test_the_first_turn_names_the_session_and_later_ones_resume_it( + tmp_path: Path, +) -> None: + """SurfSense chooses the id, so the thread knows its session before Claude Code runs.""" + first = agent_options(_launch(tmp_path)) + later = agent_options(_launch(tmp_path, resumed=True)) + + assert (first.session_id, first.resume) == ("s-1", None) + assert (later.session_id, later.resume) == (None, "s-1") + + +def test_traffic_goes_through_the_agent_proxy_and_results_reach_the_model( + tmp_path: Path, +) -> None: + """A long result is never written into ~/.claude; a plugin's long call is not cut short.""" + env = agent_options(_launch(tmp_path)).env + + assert env["HTTPS_PROXY"] == "http://127.0.0.1:5000" + assert env["ANTHROPIC_API_KEY"] == "" + assert int(env["MAX_MCP_OUTPUT_TOKENS"]) > 25_000 + assert int(env["MCP_TOOL_TIMEOUT"]) >= 300_000 + + +def test_claude_code_gives_up_on_a_plugin_when_opencode_does(tmp_path: Path) -> None: + """One limit, so a slow plugin is reported alike whichever engine runs the thread.""" + env = agent_options(_launch(tmp_path)).env + + assert int(env["MCP_TIMEOUT"]) == CONNECT_SECONDS * 1000 diff --git a/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_agent_runs.py b/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_agent_runs.py new file mode 100644 index 0000000000..0e8320d397 --- /dev/null +++ b/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_agent_runs.py @@ -0,0 +1,109 @@ +"""One running Claude Code per agent thread, kept while its turns are set up alike.""" + +from pathlib import Path + +import pytest + +from modules.agent.engines.claude_code.agent_runs import AgentRuns +from modules.agent.engines.claude_code.turn_link import TurnLink +from tests.claude_run_stand_in import ScriptedRun + +pytestmark = pytest.mark.unit + + +class _Starts: + """Starts a scripted run, each with a config file of its own.""" + + def __init__(self, folder: Path) -> None: + self.folder = folder + self.started: list[ScriptedRun] = [] + + async def __call__(self, link: TurnLink) -> tuple[ScriptedRun, Path]: + run = ScriptedRun() + file = self.folder / f"mcp-{len(self.started)}.json" + file.write_text("{}") + self.started.append(run) + return run, file + + +async def test_the_next_turn_set_up_alike_reuses_the_running_claude_code( + tmp_path: Path, +) -> None: + """The start-up is skipped: 0.9 s to a first token warm against 2.3 s cold.""" + starts = _Starts(tmp_path) + runs = AgentRuns() + + first = await runs.acquire(7, "digest-a", starts) + again = await runs.acquire(7, "digest-a", starts) + + assert again is first + assert len(starts.started) == 1 + + +async def test_a_turn_set_up_otherwise_starts_a_new_one_and_ends_the_old( + tmp_path: Path, +) -> None: + """Its MCP servers and hidden tools are fixed when it starts; its file goes with it.""" + starts = _Starts(tmp_path) + runs = AgentRuns() + first = await runs.acquire(7, "digest-a", starts) + + second = await runs.acquire(7, "digest-b", starts) + + assert second is not first + assert starts.started[0].closed + assert not (tmp_path / "mcp-0.json").exists() + + +class _WillNotReconnect(ScriptedRun): + async def reconnect(self, server: str) -> None: + raise RuntimeError(f"{server} did not answer") + + +async def test_a_server_that_will_not_reconnect_starts_the_claude_code_over( + tmp_path: Path, +) -> None: + """A run left with a server's old tools would offer the model what the server no longer has.""" + starts = _Starts(tmp_path) + runs = AgentRuns() + held = await runs.acquire(7, "digest-a", starts, {"notes": "tools-a"}) + held.run = _WillNotReconnect() + + again = await runs.acquire(7, "digest-a", starts, {"notes": "tools-b"}) + + assert again is not held + assert len(starts.started) == 2 + assert again.listings == {"notes": "tools-b"} + + +async def test_an_idle_claude_code_is_ended(tmp_path: Path) -> None: + """Each holds memory; five minutes covers a reply read and answered.""" + now = [0.0] + starts = _Starts(tmp_path) + runs = AgentRuns(idle_seconds=300, clock=lambda: now[0]) + await runs.acquire(7, "digest-a", starts) + runs.release(7) + + now[0] = 301 + await runs.acquire(8, "digest-a", starts) + + assert starts.started[0].closed + assert not (tmp_path / "mcp-0.json").exists() + + +async def test_forgetting_a_thread_and_quitting_end_its_claude_code( + tmp_path: Path, +) -> None: + """A deleted thread's Claude Code goes at once; quitting ends every one left.""" + starts = _Starts(tmp_path) + runs = AgentRuns() + await runs.acquire(7, "digest-a", starts) + await runs.acquire(8, "digest-a", starts) + + await runs.forget(7) + assert starts.started[0].closed + assert not starts.started[1].closed + + await runs.close_all() + assert starts.started[1].closed + assert list(tmp_path.iterdir()) == [] diff --git a/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_gate.py b/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_gate.py new file mode 100644 index 0000000000..49934d6963 --- /dev/null +++ b/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_gate.py @@ -0,0 +1,189 @@ +"""The rules Claude Code's tool calls meet: opencode's, for the same thread.""" + +from pathlib import Path + +import pytest + +from modules.agent.engines.claude_code.gate import Gate + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def gate(tmp_path: Path) -> Gate: + """Thread 7's rules, with one plugin's tools given: one allowed, one to ask.""" + folder = tmp_path / "threads" / "7" + (folder / "sources").mkdir(parents=True) + (folder / "outputs").mkdir() + skills = tmp_path / "skills" + (skills / "surfsense-documents").mkdir(parents=True) + return Gate( + folder=folder, + skills=skills, + skill_names=frozenset({"surfsense:surfsense-documents"}), + plugin_rules={ + "mcp__notion__search": "allow", + "mcp__notion__create_page": "ask", + }, + ) + + +def test_reads_inside_the_thread_folder_are_allowed(gate: Gate) -> None: + """Relative paths are the thread folder's, as Claude Code's working folder.""" + assert gate.decide("Read", {"file_path": "sources/plan.txt"}).action == "allow" + absolute = str(gate.folder / "outputs" / "q3.md") + assert gate.decide("Read", {"file_path": absolute}).action == "allow" + assert gate.decide("Glob", {"pattern": "sources/**/*.md"}).action == "allow" + assert gate.decide("Grep", {"pattern": "revenue", "path": "sources"}).action == ( + "allow" + ) + # With no path, Glob and Grep search the working folder, the thread's. + assert gate.decide("Grep", {"pattern": "revenue"}).action == "allow" + + +def test_a_skill_may_point_into_the_skills_folder(gate: Gate) -> None: + """As opencode's external_directory rule allows the skills folder alone.""" + guide = str(gate.skills / "surfsense-documents" / "SKILL.md") + + assert gate.decide("Read", {"file_path": guide}).action == "allow" + + +def test_reads_outside_the_thread_are_refused_with_a_reason(gate: Gate) -> None: + """Another thread's folder included; the reason reaches the model.""" + for tool, tool_input in [ + ("Read", {"file_path": "/etc/hosts"}), + ("Read", {"file_path": "../8/sources/secret.txt"}), + ("Read", {"file_path": str(gate.skills / ".." / "elsewhere.txt")}), + ("Glob", {"pattern": "../**/*"}), + ("Glob", {"pattern": "*.md", "path": "/Users"}), + ("Grep", {"pattern": "x", "path": ".."}), + ]: + verdict = gate.decide(tool, tool_input) + assert verdict.action == "deny", (tool, tool_input) + assert verdict.reason + + +def test_a_link_out_of_the_folder_is_followed_before_it_is_judged( + gate: Gate, tmp_path: Path +) -> None: + """A path is compared once symlinks are resolved, so a link cannot smuggle a read.""" + (tmp_path / "private.txt").write_text("no") + (gate.folder / "outputs" / "link.txt").symlink_to(tmp_path / "private.txt") + + assert gate.decide("Read", {"file_path": "outputs/link.txt"}).action == "deny" + + +def test_writes_go_only_under_outputs(gate: Gate) -> None: + """Sources, the folder's root, other threads and the skills stay untouched.""" + assert gate.decide("Write", {"file_path": "outputs/q3.md"}).action == "allow" + nested = str(gate.folder / "outputs" / "drafts" / "q3.md") + assert gate.decide("Edit", {"file_path": nested}).action == "allow" + for path in [ + "sources/plan.txt", + "notes.md", + "../8/outputs/q3.md", + "/tmp/escaped.txt", + str(gate.skills / "surfsense-documents" / "SKILL.md"), + ]: + verdict = gate.decide("Write", {"file_path": path}) + assert verdict.action == "deny", path + assert verdict.reason + + +def test_files_an_agent_reads_as_instructions_are_never_written(gate: Gate) -> None: + """opencode refuses these in any case on macOS too, where names ignore case.""" + for name in ["AGENTS.md", "claude.md", "Context.md", ".claude/settings.json"]: + verdict = gate.decide("Write", {"file_path": f"outputs/{name}"}) + assert verdict.action == "deny", name + + +def test_a_link_from_outputs_into_sources_cannot_be_written_through( + gate: Gate, +) -> None: + """A source edited through a link would change what every thread reads.""" + (gate.folder / "sources" / "plan.txt").write_text("source") + (gate.folder / "outputs" / "plan.txt").symlink_to( + gate.folder / "sources" / "plan.txt" + ) + + assert gate.decide("Edit", {"file_path": "outputs/plan.txt"}).action == "deny" + + +def test_only_surfsenses_own_skills_load(gate: Gate) -> None: + """Claude Code's built-in skills and the user's own are refused.""" + assert gate.decide("Skill", {"skill": "surfsense:surfsense-documents"}).action == ( + "allow" + ) + for name in ["deep-research", "code-review", "surfsense:other"]: + assert gate.decide("Skill", {"skill": name}).action == "deny", name + + +def test_surfsenses_tools_are_allowed(gate: Gate) -> None: + """Each refuses on its own what a turn may not do, as it does for opencode.""" + assert gate.decide("mcp__surfsense__search_sources", {"query": "q3"}).action == ( + "allow" + ) + + +def test_a_plugin_tool_follows_its_rule_and_any_other_is_refused(gate: Gate) -> None: + """The rules are the gateway's, as opencode's session rules are.""" + assert gate.decide("mcp__notion__search", {}).action == "allow" + assert gate.decide("mcp__notion__create_page", {}).action == "ask" + # Switched off, unreviewed, or on a server not given this turn. + assert gate.decide("mcp__notion__delete_page", {}).action == "deny" + assert gate.decide("mcp__github__create_issue", {}).action == "deny" + + +def test_tools_surfsense_never_gives_are_refused(gate: Gate) -> None: + """Even if a later Claude Code offered them unasked.""" + for tool in ["Bash", "WebFetch", "WebSearch", "Task", "NotebookEdit", "Monitor"]: + verdict = gate.decide(tool, {}) + assert verdict.action == "deny", tool + assert verdict.reason + + +def test_a_long_result_claude_code_saved_for_this_session_can_be_read( + tmp_path: Path, +) -> None: + """Claude Code saves a large tool result and tells the model to read it there, + as opencode does with its tool-output folder.""" + home = tmp_path / "claude-home" + saved = home / "projects" / "-threads-7" / "session-7" / "tool-results" + saved.mkdir(parents=True) + (saved / "toolu_1.txt").write_text("{}") + gate = Gate( + folder=tmp_path / "threads" / "7", + skills=tmp_path / "skills", + claude_home=home, + session_id="session-7", + ) + + assert gate.decide("Read", {"file_path": str(saved / "toolu_1.txt")}).action == ( + "allow" + ) + assert gate.decide("Grep", {"pattern": "title", "path": str(saved)}).action == ( + "allow" + ) + + +def test_only_this_sessions_saved_results_and_never_to_write(tmp_path: Path) -> None: + """Not another session's results, nor Claude Code's own settings and sign-in.""" + home = tmp_path / "claude-home" + gate = Gate( + folder=tmp_path / "threads" / "7", + skills=tmp_path / "skills", + claude_home=home, + session_id="session-7", + ) + projects = home / "projects" / "-threads-7" + + for path in [ + projects / "session-8" / "tool-results" / "toolu_1.txt", + projects / "session-7.jsonl", + projects / "session-7" / "subagents" / "x.txt", + home / "settings.json", + home / ".credentials.json", + ]: + assert gate.decide("Read", {"file_path": str(path)}).action == "deny", path + saved = projects / "session-7" / "tool-results" / "toolu_1.txt" + assert gate.decide("Write", {"file_path": str(saved)}).action == "deny" diff --git a/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_mcp_config.py b/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_mcp_config.py new file mode 100644 index 0000000000..df76791614 --- /dev/null +++ b/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_mcp_config.py @@ -0,0 +1,45 @@ +"""The MCP servers a turn gives Claude Code, in a file only the user can read.""" + +import json +import os +import sys +from pathlib import Path + +import pytest + +from modules.agent.engines.claude_code.mcp_config import write_mcp_config + +pytestmark = pytest.mark.unit +SERVERS = { + "notion": { + "type": "http", + "url": "https://mcp.notion.example/mcp", + "headers": {"Authorization": "Bearer token"}, + } +} + + +def test_the_servers_and_their_headers_are_written_for_claude_code( + tmp_path: Path, +) -> None: + """As Claude Code's --mcp-config reads them; the SDK would put a dict on its command line.""" + path = write_mcp_config(tmp_path / "claude", SERVERS) + + assert json.loads(path.read_text()) == {"mcpServers": SERVERS} + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX modes") +def test_no_other_user_can_read_the_file(tmp_path: Path) -> None: + """It holds sign-in headers.""" + path = write_mcp_config(tmp_path / "claude", SERVERS) + + assert os.stat(path).st_mode & 0o077 == 0 + assert os.stat(path.parent).st_mode & 0o077 == 0 + + +def test_two_turns_never_share_a_file(tmp_path: Path) -> None: + """Each client removes its own when it closes.""" + first = write_mcp_config(tmp_path, SERVERS) + second = write_mcp_config(tmp_path, SERVERS) + + assert first != second diff --git a/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_opencode_shape.py b/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_opencode_shape.py new file mode 100644 index 0000000000..537890bcbe --- /dev/null +++ b/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_opencode_shape.py @@ -0,0 +1,208 @@ +"""A Claude Code turn reaches the thread as the frames an opencode turn sends.""" + +from typing import Any + +import pytest +from claude_agent_sdk import ( + AssistantMessage, + ResultMessage, + StreamEvent, + TextBlock, + ThinkingBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) + +from modules.agent.agent_threads.replies import turn_reply +from modules.agent.agent_threads.turn_frames import TurnFrames +from modules.agent.engines.claude_code.opencode_shape import OpencodeShape + +pytestmark = pytest.mark.unit +SESSION = "claude-session" + + +def _stream(event: dict[str, Any]) -> StreamEvent: + return StreamEvent(uuid="u", session_id=SESSION, event=event) + + +def _start(message_id: str) -> StreamEvent: + return _stream({"type": "message_start", "message": {"id": message_id}}) + + +def _block(index: int, block: dict[str, Any]) -> StreamEvent: + return _stream( + {"type": "content_block_start", "index": index, "content_block": block} + ) + + +def _delta(index: int, delta: dict[str, Any]) -> StreamEvent: + return _stream({"type": "content_block_delta", "index": index, "delta": delta}) + + +def _stop(index: int) -> StreamEvent: + return _stream({"type": "content_block_stop", "index": index}) + + +def _assistant(message_id: str, *blocks: Any) -> AssistantMessage: + return AssistantMessage(content=list(blocks), model="sonnet", message_id=message_id) + + +def _done() -> ResultMessage: + return ResultMessage( + subtype="success", + duration_ms=1, + duration_api_ms=1, + is_error=False, + num_turns=1, + session_id=SESSION, + ) + + +def _frames(*messages: Any) -> tuple[list[dict[str, Any]], TurnFrames]: + shape = OpencodeShape(SESSION) + turn = TurnFrames(SESSION) + frames = [] + for event in shape.user("turn-1", created_ms=1_000): + frames += turn.frames(event) + for message in messages: + for event in shape.read(message): + frames += turn.frames(event) + return frames, turn + + +def test_streamed_text_is_the_reply_and_the_turn_ends() -> None: + """The reply streams as it is written, and the result ends the turn.""" + frames, turn = _frames( + _start("msg_1"), + _block(0, {"type": "text", "text": ""}), + _delta(0, {"type": "text_delta", "text": "Revenue "}), + _delta(0, {"type": "text_delta", "text": "grew 12%."}), + _stop(0), + _assistant("msg_1", TextBlock("Revenue grew 12%.")), + _done(), + ) + + assert frames[0]["type"] == "accepted" + assert frames[0]["user_message_id"] == "turn-1" + assert [f["text"] for f in frames if f["type"] == "delta"] == [ + "Revenue ", + "grew 12%.", + ] + assert turn.finished + + +def test_text_that_never_streamed_still_reaches_the_reply() -> None: + """Claude Code may send a block whole; nothing of the reply is lost.""" + frames, _ = _frames( + _assistant("msg_1", TextBlock("Done.")), + _done(), + ) + + assert [f["text"] for f in frames if f["type"] == "delta"] == ["Done."] + + +def test_thinking_is_reasoning_and_says_when_it_ended() -> None: + """The thread shows how long the model thought, once.""" + frames, _ = _frames( + _start("msg_1"), + _block(0, {"type": "thinking", "thinking": ""}), + _delta(0, {"type": "thinking_delta", "thinking": "Check Q3 first."}), + _stop(0), + _assistant("msg_1", ThinkingBlock("Check Q3 first.", "sig")), + _done(), + ) + + assert [f["text"] for f in frames if f["type"] == "reasoning"] == [ + "Check Q3 first." + ] + assert [f["type"] for f in frames].count("reasoning-end") == 1 + + +def test_a_tool_call_is_a_step_named_and_shown_as_opencodes() -> None: + """Running with its input, then completed with what it returned.""" + frames, _ = _frames( + _start("msg_1"), + _block(0, {"type": "tool_use", "id": "toolu_1", "name": "Read", "input": {}}), + _stop(0), + _assistant( + "msg_1", + ToolUseBlock("toolu_1", "Read", {"file_path": "sources/q3.txt"}), + ), + UserMessage( + content=[ToolResultBlock("toolu_1", "Q3 revenue: 12%", is_error=False)] + ), + _done(), + ) + + steps = [f for f in frames if f["type"] == "agent-step"] + assert [s["status"] for s in steps] == ["pending", "running", "completed"] + assert steps[-1]["tool"] == "read" + assert steps[-1]["call_id"] == "toolu_1" + assert steps[-1]["input"] == {"filePath": "sources/q3.txt"} + assert steps[-1]["output"] == "Q3 revenue: 12%" + + +def test_a_surfsense_tool_returns_its_text_and_a_refusal_is_an_error_step() -> None: + """An MCP result's text blocks are the output; a refused call fails its step.""" + frames, _ = _frames( + _assistant( + "msg_1", + ToolUseBlock("toolu_1", "mcp__surfsense__search_sources", {"query": "q3"}), + ToolUseBlock("toolu_2", "Bash", {"command": "ls"}), + ), + UserMessage( + content=[ + ToolResultBlock( + "toolu_1", [{"type": "text", "text": "Found 2 passages."}] + ), + ToolResultBlock("toolu_2", "Bash is not available.", is_error=True), + ] + ), + _done(), + ) + + ended = { + s["tool"]: s + for s in frames + if s["type"] == "agent-step" and s["status"] in {"completed", "error"} + } + assert ended["surfsense_search_sources"]["output"] == "Found 2 passages." + assert ended["bash"]["status"] == "error" + assert ended["bash"]["error"] == "Bash is not available." + + +def test_words_after_a_tool_open_a_new_paragraph() -> None: + """Each API message is one step's words, as opencode's parts are.""" + frames, _ = _frames( + _assistant("msg_1", TextBlock("Let me check.")), + _assistant("msg_1", ToolUseBlock("toolu_1", "Glob", {"pattern": "*.md"})), + UserMessage(content=[ToolResultBlock("toolu_1", "a.md")]), + _assistant("msg_2", TextBlock("Found a.md.")), + _done(), + ) + + assert "".join(f["text"] for f in frames if f["type"] == "delta") == ( + "Let me check.\n\nFound a.md." + ) + + +def test_the_turn_read_live_is_the_reply_it_stores() -> None: + """The reply is stated from what streamed: the transcript lands in batches, after.""" + shape = OpencodeShape(SESSION) + shape.user("turn-1", created_ms=1_000) + for message in [ + _assistant("msg_1", ToolUseBlock("toolu_1", "Glob", {"pattern": "*.md"})), + UserMessage(content=[ToolResultBlock("toolu_1", "a.md")]), + _assistant("msg_2", TextBlock("Found a.md.")), + _done(), + ]: + shape.read(message) + + reply = turn_reply(shape.messages(), "turn-1", []) + + assert reply is not None + assert reply["id"] == "turn-1:reply" + assert reply["content"]["text"] == "Found a.md." + assert [step["status"] for step in reply["content"]["steps"]] == ["completed"] + assert reply["completed_at"] is not None diff --git a/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_plugin_statuses.py b/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_plugin_statuses.py new file mode 100644 index 0000000000..5ade7df502 --- /dev/null +++ b/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_plugin_statuses.py @@ -0,0 +1,64 @@ +"""A plugin is reported unavailable only from Claude Code's settled answer for the turn.""" + +import pytest + +from modules.agent.engines.claude_code.plugin_statuses import unavailable_plugins +from modules.agent.plugin_tools.turn_plugins import TurnPlugin +from modules.agent.plugin_tools.unavailable_plugin import UnavailablePlugin + +pytestmark = pytest.mark.unit + + +def _plugin(name: str, title: str, *, licensed: bool = False) -> TurnPlugin: + return TurnPlugin( + name=name, + title=title, + url=f"https://{name}.example/mcp", + headers={}, + licensed=licensed, + ) + + +PLUGINS = [ + _plugin("google-search", "Google Search", licensed=True), + _plugin("deepwiki", "DeepWiki"), + _plugin("notion", "Notion"), +] + + +def _servers(**statuses: str) -> list[dict]: + return [{"name": name, "status": status} for name, status in statuses.items()] + + +def test_connected_plugins_are_not_reported() -> None: + """The false alarm: a plugin still connecting at start is connected by the turn.""" + servers = _servers( + surfsense="connected", **{"google-search": "connected"}, deepwiki="connected" + ) + + assert unavailable_plugins(servers, PLUGINS) == [] + + +def test_each_settled_failure_says_what_to_do() -> None: + """Named as opencode's turn names them: unreachable, or a sign-in, or the license.""" + servers = _servers( + **{"google-search": "needs-auth"}, deepwiki="failed", notion="needs-auth" + ) + + assert unavailable_plugins(servers, PLUGINS) == [ + UnavailablePlugin("Google Search", "license"), + UnavailablePlugin("DeepWiki", "unreachable"), + UnavailablePlugin("Notion", "sign_in"), + ] + + +def test_a_plugin_still_connecting_when_the_turn_began_is_unreachable() -> None: + """Claude Code waits its MCP timeout before a turn; past it, as opencode's add times out.""" + assert unavailable_plugins(_servers(deepwiki="pending"), PLUGINS) == [ + UnavailablePlugin("DeepWiki", "unreachable") + ] + + +def test_surfsenses_own_tools_are_never_reported_as_a_plugin() -> None: + """Its tool endpoint is no plugin; a failure there is not the user's to fix.""" + assert unavailable_plugins(_servers(surfsense="failed"), PLUGINS) == [] diff --git a/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_skills_plugin.py b/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_skills_plugin.py new file mode 100644 index 0000000000..94433a6e16 --- /dev/null +++ b/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_skills_plugin.py @@ -0,0 +1,51 @@ +"""SurfSense's skills, given to Claude Code as a local plugin with its own tool names.""" + +import json +from pathlib import Path + +import pytest + +from modules.agent.engines.claude_code.skills_plugin import build_skills_plugin + +pytestmark = pytest.mark.unit + + +def _skills(tmp_path: Path) -> Path: + skills = tmp_path / "skills" + (skills / "surfsense-documents" / "reference").mkdir(parents=True) + (skills / "surfsense-documents" / "SKILL.md").write_text( + "---\nname: surfsense-documents\n---\nCall surfsense_render_document.\n" + ) + (skills / "surfsense-documents" / "reference" / "styles.md").write_text("Fonts.") + return skills + + +def test_each_skill_is_named_under_the_surfsense_plugin(tmp_path: Path) -> None: + """As the Skill tool names them, which is what the gate allows.""" + plugin = build_skills_plugin(_skills(tmp_path), tmp_path / "plugin") + + assert plugin.names == frozenset({"surfsense:surfsense-documents"}) + manifest = json.loads((plugin.root / ".claude-plugin" / "plugin.json").read_text()) + assert manifest["name"] == "surfsense" + + +def test_a_skill_names_tools_as_claude_code_sees_them(tmp_path: Path) -> None: + """And keeps the files it points to, which the gate lets Claude Code read.""" + plugin = build_skills_plugin(_skills(tmp_path), tmp_path / "plugin") + + skill = plugin.skills / "surfsense-documents" + assert "mcp__surfsense__render_document" in (skill / "SKILL.md").read_text() + assert (skill / "reference" / "styles.md").read_text() == "Fonts." + + +def test_a_rebuild_drops_a_skill_that_is_gone(tmp_path: Path) -> None: + """Built again at each start, from what this version ships.""" + skills = _skills(tmp_path) + build_skills_plugin(skills, tmp_path / "plugin") + (skills / "surfsense-documents" / "reference" / "styles.md").unlink() + + plugin = build_skills_plugin(skills, tmp_path / "plugin") + + assert not ( + plugin.skills / "surfsense-documents" / "reference" / "styles.md" + ).exists() diff --git a/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_tool_names.py b/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_tool_names.py new file mode 100644 index 0000000000..7628fdb15a --- /dev/null +++ b/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_tool_names.py @@ -0,0 +1,60 @@ +"""Claude Code's tool names and opencode's, for the same tool.""" + +import pytest + +from modules.agent.engines.claude_code.tool_names import ( + claude_tool, + for_claude, + step_input, + step_tool, +) + +pytestmark = pytest.mark.unit + + +def test_a_step_is_named_as_opencode_names_it_so_the_thread_labels_it_alike() -> None: + """The frontend labels steps, and opens versions, by opencode's names.""" + assert step_tool("Read") == "read" + assert step_tool("Glob") == "glob" + assert step_tool("Edit") == "edit" + assert step_tool("mcp__surfsense__render_document") == "surfsense_render_document" + assert step_tool("mcp__notion__create_page") == "notion_create_page" + + +def test_a_plugin_tool_is_named_as_claude_code_names_it() -> None: + """Claude Code turns anything but letters, digits, _ and - into _, as opencode does.""" + assert claude_tool("notion", "create_page") == "mcp__notion__create_page" + assert claude_tool("my plugin", "do.thing/now") == "mcp__my_plugin__do_thing_now" + + +def test_a_steps_input_uses_opencodes_keys() -> None: + """The label reads `filePath`; Claude Code's file tools send `file_path`.""" + assert step_input("Edit", {"file_path": "outputs/a.md", "old_string": "x"}) == { + "filePath": "outputs/a.md", + "oldString": "x", + } + assert step_input("mcp__surfsense__search_sources", {"query_text": "q"}) == { + "query_text": "q" + } + + +def test_the_skills_name_surfsenses_tools_as_claude_code_sees_them() -> None: + """Written for opencode, the skills and prompt are given to Claude Code renamed.""" + text = ( + "Call surfsense_render_document, then surfsense_read_document. " + "The surfsense-documents skill, and surfsense_nothing, stay as they are." + ) + + assert for_claude(text) == ( + "Call mcp__surfsense__render_document, then mcp__surfsense__read_document. " + "The surfsense-documents skill, and surfsense_nothing, stay as they are." + ) + + +def test_the_file_tools_a_note_names_are_claude_codes() -> None: + """opencode's `read`, `grep` and `glob` are Claude Code's Read, Grep and Glob.""" + text = "Open them with `read`, or search with `grep` and `glob`; reading stays." + + assert for_claude(text) == ( + "Open them with `Read`, or search with `Grep` and `Glob`; reading stays." + ) diff --git a/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_transcript_shape.py b/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_transcript_shape.py new file mode 100644 index 0000000000..dab45f10d4 --- /dev/null +++ b/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_transcript_shape.py @@ -0,0 +1,129 @@ +"""A Claude Code transcript read back as the thread's turns, as an opencode session is.""" + +from typing import Any + +import pytest + +from modules.agent.agent_threads.replies import thread_turns +from modules.agent.engines.claude_code.transcript_shape import opencode_messages + +pytestmark = pytest.mark.unit + + +def _user(uuid: str, content: Any, **extra: Any) -> dict[str, Any]: + return { + "type": "user", + "uuid": uuid, + "timestamp": "2026-10-16T10:00:00.000Z", + "isSidechain": False, + "message": {"role": "user", "content": content}, + **extra, + } + + +def _assistant(message_id: str, block: dict[str, Any], at: str) -> dict[str, Any]: + return { + "type": "assistant", + "uuid": f"{message_id}-{block['type']}", + "timestamp": at, + "isSidechain": False, + "message": {"id": message_id, "role": "assistant", "content": [block]}, + } + + +TURN = [ + {"type": "queue-operation", "timestamp": "2026-10-16T10:00:00.000Z"}, + _user( + "turn-1", + [ + {"type": "text", "text": "[surfsense-scope: 4]\nUse only that one."}, + {"type": "text", "text": "What grew in Q3?"}, + ], + ), + {"type": "attachment", "uuid": "a1", "timestamp": "2026-10-16T10:00:00.000Z"}, + _assistant( + "msg_1", + {"type": "thinking", "thinking": "Search first.", "signature": "s"}, + "2026-10-16T10:00:01.000Z", + ), + _assistant( + "msg_1", + { + "type": "tool_use", + "id": "toolu_1", + "name": "mcp__surfsense__search_sources", + "input": {"query": "Q3"}, + }, + "2026-10-16T10:00:02.000Z", + ), + _user( + "result-1", + [ + { + "type": "tool_result", + "tool_use_id": "toolu_1", + "content": [{"type": "text", "text": "[1] Revenue grew 12%."}], + } + ], + ), + _assistant( + "msg_2", + {"type": "text", "text": "Revenue grew 12%."}, + "2026-10-16T10:00:04.000Z", + ), +] + + +def test_a_turn_is_the_users_words_then_one_reply_with_its_steps() -> None: + """Thinking, a call and its result, and the answer, across two API messages.""" + turns = thread_turns(opencode_messages(TURN), []) + + assert [turn["role"] for turn in turns] == ["user", "assistant"] + user, reply = turns + assert user["id"] == "turn-1" + # SurfSense's notes travel as blocks before the user's own words. + assert user["content"]["text"] == "What grew in Q3?" + assert user["content"]["scope"] is not None + assert reply["id"] == "turn-1:reply" + assert reply["content"]["text"] == "Revenue grew 12%." + (step,) = reply["content"]["steps"] + assert step["tool"] == "surfsense_search_sources" + assert step["status"] == "completed" + assert step["output"] == "[1] Revenue grew 12%." + assert reply["created_at"] == "2026-10-16T10:00:01Z" + assert reply["completed_at"] == "2026-10-16T10:00:04Z" + + +def test_claude_codes_own_messages_are_not_turns() -> None: + """A compaction's summary, a meta message and an interruption notice are Claude Code's.""" + entries = [ + *TURN, + {"type": "system", "subtype": "compact_boundary", "uuid": "c1"}, + _user("summary", "This session is being continued…", isCompactSummary=True), + _user("meta", "Caveat: the messages below…", isMeta=True), + _user("stop", [{"type": "text", "text": "[Request interrupted by user]"}]), + _user("turn-2", "And Q4?"), + _assistant( + "msg_3", + {"type": "text", "text": "Not yet known."}, + "2026-10-16T10:01:00.000Z", + ), + ] + + turns = thread_turns(opencode_messages(entries), []) + + assert [turn["id"] for turn in turns] == [ + "turn-1", + "turn-1:reply", + "turn-2", + "turn-2:reply", + ] + + +def test_a_subagents_entries_are_left_out() -> None: + """SurfSense gives Claude Code no subagents; a sidechain is never the thread's.""" + sidechain = {**_user("side", "hidden"), "isSidechain": True} + + turns = thread_turns(opencode_messages([*TURN, sidechain]), []) + + assert len(turns) == 2 diff --git a/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_turn_link.py b/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_turn_link.py new file mode 100644 index 0000000000..12d5a839ab --- /dev/null +++ b/surfsense_local/backend/tests/unit/agent/engines/claude_code/test_turn_link.py @@ -0,0 +1,131 @@ +"""Each tool call of a Claude Code turn meets the gate; an `ask` waits on the user's answer.""" + +import asyncio +from pathlib import Path + +import pytest +from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny + +from modules.agent.engines.claude_code.approvals import Approvals +from modules.agent.engines.claude_code.gate import Gate +from modules.agent.engines.claude_code.turn_link import TurnLink + +pytestmark = pytest.mark.unit +THREAD = 7 + + +def _link(tmp_path: Path) -> tuple[TurnLink, Approvals, asyncio.Queue]: + folder = tmp_path / "7" + (folder / "outputs").mkdir(parents=True) + approvals = Approvals() + link = TurnLink(THREAD, approvals) + events: asyncio.Queue = asyncio.Queue() + link.begin( + Gate( + folder=folder, + skills=tmp_path / "skills", + plugin_rules={"mcp__notion__create_page": "ask"}, + ), + events, + "claude-session", + ) + return link, approvals, events + + +def _hook_input(tool: str, tool_input: dict) -> dict: + return { + "hook_event_name": "PreToolUse", + "tool_name": tool, + "tool_input": tool_input, + } + + +async def test_the_hook_answers_at_once_with_the_gates_verdict(tmp_path: Path) -> None: + """Fast, so Claude Code's hook timeout never decides a call.""" + link, _, _ = _link(tmp_path) + + allowed = await link.pre_tool_use( + _hook_input("Write", {"file_path": "outputs/a.md"}), "toolu_1", {} + ) + refused = await link.pre_tool_use( + _hook_input("Bash", {"command": "ls"}), "toolu_2", {} + ) + asked = await link.pre_tool_use( + _hook_input("mcp__notion__create_page", {}), "toolu_3", {} + ) + + assert allowed["hookSpecificOutput"]["permissionDecision"] == "allow" + assert refused["hookSpecificOutput"]["permissionDecision"] == "deny" + assert refused["hookSpecificOutput"]["permissionDecisionReason"] + assert asked["hookSpecificOutput"]["permissionDecision"] == "ask" + + +async def test_an_ask_waits_for_the_user_and_the_thread_sees_both_ends( + tmp_path: Path, +) -> None: + """The approval card names the step it holds back; Allow once runs the call.""" + link, approvals, events = _link(tmp_path) + + asking = asyncio.create_task( + link.can_use_tool( + "mcp__notion__create_page", {"title": "Q3"}, {"tool_use_id": "toolu_3"} + ) + ) + asked = await asyncio.wait_for(events.get(), 1) + assert asked.type == "permission.asked" + assert asked.properties["tool"]["callID"] == "toolu_3" + assert asked.properties["permission"] == "notion_create_page" + assert not asking.done() + + assert approvals.answer(THREAD, asked.properties["id"], "once") + + assert isinstance(await asking, PermissionResultAllow) + replied = await events.get() + assert replied.type == "permission.replied" + assert replied.properties["reply"] == "once" + + +async def test_a_rejected_ask_never_runs(tmp_path: Path) -> None: + """Reject reaches Claude Code as a refusal the model reads.""" + link, approvals, events = _link(tmp_path) + + asking = asyncio.create_task( + link.can_use_tool("mcp__notion__create_page", {}, {"tool_use_id": "toolu_3"}) + ) + asked = await events.get() + approvals.answer(THREAD, asked.properties["id"], "reject") + + refused = await asking + assert isinstance(refused, PermissionResultDeny) + assert refused.message == "The user rejected this action." + + +async def test_the_second_gate_applies_the_same_rules(tmp_path: Path) -> None: + """A call that reached it without the hook still meets the rules.""" + link, _, _ = _link(tmp_path) + + refused = await link.can_use_tool("Bash", {"command": "ls"}, {}) + allowed = await link.can_use_tool("Write", {"file_path": "outputs/a.md"}, {}) + + assert isinstance(refused, PermissionResultDeny) + assert isinstance(allowed, PermissionResultAllow) + + +async def test_an_answer_for_no_waiting_request_is_refused(tmp_path: Path) -> None: + """The route then answers 404, as opencode's does.""" + _, approvals, _ = _link(tmp_path) + + assert approvals.answer(THREAD, "perm_unknown", "once") is False + + +async def test_stop_refuses_every_waiting_ask(tmp_path: Path) -> None: + """A stopped turn leaves no call waiting on a card nobody can answer.""" + link, approvals, events = _link(tmp_path) + asking = asyncio.create_task( + link.can_use_tool("mcp__notion__create_page", {}, {"tool_use_id": "toolu_3"}) + ) + await events.get() + + approvals.drop(THREAD) + + assert isinstance(await asking, PermissionResultDeny) diff --git a/surfsense_local/backend/tests/unit/agent/engines/handover/test_note.py b/surfsense_local/backend/tests/unit/agent/engines/handover/test_note.py new file mode 100644 index 0000000000..5b1f218c5a --- /dev/null +++ b/surfsense_local/backend/tests/unit/agent/engines/handover/test_note.py @@ -0,0 +1,96 @@ +"""The note an engine taking over a thread is given: what happened while it was away.""" + +from typing import Any + +import pytest + +from modules.agent.engines.handover.note import TAG, handover_note + +pytestmark = pytest.mark.unit + + +def _turn(text: str, reply: str, steps: list[dict[str, Any]] = ()) -> list[dict]: + return [ + {"role": "user", "content": {"text": text}}, + {"role": "assistant", "content": {"text": reply, "steps": list(steps)}}, + ] + + +def _step(tool: str, given: dict, output: str = "", error: str | None = None) -> dict: + return {"tool": tool, "input": given, "output": output, "error": error} + + +def test_the_note_holds_each_message_reply_call_and_the_files_made() -> None: + """In the order they happened, tagged so reading the thread back takes it out.""" + turns = _turn( + "What grew in Q3?", + "Revenue grew 12%.", + [_step("surfsense_search_sources", {"query": "Q3"}, "[1] Revenue up 12%.")], + ) + + note = handover_note(turns, ["outputs/q3.md"], budget=10_000) + + assert note.startswith(f"<{TAG}>") + assert note.rstrip().endswith(f"") + assert "What grew in Q3?" in note + assert "Revenue grew 12%." in note + assert 'surfsense_search_sources {"query": "Q3"}' in note + assert "[1] Revenue up 12%." in note + assert "outputs/q3.md" in note + + +def test_a_long_tool_output_is_cut() -> None: + """2,000 characters of each, as the proposal sets.""" + turns = _turn("Read it", "Done.", [_step("read", {"filePath": "a.md"}, "x" * 5000)]) + + note = handover_note(turns, [], budget=100_000) + + assert "x" * 2000 in note + assert "x" * 2001 not in note + + +def test_what_a_file_said_before_it_was_rewritten_is_left_out() -> None: + """The file is on disk as it is now; its old text would only mislead.""" + turns = _turn( + "Fix the draft", + "Fixed.", + [ + _step("read", {"filePath": "outputs/draft.md"}, "OLD DRAFT TEXT"), + _step("write", {"filePath": "outputs/draft.md", "content": "new"}), + ], + ) + + note = handover_note(turns, ["outputs/draft.md"], budget=100_000) + + assert "OLD DRAFT TEXT" not in note + assert "outputs/draft.md" in note + + +def test_too_long_a_note_loses_older_tool_outputs_first() -> None: + """The newest turns stay whole; the oldest calls keep their names, not their outputs.""" + old = _turn( + "First", "First answer.", [_step("read", {"filePath": "a"}, "A" * 1500)] + ) + new = _turn( + "Second", "Second answer.", [_step("read", {"filePath": "b"}, "B" * 1500)] + ) + + note = handover_note([*old, *new], [], budget=2500) + + assert "B" * 1500 in note + assert "A" * 1500 not in note + assert "First answer." in note + assert len(note) <= 2500 + + +def test_a_note_that_still_does_not_fit_keeps_the_newest_turns() -> None: + """The oldest turns go last of all, and the newest is never cut away.""" + turns = [] + for n in range(50): + turns += _turn(f"Question {n}", f"Answer {n}. " + "word " * 100) + + note = handover_note(turns, [], budget=3000) + + assert len(note) <= 3000 + assert "Question 49" in note + assert "Answer 49." in note diff --git a/surfsense_local/backend/tests/unit/agent/engines/test_both_sessions.py b/surfsense_local/backend/tests/unit/agent/engines/test_both_sessions.py new file mode 100644 index 0000000000..f59d050a59 --- /dev/null +++ b/surfsense_local/backend/tests/unit/agent/engines/test_both_sessions.py @@ -0,0 +1,91 @@ +"""A thread that switched engines reads back from both, even while the one it left cannot answer.""" + +from types import SimpleNamespace +from typing import Any + +import pytest +from fastapi import HTTPException, status + +from modules.agent.engines.both_sessions import stored_step_of, thread_turns_of +from modules.chat.models import AgentEngineKind + +pytestmark = pytest.mark.unit +# A thread now on Claude Code that ran on opencode before. +THREAD = SimpleNamespace(id=7, agent_engine=AgentEngineKind.CLAUDE_CODE) + + +class _Engine: + """An engine holding a session of the thread, answering or refusing as told.""" + + def __init__( + self, + kind: AgentEngineKind, + turns: list[dict[str, Any]] | None = None, + steps: dict[str, dict[str, Any]] | None = None, + refusal: int | None = None, + ) -> None: + self.kind = kind + self._turns = turns or [] + self._steps = steps or {} + self._refusal = refusal + + def has_session(self, thread: object) -> bool: + return True + + async def thread_messages(self, *_args: object) -> list[dict[str, Any]]: + self._refuse() + return self._turns + + async def stored_step(self, _session: object, _thread: object, part_id: str): + self._refuse() + if part_id not in self._steps: + raise HTTPException(status.HTTP_404_NOT_FOUND, "no such step") + return self._steps[part_id] + + def _refuse(self) -> None: + if self._refusal is not None: + raise HTTPException(self._refusal, "not answering") + + +def _turn(role: str, text: str) -> dict[str, Any]: + return { + "role": role, + "content": {"text": text}, + "created_at": "2026-10-17T09:00:00Z", + } + + +async def test_the_engine_left_behind_not_answering_still_reads_the_thread() -> None: + """opencode not running must not hide the turns the engine running the thread holds.""" + left = _Engine(AgentEngineKind.OPENCODE, refusal=503) + current = _Engine( + AgentEngineKind.CLAUDE_CODE, + [_turn("user", "Second"), _turn("assistant", "Two.")], + ) + + turns = await thread_turns_of(None, THREAD, "key", False, [left, current]) + + assert [t["content"]["text"] for t in turns] == ["Second", "Two."] + + +async def test_the_engine_running_the_thread_not_answering_is_said() -> None: + """Its turns are the thread's latest: reading without them would show a stale thread.""" + left = _Engine(AgentEngineKind.OPENCODE, [_turn("user", "First")]) + current = _Engine(AgentEngineKind.CLAUDE_CODE, refusal=503) + + with pytest.raises(HTTPException) as refused: + await thread_turns_of(None, THREAD, "key", False, [left, current]) + + assert refused.value.status_code == 503 + + +async def test_a_step_is_found_past_the_engine_left_behind_not_answering() -> None: + """Save to Sources on a step of the engine running the thread, while the other cannot answer.""" + left = _Engine(AgentEngineKind.OPENCODE, refusal=502) + current = _Engine( + AgentEngineKind.CLAUDE_CODE, steps={"prt_1": {"id": "prt_1", "type": "tool"}} + ) + + step = await stored_step_of(None, THREAD, "prt_1", [left, current]) + + assert step["id"] == "prt_1" diff --git a/surfsense_local/backend/tests/unit/agent/test_give_plugins.py b/surfsense_local/backend/tests/unit/agent/test_give_plugins.py index 6ec3a8374d..8ad4565fc0 100644 --- a/surfsense_local/backend/tests/unit/agent/test_give_plugins.py +++ b/surfsense_local/backend/tests/unit/agent/test_give_plugins.py @@ -10,15 +10,12 @@ from modules.agent.plugin_tools import give_plugins as giving from modules.agent.plugin_tools import stop_plugin_calls as stopping -from modules.agent.plugin_tools.give_plugins import ( - UnavailablePlugin, - give_plugins, - set_plugin_rules, -) +from modules.agent.plugin_tools.give_plugins import give_plugins, set_plugin_rules from modules.agent.plugin_tools.given_servers import digest, given_as, remember_given from modules.agent.plugin_tools.stop_plugin_calls import HostCalls, timed_out_plugin from modules.agent.plugin_tools.thread_calls import CALLS_HEADER from modules.agent.plugin_tools.turn_plugins import TurnPlugin +from modules.agent.plugin_tools.unavailable_plugin import UnavailablePlugin from modules.agent.tool_endpoint.registration import ( PLUGIN_CALL_SECONDS, TOOL_CALL_SECONDS, @@ -91,7 +88,7 @@ async def test_a_server_that_hangs_or_fails_is_named_alone( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The others are given, and only an answered add is remembered as given.""" - monkeypatch.setattr(giving, "ADD_SECONDS", 0.2) + monkeypatch.setattr(giving, "CONNECT_SECONDS", 0.2) opencode = _Opencode({"a": 0, "hung": None, "down": httpx.ConnectError("refused")}) held: dict[str, str] = {} @@ -165,7 +162,7 @@ async def test_a_server_that_failed_this_turn_is_not_waited_on_again( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A second giving, after the check changed the list, names it at once; one given anew is tried.""" - monkeypatch.setattr(giving, "ADD_SECONDS", 0.2) + monkeypatch.setattr(giving, "CONNECT_SECONDS", 0.2) opencode = _Opencode({"hung": None, "b": 0}) held: dict[str, str] = {} first = await give_plugins(opencode, tmp_path, held, [plugin("hung")]) diff --git a/surfsense_local/backend/tests/unit/chat/test_errors.py b/surfsense_local/backend/tests/unit/chat/test_errors.py index 2dfe3c980d..71612f4ed0 100644 --- a/surfsense_local/backend/tests/unit/chat/test_errors.py +++ b/surfsense_local/backend/tests/unit/chat/test_errors.py @@ -158,3 +158,41 @@ def test_a_shared_cache_that_ran_out_of_room_says_so(said: str) -> None: assert kind is ChatErrorKind.RUNTIME_BUSY assert "running together" in message + + +def test_a_claude_plan_that_must_sign_in_again_points_at_claude_code() -> None: + """Its sign-in is Claude Code's, in a terminal, not ChatGPT's in Model setup.""" + from modules.llm.subscriptions.claude.errors import ClaudeSignInRequiredError + + kind, message = classify_chat_error( + ClaudeSignInRequiredError("signed out"), "openai_compatible" + ) + + assert kind is ChatErrorKind.CLAUDE_SIGN_IN + assert "Claude Code" in message + assert "ChatGPT" not in message + + +def test_a_used_up_claude_plan_says_whose_limit_it_is() -> None: + """The limit is the Claude plan's, which resets on its own.""" + from modules.llm.subscriptions.claude.errors import ClaudePlanLimitError + + kind, message = classify_chat_error( + ClaudePlanLimitError("limit"), "openai_compatible" + ) + + assert kind is ChatErrorKind.CLAUDE_LIMIT + assert "Claude" in message + assert "ChatGPT" not in message + + +def test_a_host_turned_off_during_a_reply_says_where_to_allow_it() -> None: + """Not `unknown`: the fix is a switch in Settings, which the user can flip.""" + from modules.egress.service import EgressDeniedError + + kind, message = classify_chat_error( + EgressDeniedError("host:api.anthropic.com"), "openai_compatible" + ) + + assert kind is ChatErrorKind.EGRESS_DISABLED + assert "Settings > Network" in message diff --git a/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_chat.py b/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_chat.py new file mode 100644 index 0000000000..4f67bf6c62 --- /dev/null +++ b/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_chat.py @@ -0,0 +1,317 @@ +import json +from pathlib import Path + +import pytest +from claude_agent_sdk import ( + AssistantMessage, + RateLimitEvent, + RateLimitInfo, + SystemMessage, + TextBlock, +) + +from modules.egress.service import EgressDeniedError +from modules.llm.providers.openai_responses.errors import ( + PlanLimitError, + SignInRequiredError, +) +from modules.llm.providers.types import Delta, Image, Message +from modules.llm.subscriptions.claude.chat import ClaudePlanProvider +from modules.llm.subscriptions.claude.chat_runs import ChatRuns +from tests.claude_run_stand_in import ScriptedRun, answer, done + +pytestmark = pytest.mark.unit +SYSTEM = Message("system", "You are SurfSense.") + + +class Opener: + """Hands out scripted runs in order, recording the options each was opened with.""" + + def __init__(self, *runs: ScriptedRun) -> None: + self.runs = list(runs) + self.opened: list[ScriptedRun] = [] + + async def __call__(self, options: object) -> ScriptedRun: + run = self.runs.pop(0) + run.options = options + self.opened.append(run) + return run + + +def _provider( + opener: Opener, tmp_path: Path, *, capacity: int = 4 +) -> ClaudePlanProvider: + return ClaudePlanProvider( + runs=ChatRuns(opener, capacity=capacity), + find_claude=lambda: Path("/home/ada/.local/bin/claude"), + work_dir=tmp_path, + ) + + +async def _deltas( + provider: ClaudePlanProvider, messages: list[Message], **kw +) -> list[Delta]: + return [d async for d in provider.chat_deltas("claude-sonnet-5-5", messages, **kw)] + + +async def test_the_answer_streams_as_claude_code_writes_it(tmp_path: Path) -> None: + """Each text delta reaches the chat as it arrives.""" + provider = _provider(Opener(ScriptedRun([answer("Hel", "lo")])), tmp_path) + + deltas = await _deltas(provider, [SYSTEM, Message("user", "Hi")]) + + assert [d.text for d in deltas] == ["Hel", "lo"] + + +async def test_a_chat_run_has_no_tools_and_surfsenses_prompt(tmp_path: Path) -> None: + """ADR 0052: the chat model never calls a tool; nothing is written to ~/.claude.""" + opener = Opener(ScriptedRun([answer("ok")])) + + await _deltas(_provider(opener, tmp_path), [SYSTEM, Message("user", "Hi")]) + + options = opener.opened[0].options + assert options.tools == [] + assert options.max_turns == 1 + assert options.system_prompt == "You are SurfSense." + assert options.model == "claude-sonnet-5-5" + assert options.include_partial_messages is True + assert "no-session-persistence" in options.extra_args + assert options.cwd == str(tmp_path) + + +async def test_a_new_conversation_sends_its_history_with_the_newest_message_last( + tmp_path: Path, +) -> None: + """A run that has not seen the conversation is told it as text.""" + opener = Opener(ScriptedRun([answer("Biscuit")])) + history = [ + SYSTEM, + Message("user", "My cat is Biscuit."), + Message("assistant", "Nice name!"), + Message("user", "What is my cat called?"), + ] + + await _deltas(_provider(opener, tmp_path), history, conversation="t1") + + sent = opener.opened[0].sent[0] + assert "My cat is Biscuit." in sent + assert "Nice name!" in sent + assert sent.rstrip().endswith("What is my cat called?") + assert sent.index("Biscuit.") < sent.index("Nice name!") + + +async def test_the_next_message_of_a_conversation_reuses_its_run( + tmp_path: Path, +) -> None: + """A warm run already holds the history, so only the new message is sent.""" + run = ScriptedRun([answer("Nice name!"), answer("Biscuit")]) + opener = Opener(run) + provider = _provider(opener, tmp_path) + first = [SYSTEM, Message("user", "My cat is Biscuit.")] + await _deltas(provider, first, conversation="t1") + + await _deltas( + provider, + [*first, Message("assistant", "Nice name!"), Message("user", "Her name?")], + conversation="t1", + ) + + assert len(opener.opened) == 1 + assert run.sent[1] == "Her name?" + + +async def test_an_edited_conversation_starts_a_fresh_run(tmp_path: Path) -> None: + """A retried or edited message leaves the warm run's memory wrong.""" + old, new = ScriptedRun([answer("a")]), ScriptedRun([answer("b")]) + opener = Opener(old, new) + provider = _provider(opener, tmp_path) + await _deltas(provider, [SYSTEM, Message("user", "First try")], conversation="t1") + + await _deltas(provider, [SYSTEM, Message("user", "Second try")], conversation="t1") + + assert old.closed + assert new.sent[0].rstrip().endswith("Second try") + + +async def test_thinking_is_marked_apart_and_left_out_of_the_answer( + tmp_path: Path, +) -> None: + """Shown only with display=summarized; chat() keeps the answer alone.""" + script = [ + answer("42", thinking=["6 times 7"]), + answer("42", thinking=["6 times 7"]), + ] + opener = Opener(ScriptedRun(script[:1]), ScriptedRun(script[1:])) + provider = _provider(opener, tmp_path) + messages = [SYSTEM, Message("user", "6x7?")] + + deltas = await _deltas(provider, messages, reasoning=True) + text = [ + t async for t in provider.chat("claude-sonnet-5-5", messages, reasoning=True) + ] + + assert deltas == [Delta("6 times 7", reasoning=True), Delta("42")] + assert text == ["42"] + assert opener.opened[0].options.thinking == { + "type": "adaptive", + "display": "summarized", + } + + +async def test_a_schema_returns_claude_codes_structured_output(tmp_path: Path) -> None: + """Claude Code answers a schema through its own tool, so it needs more than one turn.""" + schema = {"type": "object", "properties": {"tool": {"type": "string"}}} + opener = Opener(ScriptedRun([[done(structured_output={"tool": "none"})]])) + + deltas = await _deltas( + _provider(opener, tmp_path), + [SYSTEM, Message("user", "pick")], + json_schema=schema, + ) + + assert json.loads("".join(d.text for d in deltas)) == {"tool": "none"} + options = opener.opened[0].options + assert options.output_format == {"type": "json_schema", "schema": schema} + assert options.max_turns == 3 + + +async def test_a_signed_out_claude_code_asks_to_sign_in(tmp_path: Path) -> None: + """Signed out in a terminal after connecting: the chat offers the sign-in notice.""" + signed_out = AssistantMessage( + content=[TextBlock("Not logged in")], model="x", error="authentication_failed" + ) + opener = Opener(ScriptedRun([[signed_out, done(is_error=True)]])) + + with pytest.raises(SignInRequiredError): + await _deltas(_provider(opener, tmp_path), [SYSTEM, Message("user", "Hi")]) + + +async def test_a_used_up_plan_is_the_plan_limit(tmp_path: Path) -> None: + """Raised before any text, so the chat shows the limit, not half an answer.""" + info = RateLimitInfo(status="rejected", resets_at=1792107600) + limit = RateLimitEvent(rate_limit_info=info, uuid="u", session_id="s") + opener = Opener(ScriptedRun([[limit, done(is_error=True)]])) + + with pytest.raises(PlanLimitError): + await _deltas(_provider(opener, tmp_path), [SYSTEM, Message("user", "Hi")]) + + +async def test_no_claude_code_asks_to_set_it_up(tmp_path: Path) -> None: + """Uninstalled after connecting: the chat shows the sign-in notice, not a crash.""" + provider = ClaudePlanProvider( + runs=ChatRuns(Opener(), capacity=4), find_claude=lambda: None, work_dir=tmp_path + ) + + with pytest.raises(SignInRequiredError): + await _deltas(provider, [SYSTEM, Message("user", "Hi")]) + + +async def test_a_token_cap_and_temperature_are_dropped(tmp_path: Path) -> None: + """Claude Code exposes neither; refusing them would break every caller that sets one.""" + provider = _provider(Opener(ScriptedRun([answer("ok")])), tmp_path) + + deltas = await _deltas( + provider, [SYSTEM, Message("user", "Hi")], max_tokens=64, temperature=0.2 + ) + + assert [d.text for d in deltas] == ["ok"] + + +async def test_the_least_recently_used_run_is_closed_past_the_cap( + tmp_path: Path, +) -> None: + """At most this many Claude Code processes stay warm.""" + first, second, third = (ScriptedRun([answer("x")]) for _ in range(3)) + provider = _provider(Opener(first, second, third), tmp_path, capacity=2) + + for name in ("a", "b", "c"): + await _deltas(provider, [SYSTEM, Message("user", name)], conversation=name) + + assert first.closed + assert not second.closed + assert not third.closed + + +async def test_an_image_goes_beside_the_newest_message(tmp_path: Path) -> None: + """Sent as an image block, never dropped: the manifest says the model reads images.""" + opener = Opener(ScriptedRun([answer("A cat")])) + picture = Image("image/png", b"\x89PNG") + + await _deltas( + _provider(opener, tmp_path), + [SYSTEM, Message("user", "What is this?", images=(picture,))], + ) + + blocks = opener.opened[0].sent[0] + assert blocks[-1] == {"type": "text", "text": "What is this?"} + assert blocks[0]["type"] == "image" + assert blocks[0]["source"]["media_type"] == "image/png" + + +async def test_the_models_window_comes_from_the_manifest(tmp_path: Path) -> None: + """Haiku 5.5 is not in the manifest yet, so its window is unknown, not zero.""" + provider = _provider(Opener(), tmp_path) + + assert await provider.context_tokens("claude-sonnet-5-5") == 1_000_000 + assert await provider.context_tokens("claude-haiku-5-5") is None + + +async def test_a_run_left_idle_is_closed(tmp_path: Path) -> None: + """A conversation nobody answers for five minutes gives its process back.""" + now = [0.0] + idle, later = ScriptedRun([answer("x")]), ScriptedRun([answer("y")]) + provider = ClaudePlanProvider( + runs=ChatRuns(Opener(idle, later), idle_seconds=300, clock=lambda: now[0]), + find_claude=lambda: Path("/home/ada/.local/bin/claude"), + work_dir=tmp_path, + ) + await _deltas(provider, [SYSTEM, Message("user", "a")], conversation="a") + + now[0] = 301.0 + await _deltas(provider, [SYSTEM, Message("user", "b")], conversation="b") + + assert idle.closed + + +async def test_a_message_sent_while_its_run_is_busy_gets_a_run_of_its_own( + tmp_path: Path, +) -> None: + """Two tabs on one thread: the second answers from the whole conversation.""" + busy, other = ScriptedRun([answer("1")]), ScriptedRun([answer("2")]) + provider = _provider(Opener(busy, other), tmp_path) + first = [SYSTEM, Message("user", "a")] + stream = provider.chat_deltas("claude-sonnet-5-5", first, conversation="t1") + await anext(stream) + + second = await _deltas( + provider, + [*first, Message("assistant", "1"), Message("user", "b")], + conversation="t1", + ) + await stream.aclose() + + assert [d.text for d in second] == ["2"] + assert other.closed + assert other.sent[0].rstrip().endswith("b") + + +async def test_a_host_turned_off_mid_reply_stops_the_run_at_its_first_retry( + tmp_path: Path, +) -> None: + """Rather than after Claude Code's ten retries.""" + retry = SystemMessage(subtype="api_retry", data={"attempt": 1}) + refusal = EgressDeniedError("host:api.anthropic.com") + run = ScriptedRun([[retry, *answer("never")]]) + + async def host_off() -> EgressDeniedError | None: + return refusal + + provider = ClaudePlanProvider( + runs=ChatRuns(Opener(run)), + find_claude=lambda: Path("/home/ada/.local/bin/claude"), + work_dir=tmp_path, + proxy_refusal=host_off, + ) + + with pytest.raises(EgressDeniedError): + await _deltas(provider, [SYSTEM, Message("user", "Hi")]) diff --git a/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_errors.py b/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_errors.py new file mode 100644 index 0000000000..0093a6125b --- /dev/null +++ b/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_errors.py @@ -0,0 +1,126 @@ +import pytest +from claude_agent_sdk import ( + AssistantMessage, + RateLimitEvent, + RateLimitInfo, + ResultMessage, + SystemMessage, + TextBlock, +) + +from modules.egress.service import EgressDeniedError +from modules.llm.providers.openai_responses.errors import ( + PlanLimitError, + SignInRequiredError, +) +from modules.llm.subscriptions.claude.errors import ( + ClaudePlanLimitError, + ClaudeRunError, + ClaudeSignInRequiredError, + failure_of, +) + +pytestmark = pytest.mark.unit + + +def _assistant(error: str | None = None) -> AssistantMessage: + return AssistantMessage( + content=[TextBlock("hi")], model="claude-haiku-5-5", error=error + ) + + +def _limit(status: str) -> RateLimitEvent: + info = RateLimitInfo( + status=status, resets_at=1792107600, rate_limit_type="five_hour" + ) + return RateLimitEvent(rate_limit_info=info, uuid="u", session_id="s") + + +def _result(*, is_error: bool, errors: list[str] | None = None) -> ResultMessage: + return ResultMessage( + subtype="error_during_execution" if is_error else "success", + duration_ms=1, + duration_api_ms=1, + is_error=is_error, + num_turns=1, + session_id="s", + errors=errors, + ) + + +def _api_retry() -> SystemMessage: + return SystemMessage(subtype="api_retry", data={"attempt": 1, "max_retries": 10}) + + +def test_a_signed_out_claude_code_asks_to_sign_in() -> None: + """Measured: an empty login fails the first request with authentication_failed.""" + assert isinstance( + failure_of(_assistant("authentication_failed")), SignInRequiredError + ) + assert isinstance( + failure_of(_assistant("authentication_failed")), ClaudeSignInRequiredError + ) + + +@pytest.mark.parametrize( + "message", + [_limit("rejected"), _assistant("rate_limit")], + ids=["event", "assistant"], +) +def test_a_used_up_plan_is_the_plan_limit(message: object) -> None: + """Claude Code reports a used-up plan as an event or on the reply itself.""" + assert isinstance(failure_of(message), PlanLimitError) + assert isinstance(failure_of(message), ClaudePlanLimitError) + + +@pytest.mark.parametrize( + "extra", + [{"isUsingOverage": True}, {"overageInUse": True}], + ids=["using-overage", "overage-in-use"], +) +def test_a_used_up_plan_on_extra_usage_is_no_failure(extra: dict) -> None: + """As Claude Code 2.1.301 decides: past the plan's limit, extra usage lets the reply go on.""" + info = RateLimitInfo( + status="rejected", + resets_at=1792107600, + rate_limit_type="five_hour", + overage_status="allowed", + raw={"status": "rejected", "overageStatus": "allowed", **extra}, + ) + event = RateLimitEvent(rate_limit_info=info, uuid="u", session_id="s") + + assert failure_of(event) is None + + +@pytest.mark.parametrize("status", ["allowed", "allowed_warning"]) +def test_a_plan_with_room_left_is_no_failure(status: str) -> None: + """A warning that the plan is nearly used up still answers.""" + assert failure_of(_limit(status)) is None + + +def test_a_retry_after_the_proxy_refused_is_the_egress_refusal() -> None: + """Claude Code retries a refused tunnel ten times rather than failing.""" + refusal = EgressDeniedError("host:platform.claude.com") + + assert failure_of(_api_retry(), proxy_refusal=refusal) is refusal + + +def test_a_retry_the_proxy_did_not_cause_is_no_failure() -> None: + """An ordinary retry is Claude Code's own business.""" + assert failure_of(_api_retry(), proxy_refusal=None) is None + + +def test_any_other_failed_run_is_a_run_error() -> None: + """A failure that is not the plan's or the sign-in's keeps Claude Code's detail.""" + failure = failure_of(_result(is_error=True, errors=["boom"])) + + assert isinstance(failure, ClaudeRunError) + assert "boom" in str(failure) + + +@pytest.mark.parametrize( + "message", [_assistant(), _result(is_error=False)], ids=["text", "success"] +) +def test_an_ordinary_message_is_no_failure(message: object) -> None: + """Text and a successful result mean nothing went wrong.""" + assert failure_of(message) is None diff --git a/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_launch.py b/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_launch.py new file mode 100644 index 0000000000..2c82654ef9 --- /dev/null +++ b/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_launch.py @@ -0,0 +1,83 @@ +from pathlib import Path + +import pytest + +from modules.llm.subscriptions.claude.launch import launch_options + +pytestmark = pytest.mark.unit +BINARY = Path("/home/ada/.local/bin/claude") + + +def test_every_run_is_isolated_from_the_users_claude_setup() -> None: + """The user's settings, CLAUDE.md, MCP servers and permission mode never apply.""" + options = launch_options(BINARY, proxy_url=None) + + assert options.cli_path == str(BINARY) + assert options.setting_sources == [] + assert options.strict_mcp_config is True + assert options.verbatim_prompts is True + assert options.permission_mode == "default" + + +def test_the_caller_sets_what_differs_per_run() -> None: + """Chat and the agent pass their own tools, prompt and turn limit.""" + options = launch_options( + BINARY, + proxy_url=None, + tools=[], + system_prompt="You are SurfSense.", + max_turns=1, + ) + + assert options.tools == [] + assert options.system_prompt == "You are SurfSense." + assert options.max_turns == 1 + + +@pytest.mark.parametrize( + "fixed", + [ + "cli_path", + "setting_sources", + "strict_mcp_config", + "verbatim_prompts", + "permission_mode", + ], +) +def test_a_caller_cannot_override_isolation(fixed: str) -> None: + """Losing one of these would let the user's own Claude setup, or `auto` mode, in.""" + with pytest.raises(ValueError, match=fixed): + launch_options(BINARY, proxy_url=None, **{fixed: None}) + + +def test_a_key_in_the_environment_never_replaces_the_plan() -> None: + """Measured: with ANTHROPIC_API_KEY set, Claude Code bills the key, not the plan.""" + options = launch_options(BINARY, proxy_url=None, env={"ANTHROPIC_API_KEY": "sk-x"}) + + assert options.env["ANTHROPIC_API_KEY"] == "" + + +def test_claude_code_sends_nothing_but_model_calls() -> None: + """Without this, it also reports to an error-tracking host.""" + options = launch_options(BINARY, proxy_url=None) + + assert options.env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] == "1" + + +def test_its_traffic_goes_through_surfsenses_proxy() -> None: + """Loopback stays direct, so the agent reaches SurfSense's own MCP endpoint.""" + options = launch_options(BINARY, proxy_url="http://127.0.0.1:4711") + + assert options.env["HTTPS_PROXY"] == "http://127.0.0.1:4711" + assert options.env["HTTP_PROXY"] == "http://127.0.0.1:4711" + assert options.env["NO_PROXY"] == "127.0.0.1,localhost" + + +def test_a_caller_cannot_route_around_the_proxy() -> None: + """A run may add environment, never undo the proxy.""" + options = launch_options( + BINARY, proxy_url="http://127.0.0.1:4711", env={"HTTPS_PROXY": "", "TZ": "UTC"} + ) + + assert options.env["HTTPS_PROXY"] == "http://127.0.0.1:4711" + assert options.env["TZ"] == "UTC" diff --git a/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_locate.py b/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_locate.py new file mode 100644 index 0000000000..ddbe206a78 --- /dev/null +++ b/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_locate.py @@ -0,0 +1,149 @@ +import sys +from pathlib import Path + +import pytest + +from modules.llm.subscriptions.claude.locate import locate_claude + +pytestmark = pytest.mark.unit +posix_only = pytest.mark.skipif( + sys.platform == "win32", reason="Windows has no executable bit" +) + + +def _executable(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("#!/bin/sh\n") + path.chmod(0o755) + return path + + +def test_claude_on_path_is_found(tmp_path: Path) -> None: + """A Claude Code the PATH names is the one used.""" + on_path = _executable(tmp_path / "bin" / "claude") + + found = locate_claude( + path_env=str(on_path.parent), home=tmp_path / "home", windows=False + ) + + assert found == on_path + + +def test_native_install_is_found_when_path_lacks_it(tmp_path: Path) -> None: + """A GUI app's PATH misses ~/.local/bin, where Claude Code's installer links it.""" + home = tmp_path / "home" + installed = _executable(home / ".local" / "bin" / "claude") + + found = locate_claude(path_env="/usr/bin:/bin", home=home, windows=False) + + assert found == installed + + +@posix_only +def test_a_file_that_cannot_run_is_skipped(tmp_path: Path) -> None: + """A non-executable file at an install location is passed over for the next.""" + home = tmp_path / "home" + broken = home / ".local" / "bin" / "claude" + broken.parent.mkdir(parents=True) + broken.write_text("not a program") + older = _executable(home / ".claude" / "local" / "claude") + + found = locate_claude(path_env="/usr/bin:/bin", home=home, windows=False) + + assert found == older + + +@pytest.mark.skipif( + any( + Path(p).exists() for p in ("/opt/homebrew/bin/claude", "/usr/local/bin/claude") + ), + reason="this machine has a system-wide Claude Code", +) +def test_nothing_found_is_none(tmp_path: Path) -> None: + """No Claude Code anywhere is no binary, not an error.""" + assert ( + locate_claude(path_env="/usr/bin:/bin", home=tmp_path / "home", windows=False) + is None + ) + + +def test_windows_native_install_is_found_when_path_lacks_it(tmp_path: Path) -> None: + """The Windows installer puts claude.exe under %USERPROFILE%\\.local\\bin.""" + home = tmp_path / "home" + installed = _executable(home / ".local" / "bin" / "claude.exe") + + found = locate_claude(path_env="", home=home, windows=True, env={}) + + assert found == installed + + +def test_newest_nvm_node_is_preferred(tmp_path: Path) -> None: + """With several nvm Node versions, the newest one's global claude is used.""" + home = tmp_path / "home" + node = home / ".nvm" / "versions" / "node" + _executable(node / "v9.11.2" / "bin" / "claude") + newest = _executable(node / "v22.4.0" / "bin" / "claude") + + found = locate_claude(path_env="/usr/bin:/bin", home=home, windows=False) + + assert found == newest + + +def test_windows_npm_launcher_is_passed_over_for_its_executable( + tmp_path: Path, +) -> None: + """npm puts claude.cmd on PATH; the Agent SDK refuses it, so its claude.exe is used.""" + app_data = tmp_path / "Roaming" + launcher = _executable(app_data / "npm" / "claude.cmd") + real = _executable( + app_data + / "npm" + / "node_modules" + / "@anthropic-ai" + / "claude-code" + / "bin" + / "claude.exe" + ) + + found = locate_claude( + path_env=str(launcher.parent), + home=tmp_path / "home", + windows=True, + env={"APPDATA": str(app_data)}, + ) + + assert found == real + + +def test_windows_winget_link_is_found(tmp_path: Path) -> None: + """WinGet links the executable into its Links folder under %LOCALAPPDATA%.""" + local = tmp_path / "Local" + link = _executable(local / "Microsoft" / "WinGet" / "Links" / "claude.exe") + + found = locate_claude( + path_env="", + home=tmp_path / "home", + windows=True, + env={"LOCALAPPDATA": str(local)}, + ) + + assert found == link + + +def test_windows_nothing_found_is_none(tmp_path: Path) -> None: + """Windows has no system-wide location to search, so an empty home finds nothing.""" + assert ( + locate_claude(path_env="", home=tmp_path / "home", windows=True, env={}) is None + ) + + +def test_a_folder_that_is_not_a_node_version_is_tried_last(tmp_path: Path) -> None: + """nvm names each Node folder vX.Y.Z; anything else there ranks below them.""" + home = tmp_path / "home" + node = home / ".nvm" / "versions" / "node" + _executable(node / "system" / "bin" / "claude") + versioned = _executable(node / "v18.20.1" / "bin" / "claude") + + found = locate_claude(path_env="/usr/bin:/bin", home=home, windows=False) + + assert found == versioned diff --git a/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_sdk_install.py b/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_sdk_install.py new file mode 100644 index 0000000000..9f559253fe --- /dev/null +++ b/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_sdk_install.py @@ -0,0 +1,23 @@ +"""SurfSense ships no Claude Code (ADR 0057), and installs none either. + +The Agent SDK's wheels carry Claude Code in `claude_agent_sdk/_bundled`, 241 MB; +its source package does not. `no-binary-package` in pyproject.toml makes uv build +the SDK from source, so no venv, CI cache or frozen app holds that copy. Run on +every system in CI, this fails the day that line is lost. +""" + +from pathlib import Path + +import claude_agent_sdk +import pytest + +pytestmark = pytest.mark.unit + + +def test_the_installed_agent_sdk_carries_no_claude_code() -> None: + """Every run uses the user's own Claude Code, so the SDK needs none of its own.""" + bundled = Path(claude_agent_sdk.__file__).parent / "_bundled" + + shipped = [path.name for path in bundled.iterdir() if path.name != ".gitignore"] + + assert shipped == [], f"the Agent SDK was installed from a wheel: {shipped}" diff --git a/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_status.py b/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_status.py new file mode 100644 index 0000000000..fb3341764c --- /dev/null +++ b/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_status.py @@ -0,0 +1,90 @@ +import json +from pathlib import Path + +import pytest + +from modules.llm.subscriptions.claude.status import ClaudeState, read_status +from tests.claude_stand_in import SIGNED_IN, SIGNED_OUT, claude_stand_in + +pytestmark = pytest.mark.unit + + +def test_no_binary_is_not_installed() -> None: + """Nothing found is the not-installed state the dialog explains.""" + assert read_status(None).state is ClaudeState.NOT_INSTALLED + + +def test_a_signed_in_plan_is_ready_with_its_account(tmp_path: Path) -> None: + """A claude.ai login is ready, with the account the dialog names.""" + status = read_status(claude_stand_in(tmp_path)) + + assert status.state is ClaudeState.READY + assert status.email == "ada@example.com" + assert status.plan == "max" + assert status.version == "2.1.301" + + +def test_not_signed_in_is_signed_out(tmp_path: Path) -> None: + """An installed Claude Code with no login asks the user to sign in.""" + status = read_status(claude_stand_in(tmp_path, auth=SIGNED_OUT)) + + assert status.state is ClaudeState.SIGNED_OUT + + +def test_a_console_login_is_not_a_plan(tmp_path: Path) -> None: + """Only a claude.ai login carries a plan; a Console login bills an API account.""" + console = {**SIGNED_IN, "authMethod": "console", "subscriptionType": None} + + status = read_status(claude_stand_in(tmp_path, auth=console)) + + assert status.state is ClaudeState.SIGNED_OUT + + +def test_an_old_claude_code_is_too_old(tmp_path: Path) -> None: + """A version below the oldest one measured asks the user to update.""" + status = read_status(claude_stand_in(tmp_path, version="2.0.12 (Claude Code)")) + + assert status.state is ClaudeState.TOO_OLD + assert status.version == "2.0.12" + + +def test_the_oldest_tested_version_is_accepted(tmp_path: Path) -> None: + """The minimum itself is new enough.""" + status = read_status(claude_stand_in(tmp_path, version="2.1.300 (Claude Code)")) + + assert status.state is ClaudeState.READY + + +def test_an_unreadable_sign_in_answer_is_signed_out(tmp_path: Path) -> None: + """A sign-in answer that is not JSON cannot show a login, so asks for one.""" + status = read_status(claude_stand_in(tmp_path, auth="Error: keychain locked")) + + assert status.state is ClaudeState.SIGNED_OUT + + +def test_an_unreadable_version_is_too_old(tmp_path: Path) -> None: + """A version that cannot be read cannot be shown to be new enough.""" + status = read_status(claude_stand_in(tmp_path, version="claude: unknown")) + + assert status.state is ClaudeState.TOO_OLD + + +def test_a_claude_code_that_hangs_is_too_old(tmp_path: Path) -> None: + """It never says its version, so it cannot be shown to be new enough.""" + status = read_status(claude_stand_in(tmp_path, hang_seconds=3), timeout=0.5) + + assert status.state is ClaudeState.TOO_OLD + + +def test_the_status_check_runs_through_surfsenses_proxy(tmp_path: Path) -> None: + """It contacts nothing today; were a later Claude Code to, egress still holds.""" + env_log = tmp_path / "env.json" + + read_status( + claude_stand_in(tmp_path, env_log=env_log), proxy_url="http://127.0.0.1:9" + ) + + started_with = json.loads(env_log.read_text()) + assert started_with["HTTPS_PROXY"] == "http://127.0.0.1:9" + assert started_with["ANTHROPIC_API_KEY"] == "" + assert started_with["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] == "1" diff --git a/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_tested_version.py b/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_tested_version.py new file mode 100644 index 0000000000..eb59c05d65 --- /dev/null +++ b/surfsense_local/backend/tests/unit/llm/subscriptions/claude/test_tested_version.py @@ -0,0 +1,47 @@ +import logging +from pathlib import Path + +import pytest + +from modules.llm.subscriptions.claude.tested_version import note_untested +from tests.claude_stand_in import claude_stand_in + +pytestmark = pytest.mark.unit + + +def _warnings(caplog: pytest.LogCaptureFixture) -> list[str]: + return [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] + + +def test_a_claude_code_newer_than_any_tested_is_named_in_the_log( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An update may break what SurfSense measured; the log says where to look.""" + binary = claude_stand_in(tmp_path, version="2.2.0 (Claude Code)") + + note_untested(binary) + + (warning,) = _warnings(caplog) + assert "2.2.0" in warning + assert "2.1.301" in warning + + +def test_a_tested_claude_code_is_not_named( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """The version the docs were measured on needs no warning.""" + note_untested(claude_stand_in(tmp_path, version="2.1.301 (Claude Code)")) + + assert _warnings(caplog) == [] + + +def test_each_untested_binary_is_named_once( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Every run checks; the log does not repeat itself for each one.""" + binary = claude_stand_in(tmp_path, version="2.3.1 (Claude Code)") + + note_untested(binary) + note_untested(binary) + + assert len(_warnings(caplog)) == 1 diff --git a/surfsense_local/backend/uv.lock b/surfsense_local/backend/uv.lock index 25008cf00a..5bdb0cd354 100644 --- a/surfsense_local/backend/uv.lock +++ b/surfsense_local/backend/uv.lock @@ -383,6 +383,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/93/e3167b1823f919f1a4c517092f73dc4107737de8bb53c67ff60c964b5244/chonkie_core-0.10.2-cp314-cp314-win_amd64.whl", hash = "sha256:20c9d64b9c5169d1f7e5ceeaa22bff3613017b8937c224ba85e3e24748fb0ba3", size = 228655, upload-time = "2026-05-28T19:16:11.357Z" }, ] +[[package]] +name = "claude-agent-sdk" +version = "0.2.168" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "jsonschema" }, + { name = "mcp" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/ee/31e159daf956f0c5503eac67d02743da692c0999fef046cdde3cc52702b9/claude_agent_sdk-0.2.168.tar.gz", hash = "sha256:80388054a09c137d76fc29caa6e46bc90d2ae2ef09fac5ee1c38fc36d2762fda", size = 371591, upload-time = "2026-10-15T20:20:43.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/31/9a205fc0b7ae1b66d5964774bb534ab378047c9bf87578f443bd1fb2205e/claude_agent_sdk-0.2.168-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2ef1e7fc442c5abdc1e698027bf1b5faa8ea2130134a14364e2ae3c53bf31fdd", size = 104355641, upload-time = "2026-10-15T20:20:48.105Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ba/12bbe92d5e9145c7aaa94aa349a0859b77a9b58ccc40ae9e69cf7f078e5b/claude_agent_sdk-0.2.168-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:653fc7bbe56630e6ddd9601a7cfe50586667e74102da3fb90571456c63655a62", size = 109438860, upload-time = "2026-10-15T20:20:52.274Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/26e587846f14f67f3353f96811b86931263a9c7e264d17a691d9577e4da5/claude_agent_sdk-0.2.168-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:77ae66b10edfd07b6ea3dec2af106ad47a9d616f8eb52d4f9b385e1b00bb2a27", size = 114030032, upload-time = "2026-10-15T20:20:56.813Z" }, + { url = "https://files.pythonhosted.org/packages/00/03/09d347dcddbbab4becd41fef3a811967eddf24135b2d62dac9e321c81537/claude_agent_sdk-0.2.168-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:be197be92549909ec62a2cee04c27f4b0a38c80f723735538a3efc352507f81d", size = 114443626, upload-time = "2026-10-15T20:21:01.794Z" }, + { url = "https://files.pythonhosted.org/packages/48/93/520a18ab853b688afcc31d504344e541a7dc68bc2b515ea8a5fef1c868a1/claude_agent_sdk-0.2.168-py3-none-win_amd64.whl", hash = "sha256:ed1814b0dd1c113bdaf3e51e6a67aa5b96b8f5c5839a43aeb6ae1e6ed23a5b35", size = 117292475, upload-time = "2026-10-15T20:21:07.053Z" }, +] + [[package]] name = "click" version = "8.5.0" @@ -3077,6 +3096,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "soupsieve" version = "2.9.2" @@ -3166,6 +3194,7 @@ source = { editable = "." } dependencies = [ { name = "alembic" }, { name = "chonkie" }, + { name = "claude-agent-sdk" }, { name = "cryptography" }, { name = "docling" }, { name = "fastapi" }, @@ -3213,6 +3242,7 @@ dev = [ requires-dist = [ { name = "alembic", specifier = ">=1.13,<1.19" }, { name = "chonkie", specifier = ">=1.7.0" }, + { name = "claude-agent-sdk", specifier = "==0.2.168" }, { name = "cryptography", specifier = ">=43" }, { name = "docling", specifier = ">=2.125.0" }, { name = "fastapi", specifier = ">=0.115.8" }, diff --git a/surfsense_local/electron/src/main/external-url.test.ts b/surfsense_local/electron/src/main/external-url.test.ts index b2bbde18c3..f9ee092b4c 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 2c6fa29f2d..5ea0d263a2 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/app/app-bootstrap.test.tsx b/surfsense_local/frontend/src/app/app-bootstrap.test.tsx index 019906524d..80abe1257e 100644 --- a/surfsense_local/frontend/src/app/app-bootstrap.test.tsx +++ b/surfsense_local/frontend/src/app/app-bootstrap.test.tsx @@ -299,12 +299,17 @@ describe("app bootstrap", () => { { status: 403 } ), (path, init) => { - if (path === "/egress/host:openrouter.ai" && init?.method === "PUT") { + if ( + path === "/egress" && + init?.method === "PUT" && + JSON.parse(String(init.body)).destinations.includes( + "host:openrouter.ai" + ) + ) { allowed = true - return Response.json({ - destination: "host:openrouter.ai", - enabled: true, - }) + return Response.json([ + { destination: "host:openrouter.ai", enabled: true }, + ]) } return null } diff --git a/surfsense_local/frontend/src/components/ui/icons.tsx b/surfsense_local/frontend/src/components/ui/icons.tsx index 169b658eed..2be6bddd14 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 66931cc7db..ac7397aefe 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,24 @@ 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.", + }), + egress_disabled: () => + intl.formatMessage({ + id: "chat_error_egress_disabled", + defaultMessage: + "A host this model needs was turned off in Settings › Network. Allow it there, then try again.", + }), network: () => intl.formatMessage({ id: "chat_error_network", diff --git a/surfsense_local/frontend/src/features/chat/claude-code-refusal.ts b/surfsense_local/frontend/src/features/chat/claude-code-refusal.ts new file mode 100644 index 0000000000..8ef8954742 --- /dev/null +++ b/surfsense_local/frontend/src/features/chat/claude-code-refusal.ts @@ -0,0 +1,17 @@ +import { ApiError } from "@/lib/api" + +// What the chat model's check also reports, so the composer says it, not a reply. +const CLAUDE_CODE_REFUSALS = new Set([ + "claude_too_old", + "claude_sign_in_required", +]) + +/** A send refused before it ran because the user's Claude Code cannot answer. */ +export function isClaudeCodeRefusal(cause: unknown): boolean { + return ( + cause instanceof ApiError && + cause.status === 409 && + cause.code !== null && + CLAUDE_CODE_REFUSALS.has(cause.code) + ) +} diff --git a/surfsense_local/frontend/src/features/chat/extra-usage/extra-usage-notice.tsx b/surfsense_local/frontend/src/features/chat/extra-usage/extra-usage-notice.tsx new file mode 100644 index 0000000000..b5c88e9bf6 --- /dev/null +++ b/surfsense_local/frontend/src/features/chat/extra-usage/extra-usage-notice.tsx @@ -0,0 +1,34 @@ +import { CircleAlertIcon } from "@/components/ui/icons" +import { intl } from "@/i18n/intl" + +import type { ExtraUsage } from "./extra-usage-store" + +function said({ resetsAt }: ExtraUsage): string { + return resetsAt === null + ? intl.formatMessage({ + id: "chat_composer_extra_usage_body", + defaultMessage: + "Your plan’s limit is reached, so replies use extra usage.", + }) + : intl.formatMessage( + { + id: "chat_composer_extra_usage_until_body", + defaultMessage: + "Your plan’s limit is reached, so replies use extra usage until {resetsAt, date, ::EEEjmm}.", + }, + { resetsAt } + ) +} + +/** Before the next message, since each reply now costs apart from the plan. */ +export function ExtraUsageNotice({ usage }: { usage: ExtraUsage }) { + return ( +
+ +

{said(usage)}

+
+ ) +} diff --git a/surfsense_local/frontend/src/features/chat/extra-usage/extra-usage-store.test.ts b/surfsense_local/frontend/src/features/chat/extra-usage/extra-usage-store.test.ts new file mode 100644 index 0000000000..139755506a --- /dev/null +++ b/surfsense_local/frontend/src/features/chat/extra-usage/extra-usage-store.test.ts @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { extraUsage, notePlanUsage } from "./extra-usage-store" + +// 17 Oct 2026, 14:00 UTC, in Unix seconds. +const RESETS_AT = 1792245600 + +describe("extra usage", () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-10-17T12:00:00Z")) + }) + afterEach(() => { + notePlanUsage({ using: false, resets_at: null }) + vi.useRealTimers() + }) + + it("holds until when the plan resets, once a reply runs on extra usage", () => { + notePlanUsage({ using: true, resets_at: RESETS_AT }) + + expect(extraUsage()?.resetsAt).toEqual(new Date("2026-10-17T14:00:00Z")) + }) + + it("ends once a reply comes back within the plan", () => { + notePlanUsage({ using: true, resets_at: RESETS_AT }) + + notePlanUsage({ using: false, resets_at: RESETS_AT }) + + expect(extraUsage()).toBeNull() + }) + + it("ends on its own when the plan resets", () => { + notePlanUsage({ using: true, resets_at: RESETS_AT }) + + vi.advanceTimersByTime(2 * 60 * 60 * 1000 + 1) + + expect(extraUsage()).toBeNull() + }) + + it("holds with no end when the plan does not say when it resets", () => { + notePlanUsage({ using: true, resets_at: null }) + + expect(extraUsage()).toEqual({ resetsAt: null }) + }) +}) diff --git a/surfsense_local/frontend/src/features/chat/extra-usage/extra-usage-store.ts b/surfsense_local/frontend/src/features/chat/extra-usage/extra-usage-store.ts new file mode 100644 index 0000000000..d4fe598755 --- /dev/null +++ b/surfsense_local/frontend/src/features/chat/extra-usage/extra-usage-store.ts @@ -0,0 +1,63 @@ +import { useEffect, useSyncExternalStore } from "react" + +import { onRunFrame } from "../runs/run-store" + +/** The plan answers past its limit, billed as extra usage, until `resetsAt`. */ +export type ExtraUsage = { resetsAt: Date | null } + +// The plan's, not a thread's: the last reply anywhere says where it stands. +let current: ExtraUsage | null = null +let resetTimer: ReturnType | null = null +const listeners = new Set<() => void>() + +function set(next: ExtraUsage | null) { + current = next + for (const listener of listeners) listener() +} + +/** What a reply's `plan-extra-usage` frame says. */ +export function notePlanUsage({ + using, + resets_at, +}: { + using: boolean + resets_at: number | null +}) { + if (resetTimer !== null) clearTimeout(resetTimer) + resetTimer = null + if (!using) { + set(null) + return + } + const resetsAt = resets_at === null ? null : new Date(resets_at * 1000) + if (resetsAt !== null) { + const left = resetsAt.getTime() - Date.now() + if (left <= 0) { + set(null) + return + } + resetTimer = setTimeout(() => set(null), left) + } + set({ resetsAt }) +} + +export function extraUsage(): ExtraUsage | null { + return current +} + +function subscribe(listener: () => void) { + listeners.add(listener) + return () => listeners.delete(listener) +} + +/** Whether the plan is on extra usage now, from every run's frames. */ +export function useExtraUsage(): ExtraUsage | null { + useEffect( + () => + onRunFrame((_threadId, event) => { + if (event.type === "plan-extra-usage") notePlanUsage(event) + }), + [] + ) + return useSyncExternalStore(subscribe, extraUsage) +} diff --git a/surfsense_local/frontend/src/features/chat/extra-usage/use-selected-on-claude-plan.ts b/surfsense_local/frontend/src/features/chat/extra-usage/use-selected-on-claude-plan.ts new file mode 100644 index 0000000000..ca36570b3a --- /dev/null +++ b/surfsense_local/frontend/src/features/chat/extra-usage/use-selected-on-claude-plan.ts @@ -0,0 +1,15 @@ +import { useConnections } from "@/features/models/remote/connections/use-connections" +import type { ModelSelection } from "@/features/models/selection/api" + +/** Whether the chat model now selected is answered by the Claude plan, whose extra usage the notice is about. */ +export function useSelectedOnClaudePlan(selection: ModelSelection | null) { + const { data: connections } = useConnections() + return ( + selection?.connection_id != null && + connections?.some( + (connection) => + connection.id === selection.connection_id && + connection.auth_kind === "claude_plan" + ) === true + ) +} diff --git a/surfsense_local/frontend/src/features/chat/model-issue-notice.test.tsx b/surfsense_local/frontend/src/features/chat/model-issue-notice.test.tsx new file mode 100644 index 0000000000..0389ee1b59 --- /dev/null +++ b/surfsense_local/frontend/src/features/chat/model-issue-notice.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { ModelIssueNotice } from "./model-issue-notice" + +describe("ModelIssueNotice", () => { + it("says a Claude Code too old for the plan has to be updated", () => { + render( + + ) + + expect(screen.getByRole("status").textContent).toContain( + "Claude Code is too old. Update it." + ) + }) +}) 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 4a0eb785dc..dfc4948246 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,24 @@ 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 "claude_too_old": + return intl.formatMessage( + { + id: "chat_model_issue_claude_too_old_body", + defaultMessage: + "Couldn’t use {model}: Claude Code is too old. Update it.", + }, + { 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 0465bb5b47..297269a9fd 100644 --- a/surfsense_local/frontend/src/features/chat/sse.ts +++ b/surfsense_local/frontend/src/features/chat/sse.ts @@ -43,6 +43,9 @@ export type ChatStreamEvent = | { type: "agent-scope"; scope: TurnSources } // Connected plugins the turn goes on without. | { type: "agent-plugins-unavailable"; plugins: UnavailablePlugin[] } + // Where the plan answering stands after a reply: past its limit on extra + // usage, or not, until `resets_at` (Unix seconds). + | { type: "plan-extra-usage"; using: boolean; resets_at: number | null } | ({ type: "agent-step" } & AgentStep) | ({ type: "permission-request" } & PermissionRequest) | { type: "permission-replied"; id: string; reply: string } @@ -65,6 +68,9 @@ export type ChatErrorKind = | "context_too_long" | "subscription_sign_in" | "subscription_limit" + | "claude_sign_in" + | "claude_limit" + | "egress_disabled" | "runtime_busy" | "network" | "timeout" diff --git a/surfsense_local/frontend/src/features/chat/thread-panel.tsx b/surfsense_local/frontend/src/features/chat/thread-panel.tsx index 083fe7cc73..a040a2d6d8 100644 --- a/surfsense_local/frontend/src/features/chat/thread-panel.tsx +++ b/surfsense_local/frontend/src/features/chat/thread-panel.tsx @@ -125,7 +125,7 @@ function ComposerDraftLifecycle({ const aui = useAui() const conversationId = view.status === "active" ? `thread:${view.threadId}` : view.status - const returned = view.status === "new" ? view.returned : undefined + const returned = view.status === "creating" ? undefined : view.returned // Voice input outlives the composer, so it is cancelled, never stopped: no // late phrase lands in the thread opened next. diff --git a/surfsense_local/frontend/src/features/chat/use-chat-runtime.test.tsx b/surfsense_local/frontend/src/features/chat/use-chat-runtime.test.tsx index e87b0fd752..838d89a243 100644 --- a/surfsense_local/frontend/src/features/chat/use-chat-runtime.test.tsx +++ b/surfsense_local/frontend/src/features/chat/use-chat-runtime.test.tsx @@ -95,6 +95,8 @@ class FakeApi { // Each new chat's request, and a refusal to answer it with instead. creates: Array> = [] refuseCreate: { status: number; code: string } | null = null + // A refusal to answer the next send with, before any frame. + refuseSend: { status: number; code: string } | null = null sendStreams: Stream[] = [] stops: number[] = [] follows: number[] = [] @@ -137,8 +139,15 @@ class FakeApi { this.nextReads[threadId]?.shift() ?? this.messages[threadId] ?? [] ) } - const stream = new Stream() this.sends.push({ threadId, body: JSON.parse(String(init?.body)) }) + if (this.refuseSend) { + const { status, code } = this.refuseSend + return Response.json( + { detail: { code, message: "refused" } }, + { status } + ) + } + const stream = new Stream() this.sendStreams.push(stream) init?.signal?.addEventListener("abort", () => stream.hangUp()) return new Response(stream.body, { @@ -202,6 +211,7 @@ function renderRuntime({ workspaceId = WORKSPACE, // The app's one client, kept across a switch of workspace. client = createQueryClient(), + onModelCheck = vi.fn(), } = {}) { const result = { current: undefined as unknown as Rendered } const renders = { page: 0, thread: 0 } @@ -224,6 +234,7 @@ function renderRuntime({ canSkipThinking: false, newChatMode, onModelRequired: vi.fn(), + onModelCheck, }) renders.page += 1 return ( @@ -1032,4 +1043,24 @@ describe("a new chat's mode", () => { returned: { text: "Draft the board pack", images: [] }, }) }) + + it.each(["claude_too_old", "claude_sign_in_required"])( + "hands back a send Claude Code was refused for, with no reply, and asks the model check again (%s)", + async (code) => { + api.refuseSend = { status: 409, code } + const onModelCheck = vi.fn() + const { result } = renderRuntime({ onModelCheck }) + await openThread(result, 1) + + sendIn(result, "What about Q4?") + + await waitFor(() => expect(onModelCheck).toHaveBeenCalledTimes(1)) + expect(result.current.conversationView).toEqual({ + status: "active", + threadId: 1, + returned: { text: "What about Q4?", images: [] }, + }) + expect(result.current.thread.getState().messages).toEqual([]) + } + ) }) diff --git a/surfsense_local/frontend/src/features/chat/use-chat-runtime.ts b/surfsense_local/frontend/src/features/chat/use-chat-runtime.ts index b690cc46c3..bd16d5ae85 100644 --- a/surfsense_local/frontend/src/features/chat/use-chat-runtime.ts +++ b/surfsense_local/frontend/src/features/chat/use-chat-runtime.ts @@ -41,6 +41,7 @@ import { ChatImageAdapter, previewOf, uploadsOf } from "./image-attachments" import { AGENTIC_REFUSALS, agenticRefusedText } from "./modes/mode-text" import { AGENTIC_GATES, type NewChatChoice } from "./modes/new-chat-mode" import type { LiveThreadSource } from "./live-thread-runtime" +import { isClaudeCodeRefusal } from "./claude-code-refusal" import { chatKeys } from "./query-keys" import type { LivePair } from "./runs/apply-frame" import { @@ -135,10 +136,10 @@ function lastThreadKey(workspaceId: number) { const NEW_CHAT = "new" export type ConversationView = - // `returned` is what a refused new chat hands back to the composer. + // `returned` is what a refused send hands back to the composer. | { status: "new"; returned?: ReturnedDraft } | { status: "creating" } - | { status: "active"; threadId: number } + | { status: "active"; threadId: number; returned?: ReturnedDraft } export type ReturnedDraft = { text: string; images: ImageUpload[] } @@ -227,6 +228,7 @@ export function useChatRuntime({ newChatMode = null, onAgenticGate, onModelRequired, + onModelCheck, }: { workspaceId: number canSend: boolean @@ -245,6 +247,8 @@ export function useChatRuntime({ // A gate the API refused an Agentic chat by, which the model's modes did not say. onAgenticGate?: (gate: string) => void onModelRequired: () => void + // A send was refused for a reason the chat model's check reports: check again. + onModelCheck?: () => void }) { const queryClient = useQueryClient() const [conversationView, setConversationView] = useState( @@ -724,6 +728,17 @@ export function useChatRuntime({ ) { dropRun(runThreadId) onModelRequired() + } else if (isClaudeCodeRefusal(cause)) { + // Nothing ran: the question goes back, and the composer says why. + dropRun(runThreadId) + if (requestVersion.current === version) { + setConversationView({ + status: "active", + threadId: runThreadId, + returned: { text: typed, images }, + }) + } + onModelCheck?.() } else if (isOutdatedThreadRefusal(cause)) { updatePair(runThreadId, ([user, assistant]) => [ user, @@ -786,6 +801,7 @@ export function useChatRuntime({ isRunning, newChatMode, onAgenticGate, + onModelCheck, onModelRequired, queryClient, selectedDocumentIds, diff --git a/surfsense_local/frontend/src/features/dashboard/dashboard-page.tsx b/surfsense_local/frontend/src/features/dashboard/dashboard-page.tsx index 49b4c35fe8..89f58d9480 100644 --- a/surfsense_local/frontend/src/features/dashboard/dashboard-page.tsx +++ b/surfsense_local/frontend/src/features/dashboard/dashboard-page.tsx @@ -20,7 +20,10 @@ import { import { CitationPanel } from "@/features/chat/citation-panel" import { consentPlaceholder } from "@/features/chat/model-issue" import { askEgress } from "@/features/egress/ask-egress" -import { setDestinationEnabled } from "@/features/egress/api" +import { setDestinationsEnabled } from "@/features/egress/api" +import { ExtraUsageNotice } from "@/features/chat/extra-usage/extra-usage-notice" +import { useExtraUsage } from "@/features/chat/extra-usage/extra-usage-store" +import { useSelectedOnClaudePlan } from "@/features/chat/extra-usage/use-selected-on-claude-plan" import { ModelIssueNotice } from "@/features/chat/model-issue-notice" import { useBlockAgentic, @@ -100,6 +103,7 @@ function WorkspaceDashboard({ onModelIssueSettings, onAllowModelIssue, onModelRequired, + onModelCheck, onModelSelected, onOpenLicense, onOpenAudioSettings, @@ -112,6 +116,7 @@ function WorkspaceDashboard({ selection: ModelSelection | null providerAvailable: boolean modelIssue: ModelIssue | null + onModelCheck: () => void needsConsent: boolean onModelIssueSettings: () => void onAllowModelIssue: () => void @@ -163,6 +168,7 @@ function WorkspaceDashboard({ newChatMode: useNewChatChoice(selection), onAgenticGate, onModelRequired, + onModelCheck, }) const sourcePreview = sources.documents.find( (document) => document.id === sourcePreviewId @@ -184,6 +190,9 @@ function WorkspaceDashboard({ const composerHold = modelIssue && needsConsent ? consentPlaceholder(modelIssue) : undefined + // A model issue blocks sending, so it takes the composer's one notice first. + const extraUsage = useExtraUsage() + const onClaudePlan = useSelectedOnClaudePlan(selection) const closeInspect = () => setInspect(null) const startNewChat = () => { @@ -485,6 +494,8 @@ function WorkspaceDashboard({ onAllow={needsConsent ? onAllowModelIssue : undefined} onOpenSettings={onModelIssueSettings} /> + ) : extraUsage && onClaudePlan ? ( + ) : null } blockedPlaceholder={composerHold} @@ -675,6 +686,8 @@ export function DashboardPage({ useState("general") // Bumped after egress is allowed from the notice, to check the model again. const [consents, setConsents] = useState(0) + // Bumped when a send is refused for a reason the check reports, so it says why. + const [refusedSends, setRefusedSends] = useState(0) // Set while Settings opens on Office support's consent, from a thread's offer. const [askOfficeConsent, setAskOfficeConsent] = useState(false) @@ -703,7 +716,8 @@ export function DashboardPage({ ) // Checked when the model changes, when settings close (a key entered again, - // egress switched, a connection edited) and after egress is allowed. + // egress switched, a connection edited), after egress is allowed, and after a + // send was refused for Claude Code. useEffect(() => { if (!selection) return if (skipFirstCheck.current) { @@ -718,7 +732,7 @@ export function DashboardPage({ } ) return () => controller.abort() - }, [selection, modelsVisited, consents]) + }, [selection, modelsVisited, consents, refusedSends]) const availability: Availability = selection && checked.key === modelKey(selection) @@ -740,7 +754,7 @@ export function DashboardPage({ void askEgress({ destination, host: issue.host ?? "", - allow: () => setDestinationEnabled(destination, true), + allow: () => setDestinationsEnabled([destination], true), }).then((allowed) => { if (allowed) setConsents((count) => count + 1) }) @@ -791,6 +805,7 @@ export function DashboardPage({ ) } onModelRequired={() => openSettings("chat-models")} + onModelCheck={() => setRefusedSends((count) => count + 1)} onModelSelected={onModelSelected} onOpenLicense={() => openSettings("license")} onOpenAudioSettings={() => openSettings("audio-models")} diff --git a/surfsense_local/frontend/src/features/egress/api.ts b/surfsense_local/frontend/src/features/egress/api.ts index 22d0899ce5..def4cb69b3 100644 --- a/surfsense_local/frontend/src/features/egress/api.ts +++ b/surfsense_local/frontend/src/features/egress/api.ts @@ -8,6 +8,17 @@ export type Destination = { last_call_at: string | null } +/** + * Switch on a question's destination and every other host it named, in one + * request the API commits whole: a failed Allow switches none of them on. + */ +export function allowAll(destination: string, also: string[] = []) { + return setDestinationsEnabled( + [destination, ...also.map((host) => `host:${host}`)], + true + ) +} + /** Search, model weights and image weights all reach this one host. */ export const HUGGINGFACE = "host:huggingface.co" @@ -17,22 +28,105 @@ export function listDestinations(signal?: AbortSignal): Promise { return requestJson("/egress", { signal }) } -export function setDestinationEnabled( - destination: string, +/** Switch these destinations on or off together; one row in Settings › Network is a list of one. */ +export function setDestinationsEnabled( + destinations: string[], enabled: boolean -): Promise { - return requestJson(`/egress/${destination}`, { +): Promise { + return requestJson("/egress", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enabled }), + body: JSON.stringify({ destinations, enabled }), }) } +// 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 your prompts and document excerpts.", + }, + { host: CLAUDE_API_HOST } + ) + ) + if (hosts.includes(CLAUDE_SIGN_IN_HOST)) + details.push( + intl.formatMessage( + { + id: "egress_claude_sign_in_host_body", + defaultMessage: "{host} only refreshes Claude Code’s sign-in.", + }, + { 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 reaches only the hosts you allow, through SurfSense. Turn each 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 379dc15462..da4369b619 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 48cffb57b6..25af22f602 100644 --- a/surfsense_local/frontend/src/features/egress/egress-prompt.test.tsx +++ b/surfsense_local/frontend/src/features/egress/egress-prompt.test.tsx @@ -27,9 +27,9 @@ function stubApi() { vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const path = String(input) calls.push(`${init?.method ?? "GET"} ${path}`) - if (path.startsWith("/egress/")) { + if (path === "/egress" && init?.method === "PUT") { allowed = true - return Response.json({}) + return Response.json([]) } return allowed ? Response.json({}) @@ -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 === "/egress" && init?.method === "PUT") { + calls[calls.length - 1] += + ` ${JSON.parse(String(init.body)).destinations.join(",")}` + allowed = true + 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 your prompts") + expect(prompt.textContent).toContain( + "platform.claude.com only refreshes Claude Code’s sign-in" + ) + await user.click(screen.getByRole("button", { name: "Allow" })) + + await expect(pending).resolves.toBeUndefined() + expect(calls).toEqual([ + "POST /llm/connections/claude", + // One request: Allow switches both on together, or neither. + "PUT /egress host:api.anthropic.com,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() @@ -59,7 +150,7 @@ describe("egress prompt", () => { await expect(pending).resolves.toBeUndefined() expect(calls).toEqual([ "POST /chat/threads/1/messages", - "PUT /egress/host:api.provider.example", + "PUT /egress", "POST /chat/threads/1/messages", ]) await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull()) diff --git a/surfsense_local/frontend/src/features/egress/egress-prompt.tsx b/surfsense_local/frontend/src/features/egress/egress-prompt.tsx index a90c067da5..58a2a1e038 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/egress/network-settings.tsx b/surfsense_local/frontend/src/features/egress/network-settings.tsx index d9c4a53fee..cc69a19fb5 100644 --- a/surfsense_local/frontend/src/features/egress/network-settings.tsx +++ b/surfsense_local/frontend/src/features/egress/network-settings.tsx @@ -14,7 +14,7 @@ import { describeDestination, destinationsQueryKey, listDestinations, - setDestinationEnabled, + setDestinationsEnabled, } from "./api" function DestinationRow({ @@ -108,7 +108,7 @@ export function NetworkSettings() { }: { destination: string enabled: boolean - }) => setDestinationEnabled(destination, enabled), + }) => setDestinationsEnabled([destination], enabled), onSettled: () => queryClient.invalidateQueries({ queryKey: destinationsQueryKey }), }) 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 6097df42bf..0a681aa227 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/local/chat/download-chat-models.test.tsx b/surfsense_local/frontend/src/features/models/local/chat/download-chat-models.test.tsx index 23b7b4d26a..20097e0a1f 100644 --- a/surfsense_local/frontend/src/features/models/local/chat/download-chat-models.test.tsx +++ b/surfsense_local/frontend/src/features/models/local/chat/download-chat-models.test.tsx @@ -581,7 +581,13 @@ describe("model catalog", () => { vi.stubGlobal( "fetch", serving(catalog(), (path, init) => { - calls.push(`${init?.method ?? "GET"} ${path}`) + const put = init?.method === "PUT" + calls.push( + put && path === "/egress" + ? `PUT /egress ${JSON.parse(String(init?.body)).destinations.join(",")}` + : `${init?.method ?? "GET"} ${path}` + ) + if (path === "/egress" && put) return Response.json([]) if (path === "/egress") { return Response.json([ { @@ -592,7 +598,6 @@ describe("model catalog", () => { }, ]) } - if (path.startsWith("/egress/")) return Response.json({}) return null }) ) @@ -612,7 +617,7 @@ describe("model catalog", () => { await user.click(screen.getByRole("button", { name: "Allow" })) await waitFor(() => - expect(calls).toContain("PUT /egress/host:huggingface.co") + expect(calls).toContain("PUT /egress host:huggingface.co") ) }) @@ -625,9 +630,9 @@ describe("model catalog", () => { }) vi.stubGlobal( "fetch", - serving(catalog(), (path) => { - if (path === "/egress") return null - if (path.startsWith("/egress/")) return Response.json({}) + serving(catalog(), (path, init) => { + if (path === "/egress" && init?.method === "PUT") + return Response.json([]) return null }) ) diff --git a/surfsense_local/frontend/src/features/models/local/chat/model-search.tsx b/surfsense_local/frontend/src/features/models/local/chat/model-search.tsx index b8f61e305c..59da96261b 100644 --- a/surfsense_local/frontend/src/features/models/local/chat/model-search.tsx +++ b/surfsense_local/frontend/src/features/models/local/chat/model-search.tsx @@ -17,7 +17,7 @@ import { HUGGINGFACE, destinationsQueryKey, listDestinations, - setDestinationEnabled, + setDestinationsEnabled, } from "@/features/egress/api" import { askEgress } from "@/features/egress/ask-egress" import { intl } from "@/i18n/intl" @@ -275,7 +275,7 @@ export function ModelSearch({ void askEgress({ destination: huggingface.destination, host: huggingface.co, - allow: () => setDestinationEnabled(huggingface.destination, true), + allow: () => setDestinationsEnabled([huggingface.destination], true), }) }, [reached, huggingface]) 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 69bb68ee1b..d5a4473aff 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 ceebf01117..b22c7c7de9 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 0000000000..f5cbb06962 --- /dev/null +++ b/surfsense_local/frontend/src/features/models/remote/connections/claude/api.ts @@ -0,0 +1,78 @@ +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 } + +/** Whether a slot offers a Claude plan: answered at once, without running Claude Code. */ +export type ClaudeOption = { + serves: ModelType[] + /** The hosts a Claude plan reaches that are still off, model calls first. */ + hosts: RefusedHost[] +} + +export type ClaudeStatus = { + state: ClaudeState + version: string | null + email: string | null + /** As Claude Code reports it: `pro`, `max`, `team`. */ + plan: string | null +} + +/** 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 }) +} + +export const claudeStatusQueryKey = ["claude-plan-status"] as const + +/** Runs the user's Claude Code, so only the dialog asks. */ +export function getClaudeStatus(signal?: AbortSignal): Promise { + return requestJson("/llm/connections/claude/status", { 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 0000000000..1579b9ceed --- /dev/null +++ b/surfsense_local/frontend/src/features/models/remote/connections/claude/claude-dialog.tsx @@ -0,0 +1,257 @@ +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, + claudeStatusQueryKey, + connectClaude, + getClaudeStatus, + type ClaudeStatus, +} 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: ClaudeStatus }) { + 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. +
  5. + {intl.formatMessage({ + id: "models_claude_dialog_come_back_body", + defaultMessage: "Then come back here and click Check again.", + })} +
  6. +
+ ) + 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, Agentic mode, 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: claudeStatusQueryKey, + queryFn: ({ signal }) => getClaudeStatus(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 0000000000..66b167067a --- /dev/null +++ b/surfsense_local/frontend/src/features/models/remote/connections/claude/claude-option.test.tsx @@ -0,0 +1,212 @@ +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, type ClaudeStatus } from "./api" +import { ClaudeOption as ClaudeRow } from "./claude-option" + +type Option = ClaudeOption & ClaudeStatus + +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 row and dialog see it: each status read takes the next of `states`. */ +function serve( + states: Partial