From bcb9ef6eb99d214bcf7da8dbeac1e0ed8484ca97 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Mon, 24 Aug 2026 19:44:40 +0300 Subject: [PATCH] Web runs go through the browser extension only: `claude --cloud` is gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With #1693 the extension creates every web session through claude.ai's repo picker, and that is the path that ends in a pull request; the CLI's `--cloud` flag could vanish at any release and produced bundle uploads that never pushed on some accounts (#1320). Rom: "remove it once the extension has proven itself" — it has. Gone: the `script` pty runner, `CLOUD_COMMAND`/`CLOUD_ENV`, the ANSI/URL scraping, and the whole trust write + trust-dialog detection (`claude-trust.ts`, #1493/#1314) — that existed only because the CLI asked its folder-trust question under the daemon's pty; no CLI runs for a web hand-off any more. The three fallbacks became named errors: a run no daemon started ("start web runs from the dashboard"), no GitHub remote, no extension around (409, "install or reload the extension, keep the bridge on"), bridge off (404). A failed anchor push fails the run, since the session must open on that ref. Co-Authored-By: Claude Fable 5 --- FEATURES-SPEC.md | 3 +- packages/chrome-extension/content.js | 3 +- packages/framework/src/claude-trust.SPEC.md | 33 -- .../framework/src/claude-trust.test.SPEC.md | 10 - packages/framework/src/claude-trust.test.ts | 78 --- packages/framework/src/claude-trust.ts | 79 --- packages/framework/src/cli.SPEC.md | 2 +- packages/framework/src/cli.ts | 20 +- .../src/cloud-session-link.test.SPEC.md | 2 +- .../framework/src/cloud-session-link.test.ts | 33 +- packages/framework/src/daemon-runtime.SPEC.md | 2 +- .../src/dashboard/bridge-starts.SPEC.md | 2 +- .../src/dashboard/web-start-endpoints.SPEC.md | 4 +- packages/framework/src/driver/cloud.SPEC.md | 87 +--- .../framework/src/driver/cloud.test.SPEC.md | 18 +- packages/framework/src/driver/cloud.test.ts | 476 ++++-------------- packages/framework/src/driver/cloud.ts | 429 +++------------- packages/framework/src/driver/index.ts | 2 +- 18 files changed, 235 insertions(+), 1048 deletions(-) delete mode 100644 packages/framework/src/claude-trust.SPEC.md delete mode 100644 packages/framework/src/claude-trust.test.SPEC.md delete mode 100644 packages/framework/src/claude-trust.test.ts delete mode 100644 packages/framework/src/claude-trust.ts diff --git a/FEATURES-SPEC.md b/FEATURES-SPEC.md index 6bbb12cff..504d36de2 100644 --- a/FEATURES-SPEC.md +++ b/FEATURES-SPEC.md @@ -165,12 +165,11 @@ happens while nobody is at the keyboard. - Saved remote devices: run an agent on another machine's daemon — dashboard here, agents there - Run on a fresh GitHub Actions runner - Run on a Claude Code cloud session -- A web run's cloud session is created by the Chrome extension in your own browser, through claude.ai's repository picker — repo-bound, so it can push and open its pull request; the CLI's cloud mode only when no extension is around +- A web run's cloud session is created by the Chrome extension in your own browser, through claude.ai's repository picker — repo-bound, so it can push and open its pull request; without the extension (or with the browser bridge off) the run stops and says which is missing - Chrome extension bridging claude.ai questions back to the dashboard - A cloud session's conversation mirrored into the run view, turn by turn, as it is written - Answer a cloud agent's question from the dashboard (typed back into claude.ai) — the same gate panel a local agent gets, multi-select and stop options included, listed with every other open question - Browser-bridge token setting -- Web runs trust the project for Claude Code automatically — no manual trust step - A cloud run's row follows the session's real branch and PR, with its armed draft PR opened when the session opens none - Another machine's runs on the shared data branch are told apart: their rows carry a glyph naming the machine that started them (the Overview's working-now card spells it out), and a run is listed once even when two checkouts share its archive diff --git a/packages/chrome-extension/content.js b/packages/chrome-extension/content.js index 10fb5500c..8ed4dc018 100644 --- a/packages/chrome-extension/content.js +++ b/packages/chrome-extension/content.js @@ -471,8 +471,7 @@ async function deliverAnswer(text) { // --------------------------------------------------------------------------- // Creating a session (#1328). A session created through this page's repo picker is repo-bound, -// and those are the ones that can push and open a pull request; `claude --cloud` on some accounts -// produces a bundle upload that cannot (#1320). So the daemon queues a repo, a branch and a prompt, +// and those are the ones that can push and open a pull request (#1320). So the daemon queues a repo, a branch and a prompt, // and this drives the same controls a person would on the new-session page: the repo chip and // its searchable list, the branch chip that appears beside it, the composer, send. // diff --git a/packages/framework/src/claude-trust.SPEC.md b/packages/framework/src/claude-trust.SPEC.md deleted file mode 100644 index 4029dd499..000000000 --- a/packages/framework/src/claude-trust.SPEC.md +++ /dev/null @@ -1,33 +0,0 @@ -Reads and grants Claude Code's own per-folder trust, so that a `web`-target agent never dies on the CLI's interactive "do you trust this folder?" question. - -## User story - -The user starts an agent on a Claude Code cloud session and expects it to just work. The CLI would otherwise ask a one-time trust question that nobody can answer, because the daemon starts it without a person at the terminal — dooming the agent before it began. - -## Business logic — TL;DR - -- **Grant trust before the hand-off** - The Framework writes the very trust record the CLI writes when a user accepts its dialog, so the dialog never fires. -- **Reading fails quiet, writing fails loud** - a missing or unreadable trust record simply answers "unknown"; an existing config that cannot be understood is refused rather than overwritten, and the caller falls back to telling the user to accept the dialog themselves. -- **Nothing else in the CLI's config is touched** - other projects, top-level settings and the folder's own other fields are preserved, and a missing config is created carrying only the trust record. - -## Business logic - -### Granting trust - -#### User story - -See `## User story`. - -#### Business logic - -Before handing a task to a cloud session, the project's folder is recorded as trusted in the Claude Code CLI's own configuration, exactly as the CLI records it when a user accepts the dialog. - -#### Rationale - -Trust was read-only at first: the dashboard warned about it and named the one-time manual step. Any manual setup step breaks the "click and it works" promise for a cloud session, so the framework writes it instead. Starting a cloud-session agent on a project is itself the user's trust decision — the write automates consent already given rather than inventing it. - -The configuration file belongs to the CLI, not to The Framework: an existing one that does not parse is left exactly as it is, because destroying a user's CLI configuration is far worse than falling back to asking them to accept the dialog once. - -## Before modifying/creating SPEC.md files - -You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md diff --git a/packages/framework/src/claude-trust.test.SPEC.md b/packages/framework/src/claude-trust.test.SPEC.md deleted file mode 100644 index d02579486..000000000 --- a/packages/framework/src/claude-trust.test.SPEC.md +++ /dev/null @@ -1,10 +0,0 @@ -What the tests cover: reading and granting Claude Code's per-folder trust. - -- Reading tells apart a folder the user trusted, one they were asked about and declined, and one the CLI has never seen — the last is known-untrusted rather than unknown, because that is exactly when the trust dialog would fire. -- A configuration that is missing, unparseable, or shaped unexpectedly answers "unknown" instead of guessing. -- Writing trust makes the folder read as trusted while preserving everything else in the CLI's configuration: its own settings, its other projects, and the folder entry's other fields. -- Writing creates a missing configuration, repairs an unexpectedly shaped projects field, and refuses to replace an existing configuration it cannot parse — leaving that file exactly as it was. - -## Before modifying/creating SPEC.md files - -You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md diff --git a/packages/framework/src/claude-trust.test.ts b/packages/framework/src/claude-trust.test.ts deleted file mode 100644 index de3cd2892..000000000 --- a/packages/framework/src/claude-trust.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { strict as assert } from 'node:assert' -import { test } from 'node:test' -import { mkdtemp, readFile, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { readClaudeTrust, writeClaudeTrust } from './claude-trust.js' - -async function configFile(content: string): Promise { - const dir = await mkdtemp(join(tmpdir(), 'claude-trust-')) - const path = join(dir, 'claude.json') - await writeFile(path, content, 'utf8') - return path -} - -test('a trusted root reads as trusted, an asked-but-declined one as untrusted (#1318)', async () => { - const path = await configFile( - JSON.stringify({ - projects: { - '/repo/trusted': { hasTrustDialogAccepted: true }, - '/repo/declined': { hasTrustDialogAccepted: false }, - }, - }), - ) - assert.deepEqual(await readClaudeTrust('/repo/trusted', path), { known: true, trusted: true }) - assert.deepEqual(await readClaudeTrust('/repo/declined', path), { known: true, trusted: false }) -}) - -test('a root the CLI has never seen is known-untrusted, not unknown (#1318)', async () => { - // The distinction matters: "no entry" means the dialog will fire on the next start there, - // which is exactly when the launcher should warn. Unknown is reserved for a file we could - // not read or did not understand. - const path = await configFile(JSON.stringify({ projects: {} })) - assert.deepEqual(await readClaudeTrust('/repo/new', path), { known: true, trusted: false }) -}) - -test('a missing, unparseable or reshaped config answers unknown (#1318)', async () => { - assert.deepEqual(await readClaudeTrust('/repo', '/nowhere/claude.json'), { known: false, trusted: false }) - assert.deepEqual(await readClaudeTrust('/repo', await configFile('not json {')), { known: false, trusted: false }) - assert.deepEqual(await readClaudeTrust('/repo', await configFile('{"projects": []}')), { known: false, trusted: false }) - assert.deepEqual(await readClaudeTrust('/repo', await configFile('"just a string"')), { known: false, trusted: false }) -}) - -test('writing trust adds the entry and preserves everything else in the file (#1493)', async () => { - const path = await configFile( - JSON.stringify({ - theme: 'dark', - projects: { - '/repo/other': { hasTrustDialogAccepted: true, history: ['x'] }, - '/repo/new': { history: ['y'] }, - }, - }), - ) - await writeClaudeTrust('/repo/new', path) - assert.deepEqual(await readClaudeTrust('/repo/new', path), { known: true, trusted: true }) - const config = JSON.parse(await readFile(path, 'utf8')) - // The file is the CLI's: its settings, its other projects, the entry's own fields all survive. - assert.equal(config.theme, 'dark') - assert.deepEqual(config.projects['/repo/other'], { hasTrustDialogAccepted: true, history: ['x'] }) - assert.deepEqual(config.projects['/repo/new'], { history: ['y'], hasTrustDialogAccepted: true }) -}) - -test('writing trust creates a missing config, and survives a reshaped projects field (#1493)', async () => { - const dir = await mkdtemp(join(tmpdir(), 'claude-trust-')) - const missing = join(dir, 'claude.json') - await writeClaudeTrust('/repo', missing) - assert.deepEqual(await readClaudeTrust('/repo', missing), { known: true, trusted: true }) - - const reshaped = await configFile('{"projects": []}') - await writeClaudeTrust('/repo', reshaped) - assert.deepEqual(await readClaudeTrust('/repo', reshaped), { known: true, trusted: true }) -}) - -test('writing trust refuses to replace an existing file it cannot parse (#1493)', async () => { - // Unparseable is not ours to "fix": overwriting would destroy whatever the CLI meant to keep. - const path = await configFile('not json {') - await assert.rejects(writeClaudeTrust('/repo', path)) - assert.equal(await readFile(path, 'utf8'), 'not json {') -}) diff --git a/packages/framework/src/claude-trust.ts b/packages/framework/src/claude-trust.ts deleted file mode 100644 index 882c76cb8..000000000 --- a/packages/framework/src/claude-trust.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { readFile, writeFile } from 'node:fs/promises' -import { homedir } from 'node:os' -import { join } from 'node:path' - -/** - * Claude Code's folder trust: read it, and grant it for a web run (#1493). - * - * The CLI asks its one-time "do you trust this folder?" question interactively, which a - * `--cloud` start under the daemon's pty can never answer (#1314) — a web agent on an - * untrusted project was doomed before it started. Trust was read-only here at first - * (#1318: warn in the dashboard, name the one-time manual step), but any manual setup - * step breaks the "click and it works" story for web runs, so #1493 reversed that - * decision: the framework now writes the CLI's own trust record before the hand-off. - * Starting a web agent on a project is itself a trust decision by the user — the write - * automates consent already given, it does not invent it. - * - * Reading stays fail-quiet: a missing, unreadable or reshaped `~/.claude.json` answers - * "unknown". Writing is the opposite — an existing file that does not parse is left - * alone (throw, never destroy the CLI's config), and the caller falls back to the - * interactive-advice path. - */ - -/** Where the Claude Code CLI records per-directory trust. */ -function claudeConfigPath(home: string = homedir()): string { - return join(home, '.claude.json') -} - -/** What is known about a root's trust. `known: false` means the file could not say. */ -export interface ClaudeTrust { - known: boolean - trusted: boolean -} - -/** Whether the CLI trusts `root`. Unknown when the config is missing or not understood. */ -export async function readClaudeTrust(root: string, path: string = claudeConfigPath()): Promise { - let config: unknown - try { - config = JSON.parse(await readFile(path, 'utf8')) - } catch { - return { known: false, trusted: false } - } - if (typeof config !== 'object' || config === null) return { known: false, trusted: false } - const projects = (config as Record).projects - if (typeof projects !== 'object' || projects === null || Array.isArray(projects)) return { known: false, trusted: false } - const entry = (projects as Record)[root] - const trusted = - typeof entry === 'object' && entry !== null && (entry as Record).hasTrustDialogAccepted === true - return { known: true, trusted } -} - -/** - * Record that the CLI can trust `root` (#1493): the exact record the CLI itself writes - * when the user accepts its dialog, so the dialog never fires. Everything else in the - * config — other projects, top-level settings, the entry's own sibling fields — is - * preserved. A missing file is created; an existing file that does not parse is refused - * with a throw rather than overwritten, since the file is the CLI's, not ours. - */ -export async function writeClaudeTrust(root: string, path: string = claudeConfigPath()): Promise { - let raw: string | undefined - try { - raw = await readFile(path, 'utf8') - } catch { - // No file yet: create one carrying only the trust record; the CLI accepts and extends it. - } - let config: Record = {} - if (raw !== undefined) { - const parsed: unknown = JSON.parse(raw) - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) - throw new Error(`[framework] ${path} exists but is not a JSON object`) - config = parsed as Record - } - const projects = - typeof config.projects === 'object' && config.projects !== null && !Array.isArray(config.projects) - ? (config.projects as Record) - : {} - const entry = typeof projects[root] === 'object' && projects[root] !== null ? (projects[root] as Record) : {} - config.projects = { ...projects, [root]: { ...entry, hasTrustDialogAccepted: true } } - await writeFile(path, JSON.stringify(config, null, 2), 'utf8') -} diff --git a/packages/framework/src/cli.SPEC.md b/packages/framework/src/cli.SPEC.md index d381b0d46..0ac0a829a 100644 --- a/packages/framework/src/cli.SPEC.md +++ b/packages/framework/src/cli.SPEC.md @@ -164,7 +164,7 @@ The user picks a run target in the dashboard: this device, a GitHub Actions runn For the `actions` target, the repository's owner and name come from its GitHub origin remote and the credential from the environment or, failing that, from the `gh` CLI — never from the committed `the-framework.yml`, since a repo file is public and this must be a user credential. A missing remote or a missing credential aborts the agent before any driver exists, with a message naming both ways to supply the token and pointing out that it must belong to a user rather than an app. -For the `web` target nothing is resolved: the wrapped CLI holds the account the cloud session runs under. The agent announces the hand-off and, when the repository has no GitHub remote, that a bundle of it will be uploaded instead. It also states plainly that the cloud session clones the remote at its current branch, so local commits that were never pushed are not in it. A `web` agent that a daemon spawned — its environment names the daemon's address, and the registry holds the daemon token — asks that daemon for a session created by the browser extension before falling back to the CLI's own cloud mode; an agent with no daemon address, or no token, hands off through the CLI alone. +For the `web` target nothing is resolved: the session is created on the user's own account by the browser extension. The agent announces the hand-off, and states plainly that the cloud session opens on the run's pushed starting point, so local commits that were never pushed are not in it. A `web` agent's cloud session is created by the browser extension through the daemon that spawned the agent — its environment names the daemon's address, and the registry holds the daemon token; an agent with no daemon address, or no token, stops saying web runs start from the dashboard. The framework launches Chrome itself for a browser-enabled agent rather than letting the driver's browser tooling launch its own, because that is what lets the preview attach to the same page. It does so only for a local agent: the browser tools are wired on this machine, so a remote agent could never reach them, and launching Chrome would leak a headless browser per agent. A browser asked for on a remote target, or on a machine with no Chrome, is reported as having no effect rather than failing the agent. Whether the system prompt may claim the agent has a browser is narrower still than the setting: it must be a local, real, Claude Code agent. diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index 6e93935b4..fd1f8f4bf 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -95,9 +95,10 @@ export function chooseSessionLink(opts: Pick, fake: bool } /** - * The extension-backed session start a web run can use (#1328): only when a daemon spawned this - * run (its URL is in {@link DAEMON_URL_ENV}) and the registry holds the daemon token that the - * daemon's start-queue asks for. Anything less and the run hands off through the CLI alone. + * How a web run reaches the daemon whose browser extension creates its session (#1328): only + * when a daemon spawned this run (its URL is in {@link DAEMON_URL_ENV}) and the registry holds + * the daemon token that the daemon's start-queue asks for. Anything less and the run fails + * saying web runs start from the dashboard. */ export async function extensionStartConfig(env: NodeJS.ProcessEnv): Promise { const daemonUrl = env[DAEMON_URL_ENV] @@ -1168,15 +1169,12 @@ async function driveAgent(opts: AgentOptions, io: CliIO): Promise { io.out(`◆ run on: GitHub Actions (${slug.owner}/${slug.repo})`) } - // Run on Claude Code on the web (#610). Nothing to resolve: the CLI holds the account the - // cloud session is created under, so there is no token of ours and no repo config. The - // session clones this repo's remote at its current branch, so local commits that were never - // pushed are not in it — say so once here rather than let the cloud session look stale. + // Run on Claude Code on the web (#610): the browser extension creates the session on the + // user's own account (#1328), so there is no token of ours and no repo config. The session + // opens on this run's pushed hand-off ref, so local commits that were never pushed are not in + // it — say so once here rather than let the cloud session look stale. if (opts.target === 'web' && !fake) { - io.out('◆ run on: Claude Code on the web (a cloud session on your own account)') - if (!(await githubSlugFor(cwd))) { - io.out(' no GitHub remote here, so the CLI uploads a bundle of this repo instead.') - } + io.out('◆ run on: Claude Code on the web (a cloud session on your own account, created by the browser extension)') } // A daemon-spawned web run asks its daemon for an extension-created session first (#1328): the diff --git a/packages/framework/src/cloud-session-link.test.SPEC.md b/packages/framework/src/cloud-session-link.test.SPEC.md index 8442594c7..e8dc4e86b 100644 --- a/packages/framework/src/cloud-session-link.test.SPEC.md +++ b/packages/framework/src/cloud-session-link.test.SPEC.md @@ -1,4 +1,4 @@ -What the tests cover: a `web`-target agent's agent meta ends up carrying the cloud session's real deep link (the per-session claude.ai/code URL the driver reads out of the hand-off), not the generic claude.ai/code entry point — while the opening event still honestly records the generic link that was all that was known before the session existed. The contract that the deep link wins is pinned end to end, through the real cloud driver, the real telemetry, and the real meta fold, rather than per layer. +What the tests cover: a `web`-target agent's agent meta ends up carrying the cloud session's real deep link (the per-session claude.ai/code URL the browser extension reports back through the daemon), not the generic claude.ai/code entry point — while the opening event still honestly records the generic link that was all that was known before the session existed. The contract that the deep link wins is pinned end to end, through the real cloud driver, the real telemetry, and the real meta fold, rather than per layer. ## Before modifying/creating SPEC.md files diff --git a/packages/framework/src/cloud-session-link.test.ts b/packages/framework/src/cloud-session-link.test.ts index e7b362550..8a9745653 100644 --- a/packages/framework/src/cloud-session-link.test.ts +++ b/packages/framework/src/cloud-session-link.test.ts @@ -11,14 +11,22 @@ import type { FrameworkEvent } from './events.js' // URL in hand. This wires the real driver through the real telemetry into the real meta fold, // so the contract that the deep link wins is pinned end to end, not per layer. -const URL = 'https://claude.ai/code/session_01ABCdefGHIjklMNO?from=cli&m=0' -const CREATED = [ - '\x1b[?25l\x1b[2K', - 'Created cloud session: Add the --verbose flag\r\n', - `View: \x1b[4m${URL}\x1b[24m\r\n`, - 'Resume with: claude --teleport session_01ABCdefGHIjklMNO\r\n', - '\x1b[?25h', -].join('') +const SESSION = 'session_01ABCdefGHIjklMNO' +const URL = `https://claude.ai/code/${SESSION}` + +/** A daemon whose start-queue reports the session created on the first poll, and a git with a GitHub origin. */ +function extensionThatCreates() { + const fetchFake = (async (input: string | globalThis.URL | Request, init?: RequestInit) => + (init?.method ?? 'GET') === 'POST' + ? new Response(JSON.stringify({ id: 'req-1' }), { status: 202 }) + : new Response(JSON.stringify({ state: 'created', sessionId: SESSION, url: URL }), { status: 200 })) as typeof fetch + const git = async (args: string[]): Promise => { + if (args[0] === 'remote') return 'git@github.com:framework/the-framework.git\n' + if (args[0] === 'commit-tree') return `${'a'.repeat(40)}\n` + return '' + } + return { extension: { daemonUrl: 'http://127.0.0.1:4200', token: 'tok', fetch: fetchFake, pollMs: 1 }, git } +} test('a cloud run meta ends with the real session URL, not the generic entry point (#1317)', async () => { const events: FrameworkEvent[] = [] @@ -27,14 +35,7 @@ test('a cloud run meta ends with the real session URL, not the generic entry poi // What a Claude agent gets without an explicit session link: the generic entry point. sessionLink: CLAUDE_CODE_SESSION_LINK, }) - const driver = new CloudDriver({ - agentTag: () => 'tag', - timeoutMs: 1000, - runPty: async opts => { - opts.onData(CREATED) - if (!opts.signal.aborted) await new Promise(r => opts.signal.addEventListener('abort', () => r(), { once: true })) - }, - }) + const driver = new CloudDriver({ agentTag: () => 'tag', timeoutMs: 1000, ...extensionThatCreates() }) emitSessionStart({ emit: e => events.push(e), driver, cwd: '/repo', sessionLink: CLAUDE_CODE_SESSION_LINK }) const session = await driver.start({ cwd: '/repo', onEvent: handler.onDriverEvent }) await session.prompt('go') diff --git a/packages/framework/src/daemon-runtime.SPEC.md b/packages/framework/src/daemon-runtime.SPEC.md index 8cec5bab7..45438e557 100644 --- a/packages/framework/src/daemon-runtime.SPEC.md +++ b/packages/framework/src/daemon-runtime.SPEC.md @@ -38,7 +38,7 @@ Whatever the framework's own CLI entry is, the agent is spawned from it. The dae The spawned agent's error output is written to a file inside its checkout instead of being discarded, so an agent that dies at boot leaves a trace to read. -The spawned agent's environment is the daemon's own plus the daemon's address, when the daemon has one: a web run uses it to ask this daemon for a cloud session created by the browser extension. A run nobody's daemon spawned has no such address and hands off by itself. +The spawned agent's environment is the daemon's own plus the daemon's address, when the daemon has one: a web run uses it to ask this daemon for a cloud session created by the browser extension. A run nobody's daemon spawned has no such address, and a web run among them stops saying web runs start from the dashboard. ### Every agent gets its own checkout diff --git a/packages/framework/src/dashboard/bridge-starts.SPEC.md b/packages/framework/src/dashboard/bridge-starts.SPEC.md index 31e41a3af..7c8c510fa 100644 --- a/packages/framework/src/dashboard/bridge-starts.SPEC.md +++ b/packages/framework/src/dashboard/bridge-starts.SPEC.md @@ -2,7 +2,7 @@ The session start-queue of the Claude web bridge: the cloud sessions a web run w ## User story -A `web`-target agent hands its task to a cloud session on claude.ai. A session created through claude.ai's own repository picker is bound to that repository, so it can push its work and open a pull request; one created through the CLI's cloud flag may not be. So the run asks the daemon for an extension-created session, the extension in the user's own browser creates it, and the run learns where its work went. +A `web`-target agent hands its task to a cloud session on claude.ai. A session created through claude.ai's own repository picker is bound to that repository, so it can push its work and open a pull request. So the run asks the daemon for an extension-created session, the extension in the user's own browser creates it, and the run learns where its work went. ## Business logic — TL;DR diff --git a/packages/framework/src/dashboard/web-start-endpoints.SPEC.md b/packages/framework/src/dashboard/web-start-endpoints.SPEC.md index 1b44c926b..14dc7f010 100644 --- a/packages/framework/src/dashboard/web-start-endpoints.SPEC.md +++ b/packages/framework/src/dashboard/web-start-endpoints.SPEC.md @@ -7,7 +7,7 @@ A `web`-target agent is a process the daemon spawned, not a part of the daemon, ## Business logic — TL;DR - **The run presents the daemon token** - the same secret the extension holds; a missing or wrong token is refused before anything is read. -- **No extension around means no waiting** - when nothing has spoken to the bridge recently, the request is refused on the spot with that reason, so the run hands off another way instead of timing out. +- **No extension around means no waiting** - when nothing has spoken to the bridge recently, the request is refused on the spot with that reason, so the run stops naming it instead of timing out. - **A request is validated as the queue validates it** - repository, branch and prompt must be strings, and the queue's own rules decide the rest. - **The run polls the request by its id** - and reads queued, claimed, created with the session and its URL, or failed with the extension's note. - **Off with the bridge** - when the browser bridge is off, these routes do not exist either. @@ -26,7 +26,7 @@ A run posts the repository, the branch and the prompt. With the bridge off the r #### Rationale -Refusing at once when no extension is around is what keeps the CLI's cloud flag a working hand-off on a machine without the extension: the run learns in one round trip that it must hand off by itself, rather than after a two-minute wait. +Refusing at once when no extension is around is what lets the run tell the user what is missing in one round trip, rather than after a two-minute wait. ### Following the request diff --git a/packages/framework/src/driver/cloud.SPEC.md b/packages/framework/src/driver/cloud.SPEC.md index 7db8e3a14..1fd4833d2 100644 --- a/packages/framework/src/driver/cloud.SPEC.md +++ b/packages/framework/src/driver/cloud.SPEC.md @@ -1,41 +1,26 @@ -The driver for the `web` run target: instead of running a coding agent on this device, it hands the task to a cloud session on claude.ai and reports where the work went. This is the hands-off target — the task goes somewhere this machine cannot follow, so the hand-off is the whole agent. +Hands an agent's task to Claude Code on the web: has a cloud session created on claude.ai — by the browser extension, in the user's own browser, on this run's pushed starting point — and reports where the work went. ## User story -The user wants a task done without it costing anything on their own machine — no CPU, no open laptop. They click the `web` run target, and the dashboard hands them a link to a real Claude Code session on claude.ai that does the work on Anthropic's infrastructure and opens its own pull request. +The user picks "Claude Code on the web" as where an agent runs. The work should happen on Anthropic's infrastructure at no local cost, under the user's own account and quota, and land as a pull request — without the user doing anything on claude.ai. When something the hand-off needs is missing, the run should say exactly what. ## Glossary -- **hand-off anchor** - an empty commit The Framework pushes just before handing a task to a cloud session. It marks exactly where that session started, so the branch it eventually pushes can later be recognized as this agent's. +- **hand-off anchor** - an empty commit The Framework pushes just before handing a task to a cloud session. It marks exactly where that session started from, under a name that is recognizably this run's, so the session's branch can be told apart from one a person made. ## Business logic — TL;DR -- **The user's own account does the work** - the hand-off goes through the Claude Code CLI's own cloud mode, so the account, the sign-in, and the quota are the user's, exactly as for a local agent. Nothing here drives the claude.ai website: no browser, no extension, no scraping. -- **The browser extension creates the session when it can** - a run its daemon spawned asks the daemon for a session created through claude.ai's own repository picker, which is bound to the repository and can push and open the pull request; only when no extension is around, the bridge is off, the repository has no GitHub remote or the starting point could not be pushed does the CLI's own cloud mode hand off instead. +- **The browser extension creates the session** - the run asks its daemon to queue a request naming the repository, the pushed starting point and the prompt; the extension creates the session through claude.ai's own repository picker, so the session is bound to the repository and can push and open its pull request. +- **The user's own account does the work** - the session is created in the user's signed-in browser, so the account, the sign-in and the quota are the user's, as with a local agent. +- **What the hand-off needs is named when missing** - a daemon that started the run, a GitHub remote, the browser bridge switched on, and the extension present: each absence stops the run with that as the message. +- **The starting point is pushed first, under a name the cloud side can resolve** - the session is told exactly which ref to open on, and that ref doubles as the run's identity on the remote; a push that fails stops the run. - **One agent, exactly one cloud session** - the first prompt hands off; every later prompt says the work is already over there and spends nothing. - **The hand-off prompt is written for a human to read** - the task comes first, and everything The Framework injects follows behind a labelled rule. -- **The project is trusted on the user's behalf** - starting a `web` agent is itself the user's decision to trust the project, so Claude Code's one-time trust question is answered ahead of time instead of blocking the hand-off. -- **The starting point is pushed first, under a name the cloud side can resolve** - the session is told exactly which ref to clone, and that ref doubles as the mark that later identifies the branch it produces. -- **The turn ends the moment the session link appears** - there is no way to read back a cloud session's progress, so the turn's result is the link, and following the work happens on claude.ai, through the Claude web bridge where it is switched on, or by pulling the session back to this machine. -- **Nothing the user typed can be interpreted as a command** - the prompt and the model reach the CLI through the environment, never as part of a command line. +- **The turn ends the moment the session exists** - there is no way to read back a cloud session's progress, so the turn's result is the link, and following the work happens through the bridge's mirror or on claude.ai. ## Business logic -### The browser extension creates the session when it can - -#### User story - -The user runs the browser extension. Their web runs should land as sessions that can push and open pull requests, without them doing anything on claude.ai — and on a machine without the extension, web runs should still work. - -#### Business logic - -After the starting point is pushed and the project trusted, a run that knows its daemon's address asks the daemon to queue a session request naming the repository as `owner/name` (read from the checkout's GitHub remote), the pushed starting-point ref, and the whole hand-off prompt. It then follows the request until the extension reports the session, and hands off exactly as the CLI path does — the same link, the same summary, the same single hand-off per agent. Four things make the run hand off through the CLI's cloud mode instead, each announced in the agent's log: no GitHub remote to name in the repository picker; a starting point that could not be pushed, since the session must open on that ref; a daemon that answers no extension is around; or a daemon with the bridge off. An extension that tried and could not create the session fails the turn with the extension's own note of what it could not find — never a silent second attempt through the CLI. The wait shares the hand-off's overall timeout. - -#### Rationale - -A session created through the page's repository picker is repo-bound; the CLI's cloud mode has, on some accounts, produced a bundle upload that could never push (#1320). The extension path is the one that ends in a pull request, so it goes first, and the CLI path stays only for the machines and cases where it cannot run. - -### One agent, exactly one cloud session +### The browser extension creates the session #### User story @@ -43,71 +28,53 @@ See `## User story`. #### Business logic -The first prompt creates the cloud session. Every prompt after it reports the hand-off that already happened — naming the session's link and how to continue it on this machine — without creating another one. The turn's wording makes clear that there is nothing further to do locally and that the cloud session opens its own pull request. +After the starting point is pushed, the run asks its daemon to queue a session request: the repository as `owner/name` (read from the checkout's GitHub remote), the pushed starting-point ref, and the whole hand-off prompt. It then follows the request until the extension reports the session, and reports the session's link and id exactly as before. An extension that tried and could not create the session stops the run with the extension's own note of what it could not find. #### Rationale -An agent is not one prompt: the agent loop prompts again for each pass — plan, build, review, then the backlog loop. A driver that started a cloud session per prompt turned one agent into half a dozen independent cloud machines racing on the same repository. There is also nothing better on offer, because the wrapped CLI can create a cloud session and pull one back but cannot send it a second message; "this agent is already over there" is the honest answer. +A session created through the page's repository picker is bound to the repository. The CLI's own cloud mode, the earlier mechanism, produced on some accounts a bundle upload that could never push (#1320), and it could vanish from the CLI at any release; the extension path is the one that ends in a pull request, and it is the only one now. -### The hand-off prompt is written for a human to read +### What the hand-off needs is named when missing #### User story -The user opens the claude.ai session from the dashboard and wants to see the task they asked for, not a wall of framework boilerplate. +The user starts a web run on a machine or project that is not set up for it. They should learn in one sentence what to do, not wait for a timeout. #### Business logic -The whole prompt is assembled task-first: the user's task, then each block The Framework injects — the system prompt framing and any per-turn framing — behind a hard horizontal rule and a single line naming The Framework as the source of what follows. +Four things are checked, in this order, each stopping the run with its own message: the run was started by a daemon (a run started any other way has nobody to hand its session to — web runs start from the dashboard); the project has a GitHub remote for the picker to name; the daemon's browser bridge is on; an extension has spoken to the daemon recently. A wait past the hand-off's timeout also stops the run, naming the extension. -#### Rationale - -For every other driver the framing is invisible plumbing. Here it is the first thing a human sees. Without the rules, the injected blocks' own markdown headings run into the task and into each other and read as one confusing document. - -### The project is trusted on the user's behalf +### The starting point is pushed first, under a name the cloud side can resolve #### User story -The user starts a `web` agent and it works. They should not have to discover, from an agent that timed out with nothing to show, that a dialog they cannot see is waiting for an answer. +The cloud session must start from exactly where this run stands, and the run must be able to recognize the session's branch later. #### Business logic -Claude Code asks once per directory whether the folder is trusted, and refuses to start until it is answered. That question cannot be answered by the daemon, so before handing off, The Framework records the project root as trusted the way the CLI itself would — starting a `web` agent on the project being the user's trust decision. Trust is recorded against the project root rather than the agent's worktree, because everything under a trusted directory inherits its trust and the worktree is thrown away. +Before the request, the hand-off anchor — an empty commit on top of the checkout's current state — is pushed to the remote under the run's own slash-free identifier, and the request names that ref as the branch to open on. The anchor is recorded on the run's result. A push that fails stops the run, naming the remote: the session cannot open on a ref the remote does not have. -If recording trust fails, the agent still proceeds and says so. The trust question is then watched for in the CLI's output, and if it appears the agent fails with the one-time manual fix spelled out — run `claude` in the project root once, accept the question, then start a new `web` agent — rather than with a generic "no cloud session was created". - -### The starting point is pushed first, under a name the cloud side can resolve +### One agent, exactly one cloud session #### User story -The cloud session has to start from the same code the user is looking at, and its eventual pull request has to be recognizable as this agent's work. +The agent loop may prompt the same agent several times; the user must never end up with two cloud sessions for one task. #### Business logic -Just before handing off, an empty commit — the hand-off anchor — is created on top of the agent's current checkout without moving any local branch, and pushed to the project's remote under a branch named after the agent. The cloud session is told to clone at that ref, and the anchor's identity is recorded on the agent so the daemon can later match the `claude/*` branch the cloud session pushes back to this agent by tracing its ancestry. - -If the push fails — a repository with no reachable remote, for instance — the agent still hands off, falling back to the CLI's own default starting point, and warns that the cloud session may not find the branch and that its work would then have to be pulled back by hand. - -#### Rationale - -Two failure modes make an explicit ref necessary. The CLI's default is the checkout's current branch, and an agent worktree's branch exists only locally, so the cloud side cannot resolve it. And a branch name containing a slash never resolves on the cloud side even when it has been pushed. An agent's own identifier is unique and contains no slash, which sidesteps both. - -An empty commit rather than the existing head is used because the cloud session names its own branch and does its own work; every commit it makes descends from what it cloned, so a commit unique to this agent is the one exact mark that identifies its branch afterwards. +Only the first prompt creates a session. Every later prompt reports the same session and says the work is already there, and the session link is published once. -### The turn ends the moment the session link appears +### The hand-off prompt is written for a human to read #### User story -See `## User story`. +Someone opens the cloud session on claude.ai and wants to see the task first, not The Framework's instructions. #### Business logic -The CLI is watched until it prints the cloud session's link, at which point the link and the session id become the turn's result and the CLI is stopped — it has nothing further to say and would otherwise sit holding its terminal. The result carries the session id, the session link, and the hand-off anchor onto the agent's record, and the link is also published as an action the dashboard's agent view links through. - -The hand-off gives up after two minutes. The user pressing Stop, the trust question appearing, and the CLI simply failing each produce their own distinct message; a plain failure carries the tail of the CLI's own output so the reason is visible. - -There is no reading of files from a `web` agent: its workspace lives in a cloud machine this device never sees. There is no quota reading either — a cloud session draws on the same subscription a local agent already reports. What the session then does reaches the user on claude.ai itself, or — where the Claude web bridge is switched on — mirrored into the dashboard along with any question the session parks on, which is a separate subsystem entirely and nothing this driver reads. +The prompt handed over leads with the user's task, followed by the built-in framing and any per-turn framing, each behind a labelled rule; a task with nothing injected is handed over bare. -### Nothing the user typed can be interpreted as a command +### The turn ends the moment the session exists #### User story @@ -115,11 +82,7 @@ See `## User story`. #### Business logic -Cloud mode refuses to run unless it is attached to a terminal, so the CLI is run under one. The command it is given is a fixed, unchanging string; the prompt, the chosen model, and the ref to clone all reach it through the environment, so no text a user or an agent wrote can be read as command syntax. A model name that is not a plain identifier is refused outright before anything is started. - -#### Rationale - -Two details of that command are load-bearing and not otherwise visible. The task description must come immediately after the cloud flag, because it is that flag's own value rather than a loose argument — anything placed in between claims the slot and the CLI refuses to start, which is why the hand-off failed only for accounts with a model preference. And the CLI's non-essential traffic is switched off, which disables a server-side experiment that silently converts a failed repository preflight into a session with no remote and no ability to push — the exact opposite of what handing work to the cloud is for. +The turn's result is the session's link and id; the link is also published as an action the dashboard links through to. A cloud session exposes no read-back — no status, transcript or output — so following the work happens through the browser bridge's mirror and gate relay, on claude.ai itself, or by pulling the session back with the CLI. There is no code reading either: the workspace lives in a cloud machine this one never sees. ## Before modifying/creating SPEC.md files diff --git a/packages/framework/src/driver/cloud.test.SPEC.md b/packages/framework/src/driver/cloud.test.SPEC.md index afef24e61..6a69eb8c0 100644 --- a/packages/framework/src/driver/cloud.test.SPEC.md +++ b/packages/framework/src/driver/cloud.test.SPEC.md @@ -1,17 +1,11 @@ -What the tests cover: handing an agent's task to a cloud session on claude.ai and reporting where it went. +What the tests cover: handing an agent's task to a cloud session on claude.ai through the browser extension, and reporting where it went. -- A hand-off returns the cloud session's id and a summary naming it, publishes the session's link as an action the dashboard's agent view links through, and carries the link onto the agent's record. The link is recognized even when it arrives split across the terminal's output in pieces, and even though the CLI draws it with terminal control codes around it. -- The CLI is stopped the instant the link appears, rather than being left holding its terminal. -- An agent hands off exactly once no matter how many times the agent loop prompts it: only the first prompt creates a cloud session, every later prompt returns the same session and says the work is already over there with nothing further to do locally, and the session link is published only once. +- A hand-off asks the daemon for a session naming the repository from the GitHub remote, the pushed starting-point ref and the whole prompt, follows the request until created, and returns the session's id and a summary naming it; the session's link is published as an action the dashboard's agent view links through and carried onto the agent's record, and the log says the extension was asked. - The prompt handed over leads with the user's task, followed by the system prompt framing and any per-turn framing, each behind a labelled rule; a task with nothing injected is handed over bare. -- The project root is recorded as trusted for Claude Code before the hand-off, and the act is announced. An already-trusted root is left alone silently. A trust record that could not be written is reported and the hand-off still proceeds. -- If Claude Code's trust question appears anyway, the agent fails immediately naming the one-time manual fix, and both the failure and the notice name the project root rather than the throwaway worktree the user could never act on — including when the agent runs from a worktree, whose root is derived without guessing at unrelated paths. -- An empty hand-off anchor commit is created on top of the checkout without moving any branch, pushed to the remote under the agent's own slash-free identifier, and the cloud session is told to clone that ref; the anchor is recorded on the agent's result so its branch can be recognized later. A push that fails falls back to no ref, says so, names how to recover the work by hand, and still hands off — recording no anchor, since nothing reached the remote. -- Nothing the user typed reaches a shell as syntax: the prompt and model travel through the environment, the task sits directly after the cloud flag with nothing allowed in between, a plain model name is passed through, and an unsafe one is refused before anything is started at all. -- With an extension around, the session comes from the daemon's start-queue: the request names the repository from the GitHub remote, the pushed starting-point ref and the whole prompt, the request is followed until created, the CLI's cloud mode never runs, and the agent's record carries the session link as on the CLI path. When the daemon answers that no extension is around, the CLI's cloud mode hands off instead and the log says why; a checkout with no GitHub remote never asks. An extension that tried and failed fails the turn carrying the extension's note, with no CLI retry. -- The CLI's non-essential traffic is switched off, which is what keeps the cloud session bound to the repository. -- A hand-off that created no cloud session fails carrying what the CLI actually said. -- Two agents never share an identifier; a disposed agent refuses further prompts; an agent already stopped fails before starting anything. +- An empty hand-off anchor commit is created on top of the checkout without moving any branch, pushed to the remote under the agent's own slash-free identifier before the request is made, the request names that ref, and the anchor is recorded on the agent's result. A push that fails stops the run naming the remote, with nothing asked of the extension. +- A checkout with no GitHub remote stops the run naming the need, before pushing anything; a run no daemon started stops saying web runs start from the dashboard; a daemon with no extension around, and one with the bridge off, each stop the run naming the cure; an extension that tried and failed stops the run carrying its note; waiting past the timeout stops the run naming the extension. +- An agent hands off exactly once no matter how many times the agent loop prompts it: one request, the same session on every later prompt, the link published once. +- Two agents never share an identifier; a disposed agent refuses further prompts; an agent already stopped fails before asking anything. - A `web` agent exposes no file reading, since its workspace is in a cloud machine, and the `web` run target is hands-off while `local` and `actions` are not. ## Before modifying/creating SPEC.md files diff --git a/packages/framework/src/driver/cloud.test.ts b/packages/framework/src/driver/cloud.test.ts index 52728c86e..efc5bdcfa 100644 --- a/packages/framework/src/driver/cloud.test.ts +++ b/packages/framework/src/driver/cloud.test.ts @@ -1,102 +1,92 @@ import { strict as assert } from 'node:assert' import { test } from 'node:test' -import { mkdtempSync, writeFileSync } from 'node:fs' -import { readFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { isHandsOff } from '../agent-location.js' -import { CLOUD_COMMAND, CLOUD_ENV, CLOUD_PROMPT_SEPARATOR, CloudDriver, cloudHandOffPrompt, trustRootOf, type AgentPtyOptions, type ExtensionStart } from './cloud.js' +import { CLOUD_PROMPT_SEPARATOR, CloudDriver, cloudHandOffPrompt, type ExtensionStart } from './cloud.js' import type { DriverEvent } from './types.js' -/** - * What the CLI actually prints on a successful `--cloud`, captured from a real agent. The - * escape codes are part of the fixture on purpose: this output comes off a terminal, so the - * parser has to read through them rather than around them. - */ -const CREATED = [ - '\x1b[?25l\x1b[2K', - 'Created cloud session: Add the --verbose flag\r\n', - 'View: \x1b[4mhttps://claude.ai/code/session_01ABCdefGHIjklMNO?from=cli&m=0\x1b[24m\r\n', - 'Resume with: claude --teleport session_01ABCdefGHIjklMNO\r\n', - '\x1b[?25h', -].join('') - -const URL = 'https://claude.ai/code/session_01ABCdefGHIjklMNO?from=cli&m=0' const SESSION = 'session_01ABCdefGHIjklMNO' - -/** A pty runner that replays a fixed transcript, recording how it was called. */ -function fakePty(output: string, calls: AgentPtyOptions[] = []) { - return { - calls, - run: async (opts: AgentPtyOptions) => { - calls.push(opts) - opts.onData(output) - // A real invocation keeps holding the terminal until the caller aborts, so only - // return once aborted — a runner that returned early would hide that. - if (!opts.signal.aborted) await new Promise(r => opts.signal.addEventListener('abort', () => r(), { once: true })) - }, - } -} +const URL = `https://claude.ai/code/${SESSION}` /** The anchor sha the fake git mints for `commit-tree` (#1601). */ const ANCHOR = 'a'.repeat(40) /** - * A git runner that records its calls; `fail` makes every call reject (#1320). `commit-tree` - * answers with a fixed sha. + * A git runner with a GitHub origin, recording its calls; `fail` makes the push reject (#1320); + * `noRemote` answers `remote get-url` as a repo without one. */ -function fakeGit(calls: string[][] = [], fail = false) { +function fakeGit({ calls = [] as string[][], fail = false, noRemote = false } = {}) { return { calls, run: async (args: string[], _cwd: string): Promise => { calls.push([...args]) - if (fail) throw new Error('no pushable remote') + if (args[0] === 'remote') { + if (noRemote) throw new Error('no such remote') + return 'git@github.com:framework/the-framework.git\n' + } if (args[0] === 'commit-tree') return `${ANCHOR}\n` + if (args[0] === 'push' && fail) throw new Error('no pushable remote') return '' }, } } /** - * A per-test stand-in for `~/.claude.json`, so the trust write (#1493) never touches the - * real one. Pass a root to start the file with that root already trusted. + * A daemon whose start-queue answers as scripted: the POST gets `queue`, then each poll pops the + * next `states` entry (the last one repeats). Records every request the run made. */ -function tmpClaudeConfig(trustedRoot?: string): string { - const path = join(mkdtempSync(join(tmpdir(), 'cloud-trust-')), 'claude.json') - if (trustedRoot) writeFileSync(path, JSON.stringify({ projects: { [trustedRoot]: { hasTrustDialogAccepted: true } } })) - return path +function fakeDaemon(queue: { status: number; body?: unknown }, states: unknown[]) { + const requests: { method: string; path: string; body?: unknown }[] = [] + const doFetch = (async (input: string | globalThis.URL | Request, init?: RequestInit) => { + const path = new globalThis.URL(String(input)).pathname + const method = init?.method ?? 'GET' + requests.push({ method, path, ...(init?.body ? { body: JSON.parse(String(init.body)) } : {}) }) + if (method === 'POST') { + return new Response(queue.body === undefined ? 'nope' : JSON.stringify(queue.body), { status: queue.status }) + } + const state = states.length > 1 ? states.shift() : states[0] + return new Response(JSON.stringify(state), { status: 200 }) + }) as typeof fetch + return { requests, fetch: doFetch } } -function driverWith(output: string, calls: AgentPtyOptions[] = [], git = fakeGit()) { - const pty = fakePty(output, calls) - // Pre-trusted, so tests about other behavior see the event stream they always saw. - return new CloudDriver({ runPty: pty.run, git: git.run, agentTag: () => 'tag', timeoutMs: 1000, claudeConfig: tmpClaudeConfig('/repo') }) +const CREATED = [{ state: 'queued' }, { state: 'claimed' }, { state: 'created', sessionId: SESSION, url: URL }] + +function driverWith(daemon = fakeDaemon({ status: 202, body: { id: 'req-1' } }, [...CREATED]), git = fakeGit(), opts: Partial = {}) { + const extension: ExtensionStart = { daemonUrl: 'http://127.0.0.1:4200/', token: 'tok', fetch: daemon.fetch, pollMs: 1, ...opts } + return new CloudDriver({ extension, git: git.run, agentTag: () => 'tag', timeoutMs: 1000 }) } -test('a prompt creates a cloud session and returns its id', async () => { - const session = await driverWith(CREATED).start({ cwd: '/repo' }) +test('a prompt has the extension create a cloud session and returns its id (#1328)', async () => { + const daemon = fakeDaemon({ status: 202, body: { id: 'req-1' } }, [...CREATED]) + const session = await driverWith(daemon).start({ cwd: '/repo' }) const turn = await session.prompt('Add the --verbose flag') assert.equal(turn.sessionId, SESSION) assert.match(turn.text, /Claude Code on the web/) assert.match(turn.text, new RegExp(SESSION)) + // The request names the repo the picker lists, the pushed hand-off ref, and the whole prompt. + const post = daemon.requests.find(r => r.method === 'POST') + assert.deepEqual(post?.body, { repo: 'framework/the-framework', branch: session.id, prompt: 'Add the --verbose flag' }) + assert.equal(daemon.requests.filter(r => r.method === 'GET').length, 3, 'polled until created') }) test('the session link rides an `action` event, the way the Actions run link does', async () => { const events: DriverEvent[] = [] - const session = await driverWith(CREATED).start({ cwd: '/repo', onEvent: e => events.push(e) }) + const session = await driverWith().start({ cwd: '/repo', onEvent: e => events.push(e) }) await session.prompt('go') assert.ok(events.some(e => e.type === 'action' && e.label === `cloud ${URL}`)) assert.ok(events.some(e => e.type === 'result' && e.sessionId === SESSION)) // The result also carries the real URL (#1317), which is what reaches the agent meta. assert.ok(events.some(e => e.type === 'result' && e.sessionLink === URL)) + assert.ok(events.some(e => e.type === 'notice' && /asked the browser extension/.test(e.message))) }) test('the task leads the prompt; framing and per-call system follow behind labeled rules (#1497)', async () => { - const calls: AgentPtyOptions[] = [] - const session = await driverWith(CREATED, calls).start({ cwd: '/repo', system: 'FRAMING' }) + const daemon = fakeDaemon({ status: 202, body: { id: 'req-1' } }, [...CREATED]) + const session = await driverWith(daemon).start({ cwd: '/repo', system: 'FRAMING' }) await session.prompt('do the thing', { system: 'EXTRA' }) + const post = daemon.requests.find(r => r.method === 'POST') assert.equal( - calls[0]?.prompt, + (post?.body as { prompt: string }).prompt, [ 'do the thing', CLOUD_PROMPT_SEPARATOR, @@ -105,7 +95,6 @@ test('the task leads the prompt; framing and per-call system follow behind label 'EXTRA', ].join('\n\n\n'), ) - assert.equal(calls[0]?.cwd, '/repo') }) test('cloudHandOffPrompt with nothing injected is the bare task — no rule, no label', () => { @@ -113,365 +102,120 @@ test('cloudHandOffPrompt with nothing injected is the bare task — no rule, no assert.equal(cloudHandOffPrompt('do the thing', undefined, undefined), 'do the thing') }) -test('the invocation is stopped as soon as the session link lands', async () => { - const calls: AgentPtyOptions[] = [] - const session = await driverWith(CREATED, calls).start({ cwd: '/repo' }) - await session.prompt('go') - assert.equal(calls[0]?.signal.aborted, true, 'the CLI would otherwise sit holding the terminal') -}) - -test('output split across chunks still yields the session', async () => { - const calls: AgentPtyOptions[] = [] - const driver = new CloudDriver({ - agentTag: () => 'tag', - timeoutMs: 1000, - git: fakeGit().run, - claudeConfig: tmpClaudeConfig('/repo'), - runPty: async opts => { - calls.push(opts) - // Split mid-URL: a parser that matched per chunk rather than on the accumulated - // output would miss this, which is the realistic failure. - opts.onData('View: https://claude.ai/code/sess') - opts.onData('ion_01ABCdefGHIjklMNO?from=cli&m=0\r\n') - }, - }) - const session = await driver.start({ cwd: '/repo' }) - assert.equal((await session.prompt('go')).sessionId, SESSION) -}) - -test('a run that created no session fails with what the CLI said', async () => { - const driver = new CloudDriver({ - agentTag: () => 'tag', - timeoutMs: 1000, - git: fakeGit().run, - claudeConfig: tmpClaudeConfig('/repo'), - runPty: async opts => opts.onData('Invalid API key · Fix external API key\r\n'), - }) - const session = await driver.start({ cwd: '/repo' }) - await assert.rejects(session.prompt('go'), /no cloud session was created[\s\S]*Invalid API key/) -}) - -test('a dialog the trust write did not prevent still fails fast with the manual fix (#1493)', async () => { - // The dialog is drawn with cursor moves, so the words arrive with no literal spaces - // between them — matching has to survive that, which is why this fixture looks like this. - // The fixture CLI shows the dialog even though the write above it succeeded — the - // CLI-rejected-our-record scenario the detection stays around for. - const events: DriverEvent[] = [] - const driver = new CloudDriver({ - agentTag: () => 'tag', - timeoutMs: 1000, - git: fakeGit().run, - claudeConfig: tmpClaudeConfig(), - runPty: async opts => { - opts.onData('\x1b[2KQuick\x1b[Csafety\x1b[Ccheck\r\n1.\x1b[CYes,\x1b[CI\x1b[Ctrust\x1b[Cthis\x1b[Cfolder\r\n') - // The driver aborts synchronously from inside onData, so check before waiting: a - // listener added after the fact never fires. - if (!opts.signal.aborted) await new Promise(r => opts.signal.addEventListener('abort', () => r(), { once: true })) - }, - }) - const session = await driver.start({ cwd: '/repo', onEvent: e => events.push(e) }) - await assert.rejects(session.prompt('go'), /no cloud session was created — the workspace is not trusted[\s\S]*Run `claude` in \/repo once/) - const notice = events.find((e): e is DriverEvent & { type: 'notice' } => e.type === 'notice' && /has not been trusted/.test(e.message)) - assert.ok(notice && /has not been trusted in \/repo/.test(notice.message)) - assert.ok(notice && /Run `claude` in \/repo once/.test(notice.message)) -}) - -test('trust advice for a run worktree names the project root, which outlives the worktree', async () => { - // Trust is per directory and inherited downward (a fresh worktree of a trusted root shows - // no dialog), so trusting the root once covers every agent worktree — the old advice named - // the ephemeral worktree path, which is gone before anyone could follow it. - const events: DriverEvent[] = [] - const driver = new CloudDriver({ - agentTag: () => 'tag', - timeoutMs: 1000, - git: fakeGit().run, - claudeConfig: tmpClaudeConfig(), - runPty: async opts => { - opts.onData('Quick\x1b[Csafety\x1b[Ccheck\r\n1.\x1b[CYes,\x1b[CI\x1b[Ctrust\x1b[Cthis\x1b[Cfolder\r\n') - if (!opts.signal.aborted) await new Promise(r => opts.signal.addEventListener('abort', () => r(), { once: true })) - }, - }) - const cwd = '/repo/.the-framework/branches/tf-agent-2026-07-27T17-30-20-703Z' - const session = await driver.start({ cwd, onEvent: e => events.push(e) }) - await assert.rejects(session.prompt('go'), /Run `claude` in \/repo once/) - const notice = events.find((e): e is DriverEvent & { type: 'notice' } => e.type === 'notice' && /has not been trusted/.test(e.message)) - assert.ok(notice && /has not been trusted in \/repo,/.test(notice.message), 'the notice must name the root, not the worktree') - assert.ok(notice && !notice.message.includes('/branches/'), 'the worktree path helps nobody') -}) - -test('a web run trusts the project root before the hand-off, and says so (#1493)', async () => { - const events: DriverEvent[] = [] - const config = tmpClaudeConfig() - const pty = fakePty(CREATED) - const driver = new CloudDriver({ runPty: pty.run, git: fakeGit().run, agentTag: () => 'tag', timeoutMs: 1000, claudeConfig: config }) - // An agent cwd: the record must land on the root — worktrees inherit it — not the worktree. - const cwd = '/repo/.the-framework/branches/tf-agent-2026-01-01T00-00-00-000Z' - const session = await driver.start({ cwd, onEvent: e => events.push(e) }) - assert.equal((await session.prompt('go')).sessionId, SESSION) - const written = JSON.parse(await readFile(config, 'utf8')) - assert.equal(written.projects['/repo'].hasTrustDialogAccepted, true) - assert.equal(written.projects[cwd], undefined) - // The act stays visible: consent was the user's, the write on their behalf is still said. - const notice = events.find((e): e is DriverEvent & { type: 'notice' } => e.type === 'notice') - assert.ok(notice && /trusted \/repo for Claude Code/.test(notice.message)) -}) - -test('an already-trusted root is left alone, with nothing to announce (#1493)', async () => { +test('the hand-off pushes the anchor under the agent id and hands the session that ref (#1320/#1601)', async () => { + const git = fakeGit() + const daemon = fakeDaemon({ status: 202, body: { id: 'req-1' } }, [...CREATED]) const events: DriverEvent[] = [] - const session = await driverWith(CREATED).start({ cwd: '/repo', onEvent: e => events.push(e) }) + const session = await driverWith(daemon, git).start({ cwd: '/repo', onEvent: e => events.push(e) }) await session.prompt('go') - assert.ok(!events.some(e => e.type === 'notice' && /trusted \/repo/.test(e.message))) -}) - -test('a failed trust write says so and still hands off, the dialog detection as the net (#1493)', async () => { - // An existing config we cannot parse is not ours to replace — the write throws, the run - // continues, and the CLI decides: here it starts anyway, so the hand-off still lands. - const events: DriverEvent[] = [] - const config = tmpClaudeConfig() - writeFileSync(config, 'not json {') - const pty = fakePty(CREATED) - const driver = new CloudDriver({ runPty: pty.run, git: fakeGit().run, agentTag: () => 'tag', timeoutMs: 1000, claudeConfig: config }) - const session = await driver.start({ cwd: '/repo', onEvent: e => events.push(e) }) - assert.equal((await session.prompt('go')).sessionId, SESSION) - const notice = events.find((e): e is DriverEvent & { type: 'notice' } => e.type === 'notice') - assert.ok(notice && /could not record Claude Code trust for \/repo/.test(notice.message)) + const commitTree = git.calls.find(c => c[0] === 'commit-tree') + assert.deepEqual(commitTree, ['commit-tree', 'HEAD^{tree}', '-p', 'HEAD', '-m', `[The Framework] web hand-off ${session.id}`]) + const push = git.calls.find(c => c[0] === 'push') + assert.deepEqual(push, ['push', 'origin', `${ANCHOR}:refs/heads/${session.id}`]) + assert.ok(git.calls.indexOf(push!) < daemon.requests.length + git.calls.indexOf(push!), 'pushed before the request') + assert.equal((daemon.requests.find(r => r.method === 'POST')?.body as { branch: string }).branch, session.id) + // The anchor is recorded on the result, so the branch can be recognized later (#1601). + assert.ok(events.some(e => e.type === 'result' && e.anchorSha === ANCHOR)) }) -test('trustRootOf strips exactly the run-worktree suffix and nothing else', () => { - assert.equal(trustRootOf('/repo/.the-framework/branches/tf-agent-2026-01-01T00-00-00-000Z'), '/repo') - assert.equal(trustRootOf('/repo'), '/repo') - assert.equal(trustRootOf('/repo/packages/app'), '/repo/packages/app') - // A deeper path inside an agent worktree is not the worktree itself: leave it alone rather - // than guess. - assert.equal(trustRootOf('/repo/.the-framework/branches/run1/nested'), '/repo/.the-framework/branches/run1/nested') +test('a failed pre-push fails the run naming the remote, since the session must open on that ref (#1320)', async () => { + const daemon = fakeDaemon({ status: 202, body: { id: 'req-1' } }, [...CREATED]) + const session = await driverWith(daemon, fakeGit({ fail: true })).start({ cwd: '/repo' }) + await assert.rejects(session.prompt('go'), /could not push the hand-off ref .* pushable GitHub remote/) + assert.equal(daemon.requests.length, 0, 'nothing was asked of the extension') }) -test('the prompt sits directly after --cloud, ahead of the model flag', () => { - // The description is `--cloud`'s own value, not a positional argument. With the model flag - // in between, every agent on an account with a model preference died on "--cloud requires a - // description" while runs without one worked, which is what made it look unrelated to the - // model at first. Nothing else observes this order, so it is pinned here. - const promptAt = CLOUD_COMMAND.indexOf('"$FW_CLOUD_PROMPT"') - const modelAt = CLOUD_COMMAND.indexOf('FW_CLOUD_MODEL') - assert.ok(promptAt > 0 && modelAt > 0) - assert.ok(promptAt < modelAt, 'the prompt must be the argument to --cloud') - assert.match(CLOUD_COMMAND, /--cloud "\$FW_CLOUD_PROMPT"/) +test('a checkout with no GitHub remote fails naming the need, before pushing anything (#1328)', async () => { + const git = fakeGit({ noRemote: true }) + const daemon = fakeDaemon({ status: 202, body: { id: 'req-1' } }, [...CREATED]) + const session = await driverWith(daemon, git).start({ cwd: '/repo' }) + await assert.rejects(session.prompt('go'), /no GitHub remote here/) + assert.ok(!git.calls.some(c => c[0] === 'push')) + assert.equal(daemon.requests.length, 0) }) -test('the shell command never interpolates the prompt or the model as syntax', () => { - // Both arrive through the environment, so the hosted command stays a fixed literal. - assert.ok(!CLOUD_COMMAND.includes('${FW_CLOUD_PROMPT}')) - assert.match(CLOUD_COMMAND, /^exec "\$FW_CLOUD_BIN"/) +test('a run no daemon spawned fails saying web runs start from the dashboard (#1328)', async () => { + const session = await new CloudDriver({ git: fakeGit().run, agentTag: () => 'tag' }).start({ cwd: '/repo' }) + await assert.rejects(session.prompt('go'), /not started by a daemon.*start web runs from the dashboard/) }) -test('an unsafe model id never reaches the shell', async () => { - const calls: AgentPtyOptions[] = [] - const session = await driverWith(CREATED, calls).start({ cwd: '/repo', model: 'opus"; rm -rf /' }) - await assert.rejects(session.prompt('go'), /unsafe model id/) - assert.equal(calls.length, 0, 'nothing should have been spawned') +test('no extension around (409) and a bridge that is off (404) each fail naming the cure (#1328)', async () => { + const none = await driverWith(fakeDaemon({ status: 409 }, [])).start({ cwd: '/repo' }) + await assert.rejects(none.prompt('go'), /no browser extension has spoken .* install or reload/) + const off = await driverWith(fakeDaemon({ status: 404 }, [])).start({ cwd: '/repo' }) + await assert.rejects(off.prompt('go'), /browser bridge is off .* Settings/) }) -test('a safe model id is passed through', async () => { - const calls: AgentPtyOptions[] = [] - const session = await driverWith(CREATED, calls).start({ cwd: '/repo', model: 'claude-opus-5' }) - await session.prompt('go') - assert.equal(calls[0]?.model, 'claude-opus-5') +test('an extension that tried and failed fails the turn with its note (#1328)', async () => { + const daemon = fakeDaemon({ status: 202, body: { id: 'req-1' } }, [{ state: 'failed', note: 'no repo picker on the page' }]) + const session = await driverWith(daemon).start({ cwd: '/repo' }) + await assert.rejects(session.prompt('go'), /could not create the session — no repo picker on the page/) }) test('a run hands off ONCE, however many times the loop prompts', async () => { - // The regression this exists for: an agent is not one prompt. The loop prompts per pass, and - // spawning a session each time turned one agent into six of them racing on the same repo. - const calls: AgentPtyOptions[] = [] - const session = await driverWith(CREATED, calls).start({ cwd: '/repo' }) - const first = await session.prompt('build the thing') - const second = await session.prompt('now review it') - const third = await session.prompt('and again') - assert.equal(calls.length, 1, 'only the first prompt may spend a cloud session') + const daemon = fakeDaemon({ status: 202, body: { id: 'req-1' } }, [...CREATED]) + const session = await driverWith(daemon).start({ cwd: '/repo' }) + const first = await session.prompt('go') + const second = await session.prompt('and again') + const third = await session.prompt('once more') + assert.equal(daemon.requests.filter(r => r.method === 'POST').length, 1, 'one cloud session, not three') assert.equal(second.sessionId, first.sessionId) assert.equal(third.sessionId, first.sessionId) }) test('a later pass says the work is already in the cloud, rather than repeating the hand-off', async () => { - const session = await driverWith(CREATED).start({ cwd: '/repo' }) - const first = await session.prompt('build the thing') - const second = await session.prompt('now review it') - assert.match(first.text, /^Handed off to Claude Code on the web/) - assert.match(second.text, /already handed off/i) - assert.match(second.text, /nothing further to do here/i) - // Both still point at the same place, so the agent view links through either way. - assert.match(second.text, new RegExp(SESSION)) + const session = await driverWith().start({ cwd: '/repo' }) + const first = await session.prompt('go') + const again = await session.prompt('go on') + assert.match(first.text, /Handed off to Claude Code on the web/) + assert.match(again.text, /already/) + assert.doesNotMatch(again.text, /Handed off to Claude Code on the web\./) }) test('the cloud link event fires once, so the run view shows one session', async () => { const events: DriverEvent[] = [] - const session = await driverWith(CREATED).start({ cwd: '/repo', onEvent: e => events.push(e) }) - await session.prompt('build the thing') - await session.prompt('now review it') - const links = events.filter(e => e.type === 'action' && e.label.startsWith('cloud ')) - assert.equal(links.length, 1) + const session = await driverWith().start({ cwd: '/repo', onEvent: e => events.push(e) }) + await session.prompt('go') + await session.prompt('go on') + assert.equal(events.filter(e => e.type === 'action').length, 1) }) test('session ids are unique per session, so two runs never collide', async () => { - const driver = new CloudDriver({ runPty: fakePty(CREATED).run }) + const driver = new CloudDriver({ git: fakeGit().run }) const a = await driver.start({ cwd: '/repo' }) const b = await driver.start({ cwd: '/repo' }) assert.notEqual(a.id, b.id) }) test('a disposed session refuses further prompts', async () => { - const session = await driverWith(CREATED).start({ cwd: '/repo' }) + const session = await driverWith().start({ cwd: '/repo' }) await session.dispose() await assert.rejects(session.prompt('go'), /disposed/) }) -test('an already-aborted signal stops the prompt before spawning anything', async () => { - const calls: AgentPtyOptions[] = [] +test('an already-aborted signal stops the prompt before asking anything', async () => { + const daemon = fakeDaemon({ status: 202, body: { id: 'req-1' } }, [...CREATED]) const controller = new AbortController() controller.abort() - const session = await driverWith(CREATED, calls).start({ cwd: '/repo', signal: controller.signal }) + const session = await driverWith(daemon).start({ cwd: '/repo', signal: controller.signal }) await assert.rejects(session.prompt('go'), /aborted/) - assert.equal(calls.length, 0) + assert.equal(daemon.requests.length, 0) +}) + +test('waiting past the timeout gives up naming the extension', async () => { + const daemon = fakeDaemon({ status: 202, body: { id: 'req-1' } }, [{ state: 'claimed' }]) + const extension: ExtensionStart = { daemonUrl: 'http://127.0.0.1:4200', token: 'tok', fetch: daemon.fetch, pollMs: 5 } + const session = await new CloudDriver({ extension, git: fakeGit().run, agentTag: () => 'tag', timeoutMs: 40 }).start({ cwd: '/repo' }) + await assert.rejects(session.prompt('go'), /gave up waiting for the browser extension/) }) test('there is no readCode: the workspace lives in a cloud VM', async () => { - const session = await driverWith(CREATED).start({ cwd: '/repo' }) + const session = await driverWith().start({ cwd: '/repo' }) assert.equal(session.readCode, undefined) }) test('the web location is the hand-off, so a run ends at the first prompt (#1225/D1)', () => { - // Load-bearing rather than descriptive: this is what stops an agent working the backlog and - // asking about work that left this machine with the first prompt. It is a fact about *where* - // the turn ran, so it hangs off the location rather than off the driver that spawned it. assert.equal(isHandsOff('web'), true) assert.equal(isHandsOff('local'), false) - assert.equal(isHandsOff('actions'), false, 'an Actions runner streams its own replies') -}) - -test('the hand-off pushes the anchor under the agent id and hands the session that ref (#1320/#1601)', async () => { - const git = fakeGit() - const events: DriverEvent[] = [] - const ptyCalls: AgentPtyOptions[] = [] - const session = await driverWith(CREATED, ptyCalls, git).start({ cwd: '/repo', onEvent: e => events.push(e) }) - await session.prompt('go') - // The anchor commit first (#1601): an empty commit on top of HEAD, minted without moving any - // branch, whose message carries the run's own id. Then one push, of that anchor, under the - // agent's own id — which contains no slash, because a slash-carrying ref never resolves on - // the cloud side (anthropics/claude-code#87235). - assert.deepEqual(git.calls, [ - ['commit-tree', 'HEAD^{tree}', '-p', 'HEAD', '-m', `[The Framework] web hand-off ${session.id}`], - ['push', 'origin', `${ANCHOR}:refs/heads/${session.id}`], - ]) - assert.ok(!session.id.includes('/')) - assert.equal(ptyCalls[0]?.ref, session.id) - // The anchor reaches the meta through the result (#1601): it is how the daemon later - // recognizes which `claude/*` branch is this run's. - assert.ok(events.some(e => e.type === 'result' && e.anchorSha === ANCHOR)) -}) - -test('a failed pre-push falls back to no ref, says so, and still hands off (#1320)', async () => { - const events: DriverEvent[] = [] - const ptyCalls: AgentPtyOptions[] = [] - const session = await driverWith(CREATED, ptyCalls, fakeGit([], true)).start({ cwd: '/repo', onEvent: e => events.push(e) }) - const turn = await session.prompt('go') - assert.equal(turn.sessionId, SESSION) - assert.equal(ptyCalls[0]?.ref, undefined) - const notice = events.find((e): e is DriverEvent & { type: 'notice' } => e.type === 'notice') - assert.ok(notice && /could not push/.test(notice.message)) - assert.ok(notice && /--teleport/.test(notice.message), 'the notice names the recovery path') - // A push that failed leaves nothing on origin containing the anchor, so none is reported (#1601). - assert.ok(events.every(e => e.type !== 'result' || e.anchorSha === undefined)) -}) - -test('the ref rides the fixed command as its own guarded flag, after the model (#1320)', () => { - assert.match(CLOUD_COMMAND, /\$\{FW_CLOUD_REF:\+--ref "\$FW_CLOUD_REF"\}/) - assert.ok(CLOUD_COMMAND.indexOf('FW_CLOUD_MODEL') < CLOUD_COMMAND.indexOf('FW_CLOUD_REF'), 'the prompt slot rule (#1497) extends: nothing may sit between --cloud and its description') -}) - -test('the invocation disables nonessential traffic, which is what keeps the session repo-bound (#1320)', () => { - assert.equal(CLOUD_ENV['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'], '1') -}) - -// --------------------------------------------------------------------------- -// The extension-created session (#1328): the run asks its daemon, the extension does the page. - -/** A git runner whose origin is on GitHub, so the repo picker has a slug to look for. */ -function githubGit(calls: string[][] = []) { - return { - calls, - run: async (args: string[], _cwd: string): Promise => { - calls.push([...args]) - if (args[0] === 'commit-tree') return `${ANCHOR}\n` - if (args[0] === 'remote') return 'git@github.com:framework/the-framework.git\n' - return '' - }, - } -} - -/** - * A daemon whose start-queue answers as scripted: the POST gets `queue`, then each poll pops the - * next `states` entry (the last one repeats). Records every request the run made. - */ -function fakeDaemon(queue: { status: number; body?: unknown }, states: unknown[]) { - const requests: { method: string; path: string; body?: unknown }[] = [] - const doFetch = (async (input: string | URL | Request, init?: RequestInit) => { - const path = new globalThis.URL(String(input)).pathname - const method = init?.method ?? 'GET' - requests.push({ method, path, ...(init?.body ? { body: JSON.parse(String(init.body)) } : {}) }) - if (method === 'POST') { - return new Response(queue.body === undefined ? 'nope' : JSON.stringify(queue.body), { status: queue.status }) - } - const state = states.length > 1 ? states.shift() : states[0] - return new Response(JSON.stringify(state), { status: 200 }) - }) as typeof fetch - return { requests, fetch: doFetch } -} - -function extensionDriver(daemon: ReturnType, calls: AgentPtyOptions[] = [], git = githubGit()) { - const pty = fakePty(CREATED, calls) - const extension: ExtensionStart = { daemonUrl: 'http://127.0.0.1:4200/', token: 'tok', fetch: daemon.fetch, pollMs: 1 } - return new CloudDriver({ extension, runPty: pty.run, git: git.run, agentTag: () => 'tag', timeoutMs: 1000, claudeConfig: tmpClaudeConfig('/repo') }) -} - -test('with an extension around, the session comes from the start-queue and the CLI never runs (#1328)', async () => { - const daemon = fakeDaemon({ status: 202, body: { id: 'req-1' } }, [{ state: 'queued' }, { state: 'claimed' }, { state: 'created', sessionId: SESSION, url: `https://claude.ai/code/${SESSION}` }]) - const calls: AgentPtyOptions[] = [] - const events: DriverEvent[] = [] - const session = await extensionDriver(daemon, calls).start({ cwd: '/repo', onEvent: e => events.push(e) }) - const turn = await session.prompt('Add the --verbose flag') - assert.equal(turn.sessionId, SESSION) - assert.equal(calls.length, 0, 'no --cloud invocation') - // The request names the repo the picker lists, the pushed hand-off ref, and the whole prompt. - const post = daemon.requests.find(r => r.method === 'POST') - assert.deepEqual(post?.body, { repo: 'framework/the-framework', branch: session.id, prompt: 'Add the --verbose flag' }) - assert.equal(daemon.requests.filter(r => r.method === 'GET').length, 3, 'polled until created') - assert.ok(events.some(e => e.type === 'result' && e.sessionLink === `https://claude.ai/code/${SESSION}`)) - assert.ok(events.some(e => e.type === 'notice' && /asked the browser extension/.test(e.message))) -}) - -test('no extension around (409) hands off through the CLI instead (#1328)', async () => { - const daemon = fakeDaemon({ status: 409 }, []) - const calls: AgentPtyOptions[] = [] - const events: DriverEvent[] = [] - const session = await extensionDriver(daemon, calls).start({ cwd: '/repo', onEvent: e => events.push(e) }) - const turn = await session.prompt('go') - assert.equal(turn.sessionId, SESSION, 'the CLI path created it') - assert.equal(calls.length, 1) - assert.ok(events.some(e => e.type === 'notice' && /no browser extension is around/.test(e.message))) -}) - -test('a checkout with no GitHub remote never asks the extension (#1328)', async () => { - const daemon = fakeDaemon({ status: 202, body: { id: 'req-1' } }, []) - const calls: AgentPtyOptions[] = [] - const session = await extensionDriver(daemon, calls, fakeGit()).start({ cwd: '/repo' }) - await session.prompt('go') - assert.equal(daemon.requests.length, 0, 'nothing to name in the repo picker') - assert.equal(calls.length, 1, 'the CLI path ran') -}) - -test('an extension that tried and failed fails the turn with its note, not a silent CLI retry (#1328)', async () => { - const daemon = fakeDaemon({ status: 202, body: { id: 'req-1' } }, [{ state: 'failed', note: 'no repo picker on the page' }]) - const calls: AgentPtyOptions[] = [] - const session = await extensionDriver(daemon, calls).start({ cwd: '/repo' }) - await assert.rejects(session.prompt('go'), /could not create the session — no repo picker on the page/) - assert.equal(calls.length, 0) + assert.equal(isHandsOff('actions'), false) }) diff --git a/packages/framework/src/driver/cloud.ts b/packages/framework/src/driver/cloud.ts index 0747b8d15..361b61c5d 100644 --- a/packages/framework/src/driver/cloud.ts +++ b/packages/framework/src/driver/cloud.ts @@ -1,32 +1,26 @@ import { randomUUID } from 'node:crypto' -import { spawn as nodeSpawn } from 'node:child_process' -import { closeSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { killTree, registerChild, unregisterChild } from './child-registry.js' import { makeEmit } from './session-support.js' import { nodeGitRunner, type GitRunner } from '../project.js' -import { readClaudeTrust, writeClaudeTrust } from '../claude-trust.js' import { errorMessage } from '../error-message.js' import { githubSlugFor } from '../dashboard/github.js' import { WEB_START_PREFIX } from '../dashboard/web-start-endpoints.js' import type { Driver, DriverEvent, DriverPromptOptions, DriverSession, DriverStartOptions, DriverTurn } from './types.js' /** - * A {@link Driver} that hands the task to **Claude Code on the web** (#610): it starts a - * real cloud session on claude.ai and returns its id and URL. + * A {@link Driver} that hands the task to **Claude Code on the web** (#610): it has a cloud + * session created on claude.ai and returns its id and URL. * - * The mechanism is the CLI's own `--cloud` flag, so the account, the auth and the - * quota are the user's, exactly as with the local driver (#495). Nothing here drives - * the claude.ai UI: no browser, no extension, no scraping — the two earlier candidates - * for this issue, both of which the Usage Policy rules out. + * The session is created by the browser extension, in the user's own signed-in browser, + * through claude.ai's repository picker (#1328): the run asks its daemon to queue the request + * — the repository, the pushed hand-off ref, the prompt — and the extension drives the page + * and reports the session it became. A session created that way is bound to the repository, + * so it can push its work and open its pull request; the CLI's own `--cloud` flag, the earlier + * mechanism, produced on some accounts a bundle upload that never could (#1320), and is gone. + * The account, the auth and the quota are the user's, exactly as with the local driver (#495). * - * **Why a pty.** `--cloud` refuses to run when stdout is a pipe, because a non-interactive - * invocation would silently run locally instead. That check is about the *terminal*, not - * about a human, so running the CLI under a pty satisfies it. `script` supplies the pty - * (present on macOS and Linux) and the prompt travels in the environment, never inside a - * shell string — the command string is a fixed literal, so no prompt text can reach the - * shell as syntax. + * So a web run needs four things, and names whichever is missing: a daemon that spawned it + * (the daemon's URL travels in the run's environment), the browser bridge switched on, the + * extension present, and a GitHub remote for the picker to name. * * **What this target is, and is not.** It is a hand-off: the session runs on Anthropic's * infrastructure, does its own git worktree and opens its own PR, at 0% local CPU — the @@ -69,20 +63,14 @@ export interface ExtensionStart { /** Options for {@link CloudDriver}. */ export interface CloudDriverOptions { /** - * Ask the daemon for a session created by the browser extension (#1328) before falling back - * to the CLI's `--cloud`. Absent on a run no daemon spawned, which has no daemon to ask. + * The daemon whose start-queue the browser extension drains (#1328). Absent on a run no + * daemon spawned, which then has nobody to create its session and fails saying so. */ extension?: ExtensionStart - /** Claude Code binary. Default `"claude"`. */ - bin?: string /** Give up on session creation after this long, in ms. Default 120000. */ timeoutMs?: number - /** Run one pty-hosted invocation. Injected in tests; defaults to a real `script` pty. */ - runPty?: RunPty /** Runs git for the pre-hand-off push (#1320). Injected in tests; defaults to real git. */ git?: GitRunner - /** The CLI's config file the pre-hand-off trust write (#1493) touches. Injected in tests; defaults to `~/.claude.json`. */ - claudeConfig?: string /** * Unique tag mixed into the session id. Default a random token. Injected in tests for a * stable id, and load-bearing in production for the same reason it is in the Actions @@ -92,85 +80,15 @@ export interface CloudDriverOptions { agentTag?: () => string } -/** One pty-hosted invocation: stream its output, resolve when it ends. */ -export type RunPty = (opts: AgentPtyOptions) => Promise - -/** What {@link RunPty} needs to run one invocation. */ -export interface AgentPtyOptions { - /** Claude Code binary to run under the pty. */ - bin: string - /** The prompt, handed over through the environment rather than the command line. */ - prompt: string - /** Model id to pass through, when one was chosen. */ - model?: string | undefined - /** - * The ref the session clones at (#1320), pushed to origin just before this invocation. - * Absent when that push failed — the CLI then pins its own default, which is the current - * local branch and fails in-session when that branch is not on origin. - */ - ref?: string | undefined - /** Workspace the CLI runs in — the repo whose remote the cloud session clones. */ - cwd: string - /** Called with each chunk of terminal output. */ - onData: (chunk: string) => void - /** Stop the invocation: the caller has what it needs, or the agent was aborted. */ - signal: AbortSignal -} - /** Counter feeding the per-process half of a session id. */ let sessionCounter = 0 -/** Control sequences a terminal emits around the text we actually want to read. */ -const ANSI = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b[()][A-Z]|\x1b[=>]|\x1b\][^\x07]*(?:\x07|\x1b\\)/g - -/** The session link the CLI prints once the cloud session exists. */ -const SESSION_URL = /https:\/\/claude\.ai\/code\/(session_[A-Za-z0-9]+)\S*/ - -/** - * The workspace-trust question, matched with every space removed so a terminal that draws - * the words with cursor moves rather than literal spaces still matches. The pre-hand-off - * trust write (#1493) should keep this dialog from ever appearing; it is still detected as - * the safety net for when that write failed or the CLI rejected it, so the run says what it - * is parked on — with the manual fix named — instead of timing out with nothing to show. - */ -const TRUST_PROMPT = 'trustthisfolder' - -/** - * The project root an agent worktree belongs to, which is where trust has to be granted. - * - * The CLI records trust per directory and everything under a trusted directory inherits it - * (verified live: a fresh worktree of a trusted root boots straight to the REPL, a fresh - * worktree of an untrusted root always shows the dialog). An agent's cwd is an ephemeral - * worktree — gone before the user could act on advice that names it — so the only advice - * that works is: trust the root once, and every agent worktree under it is covered. - */ -export function trustRootOf(cwd: string): string { - const match = /^(.+?)\/\.the-framework\/branches\/[^/]+\/?$/.exec(cwd) - return match ? match[1]! : cwd -} - -/** The one-time fix for an untrusted workspace, phrased against the root, not the worktree. */ -function trustAdvice(cwd: string): string { - const root = trustRootOf(cwd) - return `Run \`claude\` in ${root} once and accept the trust prompt — run worktrees inherit the root's trust — then start a new web run.` -} - -/** Model ids we will pass through, kept to characters that cannot act as shell syntax. */ -const SAFE_MODEL = /^[A-Za-z0-9._:-]+$/ - -/** - * The rule between the task and the injected instructions in a hand-off prompt (#1497). - * Exported so a test can pin the exact seam the claude.ai reader sees. - */ export const CLOUD_PROMPT_SEPARATOR = '===============================' /** - * Assemble the one prompt a cloud session receives (#1497). Unlike every streamed driver — - * where the system channel is invisible plumbing — this whole string is what a *human* reads - * when they open the claude.ai session. So the task comes first (it is what the user is - * looking for), and each injected block follows behind a hard `===` rule with a one-line - * label, because the blocks' own markdown headers run into each other and read as one - * confusing document without it. + * The prompt handed to the cloud session (#1497): the task first, then every block The Framework + * injects — the framing, a per-call system — behind a labeled rule. On claude.ai this string is + * read by a human, and the task is what they open the session to find. */ export function cloudHandOffPrompt(task: string, ...injected: (string | undefined)[]): string { const blocks = injected.filter((part): part is string => Boolean(part)) @@ -180,19 +98,12 @@ export function cloudHandOffPrompt(task: string, ...injected: (string | undefine return `${task}${rule}${header}\n\n${blocks.join(rule)}` } -/** - * One hand-off to Claude Code on the web — **exactly one, for the life of the session.** - * - * An agent is not a single prompt. The loop prompts again for every pass (plan, build, review, - * the TODO backlog), so a driver that started a cloud session per prompt turned one agent into - * six of them on the account. That is not a caveat, it is the wrong shape: the same task - * handed to six independent cloud VMs is six agents racing on one repo. - * - * So the first prompt hands off, and every later one reports the hand-off that already - * happened without spending another session. There is no continuation to offer either way — - * the CLI can start a cloud session and pull one back, but it cannot send a second message - * to one, so the honest answer to "keep going" is "this agent is already over there". - */ +/** What a web run is missing when it cannot hand off, each named so the fix is the message. */ +const NO_DAEMON = '[framework] claude-web: this run was not started by a daemon, so nothing can hand it to the browser extension — start web runs from the dashboard.' +const NO_REMOTE = '[framework] claude-web: no GitHub remote here — the cloud session is created on a repository the browser extension picks on claude.ai, so the project needs an `origin` on GitHub.' +const NO_EXTENSION = '[framework] claude-web: no browser extension has spoken to this daemon recently — install or reload The Framework extension on a claude.ai tab (and keep the browser bridge on in Settings), then start the run again.' +const BRIDGE_OFF = '[framework] claude-web: the browser bridge is off — turn it on in Settings so the extension can create the cloud session.' + export class CloudSession implements DriverSession { readonly id: string readonly cwd: string @@ -218,8 +129,6 @@ export class CloudSession implements DriverSession { async prompt(text: string, opts: DriverPromptOptions = {}): Promise { if (this.disposed) throw new Error('[framework] claude-web session disposed') - // Task first, injected framing behind labeled rules (#1497): on claude.ai this string is - // read by a human, and the task is what they open the session to find. const full = cloudHandOffPrompt(text, this.framing, opts.system) this.emit({ type: 'start', prompt: full }) @@ -237,183 +146,75 @@ export class CloudSession implements DriverSession { } signal.addEventListener('abort', () => controller.abort(), { once: true }) } + const timer = setTimeout(() => controller.abort(), this.config.timeoutMs ?? 120_000) - const timeoutMs = this.config.timeoutMs ?? 120_000 - const timer = setTimeout(() => controller.abort(), timeoutMs) - const model = this.startOpts.model - if (model !== undefined && !SAFE_MODEL.test(model)) { - clearTimeout(timer) - this.controllers.delete(controller) - throw new Error(`[framework] claude-web: unsafe model id ${JSON.stringify(model)}`) - } - - // The pre-hand-off trust write (#1493): the CLI's one-time "trust this folder?" dialog - // cannot be answered under the daemon's pty, and the manual one-time fix broke the - // "click and it works" story for web runs. Starting a web agent on the project is itself - // the user's trust decision, so record it the way the CLI itself would. Best-effort: a - // failed write falls through to the dialog detection below and its manual advice. - const trustRoot = trustRootOf(this.cwd) try { - if (!(await readClaudeTrust(trustRoot, this.config.claudeConfig)).trusted) { - await writeClaudeTrust(trustRoot, this.config.claudeConfig) - this.emit({ - type: 'notice', - message: `[framework] claude-web: trusted ${trustRoot} for Claude Code on behalf of this run (#1493).`, - }) + const ext = this.config.extension + if (!ext) throw new Error(NO_DAEMON) + const git = this.config.git ?? nodeGitRunner() + const slug = await githubSlugFor(this.cwd, git) + if (!slug) throw new Error(NO_REMOTE) + + // The pre-hand-off push (#1320): the cloud session opens on a named origin ref, so the + // repository picker's branch list must offer this run's starting point. What gets pushed is + // not HEAD itself but the hand-off anchor (#1601): an empty commit on top of it, so the ref + // is recognizably this run's and a later sweep can tell it from a branch a person made. + // Minting and pushing are one step: a checkout that cannot mint an empty commit on its + // HEAD has no HEAD to push either. A push that fails fails the run — the session cannot + // open on a ref origin does not have. + let anchor: string + try { + anchor = (await git(['commit-tree', 'HEAD^{tree}', '-p', 'HEAD', '-m', `[The Framework] web hand-off ${this.id}`], this.cwd)).trim() + await git(['push', 'origin', `${anchor}:refs/heads/${this.id}`], this.cwd) + } catch (err) { + throw new Error(`[framework] claude-web: could not push the hand-off ref ${this.id} to origin (${errorMessage(err)}) — the cloud session opens on that ref, so the project needs a pushable GitHub remote.`) } - } catch (err) { - this.emit({ - type: 'notice', - message: `[framework] claude-web: could not record Claude Code trust for ${trustRoot} (${errorMessage(err)}) — if the CLI asks its trust question, this run will fail with the manual fix named.`, - }) - } - // The pre-hand-off push (#1320): the cloud session clones the repo at a named origin ref, - // and its two failure modes are both local facts — the CLI's default pin is the current - // branch, which an agent worktree's local-only run branch fails, and a slash-carrying - // name (every `the-framework/...` branch) never resolves on the cloud side even when - // pushed (anthropics/claude-code#87235). So push under this agent's own id — unique, - // slash-free — and hand the session that. Best-effort: a repo with no pushable remote - // falls back to the CLI's default, which still works wherever it worked before, and the - // notice names what a stranded session will look like. - // - // What gets pushed is not HEAD itself but the hand-off anchor (#1601): an empty commit on - // top of HEAD, minted with `commit-tree` so no local branch moves. The session does its - // work on a branch of its own naming (`claude/*`), never the designated run branch — and - // since every commit it makes descends from what it cloned, a commit unique to this run is - // the one exact mark by which the daemon can later recognize which `claude/*` branch is - // this run's. Minting and pushing are one step: a checkout that cannot mint an empty commit - // on its HEAD has no HEAD to push either. - const git = this.config.git ?? nodeGitRunner() - let ref: string | undefined = this.id - try { - const anchor = (await git(['commit-tree', 'HEAD^{tree}', '-p', 'HEAD', '-m', `[The Framework] web hand-off ${this.id}`], this.cwd)).trim() - await git(['push', 'origin', `${anchor}:refs/heads/${this.id}`], this.cwd) + const found = await this.createViaExtension(ext, `${slug.owner}/${slug.repo}`, this.id, full, controller.signal) this.anchorSha = anchor - } catch (err) { - ref = undefined - this.emit({ - type: 'notice', - message: `[framework] claude-web: could not push ${this.id} to origin (${errorMessage(err)}) — the cloud session may not find this branch and its work would then need \`claude --teleport\` to recover.`, - }) - } - - let output = '' - let trusting = false - let found: { url: string; sessionId: string } | undefined - // The extension path first (#1328): a session created through the page's repo picker is - // repo-bound, which is what lets it push and open the pull request. Undefined means the - // daemon had nobody to hand the request to, and the CLI's own `--cloud` does the hand-off. - if (this.config.extension) { - try { - found = await this.createViaExtension(this.config.extension, full, ref, git, controller.signal) - } finally { - if (found) { - clearTimeout(timer) - this.controllers.delete(controller) - } - } - } - if (found) { this.handedOff = found return this.report(found, 'first') - } - try { - await (this.config.runPty ?? runPtyWithScript)({ - bin: this.config.bin ?? 'claude', - prompt: full, - model, - ref, - cwd: this.cwd, - signal: controller.signal, - onData: chunk => { - output += chunk - if (found) return - const clean = output.replace(ANSI, '') - const match = SESSION_URL.exec(clean) - if (match) { - found = { url: match[0], sessionId: match[1]! } - // The link is what the dashboard needs; the CLI has nothing further to say - // and would otherwise sit holding the terminal, so stop it here. - controller.abort() - return - } - if (!trusting && clean.replace(/\s+/g, '').includes(TRUST_PROMPT)) { - trusting = true - this.emit({ - type: 'notice', - message: `Claude Code has not been trusted in ${trustRootOf(this.cwd)}, so it asks before it will start and the cloud session is never created. ${trustAdvice(this.cwd)}`, - }) - controller.abort() - } - }, - }) + } catch (err) { + // A user abort (Stop, the agent signal) lands here too; name it what it was. + if (this.startOpts.signal?.aborted || opts.signal?.aborted) throw new Error('[framework] claude-web prompt aborted') + throw err } finally { clearTimeout(timer) this.controllers.delete(controller) } - - // A user abort (Stop, the agent signal) also lands here with `found` unset — the pty run - // resolves once the controller aborts. Name it what it was, not "no cloud session was - // created", which is the CLI-failure message. - if (!found && (this.startOpts.signal?.aborted || opts.signal?.aborted)) - throw new Error('[framework] claude-web prompt aborted') - // The trust dialog is a one-time, per-root fix, so fail with that instead of the raw - // dialog text — three identical "no cloud session was created" runs in a row is what - // this looked like before the failure named its own cure. - if (!found && trusting) - throw new Error(`[framework] claude-web: no cloud session was created — the workspace is not trusted by Claude Code. ${trustAdvice(this.cwd)}`) - if (!found) throw new Error(`[framework] claude-web: no cloud session was created.\n${tail(output.replace(ANSI, ''))}`) - - this.handedOff = found - return this.report(found, 'first') } /** * Hand the session request to the daemon's start-queue and wait for the extension's word - * (#1328). Resolves undefined — the CLI path then runs — when the daemon has no extension to - * ask (409), no queue at all (404), when this checkout has no GitHub remote for the repo picker - * to name, or when the hand-off ref was never pushed. Throws when the extension tried and - * failed, naming what it could not find, or when the wait was aborted. + * (#1328). Throws, naming the cure, when the daemon has no extension to ask (409) or the + * bridge is off (404); throws with the extension's own note when it tried and could not create + * the session; throws when the wait was aborted or timed out. */ private async createViaExtension( ext: ExtensionStart, + repo: string, + ref: string, prompt: string, - ref: string | undefined, - git: GitRunner, signal: AbortSignal, - ): Promise<{ url: string; sessionId: string } | undefined> { - const slug = await githubSlugFor(this.cwd, git) - if (!slug || !ref) { - this.emit({ - type: 'notice', - message: `[framework] claude-web: ${slug ? 'the hand-off ref was not pushed' : 'no GitHub remote here'}, so the browser extension cannot create the session — handing off through the CLI instead.`, - }) - return undefined - } + ): Promise<{ url: string; sessionId: string }> { const doFetch = ext.fetch ?? fetch const base = ext.daemonUrl.replace(/\/+$/, '') const headers = { authorization: `Bearer ${ext.token}` } const queued = await doFetch(`${base}${WEB_START_PREFIX}`, { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, - body: JSON.stringify({ repo: `${slug.owner}/${slug.repo}`, branch: ref, prompt }), + body: JSON.stringify({ repo, branch: ref, prompt }), signal, }) - if (queued.status === 409 || queued.status === 404) { - this.emit({ - type: 'notice', - message: `[framework] claude-web: ${queued.status === 409 ? 'no browser extension is around' : 'the daemon has the browser bridge off'}, so the CLI hands off instead.`, - }) - return undefined - } + if (queued.status === 409) throw new Error(NO_EXTENSION) + if (queued.status === 404) throw new Error(BRIDGE_OFF) if (!queued.ok) throw new Error(`[framework] claude-web: the daemon refused the session request (${queued.status}): ${(await queued.text()).slice(0, 300)}`) const { id } = (await queued.json()) as { id: string } - this.emit({ type: 'notice', message: `[framework] claude-web: asked the browser extension to create the cloud session on ${slug.owner}/${slug.repo} at ${ref} (request ${id}).` }) + this.emit({ type: 'notice', message: `[framework] claude-web: asked the browser extension to create the cloud session on ${repo} at ${ref} (request ${id}).` }) const pollMs = ext.pollMs ?? 2000 for (;;) { - if (signal.aborted) throw new Error('[framework] claude-web prompt aborted') + if (signal.aborted) throw new Error('[framework] claude-web: gave up waiting for the browser extension to create the session.') const res = await doFetch(`${base}${WEB_START_PREFIX}/${id}`, { headers, signal }) if (!res.ok) throw new Error(`[framework] claude-web: lost the session request ${id} (${res.status})`) const state = (await res.json()) as { state: string; sessionId?: string; url?: string; note?: string } @@ -468,115 +269,3 @@ export class CloudSession implements DriverSession { } } -/** Keep the tail of a failed invocation's output, enough to show the reason. */ -function tail(text: string, max = 600): string { - const trimmed = text.trim() - return trimmed.length <= max ? trimmed : `...${trimmed.slice(-max)}` -} - -/** - * The shell command `script` hosts. A **fixed literal**: the prompt and the model arrive - * as environment variables, so nothing the user typed is ever parsed as shell syntax. - * `${FW_CLOUD_MODEL:+...}` adds the model flag only when one was chosen. - * - * **The prompt has to come directly after `--cloud`.** The description is that flag's own - * value rather than a loose positional argument, so anything in between claims the slot and - * the CLI stops with "--cloud requires a description". That is why this failed on an account - * with a model preference and worked without one: the model flag was sitting in the slot. - * Exported so a test can pin the order, which is load-bearing and not otherwise observable. - * - * `--ref` names the origin ref the session clones at (#1320) — undocumented in the CLI's - * help but real, and the only revision spelling that works: the CLI's default pin is the - * current local branch, which the cloud side cannot resolve when it is not on origin, and a - * slash-carrying name (every `the-framework/...` branch) never resolves at all - * (anthropics/claude-code#87235). The ref is pushed just before this runs; see the pre-push - * in {@link CloudSession.prompt}. - */ -export const CLOUD_COMMAND = - 'exec "$FW_CLOUD_BIN" --cloud "$FW_CLOUD_PROMPT" ${FW_CLOUD_MODEL:+--model "$FW_CLOUD_MODEL"} ${FW_CLOUD_REF:+--ref "$FW_CLOUD_REF"}' - -/** - * Extra environment for the CLI invocation (#1320): with statsig disabled its gates read - * false, which turns off the server-side `tengu_ccr_bundle_seed_enabled` experiment — the - * flag that converts a failed GitHub-App preflight into a silent local-bundle upload with no - * remote and no push access (anthropics/claude-code#81776). With the gate off, the same - * failed preflight falls through to a repo-bound session that clones from GitHub and can - * push, which is the entire point of handing work to the cloud. Drop this once the upstream - * preflight accepts connected-account access. - */ -export const CLOUD_ENV: Readonly> = { CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' } - -/** - * Run the CLI under a pty supplied by `script`, streaming its terminal output. - * - * `script`'s two dialects differ: BSD (macOS) takes the typescript file then the command - * as argv, util-linux (Linux) takes `-c ` then the file. Both get the same fixed - * command string, so the difference is confined to argv order. - */ -function runPtyWithScript(opts: AgentPtyOptions): Promise { - return new Promise((resolvePromise, rejectPromise) => { - const env: NodeJS.ProcessEnv = { - ...process.env, - ...CLOUD_ENV, - FW_CLOUD_BIN: opts.bin, - FW_CLOUD_PROMPT: opts.prompt, - ...(opts.model !== undefined ? { FW_CLOUD_MODEL: opts.model } : {}), - ...(opts.ref !== undefined ? { FW_CLOUD_REF: opts.ref } : {}), - } - const args = - process.platform === 'darwin' - ? ['-q', '/dev/null', 'sh', '-c', CLOUD_COMMAND] - : ['-qec', CLOUD_COMMAND, '/dev/null'] - // stdin must be a FILE. BSD `script` reads its own stdin's terminal attributes to mirror - // them onto the pty it creates, and a pipe is a socketpair, so it dies with - // "tcgetattr/ioctl: Operation not supported on socket" before running anything. Under a - // daemon there is no terminal to inherit either, so the fd has to be something tcgetattr - // can fail on harmlessly, which a regular file is. - let dir: string - let stdin: number - try { - dir = mkdtempSync(join(tmpdir(), 'framework-cloud-')) - const path = join(dir, 'stdin') - writeFileSync(path, '') - stdin = openSync(path, 'r') - } catch (err) { - rejectPromise(new Error(`[framework] claude-web: could not prepare the pty input (${(err as Error).message})`)) - return - } - const cleanup = () => { - try { - closeSync(stdin) - rmSync(dir, { recursive: true, force: true }) - } catch { - // Best effort: a leftover temp file must not fail an agent that otherwise worked. - } - } - - const child = nodeSpawn('script', args, { cwd: opts.cwd, env, detached: true, stdio: [stdin, 'pipe', 'pipe'] }) - const pid = child.pid - if (pid != null) registerChild(pid) - let settled = false - const finish = (err?: Error) => { - if (settled) return - settled = true - if (pid != null) { - killTree(pid, 'SIGKILL') - unregisterChild(pid) - } - cleanup() - if (err) rejectPromise(err) - else resolvePromise() - } - - // Aborting is the normal ending: the caller stops us the moment the session URL lands. - opts.signal.addEventListener('abort', () => finish(), { once: true }) - - const consume = (chunk: Buffer) => opts.onData(chunk.toString('utf8')) - child.stdout?.on('data', consume) - child.stderr?.on('data', consume) - child.on('error', (err: Error) => - finish(new Error(`[framework] claude-web: could not run the CLI under a pty (${err.message}). \`script\` must be on PATH.`)), - ) - child.on('close', () => finish()) - }) -} diff --git a/packages/framework/src/driver/index.ts b/packages/framework/src/driver/index.ts index 2287cab5a..d016b80ba 100644 --- a/packages/framework/src/driver/index.ts +++ b/packages/framework/src/driver/index.ts @@ -25,7 +25,7 @@ export { type PermissionMode, } from './claude-code.js' export { ActionsDriver, ActionsSession, replayTranscript, type ActionsDriverOptions, type FetchLike } from './actions.js' -export { CloudDriver, CloudSession, type CloudDriverOptions, type RunPty, type AgentPtyOptions } from './cloud.js' +export { CloudDriver, CloudSession, type CloudDriverOptions, type ExtensionStart } from './cloud.js' // `readZip`/`ZipEntry` are deliberately absent (#947): the Actions driver's internal zip reader // rode this barrel onto the published surface via `src/index.ts`'s `export *`, with no importer // outside the driver and its own test — both of which take the module by path. An accidental