diff --git a/docs/chat-swarm-runtime-macos.md b/docs/chat-swarm-runtime-macos.md new file mode 100644 index 00000000..3700caa5 --- /dev/null +++ b/docs/chat-swarm-runtime-macos.md @@ -0,0 +1,52 @@ +# 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 144+ with remote debugging explicitly enabled at `chrome://inspect/#remote-debugging`, followed by the user selecting **Allow** in Chrome's connection authorization dialog. Consent remains under Chrome/user control; DevSpace does not automate either step. See [Chrome's existing-session flow](https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session) and the [DevTools MCP endpoint discovery implementation](https://github.com/ChromeDevTools/chrome-devtools-mcp/blob/main/src/browser.ts). + +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. +- When provision has no authenticated peer evidence, only the actual authenticated `bootstrap` caller establishes the peer fingerprint. Binding persists that fingerprint in both the provision receipt and the `PARKED` slot. A peer recorded during provision must match exactly, and a peer bound to another active runtime slot is rejected before worker admission. Conversation fingerprints never supply missing peer evidence. The existing-worker ensure shortcut requires a peer already established during provision; otherwise ensure must deliver bootstrap, even if a worker matches the conversation fingerprint. +- 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:EXPLICIT_USER_DATA_DIR_REQUIRED` identifies known incomplete existing-session setup: no explicit absolute Chrome user-data-dir was selected. Config loading reports this reason before connection; an internally supplied invalid config yields preflight `SETUP_REQUIRED`. This is a setup classification, not an assertion that Chrome denied consent. +- `BROWSER_CONTROL_UNAVAILABLE:*` identifies missing/unreadable/invalid metadata, unavailable authorization/connection, disconnects, or rejected control commands. Missing `DevToolsActivePort` can also mean Chrome is stopped or the selected directory is wrong; neither it nor a socket close/timeout proves a user denied consent. These ambiguous cases remain control-unavailable and never trigger a fallback or consent retry. +- `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:UNKNOWN|DISABLED|STALE` separates binding evidence from browser readiness. Successful CDP preflight still has `ready=true`, but reports binding `UNKNOWN` and its reason: it has checked control, not a worker's app. Runtime status forwards the reason without changing the existing MCP schema. A top-level `READY` alone is not proof of authenticated worker readiness; inspect slot identity/state and adapter binding evidence separately. +- Known disabled/stale binding blocks prompt delivery and recovery. DOM binding observations remain heuristic (including explicit ` disabled` / ` stale` text), not authenticated peer proof. `UNKNOWN` is advisory: exact-target bootstrap/send/recovery may proceed, and the driver surfaces the unresolved reason. A created slot retains this reason through bootstrap; successful authenticated bootstrap clears the slot reason and binds the worker through the existing authority checks. This avoids rejecting valid workers merely because the app label is not visible. +- Bootstrap delivery failures retain their specific blocker through adapter/manager mapping. `PEER_IDENTITY_UNRESOLVED` identifies an authenticated bootstrap acknowledgement timeout; it preserves the existing transport-unknown error code and durable `RECONCILE_REQUIRED` fence. `CHATGPT_CONVERSATION_IDENTITY_DRIFT` remains the exact-URL guard. Neither browser control nor a successful prompt send proves authenticated peer identity or grants retry authority. +- 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 setup versus ambiguous control failure, unknown/disabled/stale binding classification, authenticated bootstrap resolution, peer acknowledgement timeout, metadata rejection, endpoint changes, exact target/session isolation, target loss, 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 aea37754..4493d941 100644 --- a/src/chat-swarm-runtime.test.ts +++ b/src/chat-swarm-runtime.test.ts @@ -1,11 +1,14 @@ import assert from "node:assert/strict"; +import childProcess from "node:child_process"; +import { syncBuiltinESMExports } from "node:module"; 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, @@ -475,6 +478,12 @@ test("managed bootstrap binds the authenticated peer separately from the browser ), /authenticated peer does not match the managed carrier/, ); + assert.equal(f.store.listWorkers(f.swarm.id).length, 0); + assert.equal(f.registry.getSlot(f.swarm.id, 1)?.state, "BOOTSTRAPPING"); + assert.equal( + f.registry.getProvision(prepared.operation.operationId)?.receipt?.authenticatedPeerFingerprint, + peerFingerprint, + ); const accepted = f.manager.bootstrap( { "openai/session": "authenticated-peer-identity" }, prepared.operation!.operationId, @@ -492,6 +501,82 @@ test("managed bootstrap binds the authenticated peer separately from the browser } }); +for (const sameSwarm of [true, false]) { + test(`authenticated bootstrap rejects a peer already bound to another slot (${sameSwarm ? "same" : "different"} swarm)`, () => { + const f = fixture(); + try { + const otherSwarm = sameSwarm ? f.swarm : f.coordinator.createSwarm( + { "openai/session": "other-owner" }, { workerLimit: 5 }, + ); + const prepare = (swarmId: string, runtimeSlot: number) => { + const slot = f.registry.ensureSlot(swarmId, runtimeSlot, + "https://chatgpt.com/g/g-p-runtime-test/project", "1".repeat(64)); + const { operation } = f.registry.prepareProvision(slot, 5_000); + assert.ok(operation); + assert.equal(f.registry.claimProvision(operation.operationId), true); + const conversation = `conversation-${runtimeSlot}`; + f.registry.markCarrierCreated(operation.operationId, { + conversationUrl: `https://chatgpt.com/c/${conversation}`, + conversationFingerprint: fingerprint(conversation), appBinding: "READY", + }); + assert.equal(f.registry.claimBootstrap(operation.operationId), true); + return operation.operationId; + }; + const firstOperation = prepare(f.swarm.id, 1); + const secondOperation = prepare(otherSwarm.id, 2); + const meta = { "openai/session": "shared-authenticated-peer" }; + const first = f.manager.bootstrap(meta, firstOperation); + assert.throws(() => f.manager.bootstrap(meta, secondOperation), { + code: "OWNERSHIP_CONFLICT", + message: "authenticated peer is already managed by another runtime slot", + }); + assert.equal(f.registry.getSlot(otherSwarm.id, 2)?.workerId, undefined); + assert.equal(f.registry.getSlot(otherSwarm.id, 2)?.authenticatedPeerFingerprint, undefined); + assert.equal(f.registry.getProvision(secondOperation)?.status, "bootstrapping"); + assert.deepEqual(f.registry.getSlot(f.swarm.id, 1), first.slot); + assert.equal(f.store.listWorkers(f.swarm.id).length, 1); + if (!sameSwarm) assert.equal(f.store.listWorkers(otherSwarm.id).length, 0); + const replay = f.manager.bootstrap(meta, firstOperation); + assert.equal(replay.worker.id, first.worker.id); + assert.throws(() => f.manager.bootstrap( + { "openai/session": "different-peer-after-binding" }, firstOperation, + ), /authenticated peer does not match/); + } finally { cleanup(f); } + }); +} + +for (const provisionKnowsPeer of [false, true]) { + test(`existing worker ensure ${provisionKnowsPeer ? "uses provisioned peer fast path" : "requires actual bootstrap for unknown peer"}`, async (t) => { + const f = fixture(); + try { + const peerFingerprint = fingerprint("managed-conversation-1"); + const existing = f.store.joinWorkerAtomic(f.swarm.id, peerFingerprint, { + swarmId: f.swarm.id, label: "existing-worker", runtimeKind: "mcp_peer", + carrierConversationFingerprint: peerFingerprint, + }); + const provision = f.adapter.provision.bind(f.adapter); + t.mock.method(f.adapter, "provision", async (input: Parameters[0]) => ({ + ...await provision(input), + authenticatedPeerFingerprint: provisionKnowsPeer ? peerFingerprint : undefined, + })); + const bootstrap = f.adapter.bootstrap.bind(f.adapter); + t.mock.method(f.adapter, "bootstrap", async (input: Parameters[0]) => { + assert.equal(f.registry.getSlot(f.swarm.id, 1)?.authenticatedPeerFingerprint, undefined); + assert.throws(() => f.registry.bindWorker(input.operationId, existing), /worker does not match managed provision identity/); + return bootstrap(input); + }); + const bound = await f.manager.ensure(f.owner, f.swarm.id, 1); + assert.equal(bound.slots[0]?.state, "PARKED"); + assert.equal(bound.slots[0]?.workerId, existing.id); + assert.equal(bound.slots[0]?.authenticatedPeerFingerprint, peerFingerprint); + await f.manager.ensure(f.owner, f.swarm.id, 1); + assert.equal(f.adapter.bootstrapCalls, provisionKnowsPeer ? 0 : 1); + assert.equal(f.adapter.provisionCalls, 1); + assert.equal(f.store.listWorkers(f.swarm.id).length, 1); + } finally { cleanup(f); } + }); +} + test("targeted dispatch wake is a delivery hint and preserves canonical claimed task truth", async () => { const f = fixture(); try { @@ -632,4 +717,491 @@ test("managed registry survives reopen without duplicating logical workers", asy } finally { cleanup(f); } -}); \ No newline at end of file +}); + +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)); + assert.equal(loadChatSwarmRuntimeConfig(base, { DEVSPACE_CHAT_SWARM_CDP_MODE: "managed" }).cdpMode, "managed"); + 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); + t.after(() => 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", + }); + const calls: Array<{ 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() { this.closed = true; queueMicrotask(() => this.onclose?.()); } + send(data: string) { + const command = JSON.parse(data); + 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"); + // Execute the actual guard, before any DOM write could happen. + 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 }) })); + } + } + t.mock.method(globalThis, "WebSocket", function (url: string) { return new FakeSocket(url); } as any); + t.mock.method(globalThis, "fetch", async () => { assert.fail("existing-session must use the metadata browser websocket, never HTTP/default port"); }); + 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("carrier effects never use conversation identity as missing peer authority", async (t) => { + const f = existingCdpFixture(t); + const conversationUrl = "https://chatgpt.com/c/worker-a"; + const conversationFingerprint = fingerprint("worker-a"); + const slot: ManagedCarrierSlot = { + managedCarrierId: "legacy-conversation-only", + swarmId: "swarm", + runtimeSlot: 1, + generation: 1, + state: "PARKED", + projectUrl: f.config.projectUrl!, + browserProfileId: "1".repeat(64), + workerId: "worker", + conversationUrl, + conversationFingerprint, + continuationEpoch: 0, + lastOperationId: "legacy-operation", + updatedAt: new Date().toISOString(), + }; + const registry = { + getSlotByWorker: (swarmId: string, workerId: string) => + swarmId === slot.swarmId && workerId === slot.workerId ? slot : undefined, + } as unknown as ChatSwarmRuntimeStore; + const adapter = new MacWebChatCarrierAdapter(f.config, registry, f.driver); + const input: CarrierCallInput = { + operationId: "carrier-operation", + operationKey: "carrier-operation-key", + swarmId: slot.swarmId, + workerId: slot.workerId!, + carrierKind: "mcp_peer", + carrierFingerprint: conversationFingerprint, + expectedEpoch: 0, + adapterConfigHash: adapter.configHash, + signal: new AbortController().signal, + deadlineAt: f.deadline(), + }; + const ensured = await adapter.ensureExisting(input); + assert.equal(ensured.disposition, "UNSUPPORTED"); + assert.equal(ensured.remoteMayContinue, false); + const woke = await adapter.wake({ ...input, taskId: "task" }); + assert.equal(woke.disposition, "UNSUPPORTED"); + assert.equal(woke.remoteMayContinue, false); + assert.equal(f.calls.length, 0); + assert.equal(f.sockets.length, 0); +}); + +const macOsOnlyTest = process.platform === "darwin" ? test : test.skip; + +macOsOnlyTest("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/); + } + // Even an incorrectly assembled internal config cannot launch Chrome in this mode. + 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); +}); + +macOsOnlyTest("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(s => s.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(s => s.closed)); + assert.deepEqual(f.calls.map(c => c.method), ["Browser.getVersion", "Browser.getVersion"]); +}); + +macOsOnlyTest("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(c => c.method === "Target.attachToTarget").map(c => c.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(c => c.method === "Target.createTarget").map(c => c.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(c => c.method === "Target.closeTarget").length, 1); + assert.ok(f.sockets.every(s => s.closed)); +}); + +macOsOnlyTest("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(c => c.method === "Runtime.evaluate").length, 1); + assert.equal(f.calls.filter(c => c.method === "Target.createTarget").length, 0); + assert.equal(f.sockets.length, 1); + assert.equal(result.remoteMayContinue, true); +}); + +macOsOnlyTest("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"); +}); + + +macOsOnlyTest("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(s => s.closed)); +}); + +macOsOnlyTest("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(c => c.method === "Runtime.evaluate" && c.params.expression.includes("const prompt=")).length, 1); + assert.equal(f.calls.filter(c => c.method === "Target.createTarget").length, 0); + assert.equal(f.sockets.length, 1); +}); + +macOsOnlyTest("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/); +}); + +macOsOnlyTest("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"); +}); + +macOsOnlyTest("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(c => c.method === "Target.createTarget").map(c => c.params.url), [url]); + assert.equal(f.calls.filter(c => c.method === "Runtime.evaluate" && c.params.expression.includes("const prompt=")).length, 0); + assert.equal(f.targets.get("target-b"), "https://chatgpt.com/c/worker-b"); +}); + +macOsOnlyTest("explicit and legacy managed CDP retain the direct endpoint path", async (t) => { + const config = loadChatSwarmRuntimeConfig({ stateDir: "/tmp/devspace-managed", chatSwarmMaxWorkers: 3 }, { + DEVSPACE_CHAT_SWARM_CDP_MODE: "managed", + DEVSPACE_CHAT_SWARM_CDP_ENDPOINT: "http://127.0.0.1:9333", + DEVSPACE_CHAT_SWARM_PROJECT_URL: "https://chatgpt.com/g/g-p-test/project", + }); + const calls: string[] = []; + t.mock.method(globalThis, "fetch", async (url: string | URL | Request) => { + calls.push(String(url)); + return new Response(JSON.stringify({ Browser: "Chrome/managed" })); + }); + for (const cdpMode of ["managed", undefined] as const) { + assert.equal((await new CdpMacWebDriver({ ...config, cdpMode }).preflight()).ready, true); + } + assert.deepEqual(calls, ["http://127.0.0.1:9333/json/version", "http://127.0.0.1:9333/json/version"]); +}); + + +test("unset CDP mode retains managed browserExecutable startup without launching a real browser", async (t) => { + const calls: Array<{ executable: string; args: string[]; options: unknown }> = []; + let unrefs = 0; + t.mock.method(childProcess, "spawn", ((executable: string, args: string[], options: unknown) => { + calls.push({ executable, args, options }); + return { unref: () => { unrefs += 1; } }; + }) as any); + syncBuiltinESMExports(); + t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); }); + const config = loadChatSwarmRuntimeConfig({ stateDir: "/tmp/devspace-managed", chatSwarmMaxWorkers: 3 }, { + 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", + DEVSPACE_CHAT_SWARM_PROJECT_URL: "https://chatgpt.com/g/g-p-test/project", + }); + for (const cdpMode of [config.cdpMode, undefined]) { + const driver = new CdpMacWebDriver({ ...config, cdpMode }); + let probes = 0; + t.mock.method(driver, "preflight", async () => ({ + ready: ++probes > 1, state: "CONFIGURED_NOT_READY", controlMechanism: "CDP", appBinding: "UNKNOWN", + } as RuntimePreflight)); + await (driver as any).ensureRuntime(new Date(Date.now() + 1500).toISOString()); + assert.equal(probes, 2); + } + assert.equal(unrefs, 2); + assert.deepEqual(calls, [0, 1].map(() => ({ + executable: "/explicit/chrome", + args: ["--remote-debugging-port=9333", "--user-data-dir=/explicit/managed-profile", "--no-first-run", "--no-default-browser-check", config.projectUrl!], + options: { detached: true, stdio: "ignore" }, + }))); +}); + +macOsOnlyTest("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); +}); + +macOsOnlyTest("existing-session browser readiness surfaces unknown app binding without blocking bootstrap 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(c => c.method === "Runtime.evaluate" && c.params.expression.includes("const prompt=")).length, 1); + assert.ok(f.calls.filter(c => c.method === "Target.attachToTarget").every(c => c.params.targetId === "target-a")); + assert.equal(f.targets.get("target-b"), "https://chatgpt.com/c/worker-b"); +}); + +for (const binding of ["DISABLED", "STALE"] as const) { + macOsOnlyTest(`existing-session ${binding} app binding blocks send and recovery with explicit NOT_READY`, async (t) => { + const f = existingCdpFixture(t); + f.dom(binding.toLowerCase()); + const url = "https://chatgpt.com/c/worker-a"; + 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 registry = new ChatSwarmRuntimeStore(f.root); + try { + 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(c => c.method === "Runtime.evaluate" && c.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 { + const peerFingerprint = fingerprint("actual-bootstrap-peer"); + f.adapter.onBootstrap = (operationId) => { + assert.equal(f.registry.getProvision(operationId)?.receipt?.authenticatedPeerFingerprint, undefined); + assert.throws(() => f.manager.bootstrap({}, operationId), /identity evidence/); + f.manager.bootstrap({ "openai/session": "actual-bootstrap-peer" }, operationId); + }; + 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.equal(bound.slots[0]?.authenticatedPeerFingerprint, peerFingerprint); + assert.notEqual(bound.slots[0]?.conversationFingerprint, peerFingerprint); + const persisted = new ChatSwarmRuntimeStore(f.root); + try { + assert.deepEqual(persisted.getSlot(f.swarm.id, 1), bound.slots[0]); + assert.equal(persisted.getProvision(bound.slots[0]!.lastOperationId!)?.receipt?.authenticatedPeerFingerprint, + peerFingerprint); + } finally { persisted.close(); } + await f.manager.ensure(f.owner, f.swarm.id, 1); + assert.equal(f.adapter.provisionCalls, 1); + assert.equal(f.adapter.bootstrapCalls, 1); + assert.equal(f.store.listWorkers(f.swarm.id).length, 1); + } finally { cleanup(f); } +}); + +for (const binding of ["DISABLED", "STALE"] as const) { + test(`runtime retains ${binding} binding reason in durable status without bootstrap or reprovision`, async (t) => { + 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); + } 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); } +}); diff --git a/src/chat-swarm-runtime.ts b/src/chat-swarm-runtime.ts index bd292412..9f073538 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,9 +859,49 @@ export class ChatSwarmRuntimeStore { return tx.immediate(); } - bindWorker(operationId: string, worker: ChatSwarmWorker): ManagedCarrierSlot { + bindWorker( + operationId: string, + worker: ChatSwarmWorker, + observedBootstrapPeerFingerprint?: string, + ): ManagedCarrierSlot { const tx = this.database.sqlite.transaction(() => { const operation = this.requireProvision(operationId); + const recordedPeerFingerprint = operation.receipt?.authenticatedPeerFingerprint; + if ( + recordedPeerFingerprint && + observedBootstrapPeerFingerprint && + recordedPeerFingerprint !== observedBootstrapPeerFingerprint + ) { + throw new ChatSwarmError( + "OWNERSHIP_CONFLICT", + "caller authenticated peer does not match the managed carrier created for this operation", + ); + } + // Only the authenticated bootstrap caller may establish an unknown peer. + const expectedPeerFingerprint = + recordedPeerFingerprint ?? observedBootstrapPeerFingerprint; + if ( + !expectedPeerFingerprint || + worker.swarmId !== operation.request.swarmId || + worker.carrierConversationFingerprint !== expectedPeerFingerprint + ) { + throw new ChatSwarmError( + "OWNERSHIP_CONFLICT", + "worker does not match managed provision identity", + ); + } + assertFingerprint(expectedPeerFingerprint, "authenticated peer fingerprint"); + const peerConflict = this.getSlotByAuthenticatedPeerFingerprint(expectedPeerFingerprint); + if ( + peerConflict && + (peerConflict.swarmId !== operation.request.swarmId || + peerConflict.runtimeSlot !== operation.request.runtimeSlot) + ) { + throw new ChatSwarmError( + "OWNERSHIP_CONFLICT", + "authenticated peer is already managed by another runtime slot", + ); + } if (operation.status === "succeeded" && operation.receipt?.workerId) { if (operation.receipt.workerId !== worker.id) { throw new ChatSwarmError( @@ -852,14 +917,7 @@ export class ChatSwarmRuntimeStore { "carrier identity is not established", ); } - const expectedPeerFingerprint = - operation.receipt.authenticatedPeerFingerprint ?? - operation.receipt.conversationFingerprint; - if ( - !["carrier_created", "bootstrapping"].includes(operation.status) || - worker.swarmId !== operation.request.swarmId || - worker.carrierConversationFingerprint !== expectedPeerFingerprint - ) { + if (!["carrier_created", "bootstrapping"].includes(operation.status)) { throw new ChatSwarmError( "OWNERSHIP_CONFLICT", "worker does not match managed provision identity", @@ -871,8 +929,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 +945,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 +1189,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 +1714,146 @@ 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 +1874,34 @@ 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 +1910,9 @@ 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 +1936,28 @@ 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 +1972,23 @@ 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 +1999,17 @@ 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 +2021,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 +2052,11 @@ 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 +2067,16 @@ 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 +2112,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 +2164,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 +2181,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 +2213,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 +2260,8 @@ 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, @@ -2064,6 +2312,7 @@ export class MacWebChatCarrierAdapter implements ChatSwarmManagedCarrierAdapter ? ("SETUP_REQUIRED" as const) : ("UNKNOWN" as const), remoteMayContinue: sent.remoteMayContinue, + blocker: sent.blocker, }; } return { disposition: "DELIVERED" as const, remoteMayContinue: true }; @@ -2087,8 +2336,7 @@ export class MacWebChatCarrierAdapter implements ChatSwarmManagedCarrierAdapter async ensureExisting(input: CarrierCallInput): Promise { const slot = this.registry.getSlotByWorker(input.swarmId, input.workerId); - const slotAuthorityFingerprint = - slot?.authenticatedPeerFingerprint ?? slot?.conversationFingerprint; + const slotAuthorityFingerprint = slot?.authenticatedPeerFingerprint; if ( !slot?.conversationUrl || slot.workerId !== input.workerId || @@ -2123,8 +2371,7 @@ export class MacWebChatCarrierAdapter implements ChatSwarmManagedCarrierAdapter async wake(input: CarrierCallInput): Promise { const slot = this.registry.getSlotByWorker(input.swarmId, input.workerId); - const slotAuthorityFingerprint = - slot?.authenticatedPeerFingerprint ?? slot?.conversationFingerprint; + const slotAuthorityFingerprint = slot?.authenticatedPeerFingerprint; if ( !slot?.conversationUrl || slot.workerId !== input.workerId || @@ -2332,8 +2579,7 @@ export class ChatSwarmRuntimeManager { } if (slot.state === "SETUP_REQUIRED" || !slot.conversationUrl) continue; - const slotAuthorityFingerprint = - slot.authenticatedPeerFingerprint ?? slot.conversationFingerprint; + const slotAuthorityFingerprint = slot.authenticatedPeerFingerprint; const existingWorker = slotAuthorityFingerprint ? this.coordinator.store.findWorkerByCarrier( swarmId, @@ -2370,9 +2616,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; } @@ -2424,14 +2670,29 @@ export class ChatSwarmRuntimeManager { "runtime provision has no observed conversation identity", ); } - const authenticatedPeerFingerprint = - operation.receipt?.authenticatedPeerFingerprint ?? transportFingerprint; - if (identity.fingerprint !== authenticatedPeerFingerprint) { + const authenticatedPeerFingerprint = identity.fingerprint; + if ( + operation.receipt?.authenticatedPeerFingerprint && + operation.receipt.authenticatedPeerFingerprint !== authenticatedPeerFingerprint + ) { throw new ChatSwarmError( "OWNERSHIP_CONFLICT", "caller authenticated peer does not match the managed carrier created for this operation", ); } + const peerConflict = this.registry.getSlotByAuthenticatedPeerFingerprint( + authenticatedPeerFingerprint, + ); + if ( + peerConflict && + (peerConflict.swarmId !== operation.request.swarmId || + peerConflict.runtimeSlot !== operation.request.runtimeSlot) + ) { + throw new ChatSwarmError( + "OWNERSHIP_CONFLICT", + "authenticated peer is already managed by another runtime slot", + ); + } const existing = this.coordinator.store.findWorkerByCarrier( operation.request.swarmId, authenticatedPeerFingerprint, @@ -2448,7 +2709,10 @@ export class ChatSwarmRuntimeManager { carrierConversationFingerprint: authenticatedPeerFingerprint, }, ); - return { slot: this.registry.bindWorker(operationId, worker), worker }; + return { + slot: this.registry.bindWorker(operationId, worker, identity.fingerprint), + worker, + }; } async wakeForDispatchedTask(meta: unknown, task: ChatSwarmTask): Promise { @@ -2656,7 +2920,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, };