From 4d1c822e43851b43298386b0ba0a728fd7208a2e Mon Sep 17 00:00:00 2001 From: Antigravity Date: Wed, 16 Sep 2026 15:35:05 +0800 Subject: [PATCH] feat(chat-swarm-runtime): add existing Chrome session control --- docs/chat-swarm-runtime-macos.md | 49 +++ src/chat-swarm-runtime.test.ts | 586 ++++++++++++++++++++++++++++++- src/chat-swarm-runtime.ts | 396 +++++++++++++++++++-- 3 files changed, 992 insertions(+), 39 deletions(-) create mode 100644 docs/chat-swarm-runtime-macos.md diff --git a/docs/chat-swarm-runtime-macos.md b/docs/chat-swarm-runtime-macos.md new file mode 100644 index 000000000..e23e46544 --- /dev/null +++ b/docs/chat-swarm-runtime-macos.md @@ -0,0 +1,49 @@ +# macOS Chat Swarm: existing Chrome control (Issue #117 Wave 3 G1) + +This is the connection configuration for the existing `MacWebDriver` / `CdpMacWebDriver` runtime. It preserves the [managed worker lifecycle](chat-swarm-macos-runtime.md): Chat Swarm storage owns worker, task, attempt, result, replay, and reconciliation state. A browser target or successful CDP command does not establish worker authority. + +This source candidate is G1 only. No installed-build, Chrome authorization, authenticated ChatGPT, native host, or G2–G6 acceptance is claimed. The live claim remains `MACOS_ZERO_TOUCH_CHATGPT_WORKER_RUNTIME_NOT_YET_PROVEN`. + +## Explicit existing-session configuration + +The normal intended connection is to an already running, authenticated Chrome selected by the operator. Configuration names below describe server inputs; this change does not modify the machine's environment. + +| Setting | Value | +| --- | --- | +| `DEVSPACE_CHAT_SWARM_RUNTIME` | `1` to enable managed runtime | +| `DEVSPACE_CHAT_SWARM_PROJECT_URL` | Exact `https://chatgpt.com/...` Project URL | +| `DEVSPACE_CHAT_SWARM_TRANSPORT` | `cdp` (default transport) | +| `DEVSPACE_CHAT_SWARM_CDP_MODE` | `existing-session` (must be explicit) | +| `DEVSPACE_CHAT_SWARM_BROWSER_PROFILE_DIR` | Explicit absolute existing Chrome **user-data-dir**, containing `DevToolsActivePort`; not a child `Default` or `Profile 1` directory | +| `DEVSPACE_CHAT_SWARM_APP_LABEL` | `dev` by default; override only for the intended installed app | + +`DEVSPACE_CHAT_SWARM_BROWSER_BIN` and `DEVSPACE_CHAT_SWARM_CDP_ENDPOINT` must be unset in this mode. There is no default-profile discovery, port scan, fixed-port fallback, or automatic switch to another transport. When CDP mode is omitted, legacy managed CDP behavior is preserved; existing-session discovery is never inferred. + +One-time setup requires Chrome remote debugging to be explicitly enabled and the user to authorize the connection in Chrome. Consent remains under Chrome/user control; DevSpace does not automate either step. + +The driver reads only `/DevToolsActivePort`, a public endpoint artifact. It accepts a bounded regular file containing a decimal port in `1..65535` and `/devtools/browser/`, then connects to `ws://127.0.0.1:`. Missing, symlinked, oversized, or malformed metadata fails closed; artifact contents are not echoed in errors. It does not assume that the authorized browser WebSocket also serves `/json/list` or `/json/new`. + +Each lifecycle operation resolves the artifact afresh and opens one bounded control connection. `Browser.getVersion`, `Target.getTargets`, `Target.createTarget`, `Target.attachToTarget` with flattened sessions, and `Runtime.evaluate` use that connection. The connection closes when the operation finishes or fails. Changed ports and browser endpoint IDs are not persisted. Chrome may request consent for a new connection; this G1 seam does not promise consent-free native operation. + +Existing-session mode never launches, terminates, restarts, or changes the configuration of the user's Chrome. It never copies cookies, tokens, local storage, or profile state, and never clicks or bypasses browser authorization UI. Authorized worker lifecycle operations can open, inspect, send to, or close the exact worker tabs. Missing metadata or denied control is not permission to launch a replacement browser or retry by another mechanism. + +## Legacy managed fallback and optional OpenCLI + +The dedicated managed direct-CDP path remains available with `DEVSPACE_CHAT_SWARM_CDP_MODE=managed` or with CDP mode unset for backward compatibility. It uses `DEVSPACE_CHAT_SWARM_CDP_ENDPOINT` (default `http://127.0.0.1:9222`) and the existing dedicated `DEVSPACE_CHAT_SWARM_BROWSER_PROFILE_DIR` (default `~/.devspace/chat-swarm-browser`). `DEVSPACE_CHAT_SWARM_BROWSER_BIN`, if configured, permits the existing managed startup path with `--user-data-dir` when control is unavailable. Use a dedicated directory, never an authenticated user's regular profile. This remains the legacy fallback, not normal existing-session UX; existing-session must always be explicitly selected. + +`DEVSPACE_CHAT_SWARM_TRANSPORT=opencli` remains an optional compatibility/diagnostic adapter. It is not canonical and is never selected automatically after a CDP failure. No per-worker browser profiles or processes are introduced. + +## Exact conversations and failure boundaries + +- Persisted conversation URL/fingerprint and authenticated peer binding remain distinct. `targetId` and CDP `sessionId` exist only inside a control operation and never become logical worker identity. +- Reuse requires an exact conversation URL match. If the target disappeared or navigated elsewhere, recovery opens only the persisted URL. It does not mint a worker, task, attempt, or result. A failed recovery leaves the existing durable reconciliation rules in force. +- Recovery verifies the observed URL; prompt delivery also checks the exact URL inside the DOM mutation before touching the composer. Other worker targets remain untouched. Cross-session CDP responses fail closed. +- `BROWSER_AUTHORIZATION_REQUIRED:*` is reserved for known setup evidence such as missing explicit existing-session selection inputs. A missing endpoint artifact or closed socket alone is not proof that Chrome authorization was denied. +- `BROWSER_CONTROL_UNAVAILABLE:*` identifies missing/invalid metadata, unavailable authorization/connection, disconnects, or rejected control commands. A closed socket alone cannot distinguish a user's denial from a transport failure. +- `CHATGPT_SIGNED_OUT` identifies visible login UI or an auth route. Composer timeout remains a separate UI-readiness error; it does not imply signed-out status. +- `HOST_APP_BINDING_NOT_READY:*`, `PEER_IDENTITY_UNRESOLVED:*`, `CHATGPT_CONVERSATION_IDENTITY_DRIFT`, authenticated peer checks, and durable `RECONCILE_REQUIRED` retain separate meanings. Control connectivity alone proves none of them. +- No automatic reconnect or command resend occurs within a failed operation. Failed prompt delivery retains the existing conservative `remoteMayContinue=true`, including when a target may have been reopened or a send acknowledgement was lost. Existing durable operation journals govern replay and reconciliation; an unknown outcome is not retry permission. + +## Verification boundary + +G1 uses temporary public-artifact fixtures and a fake CDP WebSocket, plus existing durable worker/task tests. Focused tests cover explicit configuration, metadata rejection, endpoint changes, exact target/session isolation, target loss, authorization/connection failure, app-binding readiness, authenticated-peer acknowledgement, and uncertain sends. Build/typecheck validate source integration only. Real Chrome consent, ChatGPT login/app binding, installed-host operation, and G2–G6 require separate authorized native witnesses. diff --git a/src/chat-swarm-runtime.test.ts b/src/chat-swarm-runtime.test.ts index aea37754e..4e71a9c98 100644 --- a/src/chat-swarm-runtime.test.ts +++ b/src/chat-swarm-runtime.test.ts @@ -1,11 +1,12 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { CdpMacWebDriver, + MacWebChatCarrierAdapter, OpenCliMacWebDriver, ChatSwarmRuntimeManager, ChatSwarmRuntimeStore, @@ -632,4 +633,587 @@ test("managed registry survives reopen without duplicating logical workers", asy } finally { cleanup(f); } +}); + +test("CDP existing-session requires explicit mode and absolute profile; unset mode preserves managed config", () => { + const base = { stateDir: "/tmp/devspace-runtime-config-test", chatSwarmMaxWorkers: 3 }; + const config = loadChatSwarmRuntimeConfig(base, {}); + assert.equal(config.cdpMode, "managed"); + assert.equal(config.appLabel, "dev"); + for (const env of [ + { DEVSPACE_CHAT_SWARM_CDP_MODE: "auto" }, + { DEVSPACE_CHAT_SWARM_CDP_MODE: "existing-session" }, + { DEVSPACE_CHAT_SWARM_CDP_MODE: "existing-session", DEVSPACE_CHAT_SWARM_BROWSER_PROFILE_DIR: "relative" }, + { DEVSPACE_CHAT_SWARM_CDP_MODE: "existing-session", DEVSPACE_CHAT_SWARM_BROWSER_PROFILE_DIR: "/explicit", DEVSPACE_CHAT_SWARM_BROWSER_BIN: "/chrome" }, + { DEVSPACE_CHAT_SWARM_CDP_MODE: "existing-session", DEVSPACE_CHAT_SWARM_BROWSER_PROFILE_DIR: "/explicit", DEVSPACE_CHAT_SWARM_CDP_ENDPOINT: "http://127.0.0.1:9222" }, + ]) { + assert.throws(() => loadChatSwarmRuntimeConfig(base, env)); + } + const legacy = loadChatSwarmRuntimeConfig(base, { + DEVSPACE_CHAT_SWARM_CDP_ENDPOINT: "http://127.0.0.1:9333", + DEVSPACE_CHAT_SWARM_BROWSER_BIN: "/explicit/chrome", + DEVSPACE_CHAT_SWARM_BROWSER_PROFILE_DIR: "/explicit/managed-profile", + }); + assert.equal(legacy.cdpMode, "managed"); + assert.equal(legacy.cdpEndpoint, "http://127.0.0.1:9333"); + assert.equal(legacy.browserExecutable, "/explicit/chrome"); + assert.equal(legacy.browserProfileDir, "/explicit/managed-profile"); +}); + +function existingCdpFixture( + t: test.TestContext, + metadata = "43123\n/devtools/browser/browser-one\n", +) { + const root = mkdtempSync(join(tmpdir(), "devspace-existing-cdp-")); + if (metadata) writeFileSync(join(root, "DevToolsActivePort"), metadata); + const calls: Array<{ id: number; method: string; params: any; sessionId?: string }> = []; + const sockets: FakeSocket[] = []; + const targets = new Map([ + ["target-a", "https://chatgpt.com/c/worker-a"], + ["target-b", "https://chatgpt.com/c/worker-b"], + ]); + const sessions = new Map(); + let disconnectOn = ""; + let domState = "ready"; + let denyConnection = false; + let wrongSession = false; + let driftBeforeSend = false; + let disconnectAfterSend = false; + + class FakeSocket { + onopen?: () => void; + onmessage?: (event: { data: string }) => void; + onerror?: () => void; + onclose?: () => void; + closed = false; + + constructor(readonly url: string) { + sockets.push(this); + queueMicrotask(() => denyConnection ? this.onclose?.() : this.onopen?.()); + } + + close() { + if (this.closed) return; + this.closed = true; + queueMicrotask(() => this.onclose?.()); + } + + send(data: string) { + const command = JSON.parse(data) as { + id: number; + method: string; + params: any; + sessionId?: string; + }; + calls.push(command); + if (command.method === disconnectOn) { + this.close(); + return; + } + let result: any = {}; + switch (command.method) { + case "Browser.getVersion": + result = { product: "Chrome/fake" }; + break; + case "Target.getTargets": + result = { + targetInfos: [...targets].map(([targetId, url]) => ({ + targetId, + url, + type: "page", + })), + }; + break; + case "Target.createTarget": { + const targetId = `reopened-${targets.size}`; + targets.set(targetId, command.params.url); + result = { targetId }; + break; + } + case "Target.attachToTarget": { + assert.equal(command.params.flatten, true); + const sessionId = `session-${sessions.size}`; + sessions.set(sessionId, command.params.targetId); + result = { sessionId }; + break; + } + case "Target.closeTarget": + targets.delete(command.params.targetId); + result = { success: true }; + break; + case "Runtime.evaluate": { + const url = targets.get(sessions.get(command.sessionId!)!); + assert.ok(url, "evaluation must be attached to one live target"); + const expression: string = command.params.expression; + let value: any = true; + if (expression === "location.href") { + value = url; + } else if (expression.includes("const prompt=")) { + if (disconnectAfterSend) { + this.close(); + return; + } + if (driftBeforeSend) { + targets.set(sessions.get(command.sessionId!)!, "https://chatgpt.com/c/unrelated"); + value = new Function( + "location", + expression.replace("(() =>", "return (() =>"), + )({ href: "https://chatgpt.com/c/unrelated" }); + } else { + value = { ok: true }; + } + } else if (expression.includes("const label=")) { + const text = domState === "unknown" ? "ChatGPT" : `dev ${domState}`; + value = new Function("document", `return ${expression}`)({ + body: { innerText: text }, + }); + } else { + value = domState === "signed-out" ? "SIGNED_OUT" : true; + } + result = { result: { value } }; + break; + } + default: + assert.fail(`unexpected CDP command ${command.method}`); + } + queueMicrotask(() => this.onmessage?.({ + data: JSON.stringify({ + id: command.id, + sessionId: wrongSession && command.sessionId + ? "foreign-session" + : command.sessionId, + result, + }), + })); + } + } + + const originalWebSocket = globalThis.WebSocket; + const originalFetch = globalThis.fetch; + (globalThis as any).WebSocket = function (url: string) { + return new FakeSocket(url); + }; + (globalThis as any).fetch = async () => { + assert.fail("existing-session must use the metadata browser websocket, never HTTP/default port"); + }; + t.after(() => { + (globalThis as any).WebSocket = originalWebSocket; + (globalThis as any).fetch = originalFetch; + rmSync(root, { recursive: true, force: true }); + }); + + const config = loadChatSwarmRuntimeConfig( + { stateDir: root, chatSwarmMaxWorkers: 3 }, + { + DEVSPACE_CHAT_SWARM_CDP_MODE: "existing-session", + DEVSPACE_CHAT_SWARM_BROWSER_PROFILE_DIR: root, + DEVSPACE_CHAT_SWARM_PROJECT_URL: "https://chatgpt.com/g/g-p-test/project", + DEVSPACE_CHAT_SWARM_RUNTIME_TIMEOUT_MS: "1000", + }, + ); + + return { + root, + config, + calls, + sockets, + targets, + sessions, + driver: new CdpMacWebDriver(config), + deadline: () => new Date(Date.now() + 1500).toISOString(), + disconnect: (method: string) => { disconnectOn = method; }, + dom: (state: string) => { domState = state; }, + deny: () => { denyConnection = true; }, + spoofSession: () => { wrongSession = true; }, + drift: () => { driftBeforeSend = true; }, + loseSendAck: () => { disconnectAfterSend = true; }, + }; +} + +test("existing-session missing or malformed metadata fails closed before connection", async (t) => { + const f = existingCdpFixture(t, ""); + let result = await f.driver.preflight(); + assert.match(result.blocker!, /BROWSER_CONTROL_UNAVAILABLE:DEVTOOLS_ACTIVE_PORT_UNAVAILABLE/); + for (const invalid of [ + "", + "0\n/devtools/browser/id", + "65536\n/devtools/browser/id", + "1e3\n/devtools/browser/id", + "9222junk\n/devtools/browser/id", + "9222", + "9222\nws://evil/", + "9222\n//evil", + "9222\n/devtools/browser/id?redirect=1", + "9222\n/devtools/browser/../id", + "9222\n/devtools/browser/id\nextra", + "x".repeat(2048), + ]) { + writeFileSync(join(f.root, "DevToolsActivePort"), invalid); + result = await f.driver.preflight(); + assert.equal(result.ready, false); + assert.match(result.blocker!, /BROWSER_CONTROL_UNAVAILABLE:DEVTOOLS_ACTIVE_PORT_MALFORMED/); + } + const unsafe = new CdpMacWebDriver({ ...f.config, browserExecutable: "/must-not-spawn" }); + await assert.rejects( + unsafe.createManagedConversation(f.config.projectUrl!, f.deadline()), + /BROWSER_CONTROL_UNAVAILABLE/, + ); + assert.equal(f.sockets.length, 0); + assert.equal(f.calls.length, 0); +}); + +test("existing-session resolves dynamic browser endpoint anew and closes each bounded connection", async (t) => { + const f = existingCdpFixture(t); + assert.equal((await f.driver.preflight()).ready, true); + writeFileSync( + join(f.root, "DevToolsActivePort"), + "45234\r\n/devtools/browser/browser-two\r\n", + ); + assert.equal((await f.driver.preflight()).ready, true); + assert.deepEqual(f.sockets.map((socket) => socket.url), [ + "ws://127.0.0.1:43123/devtools/browser/browser-one", + "ws://127.0.0.1:45234/devtools/browser/browser-two", + ]); + assert.ok(f.sockets.every((socket) => socket.closed)); + assert.deepEqual(f.calls.map((call) => call.method), [ + "Browser.getVersion", + "Browser.getVersion", + ]); +}); + +test("existing-session isolates exact targets, reopens lost conversations, and closes only the requested target", async (t) => { + const f = existingCdpFixture(t); + const url = "https://chatgpt.com/c/worker-a"; + assert.equal((await f.driver.sendPrompt(url, "wake a", f.deadline())).delivered, true); + assert.deepEqual( + f.calls.filter((call) => call.method === "Target.attachToTarget").map((call) => call.params.targetId), + ["target-a"], + ); + f.targets.set("target-a", "https://chatgpt.com/c/someone-else"); + const recovered = await f.driver.recoverConversation(url, f.deadline()); + assert.equal(recovered.ready, true); + assert.deepEqual( + f.calls.filter((call) => call.method === "Target.createTarget").map((call) => call.params.url), + [url], + ); + assert.equal(f.targets.get("target-b"), "https://chatgpt.com/c/worker-b"); + assert.equal(f.targets.get("target-a"), "https://chatgpt.com/c/someone-else"); + await f.driver.closeConversation(url); + assert.equal(f.targets.size, 2); + assert.equal(f.calls.filter((call) => call.method === "Target.closeTarget").length, 1); + assert.ok(f.sockets.every((socket) => socket.closed)); +}); + +test("existing-session disconnect before acknowledgement never retries a prompt or switches transport", async (t) => { + const f = existingCdpFixture(t); + f.disconnect("Runtime.evaluate"); + const result = await f.driver.sendPrompt( + "https://chatgpt.com/c/worker-a", + "wake", + f.deadline(), + ); + assert.equal(result.delivered, false); + assert.match(result.blocker!, /BROWSER_CONTROL_UNAVAILABLE/); + assert.equal(f.calls.filter((call) => call.method === "Runtime.evaluate").length, 1); + assert.equal(f.calls.filter((call) => call.method === "Target.createTarget").length, 0); + assert.equal(f.sockets.length, 1); + assert.equal(result.remoteMayContinue, true); +}); + +test("existing-session keeps signed-out and app binding blockers separate from control availability", async (t) => { + const f = existingCdpFixture(t); + f.dom("signed-out"); + assert.match( + (await f.driver.recoverConversation("https://chatgpt.com/c/worker-a", f.deadline())).blocker!, + /CHATGPT_SIGNED_OUT/, + ); + f.dom("disabled"); + assert.equal( + (await f.driver.sendPrompt("https://chatgpt.com/c/worker-a", "wake", f.deadline())).blocker, + "HOST_APP_BINDING_NOT_READY:DISABLED", + ); +}); + +test("existing-session socket close cannot distinguish authorization denial from transport failure", async (t) => { + const f = existingCdpFixture(t); + f.deny(); + const result = await f.driver.preflight(); + assert.equal(result.ready, false); + assert.equal(result.appBinding, "UNKNOWN"); + assert.match( + result.blocker!, + /BROWSER_CONTROL_UNAVAILABLE:AUTHORIZATION_OR_CONNECTION_CLOSED/, + ); + assert.equal(f.calls.length, 0); + assert.equal(f.sockets.length, 1); + assert.ok(f.sockets.every((socket) => socket.closed)); +}); + +test("existing-session prompt acknowledgement loss remains unknown and is not blindly resent", async (t) => { + const f = existingCdpFixture(t); + f.loseSendAck(); + const result = await f.driver.sendPrompt( + "https://chatgpt.com/c/worker-a", + "wake", + f.deadline(), + ); + assert.equal(result.delivered, false); + assert.equal(result.remoteMayContinue, true); + assert.match(result.blocker!, /BROWSER_CONTROL_UNAVAILABLE:DISCONNECTED/); + assert.equal( + f.calls.filter( + (call) => call.method === "Runtime.evaluate" && call.params.expression.includes("const prompt="), + ).length, + 1, + ); + assert.equal(f.calls.filter((call) => call.method === "Target.createTarget").length, 0); + assert.equal(f.sockets.length, 1); +}); + +test("existing-session rejects cross-session responses", async (t) => { + const f = existingCdpFixture(t); + f.spoofSession(); + const spoofed = await f.driver.recoverConversation( + "https://chatgpt.com/c/worker-a", + f.deadline(), + ); + assert.equal(spoofed.ready, false); + assert.match(spoofed.blocker!, /SESSION_IDENTITY_MISMATCH/); +}); + +test("existing-session checks the exact URL inside the prompt mutation", async (t) => { + const f = existingCdpFixture(t); + f.drift(); + const drifted = await f.driver.sendPrompt( + "https://chatgpt.com/c/worker-a", + "wake", + f.deadline(), + ); + assert.equal(drifted.delivered, false); + assert.match(drifted.blocker!, /CHATGPT_CONVERSATION_IDENTITY_DRIFT/); + assert.equal(f.targets.get("target-b"), "https://chatgpt.com/c/worker-b"); +}); + +test("existing-session recovery after target deletion reopens only the saved conversation", async (t) => { + const f = existingCdpFixture(t); + f.targets.delete("target-a"); + const url = "https://chatgpt.com/c/worker-a"; + assert.equal((await f.driver.recoverConversation(url, f.deadline())).ready, true); + assert.deepEqual( + f.calls.filter((call) => call.method === "Target.createTarget").map((call) => call.params.url), + [url], + ); + assert.equal( + f.calls.filter( + (call) => call.method === "Runtime.evaluate" && call.params.expression.includes("const prompt="), + ).length, + 0, + ); + assert.equal(f.targets.get("target-b"), "https://chatgpt.com/c/worker-b"); +}); + +test("existing-session missing explicit setup is authorization-required, never inferred from missing metadata", async (t) => { + const f = existingCdpFixture(t, ""); + for (const browserProfileDir of ["", "relative"]) { + assert.throws( + () => loadChatSwarmRuntimeConfig( + { stateDir: f.root, chatSwarmMaxWorkers: 3 }, + { + DEVSPACE_CHAT_SWARM_CDP_MODE: "existing-session", + DEVSPACE_CHAT_SWARM_BROWSER_PROFILE_DIR: browserProfileDir, + }, + ), + /BROWSER_AUTHORIZATION_REQUIRED:EXPLICIT_USER_DATA_DIR_REQUIRED/, + ); + const driver = new CdpMacWebDriver({ ...f.config, browserProfileDir }); + const result = await driver.preflight(); + assert.equal(result.ready, false); + assert.equal(result.state, "SETUP_REQUIRED"); + assert.equal( + result.blocker, + "BROWSER_AUTHORIZATION_REQUIRED:EXPLICIT_USER_DATA_DIR_REQUIRED", + ); + await assert.rejects( + driver.createManagedConversation(f.config.projectUrl!, f.deadline()), + /BROWSER_AUTHORIZATION_REQUIRED/, + ); + } + const unavailable = await f.driver.preflight(); + assert.equal(unavailable.state, "CONFIGURED_NOT_READY"); + assert.equal( + unavailable.blocker, + "BROWSER_CONTROL_UNAVAILABLE:DEVTOOLS_ACTIVE_PORT_UNAVAILABLE", + ); + assert.equal(f.sockets.length, 0); + assert.equal(f.calls.length, 0); +}); + +test("existing-session browser readiness surfaces unknown app binding without blocking delivery", async (t) => { + const f = existingCdpFixture(t); + f.dom("unknown"); + const preflight = await f.driver.preflight(); + assert.equal(preflight.ready, true); + assert.equal(preflight.appBinding, "UNKNOWN"); + assert.equal(preflight.blocker, "HOST_APP_BINDING_NOT_READY:UNKNOWN"); + const url = "https://chatgpt.com/c/worker-a"; + assert.deepEqual(await f.driver.sendPrompt(url, "bootstrap", f.deadline()), { + delivered: true, + remoteMayContinue: true, + blocker: "HOST_APP_BINDING_NOT_READY:UNKNOWN", + }); + assert.deepEqual(await f.driver.recoverConversation(url, f.deadline()), { + ready: true, + blocker: "HOST_APP_BINDING_NOT_READY:UNKNOWN", + }); + assert.equal( + f.calls.filter( + (call) => call.method === "Runtime.evaluate" && call.params.expression.includes("const prompt="), + ).length, + 1, + ); + assert.ok( + f.calls + .filter((call) => call.method === "Target.attachToTarget") + .every((call) => call.params.targetId === "target-a"), + ); + assert.equal(f.targets.get("target-b"), "https://chatgpt.com/c/worker-b"); +}); + +test("existing-session DISABLED and STALE app binding block send, recovery, and bootstrap", async (t) => { + const f = existingCdpFixture(t); + const url = "https://chatgpt.com/c/worker-a"; + const registry = new ChatSwarmRuntimeStore(f.root); + try { + for (const binding of ["DISABLED", "STALE"] as const) { + f.dom(binding.toLowerCase()); + assert.deepEqual(await f.driver.sendPrompt(url, "wake", f.deadline()), { + delivered: false, + remoteMayContinue: false, + blocker: `HOST_APP_BINDING_NOT_READY:${binding}`, + }); + assert.deepEqual(await f.driver.recoverConversation(url, f.deadline()), { + ready: false, + blocker: `HOST_APP_BINDING_NOT_READY:${binding}`, + }); + const adapter = new MacWebChatCarrierAdapter(f.config, registry, f.driver); + const bootstrap = await adapter.bootstrap({ + operationId: "operation", + swarmId: "swarm", + runtimeSlot: 1, + conversationUrl: url, + workerLabel: "worker", + deadlineAt: f.deadline(), + }); + assert.equal(bootstrap.disposition, "SETUP_REQUIRED"); + assert.equal(bootstrap.remoteMayContinue, false); + assert.equal(bootstrap.blocker, `HOST_APP_BINDING_NOT_READY:${binding}`); + } + } finally { + registry.close(); + } + assert.equal( + f.calls.filter( + (call) => call.method === "Runtime.evaluate" && call.params.expression.includes("const prompt="), + ).length, + 0, + ); +}); + +test("runtime status surfaces binding uncertainty and authenticated bootstrap can resolve the slot", async (t) => { + const f = fixture(); + try { + t.mock.method(f.adapter, "preflight", async () => ({ + ready: true, + state: "READY", + controlMechanism: "CDP", + appBinding: "UNKNOWN", + } as RuntimePreflight)); + const provision = f.adapter.provision.bind(f.adapter); + t.mock.method(f.adapter, "provision", async (input: Parameters[0]) => ({ + ...await provision(input), + appBinding: "UNKNOWN" as const, + })); + const bootstrap = f.adapter.bootstrap.bind(f.adapter); + t.mock.method(f.adapter, "bootstrap", async (input: Parameters[0]) => { + const pending = await f.manager.status(f.owner, f.swarm.id); + assert.equal(pending.state, "DEGRADED"); + assert.equal(pending.slots[0]?.blocker, "HOST_APP_BINDING_NOT_READY:UNKNOWN"); + assert.equal(pending.slots[0]?.workerId, undefined); + return bootstrap(input); + }); + const initial = await f.manager.status(f.owner, f.swarm.id); + assert.equal(initial.adapter.blocker, "HOST_APP_BINDING_NOT_READY:UNKNOWN"); + assert.equal(initial.slots.length, 0); + const bound = await f.manager.ensure(f.owner, f.swarm.id, 1); + assert.equal(bound.state, "READY"); + assert.equal(bound.slots[0]?.state, "PARKED"); + assert.equal(bound.slots[0]?.blocker, undefined); + assert.ok(bound.slots[0]?.authenticatedPeerFingerprint); + await f.manager.ensure(f.owner, f.swarm.id, 1); + assert.equal(f.adapter.provisionCalls, 1); + assert.equal(f.adapter.bootstrapCalls, 1); + } finally { + cleanup(f); + } +}); + +test("runtime retains DISABLED and STALE binding reasons without bootstrap or reprovision", async (t) => { + for (const binding of ["DISABLED", "STALE"] as const) { + const f = fixture(); + try { + const provision = f.adapter.provision.bind(f.adapter); + t.mock.method(f.adapter, "provision", async (input: Parameters[0]) => ({ + ...await provision(input), + appBinding: binding, + })); + const status = await f.manager.ensure(f.owner, f.swarm.id, 1); + assert.equal(status.slots[0]?.state, "SETUP_REQUIRED"); + assert.equal(status.slots[0]?.blocker, `HOST_APP_BINDING_NOT_READY:${binding}`); + await f.manager.ensure(f.owner, f.swarm.id, 1); + assert.equal(f.adapter.bootstrapCalls, 0); + assert.equal(f.adapter.provisionCalls, 1); + t.mock.restoreAll(); + } finally { + cleanup(f); + } + } +}); + +test("binding disabled after creation survives bootstrap mapping and remains reconcile-required without resend", async (t) => { + const f = fixture(); + try { + t.mock.method(f.adapter, "bootstrap", async () => { + f.adapter.bootstrapCalls += 1; + return { + disposition: "SETUP_REQUIRED", + remoteMayContinue: false, + blocker: "HOST_APP_BINDING_NOT_READY:DISABLED", + } as any; + }); + const status = await f.manager.ensure(f.owner, f.swarm.id, 1); + assert.equal(status.state, "RECONCILE_REQUIRED"); + assert.equal(status.slots[0]?.blocker, "HOST_APP_BINDING_NOT_READY:DISABLED"); + await f.manager.ensure(f.owner, f.swarm.id, 1); + assert.equal(f.adapter.bootstrapCalls, 1); + assert.equal(f.adapter.provisionCalls, 1); + } finally { + cleanup(f); + } +}); + +test("missing authenticated peer acknowledgement is unresolved and never grants bootstrap retry", async () => { + const f = fixture(); + try { + f.adapter.onBootstrap = undefined; + f.manager.runtimeConfig.bootstrapWaitMs = 1; + const status = await f.manager.ensure(f.owner, f.swarm.id, 1); + assert.equal(status.state, "RECONCILE_REQUIRED"); + assert.match(status.slots[0]!.blocker!, /^PEER_IDENTITY_UNRESOLVED:/); + await f.manager.ensure(f.owner, f.swarm.id, 1); + assert.equal(f.adapter.bootstrapCalls, 1); + assert.equal(f.adapter.provisionCalls, 1); + assert.equal(status.slots[0]?.workerId, undefined); + } finally { + cleanup(f); + } }); \ No newline at end of file diff --git a/src/chat-swarm-runtime.ts b/src/chat-swarm-runtime.ts index bd292412b..69f66be77 100644 --- a/src/chat-swarm-runtime.ts +++ b/src/chat-swarm-runtime.ts @@ -1,7 +1,9 @@ import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { open } from "node:fs/promises"; import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import { isAbsolute, join, resolve } from "node:path"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { ChatSwarmError, @@ -66,6 +68,7 @@ export interface ChatSwarmRuntimeConfig { transport: "cdp" | "opencli"; openCliExecutable: string; cdpEndpoint: string; + cdpMode?: "existing-session" | "managed"; browserExecutable?: string; browserProfileDir: string; appLabel: string; @@ -173,6 +176,7 @@ export interface RuntimeStopRecord { } export interface RuntimePreflight { + // Browser control readiness only; UNKNOWN app binding may be proven by bootstrap. ready: boolean; state: "READY" | "CONFIGURED_NOT_READY" | "SETUP_REQUIRED"; controlMechanism: "CDP" | "OPENCLI"; @@ -224,6 +228,7 @@ export interface ChatSwarmManagedCarrierAdapter extends ChatSwarmCarrierAdapter }): Promise<{ disposition: "DELIVERED" | "UNKNOWN" | "SETUP_REQUIRED"; remoteMayContinue: boolean; + blocker?: string; }>; recover(slot: ManagedCarrierSlot): Promise<{ ready: boolean; blocker?: string }>; stop(slot: ManagedCarrierSlot): Promise; @@ -257,6 +262,10 @@ function boolEnv(value: string | undefined): boolean { return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? ""); } +function appBindingBlocker(binding: RuntimePreflight["appBinding"]): string | undefined { + return binding === "READY" ? undefined : `HOST_APP_BINDING_NOT_READY:${binding}`; +} + function boundedInt( value: string | undefined, fallback: number, @@ -301,6 +310,22 @@ export function loadChatSwarmRuntimeConfig( if (transport !== "cdp" && transport !== "opencli") { throw new Error("DEVSPACE_CHAT_SWARM_TRANSPORT must be cdp or opencli"); } + const cdpMode = env.DEVSPACE_CHAT_SWARM_CDP_MODE?.trim() || "managed"; + if (cdpMode !== "existing-session" && cdpMode !== "managed") { + throw new Error("DEVSPACE_CHAT_SWARM_CDP_MODE must be existing-session or managed"); + } + const profileDir = env.DEVSPACE_CHAT_SWARM_BROWSER_PROFILE_DIR?.trim(); + if (cdpMode === "existing-session") { + if (transport !== "cdp") { + throw new Error("existing-session requires CDP"); + } + if (!profileDir || !isAbsolute(profileDir)) { + throw new Error("BROWSER_AUTHORIZATION_REQUIRED:EXPLICIT_USER_DATA_DIR_REQUIRED: existing-session requires an explicit absolute DEVSPACE_CHAT_SWARM_BROWSER_PROFILE_DIR (Chrome user-data-dir)"); + } + if (env.DEVSPACE_CHAT_SWARM_BROWSER_BIN?.trim() || env.DEVSPACE_CHAT_SWARM_CDP_ENDPOINT?.trim()) { + throw new Error("existing-session uses only DevToolsActivePort; BROWSER_BIN and CDP_ENDPOINT must be unset"); + } + } const openCliExecutable = env.DEVSPACE_CHAT_SWARM_OPENCLI_BIN?.trim() || "opencli"; const cdpEndpoint = ( env.DEVSPACE_CHAT_SWARM_CDP_ENDPOINT?.trim() || "http://127.0.0.1:9222" @@ -322,9 +347,10 @@ export function loadChatSwarmRuntimeConfig( transport, openCliExecutable, cdpEndpoint, + cdpMode, browserExecutable: env.DEVSPACE_CHAT_SWARM_BROWSER_BIN?.trim() || undefined, browserProfileDir: resolve( - env.DEVSPACE_CHAT_SWARM_BROWSER_PROFILE_DIR?.trim() || + profileDir || join(homedir(), ".devspace", "chat-swarm-browser"), ), appLabel, @@ -714,7 +740,7 @@ export class ChatSwarmRuntimeStore { setupRequired ? "outcome_unknown" : "carrier_created", receipt, setupRequired ? "HOST_APP_BINDING_SETUP_REQUIRED" : undefined, - setupRequired ? `HOST_APP_BINDING_${evidence.appBinding}` : undefined, + setupRequired ? appBindingBlocker(evidence.appBinding) : undefined, ); const slot = this.getSlot(operation.request.swarmId, operation.request.runtimeSlot)!; this.updateSlotReceipt( @@ -727,9 +753,7 @@ export class ChatSwarmRuntimeStore { conversationFingerprint: evidence.conversationFingerprint, authenticatedPeerFingerprint: evidence.authenticatedPeerFingerprint, lastOperationId: operationId, - ...(setupRequired - ? { blocker: `HOST_APP_BINDING_${evidence.appBinding}` } - : {}), + blocker: appBindingBlocker(evidence.appBinding), updatedAt: observedAt, }, setupRequired ? "outcome_unknown" : "started", @@ -776,6 +800,7 @@ export class ChatSwarmRuntimeStore { authenticatedPeerFingerprint: operation.receipt?.authenticatedPeerFingerprint, lastOperationId: operationId, + blocker: slot.blocker, updatedAt: observedAt, }, "started", @@ -834,7 +859,17 @@ export class ChatSwarmRuntimeStore { return tx.immediate(); } - bindWorker(operationId: string, worker: ChatSwarmWorker): ManagedCarrierSlot { + bindWorker( + operationId: string, + worker: ChatSwarmWorker, + observedAuthenticatedPeerFingerprint?: string, + ): ManagedCarrierSlot { + if (observedAuthenticatedPeerFingerprint) { + assertFingerprint( + observedAuthenticatedPeerFingerprint, + "authenticated peer fingerprint", + ); + } const tx = this.database.sqlite.transaction(() => { const operation = this.requireProvision(operationId); if (operation.status === "succeeded" && operation.receipt?.workerId) { @@ -852,8 +887,19 @@ export class ChatSwarmRuntimeStore { "carrier identity is not established", ); } + if ( + operation.receipt.authenticatedPeerFingerprint && + observedAuthenticatedPeerFingerprint && + operation.receipt.authenticatedPeerFingerprint !== observedAuthenticatedPeerFingerprint + ) { + throw new ChatSwarmError( + "OWNERSHIP_CONFLICT", + "authenticated peer observation conflicts with provision identity", + ); + } const expectedPeerFingerprint = operation.receipt.authenticatedPeerFingerprint ?? + observedAuthenticatedPeerFingerprint ?? operation.receipt.conversationFingerprint; if ( !["carrier_created", "bootstrapping"].includes(operation.status) || @@ -871,8 +917,7 @@ export class ChatSwarmRuntimeStore { disposition: "BOUND", conversationUrl: operation.receipt.conversationUrl, conversationFingerprint: operation.receipt.conversationFingerprint, - authenticatedPeerFingerprint: - operation.receipt.authenticatedPeerFingerprint, + authenticatedPeerFingerprint: expectedPeerFingerprint, workerId: worker.id, remoteMayContinue: false, observedAt, @@ -888,8 +933,7 @@ export class ChatSwarmRuntimeStore { workerId: worker.id, conversationUrl: operation.receipt.conversationUrl, conversationFingerprint: operation.receipt.conversationFingerprint, - authenticatedPeerFingerprint: - operation.receipt.authenticatedPeerFingerprint, + authenticatedPeerFingerprint: expectedPeerFingerprint, continuationEpoch: worker.continuationEpoch, lastOperationId: operationId, updatedAt: observedAt, @@ -1133,7 +1177,7 @@ export class ChatSwarmRuntimeStore { rejectPromise( new ChatSwarmError( "TRANSPORT_UNKNOWN", - "managed worker bootstrap acknowledgement timed out", + "PEER_IDENTITY_UNRESOLVED: managed worker bootstrap acknowledgement timed out", ), ); return; @@ -1658,9 +1702,195 @@ export class OpenCliMacWebDriver implements MacWebDriver { } } +// One bounded browser control connection per lifecycle operation. Neither this +// connection nor its flattened CDP sessions are durable worker identity. +class ExistingChromeControl { + private nextId = 0; + private readonly pending = new Map void; + reject: (error: Error) => void; + timer: ReturnType; + sessionId?: string; + }>(); + private readonly sessions = new Map(); + private failure?: Error; + + private constructor(private readonly socket: WebSocket, private readonly deadline: number) {} + + static async connect(config: ChatSwarmRuntimeConfig, deadlineAt: string): Promise { + if (!isAbsolute(config.browserProfileDir)) { + // Missing explicit selection is known setup evidence. An absent endpoint + // artifact or closed socket is not evidence that Chrome consent was denied. + throw new Error("BROWSER_AUTHORIZATION_REQUIRED:EXPLICIT_USER_DATA_DIR_REQUIRED"); + } + let metadata: string; + try { + // Read only this public endpoint artifact, never profile/session material. + const file = await open( + join(config.browserProfileDir, "DevToolsActivePort"), + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + try { + const stat = await file.stat(); + if (!stat.isFile() || stat.size > 1024) { + throw new Error("BROWSER_CONTROL_UNAVAILABLE:DEVTOOLS_ACTIVE_PORT_MALFORMED"); + } + const buffer = Buffer.alloc(1025); + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); + metadata = buffer.subarray(0, bytesRead).toString("utf8"); + } finally { + await file.close(); + } + } catch (error) { + if ( + error instanceof Error && + error.message.startsWith("BROWSER_CONTROL_UNAVAILABLE:") + ) throw error; + throw new Error("BROWSER_CONTROL_UNAVAILABLE:DEVTOOLS_ACTIVE_PORT_UNAVAILABLE"); + } + const match = /^([1-9][0-9]{0,4})\r?\n(\/devtools\/browser\/[A-Za-z0-9_-]+)(?:\r?\n)?$/.exec(metadata); + if (metadata.length > 1024 || !match || Number(match[1]) > 65535) { + throw new Error("BROWSER_CONTROL_UNAVAILABLE:DEVTOOLS_ACTIVE_PORT_MALFORMED"); + } + const deadline = Math.min( + Date.parse(deadlineAt), + Date.now() + config.operationTimeoutMs, + ); + if (!Number.isFinite(deadline) || deadline <= Date.now()) { + throw new Error("BROWSER_CONTROL_UNAVAILABLE:DEADLINE"); + } + let socket: WebSocket; + try { + socket = new WebSocket(`ws://127.0.0.1:${match[1]}${match[2]}`); + } catch { + throw new Error("BROWSER_CONTROL_UNAVAILABLE:WEBSOCKET_UNAVAILABLE"); + } + const control = new ExistingChromeControl(socket, deadline); + await new Promise((resolvePromise, rejectPromise) => { + const timer = setTimeout(() => { + control.fail("AUTHORIZATION_OR_CONNECTION_TIMEOUT"); + rejectPromise(control.failure); + }, deadline - Date.now()); + const unavailable = () => { + clearTimeout(timer); + control.fail("AUTHORIZATION_OR_CONNECTION_CLOSED"); + rejectPromise(control.failure); + }; + socket.onerror = unavailable; + socket.onclose = unavailable; + socket.onopen = () => { + clearTimeout(timer); + socket.onerror = () => control.fail("CONNECTION_ERROR"); + socket.onclose = () => control.fail("DISCONNECTED"); + resolvePromise(); + }; + socket.onmessage = (event) => { + try { + const message = JSON.parse(String(event.data)); + const pending = control.pending.get(message.id); + if (!pending) return; + if (message.sessionId !== pending.sessionId) { + control.fail("SESSION_IDENTITY_MISMATCH"); + return; + } + clearTimeout(pending.timer); + control.pending.delete(message.id); + if (message.error) { + pending.reject(new Error("BROWSER_CONTROL_UNAVAILABLE:CDP_COMMAND_REJECTED")); + } else { + pending.resolve(message.result); + } + } catch { + control.fail("MALFORMED_CDP_RESPONSE"); + } + }; + }); + return control; + } + + private fail(reason: string): void { + if (this.failure) return; + this.failure = new Error(`BROWSER_CONTROL_UNAVAILABLE:${reason}`); + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(this.failure); + } + this.pending.clear(); + this.sessions.clear(); + try { this.socket.close(); } catch {} + } + + close(): void { this.fail("CLOSED"); } + + async command( + method: string, + params: Record = {}, + sessionId?: string, + ): Promise { + if (this.failure) throw this.failure; + if (Date.now() >= this.deadline) { + this.fail("DEADLINE"); + throw this.failure; + } + return new Promise((resolvePromise, rejectPromise) => { + const id = ++this.nextId; + const timer = setTimeout( + () => this.fail("COMMAND_TIMEOUT"), + this.deadline - Date.now(), + ); + this.pending.set(id, { resolve: resolvePromise, reject: rejectPromise, timer, sessionId }); + try { + this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); + } catch { + this.fail("SEND_FAILED"); + } + }); + } + + async evaluate(targetId: string, expression: string): Promise { + let sessionId = this.sessions.get(targetId); + if (!sessionId) { + const attached = await this.command<{ sessionId: string }>( + "Target.attachToTarget", + { targetId, flatten: true }, + ); + if (!attached.sessionId) { + throw new Error("BROWSER_CONTROL_UNAVAILABLE:ATTACH_FAILED"); + } + sessionId = attached.sessionId; + this.sessions.set(targetId, sessionId); + } + const result = await this.command<{ + result?: { value?: T }; + exceptionDetails?: unknown; + }>( + "Runtime.evaluate", + { expression, returnByValue: true, awaitPromise: true }, + sessionId, + ); + if (result.exceptionDetails) throw new Error("CHATGPT_EVALUATION_FAILED"); + return result.result?.value as T; + } +} + export class CdpMacWebDriver implements MacWebDriver { + private control?: ExistingChromeControl; + constructor(private readonly config: ChatSwarmRuntimeConfig) {} + private async withExistingControl( + deadlineAt: string, + action: (driver: CdpMacWebDriver) => Promise, + ): Promise { + const driver = new CdpMacWebDriver(this.config); + driver.control = await ExistingChromeControl.connect(this.config, deadlineAt); + try { + return await action(driver); + } finally { + driver.control.close(); + } + } + async preflight(): Promise { if (process.platform !== "darwin") { return { @@ -1681,24 +1911,36 @@ export class CdpMacWebDriver implements MacWebDriver { }; } try { - const version = await this.fetchJson<{ Browser?: string }>( - "/json/version", - new Date(Date.now() + this.config.operationTimeoutMs).toISOString(), - ); + if (this.config.cdpMode === "existing-session" && !this.control) { + return await this.withExistingControl( + new Date(Date.now() + this.config.operationTimeoutMs).toISOString(), + (driver) => driver.preflight(), + ); + } + const version = this.control + ? { Browser: (await this.control.command<{ product: string }>("Browser.getVersion")).product } + : await this.fetchJson<{ Browser?: string }>( + "/json/version", + new Date(Date.now() + this.config.operationTimeoutMs).toISOString(), + ); return { ready: true, state: "READY", controlMechanism: "CDP", browserVersion: version.Browser, appBinding: "UNKNOWN", + blocker: appBindingBlocker("UNKNOWN"), }; } catch (error) { + const blocker = error instanceof Error ? error.message : String(error); return { ready: false, - state: "CONFIGURED_NOT_READY", + state: blocker.startsWith("BROWSER_AUTHORIZATION_REQUIRED:") + ? "SETUP_REQUIRED" + : "CONFIGURED_NOT_READY", controlMechanism: "CDP", appBinding: "UNKNOWN", - blocker: error instanceof Error ? error.message : String(error), + blocker, }; } } @@ -1707,6 +1949,12 @@ export class CdpMacWebDriver implements MacWebDriver { projectUrl: string, deadlineAt: string, ): Promise { + if (this.config.cdpMode === "existing-session" && !this.control) { + return this.withExistingControl( + deadlineAt, + (driver) => driver.createManagedConversation(projectUrl, deadlineAt), + ); + } await this.ensureRuntime(deadlineAt); const target = await this.newTarget(projectUrl, deadlineAt); await this.waitForComposer(target, deadlineAt); @@ -1730,19 +1978,32 @@ export class CdpMacWebDriver implements MacWebDriver { deadlineAt: string, ): Promise<{ delivered: boolean; remoteMayContinue: boolean; blocker?: string }> { try { + if (this.config.cdpMode === "existing-session" && !this.control) { + return await this.withExistingControl( + deadlineAt, + (driver) => driver.sendPrompt(conversationUrl, prompt, deadlineAt), + ); + } await this.ensureRuntime(deadlineAt); const target = await this.openOrReuse(conversationUrl, deadlineAt); await this.waitForComposer(target, deadlineAt); + if (await this.evaluate(target, "location.href") !== conversationUrl) { + throw new Error("CHATGPT_CONVERSATION_IDENTITY_DRIFT"); + } const binding = await this.observeAppBinding(target); if (binding === "DISABLED" || binding === "STALE") { return { delivered: false, remoteMayContinue: false, - blocker: `HOST_APP_BINDING_${binding}`, + blocker: appBindingBlocker(binding), }; } - await this.sendPromptToTarget(target, prompt, deadlineAt); - return { delivered: true, remoteMayContinue: true }; + await this.sendPromptToTarget(target, prompt, deadlineAt, conversationUrl); + return { + delivered: true, + remoteMayContinue: true, + ...(binding === "UNKNOWN" ? { blocker: appBindingBlocker(binding) } : {}), + }; } catch (error) { return { delivered: false, @@ -1757,14 +2018,26 @@ export class CdpMacWebDriver implements MacWebDriver { deadlineAt: string, ): Promise<{ ready: boolean; blocker?: string }> { try { + if (this.config.cdpMode === "existing-session" && !this.control) { + return await this.withExistingControl( + deadlineAt, + (driver) => driver.recoverConversation(conversationUrl, deadlineAt), + ); + } await this.ensureRuntime(deadlineAt); const target = await this.openOrReuse(conversationUrl, deadlineAt); await this.waitForComposer(target, deadlineAt); + if (await this.evaluate(target, "location.href") !== conversationUrl) { + throw new Error("CHATGPT_CONVERSATION_IDENTITY_DRIFT"); + } const binding = await this.observeAppBinding(target); if (binding === "DISABLED" || binding === "STALE") { - return { ready: false, blocker: `HOST_APP_BINDING_${binding}` }; + return { ready: false, blocker: appBindingBlocker(binding) }; } - return { ready: true }; + return { + ready: true, + ...(binding === "UNKNOWN" ? { blocker: appBindingBlocker(binding) } : {}), + }; } catch (error) { return { ready: false, @@ -1775,9 +2048,23 @@ export class CdpMacWebDriver implements MacWebDriver { async closeConversation(conversationUrl: string): Promise { const deadlineAt = new Date(Date.now() + this.config.operationTimeoutMs).toISOString(); + if (this.config.cdpMode === "existing-session" && !this.control) { + return this.withExistingControl( + deadlineAt, + (driver) => driver.closeConversation(conversationUrl), + ); + } const targets = await this.targets(deadlineAt); const exact = targets.find((target) => target.url === conversationUrl); if (!exact) return; + if (this.control) { + const closed = await this.control.command<{ success: boolean }>( + "Target.closeTarget", + { targetId: exact.id }, + ); + if (!closed.success) throw new Error("BROWSER_CONTROL_UNAVAILABLE:CLOSE_FAILED"); + return; + } const response = await this.boundedFetch( `${this.config.cdpEndpoint}/json/close/${encodeURIComponent(exact.id)}`, { method: "PUT" }, @@ -1789,7 +2076,7 @@ export class CdpMacWebDriver implements MacWebDriver { private async ensureRuntime(deadlineAt: string): Promise { const ready = await this.preflight(); if (ready.ready) return; - if (!this.config.browserExecutable) { + if (this.config.cdpMode === "existing-session" || !this.config.browserExecutable) { throw new ChatSwarmError( "HOST_CONVERSATION_UNSUPPORTED", ready.blocker ?? "CDP browser runtime is unavailable", @@ -1820,6 +2107,14 @@ export class CdpMacWebDriver implements MacWebDriver { } private async newTarget(url: string, deadlineAt: string): Promise { + if (this.control) { + const result = await this.control.command<{ targetId: string }>( + "Target.createTarget", + { url }, + ); + if (!result.targetId) throw new Error("BROWSER_CONTROL_UNAVAILABLE:CREATE_TARGET_FAILED"); + return { id: result.targetId, url }; + } const response = await this.boundedFetch( `${this.config.cdpEndpoint}/json/new?${encodeURIComponent(url)}`, { method: "PUT" }, @@ -1830,10 +2125,19 @@ export class CdpMacWebDriver implements MacWebDriver { } private async targets(deadlineAt: string): Promise { + if (this.control) { + const result = await this.control.command<{ + targetInfos: Array<{ targetId: string; url: string; type: string }>; + }>("Target.getTargets"); + return result.targetInfos + .filter((target) => target.type === "page") + .map((target) => ({ id: target.targetId, url: target.url })); + } return this.fetchJson("/json/list", deadlineAt); } private async openOrReuse(url: string, deadlineAt: string): Promise { + conversationIdFromUrl(url); return ( (await this.targets(deadlineAt)).find((target) => target.url === url) ?? (await this.newTarget(url, deadlineAt)) @@ -1869,6 +2173,7 @@ export class CdpMacWebDriver implements MacWebDriver { } private async evaluate(target: CdpTarget, expression: string): Promise { + if (this.control) return this.control.evaluate(target.id, expression); if (!target.webSocketDebuggerUrl) { target = (await this.targets( @@ -1920,10 +2225,11 @@ export class CdpMacWebDriver implements MacWebDriver { private async waitForComposer(target: CdpTarget, deadlineAt: string): Promise { while (Date.now() < Date.parse(deadlineAt)) { - const ready = await this.evaluate( + const ready = await this.evaluate( target, - `(() => { const visible=(el) => { const rect=el.getBoundingClientRect(); const style=getComputedStyle(el); return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden'; }; return Boolean([...document.querySelectorAll('[contenteditable="true"]')].find(visible) || [...document.querySelectorAll('textarea')].find(visible)); })()`, - ).catch(() => false); + `(() => { const visible=(el) => { const rect=el.getBoundingClientRect(); const style=getComputedStyle(el); return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden'; }; if ((location.pathname === '/auth' || location.pathname.startsWith('/auth/')) || [...document.querySelectorAll('a,button')].some(el => visible(el) && /^(log in|sign in)$/i.test(el.textContent?.trim()||''))) return 'SIGNED_OUT'; return Boolean([...document.querySelectorAll('[contenteditable="true"]')].find(visible) || [...document.querySelectorAll('textarea')].find(visible)); })()`, + ); + if (ready === "SIGNED_OUT") throw new Error("CHATGPT_SIGNED_OUT"); if (ready) return; await new Promise((resolvePromise) => setTimeout(resolvePromise, 250)); } @@ -1936,22 +2242,24 @@ export class CdpMacWebDriver implements MacWebDriver { const label = JSON.stringify(this.config.appLabel.toLowerCase()); return this.evaluate<"READY" | "UNKNOWN" | "DISABLED" | "STALE">( target, - `(() => { const text=(document.body?.innerText||'').toLowerCase(); const label=${label}; if (text.includes(label) && !text.includes(label+' disabled')) return 'READY'; if (text.includes(label+' disabled')) return 'DISABLED'; return 'UNKNOWN'; })()`, - ).catch(() => "UNKNOWN"); + `(() => { const text=(document.body?.innerText||'').toLowerCase(); const label=${label}; if (text.includes(label+' disabled')) return 'DISABLED'; if (text.includes(label+' stale')) return 'STALE'; if (text.includes(label)) return 'READY'; return 'UNKNOWN'; })()`, + ); } private async sendPromptToTarget( target: CdpTarget, prompt: string, deadlineAt: string, + expectedConversationUrl?: string, ): Promise { if (Date.now() >= Date.parse(deadlineAt)) { throw new Error("prompt delivery deadline elapsed before send"); } const encoded = JSON.stringify(prompt); + const expectedUrl = JSON.stringify(expectedConversationUrl ?? null); const result = await this.evaluate<{ ok: boolean; reason?: string }>( target, - `(() => { const prompt=${encoded}; const visible=(el) => { const rect=el.getBoundingClientRect(); const style=getComputedStyle(el); return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden'; }; const editable=[...document.querySelectorAll('[contenteditable="true"]')].find(visible); const textarea=[...document.querySelectorAll('textarea')].find(visible); const el=editable||textarea; if(!el) return {ok:false,reason:'composer_missing'}; el.focus(); if(editable){ editable.textContent=prompt; editable.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:prompt})); } else { const setter=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value')?.set; setter?.call(textarea,prompt); textarea.dispatchEvent(new Event('input',{bubbles:true})); } const button=document.querySelector('[data-testid="send-button"]') || [...document.querySelectorAll('button')].find(b => /send/i.test((b.getAttribute('aria-label')||b.textContent||''))); if(!button || button.disabled) return {ok:false,reason:'send_button_missing_or_disabled'}; button.click(); return {ok:true}; })()`, + `(() => { const expectedUrl=${expectedUrl}; if(expectedUrl && location.href !== expectedUrl) return {ok:false,reason:'CHATGPT_CONVERSATION_IDENTITY_DRIFT'}; const prompt=${encoded}; const visible=(el) => { const rect=el.getBoundingClientRect(); const style=getComputedStyle(el); return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden'; }; const editable=[...document.querySelectorAll('[contenteditable="true"]')].find(visible); const textarea=[...document.querySelectorAll('textarea')].find(visible); const el=editable||textarea; if(!el) return {ok:false,reason:'composer_missing'}; el.focus(); if(editable){ editable.textContent=prompt; editable.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:prompt})); } else { const setter=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value')?.set; setter?.call(textarea,prompt); textarea.dispatchEvent(new Event('input',{bubbles:true})); } const button=document.querySelector('[data-testid="send-button"]') || [...document.querySelectorAll('button')].find(b => /send/i.test((b.getAttribute('aria-label')||b.textContent||''))); if(!button || button.disabled) return {ok:false,reason:'send_button_missing_or_disabled'}; button.click(); return {ok:true}; })()`, ); if (!result?.ok) { throw new Error(result?.reason ?? "ChatGPT prompt delivery failed"); @@ -1966,7 +2274,7 @@ export class CdpMacWebDriver implements MacWebDriver { deadlineAt: string, ): Promise { while (Date.now() < Date.parse(deadlineAt)) { - const url = await this.evaluate(target, "location.href").catch(() => ""); + const url = await this.evaluate(target, "location.href"); if (url && /\/c\/[^/?#]+/.test(url)) return url; await new Promise((resolvePromise) => setTimeout(resolvePromise, 250)); } @@ -2013,7 +2321,11 @@ export class MacWebChatCarrierAdapter implements ChatSwarmManagedCarrierAdapter this.configHash = canonicalHash({ transport: config.transport, openCliExecutable: config.transport === "opencli" ? config.openCliExecutable : null, - cdpEndpoint: config.transport === "cdp" ? config.cdpEndpoint : null, + cdpEndpoint: + config.transport === "cdp" && config.cdpMode !== "existing-session" + ? config.cdpEndpoint + : null, + ...(config.cdpMode === "existing-session" ? { cdpMode: config.cdpMode } : {}), projectUrl: config.projectUrl ?? null, browserProfileId: profileId(config.browserProfileDir), appLabel: config.appLabel, @@ -2060,10 +2372,11 @@ export class MacWebChatCarrierAdapter implements ChatSwarmManagedCarrierAdapter ); if (!sent.delivered) { return { - disposition: sent.blocker?.startsWith("HOST_APP_BINDING_") + disposition: sent.blocker?.startsWith("HOST_APP_BINDING_NOT_READY:") ? ("SETUP_REQUIRED" as const) : ("UNKNOWN" as const), remoteMayContinue: sent.remoteMayContinue, + blocker: sent.blocker, }; } return { disposition: "DELIVERED" as const, remoteMayContinue: true }; @@ -2370,9 +2683,9 @@ export class ChatSwarmRuntimeManager { if (!rebound?.workerId) { this.registry.markProvisionUnknown( operation.operationId, - delivered.disposition === "SETUP_REQUIRED" - ? "HOST_APP_BINDING_SETUP_REQUIRED" - : "BOOTSTRAP_DELIVERY_UNKNOWN", + delivered.blocker ?? (delivered.disposition === "SETUP_REQUIRED" + ? "HOST_APP_BINDING_NOT_READY:SETUP_REQUIRED" + : "BOOTSTRAP_DELIVERY_UNKNOWN"), ); break; } @@ -2448,7 +2761,14 @@ export class ChatSwarmRuntimeManager { carrierConversationFingerprint: authenticatedPeerFingerprint, }, ); - return { slot: this.registry.bindWorker(operationId, worker), worker }; + return { + slot: this.registry.bindWorker( + operationId, + worker, + authenticatedPeerFingerprint, + ), + worker, + }; } async wakeForDispatchedTask(meta: unknown, task: ChatSwarmTask): Promise { @@ -2656,7 +2976,7 @@ export class ChatSwarmRuntimeManager { controlMechanism: preflight.controlMechanism, projectConfigured: Boolean(this.runtimeConfig.projectUrl), appBinding: preflight.appBinding, - blocker: preflight.blocker, + blocker: preflight.blocker ?? appBindingBlocker(preflight.appBinding), }, slots, };