From 1f44e054ed73758dd74381530a67990fa3adb3f5 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 10 Oct 2026 18:34:31 -0700 Subject: [PATCH 01/10] Run the Planner in a client attached over a Planner link HARNESS=remote adds a HarnessV1 adapter whose sessions and turns run in a client attached at /planner-link instead of in the server. The server keeps the document, Decisions and room writes: Chopin's host tools still execute here against the room, and only the model and the client's own tools run on the client's machine. The link is a versioned, schema-validated WebSocket protocol in the new `@chopin/planner-link` package. Frames are strict JSON objects of at most 2 MiB; a malformed, out-of-order or oversized frame ends the link with an error message and close code 1008 or 1009. docs/planner-link.md states the whole wire format, and the package's minimal client uses only that format at runtime, so the remote harness contract suite runs both in Bun and as a separate Node.js process. The upgrade request authenticates a GitHub token bearer exactly as hosted MCP does (401, 403, 503 before upgrading), and attach requires pull access to the document's repository; refusals post nothing. Access is rechecked before every session. Client questions become Decisions through createHumanInput, which now also reports whether a request was answered, expired or cancelled, keeping the verbatim text, 30-minute expiry, cancellation and paused-run hold of the in-process Atomic Planner. Run cards, Stop and Resume travel over the link. A link that drops mid-turn ends the turn with an error, withdraws its questions and shows its runs stopped. HARNESS=remote is accepted on a public bind without MODEL, refuses any HARNESS_AUTH, and requires BACKGROUND_JOBS=off because summary and research workers would have no model. Until one Planner per document is enforced, the newest attachment serves new sessions. Assistant-model: Claude Opus 5.5 (fast) Assistant-workflow: hosted-planner-slices Assistant-duration: 21m converged, estimated 2h Assistant-verification: bun run types passed: every workspace package and e2e Assistant-verification: bun test passed: 4621 pass, 4 skip, 0 fail (GIT_CONFIG_GLOBAL=/dev/null bun test) Assistant-verification: harnessContract passed: remote harness over a real WebSocket with the Bun client and a Node.js 24 client process Assistant-verification: bun run ci passed: dprint, oxlint (31 warnings, same as main), tokens, design contract, Impeccable Assistant-verification: relative doc links passed: scripted check of paths and anchors in the changed docs Co-authored-by: Alex Lavaee --- AGENTS.md | 35 +- apps/server/package.json | 1 + apps/server/src/agent/planner.test.ts | 11 + apps/server/src/agent/planner.ts | 27 +- apps/server/src/chat/service.ts | 2 +- apps/server/src/config.test.ts | 13 + apps/server/src/config.ts | 18 +- apps/server/src/harness/atomic/full.ts | 2 +- apps/server/src/harness/atomic/human-input.ts | 20 +- apps/server/src/harness/harnesses.test.ts | 14 +- apps/server/src/harness/harnesses.ts | 12 + .../src/harness/remote.contract.test.ts | 39 + apps/server/src/harness/remote/adapter.ts | 61 ++ apps/server/src/harness/remote/endpoint.ts | 155 ++++ apps/server/src/harness/remote/link.test.ts | 472 +++++++++++ apps/server/src/harness/remote/link.ts | 764 ++++++++++++++++++ apps/server/src/harness/remote/registry.ts | 52 ++ apps/server/src/harness/session.test.ts | 104 ++- apps/server/src/harness/session.ts | 74 +- apps/server/src/main.ts | 31 +- apps/server/src/mcp/hosted.ts | 139 ++-- .../src/testing/planner-link-node-client.mjs | 39 + apps/server/src/testing/planner-link.ts | 243 ++++++ bun.lock | 13 + docs/hosted-agent.md | 93 ++- docs/planner-link.md | 263 ++++++ docs/self-hosting.md | 92 ++- packages/planner-link/package.json | 13 + packages/planner-link/src/client.ts | 331 ++++++++ packages/planner-link/src/index.ts | 1 + packages/planner-link/src/protocol.test.ts | 154 ++++ packages/planner-link/src/protocol.ts | 375 +++++++++ packages/planner-link/tsconfig.json | 5 + 33 files changed, 3522 insertions(+), 146 deletions(-) create mode 100644 apps/server/src/harness/remote.contract.test.ts create mode 100644 apps/server/src/harness/remote/adapter.ts create mode 100644 apps/server/src/harness/remote/endpoint.ts create mode 100644 apps/server/src/harness/remote/link.test.ts create mode 100644 apps/server/src/harness/remote/link.ts create mode 100644 apps/server/src/harness/remote/registry.ts create mode 100644 apps/server/src/testing/planner-link-node-client.mjs create mode 100644 apps/server/src/testing/planner-link.ts create mode 100644 docs/planner-link.md create mode 100644 packages/planner-link/package.json create mode 100644 packages/planner-link/src/client.ts create mode 100644 packages/planner-link/src/index.ts create mode 100644 packages/planner-link/src/protocol.test.ts create mode 100644 packages/planner-link/src/protocol.ts create mode 100644 packages/planner-link/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 7c714b847..7347d1556 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ The most useful technical references are: - [Repository channels](docs/channels.md) - [Storage and persistence](docs/storage.md) - [Hosted agent (Planner)](docs/hosted-agent.md) +- [Planner link wire format](docs/planner-link.md) - [Background jobs and workers](docs/background-jobs.md) - [Experimental implementation lifecycle](docs/implementation-lifecycle.md) - [Self-hosting](docs/self-hosting.md) @@ -62,20 +63,21 @@ Before opening a PR or repairing CI, use the repository's ## Repository map -| Area | Responsibility | Internal workspace dependencies | -| --------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------ | -| `packages/dialect` | Restricted MDX, MDAST, and Lexical schema | none | -| `packages/protocol` | WebSocket declarations and addressing helper | none | -| `packages/question` | Questionnaire definitions and shared drafts | `protocol` | -| `packages/draft` | Bounded collaborative plain-text drafts | none | -| `packages/viewport` | Browser geometry and subscriptions | none | -| `packages/diagrams` | Bounded diagram rendering and scoped React viewing | `icons` (React peer) | -| `packages/experiment` | Investigation result schemas, selections and native views | `diagrams` (React peer) | -| `apps/connector` | Local ACP client and run-scoped MCP bridge | `experiment`, `protocol` | -| `packages/editor` | Collaborative editor, decisions, comments, and widgets | `diagrams`, `dialect`, `experiment`, `question`, `protocol`, `viewport` | -| `apps/server` | Auth, channels, rooms, storage, Planner, MCP, tasks | `diagrams`, `dialect`, `draft`, `experiment`, `question`, `protocol` | -| `apps/web` | Repository picker, navigation, conversation, workspace | `dialect`, `diagrams`, `draft`, `editor`, `experiment`, `protocol`, `viewport` | -| `e2e` | Browser and system integration harness | may import server internals as fixtures | +| Area | Responsibility | Internal workspace dependencies | +| ----------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `packages/dialect` | Restricted MDX, MDAST, and Lexical schema | none | +| `packages/protocol` | WebSocket declarations and addressing helper | none | +| `packages/planner-link` | Remote Planner link messages, schemas and test client | none | +| `packages/question` | Questionnaire definitions and shared drafts | `protocol` | +| `packages/draft` | Bounded collaborative plain-text drafts | none | +| `packages/viewport` | Browser geometry and subscriptions | none | +| `packages/diagrams` | Bounded diagram rendering and scoped React viewing | `icons` (React peer) | +| `packages/experiment` | Investigation result schemas, selections and native views | `diagrams` (React peer) | +| `apps/connector` | Local ACP client and run-scoped MCP bridge | `experiment`, `protocol` | +| `packages/editor` | Collaborative editor, decisions, comments, and widgets | `diagrams`, `dialect`, `experiment`, `question`, `protocol`, `viewport` | +| `apps/server` | Auth, channels, rooms, storage, Planner, MCP, tasks | `diagrams`, `dialect`, `draft`, `experiment`, `planner-link`, `question`, `protocol` | +| `apps/web` | Repository picker, navigation, conversation, workspace | `dialect`, `diagrams`, `draft`, `editor`, `experiment`, `protocol`, `viewport` | +| `e2e` | Browser and system integration harness | may import server internals as fixtures | Runtime workspace packages do not depend on an application. E2E and skill contract tests may deliberately import server internals; do not treat those test @@ -135,6 +137,11 @@ collaborative state and external implementation runs are durable. remembered per channel in memory, or else an empty per-channel directory. Its summary and research workers stay isolated. There is no flag for this; the harness is the choice. +- **`HARNESS=remote` moves the model, not the authority.** Turns run in a client + attached at `/planner-link` with a GitHub bearer and pull access; Chopin's + host tools, Decisions, and room writes still run in the server. The attached + client sees what a turn sees and steers its document writes. See + [Hosted agent](docs/hosted-agent.md#remote-planner). - **Repository node IDs are authoritative.** Owner and repository names resolve GitHub requests but never replace the stored node identity. - **Persistence should precede publication.** Do not acknowledge or broadcast a diff --git a/apps/server/package.json b/apps/server/package.json index a67162ef1..2e24975b0 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -20,6 +20,7 @@ "@chopin/diagrams": "workspace:*", "@chopin/draft": "workspace:*", "@chopin/experiment": "workspace:*", + "@chopin/planner-link": "workspace:*", "@chopin/protocol": "workspace:*", "@chopin/question": "workspace:*", "@github/copilot": "catalog:copilot", diff --git a/apps/server/src/agent/planner.test.ts b/apps/server/src/agent/planner.test.ts index 7c67c1c1d..f712723c4 100644 --- a/apps/server/src/agent/planner.test.ts +++ b/apps/server/src/agent/planner.test.ts @@ -51,6 +51,17 @@ test("ordinary and Atomic guidance prefers native diagrams with a bounded Mermai } }); +test("a remote Planner learns where it runs and that its questions become Decisions", () => { + let prompt = plannerInstructions("octo-org/score", "Earlier context.", { remote: true }) + .replace(/\s+/g, " "); + expect(prompt).toContain("You run in a member's own session on their machine"); + expect(prompt).toContain("questions appear to the document's members as Decisions"); + expect(prompt).toContain("**Stop Planner** pauses it"); + expect(prompt).not.toContain("You have no shell"); + expect(prompt).not.toContain("Your working directory"); + expect(prompt.endsWith("Earlier context.")).toBe(true); +}); + test("ordinary guidance supplies an executable numerical chart example", () => { let examples = [...DIAGRAM_AUTHORING.matchAll(/```seecode\n([^`]+)\n```/g)] .map(match => JSON.parse(match[1]!)); diff --git a/apps/server/src/agent/planner.ts b/apps/server/src/agent/planner.ts index 736a68657..4ac3cdc47 100644 --- a/apps/server/src/agent/planner.ts +++ b/apps/server/src/agent/planner.ts @@ -305,10 +305,13 @@ the plan, use the \`detach_question\` operation rather than deleting the block.` const ROUTED_PROMPT = PROMPT.replace(DIAGRAM_INTRO, ROUTED_DIAGRAM_INTRO) .replace(DIAGRAM_AUTHORING, ROUTED_DIAGRAM_AUTHORING); +/** Where a remote Planner's model and own tools run: the attached member's machine. */ +export type RemoteWorkspace = { remote: true }; + export function plannerInstructions( repository: string, bootstrap?: string, - workspace?: PlannerWorkspace, + workspace?: PlannerWorkspace | RemoteWorkspace, visualRouting = false, ): string { let visual = visualRouting @@ -337,13 +340,6 @@ add a table or diagram without its route.` and cannot change GitHub. Ground the plan in what those reading tools return.`; return [prompt, visual, reading, isolated, bootstrap].filter(Boolean).join("\n\n"); } - let place = workspace.checkout - ? `Your working directory, ${workspace.cwd}, is a local checkout of ${repository} -verified against its origin. Its branch and working tree may differ from what the -repository tools read.` - : `Your working directory, ${workspace.cwd}, is a scratch directory Chopin keeps for -this document. It is not a checkout and holds no repository files, so read -${repository} through the repository tools.`; let questions = `\`ask_user_question\` and \`workflow\` questions appear to the document's members as Decisions. If one expires unanswered, proceed on your best judgement and say what you assumed.`; @@ -354,6 +350,21 @@ tool result suggests it. When you start a workflow, say in plain words what it w where to follow it: Chat shows a card for each run with its stages and status, its questions appear under Decisions, **Stop Planner** pauses it, and **Resume Planner** resumes it. Offer to check on or steer a run yourself with your \`workflow\` and \`intercom\` tools when someone asks.`; + if ("remote" in workspace) { + let remote = `You run in a member's own session on their machine, attached to this document. +Your own tools act on that machine and are not visible to the document's other members. The +document changes only through this document's tools, and ${repository} is read through the +repository tools, whatever that machine holds.`; + return [prompt, visual, reading, remote, questions, surface, bootstrap].filter(Boolean) + .join("\n\n"); + } + let place = workspace.checkout + ? `Your working directory, ${workspace.cwd}, is a local checkout of ${repository} +verified against its origin. Its branch and working tree may differ from what the +repository tools read.` + : `Your working directory, ${workspace.cwd}, is a scratch directory Chopin keeps for +this document. It is not a checkout and holds no repository files, so read +${repository} through the repository tools.`; let implementing = `Your own tools read files and the web but cannot edit files or run commands, so you do not implement the plan yourself. If a member asks to implement it diff --git a/apps/server/src/chat/service.ts b/apps/server/src/chat/service.ts index 1c4f18b10..fd446ade0 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -1672,7 +1672,7 @@ async function repositorySession( workspace, visualRouting(context), ), - model: context.config.model, + model: context.config.model || undefined, harness: context.config.harness, }); if (!result.ok) throw new Error(`Planner session unavailable (${result.error.kind})`); diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index 60fc83d15..92a9ec59f 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -101,6 +101,19 @@ describe("configuration", () => { }); }); + it("accepts a remote Planner on a public bind with no MODEL, and only without background jobs", () => { + let remote = { HARNESS: "remote", SERVER_HOST: "0.0.0.0", BACKGROUND_JOBS: "off" }; + let config = configured(remote); + expect(config).toMatchObject({ harness: "remote", host: "0.0.0.0", model: "" }); + expect(description(config)).toContain("agent: chosen by the attached client"); + expect(description(config)).toContain( + "Planner: attached client over /planner-link (model and client tools run on its machine)", + ); + expect(configured({ ...remote, MODEL: "openai/gpt-6" }).model).toBe("openai/gpt-6"); + expect(() => configured({ HARNESS: "remote" })) + .toThrow("HARNESS=remote requires BACKGROUND_JOBS=off"); + }); + it("chooses a full Atomic Planner by harness alone, in hosted and local configuration", () => { let atomic = { HARNESS: "atomic", HARNESS_AUTH: "ai-gateway", MODEL: "stub/model" }; for (let mode of [{}, LOCAL]) { diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 510457eb7..d510778b2 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -16,7 +16,7 @@ import type { StorageConfig } from "./storage/registry"; export type Config = { host: string; port: number; - /** Planner model. */ + /** Planner model. Empty under `HARNESS=remote` unless set, where the attached client chooses. */ model: string; harness: string; harnessAuth: string | undefined; @@ -63,10 +63,12 @@ const DEFAULT_MODEL = "gpt-6-luna"; /** The default is a Copilot model ID. Pi and Atomic resolve IDs against their * own catalogs, so those deployments must name a model rather than inherit one - * the catalog lacks. */ + * the catalog lacks. A remote Planner's client brings its own model; a + * configured one is only passed along as the turn's request. */ function model(harness: string): string { let configured = process.env.MODEL; if (configured) return configured; + if (harness === "remote") return ""; if (harness === "pi" || harness === "atomic") { throw new Error(`MODEL is required when HARNESS=${harness}`); } @@ -138,6 +140,11 @@ export function load(): Config { throw new Error("JEV_MODEL must be a valid model alias"); } let selection = harnessSelection(); + if (selection.harness === "remote" && backgroundJobs) { + throw new Error( + "HARNESS=remote requires BACKGROUND_JOBS=off: summary and research workers have no model on the server", + ); + } let serverPort = port(); return { ...selection, @@ -175,11 +182,16 @@ export function describe(config: Config): string { "chopin", `http://${config.host}:${config.port}`, config.devClient ? `client: vite (${config.devClient})` : "client: built", - config.agent ? `agent: ${config.model} (on demand)` : "agent: off", + config.agent + ? `agent: ${config.model || "chosen by the attached client"} (on demand)` + : "agent: off", `harness: ${config.harness}`, ...(config.harness === "atomic" ? ["Planner: full Atomic session (shell and filesystem access as this process's user)"] : []), + ...(config.harness === "remote" + ? ["Planner: attached client over /planner-link (model and client tools run on its machine)"] + : []), ...(config.harnessExtensions.length ? [`Planner extensions: ${config.harnessExtensions.join(", ")}`] : []), diff --git a/apps/server/src/harness/atomic/full.ts b/apps/server/src/harness/atomic/full.ts index 4cf23e980..7a98f11c1 100644 --- a/apps/server/src/harness/atomic/full.ts +++ b/apps/server/src/harness/atomic/full.ts @@ -60,7 +60,7 @@ export async function resumeOwnedRuns(workflows: SessionWorkflows): Promise, runId: string, signal: AbortSignal, ): Promise { diff --git a/apps/server/src/harness/atomic/human-input.ts b/apps/server/src/harness/atomic/human-input.ts index e5df16b11..f900e2ac0 100644 --- a/apps/server/src/harness/atomic/human-input.ts +++ b/apps/server/src/harness/atomic/human-input.ts @@ -11,23 +11,31 @@ import type { } from "@bastani/atomic"; import type { DocumentRoom } from "../../agent/tools"; +export type HumanInputStatus = "answered" | "expired" | "cancelled"; + /** * Atomic owns input requests; Chopin owns their shared decision records. * * A request nobody answers within `expiresInMs` expires: its cards stay in * Decisions marked expired, and Atomic gets no answer. An answer to a workflow * question is returned only once `hold` allows it, so a paused run stays stopped. + * `report` learns how each request ended, which the cancelled result alone + * cannot tell apart. */ export function createHumanInput( room: DocumentRoom, expiresInMs = limits.INPUT_EXPIRY_MS, hold?: (workflowRunId: string, signal: AbortSignal) => Promise, + report?: (status: HumanInputStatus) => void, ): HostInput { async function questionnaire( params: QuestionParams, options: HostInputOptions, ): Promise { - if (options.signal.aborted) return { answers: [], cancelled: true }; + if (options.signal.aborted) { + report?.("cancelled"); + return { answers: [], cancelled: true }; + } let definition = Questions.identify({ questions: params.questions.map(question => ({ header: question.header, @@ -54,11 +62,15 @@ export function createHumanInput( expiresInMs, ); if (options.signal.aborted || ended.some(outcome => outcome.status === "expired")) { + report?.(options.signal.aborted ? "cancelled" : "expired"); return { answers: [], cancelled: true }; } if (hold && options.workflowRunId !== undefined) { await hold(options.workflowRunId, options.signal); - if (options.signal.aborted) return { answers: [], cancelled: true }; + if (options.signal.aborted) { + report?.("cancelled"); + return { answers: [], cancelled: true }; + } } let answers: QuestionAnswer[] = []; ended.forEach((outcome, questionIndex) => { @@ -88,7 +100,9 @@ export function createHumanInput( }); } }); - return { answers, cancelled: ended.some(outcome => outcome.status === "cancelled") }; + let cancelled = ended.some(outcome => outcome.status === "cancelled"); + report?.(cancelled ? "cancelled" : "answered"); + return { answers, cancelled }; } async function single( header: string, diff --git a/apps/server/src/harness/harnesses.test.ts b/apps/server/src/harness/harnesses.test.ts index 1751c071b..073eeb990 100644 --- a/apps/server/src/harness/harnesses.test.ts +++ b/apps/server/src/harness/harnesses.test.ts @@ -6,8 +6,8 @@ import { harnesses, harnessFor, registerCredential, shutdownHarnesses } from "./ afterEach(shutdownHarnesses); -it("selects the tested Copilot SDK, Pi, and Atomic harnesses", () => { - expect(Object.keys(harnesses)).toEqual(["copilot-sdk", "pi", "atomic"]); +it("selects the tested Copilot SDK, Pi, Atomic, and remote harnesses", () => { + expect(Object.keys(harnesses)).toEqual(["copilot-sdk", "pi", "atomic", "remote"]); expect(harnessFor({ harness: "copilot-sdk" }).harnessId).toBe("copilot-sdk"); expect(() => harnessFor({ harness: "not-installed" })).toThrow("Unknown harness: not-installed"); }); @@ -68,6 +68,16 @@ it("refuses Atomic's host-login auto mode on a non-loopback bind but accepts ai- .toBe("atomic"); }); +it("accepts the remote harness on any bind without HARNESS_AUTH, and refuses one", () => { + for (let host of ["0.0.0.0", "::", "192.168.1.2", "127.0.0.1"]) { + expect(harnessFor({ harness: "remote", host }).harnessId).toBe("remote"); + expect(() => harnessFor({ harness: "remote", harnessAuth: "ai-gateway", host })) + .toThrow("HARNESS_AUTH does not apply to HARNESS=remote"); + } + expect(() => harnessFor({ harness: "remote", harnessExtensions: [tmpdir()] })) + .toThrow("HARNESS_EXTENSIONS requires HARNESS=atomic, not remote"); +}); + it("accepts only existing absolute extension paths, and only for the atomic harness", () => { let atomic = { harness: "atomic", harnessAuth: "auto", host: "127.0.0.1" }; expect(() => harnessFor({ ...atomic, harnessExtensions: ["relative/package"] })) diff --git a/apps/server/src/harness/harnesses.ts b/apps/server/src/harness/harnesses.ts index 685456f42..f10365956 100644 --- a/apps/server/src/harness/harnesses.ts +++ b/apps/server/src/harness/harnesses.ts @@ -5,6 +5,7 @@ import { ATOMIC_AUTH_MODES, createAtomicAdapter } from "./atomic/adapter"; import { forgetWorkspaces } from "./atomic/workspace"; import { createCopilotSdk } from "./copilot-sdk/adapter"; import { createPiAdapter } from "./pi/adapter"; +import { createRemoteAdapter } from "./remote/adapter"; import type { HarnessV1 } from "@ai-sdk/harness"; import type { AtomicAuthMode } from "./atomic/adapter"; @@ -30,10 +31,16 @@ function createAtomicHarness( }); } +/** The model runs in a client attached over the Planner link, so the server holds no credential. */ +function createRemoteHarness(): HarnessV1 & { shutdown(): Promise } { + return createRemoteAdapter(); +} + export const harnesses = { "copilot-sdk": createCopilotSdk, pi: createPiHarness, atomic: createAtomicHarness, + remote: createRemoteHarness, } satisfies Record< string, (settings: { @@ -89,6 +96,11 @@ export function harnessFor(config: { } let extensions = checkedExtensions(config.harness, config.harnessExtensions); let auth = config.harnessAuth; + if (config.harness === "remote" && auth) { + throw new Error( + "HARNESS_AUTH does not apply to HARNESS=remote: the model runs in the attached client", + ); + } let isLoopback = loopback(config.host ?? "127.0.0.1"); let modes = AUTH_MODES.get(config.harness); if (modes) { diff --git a/apps/server/src/harness/remote.contract.test.ts b/apps/server/src/harness/remote.contract.test.ts new file mode 100644 index 000000000..7d03f5b13 --- /dev/null +++ b/apps/server/src/harness/remote.contract.test.ts @@ -0,0 +1,39 @@ +import { afterAll, expect, it } from "bun:test"; + +import { harnessContract } from "./contract"; +import { createRemoteAdapter } from "./remote/adapter"; +import { attach, linkAuth, linkServer, nodeClient, scriptedPlanner } from "../testing/planner-link"; + +let { auth, channel } = await linkAuth(); +let server = linkServer(auth); +let client = await attach(server.url, channel.id, scriptedPlanner().handlers); +afterAll(async () => { + await client.detach(); + server.stop(); +}); + +harnessContract("remote", () => + createRemoteAdapter(() => ({ + link: server.links.at(-1)!, + planner: { channelId: channel.id, isolated: false }, + }))); + +let nodeLink = await linkAuth(); +let nodeServer = linkServer(nodeLink.auth); +let node = await nodeClient(nodeServer.url, nodeLink.channel.id); +afterAll(async () => { + await node?.stop(); + nodeServer.stop(); +}); + +if (node) { + it("attaches a Node.js client that uses only the documented wire format", () => { + expect(node.attached).toBe(nodeLink.channel.id); + expect(nodeServer.links).toHaveLength(1); + }); + harnessContract(`remote over Node.js ${node.node}`, () => + createRemoteAdapter(() => ({ + link: nodeServer.links.at(-1)!, + planner: { channelId: nodeLink.channel.id, isolated: false }, + }))); +} diff --git a/apps/server/src/harness/remote/adapter.ts b/apps/server/src/harness/remote/adapter.ts new file mode 100644 index 000000000..c18c5f9ea --- /dev/null +++ b/apps/server/src/harness/remote/adapter.ts @@ -0,0 +1,61 @@ +import { attachedLink, remotePlanner } from "./registry"; + +import type { HarnessV1, HarnessV1Session, HarnessV1StartOptions } from "@ai-sdk/harness"; +import type { PlannerLink } from "./link"; +import type { RemotePlanner } from "./registry"; + +export type RemoteTarget = { link: PlannerLink; planner: RemotePlanner }; + +/** + * Finds the attached client and Chopin's record for a session. Only sessions + * Chopin registered for a document's Planner have one. + */ +export function attachedTarget(sessionId: string): RemoteTarget { + let planner = remotePlanner(sessionId); + if (!planner) { + throw new Error( + "HARNESS=remote runs only Planner sessions; this session has no document to attach to.", + ); + } + let link = attachedLink(planner.channelId); + if (!link) throw new Error("No Planner is attached to this document."); + return { link, planner }; +} + +/** + * `HarnessV1` whose sessions run in a client attached over the Planner link. + * The server keeps no model and no client tools: it forwards each session and + * turn, and runs Chopin's host tools when the client calls them. + */ +export function createRemoteAdapter( + resolve: (sessionId: string) => RemoteTarget = attachedTarget, +): HarnessV1 & { shutdown(): Promise } { + let open = new Set(); + return { + specificationVersion: "harness-v1", + harnessId: "remote", + builtinTools: {}, + supportsBuiltinToolFiltering: true, + + async doStart(options: HarnessV1StartOptions): Promise { + if (options.resumeFrom || options.continueFrom) { + throw new Error("The remote Planner cannot resume a session."); + } + let { link, planner } = resolve(options.sessionId); + let session = await link.startSession(options.sessionId, planner); + let handle: HarnessV1Session = { + ...session, + async doDestroy() { + open.delete(handle); + await session.doDestroy(); + }, + }; + open.add(handle); + return handle; + }, + + async shutdown() { + await Promise.allSettled([...open].map(session => session.doDestroy())); + }, + }; +} diff --git a/apps/server/src/harness/remote/endpoint.ts b/apps/server/src/harness/remote/endpoint.ts new file mode 100644 index 000000000..a6978615c --- /dev/null +++ b/apps/server/src/harness/remote/endpoint.ts @@ -0,0 +1,155 @@ +/** + * `/planner-link`: where a client attaches as a document's Planner. + * + * The bearer is a GitHub token checked as hosted MCP checks it, before the + * socket upgrades. The `attach` that follows names a document its account must + * be able to read. Nothing is posted to the document by attaching or refusing. + */ + +import { AdmissionDenied } from "../../auth/admission"; +import { documentUrl } from "../../channels/document-url"; +import { GitHubError } from "../../github/client"; +import { locateDocument } from "../../mcp/hosted"; +import { PlannerLink } from "./link"; +import { attachLink } from "./registry"; + +import type { ServerWebSocket } from "bun"; +import type { HostedAuth } from "../../auth/routes"; +import type { HostedCaller } from "../../mcp/hosted"; +import type { AttachOutcome, LinkOptions, LinkSocket } from "./link"; + +export type PlannerLinkAuth = Pick; + +export type PlannerLinkSocketData = { + plannerLink: true; + sidebar?: undefined; + caller: HostedCaller; + link?: PlannerLink; +}; + +export type PlannerLinkHooks = { + /** A document being deleted cannot be attached. */ + unavailable?: (channelId: string) => boolean; + register?: LinkOptions["register"]; + timeoutMs?: number; +}; + +const BEARER = /^Bearer ([A-Za-z0-9._~+/-]+=*)$/i; + +function refusal(status: number, text: string): Response { + let headers = new Headers({ + "cache-control": "no-store", + "x-content-type-options": "nosniff", + }); + if (status === 401) headers.set("www-authenticate", "Bearer"); + return new Response(text, { status, headers }); +} + +function revoked(err: unknown): boolean { + return err instanceof AdmissionDenied || err instanceof GitHubError && err.status === 401; +} + +/** The caller for an upgrade request, or the HTTP refusal; nothing is upgraded on refusal. */ +export async function admitPlannerLink( + request: Request, + auth: PlannerLinkAuth, +): Promise { + let origin = request.headers.get("origin"); + if (origin !== null && origin !== auth.config.origin) { + return refusal(403, "origin is not allowed"); + } + let token = request.headers.get("authorization")?.match(BEARER)?.[1]; + if (!token) return refusal(401, "unauthorized"); + try { + return { + plannerLink: true, + caller: { oauthToken: token, user: await auth.admission.user(token) }, + }; + } catch (err) { + if (err instanceof AdmissionDenied) return refusal(403, "forbidden"); + if (err instanceof GitHubError) { + if (err.status === 401) return refusal(401, "unauthorized"); + if (err.status === 429 || err.status >= 500) { + return refusal(503, "GitHub access is temporarily unavailable"); + } + } + console.error("[planner-link] admission failed:", err); + return refusal(500, "Planner link request failed"); + } +} + +/** A link for one admitted caller, attaching only documents the caller can read. */ +export function plannerLink( + caller: HostedCaller, + auth: PlannerLinkAuth, + socket: LinkSocket, + hooks: PlannerLinkHooks = {}, +): PlannerLink { + let channelId: string | undefined; + async function attach(message: { document: string }): Promise { + let located = await locateDocument(auth, caller, message.document); + if (located === "forbidden") { + return { + ok: false, + code: "repository-forbidden", + message: "Your GitHub account cannot read this document's repository.", + }; + } + if (!located || hooks.unavailable?.(located.channel.id)) { + return { + ok: false, + code: "document-unavailable", + message: "No document matches that id or URL.", + }; + } + let { channel, repository } = located; + if (channel.archivedAt) { + return { ok: false, code: "document-archived", message: "This document is archived." }; + } + channelId = channel.id; + return { + ok: true, + document: { + id: channel.id, + title: channel.title, + repository: `${repository.owner}/${repository.name}`, + url: new URL(await documentUrl(channel, auth.storage.channels), auth.config.origin).href, + }, + }; + } + async function revalidate(): Promise { + if (!channelId) return false; + try { + await auth.admission.user(caller.oauthToken); + } catch (err) { + if (revoked(err)) return false; + throw err; + } + let located = await locateDocument(auth, caller, channelId); + return typeof located === "object" && located.channel.id === channelId + && !located.channel.archivedAt && !hooks.unavailable?.(channelId); + } + return new PlannerLink(socket, { + attach, + revalidate, + register: hooks.register ?? attachLink, + timeoutMs: hooks.timeoutMs, + }); +} + +export function isPlannerLinkSocket( + ws: ServerWebSocket, +): ws is ServerWebSocket { + return (ws.data as { plannerLink?: unknown } | undefined)?.plannerLink === true; +} + +export function openPlannerLinkSocket( + ws: ServerWebSocket, + auth: PlannerLinkAuth, + hooks: PlannerLinkHooks = {}, +): void { + ws.data.link = plannerLink(ws.data.caller, auth, { + send: text => void ws.send(text), + close: (code, reason) => ws.close(code, reason), + }, hooks); +} diff --git a/apps/server/src/harness/remote/link.test.ts b/apps/server/src/harness/remote/link.test.ts new file mode 100644 index 000000000..df31caf41 --- /dev/null +++ b/apps/server/src/harness/remote/link.test.ts @@ -0,0 +1,472 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { HarnessAgent } from "@ai-sdk/harness/agent"; +import { tool } from "ai"; +import { z } from "zod"; +import { LINK_CLOSE, MAX_LINK_MESSAGE_BYTES, PLANNER_LINK_VERSION } from "@chopin/planner-link"; +import { PlannerLinkRefused } from "@chopin/planner-link/client"; + +import { GitHubError } from "../../github/client"; +import * as Store from "../../questions/store"; +import { createHumanInput } from "../atomic/human-input"; +import { untilUnpaused } from "../atomic/full"; +import { hostInputRoom } from "../../testing/decisions"; +import { + attach, + DENIED_TOKEN, + finishTurn, + linkAuth, + linkServer, + OUTSIDER_TOKEN, + rawLink, + READER_TOKEN, + scriptedPlanner, +} from "../../testing/planner-link"; +import { createRemoteAdapter } from "./adapter"; + +import type { PlannerLinkClient, PlannerLinkHandlers } from "@chopin/planner-link/client"; +import type { RemotePlanner } from "./registry"; + +let cleanups: Array<() => unknown> = []; +afterEach(async () => { + for (let cleanup of cleanups.splice(0).toReversed()) await cleanup(); +}); + +async function setup(handlers?: Partial) { + let { auth, storage, channel } = await linkAuth(); + let server = linkServer(auth, 300); + cleanups.push(server.stop); + let script = scriptedPlanner(); + let client: PlannerLinkClient | undefined; + if (handlers !== undefined) { + client = await attach(server.url, channel.id, { ...script.handlers, ...handlers }); + cleanups.push(() => client!.detach()); + } + return { auth, storage, channel, server, script, client: client! }; +} + +function adapterFor(server: ReturnType, planner: RemotePlanner) { + return createRemoteAdapter(() => ({ link: server.links.at(-1)!, planner })); +} + +const sandboxSession = { + defaultWorkingDirectory: "/tmp", + async run() { + return { exitCode: 0, stdout: "", stderr: "" }; + }, + async destroy() {}, +} as never; + +function agentFor(harness: ReturnType) { + return new HarnessAgent({ + harness, + tools: { read_plan: tool({ inputSchema: z.object({}), execute: async () => "the plan" }) }, + activeTools: ["read_plan"], + permissionMode: "allow-reads", + }); +} + +/** Streams one turn to its end, returning its parts and any error it ended with. */ +async function streamTurn(harness: ReturnType, prompt: string) { + let agent = agentFor(harness); + let session = await agent.createSession({ sandboxSession }); + cleanups.push(() => session.destroy()); + let parts: { type: string; [key: string]: unknown }[] = []; + let failure: unknown; + try { + let result = await agent.stream({ session, prompt }); + for await (let part of result.fullStream) parts.push(part as never); + } catch (error) { + failure = error; + } + let errors = parts.filter(part => part.type === "error").map(part => String(part.error)); + if (failure) errors.push(String(failure)); + return { parts, errors, session }; +} + +function upgradeRequest(url: string, headers: Record = {}) { + return fetch(`${url}/planner-link`, { + headers: { + connection: "Upgrade", + upgrade: "websocket", + "sec-websocket-version": "13", + "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==", + ...headers, + }, + }); +} + +describe("Planner link admission", () => { + it("checks the bearer as hosted MCP does and upgrades nothing it refuses", async () => { + let { server, channel } = await setup(); + let missing = await upgradeRequest(server.url); + expect(missing.status).toBe(401); + expect(missing.headers.get("www-authenticate")).toBe("Bearer"); + expect((await upgradeRequest(server.url, { authorization: "Bearer bad-token" })).status) + .toBe(401); + expect((await upgradeRequest(server.url, { authorization: `Bearer ${DENIED_TOKEN}` })).status) + .toBe(403); + expect( + (await upgradeRequest(server.url, { + authorization: `Bearer ${READER_TOKEN}`, + origin: "https://elsewhere.test", + })).status, + ).toBe(403); + await expect(attach(server.url, channel.id, scriptedPlanner().handlers, "bad-token")) + .rejects.toThrow("closed before attaching"); + expect(server.links).toEqual([]); + }); + + it("refuses a repository the caller cannot read, a missing or archived document, and another version", async () => { + let { server, channel, storage } = await setup(); + let before = await storage.channels.get(channel.id); + let refusal = (token: string, document: string) => + attach(server.url, document, scriptedPlanner().handlers, token).then( + () => undefined, + (error: PlannerLinkRefused) => error.code, + ); + expect(await refusal(OUTSIDER_TOKEN, channel.id)).toBe("repository-forbidden"); + expect(await refusal(READER_TOKEN, crypto.randomUUID())).toBe("document-unavailable"); + let raw = await rawLink(server.url); + raw.send({ type: "attach", version: PLANNER_LINK_VERSION + 1, document: channel.id }); + expect(await raw.next("refused")).toMatchObject({ code: "unsupported-version" }); + expect((await raw.closed).code).toBe(LINK_CLOSE.unsupportedVersion); + expect(await storage.channels.get(channel.id)).toEqual(before); + await storage.channels.archive({ id: channel.id, now: new Date() }); + expect(await refusal(READER_TOKEN, channel.id)).toBe("document-archived"); + expect(server.links).toEqual([]); + }); + + it("attaches a reader to the document by id or URL", async () => { + let { server, channel } = await setup(); + let client = await attach(server.url, channel.id, scriptedPlanner().handlers); + expect(client.document).toEqual({ + id: channel.id, + title: "Linked document", + repository: "octo-org/score", + url: `https://chopin.test/documents/octo-org/score/${channel.slug}`, + }); + let byUrl = await attach(server.url, client.document.url!, scriptedPlanner().handlers); + expect(byUrl.document.id).toBe(channel.id); + expect(server.links.map(link => link.channelId)).toEqual([channel.id, channel.id]); + await byUrl.detach(); + await client.detach(); + expect(server.links).toEqual([]); + }); +}); + +describe("Planner link protocol", () => { + it("closes the link on malformed or out-of-order messages", async () => { + let { server, channel } = await setup(); + for ( + let [frames, code] of [ + [["not json"], "malformed-message"], + [[{ type: "attach", version: 1, document: channel.id, extra: true }], "malformed-message"], + [[{ type: "started", session: "s" }], "protocol"], + [[ + { type: "attach", version: 1, document: channel.id }, + { type: "attach", version: 1, document: channel.id }, + ], "protocol"], + ] as const + ) { + let raw = await rawLink(server.url); + for (let frame of frames) raw.send(frame); + expect(await raw.next("error")).toMatchObject({ code }); + expect((await raw.closed).code).toBe(LINK_CLOSE.malformed); + } + expect(server.links).toEqual([]); + }); + + it("closes the link on an oversized message", async () => { + let { server } = await setup(); + let raw = await rawLink(server.url); + raw.send(JSON.stringify({ type: "detach", padding: "x".repeat(MAX_LINK_MESSAGE_BYTES) })); + expect(await raw.next("error")).toMatchObject({ code: "message-too-large" }); + expect((await raw.closed).code).toBe(LINK_CLOSE.tooLarge); + }); + + it("ends the turn with an error when the client disconnects mid-turn", async () => { + let { server, channel } = await setup(); + let raw = await rawLink(server.url); + raw.send({ type: "attach", version: 1, document: channel.id }); + await raw.next("attached"); + let planner: RemotePlanner = { channelId: channel.id, isolated: false }; + let harness = adapterFor(server, planner); + let started = (async () => { + let start = await raw.next("start"); + raw.send({ type: "started", session: start.session }); + await raw.next("turn"); + raw.socket.close(); + })(); + let outcome = await streamTurn(harness, "hang"); + await started; + expect(outcome.errors.join("\n")).toContain("disconnected during the turn"); + expect(outcome.session.hasUnfinishedTurn()).toBe(false); + expect(server.links).toEqual([]); + }); + + it("stops the runs a dropped link reported, so nothing waits on them", async () => { + let { server, channel, client } = await setup({}); + let planner: RemotePlanner = { channelId: channel.id, isolated: false }; + let reports: unknown[] = []; + planner.onRuns = runs => reports.push(runs); + let session = await adapterFor(server, planner).doStart({ + sessionId: "runs", + sandboxSession, + sessionWorkDir: "/tmp", + }); + cleanups.push(() => session.doDestroy()); + let card = { + id: "run", + name: "plan", + status: "running" as const, + started: 1, + updated: 1, + stages: [], + waiting: 0, + }; + let done = { ...card, id: "done", status: "finished" as const, ended: 2 }; + client.reportRuns("runs", { active: ["run"], paused: [], cards: [card, done] }); + await Bun.sleep(20); + expect(planner.runs?.active).toEqual(["run"]); + await client.detach(); + await Bun.sleep(20); + expect(planner.runs).toEqual({ + active: [], + paused: [], + cards: [{ ...card, status: "stopped" }, done], + }); + expect(reports).toHaveLength(2); + }); + + it("runs Chopin's tools in the server and the client's own tools only once declared", async () => { + let reports: unknown[] = []; + let { server, channel } = await setup({ + async turn(context) { + let host = await context.callHostTool("read_plan", {}); + let undeclared = context.message.prompt === "undeclared"; + if (!undeclared) context.offerTools(["bash"]); + context.emit({ + type: "tool-call", + toolCallId: "own-1", + toolName: "bash", + input: "{}", + providerExecuted: true, + }); + context.emit({ + type: "tool-result", + toolCallId: "own-1", + toolName: "bash", + result: host.output as string, + }); + finishTurn(context); + }, + }); + let planner: RemotePlanner = { + channelId: channel.id, + isolated: false, + toolReport: (...report) => reports.push(report), + }; + let declared = await streamTurn(adapterFor(server, planner), "declared"); + expect(declared.errors).toEqual([]); + expect(declared.parts.filter(part => part.type === "tool-result").map(part => part.output)) + .toEqual(["the plan", "the plan"]); + expect(planner.activeTools).toEqual(["read_plan", "bash"]); + expect(reports).toEqual([["own-1", "the plan", "done"]]); + let undeclared = await streamTurn(adapterFor(server, planner), "undeclared"); + expect(undeclared.errors.join("\n")).toContain("Planner tool boundary failure: bash"); + let isolated = await streamTurn( + adapterFor(server, { channelId: channel.id, isolated: true }), + "declared", + ); + expect(isolated.errors.join("\n")).toContain("An isolated Planner turn cannot offer bash"); + }); + + it("fails a session the client refuses or never starts, and tells it to let go", async () => { + let destroyed: string[] = []; + let { server, channel } = await setup({ + async start(message) { + if (message.session === "refused") throw new Error("busy elsewhere"); + await Bun.sleep(1_000); + }, + destroy(message) { + destroyed.push(message.session); + }, + }); + let harness = adapterFor(server, { channelId: channel.id, isolated: false }); + let start = (sessionId: string) => + harness.doStart({ sessionId, sandboxSession, sessionWorkDir: "/tmp" }); + await expect(start("refused")).rejects.toThrow("refused the session: busy elsewhere"); + await expect(start("silent")).rejects.toThrow("did not start the session"); + await Bun.sleep(20); + expect(destroyed).toEqual(["refused", "silent"]); + }); + + it("carries Stop and Resume to the client and reports a refusal", async () => { + let controls: unknown[] = []; + let { server, channel } = await setup({ + control(message) { + controls.push({ type: message.type, run: message.run }); + if (message.type === "resume") throw new Error("nothing to resume"); + }, + }); + let planner: RemotePlanner = { channelId: channel.id, isolated: false }; + let session = await adapterFor(server, planner).doStart({ + sessionId: crypto.randomUUID(), + sandboxSession, + sessionWorkDir: "/tmp", + }); + cleanups.push(() => session.doDestroy()); + await planner.control!("stop", "run-1"); + await expect(planner.control!("resume")).rejects.toThrow("nothing to resume"); + expect(controls).toEqual([{ type: "stop", run: "run-1" }, { type: "resume", run: undefined }]); + await session.doDestroy(); + expect(planner.control).toBeUndefined(); + }); + + it("rechecks the caller's access before every session and detaches one that lost it", async () => { + let { server, channel, auth } = await setup({}); + let planner: RemotePlanner = { channelId: channel.id, isolated: false }; + let harness = adapterFor(server, planner); + let first = await harness.doStart({ + sessionId: "first", + sandboxSession, + sessionWorkDir: "/tmp", + }); + await first.doDestroy(); + auth.github.repository = async () => { + throw new GitHubError("Not Found", 404); + }; + await expect(harness.doStart({ sessionId: "second", sandboxSession, sessionWorkDir: "/tmp" })) + .rejects.toThrow("can no longer read this document"); + expect(server.links).toEqual([]); + }); +}); + +describe("Planner link questions", () => { + async function questions(options: { expiresInMs?: number; isolated?: boolean } = {}) { + let f = await hostInputRoom(options.expiresInMs); + cleanups.push(f.close); + let { server, channel, client } = await setup({}); + let planner: RemotePlanner = { channelId: channel.id, isolated: options.isolated ?? false }; + if (!options.isolated) { + planner.humanInput = report => + createHumanInput( + f.room, + options.expiresInMs, + (runId, signal) => untilUnpaused(planner, runId, signal), + report, + ); + } + let sessionId = crypto.randomUUID(); + let session = await adapterFor(server, planner).doStart({ + sessionId, + sandboxSession, + sessionWorkDir: "/tmp", + }); + cleanups.push(() => session.doDestroy()); + return { f, client, sessionId, planner, session }; + } + + let params = { + questions: [{ + header: "Choice", + question: " Which store? ", + options: [ + { label: " Postgres ", description: " durable ", preview: "create table" }, + { label: "Memory", description: "fast" }, + ], + }], + }; + + it("asks the client's question as a Decision, verbatim, and returns the member's answer", async () => { + let { f, client, sessionId } = await questions(); + let asked = client.ask(sessionId, { method: "questionnaire", params }); + let [card] = await f.cards(1); + expect(card!.definition.questions[0]).toMatchObject({ + header: "Choice", + question: " Which store? ", + options: [ + { label: " Postgres ", description: " durable \n\ncreate table" }, + { label: "Memory", description: "fast" }, + ], + }); + await f.answer(card!.id, [0]); + expect(await asked).toMatchObject({ + status: "answered", + value: { + cancelled: false, + answers: [{ + questionIndex: 0, + question: " Which store? ", + kind: "option", + answer: " Postgres ", + preview: "create table", + }], + }, + }); + let confirm = client.ask(sessionId, { method: "confirm", title: "Ship?", message: "Now." }); + let [prompt] = await f.cards(1); + await f.answer(prompt!.id, [0]); + expect(await confirm).toMatchObject({ status: "answered", value: true }); + }); + + it("expires an unanswered question and leaves its card in Decisions marked expired", async () => { + let { f, client, sessionId } = await questions({ expiresInMs: 30 }); + let asked = await client.ask(sessionId, { method: "questionnaire", params }); + expect(asked).toMatchObject({ status: "expired", value: { answers: [], cancelled: true } }); + let records = [...f.plan.records.values()]; + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ status: "expired", resolver: "chopin" }); + }); + + it("withdraws the cards when the client cancels or the session ends", async () => { + let { f, client, sessionId, session } = await questions(); + let controller = new AbortController(); + let asked = client.ask(sessionId, { method: "questionnaire", params }, { + signal: controller.signal, + }); + let [card] = await f.cards(1); + controller.abort(); + expect(await asked).toMatchObject({ status: "cancelled" }); + expect(f.plan.records.get(card!.id)).toMatchObject({ status: "cancelled" }); + let pending = client.ask(sessionId, { method: "select", title: "Pick", choices: ["a", "b"] }); + let [second] = await f.cards(1); + await session.doDestroy(); + expect(await pending).toMatchObject({ status: "cancelled" }); + expect(f.plan.records.get(second!.id)).toMatchObject({ status: "cancelled" }); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + }); + + it("holds a workflow answer until the client reports its root run unpaused", async () => { + let { f, client, sessionId, planner } = await questions(); + client.reportRuns(sessionId, { active: [], paused: ["root"], cards: [] }); + await Bun.sleep(20); + expect(planner.runs?.paused).toEqual(["root"]); + let settled = false; + let asked = client.ask(sessionId, { method: "questionnaire", params }, { + workflowRunId: "stage-run", + rootRunId: "root", + }).then(result => { + settled = true; + return result; + }); + let [card] = await f.cards(1); + await f.answer(card!.id, [1]); + await Bun.sleep(30); + expect(settled).toBe(false); + client.reportRuns(sessionId, { active: ["root"], paused: [], cards: [] }); + expect(await asked).toMatchObject({ + status: "answered", + value: { answers: [{ kind: "option", answer: "Memory" }] }, + }); + }); + + it("gives an isolated session no Decisions", async () => { + let { f, client, sessionId } = await questions({ isolated: true }); + expect(await client.ask(sessionId, { method: "questionnaire", params })).toMatchObject({ + status: "failed", + message: "Decisions are not available to this session.", + }); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + }); +}); diff --git a/apps/server/src/harness/remote/link.ts b/apps/server/src/harness/remote/link.ts new file mode 100644 index 000000000..9d3d5ca48 --- /dev/null +++ b/apps/server/src/harness/remote/link.ts @@ -0,0 +1,764 @@ +/** + * The server end of one Planner link: a client attached to a document whose + * model runs Planner turns. Each Planner session Chopin opens becomes a + * `start`, each turn a `turn`; the client streams parts back and calls + * Chopin's host tools, which still run here against the room. + */ + +import { HarnessCapabilityUnsupportedError } from "@ai-sdk/harness"; +import { + encodeMessage, + LINK_CLOSE, + parseClientMessage, + PLANNER_LINK_VERSION, +} from "@chopin/planner-link"; + +import type { + HarnessV1PromptControl, + HarnessV1PromptTurnOptions, + HarnessV1ResumeSessionState, + HarnessV1Session, + HarnessV1StreamPart, +} from "@ai-sdk/harness"; +import type { + ClientMessage, + ClientMessageOf, + ProtocolErrorCode, + RefusalCode, + ServerMessage, + ServerMessageOf, +} from "@chopin/planner-link"; +import type { HumanInputStatus } from "../atomic/human-input"; +import type { RemotePlanner } from "./registry"; + +const OPERATION_TIMEOUT_MS = 10_000; +const MAX_OPEN_INPUTS = 16; +const LIVE_RUN = new Set(["running", "waiting", "paused"]); + +export type LinkSocket = { + send(text: string): void; + close(code: number, reason: string): void; +}; + +export type AttachOutcome = + | { ok: true; document: ServerMessageOf<"attached">["document"] } + | { ok: false; code: RefusalCode; message: string }; + +export type LinkOptions = { + /** Resolves the document the client asked for, refusing what its caller cannot read. */ + attach(message: ClientMessageOf<"attach">): Promise; + /** Rechecks the caller's access before each session; false closes the link. */ + revalidate?(): Promise; + /** Records the attached link and returns how to forget it. */ + register?(channelId: string, link: PlannerLink): () => void; + timeoutMs?: number; +}; + +const REFUSAL_CLOSE: Record = { + "unsupported-version": LINK_CLOSE.unsupportedVersion, + "repository-forbidden": LINK_CLOSE.forbidden, + "access-revoked": LINK_CLOSE.forbidden, + "document-unavailable": LINK_CLOSE.unavailable, + "document-archived": LINK_CLOSE.unavailable, + unavailable: LINK_CLOSE.failed, +}; + +export class ToolBoundaryError extends Error { + constructor(message: string) { + super(message); + this.name = "ToolBoundaryError"; + } +} + +function unsupported(capability: string): HarnessCapabilityUnsupportedError { + return new HarnessCapabilityUnsupportedError({ + harnessId: "remote", + message: `The remote Planner does not support ${capability}.`, + }); +} + +function within(operation: Promise, ms: number, message: string): Promise { + let timer: ReturnType | undefined; + return Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }), + ]).finally(() => clearTimeout(timer)); +} + +function promptText(prompt: HarnessV1PromptTurnOptions["prompt"]): string { + if (typeof prompt === "string") return prompt; + if (typeof prompt.content === "string") return prompt.content; + return prompt.content.map(part => { + if (part.type !== "text") throw unsupported(`${part.type} prompt parts`); + return part.text; + }).join("\n\n"); +} + +type LinkPart = ClientMessageOf<"part">["part"]; +type LinkUsage = Extract["usage"]; + +function finishReason(reason: Extract["finishReason"]) { + return { unified: reason.unified, raw: reason.raw }; +} + +function usage(value: LinkUsage) { + let { inputTokens, outputTokens } = value; + return { + inputTokens: { + total: inputTokens.total, + noCache: inputTokens.noCache, + cacheRead: inputTokens.cacheRead, + cacheWrite: inputTokens.cacheWrite, + }, + outputTokens: { + total: outputTokens.total, + text: outputTokens.text, + reasoning: outputTokens.reasoning, + }, + }; +} + +type Port = { + send(message: ServerMessage): boolean; + control(session: string, action: "stop" | "resume", runId?: string): Promise; + forget(session: string): void; + timeoutMs: number; +}; + +type Turn = { + id: string; + emit: (part: HarnessV1StreamPart) => void; + hostNames: Set; + own: Set; + ownCalls: Set; + hostCalls: Map; + stopped: boolean; + ended: boolean; + settle: PromiseWithResolvers; + grace?: ReturnType; + listeners: (() => void)[]; +}; + +type TurnMessage = ClientMessageOf<"tools" | "part" | "host-tool-call" | "turn-end">; + +/** One Planner session whose turns run in the attached client. */ +class RemoteSession { + readonly id: string; + readonly planner: RemotePlanner; + #port: Port; + #started = Promise.withResolvers(); + #current: Turn | undefined; + #closed = false; + #roots = new Map(); + + constructor(id: string, planner: RemotePlanner, port: Port) { + this.id = id; + this.planner = planner; + this.#port = port; + void this.#started.promise.catch(() => {}); + } + + async start(): Promise { + let mode = this.planner.isolated ? "isolated" as const : "planner" as const; + if (!this.#port.send({ type: "start", session: this.id, mode })) { + throw new Error("The Planner link is closed."); + } + await within( + this.#started.promise, + this.#port.timeoutMs, + "The attached Planner did not start the session.", + ); + this.planner.control = (action, runId) => this.#port.control(this.id, action, runId); + this.planner.rootOf = runId => this.#roots.get(runId) ?? runId; + return this.#session(); + } + + acknowledge(message: ClientMessageOf<"started" | "start-failed">): void { + if (message.type === "started") this.#started.resolve(); + else {this.#started.reject( + new Error(`The attached Planner refused the session: ${message.message}`), + );} + } + + /** The root run a workflow question belongs to, so a paused root holds its answers. */ + rememberRoot(runId: string, rootRunId: string): void { + this.#roots.set(runId, rootRunId); + } + + reportRuns(message: Pick, "active" | "paused" | "cards">): void { + if (this.#closed || this.planner.isolated) return; + this.planner.runs = { active: message.active, paused: message.paused, cards: message.cards }; + this.planner.onRuns?.(this.planner.runs); + for (let wake of this.planner.waiters ?? []) wake(); + } + + /** The link is gone: nothing more will arrive for this session. */ + linkClosed(): void { + this.#started.reject(new Error("The Planner link closed.")); + let reported = this.planner.runs; + if (reported) { + this.reportRuns({ + active: [], + paused: [], + cards: reported.cards.map(card => + LIVE_RUN.has(card.status) ? { ...card, status: "stopped" as const } : card + ), + }); + } + let turn = this.#current; + if (turn) { + this.#finish( + turn, + turn.stopped ? undefined : new Error("The attached Planner disconnected during the turn."), + ); + } + this.#closed = true; + this.planner.control = undefined; + } + + receive(message: TurnMessage): void { + let turn = this.#current; + if (!turn || turn.id !== message.turn || turn.ended) { + if (message.type === "host-tool-call") { + this.#port.send({ + type: "host-tool-result", + session: this.id, + turn: message.turn, + toolCallId: message.toolCallId, + output: "The turn has ended.", + isError: true, + }); + } + return; + } + if (message.type === "turn-end") { + let failure = message.status === "finished" || turn.stopped + ? undefined + : new Error( + message.message + ?? (message.status === "aborted" + ? "The attached Planner stopped the turn." + : "The attached Planner's turn failed."), + ); + return this.#finish(turn, failure); + } + if (turn.stopped) { + if (message.type === "host-tool-call") this.#refuseHostCall(turn, message, "aborted"); + return; + } + if (message.type === "tools") return this.#offer(turn, message.names); + if (message.type === "host-tool-call") return this.#hostCall(turn, message); + this.#part(turn, message.part); + } + + #offer(turn: Turn, names: string[]): void { + if (this.planner.isolated && names.length) { + return this.#fail( + turn, + new ToolBoundaryError(`An isolated Planner turn cannot offer ${names.join(", ")}.`), + ); + } + let clash = names.filter(name => turn.hostNames.has(name)); + if (clash.length) { + return this.#fail( + turn, + new ToolBoundaryError(`The attached Planner redefined Chopin's ${clash.join(", ")}.`), + ); + } + turn.own = new Set(names); + this.planner.activeTools = [...turn.hostNames, ...names]; + } + + #hostCall(turn: Turn, message: ClientMessageOf<"host-tool-call">): void { + if (!turn.hostNames.has(message.toolName)) { + return this.#refuseHostCall(turn, message, "unavailable"); + } + if (turn.hostCalls.has(message.toolCallId) || turn.ownCalls.has(message.toolCallId)) { + return this.#fail(turn, new Error(`Tool call ${message.toolCallId} was repeated.`)); + } + turn.hostCalls.set(message.toolCallId, message.toolName); + turn.emit({ + type: "tool-call", + toolCallId: message.toolCallId, + toolName: message.toolName, + input: message.input, + }); + } + + #refuseHostCall( + turn: Turn, + message: ClientMessageOf<"host-tool-call">, + reason: "aborted" | "unavailable", + ): void { + this.#port.send({ + type: "host-tool-result", + session: this.id, + turn: turn.id, + toolCallId: message.toolCallId, + output: reason === "aborted" + ? "The turn was aborted." + : `${message.toolName} is not available in this turn.`, + isError: true, + }); + } + + #part(turn: Turn, part: LinkPart): void { + let boundary = (name: string) => + this.#fail(turn, new ToolBoundaryError(`Planner tool boundary failure: ${name}`)); + switch (part.type) { + case "tool-input-start": + if (turn.hostNames.has(part.toolName)) { + let { providerExecuted: _executed, dynamic: _dynamic, ...host } = part; + return turn.emit(host); + } + if (!turn.own.has(part.toolName)) return boundary(part.toolName); + return turn.emit({ ...part, providerExecuted: true, dynamic: true }); + case "tool-call": + if (!turn.own.has(part.toolName) || turn.hostCalls.has(part.toolCallId)) { + return boundary(part.toolName); + } + turn.ownCalls.add(part.toolCallId); + return turn.emit({ ...part, dynamic: true }); + case "tool-result": { + if (!turn.ownCalls.has(part.toolCallId)) return boundary(part.toolName); + let result = (part.result ?? null) as NonNullable; + this.planner.toolReport?.( + part.toolCallId, + result, + part.preliminary ? "running" : part.isError ? "failed" : "done", + ); + return turn.emit({ ...part, result, dynamic: true }); + } + case "error": + return turn.emit({ type: "error", error: new Error(part.error) }); + case "finish-step": + return turn.emit({ + type: "finish-step", + finishReason: finishReason(part.finishReason), + usage: usage(part.usage), + }); + case "finish": + return turn.emit({ + type: "finish", + finishReason: finishReason(part.finishReason), + totalUsage: usage(part.totalUsage), + }); + default: + return turn.emit(part); + } + } + + #stop(turn: Turn): void { + if (turn.stopped || turn.ended) return; + turn.stopped = true; + this.#port.send({ type: "abort", session: this.id, turn: turn.id }); + turn.grace = setTimeout(() => this.#finish(turn), this.#port.timeoutMs); + } + + #fail(turn: Turn, error: Error): void { + if (!turn.stopped) { + turn.stopped = true; + this.#port.send({ type: "abort", session: this.id, turn: turn.id }); + } + this.#finish(turn, error); + } + + #finish(turn: Turn, error?: Error): void { + if (turn.ended) return; + turn.ended = true; + clearTimeout(turn.grace); + for (let release of turn.listeners.splice(0)) release(); + if (this.#current === turn) this.#current = undefined; + if (error) turn.settle.reject(error); + else turn.settle.resolve(); + } + + #listen(turn: Turn, signal: AbortSignal | undefined): void { + if (!signal) return; + if (signal.aborted) return this.#stop(turn); + let abort = () => this.#stop(turn); + signal.addEventListener("abort", abort, { once: true }); + turn.listeners.push(() => signal.removeEventListener("abort", abort)); + } + + #control(turn: Turn): HarnessV1PromptControl { + return { + submitToolResult: async ({ toolCallId, output, isError }) => { + let toolName = turn.hostCalls.get(toolCallId); + if (!toolName || turn.ended) return; + turn.hostCalls.delete(toolCallId); + let result = (output ?? null) as NonNullable; + turn.emit({ type: "tool-result", toolCallId, toolName, result, isError }); + if (turn.stopped) return; + let delivered = this.#port.send({ + type: "host-tool-result", + session: this.id, + turn: turn.id, + toolCallId, + output: result as never, + ...(isError ? { isError } : {}), + }); + if (!delivered) { + this.#port.send({ + type: "host-tool-result", + session: this.id, + turn: turn.id, + toolCallId, + output: `${toolName} returned more than the Planner link can carry.`, + isError: true, + }); + } + }, + async submitToolApproval() { + // The remote Planner asks no approvals; host tools are already allowed. + }, + done: turn.settle.promise, + }; + } + + async #prompt(options: HarnessV1PromptTurnOptions): Promise { + if (this.#closed) throw new Error("The remote Planner session is closed."); + if (this.#current) throw new Error("The remote Planner session already has an active turn."); + if (options.skills.length > 0) throw unsupported("skills"); + let prompt = promptText(options.prompt); + let hostNames = options.tools.map(tool => tool.name); + let turn: Turn = { + id: crypto.randomUUID(), + emit: options.emit, + hostNames: new Set(hostNames), + own: new Set(), + ownCalls: new Set(), + hostCalls: new Map(), + stopped: false, + ended: false, + settle: Promise.withResolvers(), + listeners: [], + }; + let sent = this.#port.send({ + type: "turn", + session: this.id, + turn: turn.id, + prompt, + ...(options.instructions === undefined ? {} : { instructions: options.instructions }), + ...(options.model ? { model: options.model } : {}), + tools: options.tools.map(tool => ({ + name: tool.name, + ...(tool.description === undefined ? {} : { description: tool.description }), + ...(tool.inputSchema === undefined + ? {} + : { inputSchema: tool.inputSchema as Record }), + })), + ...(options.responseFormat + ? { responseFormat: options.responseFormat as ServerMessageOf<"turn">["responseFormat"] } + : {}), + }); + if (!sent) throw new Error("The Planner link could not carry this turn."); + this.planner.activeTools = hostNames; + this.#current = turn; + this.#listen(turn, options.abortSignal); + return this.#control(turn); + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + let turn = this.#current; + if (turn) { + this.#stop(turn); + this.#finish(turn); + } + this.planner.control = undefined; + this.#port.send({ type: "destroy", session: this.id }); + this.#port.forget(this.id); + } + + #session(): HarnessV1Session { + let stopped = async (): Promise => { + await this.close(); + return { + type: "resume-session", + harnessId: "remote", + specificationVersion: "harness-v1", + data: {}, + }; + }; + return { + sessionId: this.id, + isResume: false, + doPromptTurn: options => this.#prompt(options), + doContinueTurn: async options => { + let turn = this.#current; + if (!turn) throw unsupported("continuing a turn that is no longer running"); + turn.emit = options.emit; + this.#listen(turn, options.abortSignal); + return this.#control(turn); + }, + doCompact: async () => { + throw unsupported("compaction"); + }, + doSuspendTurn: async () => { + throw unsupported("suspending a turn"); + }, + doDetach: stopped, + doStop: stopped, + doDestroy: () => this.close(), + }; + } +} + +/** One attached client, from its `attach` until its socket closes. */ +export class PlannerLink { + #socket: LinkSocket; + #options: LinkOptions; + #state: "waiting" | "attaching" | "attached" | "closed" = "waiting"; + #channelId: string | undefined; + #forget: (() => void) | undefined; + #sessions = new Map(); + #inputs = new Map(); + #controls = new Map>(); + #port: Port; + + constructor(socket: LinkSocket, options: LinkOptions) { + this.#socket = socket; + this.#options = options; + this.#port = { + send: message => this.#send(message), + control: (session, action, runId) => this.#control(session, action, runId), + forget: session => { + this.#sessions.delete(session); + this.#cancelInputs(session); + }, + timeoutMs: options.timeoutMs ?? OPERATION_TIMEOUT_MS, + }; + } + + get channelId(): string | undefined { + return this.#channelId; + } + + get attached(): boolean { + return this.#state === "attached"; + } + + /** Opens a Planner session on the client; throws when it cannot. */ + async startSession(sessionId: string, planner: RemotePlanner): Promise { + if (this.#state !== "attached") throw new Error("The Planner link is not attached."); + if (this.#sessions.has(sessionId)) { + throw new Error("The remote Planner session is already open."); + } + if (this.#options.revalidate && !await this.#options.revalidate()) { + this.#refuse("access-revoked", "The attached account can no longer read this document."); + throw new Error("The attached Planner can no longer read this document."); + } + if (this.#state !== "attached") throw new Error("The Planner link closed."); + let session = new RemoteSession(sessionId, planner, this.#port); + this.#sessions.set(sessionId, session); + try { + return await session.start(); + } catch (error) { + this.#sessions.delete(sessionId); + this.#send({ type: "destroy", session: sessionId }); + throw error; + } + } + + receive(raw: unknown): void { + if (this.#state === "closed") return; + let parsed = parseClientMessage(raw); + if (!parsed.ok) return this.#violate(parsed.code, parsed.message); + let message = parsed.message; + if (this.#state !== "attached") { + if (this.#state === "waiting" && message.type === "attach") { + void this.#attach(message); + return; + } + return this.#violate("protocol", "Send attach first, and nothing else until it is answered."); + } + this.#dispatch(message); + } + + /** Ends the link from the server side. */ + close(code: number, reason: string): void { + if (this.#state === "closed") return; + try { + this.#socket.close(code, reason); + } finally { + this.disconnected(); + } + } + + /** The socket closed: every open turn ends, and nothing waits on this client again. */ + disconnected(): void { + if (this.#state === "closed") return; + this.#state = "closed"; + this.#forget?.(); + this.#forget = undefined; + for (let session of this.#sessions.values()) session.linkClosed(); + this.#sessions.clear(); + for (let input of this.#inputs.values()) input.controller.abort(); + this.#inputs.clear(); + for (let control of this.#controls.values()) { + control.reject(new Error("The Planner link closed.")); + } + this.#controls.clear(); + } + + #send(message: ServerMessage): boolean { + if (this.#state === "closed") return false; + let encoded = encodeMessage(message); + if (!encoded) return false; + this.#socket.send(encoded); + return true; + } + + #violate(code: ProtocolErrorCode, message: string): void { + this.#send({ type: "error", code, message }); + this.close(code === "message-too-large" ? LINK_CLOSE.tooLarge : LINK_CLOSE.malformed, code); + } + + #refuse(code: RefusalCode, message: string): void { + this.#send({ type: "refused", code, message }); + this.close(REFUSAL_CLOSE[code], code); + } + + async #attach(message: ClientMessageOf<"attach">): Promise { + this.#state = "attaching"; + if (message.version !== PLANNER_LINK_VERSION) { + return this.#refuse( + "unsupported-version", + `This Chopin speaks Planner link version ${PLANNER_LINK_VERSION}, not ${message.version}.`, + ); + } + let outcome: AttachOutcome; + try { + outcome = await this.#options.attach(message); + } catch (error) { + console.error("[planner-link] attach failed:", error); + outcome = { ok: false, code: "unavailable", message: "The document could not be checked." }; + } + if (this.#state !== "attaching") return; + if (!outcome.ok) return this.#refuse(outcome.code, outcome.message); + this.#channelId = outcome.document.id; + this.#state = "attached"; + this.#send({ type: "attached", version: PLANNER_LINK_VERSION, document: outcome.document }); + this.#forget = this.#options.register?.(outcome.document.id, this); + } + + #dispatch(message: ClientMessage): void { + switch (message.type) { + case "attach": + return this.#violate("protocol", "This link is already attached."); + case "detach": + return this.close(LINK_CLOSE.detached, "detached"); + case "started": + case "start-failed": + return this.#sessions.get(message.session)?.acknowledge(message); + case "tools": + case "part": + case "host-tool-call": + case "turn-end": + return this.#sessions.get(message.session)?.receive(message); + case "runs": + return this.#sessions.get(message.session)?.reportRuns(message); + case "control-result": { + let control = this.#controls.get(message.request); + this.#controls.delete(message.request); + if (message.ok) control?.resolve(); + else control?.reject(new Error(message.message ?? "The attached Planner refused.")); + return; + } + case "input-request": + return void this.#input(message); + case "input-cancel": + return this.#inputs.get(message.request)?.controller.abort(); + } + } + + async #control(session: string, action: "stop" | "resume", runId?: string): Promise { + let request = crypto.randomUUID(); + let pending = Promise.withResolvers(); + this.#controls.set(request, pending); + if (!this.#send({ type: action, request, session, ...(runId ? { run: runId } : {}) })) { + this.#controls.delete(request); + throw new Error("The Planner link is closed."); + } + try { + await within( + pending.promise, + this.#port.timeoutMs, + `The attached Planner did not answer ${action}.`, + ); + } finally { + this.#controls.delete(request); + } + } + + async #input(message: ClientMessageOf<"input-request">): Promise { + let reply = ( + status: ServerMessageOf<"input-result">["status"], + value?: unknown, + text?: string, + ) => + this.#send({ + type: "input-result", + request: message.request, + status, + ...(value === undefined ? {} : { value: value as never }), + ...(text === undefined ? {} : { message: text }), + }); + if (this.#inputs.has(message.request)) { + return this.#violate("protocol", `Input request ${message.request} was repeated.`); + } + let session = this.#sessions.get(message.session); + let humanInput = session?.planner.humanInput; + if (!session || !humanInput) { + return void reply("failed", undefined, "Decisions are not available to this session."); + } + if (this.#inputs.size >= MAX_OPEN_INPUTS) { + return void reply("failed", undefined, "Too many questions are already open."); + } + let controller = new AbortController(); + this.#inputs.set(message.request, { session: message.session, controller }); + if (message.workflowRunId && message.rootRunId) { + session.rememberRoot(message.workflowRunId, message.rootRunId); + } + let status: HumanInputStatus = "cancelled"; + let host = humanInput(outcome => { + status = outcome; + }); + let options = { + signal: controller.signal, + requestId: message.request, + sessionId: message.session, + ...(message.workflowRunId ? { workflowRunId: message.workflowRunId } : {}), + ...(message.workflowStageId ? { workflowStageId: message.workflowStageId } : {}), + }; + let input = message.input; + try { + let value = input.method === "questionnaire" + ? await host.questionnaire(input.params, options) + : input.method === "confirm" + ? await host.confirm(input.title, input.message, options) + : input.method === "select" + ? await host.select(input.title, input.choices, options) + : input.method === "input" + ? await host.input(input.title, input.placeholder, options) + : await host.editor(input.title, input.initial, options); + reply(controller.signal.aborted ? "cancelled" : status, value ?? null); + } catch (error) { + reply("failed", undefined, error instanceof Error ? error.message : String(error)); + } finally { + this.#inputs.delete(message.request); + } + } + + #cancelInputs(session: string): void { + for (let [request, input] of this.#inputs) { + if (input.session !== session) continue; + input.controller.abort(); + this.#inputs.delete(request); + } + } +} diff --git a/apps/server/src/harness/remote/registry.ts b/apps/server/src/harness/remote/registry.ts new file mode 100644 index 000000000..2cff6cc07 --- /dev/null +++ b/apps/server/src/harness/remote/registry.ts @@ -0,0 +1,52 @@ +import type { HostInput } from "@bastani/atomic"; +import type { FullPlanner } from "../atomic/full"; +import type { HumanInputStatus } from "../atomic/human-input"; +import type { PlannerLink } from "./link"; + +/** + * What Chopin keeps for one Planner session whose model runs in an attached + * client. Run state mirrors a full Atomic Planner's, reported by the client. + */ +export type RemotePlanner = + & Pick + & { + channelId: string; + /** A background job's session: the client may offer only Chopin's tools and ask nothing. */ + isolated: boolean; + /** Decisions for one client input request; absent for an isolated session. */ + humanInput?: (report: (status: HumanInputStatus) => void) => HostInput; + /** Stops or resumes the client's workflow runs; set while the session is open. */ + control?: (action: "stop" | "resume", runId?: string) => Promise; + }; + +let planners = new Map(); +let links = new Map(); + +export function registerRemotePlanner(sessionId: string, planner: RemotePlanner): () => void { + if (planners.has(sessionId)) throw new Error("Remote Planner session is already registered"); + planners.set(sessionId, planner); + return () => { + if (planners.get(sessionId) === planner) planners.delete(sessionId); + }; +} + +export function remotePlanner(sessionId: string): RemotePlanner | undefined { + return planners.get(sessionId); +} + +/** + * Records a link attached to a document. Until one Planner per document is + * enforced, the most recent attachment serves new sessions. + */ +export function attachLink(channelId: string, link: PlannerLink): () => void { + links.set(channelId, [...links.get(channelId) ?? [], link]); + return () => { + let remaining = (links.get(channelId) ?? []).filter(candidate => candidate !== link); + if (remaining.length) links.set(channelId, remaining); + else links.delete(channelId); + }; +} + +export function attachedLink(channelId: string): PlannerLink | undefined { + return links.get(channelId)?.at(-1); +} diff --git a/apps/server/src/harness/session.test.ts b/apps/server/src/harness/session.test.ts index 3dccbe3ca..14913ab45 100644 --- a/apps/server/src/harness/session.test.ts +++ b/apps/server/src/harness/session.test.ts @@ -6,7 +6,10 @@ import { openPlannerSession } from "./session"; import { fullPlanner } from "./atomic/full"; import { forgetWorkspaces, rememberCheckout, stateDirectory } from "./atomic/workspace"; import { plannerInstructions } from "../agent/planner"; -import { VISUAL_PLANNER_TOOL_NAMES } from "./tool-names"; +import { BACKGROUND_TOOL_NAMES, VISUAL_PLANNER_TOOL_NAMES } from "./tool-names"; +import { remotePlanner } from "./remote/registry"; +import { hostInputRoom } from "../testing/decisions"; +import { limits } from "@chopin/question"; import type { ActiveOwnerBinding } from "../agent/active-owner"; import type { PlannerSessionDependencies } from "./session"; @@ -86,6 +89,105 @@ describe("openPlannerSession", () => { await opened.value.destroy(); }); + it("registers a remote Planner for its document, with Decisions and run control on the client", async () => { + let { owner, channel, deps } = fixture(); + let room = await hostInputRoom(); + let sessionId = ""; + let instructions = ""; + deps.agent = { + createSession: async (options: { sessionId: string }) => { + sessionId = options.sessionId; + return { destroy: async () => {} }; + }, + stream: async (call: { options: { instructions: string } }) => { + instructions = call.options.instructions; + }, + } as never; + let opened = await openPlannerSession(owner, { + ...channel, + room: room.room, + harness: "remote", + instructions: workspace => plannerInstructions("owner/repo", "BOOTSTRAP", workspace), + }, deps); + if (!opened.ok) throw new Error("Planner unavailable"); + let session = opened.value; + let registered = remotePlanner(sessionId)!; + try { + expect(registered).toMatchObject({ channelId: room.room.id, isolated: false }); + await session.stream("prompt", new AbortController().signal); + expect(instructions).toContain("You run in a member's own session on their machine"); + expect(instructions).toContain("BOOTSTRAP"); + + let delays: unknown[] = []; + let original = globalThis.setTimeout; + globalThis.setTimeout = ((handler: () => void, ms?: number) => { + delays.push(ms); + return original(handler, ms); + }) as typeof setTimeout; + let asked: Promise; + try { + asked = registered.humanInput!(() => {}).confirm("Ship?", "Now.", room.options); + await room.cards(1); + } finally { + globalThis.setTimeout = original; + } + expect(delays).toContain(limits.INPUT_EXPIRY_MS); + room.controller.abort(); + expect(await asked).toBe(false); + + let seen: unknown[] = []; + session.watchRuns!(runs => seen.push(runs)); + let runs = { active: ["run"], paused: [], cards: [] }; + registered.runs = runs; + registered.onRuns!(runs); + expect(seen).toEqual([runs]); + expect(session.runs!()).toBe(runs); + await expect(session.pauseRuns!()).rejects.toThrow("no attached session to control"); + let controls: unknown[] = []; + registered.control = async (action, runId) => void controls.push([action, runId]); + await session.pauseRuns!(); + await session.resumeRun!("run"); + expect(controls).toEqual([["stop", undefined], ["resume", "run"]]); + registered.activeTools = ["read_plan", "bash"]; + expect(session.activeTools).toEqual(["read_plan", "bash"]); + } finally { + await session.destroy(); + await room.close(); + } + expect(remotePlanner(sessionId)).toBeUndefined(); + }); + + it("keeps a remote background job isolated, without Decisions or run control", async () => { + let { owner, channel, deps } = fixture(); + let sessionId = ""; + let instructions = ""; + deps.headingAgent = { + createSession: async (options: { sessionId: string }) => { + sessionId = options.sessionId; + return { destroy: async () => {} }; + }, + stream: async (call: { options: { instructions: string } }) => { + instructions = call.options.instructions; + }, + } as never; + deps.agent = undefined; + let opened = await openPlannerSession(owner, { + ...channel, + room: { id: "channel", plan: { chat: { job: { kind: "heading" } } }, server: {} } as never, + harness: "remote", + instructions: workspace => plannerInstructions("owner/repo", undefined, workspace), + }, deps); + if (!opened.ok) throw new Error("Planner unavailable"); + expect(remotePlanner(sessionId)).toMatchObject({ channelId: "channel", isolated: true }); + expect(remotePlanner(sessionId)!.humanInput).toBeUndefined(); + expect(opened.value.pauseRuns).toBeUndefined(); + expect(opened.value.activeTools).toEqual(BACKGROUND_TOOL_NAMES.heading); + await opened.value.stream("prompt", new AbortController().signal); + expect(instructions).toContain("You have no shell"); + await opened.value.destroy(); + expect(remotePlanner(sessionId)).toBeUndefined(); + }); + it("keeps the credential host-side and destroys the session and sandbox once", async () => { let { owner, channel, deps, destroyed } = fixture(); let opened = await openPlannerSession(owner, channel, deps); diff --git a/apps/server/src/harness/session.ts b/apps/server/src/harness/session.ts index 0e05b0c44..0d6cc9683 100644 --- a/apps/server/src/harness/session.ts +++ b/apps/server/src/harness/session.ts @@ -19,11 +19,13 @@ import { } from "./atomic/full"; import { createHumanInput } from "./atomic/human-input"; import { type PlannerWorkspace, plannerWorkspace } from "./atomic/workspace"; +import { registerRemotePlanner, type RemotePlanner } from "./remote/registry"; import type { HarnessAgent } from "@ai-sdk/harness/agent"; import type { ActiveOwnerBinding } from "../agent/active-owner"; import type { HostedRepository } from "../agent/repository"; import type { DocumentRoom } from "../agent/tools"; +import type { RemoteWorkspace } from "../agent/planner"; export type OpenError = | GitHubToolsError @@ -38,9 +40,12 @@ type PlannerAgent = typeof plannerAgent; export type PlannerChannel = { room: DocumentRoom; repository: HostedRepository; - instructions: string | ((workspace?: PlannerWorkspace) => string); + instructions: string | ((workspace?: PlannerWorkspace | RemoteWorkspace) => string); model?: string; - /** `atomic` runs the session as a full Atomic session in the channel's workspace. */ + /** + * `atomic` runs the session as a full Atomic session in the channel's workspace; + * `remote` runs it in the client attached to the channel. + */ harness?: string; }; @@ -54,7 +59,7 @@ export type PlannerSession = { activeTools?: readonly string[]; stream: (prompt: string, abortSignal: AbortSignal) => ReturnType; destroy: () => Promise; - /** Workflow runs this session owns; only atomic Planner sessions report them. */ + /** Workflow runs this session owns; only atomic and remote Planner sessions report them. */ runs?: () => PlannerRuns | undefined; /** Subscribes to run changes; returns the unsubscribe function. */ watchRuns?: (listener: (runs: PlannerRuns) => void) => () => void; @@ -68,6 +73,28 @@ export type PlannerSession = { resumeRun?: (runId: string) => Promise; }; +/** Run control for a remote Planner: the attached client stops and resumes its own runs. */ +function remoteRunControl( + remote: RemotePlanner, + watchRuns: NonNullable, +): Pick< + PlannerSession, + "runs" | "watchRuns" | "pauseRuns" | "resumeRuns" | "pauseRun" | "resumeRun" +> { + let control = () => { + if (!remote.control) throw new Error("The Planner has no attached session to control."); + return remote.control; + }; + return { + runs: () => remote.runs, + watchRuns, + pauseRuns: async () => control()("stop"), + resumeRuns: async () => control()("resume"), + pauseRun: async runId => control()("stop", runId), + resumeRun: async runId => control()("resume", runId), + }; +} + export type PlannerSessionDependencies = { agent?: PlannerAgent; headingAgent?: PlannerAgent; @@ -120,6 +147,7 @@ export async function openPlannerSession( } let unregister: (() => void) | undefined; let unregisterFull: (() => void) | undefined; + let unregisterRemote: (() => void) | undefined; let session: Awaited> | undefined; let timeout: ReturnType | undefined; try { @@ -151,8 +179,31 @@ export async function openPlannerSession( }; unregisterFull = registerFullPlanner(sessionId, planner); } + let remote: RemotePlanner | undefined; + if (channel.harness === "remote") { + let held: RemotePlanner = { channelId: channel.room.id, isolated: !!job }; + if (!job) { + held.humanInput = report => + createHumanInput( + channel.room, + undefined, + (runId, signal) => untilUnpaused(held, runId, signal), + report, + ); + held.onRuns = runs => { + for (let listener of listeners) listener(runs); + }; + held.toolReport = (id, output, state) => { + let chat = channel.room.plan.chat; + if (state === "running") chat?.toolProgress?.(id, output); + else chat?.toolFinished?.(id, output, state === "done"); + }; + } + remote = held; + unregisterRemote = registerRemotePlanner(sessionId, held); + } let instructions = typeof channel.instructions === "function" - ? channel.instructions(workspace) + ? channel.instructions(workspace ?? (remote && !job ? { remote: true } : undefined)) : channel.instructions; let agent = deps.agent ?? (job?.kind === "heading" ? deps.headingAgent ?? headingPlannerAgent @@ -186,18 +237,21 @@ export async function openPlannerSession( } return planner.workflows; }; + let watchRuns = (listener: (runs: PlannerRuns) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }; let runControl = planner ? { runs: () => planner.runs, - watchRuns: (listener: (runs: PlannerRuns) => void) => { - listeners.add(listener); - return () => listeners.delete(listener); - }, + watchRuns, pauseRuns: () => pauseOwnedRuns(workflows()), resumeRuns: () => resumeOwnedRuns(workflows()), pauseRun: async (runId: string) => applied(await workflows().pause(runId)), resumeRun: async (runId: string) => applied(await workflows().resume(runId)), } + : remote && !job + ? remoteRunControl(remote, watchRuns) : {}; let fixedTools = Object.freeze([ ...(job @@ -210,7 +264,7 @@ export async function openPlannerSession( ok: true, value: { get activeTools() { - return planner?.activeTools ?? fixedTools; + return planner?.activeTools ?? remote?.activeTools ?? fixedTools; }, ...runControl, stream: (prompt, abortSignal) => @@ -235,6 +289,7 @@ export async function openPlannerSession( } finally { release(); unregisterFull?.(); + unregisterRemote?.(); } } })(), @@ -253,6 +308,7 @@ export async function openPlannerSession( } unregister?.(); unregisterFull?.(); + unregisterRemote?.(); let message = cause instanceof Error ? cause.message : String(cause); let kind: "Timeout" | "ShuttingDown" | "HarnessCapabilityUnsupported" | "Unavailable" = message.includes("timed out") diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 1f43970c2..a415e5a1d 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -13,6 +13,11 @@ import { join } from "node:path"; import { ulid } from "@chopin/dialect"; import { harnessFor, shutdownHarnesses } from "./harness/harnesses"; +import { + admitPlannerLink, + isPlannerLinkSocket, + openPlannerLinkSocket, +} from "./harness/remote/endpoint"; import { ActiveOwnerBindings } from "./agent/active-owner"; import { registerAuthRoutes } from "./auth/routes"; import { registerExperimentRoutes } from "./experiments/routes"; @@ -74,7 +79,10 @@ import { StorageError } from "./storage/errors"; import { createStorage } from "./storage/registry"; import { broadcast, fail, relay, reply, repositoryTopic, tell, topic } from "./wire"; +import { PLANNER_LINK_PATH } from "@chopin/planner-link"; + import type { Server, ServerWebSocket } from "bun"; +import type { PlannerLinkSocketData } from "./harness/remote/endpoint"; import type { RepositoryAuthorizer, WatchTopics } from "./socket/decision-watch"; import type { DocumentSummaryInput } from "./jobs/document-summary"; import type { JobDefinition } from "./jobs/registry"; @@ -800,7 +808,7 @@ const VIEWER_ALLOWED = new Set([ "conversation-plan:research-link", ]); -type AnySocket = ServerWebSocket; +type AnySocket = ServerWebSocket; function isSidebarSocket(ws: AnySocket): ws is SidebarSocket { return ws.data.sidebar === true; @@ -1110,8 +1118,8 @@ function closeSidebarSocket(ws: SidebarSocket): void { function upgraded( req: Request, - self: Server, - data: SocketData | SidebarSocketData, + self: Server, + data: SocketData | SidebarSocketData | PlannerLinkSocketData, ): Response | undefined { if ( req.headers.get("x-chopin-socket-probe") === "1" @@ -1122,7 +1130,7 @@ function upgraded( } function listen(): Server { - return Bun.serve({ + return Bun.serve({ hostname: config.host, port: config.port, @@ -1139,6 +1147,11 @@ function listen(): Server { } return upgraded(req, self, outcome.data); } + if (url.pathname === PLANNER_LINK_PATH && config.agent && config.harness === "remote") { + let outcome = await admitPlannerLink(req, hostedAuth); + if (outcome instanceof Response) return outcome; + return upgraded(req, self, outcome); + } if (url.pathname === "/ws/sidebar") { let outcome = await admitSidebar(req, hostedAuth); if ("status" in outcome) { @@ -1154,11 +1167,16 @@ function listen(): Server { websocket: { open(ws: AnySocket) { - if (isSidebarSocket(ws)) scheduleSidebarRecheck(ws); + if (isPlannerLinkSocket(ws)) { + openPlannerLinkSocket(ws, hostedAuth, { + unavailable: channelId => deletingChannels.has(channelId), + }); + } else if (isSidebarSocket(ws)) scheduleSidebarRecheck(ws); else openRoomSocket(ws as Socket); }, message(ws: AnySocket, raw) { + if (isPlannerLinkSocket(ws)) return ws.data.link?.receive(raw); if (typeof raw !== "string") return; let received = isSidebarSocket(ws) ? receiveSidebar(ws, raw) : receive(ws as Socket, raw); void received.catch(err => { @@ -1167,7 +1185,8 @@ function listen(): Server { }, close(ws: AnySocket) { - if (isSidebarSocket(ws)) closeSidebarSocket(ws); + if (isPlannerLinkSocket(ws)) ws.data.link?.disconnected(); + else if (isSidebarSocket(ws)) closeSidebarSocket(ws); else closeRoomSocket(ws as Socket); }, }, diff --git a/apps/server/src/mcp/hosted.ts b/apps/server/src/mcp/hosted.ts index 04f40d241..69a585c0f 100644 --- a/apps/server/src/mcp/hosted.ts +++ b/apps/server/src/mcp/hosted.ts @@ -155,74 +155,91 @@ function claimResult(value: ClaimResult) { : value; } -/** Bind the backend-neutral MCP surface to hosted GitHub authentication. */ -export function hosted( - auth: HostedAuth, - persistence?: ImplementationPersistence, - callbacks: HostedCallbacks = {}, -): McpOptions { - async function directRepository(caller: HostedCaller, owner: string, name: string) { - try { - return await auth.github.repository(caller.oauthToken, owner, name); - } catch (err) { - if (err instanceof GitHubError && (err.status === 403 || err.status === 404)) { - return undefined; - } - throw err; - } - } +type LocatorAuth = Pick; - async function locatedChannel(caller: HostedCaller, locator: string) { - let path: URL | undefined; - try { - path = new URL(locator, auth.config.origin); - } catch { - path = undefined; - } - let local = path?.origin === auth.config.origin ? path.pathname : undefined; - let child = local === undefined ? undefined : parseChildDocumentPath(local); - if (child) { - let repository = await directRepository(caller, child.owner, child.repository); - if (!repository?.permissions.pull) return "forbidden" as const; - let channel = await auth.storage.channels.resolve( - repository.id, - documentSlug(child.childSlug), - ); - let parent = channel?.parentChannelId - ? await auth.storage.channels.resolve(repository.id, documentSlug(child.parentSlug)) - : undefined; - return channel && parent?.id === channel.parentChannelId - ? { channel, repository } - : undefined; - } - let parsed = local === undefined ? undefined : parseDocumentPath(local); - if (parsed?.slug) { - let repository = await directRepository(caller, parsed.owner, parsed.repository); - if (!repository?.permissions.pull) return "forbidden" as const; - let channel = await auth.storage.channels.resolve( - repository.id, - documentSlug(parsed.slug), - ); - return channel ? { channel, repository } : undefined; +async function directRepositoryFor( + auth: LocatorAuth, + caller: HostedCaller, + owner: string, + name: string, +) { + try { + return await auth.github.repository(caller.oauthToken, owner, name); + } catch (err) { + if (err instanceof GitHubError && (err.status === 403 || err.status === 404)) { + return undefined; } + throw err; + } +} - let legacy = path?.origin === auth.config.origin - ? /^\/channels\/([0-9a-f-]{36})\/?$/i.exec(path.pathname) +/** + * The document a locator names, by id, legacy channel URL, or document URL, + * with the caller's own repository access; "forbidden" when it cannot read it. + */ +export async function locateDocument(auth: LocatorAuth, caller: HostedCaller, locator: string) { + let path: URL | undefined; + try { + path = new URL(locator, auth.config.origin); + } catch { + path = undefined; + } + let local = path?.origin === auth.config.origin ? path.pathname : undefined; + let child = local === undefined ? undefined : parseChildDocumentPath(local); + if (child) { + let repository = await directRepositoryFor(auth, caller, child.owner, child.repository); + if (!repository?.permissions.pull) return "forbidden" as const; + let channel = await auth.storage.channels.resolve( + repository.id, + documentSlug(child.childSlug), + ); + let parent = channel?.parentChannelId + ? await auth.storage.channels.resolve(repository.id, documentSlug(child.parentSlug)) + : undefined; + return channel && parent?.id === channel.parentChannelId + ? { channel, repository } : undefined; - let id = legacy?.[1]?.toLowerCase(); - let channel = await auth.storage.channels.get(id && isChannelId(id) ? id : locator); - if (!channel) return undefined; - let repository = await directRepository( - caller, - channel.repositoryOwner, - channel.repositoryName, + } + let parsed = local === undefined ? undefined : parseDocumentPath(local); + if (parsed?.slug) { + let repository = await directRepositoryFor(auth, caller, parsed.owner, parsed.repository); + if (!repository?.permissions.pull) return "forbidden" as const; + let channel = await auth.storage.channels.resolve( + repository.id, + documentSlug(parsed.slug), ); - if (!repository?.permissions.pull || repository.id !== channel.repositoryId) { - return "forbidden" as const; - } - return { channel, repository }; + return channel ? { channel, repository } : undefined; } + let legacy = path?.origin === auth.config.origin + ? /^\/channels\/([0-9a-f-]{36})\/?$/i.exec(path.pathname) + : undefined; + let id = legacy?.[1]?.toLowerCase(); + let channel = await auth.storage.channels.get(id && isChannelId(id) ? id : locator); + if (!channel) return undefined; + let repository = await directRepositoryFor( + auth, + caller, + channel.repositoryOwner, + channel.repositoryName, + ); + if (!repository?.permissions.pull || repository.id !== channel.repositoryId) { + return "forbidden" as const; + } + return { channel, repository }; +} + +/** Bind the backend-neutral MCP surface to hosted GitHub authentication. */ +export function hosted( + auth: HostedAuth, + persistence?: ImplementationPersistence, + callbacks: HostedCallbacks = {}, +): McpOptions { + let directRepository = (caller: HostedCaller, owner: string, name: string) => + directRepositoryFor(auth, caller, owner, name); + let locatedChannel = (caller: HostedCaller, locator: string) => + locateDocument(auth, caller, locator); + async function writableChannel(caller: HostedCaller, locator: string) { let located = await locatedChannel(caller, locator); if (!located || located === "forbidden") return { kind: "unavailable" as const }; diff --git a/apps/server/src/testing/planner-link-node-client.mjs b/apps/server/src/testing/planner-link-node-client.mjs new file mode 100644 index 000000000..c1ca43db0 --- /dev/null +++ b/apps/server/src/testing/planner-link-node-client.mjs @@ -0,0 +1,39 @@ +import { connectPlannerLink } from "../../../../packages/planner-link/src/client.ts"; + +let [url, token, document] = process.argv.slice(2); +let usage = { inputTokens: {}, outputTokens: {} }; + +let client = await connectPlannerLink({ + url, + token, + document, + handlers: { + async turn(context) { + let { prompt, tools } = context.message; + context.emit({ type: "stream-start", modelId: "node-scripted" }); + if (prompt === "abort" || prompt === "hang") { + if (!context.signal.aborted) { + await new Promise(resolve => { + context.signal.addEventListener("abort", resolve, { once: true }); + }); + } + return; + } + if (prompt === "tools") { + for (let tool of tools) await context.callHostTool(tool.name, {}); + } + if (prompt === "output") { + context.emit({ type: "text-start", id: "answer" }); + context.emit({ type: "text-delta", id: "answer", delta: '{"answer":"yes"}' }); + context.emit({ type: "text-end", id: "answer" }); + } + context.emit({ type: "finish-step", finishReason: { unified: "stop" }, usage }); + context.emit({ type: "finish", finishReason: { unified: "stop" }, totalUsage: usage }); + }, + }, +}); +console.log(JSON.stringify({ attached: client.document.id, node: process.version })); +process.stdin.resume(); +process.stdin.on("end", () => void client.detach()); +await client.closed; +process.exit(0); diff --git a/apps/server/src/testing/planner-link.ts b/apps/server/src/testing/planner-link.ts new file mode 100644 index 000000000..33294d6cd --- /dev/null +++ b/apps/server/src/testing/planner-link.ts @@ -0,0 +1,243 @@ +import { connectPlannerLink, linkSocket, plannerLinkUrl } from "@chopin/planner-link/client"; + +import { AdmissionDenied } from "../auth/admission"; +import { GitHubError } from "../github/client"; +import { admitPlannerLink, openPlannerLinkSocket } from "../harness/remote/endpoint"; +import { MemoryStorage } from "../storage/memory/adapter"; + +import type { + PlannerLinkClient, + PlannerLinkHandlers, + TurnContext, +} from "@chopin/planner-link/client"; +import type { Repository } from "../github/client"; +import type { PlannerLinkAuth, PlannerLinkSocketData } from "../harness/remote/endpoint"; +import type { PlannerLink } from "../harness/remote/link"; + +export const READER_TOKEN = "reader-token"; +export const OUTSIDER_TOKEN = "outsider-token"; +export const DENIED_TOKEN = "denied-token"; + +/** + * GitHub as hosted MCP sees it: the reader can pull `octo-org/score`, the + * outsider is a valid account without access, the denied account fails + * admission, and every other token is rejected by GitHub. + */ +export async function linkAuth(channelId?: string) { + let storage = new MemoryStorage(); + let now = new Date(); + await storage.users.put({ id: "U_reader", login: "reader", avatarUrl: "", now }); + let channel = await storage.channels.create({ + id: channelId ?? crypto.randomUUID(), + repositoryId: "R_score", + repositoryOwner: "octo-org", + repositoryName: "score", + title: "Linked document", + createdBy: "U_reader", + now, + }); + let auth = { + config: { origin: "https://chopin.test" }, + storage, + admission: { + async user(token: string) { + if (token === DENIED_TOKEN) throw new AdmissionDenied(); + if (token === READER_TOKEN) return { id: "U_reader", login: "reader", avatarUrl: "" }; + if (token === OUTSIDER_TOKEN) return { id: "U_outsider", login: "outsider", avatarUrl: "" }; + throw new GitHubError("Bad credentials", 401); + }, + }, + github: { + async repository(token: string, owner: string, name: string): Promise { + if (token !== READER_TOKEN) throw new GitHubError("Not Found", 404); + return { + id: "R_score", + owner, + name, + fullName: `${owner}/${name}`, + private: true, + url: `https://github.test/${owner}/${name}`, + defaultBranch: "main", + permissions: { pull: true, push: false, admin: false }, + }; + }, + }, + } as unknown as PlannerLinkAuth; + return { auth, storage, channel }; +} + +/** A Bun server that serves only the Planner link, as the Chopin server does under HARNESS=remote. */ +export function linkServer(auth: PlannerLinkAuth, timeoutMs = 2_000) { + let links: PlannerLink[] = []; + let server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request, self) { + let outcome = await admitPlannerLink(request, auth); + if (outcome instanceof Response) return outcome; + return self.upgrade(request, { data: outcome }) + ? undefined + : new Response("upgrade failed", { status: 400 }); + }, + websocket: { + open(ws) { + openPlannerLinkSocket(ws, auth, { + timeoutMs, + register(_channelId, link) { + links.push(link); + return () => { + links = links.filter(candidate => candidate !== link); + }; + }, + }); + }, + message(ws, raw) { + ws.data.link?.receive(raw); + }, + close(ws) { + ws.data.link?.disconnected(); + }, + }, + }); + return { + url: `http://127.0.0.1:${server.port}`, + get links() { + return links; + }, + stop: () => server.stop(true), + }; +} + +/** Raw frames over a socket that authenticated as the reader, for protocol violations. */ +export async function rawLink(url: string, token = READER_TOKEN) { + let socket = linkSocket(plannerLinkUrl(url), token); + let messages: any[] = []; + let arrivals = new Set<() => void>(); + let closed = Promise.withResolvers<{ code: number; reason: string }>(); + socket.addEventListener("message", event => { + messages.push(JSON.parse(String(event.data))); + for (let wake of arrivals) wake(); + }); + socket.addEventListener("close", event => { + closed.resolve({ code: event.code, reason: event.reason }); + for (let wake of arrivals) wake(); + }); + await new Promise((resolve, reject) => { + socket.addEventListener("open", () => resolve(), { once: true }); + socket.addEventListener("error", () => reject(new Error("raw link failed")), { once: true }); + }); + async function next(type: string): Promise { + for (let deadline = Date.now() + 4_000; Date.now() < deadline;) { + let found = messages.find(message => message.type === type); + if (found) { + messages.splice(messages.indexOf(found), 1); + return found; + } + if (socket.readyState === WebSocket.CLOSED) break; + await new Promise(resolve => { + let wake = () => { + arrivals.delete(wake); + resolve(); + }; + arrivals.add(wake); + setTimeout(wake, 50); + }); + } + throw new Error(`no ${type} message arrived`); + } + return { + socket, + messages, + next, + send: (message: unknown) => + socket.send(typeof message === "string" ? message : JSON.stringify(message)), + closed: closed.promise, + }; +} + +/** Ends a scripted turn the way a model's last step does. */ +export function finishTurn(context: TurnContext): void { + let usage = { inputTokens: {}, outputTokens: {} }; + context.emit({ type: "finish-step", finishReason: { unified: "stop" }, usage }); + context.emit({ type: "finish", finishReason: { unified: "stop" }, totalUsage: usage }); +} + +/** + * The scripted test client: a fake model that answers the prompts the harness + * contract and the link tests send. + */ +export function scriptedPlanner(overrides: Partial = {}) { + let turns: TurnContext["message"][] = []; + let destroyed: string[] = []; + let handlers: PlannerLinkHandlers = { + async turn(context) { + turns.push(context.message); + context.emit({ type: "stream-start", modelId: "scripted" }); + let prompt = context.message.prompt; + if (prompt === "abort" || prompt === "hang") { + if (!context.signal.aborted) { + await new Promise(resolve => { + context.signal.addEventListener("abort", resolve, { once: true }); + }); + } + return; + } + if (prompt === "tools") { + for (let tool of context.message.tools) await context.callHostTool(tool.name, {}); + } + if (prompt === "output") { + context.emit({ type: "text-start", id: "answer" }); + context.emit({ type: "text-delta", id: "answer", delta: '{"answer":"yes"}' }); + context.emit({ type: "text-end", id: "answer" }); + } + finishTurn(context); + }, + destroy(message) { + destroyed.push(message.session); + }, + ...overrides, + }; + return { handlers, turns, destroyed }; +} + +export function attach( + url: string, + document: string, + handlers: PlannerLinkHandlers, + token = READER_TOKEN, +): Promise { + return connectPlannerLink({ url, token, document, handlers }); +} + +/** + * The same scripted client run by Node.js from the wire-only client module, or + * undefined when no Node.js with built-in TypeScript stripping is installed. + */ +export async function nodeClient(url: string, document: string) { + let node = Bun.which("node"); + if (!node) return undefined; + let probe = Bun.spawnSync([node, "-p", "Boolean(process.features.typescript)"]); + if (probe.stdout.toString().trim() !== "true") return undefined; + let child = Bun.spawn([ + node, + new URL("./planner-link-node-client.mjs", import.meta.url).pathname, + url, + READER_TOKEN, + document, + ], { stdin: "pipe", stdout: "pipe", stderr: "inherit" }); + let reader = child.stdout.getReader(); + let { value } = await reader.read(); + reader.releaseLock(); + let line = JSON.parse(new TextDecoder().decode(value).trim()) as { + attached: string; + node: string; + }; + return { + ...line, + async stop() { + child.stdin.end(); + await Promise.race([child.exited, Bun.sleep(2_000)]); + child.kill(); + }, + }; +} diff --git a/bun.lock b/bun.lock index 9f53945e6..85aeace52 100644 --- a/bun.lock +++ b/bun.lock @@ -45,6 +45,7 @@ "@chopin/dialect": "workspace:*", "@chopin/draft": "workspace:*", "@chopin/experiment": "workspace:*", + "@chopin/planner-link": "workspace:*", "@chopin/protocol": "workspace:*", "@chopin/question": "workspace:*", "@earendil-works/pi-coding-agent": "catalog:harness", @@ -232,6 +233,16 @@ "react": "catalog:react", }, }, + "packages/planner-link": { + "name": "@chopin/planner-link", + "version": "0.0.0", + "dependencies": { + "zod": "catalog:harness", + }, + "devDependencies": { + "@types/bun": "catalog:bun", + }, + }, "packages/protocol": { "name": "@chopin/protocol", "version": "0.0.0", @@ -497,6 +508,8 @@ "@chopin/icons": ["@chopin/icons@workspace:packages/icons"], + "@chopin/planner-link": ["@chopin/planner-link@workspace:packages/planner-link"], + "@chopin/protocol": ["@chopin/protocol@workspace:packages/protocol"], "@chopin/question": ["@chopin/question@workspace:packages/question"], diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index 369024138..d8f7b7cdd 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -5,7 +5,8 @@ inspect one selected GitHub repository, co-author the shared document, ask the participants structured questions, and anchor decisions to prose. For documents used as plans, it can also draft an implementation graph. Under `copilot-sdk` and `pi` it does not implement code or change GitHub; under `atomic` it runs as a full -Atomic session, described [below](#full-atomic-planner). +Atomic session, described [below](#full-atomic-planner); under `remote` its model +runs in a member's own client, described in [Remote Planner](#remote-planner). The product role is document co-authoring. The current prompt and tool vocabulary remain optimized for planning and may structure another document type as a plan; @@ -15,9 +16,10 @@ Chopin runs the Planner through `@ai-sdk/harness`: `HARNESS` selects one adapter from a code-owned map, defaulting to `copilot-sdk`, a host-process adapter over `@github/copilot-sdk`. `pi`, over `@ai-sdk/harness-pi`, and `atomic`, which embeds Atomic's headless SDK (`@bastani/atomic`) in the server process, are -further reviewed adapters. Both require an explicit `HARNESS_AUTH`. The -harness owns the agent loop and model. Under `copilot-sdk` and `pi`, Chopin owns -every tool the Planner can call. +further reviewed adapters. Both require an explicit `HARNESS_AUTH`. `remote` +runs no model in the server at all: each turn goes to a client attached over the +Planner link. The harness owns the agent loop and model. Under `copilot-sdk` and +`pi`, Chopin owns every tool the Planner can call. **Choosing `HARNESS=atomic` gives the Planner shell and filesystem access as the server process's user, on hosted instances as well as local ones.** No other flag @@ -31,7 +33,9 @@ The first eligible editor to invoke the Planner or start a model-backed research request supplies the GitHub App user access token for that channel and, under the default `copilot-sdk` harness, the Copilot entitlement. Under `HARNESS=pi` and `HARNESS=atomic` the owner supplies only the GitHub token; model access -comes from the operator's `HARNESS_AUTH` mode. The user must pass instance +comes from the operator's `HARNESS_AUTH` mode. Under `HARNESS=remote` the owner +also supplies only the GitHub token, and the model runs in the client attached +to the document. The user must pass instance admission and have repository push or administration access. Ownership is assigned atomically in storage and guarded by a generation token. @@ -248,6 +252,85 @@ any harness. The instruction is posted as the MCP caller's own message and runs under the document's Planner ownership rules (see [Ownership](#ownership)). Harnesses other than `atomic` ignore its `checkout`. +## Remote Planner + +Under `HARNESS=remote` the Planner's model runs in a client attached to the +document over the Planner link, usually a member's own Atomic session on their +machine. The server keeps the document, Decisions, room writes, and anchors; the +client keeps its code, model credentials, and its own tools. The adapter +(`apps/server/src/harness/remote`) forwards each Planner session as `start` and +each turn as `turn`, and re-emits the parts the client streams back. + +The link is a WebSocket at `/planner-link`, on the application's own origin. +[Planner link](planner-link.md) is its complete wire format, written for +clients that cannot import Chopin's packages. Its messages are declared and +validated by `@chopin/planner-link` (`packages/planner-link`), whose minimal +client in `@chopin/planner-link/client` uses only that wire format at runtime. Every message is a JSON text frame of at most +2 MiB, checked against a strict schema; a binary, malformed, out-of-order, or +oversized message ends the link (close codes 1008 or 1009) after an `error` +message naming the problem. + +| Server to client | Client to server | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `attached` or `refused` (answers `attach`) | `attach` (version, document id or URL, checkout identity) | +| `start` (session, `planner` or `isolated` mode) | `started` or `start-failed` | +| `turn` (prompt, instructions, host tool specs) | `tools` (the client's own tools for this turn) | +| `abort` | `part` (text, reasoning, tool input, own tool call/result, finish-step, finish, error) | +| `host-tool-result` | `host-tool-call` | +| `input-result` (answered, expired, cancelled, failed) | `input-request`, `input-cancel` | +| `stop`, `resume` (Stop and Resume Planner) | `control-result`, `runs` (run cards for Chat) | +| `destroy` | `turn-end`, `detach` | + +**Attaching.** The upgrade request carries `Authorization: Bearer `, +checked exactly as hosted MCP checks it: the token must resolve through instance +admission, a bad or missing token gets HTTP 401, a refused account 403, a foreign +`Origin` 403, and GitHub outages 503, all before anything is upgraded. The first +message must be `attach` with protocol version 1. Its document is resolved like +an MCP document locator, and the bearer's own account must have pull access to +the document's repository. An unsupported version, an unreadable repository, or +a missing or archived document is refused with a `refused` message and close +code 4426, 4403, or 4404. Neither attaching nor a refusal posts anything to the +document. The account and repository access are checked again before every +session the link starts; losing either refuses that session and closes the link. + +**Turns and tools.** Chopin's host tools (document, repository, and job tools) +still run in the server against the room, under the document's Planner owner and +the permission checks below. The client calls them with `host-tool-call` and +receives `host-tool-result`. Its own tools run on its machine: it must declare +them with `tools` before calling any, may not reuse a host tool's name, and +reports them as provider-executed `tool-call` and `tool-result` parts. An +undeclared tool fails the turn as a tool boundary failure. Background job +sessions start in `isolated` mode, where the client may offer no tools of its own +and ask nothing. A turn ends with `turn-end`. An abort waits briefly for the +client's `turn-end`, then ends the turn anyway. A link that closes mid-turn ends +the turn with an error, its open questions are withdrawn, and runs it reported +are shown stopped; nothing waits on a client that has gone. + +**Questions.** An `input-request` carries one Atomic `HostInput` call +(`questionnaire`, `confirm`, `select`, `input`, or `editor`) and becomes Decisions +through the same path as the [full Atomic Planner](#full-atomic-planner): the same +verbatim text, the same 30-minute expiry, cancellation that withdraws open +cards, and answers to a workflow question held while the client reports that +run's root as paused. `input-result` returns what Atomic would have received in +process, with a status that tells an expiry from a cancellation. + +**Runs.** The client reports its workflow runs with `runs`; Chat shows those +cards and keeps the session after its turn while any run is live or paused, as +for an Atomic Planner. **Stop Planner** and **Resume Planner** send `stop` and +`resume`, optionally for one run, and wait for `control-result`. + +**Trust boundary.** The server never holds the client's model credentials and +gains no shell or filesystem access from this harness. The client, in turn, +receives everything a Planner turn sees: the conversation, the document, and +the results of repository tools that run with the document Planner owner's +GitHub App token, limited to the document's repository, which its own account +can already read. Attaching requires only pull access, yet the attached client +decides what the Planner writes to the document during turns writers start, so +attach only clients you would let edit the document. Until one Planner per +document is enforced, the most recently attached link serves new sessions and +earlier ones stay connected. Summary and research workers have no model in the +server, so `HARNESS=remote` requires `BACKGROUND_JOBS=off`. + ## Permission checks Before each Chopin host tool or its repository-bound GitHub MCP tool executes, callbacks recheck: diff --git a/docs/planner-link.md b/docs/planner-link.md new file mode 100644 index 000000000..b89ee4977 --- /dev/null +++ b/docs/planner-link.md @@ -0,0 +1,263 @@ +# Planner link + +The Planner link is the wire contract between a Chopin server running +`HARNESS=remote` and the client that runs its Planner's model, usually a +member's own Atomic session. This page is complete enough to implement a client +from: it states the endpoint, authentication, version handshake, every message, +its limits, and how the link ends. [Hosted agent](hosted-agent.md#remote-planner) +describes what the server does with it, and +[Self-hosting](self-hosting.md#the-remote-planner) how to deploy it. + +`@chopin/planner-link` (`packages/planner-link`) holds the server's schemas. +Its client, `@chopin/planner-link/client`, uses nothing at runtime but this +format, JSON, and the platform `WebSocket`; the remote harness contract suite +runs it in Bun and as a separate Node.js process against a real socket. + +## Endpoint and authentication + +Open a WebSocket to `wss:///planner-link` (`ws:` only for a +loopback development server). It shares the application's origin and port with +`/ws` and `/mcp`, behind the same TLS-terminating proxy. The route exists only +when the server runs with `AGENT` on and `HARNESS=remote`; otherwise it is 404. + +The upgrade request must carry `Authorization: Bearer `, the same +bearer [local agent MCP](local-agent-mcp.md) uses. Bun's and Node.js's built-in +`WebSocket` accept it as a `headers` option (`new WebSocket(url, { headers })`; +checked on Node.js 24); browsers cannot send it. A client should not send an +`Origin` header; if it does, it must be the server's own origin. + +The server checks the bearer before upgrading and answers a refusal over HTTP, +so nothing is opened: + +| Status | Meaning | +| ------ | ----------------------------------------------------------- | +| 401 | Missing or malformed bearer, or GitHub rejected the token | +| 403 | The account fails instance admission, or a foreign `Origin` | +| 503 | GitHub is unavailable or rate limiting; retry later | +| 500 | Any other admission failure | + +## Frames + +Every message is one JSON object in one text frame, with a string `type`. A +frame may hold at most **2 MiB** (2,097,152 bytes) of UTF-8. Objects are strict: +a field this page does not list is refused. Binary frames are refused. + +Shared limits, in characters unless stated: + +| Kind | Fields | Limit | +| ---- | ------------------------------------------------------------------------------------ | ---------- | +| id | `session`, `turn`, `request`, `toolCallId`, part `id`, run and stage `id`, `run` | 1 to 128 | +| name | tool names, `toolName`, `modelId`, question `header` | 1 to 128 | +| note | `message`, `title`, error text, questions, option labels and descriptions, run names | up to 4000 | +| text | `prompt`, `instructions`, `delta`, `input` | the frame | + +`?` marks an optional field. `json` is any JSON value. + +## Handshake + +The client's first message must be `attach`, and it must send nothing else +until the server answers with `attached` or `refused`. + +```text +{ "type": "attach", "version": 1, "document": "", "checkout?": { "origin?": "", "branch?": "", "head?": "" } } +``` + +- `version` is the protocol version the client speaks; this page is version `1`. +- `document` is a document id or its Chopin URL, at most 2048 characters. +- `checkout` describes the client's working tree: `origin` (up to 2048), `branch` + (up to 256), `head` (up to 128). It is informational; the server does not + verify it. + +The server answers: + +```text +{ "type": "attached", "version": 1, "document": { "id": "", "title": "", "repository": "owner/name", "url?": "" } } +{ "type": "refused", "code": "", "message": "" } +``` + +`attach` succeeds when the bearer's GitHub account has pull access to the +document's repository. After `refused` the server closes the link. + +| Refusal code | Close code | Meaning | +| ---------------------- | ---------- | ------------------------------------------------------------- | +| `unsupported-version` | 4426 | The server speaks another `version` | +| `repository-forbidden` | 4403 | The account cannot read the document's repository | +| `document-unavailable` | 4404 | No document matches, or it is being deleted | +| `document-archived` | 4404 | The document is archived | +| `access-revoked` | 4403 | Access was lost after attaching (checked before each session) | +| `unavailable` | 1011 | The server could not check the document | + +## Server to client + +```text +{ "type": "start", "session": "", "mode": "planner" } +{ "type": "turn", "session": "", "turn": "", "prompt": "", "instructions?": "", "model?": "", "tools": [], "responseFormat?": {} } +{ "type": "abort", "session": "", "turn": "" } +{ "type": "host-tool-result", "session": "", "turn": "", "toolCallId": "", "output": json, "isError?": true } +{ "type": "input-result", "request": "", "status": "answered", "value?": json, "message?": "" } +{ "type": "stop", "request": "", "session": "", "run?": "" } +{ "type": "resume", "request": "", "session": "", "run?": "" } +{ "type": "destroy", "session": "" } +{ "type": "error", "code": "malformed-message", "message": "" } +``` + +- `start` opens a Planner session. `mode` is `planner` for a Planner turn + session or `isolated` for a background job's session, which may offer no + tools of its own and gets no Decisions. Answer within **10 seconds**. +- `turn` runs one turn. `tools` lists at most 256 of Chopin's host tools as + `{ "name": "", "description?": "", "inputSchema?": {json schema} }`; + `description` may be 64,000 characters. `instructions` is the Planner's + system prompt for the turn. `model` is set only when the operator configured + `MODEL`. `responseFormat` is `{ "type": "text" }` or + `{ "type": "json", "schema?": {}, "name?": "", "description?": "" }`; for + `json`, the turn's text must be that JSON. +- `abort` stops the turn. Answer with `turn-end`; after **10 seconds** the + server ends the turn anyway. +- `host-tool-result` answers a `host-tool-call`. An error result is a string. + A result too large for one frame arrives as an error. +- `input-result` answers an `input-request` (see [Questions](#questions)). +- `stop` and `resume` are **Stop Planner** and **Resume Planner**: pause or + resume every workflow run of the session, or only `run`. Answer with + `control-result` within **10 seconds**. +- `destroy` ends the session; abandon its turn and questions. +- `error` precedes a close for a protocol violation (see [Closing](#closing)). + +## Client to server + +```text +{ "type": "started", "session": "" } +{ "type": "start-failed", "session": "", "message": "" } +{ "type": "tools", "session": "", "turn": "", "names": [] } +{ "type": "part", "session": "", "turn": "", "part": {} } +{ "type": "host-tool-call", "session": "", "turn": "", "toolCallId": "", "toolName": "", "input": "{}" } +{ "type": "turn-end", "session": "", "turn": "", "status": "finished", "message?": "" } +{ "type": "input-request", "request": "", "session": "", "workflowRunId?": "", "workflowStageId?": "", "rootRunId?": "", "input": {} } +{ "type": "input-cancel", "request": "" } +{ "type": "runs", "session": "", "active": [], "paused": [], "cards": [] } +{ "type": "control-result", "request": "", "ok": true, "message?": "" } +{ "type": "detach" } +``` + +- `started` or `start-failed` answers `start`. +- `tools` declares, before calling any of them, the client's own tools this turn + may call (at most 256). A name may not repeat a host tool's name. An isolated + session must declare none. +- `part` streams the turn (see [Stream parts](#stream-parts)). +- `host-tool-call` runs one of the turn's host tools in the server; `input` is + its arguments as a JSON string. `toolCallId` must be unique in the turn. A + name outside the turn's `tools` is answered with an error result. +- `turn-end` ends the turn: `finished`, `failed` (with `message` as the error + shown), or `aborted`. Every turn needs exactly one. +- `input-request` and `input-cancel` ask and withdraw questions. +- `runs` reports the session's workflow runs whenever they change. Report them + before `turn-end` so the server keeps the session while runs are live. +- `control-result` answers `stop` or `resume`. +- `detach` closes the link with code 1000. + +Messages for a session or turn that has ended are ignored, except a +`host-tool-call`, which gets an error result. + +### Stream parts + +`part.part` is one of these AI SDK `HarnessV1StreamPart`s, without metadata +fields: + +```text +{ "type": "stream-start", "modelId?": "" } +{ "type": "text-start", "id": "" } +{ "type": "text-delta", "id": "", "delta": "" } +{ "type": "text-end", "id": "" } +{ "type": "reasoning-start", "id": "" } +{ "type": "reasoning-delta", "id": "", "delta": "" } +{ "type": "reasoning-end", "id": "" } +{ "type": "tool-input-start", "id": "", "toolName": "", "providerExecuted?": true, "dynamic?": true, "title?": "" } +{ "type": "tool-input-delta", "id": "", "delta": "" } +{ "type": "tool-input-end", "id": "" } +{ "type": "tool-call", "toolCallId": "", "toolName": "", "input": "{}", "providerExecuted": true, "dynamic?": true } +{ "type": "tool-result", "toolCallId": "", "toolName": "", "result": json, "isError?": true, "preliminary?": true, "dynamic?": true } +{ "type": "finish-step", "finishReason": { "unified": "stop", "raw?": "" }, "usage": {} } +{ "type": "finish", "finishReason": { "unified": "stop", "raw?": "" }, "totalUsage": {} } +{ "type": "error", "error": "" } +``` + +- `tool-call` and `tool-result` parts are for the client's own declared tools + only, and `tool-call` must say `"providerExecuted": true`. A `tool-result` + must follow its own `tool-call`. Anything else fails the turn as a tool + boundary failure. Host tools are called with `host-tool-call`; the server + emits their call and result parts itself. +- `tool-input-start` may name a host tool (its input streams before the + `host-tool-call`) or a declared own tool. +- `finishReason.unified` is one of `stop`, `length`, `content-filter`, + `tool-calls`, `error`, `other`. `usage` and `totalUsage` are + `{ "inputTokens": { "total?", "noCache?", "cacheRead?", "cacheWrite?" }, "outputTokens": { "total?", "text?", "reasoning?" } }` + with non-negative integers. +- `error` fails the turn with that text. + +## Questions + +An `input-request` carries one Atomic `HostInput` call in `input`, and becomes +Decisions in the document: + +```text +{ "method": "questionnaire", "params": { "questions": [{ "question": "", "header": "", "options": [{ "label": "", "description": "", "preview?": "" }], "multiSelect?": true }] } } +{ "method": "confirm", "title": "", "message": "" } +{ "method": "select", "title": "", "choices": [] } +{ "method": "input", "title": "", "placeholder?": "" } +{ "method": "editor", "title": "", "initial?": "" } +``` + +A questionnaire has 1 to 16 questions of up to 32 options; `preview` may be +16,000 characters; `select` offers up to 32 choices. The document's own limits +still apply. Text is shown verbatim. + +`input-result.status` is: + +- `answered`: a member answered. `value` is what Atomic's `HostInput` returns: + for `questionnaire`, `{ "answers": [...], "cancelled": false }` with Atomic's + `QuestionAnswer` objects (`questionIndex`, `question`, `kind` of `option`, + `custom`, `chat`, or `multi`, `answer`, and `selected` or `preview` where they + apply); for `confirm`, a boolean; for `select`, `input`, and `editor`, a + string or `null`. +- `expired`: nobody answered within 30 minutes. The cards stay in Decisions + marked expired, and `value` is what Atomic gets for no answer + (`{ "answers": [], "cancelled": true }`, `false`, or `null`). +- `cancelled`: a member cancelled, or the client sent `input-cancel`, or the + session ended. Open cards are withdrawn. +- `failed`: the request could not become Decisions (`message` says why), for + example in an isolated session or with 16 requests already open on the link. + +A workflow-stage question names its `workflowRunId` and, when it differs, the +`rootRunId` of the run that owns it. While the latest `runs` report lists that +root as paused, an answer is held and sent only once the root is no longer +paused, so a paused run stays stopped. + +`runs.cards` are run cards as Chat shows them, at most 64: + +```text +{ "id": "", "name": "", "status": "running", "started": 0, "updated": 0, "ended?": 0, "stages": [], "earlierStages?": 0, "waiting": 0 } +``` + +`status` is one of `running`, `waiting`, `paused`, `finished`, `blocked`, +`failed`, `stopped`; times are whole seconds since the epoch; `waiting` counts +open questions. Up to 64 `stages` are +`{ "id": "", "name": "", "kind?": "tool", "status": "", "started?": 0, "ended?": 0 }` +with `status` one of `pending`, `running`, `awaiting_input`, `paused`, `blocked`, +`completed`, `failed`, `skipped`. `active` and `paused` list at most 64 root run +ids. When the link closes, the server shows the reported live runs as stopped. + +## Closing + +| Close code | Sent after | Meaning | +| ---------- | ---------------------------------------------- | --------------------------------------------- | +| 1000 | `detach` | The client detached | +| 1008 | `error` with `malformed-message` or `protocol` | Unparseable, invalid, or out-of-order message | +| 1009 | `error` with `message-too-large` | A frame over 2 MiB | +| 1011 | `refused` with `unavailable` | The server could not check the document | +| 4403 | `refused` | `repository-forbidden` or `access-revoked` | +| 4404 | `refused` | `document-unavailable` or `document-archived` | +| 4426 | `refused` | `unsupported-version` | + +`protocol` covers a message before `attach` is answered, a second `attach`, and +a repeated `input-request` id. When the link closes for any reason, the server +ends a running turn with an error, withdraws open questions, and forgets the +link; reattaching starts new sessions. A server never replays an interrupted turn. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 954742271..f05a70eb9 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -58,8 +58,9 @@ bearer tokens and browser sessions traverse it. Chopin runs its hosted agent through `@ai-sdk/harness`. `HARNESS` selects one adapter from a code-owned map: the default is `copilot-sdk`, a host-process wrapper over `@github/copilot-sdk`; `pi` is a second reviewed adapter over -`@ai-sdk/harness-pi`; and `atomic` embeds Atomic's headless SDK -(`@bastani/atomic`) in the server process. Adding an adapter to that map is a +`@ai-sdk/harness-pi`; `atomic` embeds Atomic's headless SDK +(`@bastani/atomic`) in the server process; and `remote` runs each Planner turn in +a client attached over the Planner link instead. Adding an adapter to that map is a reviewed trust decision, not a runtime plugin choice: an adapter's `builtinTools` and `supportsBuiltinToolFiltering` are self-declarations, and Chopin's contract suite checks those declarations and the tools a session @@ -241,6 +242,32 @@ resource loading, persistence, and workflow restart boundaries. modes. Other harnesses ignore its `checkout`: they neither verify nor use nor remember it. +### The remote Planner + +`HARNESS=remote` keeps the model out of the server. Each Planner session and turn +goes to a client attached to the document at `/planner-link`, usually a member's +own Atomic session; code, model credentials, and that client's tools stay on the +member's machine, while the document, Decisions, and every room write stay in +this server. The server needs no `MODEL` (one that is set is passed to the +client as the turn's requested model) and accepts no `HARNESS_AUTH`, so it may +bind to a public interface. Startup refuses `HARNESS_AUTH` and requires +`BACKGROUND_JOBS=off`, because the summary and research workers would have no +model. The route exists only with `AGENT` on and `HARNESS=remote`. + +The trust boundary moves to whoever attaches. A client authenticates with a +GitHub token bearer, checked like hosted MCP, and needs pull access to the +document's repository. It then receives what a Planner turn sees, including +repository reads made with the document Planner owner's token, and decides what +the Planner writes during turns writers start. The server never runs the client's +tools and gains no shell or filesystem access from this harness. Until one +Planner per document is enforced, the most recently attached link serves new +sessions. Without an attached client, a Planner turn fails as unavailable. See +[Remote Planner](hosted-agent.md#remote-planner) for the behaviour and +[Planner link](planner-link.md) for the wire format. The link shares the +application's origin and port with `/ws` and `/mcp`, so a TLS-terminating proxy +such as Caddy needs no extra listener; its default `reverse_proxy` already passes +WebSocket upgrades and the `Authorization` header. + ## Prerequisites - Docker for the application image, or Bun 1.4.2 for a source deployment. @@ -293,34 +320,34 @@ Store production values in the deployment's secret manager or an owner-readable environment file outside the source tree. Do not bake `.env` or credentials into the image. -| Variable | Default | Meaning | -| ------------------------------ | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `STORAGE_DRIVER` | `postgres` | Storage adapter. `postgres` is currently the only accepted value. | -| `DATABASE_URL` | required | `postgres:` or `postgresql:` connection URL. It is not printed by Chopin. | -| `APP_ORIGIN` | required | Exact public origin, without credentials, path, query, fragment, or trailing slash. HTTPS is required unless the host is loopback. | -| `GITHUB_APP_SLUG` | required | Lowercase slug from the App's public URL. | -| `GITHUB_APP_CLIENT_ID` | required | OAuth client ID, not the numeric GitHub App ID. | -| `GITHUB_APP_CLIENT_SECRET` | required (hosted) | OAuth client secret used for user-token exchange and refresh. Unused when `AUTH_MODE=local`. | -| `GITHUB_ALLOWED_USERS` | empty | Comma-separated admitted GitHub logins. | -| `GITHUB_ALLOWED_ORGANIZATIONS` | empty | Comma-separated organizations whose active members are admitted. | -| `SESSION_ENCRYPTION_KEY` | required | Exactly 64 hexadecimal characters. Encrypts the hosted OAuth attempt cookie and derives a separate key for session credentials in PostgreSQL. Keep stable across releases and outside the database; replacing it invalidates hosted logins. Local mode requires the configured key but uses separate unpredictable, HttpOnly attempt and browser-binding cookies. | -| `AUTH_MODE` | `hosted` | Set `local` for loopback device-flow sign-in with persisted credentials (see [Authentication](authentication.md#local-device-flow-sign-in)). Any other value fails startup. | -| `CHOPIN_LOCAL_CREDENTIALS_DIR` | platform default | Local mode only. Overrides the plaintext-fallback credential directory (default `~/.config/chopin` on Linux, `~/Library/Application Support/Chopin` on macOS, `%APPDATA%\Chopin` on Windows). Must resolve outside the repository and process working directory. | -| `SERVER_HOST` | `127.0.0.1` | Source-process bind address. The image sets `0.0.0.0`, which local mode refuses. A `HARNESS_AUTH` mode that falls back to a host-logged-in subscription is refused unless this stays loopback-only. | -| `PORT` | `8787` | Source-process HTTP and WebSocket port. The supplied image and health check expect internal port 8787. | -| `MODEL` | `gpt-6-luna` | Model requested for hosted agent sessions. Required under `HARNESS=pi` and `HARNESS=atomic`; under `atomic` it must be `provider/model` from Atomic's catalog. | -| `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk`, `pi`, or `atomic`). An unknown name refuses at startup. `atomic` gives every Planner session shell and filesystem access as the server process's user, hosted instances included; see [Choose and trust a harness](#choose-and-trust-a-harness). | -| `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. For `atomic`, it is required and must be `auto`, which requires a loopback-only `SERVER_HOST`, or `ai-gateway`; every other value is refused. | -| `HARNESS_EXTENSIONS` | unset | Extension or package paths every atomic Planner session loads, as Atomic's `--extension` flag would, separated by the platform path delimiter (`:`, or `;` on Windows). A package's extensions, skills, and workflows all register. Each path must be absolute and exist; any other harness refuses the variable at startup. Background workers never load them. The code runs in the server process as its user. | -| `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | -| `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. | -| `WEB_RESEARCH` | on | Set exactly `off` to disable new public-web research while retaining durable requests, artifacts, and other jobs. | -| `COPILOT_CLI_PATH` | automatic | Advanced override for the Copilot CLI executable. Applies only to the `copilot-sdk` adapter. | -| `CONVERSATION_PLAN` | off | Set exactly `on` to enable experimental conversation-derived cards. Interpretation runs independently of `AGENT`; Planner jobs still obey the agent and job settings. | -| `PLANNER_VISUALS` | off | Set exactly `on` to enable the bounded Jev visual handoff for foreground Planner turns. Requires `AGENT=on` and `JEV_API_KEY`. | -| `JEV_MODEL` | `jev-latest` | Model alias for conversation interpretation. Independent of the hosted Planner's `MODEL`. | -| `JEV_TIMEOUT_MS` | `30000` | Per-request interpretation timeout in milliseconds, an integer between 100 and 60000. | -| `JEV_API_KEY` | required when enabled | Server-side Jev API key. Required at startup when `CONVERSATION_PLAN=on` or `PLANNER_VISUALS=on`; never send it to the browser. | +| Variable | Default | Meaning | +| ------------------------------ | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `STORAGE_DRIVER` | `postgres` | Storage adapter. `postgres` is currently the only accepted value. | +| `DATABASE_URL` | required | `postgres:` or `postgresql:` connection URL. It is not printed by Chopin. | +| `APP_ORIGIN` | required | Exact public origin, without credentials, path, query, fragment, or trailing slash. HTTPS is required unless the host is loopback. | +| `GITHUB_APP_SLUG` | required | Lowercase slug from the App's public URL. | +| `GITHUB_APP_CLIENT_ID` | required | OAuth client ID, not the numeric GitHub App ID. | +| `GITHUB_APP_CLIENT_SECRET` | required (hosted) | OAuth client secret used for user-token exchange and refresh. Unused when `AUTH_MODE=local`. | +| `GITHUB_ALLOWED_USERS` | empty | Comma-separated admitted GitHub logins. | +| `GITHUB_ALLOWED_ORGANIZATIONS` | empty | Comma-separated organizations whose active members are admitted. | +| `SESSION_ENCRYPTION_KEY` | required | Exactly 64 hexadecimal characters. Encrypts the hosted OAuth attempt cookie and derives a separate key for session credentials in PostgreSQL. Keep stable across releases and outside the database; replacing it invalidates hosted logins. Local mode requires the configured key but uses separate unpredictable, HttpOnly attempt and browser-binding cookies. | +| `AUTH_MODE` | `hosted` | Set `local` for loopback device-flow sign-in with persisted credentials (see [Authentication](authentication.md#local-device-flow-sign-in)). Any other value fails startup. | +| `CHOPIN_LOCAL_CREDENTIALS_DIR` | platform default | Local mode only. Overrides the plaintext-fallback credential directory (default `~/.config/chopin` on Linux, `~/Library/Application Support/Chopin` on macOS, `%APPDATA%\Chopin` on Windows). Must resolve outside the repository and process working directory. | +| `SERVER_HOST` | `127.0.0.1` | Source-process bind address. The image sets `0.0.0.0`, which local mode refuses. A `HARNESS_AUTH` mode that falls back to a host-logged-in subscription is refused unless this stays loopback-only. | +| `PORT` | `8787` | Source-process HTTP and WebSocket port. The supplied image and health check expect internal port 8787. | +| `MODEL` | `gpt-6-luna` | Model requested for hosted agent sessions. Required under `HARNESS=pi` and `HARNESS=atomic`; under `atomic` it must be `provider/model` from Atomic's catalog. Optional under `HARNESS=remote`, where the attached client chooses its model and a set value is only passed along as the turn's request. | +| `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk`, `pi`, `atomic`, or `remote`). An unknown name refuses at startup. `remote` runs Planner turns in a client attached at `/planner-link`; see [The remote Planner](#the-remote-planner). `atomic` gives every Planner session shell and filesystem access as the server process's user, hosted instances included; see [Choose and trust a harness](#choose-and-trust-a-harness). | +| `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. For `atomic`, it is required and must be `auto`, which requires a loopback-only `SERVER_HOST`, or `ai-gateway`; every other value is refused. For `remote`, any value is refused. | +| `HARNESS_EXTENSIONS` | unset | Extension or package paths every atomic Planner session loads, as Atomic's `--extension` flag would, separated by the platform path delimiter (`:`, or `;` on Windows). A package's extensions, skills, and workflows all register. Each path must be absolute and exist; any other harness refuses the variable at startup. Background workers never load them. The code runs in the server process as its user. | +| `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | +| `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. `HARNESS=remote` requires `off`. | +| `WEB_RESEARCH` | on | Set exactly `off` to disable new public-web research while retaining durable requests, artifacts, and other jobs. | +| `COPILOT_CLI_PATH` | automatic | Advanced override for the Copilot CLI executable. Applies only to the `copilot-sdk` adapter. | +| `CONVERSATION_PLAN` | off | Set exactly `on` to enable experimental conversation-derived cards. Interpretation runs independently of `AGENT`; Planner jobs still obey the agent and job settings. | +| `PLANNER_VISUALS` | off | Set exactly `on` to enable the bounded Jev visual handoff for foreground Planner turns. Requires `AGENT=on` and `JEV_API_KEY`. | +| `JEV_MODEL` | `jev-latest` | Model alias for conversation interpretation. Independent of the hosted Planner's `MODEL`. | +| `JEV_TIMEOUT_MS` | `30000` | Per-request interpretation timeout in milliseconds, an integer between 100 and 60000. | +| `JEV_API_KEY` | required when enabled | Server-side Jev API key. Required at startup when `CONVERSATION_PLAN=on` or `PLANNER_VISUALS=on`; never send it to the browser. | See [Background jobs and workers](background-jobs.md) for the combined `AGENT`, `BACKGROUND_JOBS`, and `WEB_RESEARCH` behavior and recovery model. @@ -422,8 +449,9 @@ The proxy must: - terminate TLS for the exact `APP_ORIGIN`; - forward the original `Host` and `Origin` headers; -- proxy WebSocket upgrades on `/ws`; -- proxy `/mcp` without removing its `Authorization` header; +- proxy WebSocket upgrades on `/ws`, and on `/planner-link` under `HARNESS=remote`; +- proxy `/mcp` and the `/planner-link` upgrade without removing their + `Authorization` header; - serve Chopin at `/`, not below a path prefix; and - redirect alternate hosts to the canonical origin before application traffic. diff --git a/packages/planner-link/package.json b/packages/planner-link/package.json new file mode 100644 index 000000000..d131609d7 --- /dev/null +++ b/packages/planner-link/package.json @@ -0,0 +1,13 @@ +{ + "name": "@chopin/planner-link", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { "types": "tsgo --noEmit --skipLibCheck" }, + "exports": { + ".": "./src/index.ts", + "./client": "./src/client.ts" + }, + "dependencies": { "zod": "catalog:harness" }, + "devDependencies": { "@types/bun": "catalog:bun" } +} diff --git a/packages/planner-link/src/client.ts b/packages/planner-link/src/client.ts new file mode 100644 index 000000000..519f950f6 --- /dev/null +++ b/packages/planner-link/src/client.ts @@ -0,0 +1,331 @@ +/** + * A minimal Planner link client. At runtime it uses only the documented wire + * format (docs/planner-link.md), JSON and the platform WebSocket, so it runs in + * Bun or Node.js and shows what an external client needs. + */ + +import type { + ClientMessage, + ClientMessageOf, + InputCall, + LinkRun, + RefusalCode, + ServerMessage, + ServerMessageOf, + StreamPart, +} from "./protocol"; + +/** The wire constants a client needs, as docs/planner-link.md states them. */ +export const WIRE = { + path: "/planner-link", + version: 1, + maxMessageBytes: 2 * 1024 * 1024, +} as const; + +export class PlannerLinkRefused extends Error { + readonly code: RefusalCode; + + constructor(code: RefusalCode, message: string) { + super(message); + this.name = "PlannerLinkRefused"; + this.code = code; + } +} + +export type TurnEnd = ClientMessageOf<"turn-end">["status"]; + +/** One turn the server asked the attached Planner to run. */ +export type TurnContext = { + readonly message: ServerMessageOf<"turn">; + /** Aborted when the server aborts the turn, destroys its session, or the link closes. */ + readonly signal: AbortSignal; + emit(part: StreamPart): void; + /** The client's own tools this turn may call, declared before calling any. */ + offerTools(names: string[]): void; + /** Runs one of Chopin's tools in the server, against the document. */ + callHostTool(toolName: string, input: unknown): Promise<{ output: unknown; isError: boolean }>; + end(status: TurnEnd, message?: string): void; +}; + +export type PlannerLinkHandlers = { + /** Throw to refuse the session. */ + start?(message: ServerMessageOf<"start">): void | Promise; + /** Ends the turn as finished when it resolves without calling `end`, failed when it rejects. */ + turn(context: TurnContext): void | Promise; + destroy?(message: ServerMessageOf<"destroy">): void; + /** Stop or resume the session's workflow runs; throw to report that it could not. */ + control?(message: ServerMessageOf<"stop"> | ServerMessageOf<"resume">): void | Promise; +}; + +export type PlannerLinkOptions = { + /** The Chopin origin, or the link's own `ws:`/`wss:` URL. */ + url: string | URL; + /** A GitHub token whose account can read the document's repository. */ + token: string; + /** The document's id or URL. */ + document: string; + checkout?: ClientMessageOf<"attach">["checkout"]; + handlers: PlannerLinkHandlers; + version?: number; +}; + +export type LinkClosed = { code: number; reason: string }; + +export type InputOptions = { + workflowRunId?: string; + workflowStageId?: string; + rootRunId?: string; + signal?: AbortSignal; +}; + +export type PlannerLinkClient = { + readonly document: ServerMessageOf<"attached">["document"]; + /** Asks the document's members through Decisions. */ + ask( + session: string, + input: InputCall, + options?: InputOptions, + ): Promise>; + reportRuns(session: string, runs: { active: string[]; paused: string[]; cards: LinkRun[] }): void; + send(message: ClientMessage): void; + detach(): Promise; + readonly closed: Promise; +}; + +export function plannerLinkUrl(url: string | URL): URL { + let target = new URL(url); + if (target.protocol === "ws:" || target.protocol === "wss:") return target; + if (target.protocol !== "http:" && target.protocol !== "https:") { + throw new Error("The Planner link URL must be http(s) or ws(s)."); + } + target.protocol = target.protocol === "https:" ? "wss:" : "ws:"; + target.pathname = WIRE.path; + target.search = ""; + target.hash = ""; + return target; +} + +/** A server message, trusted for its declared shape once it is a JSON object with a type. */ +function serverMessage(data: unknown): ServerMessage | undefined { + if (typeof data !== "string") return undefined; + try { + let value = JSON.parse(data) as { type?: unknown } | null; + return typeof value === "object" && value !== null && typeof value.type === "string" + ? value as ServerMessage + : undefined; + } catch { + return undefined; + } +} + +/** Bun's and Node's WebSocket accept request headers; the DOM declaration does not say so. */ +type HeaderWebSocket = new(url: URL, init: { headers: Record }) => WebSocket; + +/** A socket to the link URL carrying the bearer; browsers cannot send one. */ +export function linkSocket(url: URL, token: string): WebSocket { + return new (WebSocket as unknown as HeaderWebSocket)(url, { + headers: { authorization: `Bearer ${token}` }, + }); +} + +/** Attaches to one document as its Planner and serves the server's requests. */ +export function connectPlannerLink(options: PlannerLinkOptions): Promise { + let socket = linkSocket(plannerLinkUrl(options.url), options.token); + let turns = new Map(); + let hostCalls = new Map void>(); + let asks = new Map) => void>(); + let closing = Promise.withResolvers(); + let attaching = Promise.withResolvers(); + let attached = false; + + function send(message: ClientMessage): void { + let encoded = JSON.stringify(message); + if (new TextEncoder().encode(encoded).byteLength > WIRE.maxMessageBytes) { + throw new Error("The message exceeds the Planner link's size limit."); + } + if (socket.readyState === WebSocket.OPEN) socket.send(encoded); + } + + function runTurn(message: ServerMessageOf<"turn">): void { + let key = `${message.session}/${message.turn}`; + let state = { controller: new AbortController(), ended: false }; + turns.set(key, state); + let calls = 0; + let context: TurnContext = { + message, + signal: state.controller.signal, + emit: part => { + if (!state.ended) { + send({ type: "part", session: message.session, turn: message.turn, part }); + } + }, + offerTools: names => + send({ + type: "tools", + session: message.session, + turn: message.turn, + names, + }), + callHostTool: (toolName, input) => { + let toolCallId = `host-${++calls}-${crypto.randomUUID()}`; + let result = new Promise<{ output: unknown; isError: boolean }>(resolve => { + hostCalls.set(`${key}/${toolCallId}`, resolve); + }); + send({ + type: "host-tool-call", + session: message.session, + turn: message.turn, + toolCallId, + toolName, + input: JSON.stringify(input ?? {}), + }); + return result; + }, + end: (status, text) => { + if (state.ended) return; + state.ended = true; + turns.delete(key); + send({ + type: "turn-end", + session: message.session, + turn: message.turn, + status, + ...(text === undefined ? {} : { message: text }), + }); + }, + }; + Promise.resolve() + .then(() => options.handlers.turn(context)) + .then( + () => context.end(state.controller.signal.aborted ? "aborted" : "finished"), + error => context.end("failed", error instanceof Error ? error.message : String(error)), + ); + } + + async function serve(message: ServerMessage): Promise { + switch (message.type) { + case "start": + try { + await options.handlers.start?.(message); + send({ type: "started", session: message.session }); + } catch (error) { + send({ + type: "start-failed", + session: message.session, + message: error instanceof Error ? error.message : String(error), + }); + } + return; + case "turn": + return runTurn(message); + case "abort": + turns.get(`${message.session}/${message.turn}`)?.controller.abort(); + return; + case "host-tool-result": { + let key = `${message.session}/${message.turn}/${message.toolCallId}`; + hostCalls.get(key)?.({ output: message.output, isError: message.isError ?? false }); + hostCalls.delete(key); + return; + } + case "input-result": + asks.get(message.request)?.(message); + asks.delete(message.request); + return; + case "stop": + case "resume": + try { + if (!options.handlers.control) throw new Error("This Planner has no workflow runs."); + await options.handlers.control(message); + send({ type: "control-result", request: message.request, ok: true }); + } catch (error) { + send({ + type: "control-result", + request: message.request, + ok: false, + message: error instanceof Error ? error.message : String(error), + }); + } + return; + case "destroy": + for (let [key, turn] of turns) { + if (key.startsWith(`${message.session}/`)) turn.controller.abort(); + } + options.handlers.destroy?.(message); + return; + case "attached": + case "refused": + case "error": + return; + } + } + + let attachedDocument: ServerMessageOf<"attached">["document"] | undefined; + let client: PlannerLinkClient = { + get document() { + return attachedDocument!; + }, + ask(session, input, inputOptions = {}) { + let request = crypto.randomUUID(); + let { signal, ...workflow } = inputOptions; + let answered = new Promise>(resolve => { + asks.set(request, resolve); + }); + let cancel = () => send({ type: "input-cancel", request }); + signal?.addEventListener("abort", cancel, { once: true }); + send({ type: "input-request", request, session, ...workflow, input }); + return answered.finally(() => signal?.removeEventListener("abort", cancel)); + }, + reportRuns(session, runs) { + send({ type: "runs", session, ...runs }); + }, + send, + detach() { + send({ type: "detach" }); + return closing.promise; + }, + closed: closing.promise, + }; + socket.addEventListener("open", () => { + send({ + type: "attach", + version: options.version ?? WIRE.version, + document: options.document, + ...(options.checkout ? { checkout: options.checkout } : {}), + }); + }); + socket.addEventListener("message", event => { + let message = serverMessage(event.data); + if (!message) { + socket.close(1008, "malformed-message"); + return; + } + if (!attached) { + if (message.type === "attached") { + attached = true; + attachedDocument = message.document; + attaching.resolve(client); + } else if (message.type === "refused") { + attaching.reject(new PlannerLinkRefused(message.code, message.message)); + } + return; + } + void serve(message); + }); + socket.addEventListener("close", event => { + for (let turn of turns.values()) turn.controller.abort(); + turns.clear(); + for (let resolve of hostCalls.values()) { + resolve({ output: "The Planner link closed.", isError: true }); + } + hostCalls.clear(); + for (let [request, resolve] of asks) { + resolve({ type: "input-result", request, status: "cancelled", message: "link closed" }); + } + asks.clear(); + attaching.reject( + new Error(`The Planner link closed before attaching (${event.code} ${event.reason}).`), + ); + closing.resolve({ code: event.code, reason: event.reason }); + }); + return attaching.promise; +} diff --git a/packages/planner-link/src/index.ts b/packages/planner-link/src/index.ts new file mode 100644 index 000000000..9810ef416 --- /dev/null +++ b/packages/planner-link/src/index.ts @@ -0,0 +1 @@ +export * from "./protocol"; diff --git a/packages/planner-link/src/protocol.test.ts b/packages/planner-link/src/protocol.test.ts new file mode 100644 index 000000000..67a1ae2ae --- /dev/null +++ b/packages/planner-link/src/protocol.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "bun:test"; + +import { plannerLinkUrl, WIRE } from "./client"; +import { + encodeMessage, + MAX_LINK_MESSAGE_BYTES, + parseClientMessage, + parseServerMessage, + PLANNER_LINK_PATH, + PLANNER_LINK_VERSION, +} from "./protocol"; + +describe("Planner link messages", () => { + it("accepts each client message the protocol declares", () => { + let messages = [ + { type: "attach", version: PLANNER_LINK_VERSION, document: "https://chopin.test/o/r/d" }, + { type: "started", session: "s" }, + { type: "tools", session: "s", turn: "t", names: ["read"] }, + { type: "part", session: "s", turn: "t", part: { type: "text-delta", id: "a", delta: "hi" } }, + { + type: "part", + session: "s", + turn: "t", + part: { + type: "tool-call", + toolCallId: "c", + toolName: "read", + input: "{}", + providerExecuted: true, + }, + }, + { + type: "part", + session: "s", + turn: "t", + part: { + type: "finish", + finishReason: { unified: "stop" }, + totalUsage: { inputTokens: {}, outputTokens: {} }, + }, + }, + { + type: "host-tool-call", + session: "s", + turn: "t", + toolCallId: "c", + toolName: "read_plan", + input: "{}", + }, + { type: "turn-end", session: "s", turn: "t", status: "aborted" }, + { + type: "input-request", + request: "r", + session: "s", + workflowRunId: "run", + input: { + method: "questionnaire", + params: { + questions: [{ + header: "H", + question: "Q?", + options: [{ label: "A", description: "" }], + }], + }, + }, + }, + { type: "input-cancel", request: "r" }, + { type: "runs", session: "s", active: [], paused: ["run"], cards: [] }, + { type: "control-result", request: "r", ok: false, message: "no" }, + { type: "detach" }, + ]; + for (let message of messages) { + expect(parseClientMessage(JSON.stringify(message))).toEqual({ ok: true, message } as never); + } + }); + + it("refuses text that is not JSON, binary frames, unknown types, and undeclared fields", () => { + for ( + let raw of [ + "{", + new Uint8Array([123, 125]), + JSON.stringify({ type: "unknown" }), + JSON.stringify({ type: "detach", extra: true }), + JSON.stringify({ type: "started", session: "" }), + JSON.stringify({ type: "started", session: "x".repeat(129) }), + JSON.stringify({ + type: "part", + session: "s", + turn: "t", + part: { type: "tool-call", toolCallId: "c", toolName: "read", input: "{}" }, + }), + JSON.stringify({ + type: "part", + session: "s", + turn: "t", + part: { type: "raw", rawValue: 1 }, + }), + ] + ) { + expect(parseClientMessage(raw)).toMatchObject({ ok: false, code: "malformed-message" }); + } + }); + + it("refuses a message over the size limit before parsing it", () => { + let raw = JSON.stringify({ type: "detach", padding: "x".repeat(MAX_LINK_MESSAGE_BYTES) }); + expect(parseClientMessage(raw)).toMatchObject({ ok: false, code: "message-too-large" }); + let wide = JSON.stringify({ + type: "part", + session: "s", + turn: "t", + part: { type: "text-delta", id: "a", delta: "é".repeat(MAX_LINK_MESSAGE_BYTES / 2) }, + }); + expect(wide.length).toBeLessThan(MAX_LINK_MESSAGE_BYTES); + expect(parseClientMessage(wide)).toMatchObject({ ok: false, code: "message-too-large" }); + expect(encodeMessage({ type: "destroy", session: "s".repeat(MAX_LINK_MESSAGE_BYTES) })) + .toBeUndefined(); + }); + + it("round-trips server messages and states its version", () => { + let message = { + type: "turn" as const, + session: "s", + turn: "t", + prompt: "plan", + tools: [{ name: "read_plan", inputSchema: { type: "object" } }], + responseFormat: { type: "json" as const, schema: { type: "object" } }, + }; + expect(parseServerMessage(encodeMessage(message))).toEqual({ ok: true, message }); + expect(parseServerMessage(encodeMessage({ + type: "attached", + version: PLANNER_LINK_VERSION, + document: { id: "d", title: "T", repository: "o/r" }, + }))).toMatchObject({ ok: true }); + }); + + it("gives the wire-only client the same constants the server enforces", async () => { + expect(WIRE).toEqual({ + path: PLANNER_LINK_PATH, + version: PLANNER_LINK_VERSION, + maxMessageBytes: MAX_LINK_MESSAGE_BYTES, + }); + let source = await Bun.file(new URL("./client.ts", import.meta.url)).text(); + let imports = [...source.matchAll(/^import\s+(type\s+)?/gm)]; + expect(imports.every(match => match[1])).toBe(true); + }); + + it("derives the link URL from a Chopin origin", () => { + expect(plannerLinkUrl("https://chopin.test/some/doc?x=1").href) + .toBe("wss://chopin.test/planner-link"); + expect(plannerLinkUrl("http://127.0.0.1:8787").href).toBe("ws://127.0.0.1:8787/planner-link"); + expect(plannerLinkUrl("ws://127.0.0.1:1/custom").href).toBe("ws://127.0.0.1:1/custom"); + expect(() => plannerLinkUrl("ftp://chopin.test")).toThrow("http(s) or ws(s)"); + }); +}); diff --git a/packages/planner-link/src/protocol.ts b/packages/planner-link/src/protocol.ts new file mode 100644 index 000000000..a5a282d75 --- /dev/null +++ b/packages/planner-link/src/protocol.ts @@ -0,0 +1,375 @@ +import { z } from "zod"; + +/** The Planner link version this package speaks. A client states it in `attach`. */ +export const PLANNER_LINK_VERSION = 1; + +/** Where a Chopin server accepts Planner links. */ +export const PLANNER_LINK_PATH = "/planner-link"; + +/** The largest message either side may send, in UTF-8 bytes. */ +export const MAX_LINK_MESSAGE_BYTES = 2 * 1024 * 1024; + +/** WebSocket close codes a Chopin server uses to end a link. */ +export const LINK_CLOSE = { + detached: 1000, + malformed: 1008, + tooLarge: 1009, + failed: 1011, + unauthorized: 4401, + forbidden: 4403, + unavailable: 4404, + unsupportedVersion: 4426, +} as const; + +const MAX_ID = 128; +const MAX_NAME = 128; +const MAX_NOTE = 4_000; +const MAX_TOOLS = 256; +const MAX_RUNS = 64; +const MAX_STAGES = 64; +const MAX_QUESTIONS = 16; +const MAX_OPTIONS = 32; + +const id = z.string().min(1).max(MAX_ID); +const name = z.string().min(1).max(MAX_NAME); +const note = z.string().max(MAX_NOTE); +const text = z.string().max(MAX_LINK_MESSAGE_BYTES); +const count = z.number().int().nonnegative(); +const seconds = z.number().int().nonnegative(); +const json = z.json(); + +const finishReason = z.strictObject({ + unified: z.enum(["stop", "length", "content-filter", "tool-calls", "error", "other"]), + raw: z.string().max(MAX_NAME).optional(), +}); + +const usage = z.strictObject({ + inputTokens: z.strictObject({ + total: count.optional(), + noCache: count.optional(), + cacheRead: count.optional(), + cacheWrite: count.optional(), + }), + outputTokens: z.strictObject({ + total: count.optional(), + text: count.optional(), + reasoning: count.optional(), + }), +}); + +/** + * The stream parts a client may send for a turn: `HarnessV1StreamPart`s + * without metadata. A `tool-call` here is always one of the client's own tools; + * Chopin's host tools are called with `host-tool-call` instead. + */ +export const streamPartSchema = z.discriminatedUnion("type", [ + z.strictObject({ type: z.literal("stream-start"), modelId: name.optional() }), + z.strictObject({ type: z.literal("text-start"), id }), + z.strictObject({ type: z.literal("text-delta"), id, delta: text }), + z.strictObject({ type: z.literal("text-end"), id }), + z.strictObject({ type: z.literal("reasoning-start"), id }), + z.strictObject({ type: z.literal("reasoning-delta"), id, delta: text }), + z.strictObject({ type: z.literal("reasoning-end"), id }), + z.strictObject({ + type: z.literal("tool-input-start"), + id, + toolName: name, + providerExecuted: z.boolean().optional(), + dynamic: z.boolean().optional(), + title: note.optional(), + }), + z.strictObject({ type: z.literal("tool-input-delta"), id, delta: text }), + z.strictObject({ type: z.literal("tool-input-end"), id }), + z.strictObject({ + type: z.literal("tool-call"), + toolCallId: id, + toolName: name, + input: text, + providerExecuted: z.literal(true), + dynamic: z.boolean().optional(), + }), + z.strictObject({ + type: z.literal("tool-result"), + toolCallId: id, + toolName: name, + result: json, + isError: z.boolean().optional(), + preliminary: z.boolean().optional(), + dynamic: z.boolean().optional(), + }), + z.strictObject({ type: z.literal("finish-step"), finishReason, usage }), + z.strictObject({ type: z.literal("finish"), finishReason, totalUsage: usage }), + z.strictObject({ type: z.literal("error"), error: note }), +]); + +const option = z.strictObject({ + label: z.string().max(MAX_NOTE), + description: z.string().max(MAX_NOTE), + preview: z.string().max(MAX_NOTE * 4).optional(), +}); + +/** Atomic's `ask_user_question` parameters, as a client's `HostInput` receives them. */ +export const questionParamsSchema = z.strictObject({ + questions: z.array(z.strictObject({ + question: z.string().max(MAX_NOTE), + header: z.string().max(MAX_NAME), + options: z.array(option).max(MAX_OPTIONS), + multiSelect: z.boolean().optional(), + })).min(1).max(MAX_QUESTIONS), +}); + +/** One `HostInput` method call and its arguments. */ +export const inputSchema = z.discriminatedUnion("method", [ + z.strictObject({ method: z.literal("questionnaire"), params: questionParamsSchema }), + z.strictObject({ method: z.literal("confirm"), title: note, message: note }), + z.strictObject({ + method: z.literal("select"), + title: note, + choices: z.array(z.string().max(MAX_NOTE)).max(MAX_OPTIONS), + }), + z.strictObject({ method: z.literal("input"), title: note, placeholder: note.optional() }), + z.strictObject({ method: z.literal("editor"), title: note, initial: note.optional() }), +]); + +const runStage = z.strictObject({ + id, + name: z.string().max(MAX_NOTE), + kind: z.literal("tool").optional(), + status: z.enum([ + "pending", + "running", + "awaiting_input", + "paused", + "blocked", + "completed", + "failed", + "skipped", + ]), + started: seconds.optional(), + ended: seconds.optional(), +}); + +/** A workflow run card, as Chat shows it. */ +export const runSchema = z.strictObject({ + id, + name: z.string().max(MAX_NOTE), + status: z.enum(["running", "waiting", "paused", "finished", "blocked", "failed", "stopped"]), + started: seconds, + updated: seconds, + ended: seconds.optional(), + stages: z.array(runStage).max(MAX_STAGES), + earlierStages: count.optional(), + waiting: count, +}); + +const checkout = z.strictObject({ + origin: z.string().max(2_048).optional(), + branch: z.string().max(256).optional(), + head: z.string().max(MAX_ID).optional(), +}); + +/** Every message a client sends. */ +export const clientMessageSchema = z.discriminatedUnion("type", [ + z.strictObject({ + type: z.literal("attach"), + version: z.number().int(), + document: z.string().min(1).max(2_048), + checkout: checkout.optional(), + }), + z.strictObject({ type: z.literal("started"), session: id }), + z.strictObject({ type: z.literal("start-failed"), session: id, message: note }), + z.strictObject({ + type: z.literal("tools"), + session: id, + turn: id, + names: z.array(name).max(MAX_TOOLS), + }), + z.strictObject({ type: z.literal("part"), session: id, turn: id, part: streamPartSchema }), + z.strictObject({ + type: z.literal("host-tool-call"), + session: id, + turn: id, + toolCallId: id, + toolName: name, + input: text, + }), + z.strictObject({ + type: z.literal("turn-end"), + session: id, + turn: id, + status: z.enum(["finished", "failed", "aborted"]), + message: note.optional(), + }), + z.strictObject({ + type: z.literal("input-request"), + request: id, + session: id, + workflowRunId: id.optional(), + workflowStageId: id.optional(), + rootRunId: id.optional(), + input: inputSchema, + }), + z.strictObject({ type: z.literal("input-cancel"), request: id }), + z.strictObject({ + type: z.literal("runs"), + session: id, + active: z.array(id).max(MAX_RUNS), + paused: z.array(id).max(MAX_RUNS), + cards: z.array(runSchema).max(MAX_RUNS), + }), + z.strictObject({ + type: z.literal("control-result"), + request: id, + ok: z.boolean(), + message: note.optional(), + }), + z.strictObject({ type: z.literal("detach") }), +]); + +const toolSpec = z.strictObject({ + name, + description: z.string().max(64_000).optional(), + inputSchema: z.record(z.string(), json).optional(), +}); + +const responseFormat = z.discriminatedUnion("type", [ + z.strictObject({ type: z.literal("text") }), + z.strictObject({ + type: z.literal("json"), + schema: z.record(z.string(), json).optional(), + name: name.optional(), + description: note.optional(), + }), +]); + +/** Why a server refused an attach. */ +export const refusalCodeSchema = z.enum([ + "unsupported-version", + "repository-forbidden", + "document-unavailable", + "document-archived", + "access-revoked", + "unavailable", +]); + +/** Every message a server sends. */ +export const serverMessageSchema = z.discriminatedUnion("type", [ + z.strictObject({ + type: z.literal("attached"), + version: z.number().int(), + document: z.strictObject({ + id, + title: z.string().max(MAX_NOTE), + repository: z.string().max(2 * MAX_NAME + 1), + url: z.string().max(2_048).optional(), + }), + }), + z.strictObject({ type: z.literal("refused"), code: refusalCodeSchema, message: note }), + z.strictObject({ + type: z.literal("error"), + code: z.enum(["malformed-message", "message-too-large", "protocol"]), + message: note, + }), + z.strictObject({ + type: z.literal("start"), + session: id, + mode: z.enum(["planner", "isolated"]), + }), + z.strictObject({ + type: z.literal("turn"), + session: id, + turn: id, + prompt: text, + instructions: text.optional(), + model: z.string().max(MAX_NOTE).optional(), + tools: z.array(toolSpec).max(MAX_TOOLS), + responseFormat: responseFormat.optional(), + }), + z.strictObject({ type: z.literal("abort"), session: id, turn: id }), + z.strictObject({ + type: z.literal("host-tool-result"), + session: id, + turn: id, + toolCallId: id, + output: json, + isError: z.boolean().optional(), + }), + z.strictObject({ + type: z.literal("input-result"), + request: id, + status: z.enum(["answered", "expired", "cancelled", "failed"]), + value: json.optional(), + message: note.optional(), + }), + z.strictObject({ type: z.literal("stop"), request: id, session: id, run: id.optional() }), + z.strictObject({ type: z.literal("resume"), request: id, session: id, run: id.optional() }), + z.strictObject({ type: z.literal("destroy"), session: id }), +]); + +export type ClientMessage = z.infer; +export type ServerMessage = z.infer; +export type StreamPart = z.infer; +export type InputCall = z.infer; +export type InputMethod = InputCall["method"]; +export type LinkRun = z.infer; +export type RefusalCode = z.infer; +export type ProtocolErrorCode = Extract["code"]; +export type ClientMessageOf = Extract; +export type ServerMessageOf = Extract; + +export type Parsed = + | { ok: true; message: T } + | { ok: false; code: Exclude; message: string }; + +/** Whether a string's UTF-8 encoding exceeds the limit, encoding only when it might. */ +function oversized(value: string): boolean { + return value.length * 3 > MAX_LINK_MESSAGE_BYTES + && new TextEncoder().encode(value).byteLength > MAX_LINK_MESSAGE_BYTES; +} + +function parse(schema: z.ZodType, raw: unknown): Parsed { + if (typeof raw !== "string") { + return { ok: false, code: "malformed-message", message: "Messages are JSON text frames." }; + } + if (oversized(raw)) { + return { + ok: false, + code: "message-too-large", + message: `Messages are limited to ${MAX_LINK_MESSAGE_BYTES} bytes.`, + }; + } + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return { ok: false, code: "malformed-message", message: "The message is not JSON." }; + } + let result: ReturnType; + try { + result = schema.safeParse(value); + } catch { + return { ok: false, code: "malformed-message", message: "The message is nested too deeply." }; + } + if (result.success) return { ok: true, message: result.data }; + let issue = result.error.issues[0]; + let path = issue?.path.join(".") || "message"; + return { + ok: false, + code: "malformed-message", + message: `Invalid ${path}: ${issue?.message ?? "unknown"}`.slice(0, MAX_NOTE), + }; +} + +export function parseClientMessage(raw: unknown): Parsed { + return parse(clientMessageSchema, raw); +} + +export function parseServerMessage(raw: unknown): Parsed { + return parse(serverMessageSchema, raw); +} + +/** The message as one text frame, or undefined when it would exceed the limit. */ +export function encodeMessage(message: ClientMessage | ServerMessage): string | undefined { + let encoded = JSON.stringify(message); + return oversized(encoded) ? undefined : encoded; +} diff --git a/packages/planner-link/tsconfig.json b/packages/planner-link/tsconfig.json new file mode 100644 index 000000000..93201ad0f --- /dev/null +++ b/packages/planner-link/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"], + "compilerOptions": { "types": ["bun"], "lib": ["esnext"] } +} From 55d844b958500ec77fae9dc4142465c7e4748672 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 10 Oct 2026 18:51:34 -0700 Subject: [PATCH 02/10] Close the Planner link review gaps in access, host calls and answers A remote Planner's run reports no longer start follow-up turns: the runs are only the client's word, so a pull-only client could otherwise start a turn no writer asked for. Its blocked or failed runs still get their Chat notice. The link rechecks admission and pull access before every turn, including turns of a session kept for its runs, and every 5 minutes while attached; a lost check closes the link with access-revoked. A host call id may be used once per turn even after its call has finished, and a host call for an ended session gets the documented error result. Every host-tool error result is now { "error": "..." }, as HarnessAgent's own are. The package client settles a waiting host call as failed on abort, destroy, turn end or close, asks nothing when its signal is already aborted, settles requests made after the link closed instead of leaving them pending, and cuts generated message and error text to the 4000 character wire limit. docs/planner-link.md states the input-result value for every status and method, the QuestionAnswer shapes, partial answers on cancellation, and error output shapes; docs/architecture.md describes the remote Planner and its link. Assistant-model: Claude Opus 5.5 (fast) Assistant-workflow: hosted-planner-slices Assistant-duration: 8m converged, estimated 45m Assistant-verification: bun run types passed: every workspace package and e2e Assistant-verification: bun test passed: 4630 pass, 4 skip, 0 fail (GIT_CONFIG_GLOBAL=/dev/null bun test) Assistant-verification: bun run ci passed: dprint, oxlint (31 warnings, same as main), tokens, design contract, Impeccable Assistant-verification: client regression tests passed: the 4 new client tests fail against the previous client.ts and pass now Co-authored-by: Alex Lavaee --- apps/server/src/chat/invoke.test.ts | 14 ++ apps/server/src/chat/service.ts | 3 +- apps/server/src/harness/remote/endpoint.ts | 2 + apps/server/src/harness/remote/link.test.ts | 131 ++++++++++++++- apps/server/src/harness/remote/link.ts | 87 ++++++++-- apps/server/src/harness/session.ts | 9 +- apps/server/src/testing/planner-link.ts | 3 +- docs/architecture.md | 56 +++++-- docs/hosted-agent.md | 9 +- docs/planner-link.md | 111 +++++++++---- docs/self-hosting.md | 8 +- packages/planner-link/src/client.test.ts | 175 ++++++++++++++++++++ packages/planner-link/src/client.ts | 157 +++++++++++++----- packages/planner-link/src/protocol.test.ts | 2 + packages/planner-link/src/protocol.ts | 5 +- 15 files changed, 651 insertions(+), 121 deletions(-) create mode 100644 packages/planner-link/src/client.test.ts diff --git a/apps/server/src/chat/invoke.test.ts b/apps/server/src/chat/invoke.test.ts index 3d3df0470..d84c2d8b7 100644 --- a/apps/server/src/chat/invoke.test.ts +++ b/apps/server/src/chat/invoke.test.ts @@ -953,6 +953,20 @@ test("a run that ends blocked during the Planner's own turn starts no follow-up expect(spoken(context, "plan-review ended blocked after 10 min.")).toBe(1); }); +test("a remote Planner's run that ends blocked between turns starts no turn nobody asked for", async () => { + let { context, user } = await setup(configured(ATOMIC)); + let planner = runningSession(); + let remote = Object.assign(planner.session, { followsUpRuns: false }); + context.openPlannerSession = async () => ({ ok: true, value: remote }); + await Chat.invoke(context, user, "Run the workflow"); + await context.chat.running; + expect(context.chat.retained).toBeDefined(); + planner.set({ active: [], paused: [], cards: [card("blocked")] }); + while (!planner.calls.includes("destroy")) await tick(); + expect(followUps(planner.calls)).toHaveLength(0); + expect(spoken(context, "plan-review ended blocked after 10 min.")).toBe(1); +}); + test("the last job finishing while a reply is still being saved still lets the Planner go afterwards", async () => { let { context, user } = await setup(configured(ATOMIC)); let planner = runningSession(); diff --git a/apps/server/src/chat/service.ts b/apps/server/src/chat/service.ts index fd446ade0..f35714372 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -1389,7 +1389,8 @@ function retain( let stopWatching = opened.session.watchRuns?.(next => { if (chat.retained?.session !== opened.session) return; let planning = chat.busy && !chat.job && chat.agent === opened.session; - void publishRuns(context, opened.session, next, false, !planning); + let followUp = !planning && opened.session.followsUpRuns !== false; + void publishRuns(context, opened.session, next, false, followUp); if (next.active.length || next.paused.length) return; let releaseWhenIdle = async () => { await publishing.get(chat); diff --git a/apps/server/src/harness/remote/endpoint.ts b/apps/server/src/harness/remote/endpoint.ts index a6978615c..7662b19b6 100644 --- a/apps/server/src/harness/remote/endpoint.ts +++ b/apps/server/src/harness/remote/endpoint.ts @@ -32,6 +32,7 @@ export type PlannerLinkHooks = { unavailable?: (channelId: string) => boolean; register?: LinkOptions["register"]; timeoutMs?: number; + revalidateEveryMs?: number; }; const BEARER = /^Bearer ([A-Za-z0-9._~+/-]+=*)$/i; @@ -134,6 +135,7 @@ export function plannerLink( revalidate, register: hooks.register ?? attachLink, timeoutMs: hooks.timeoutMs, + revalidateEveryMs: hooks.revalidateEveryMs, }); } diff --git a/apps/server/src/harness/remote/link.test.ts b/apps/server/src/harness/remote/link.test.ts index df31caf41..6e19ac1b5 100644 --- a/apps/server/src/harness/remote/link.test.ts +++ b/apps/server/src/harness/remote/link.test.ts @@ -56,20 +56,33 @@ const sandboxSession = { async destroy() {}, } as never; +let readPlanRuns = 0; + function agentFor(harness: ReturnType) { return new HarnessAgent({ harness, - tools: { read_plan: tool({ inputSchema: z.object({}), execute: async () => "the plan" }) }, + tools: { + read_plan: tool({ + inputSchema: z.object({}), + execute: async () => { + readPlanRuns++; + return "the plan"; + }, + }), + }, activeTools: ["read_plan"], permissionMode: "allow-reads", }); } -/** Streams one turn to its end, returning its parts and any error it ended with. */ -async function streamTurn(harness: ReturnType, prompt: string) { - let agent = agentFor(harness); - let session = await agent.createSession({ sandboxSession }); - cleanups.push(() => session.destroy()); +type Agent = ReturnType; + +/** Streams one turn of an open session to its end, returning its parts and any error it ended with. */ +async function streamOn( + agent: Agent, + session: Awaited>, + prompt: string, +) { let parts: { type: string; [key: string]: unknown }[] = []; let failure: unknown; try { @@ -83,6 +96,26 @@ async function streamTurn(harness: ReturnType, promp return { parts, errors, session }; } +/** Streams one turn of a new session to its end. */ +async function streamTurn(harness: ReturnType, prompt: string) { + let agent = agentFor(harness); + let session = await agent.createSession({ sandboxSession }); + cleanups.push(() => session.destroy()); + return streamOn(agent, session, prompt); +} + +/** Attaches a raw link and answers the next `start`, returning the session it names. */ +async function rawAttached(server: ReturnType, document: string) { + let raw = await rawLink(server.url); + raw.send({ type: "attach", version: 1, document }); + await raw.next("attached"); + let started = raw.next("start").then(start => { + raw.send({ type: "started", session: start.session }); + return start.session as string; + }); + return { raw, started }; +} + function upgradeRequest(url: string, headers: Record = {}) { return fetch(`${url}/planner-link`, { headers: { @@ -340,6 +373,92 @@ describe("Planner link protocol", () => { .rejects.toThrow("can no longer read this document"); expect(server.links).toEqual([]); }); + + it("rechecks the caller's access before every turn of a kept session", async () => { + let { server, channel, auth } = await setup({}); + let agent = agentFor(adapterFor(server, { channelId: channel.id, isolated: false })); + let session = await agent.createSession({ sandboxSession }); + cleanups.push(() => session.destroy()); + expect((await streamOn(agent, session, "first")).errors).toEqual([]); + auth.github.repository = async () => { + throw new GitHubError("Not Found", 404); + }; + let revoked = await streamOn(agent, session, "second"); + expect(revoked.errors.join("\n")).toContain("can no longer read this document"); + expect(server.links).toEqual([]); + }); + + it("detaches a link whose access is lost while it stays attached", async () => { + let { auth, channel } = await linkAuth(); + let server = linkServer(auth, 300, 20); + cleanups.push(server.stop); + let client = await attach(server.url, channel.id, scriptedPlanner().handlers); + await Bun.sleep(60); + expect(server.links).toHaveLength(1); + auth.github.repository = async () => { + throw new GitHubError("Not Found", 404); + }; + expect(await client.closed).toEqual({ code: LINK_CLOSE.forbidden, reason: "access-revoked" }); + expect(server.links).toEqual([]); + }); + + it("refuses a host call id the turn already used, even after that call finished", async () => { + let { server, channel } = await setup(); + let { raw, started } = await rawAttached(server, channel.id); + let driving = (async () => { + let session = await started; + let turn = (await raw.next("turn")).turn; + let call = { + type: "host-tool-call", + session, + turn, + toolCallId: "same", + toolName: "read_plan", + input: "{}", + }; + raw.send(call); + expect(await raw.next("host-tool-result")).toMatchObject({ output: "the plan" }); + raw.send(call); + await raw.next("abort"); + })(); + readPlanRuns = 0; + let outcome = await streamTurn( + adapterFor(server, { channelId: channel.id, isolated: false }), + "repeat", + ); + await driving; + expect(outcome.errors.join("\n")).toContain("Tool call same was repeated"); + expect(readPlanRuns).toBe(1); + }); + + it("answers a host call for a session that has ended with an error result", async () => { + let { server, channel } = await setup(); + let { raw, started } = await rawAttached(server, channel.id); + let opened = await adapterFor(server, { channelId: channel.id, isolated: false }).doStart({ + sessionId: crypto.randomUUID(), + sandboxSession, + sessionWorkDir: "/tmp", + }); + let session = await started; + await opened.doDestroy(); + await raw.next("destroy"); + raw.send({ + type: "host-tool-call", + session, + turn: "gone", + toolCallId: "late", + toolName: "read_plan", + input: "{}", + }); + expect(await raw.next("host-tool-result")).toEqual({ + type: "host-tool-result", + session, + turn: "gone", + toolCallId: "late", + output: { error: "The session has ended." }, + isError: true, + }); + }); }); describe("Planner link questions", () => { diff --git a/apps/server/src/harness/remote/link.ts b/apps/server/src/harness/remote/link.ts index 9d3d5ca48..91ce9fb4e 100644 --- a/apps/server/src/harness/remote/link.ts +++ b/apps/server/src/harness/remote/link.ts @@ -32,6 +32,7 @@ import type { HumanInputStatus } from "../atomic/human-input"; import type { RemotePlanner } from "./registry"; const OPERATION_TIMEOUT_MS = 10_000; +const REVALIDATE_EVERY_MS = 5 * 60_000; const MAX_OPEN_INPUTS = 16; const LIVE_RUN = new Set(["running", "waiting", "paused"]); @@ -47,11 +48,13 @@ export type AttachOutcome = export type LinkOptions = { /** Resolves the document the client asked for, refusing what its caller cannot read. */ attach(message: ClientMessageOf<"attach">): Promise; - /** Rechecks the caller's access before each session; false closes the link. */ + /** Rechecks the caller's access; false closes the link. */ revalidate?(): Promise; /** Records the attached link and returns how to forget it. */ register?(channelId: string, link: PlannerLink): () => void; timeoutMs?: number; + /** How often an attached link's access is rechecked while it stays open. */ + revalidateEveryMs?: number; }; const REFUSAL_CLOSE: Record = { @@ -96,6 +99,10 @@ function promptText(prompt: HarnessV1PromptTurnOptions["prompt"]): string { }).join("\n\n"); } +function hostError(message: string): { error: string } { + return { error: message }; +} + type LinkPart = ClientMessageOf<"part">["part"]; type LinkUsage = Extract["usage"]; @@ -124,6 +131,8 @@ type Port = { send(message: ServerMessage): boolean; control(session: string, action: "stop" | "resume", runId?: string): Promise; forget(session: string): void; + /** Throws, closing the link, when the caller can no longer read the document. */ + revalidate(): Promise; timeoutMs: number; }; @@ -133,6 +142,8 @@ type Turn = { hostNames: Set; own: Set; ownCalls: Set; + /** Every host call id the turn has used, so none runs twice. */ + hostIds: Set; hostCalls: Map; stopped: boolean; ended: boolean; @@ -227,7 +238,7 @@ class RemoteSession { session: this.id, turn: message.turn, toolCallId: message.toolCallId, - output: "The turn has ended.", + output: hostError("The turn has ended."), isError: true, }); } @@ -275,9 +286,10 @@ class RemoteSession { if (!turn.hostNames.has(message.toolName)) { return this.#refuseHostCall(turn, message, "unavailable"); } - if (turn.hostCalls.has(message.toolCallId) || turn.ownCalls.has(message.toolCallId)) { + if (turn.hostIds.has(message.toolCallId) || turn.ownCalls.has(message.toolCallId)) { return this.#fail(turn, new Error(`Tool call ${message.toolCallId} was repeated.`)); } + turn.hostIds.add(message.toolCallId); turn.hostCalls.set(message.toolCallId, message.toolName); turn.emit({ type: "tool-call", @@ -297,9 +309,11 @@ class RemoteSession { session: this.id, turn: turn.id, toolCallId: message.toolCallId, - output: reason === "aborted" - ? "The turn was aborted." - : `${message.toolName} is not available in this turn.`, + output: hostError( + reason === "aborted" + ? "The turn was aborted." + : `${message.toolName} is not available in this turn.`, + ), isError: true, }); } @@ -316,7 +330,7 @@ class RemoteSession { if (!turn.own.has(part.toolName)) return boundary(part.toolName); return turn.emit({ ...part, providerExecuted: true, dynamic: true }); case "tool-call": - if (!turn.own.has(part.toolName) || turn.hostCalls.has(part.toolCallId)) { + if (!turn.own.has(part.toolName) || turn.hostIds.has(part.toolCallId)) { return boundary(part.toolName); } turn.ownCalls.add(part.toolCallId); @@ -406,7 +420,7 @@ class RemoteSession { session: this.id, turn: turn.id, toolCallId, - output: `${toolName} returned more than the Planner link can carry.`, + output: hostError(`${toolName} returned more than the Planner link can carry.`), isError: true, }); } @@ -419,10 +433,11 @@ class RemoteSession { } async #prompt(options: HarnessV1PromptTurnOptions): Promise { - if (this.#closed) throw new Error("The remote Planner session is closed."); - if (this.#current) throw new Error("The remote Planner session already has an active turn."); + this.#ready(); if (options.skills.length > 0) throw unsupported("skills"); let prompt = promptText(options.prompt); + await this.#port.revalidate(); + this.#ready(); let hostNames = options.tools.map(tool => tool.name); let turn: Turn = { id: crypto.randomUUID(), @@ -430,6 +445,7 @@ class RemoteSession { hostNames: new Set(hostNames), own: new Set(), ownCalls: new Set(), + hostIds: new Set(), hostCalls: new Map(), stopped: false, ended: false, @@ -461,6 +477,11 @@ class RemoteSession { return this.#control(turn); } + #ready(): void { + if (this.#closed) throw new Error("The remote Planner session is closed."); + if (this.#current) throw new Error("The remote Planner session already has an active turn."); + } + async close(): Promise { if (this.#closed) return; this.#closed = true; @@ -518,6 +539,7 @@ export class PlannerLink { #sessions = new Map(); #inputs = new Map(); #controls = new Map>(); + #watch: ReturnType | undefined; #port: Port; constructor(socket: LinkSocket, options: LinkOptions) { @@ -530,6 +552,7 @@ export class PlannerLink { this.#sessions.delete(session); this.#cancelInputs(session); }, + revalidate: () => this.#recheck(), timeoutMs: options.timeoutMs ?? OPERATION_TIMEOUT_MS, }; } @@ -548,11 +571,7 @@ export class PlannerLink { if (this.#sessions.has(sessionId)) { throw new Error("The remote Planner session is already open."); } - if (this.#options.revalidate && !await this.#options.revalidate()) { - this.#refuse("access-revoked", "The attached account can no longer read this document."); - throw new Error("The attached Planner can no longer read this document."); - } - if (this.#state !== "attached") throw new Error("The Planner link closed."); + await this.#recheck(); let session = new RemoteSession(sessionId, planner, this.#port); this.#sessions.set(sessionId, session); try { @@ -564,6 +583,23 @@ export class PlannerLink { } } + /** Closes the link with `access-revoked` and throws when the caller can no longer read the document. */ + async #recheck(): Promise { + if (this.#options.revalidate && !await this.#options.revalidate()) { + this.#refuse("access-revoked", "The attached account can no longer read this document."); + throw new Error("The attached Planner can no longer read this document."); + } + if (this.#state !== "attached") throw new Error("The Planner link closed."); + } + + async #recheckAttached(): Promise { + try { + await this.#recheck(); + } catch (error) { + if (this.#state === "attached") console.error("[planner-link] access recheck failed:", error); + } + } + receive(raw: unknown): void { if (this.#state === "closed") return; let parsed = parseClientMessage(raw); @@ -593,6 +629,7 @@ export class PlannerLink { disconnected(): void { if (this.#state === "closed") return; this.#state = "closed"; + clearInterval(this.#watch); this.#forget?.(); this.#forget = undefined; for (let session of this.#sessions.values()) session.linkClosed(); @@ -644,6 +681,10 @@ export class PlannerLink { this.#state = "attached"; this.#send({ type: "attached", version: PLANNER_LINK_VERSION, document: outcome.document }); this.#forget = this.#options.register?.(outcome.document.id, this); + this.#watch = setInterval( + () => void this.#recheckAttached(), + this.#options.revalidateEveryMs ?? REVALIDATE_EVERY_MS, + ); } #dispatch(message: ClientMessage): void { @@ -658,8 +699,20 @@ export class PlannerLink { case "tools": case "part": case "host-tool-call": - case "turn-end": - return this.#sessions.get(message.session)?.receive(message); + case "turn-end": { + let session = this.#sessions.get(message.session); + if (session) return session.receive(message); + if (message.type !== "host-tool-call") return; + this.#send({ + type: "host-tool-result", + session: message.session, + turn: message.turn, + toolCallId: message.toolCallId, + output: hostError("The session has ended."), + isError: true, + }); + return; + } case "runs": return this.#sessions.get(message.session)?.reportRuns(message); case "control-result": { diff --git a/apps/server/src/harness/session.ts b/apps/server/src/harness/session.ts index 0d6cc9683..54f2fb200 100644 --- a/apps/server/src/harness/session.ts +++ b/apps/server/src/harness/session.ts @@ -71,6 +71,12 @@ export type PlannerSession = { pauseRun?: (runId: string) => Promise; /** Resumes one paused run this session owns. */ resumeRun?: (runId: string) => Promise; + /** + * Whether a run that ends blocked or failed between turns starts a Planner turn + * about it. A remote Planner's runs are only what its client reports, so they + * never start a turn no writer asked for. + */ + followsUpRuns?: boolean; }; /** Run control for a remote Planner: the attached client stops and resumes its own runs. */ @@ -79,7 +85,7 @@ function remoteRunControl( watchRuns: NonNullable, ): Pick< PlannerSession, - "runs" | "watchRuns" | "pauseRuns" | "resumeRuns" | "pauseRun" | "resumeRun" + "runs" | "watchRuns" | "pauseRuns" | "resumeRuns" | "pauseRun" | "resumeRun" | "followsUpRuns" > { let control = () => { if (!remote.control) throw new Error("The Planner has no attached session to control."); @@ -92,6 +98,7 @@ function remoteRunControl( resumeRuns: async () => control()("resume"), pauseRun: async runId => control()("stop", runId), resumeRun: async runId => control()("resume", runId), + followsUpRuns: false, }; } diff --git a/apps/server/src/testing/planner-link.ts b/apps/server/src/testing/planner-link.ts index 33294d6cd..42626f853 100644 --- a/apps/server/src/testing/planner-link.ts +++ b/apps/server/src/testing/planner-link.ts @@ -67,7 +67,7 @@ export async function linkAuth(channelId?: string) { } /** A Bun server that serves only the Planner link, as the Chopin server does under HARNESS=remote. */ -export function linkServer(auth: PlannerLinkAuth, timeoutMs = 2_000) { +export function linkServer(auth: PlannerLinkAuth, timeoutMs = 2_000, revalidateEveryMs?: number) { let links: PlannerLink[] = []; let server = Bun.serve({ hostname: "127.0.0.1", @@ -83,6 +83,7 @@ export function linkServer(auth: PlannerLinkAuth, timeoutMs = 2_000) { open(ws) { openPlannerLinkSocket(ws, auth, { timeoutMs, + revalidateEveryMs, register(_channelId, link) { links.push(link); return () => { diff --git a/docs/architecture.md b/docs/architecture.md index 86969706d..ef0cacd61 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,8 +3,10 @@ Chopin is one Bun application, one browser client, and one PostgreSQL database. The application serves the built client, HTTP API, Streamable HTTP MCP endpoint, and WebSocket from the same origin. GitHub supplies identity and repository -authorization; the selected harness (GitHub Copilot by default, or Pi) supplies -the hosted document agent runtime, currently named Planner. +authorization; the selected harness (GitHub Copilot by default, Pi, or Atomic) +supplies the hosted document agent runtime, currently named Planner. Under +`HARNESS=remote` the Planner's model instead runs in a member's client attached +over the Planner link WebSocket, `/planner-link`, on the same origin. This document describes the system boundaries and collaborative document model. See [Repository channels](channels.md) for channel creation and access, @@ -33,8 +35,9 @@ and [Self-hosting](self-hosting.md) for deployment. blocks starting research from a child; the API may accept the request, but publication validation rejects linking a grandchild. - The **Planner** is the current name of Chopin's hosted document agent, backed - by GitHub Copilot by default, by Pi under `HARNESS=pi`, or by an in-process - Atomic session under `HARNESS=atomic`. + by GitHub Copilot by default, by Pi under `HARNESS=pi`, by an in-process + Atomic session under `HARNESS=atomic`, or by a client attached over the + Planner link under `HARNESS=remote`. - A **coding agent** is an external MCP client that creates, revises, or implements a document from its own local workspace. @@ -52,6 +55,7 @@ prompt remains optimized for planning. flowchart LR B[Browser] -->|HTTP and WebSocket| S[Bun application] C[Local coding agent] -->|Bearer-authenticated MCP| S + R[Remote Planner client] -->|Bearer-authenticated Planner link WebSocket| S S --> P[(PostgreSQL)] S --> G[GitHub API] S --> A[Harness model provider: Copilot, Pi, or Atomic] @@ -61,9 +65,10 @@ flowchart LR The browser uses a GitHub App user session backed by encrypted hosted credentials. Browser HTTP routes and the WebSocket intersect that identity's repository role with repositories -selected in a GitHub App installation. The external MCP endpoint instead checks -its caller-supplied GitHub bearer token directly. Both paths apply the instance -admission policy. +selected in a GitHub App installation. The external MCP endpoint and the +Planner link instead check their caller-supplied GitHub bearer token directly. +Every path applies the instance admission policy. Under `HARNESS=remote` the +server calls no model provider; the attached client does. The application holds an exclusive database-wide writer lease. It refuses to start beside another active Chopin process and stops if it can no longer renew @@ -71,17 +76,18 @@ the lease. This is a single-writer design, not an application cluster. ## Workspace packages -| Area | Responsibility | Internal dependencies | -| ------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | -| `packages/dialect` | Restricted MDX dialect, parsing, serialization, and Lexical schema | none | -| `packages/protocol` | WebSocket types and shared addressing helper | none | -| `packages/question` | Questionnaire definitions, shared drafts, and answer derivation | `protocol` | -| `packages/draft` | Bounded collaborative plain-text draft codec | none | -| `packages/viewport` | Browser viewport geometry and subscriptions | none | -| `packages/diagrams` | SeeCode-derived, bounded diagram rendering and scoped React viewing | `icons` (React peer) | -| `packages/editor` | Collaborative editor, cursors, decisions, comments, and widgets | `diagrams`, `dialect`, `question`, `protocol`, `viewport`, `visuals` | -| `apps/server` | Authentication, channels, rooms, storage, Planner, jobs, MCP, and implementation lifecycle | `diagrams`, `dialect`, `draft`, `question`, `protocol` | -| `apps/web` | Repository picker, channel navigation, Chat, and workspace shell | `dialect`, `draft`, `editor`, `protocol`, `viewport` | +| Area | Responsibility | Internal dependencies | +| ----------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | +| `packages/dialect` | Restricted MDX dialect, parsing, serialization, and Lexical schema | none | +| `packages/protocol` | WebSocket types and shared addressing helper | none | +| `packages/planner-link` | Remote Planner link messages, schemas, and test client | none | +| `packages/question` | Questionnaire definitions, shared drafts, and answer derivation | `protocol` | +| `packages/draft` | Bounded collaborative plain-text draft codec | none | +| `packages/viewport` | Browser viewport geometry and subscriptions | none | +| `packages/diagrams` | SeeCode-derived, bounded diagram rendering and scoped React viewing | `icons` (React peer) | +| `packages/editor` | Collaborative editor, cursors, decisions, comments, and widgets | `diagrams`, `dialect`, `question`, `protocol`, `viewport`, `visuals` | +| `apps/server` | Authentication, channels, rooms, storage, Planner, jobs, MCP, and implementation lifecycle | `diagrams`, `dialect`, `draft`, `planner-link`, `question`, `protocol` | +| `apps/web` | Repository picker, channel navigation, Chat, and workspace shell | `dialect`, `draft`, `editor`, `protocol`, `viewport` | Runtime workspace packages do not depend on either application. The E2E suite and skill contract tests deliberately import server internals as test harnesses; @@ -135,6 +141,18 @@ filesystem access as the server process's user. Model-backed `active-planner` workers use the same owner credential but fresh isolated harness sessions; see [Background jobs](background-jobs.md). +### Remote Planner link + +Under `HARNESS=remote`, `/planner-link` accepts a WebSocket whose upgrade carries +a GitHub bearer, checked like `/mcp`. The client attaches to one document and +needs pull access to its repository, rechecked before every session and turn and +periodically while attached. Turns run on the client's model; Chopin's host +tools, Decisions, and room writes still run in the server under the document's +Planner owner and the permission checks above. The client sees what a turn sees +and steers its document writes during turns writers start; its run reports never +start a turn. See [Remote Planner](hosted-agent.md#remote-planner) and +[Planner link](planner-link.md). + ## State ownership ### Local investigations @@ -520,6 +538,8 @@ Treat these as implementation work, not guarantees to build new behavior upon. `apps/server/src/harness/github-tools.ts` - Copilot SDK adapter: `apps/server/src/harness/copilot-sdk/adapter.ts` - Atomic SDK adapter: `apps/server/src/harness/atomic/adapter.ts` +- Remote Planner adapter and link endpoint: `apps/server/src/harness/remote/` and + `packages/planner-link` - Channel routes and IDs: `apps/server/src/channels/` - Research requests and child publication: `apps/server/src/research/service.ts` - Inline request state and anchored children: `apps/web/src/research-requests.ts` diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index d8f7b7cdd..7dad77fc6 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -291,7 +291,9 @@ the document's repository. An unsupported version, an unreadable repository, or a missing or archived document is refused with a `refused` message and close code 4426, 4403, or 4404. Neither attaching nor a refusal posts anything to the document. The account and repository access are checked again before every -session the link starts; losing either refuses that session and closes the link. +session and every turn the link starts, including turns of a session kept for +its runs, and every 5 minutes while the link stays attached; losing either +fails that session or turn and closes the link with `access-revoked`. **Turns and tools.** Chopin's host tools (document, repository, and job tools) still run in the server against the room, under the document's Planner owner and @@ -317,7 +319,10 @@ process, with a status that tells an expiry from a cancellation. **Runs.** The client reports its workflow runs with `runs`; Chat shows those cards and keeps the session after its turn while any run is live or paused, as for an Atomic Planner. **Stop Planner** and **Resume Planner** send `stop` and -`resume`, optionally for one run, and wait for `control-result`. +`resume`, optionally for one run, and wait for `control-result`. Unlike an +Atomic Planner's, a reported run that ends blocked or failed between turns gets +only its Chat notice: the reports are the client's word, so they never start a +Planner turn that no writer asked for. **Trust boundary.** The server never holds the client's model credentials and gains no shell or filesystem access from this harness. The client, in turn, diff --git a/docs/planner-link.md b/docs/planner-link.md index b89ee4977..0989dc5b9 100644 --- a/docs/planner-link.md +++ b/docs/planner-link.md @@ -78,14 +78,20 @@ The server answers: `attach` succeeds when the bearer's GitHub account has pull access to the document's repository. After `refused` the server closes the link. -| Refusal code | Close code | Meaning | -| ---------------------- | ---------- | ------------------------------------------------------------- | -| `unsupported-version` | 4426 | The server speaks another `version` | -| `repository-forbidden` | 4403 | The account cannot read the document's repository | -| `document-unavailable` | 4404 | No document matches, or it is being deleted | -| `document-archived` | 4404 | The document is archived | -| `access-revoked` | 4403 | Access was lost after attaching (checked before each session) | -| `unavailable` | 1011 | The server could not check the document | +The server checks the account's admission and pull access again before every +`start` and every `turn`, and every 5 minutes while the link is attached. Once +either is lost it sends `refused` with `access-revoked` and closes the link with +4403; the turn that triggered the check fails. A GitHub outage during a periodic +check leaves the link open. + +| Refusal code | Close code | Meaning | +| ---------------------- | ---------- | ------------------------------------------------- | +| `unsupported-version` | 4426 | The server speaks another `version` | +| `repository-forbidden` | 4403 | The account cannot read the document's repository | +| `document-unavailable` | 4404 | No document matches, or it is being deleted | +| `document-archived` | 4404 | The document is archived | +| `access-revoked` | 4403 | Access was lost after attaching (see below) | +| `unavailable` | 1011 | The server could not check the document | ## Server to client @@ -112,14 +118,20 @@ document's repository. After `refused` the server closes the link. `{ "type": "json", "schema?": {}, "name?": "", "description?": "" }`; for `json`, the turn's text must be that JSON. - `abort` stops the turn. Answer with `turn-end`; after **10 seconds** the - server ends the turn anyway. -- `host-tool-result` answers a `host-tool-call`. An error result is a string. - A result too large for one frame arrives as an error. + server ends the turn anyway. The server sends no `host-tool-result` for the + turn's calls after `abort`, so settle any call still waiting as failed. +- `host-tool-result` answers a `host-tool-call`. `output` is the tool's result + as JSON. A failed call has `"isError": true` and `output` + `{ "error": "" }`; that includes a tool that threw, invalid input, a + name outside the turn's `tools`, a call for a turn or session that has ended, + and a result too large for one frame. A call Chopin's permission policy + refuses has `output` `{ "type": "execution-denied", "reason?": "" }`. - `input-result` answers an `input-request` (see [Questions](#questions)). - `stop` and `resume` are **Stop Planner** and **Resume Planner**: pause or resume every workflow run of the session, or only `run`. Answer with `control-result` within **10 seconds**. -- `destroy` ends the session; abandon its turn and questions. +- `destroy` ends the session; abandon its turn, its waiting host calls, and its + questions. - `error` precedes a close for a protocol violation (see [Closing](#closing)). ## Client to server @@ -138,24 +150,30 @@ document's repository. After `refused` the server closes the link. { "type": "detach" } ``` -- `started` or `start-failed` answers `start`. +- `started` or `start-failed` answers `start`. Cut `message` here, in + `turn-end`, `control-result`, and an `error` part to 4000 characters: a longer + one is a malformed message and closes the link. - `tools` declares, before calling any of them, the client's own tools this turn may call (at most 256). A name may not repeat a host tool's name. An isolated session must declare none. - `part` streams the turn (see [Stream parts](#stream-parts)). - `host-tool-call` runs one of the turn's host tools in the server; `input` is - its arguments as a JSON string. `toolCallId` must be unique in the turn. A - name outside the turn's `tools` is answered with an error result. + its arguments as a JSON string. A `toolCallId` may be used once per turn, + including after its call has finished; reusing one fails the turn without + running the tool again. A name outside the turn's `tools` is answered with an + error result. - `turn-end` ends the turn: `finished`, `failed` (with `message` as the error shown), or `aborted`. Every turn needs exactly one. - `input-request` and `input-cancel` ask and withdraw questions. - `runs` reports the session's workflow runs whenever they change. Report them - before `turn-end` so the server keeps the session while runs are live. + before `turn-end` so the server keeps the session while runs are live. Reports + only change run cards and Chat notices; they never start a Planner turn. - `control-result` answers `stop` or `resume`. - `detach` closes the link with code 1000. Messages for a session or turn that has ended are ignored, except a -`host-tool-call`, which gets an error result. +`host-tool-call`, which gets an error result (`The turn has ended.` or +`The session has ended.`). ### Stream parts @@ -210,21 +228,54 @@ A questionnaire has 1 to 16 questions of up to 32 options; `preview` may be 16,000 characters; `select` offers up to 32 choices. The document's own limits still apply. Text is shown verbatim. -`input-result.status` is: +`input-result` is `{ "type": "input-result", "request": "", "status": "", "value?": json, "message?": "" }`. +`status` and `value` by method: -- `answered`: a member answered. `value` is what Atomic's `HostInput` returns: - for `questionnaire`, `{ "answers": [...], "cancelled": false }` with Atomic's - `QuestionAnswer` objects (`questionIndex`, `question`, `kind` of `option`, - `custom`, `chat`, or `multi`, `answer`, and `selected` or `preview` where they - apply); for `confirm`, a boolean; for `select`, `input`, and `editor`, a - string or `null`. +| `status` | `questionnaire` `value` | `confirm` | `select` | `input`, `editor` | `message` | +| ----------- | ---------------------------------------------------------------- | --------- | ---------------- | ----------------- | --------- | +| `answered` | `{ "answers": [answer for every question], "cancelled": false }` | boolean | choice or `null` | text | absent | +| `expired` | `{ "answers": [], "cancelled": true }` | `false` | `null` | `null` | absent | +| `cancelled` | `{ "answers": [answered questions only], "cancelled": true }` | `false` | `null` | `null` | absent | +| `failed` | absent | absent | absent | absent | why | + +- `answered`: members answered every question. `confirm` is `true` only for + **Yes**. `select` is the chosen string from `choices`, or `null` when a member + wrote an answer instead. `input` and `editor` are the text a member wrote. - `expired`: nobody answered within 30 minutes. The cards stay in Decisions - marked expired, and `value` is what Atomic gets for no answer - (`{ "answers": [], "cancelled": true }`, `false`, or `null`). -- `cancelled`: a member cancelled, or the client sent `input-cancel`, or the - session ended. Open cards are withdrawn. -- `failed`: the request could not become Decisions (`message` says why), for - example in an isolated session or with 16 requests already open on the link. + marked expired and no partial answers are returned. +- `cancelled`: a member cancelled a question, the client sent `input-cancel`, + the question was withdrawn before it was asked, the session ended, or the link + closed. Open cards are withdrawn. When a member cancelled one question of a + questionnaire after others were answered, `answers` keeps those others; it is + empty in every other case. The package client settles a question withdrawn + before it was sent, or one still open when the link closes, with a local + `cancelled` result that has a `message` and no `value`. +- `failed`: the request could not become Decisions, for example in an isolated + session or with 16 requests already open on the link. + +A questionnaire `answers` array lists only answered questions, in question +order; an unanswered question has no entry. Each entry is Atomic's +`QuestionAnswer`: + +```text +{ "questionIndex": 0, "question": "", "kind": "option", "answer": "", "preview?": "" } +{ "questionIndex": 0, "question": "", "kind": "multi", "answer": null, "selected": [""] } +{ "questionIndex": 0, "question": "", "kind": "custom", "answer": "" } +{ "questionIndex": 0, "question": "", "kind": "chat", "answer": "" } +``` + +- `questionIndex` is the question's 0-based position and `question` its + verbatim text. +- `option`: a single-select question answered with one offered option. `answer` + is its label; `preview` is that option's `preview`, present only when it had + one. +- `multi`: a `multiSelect` question answered with offered options. `selected` + lists their labels; `answer` is `null`. +- `custom`: a member wrote the answer to a single-select question whose options + have no `preview`. `answer` is the text, or the labels of the options members + added, joined by a comma and a space. +- `chat`: the same written answer for a `multiSelect` question or one whose + options have a `preview`. A workflow-stage question names its `workflowRunId` and, when it differs, the `rootRunId` of the run that owns it. While the latest `runs` report lists that diff --git a/docs/self-hosting.md b/docs/self-hosting.md index f05a70eb9..203981da6 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -256,9 +256,11 @@ model. The route exists only with `AGENT` on and `HARNESS=remote`. The trust boundary moves to whoever attaches. A client authenticates with a GitHub token bearer, checked like hosted MCP, and needs pull access to the -document's repository. It then receives what a Planner turn sees, including -repository reads made with the document Planner owner's token, and decides what -the Planner writes during turns writers start. The server never runs the client's +document's repository, rechecked before every session and turn and every 5 +minutes while it stays attached. It then receives what a Planner turn sees, +including repository reads made with the document Planner owner's token, and +decides what the Planner writes during turns writers start; its run reports +never start a turn. The server never runs the client's tools and gains no shell or filesystem access from this harness. Until one Planner per document is enforced, the most recently attached link serves new sessions. Without an attached client, a Planner turn fails as unavailable. See diff --git a/packages/planner-link/src/client.test.ts b/packages/planner-link/src/client.test.ts new file mode 100644 index 000000000..8c6290222 --- /dev/null +++ b/packages/planner-link/src/client.test.ts @@ -0,0 +1,175 @@ +import { afterEach, describe, expect, it } from "bun:test"; + +import { connectPlannerLink } from "./client"; +import { MAX_LINK_NOTE_LENGTH, parseClientMessage } from "./protocol"; + +import type { ServerWebSocket } from "bun"; +import type { HostToolResult, PlannerLinkHandlers, TurnContext } from "./client"; +import type { ClientMessage, ServerMessage } from "./protocol"; + +let stops: Array<() => unknown> = []; +afterEach(async () => { + for (let stop of stops.splice(0)) await stop(); +}); + +/** A server that speaks only the wire format, so each test drives the client frame by frame. */ +function fakeServer() { + let socket: ServerWebSocket | undefined; + let received: ClientMessage[] = []; + let arrivals = new Set<() => void>(); + let server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request, self) { + return self.upgrade(request) ? undefined : new Response("upgrade failed", { status: 400 }); + }, + websocket: { + open(ws) { + socket = ws; + }, + message(ws, raw) { + let parsed = parseClientMessage(String(raw)); + if (!parsed.ok) throw new Error(parsed.message); + received.push(parsed.message); + if (parsed.message.type === "attach") { + ws.send(JSON.stringify({ + type: "attached", + version: 1, + document: { id: "d", title: "T", repository: "o/r" }, + })); + } + for (let wake of arrivals) wake(); + }, + }, + }); + stops.push(() => server.stop(true)); + async function next( + type: T, + ): Promise> { + for (let deadline = Date.now() + 2_000; Date.now() < deadline;) { + let index = received.findIndex(message => message.type === type); + if (index >= 0) return received.splice(index, 1)[0] as Extract; + await new Promise(resolve => { + let wake = () => { + arrivals.delete(wake); + resolve(); + }; + arrivals.add(wake); + setTimeout(wake, 50); + }); + } + throw new Error(`no ${type} message arrived`); + } + return { + url: `http://127.0.0.1:${server.port}`, + received, + next, + send: (message: ServerMessage) => socket!.send(JSON.stringify(message)), + close: () => socket!.close(1000, "gone"), + }; +} + +async function connect(server: ReturnType, handlers: PlannerLinkHandlers) { + let client = await connectPlannerLink({ url: server.url, token: "t", document: "d", handlers }); + stops.push(() => client.detach()); + return client; +} + +const turn = (id: string) => ({ + type: "turn" as const, + session: "s", + turn: id, + prompt: "p", + tools: [{ name: "read_plan" }], +}); + +describe("Planner link client", () => { + it("settles a host call the server aborts or destroys instead of answering", async () => { + let server = fakeServer(); + let results: HostToolResult[] = []; + await connect(server, { + async turn(context) { + results.push(await context.callHostTool("read_plan", {})); + }, + }); + server.send(turn("aborted")); + await server.next("host-tool-call"); + server.send({ type: "abort", session: "s", turn: "aborted" }); + expect(await server.next("turn-end")).toMatchObject({ turn: "aborted", status: "aborted" }); + server.send(turn("destroyed")); + await server.next("host-tool-call"); + server.send({ type: "destroy", session: "s" }); + expect(await server.next("turn-end")).toMatchObject({ turn: "destroyed", status: "aborted" }); + expect(results).toEqual([ + { output: { error: "The turn was aborted." }, isError: true }, + { output: { error: "The turn was aborted." }, isError: true }, + ]); + }); + + it("withdraws a question whose signal is already aborted without asking it", async () => { + let server = fakeServer(); + let client = await connect(server, { turn() {} }); + let controller = new AbortController(); + controller.abort(); + let asked = await client.ask("s", { method: "confirm", title: "Ship?", message: "Now." }, { + signal: controller.signal, + }); + expect(asked).toMatchObject({ type: "input-result", status: "cancelled" }); + client.reportRuns("s", { active: [], paused: [], cards: [] }); + await server.next("runs"); + expect(server.received.some(message => message.type === "input-request")).toBe(false); + }); + + it("cuts generated error text to the wire limit instead of breaking the link", async () => { + let server = fakeServer(); + let long = "x".repeat(MAX_LINK_NOTE_LENGTH * 3); + let client = await connect(server, { + start() { + throw new Error(long); + }, + turn(context) { + context.emit({ type: "error", error: long }); + throw new Error(long); + }, + control() { + throw new Error(long); + }, + }); + server.send({ type: "start", session: "s", mode: "planner" }); + expect((await server.next("start-failed")).message).toHaveLength(MAX_LINK_NOTE_LENGTH); + server.send(turn("failing")); + let part = (await server.next("part")).part; + expect(part.type === "error" && part.error).toHaveLength(MAX_LINK_NOTE_LENGTH); + let ended = await server.next("turn-end"); + expect(ended).toMatchObject({ status: "failed" }); + expect(ended.message).toHaveLength(MAX_LINK_NOTE_LENGTH); + server.send({ type: "stop", request: "r", session: "s" }); + expect((await server.next("control-result")).message).toHaveLength(MAX_LINK_NOTE_LENGTH); + expect(client.send({ type: "runs", session: "s", active: [], paused: [], cards: [] })) + .toBe(true); + }); + + it("settles requests made once the link has closed instead of leaving them pending", async () => { + let server = fakeServer(); + let holding = Promise.withResolvers(); + let client = await connect(server, { + async turn(context) { + holding.resolve(context); + await new Promise(resolve => context.signal.addEventListener("abort", resolve)); + }, + }); + server.send(turn("open")); + let held = await holding.promise; + server.close(); + await client.closed; + expect(await client.ask("s", { method: "input", title: "Name?" })).toMatchObject({ + status: "cancelled", + message: "The Planner link closed.", + }); + expect(await held.callHostTool("read_plan", {})).toEqual({ + output: { error: "The turn has ended." }, + isError: true, + }); + expect(client.send({ type: "detach" })).toBe(false); + }); +}); diff --git a/packages/planner-link/src/client.ts b/packages/planner-link/src/client.ts index 519f950f6..aa0d90e46 100644 --- a/packages/planner-link/src/client.ts +++ b/packages/planner-link/src/client.ts @@ -20,6 +20,7 @@ export const WIRE = { path: "/planner-link", version: 1, maxMessageBytes: 2 * 1024 * 1024, + maxNoteLength: 4_000, } as const; export class PlannerLinkRefused extends Error { @@ -42,11 +43,16 @@ export type TurnContext = { emit(part: StreamPart): void; /** The client's own tools this turn may call, declared before calling any. */ offerTools(names: string[]): void; - /** Runs one of Chopin's tools in the server, against the document. */ - callHostTool(toolName: string, input: unknown): Promise<{ output: unknown; isError: boolean }>; + /** + * Runs one of Chopin's tools in the server, against the document. Settles with an + * error result once the turn is aborted, destroyed, or ended, or the link closes. + */ + callHostTool(toolName: string, input: unknown): Promise; end(status: TurnEnd, message?: string): void; }; +export type HostToolResult = { output: unknown; isError: boolean }; + export type PlannerLinkHandlers = { /** Throw to refuse the session. */ start?(message: ServerMessageOf<"start">): void | Promise; @@ -87,7 +93,8 @@ export type PlannerLinkClient = { options?: InputOptions, ): Promise>; reportRuns(session: string, runs: { active: string[]; paused: string[]; cards: LinkRun[] }): void; - send(message: ClientMessage): void; + /** Sends one message; false when the link is no longer open. */ + send(message: ClientMessage): boolean; detach(): Promise; readonly closed: Promise; }; @@ -128,36 +135,72 @@ export function linkSocket(url: URL, token: string): WebSocket { }); } +const LINK_CLOSED = "The Planner link closed."; + +type TurnState = { + controller: AbortController; + ended: boolean; + calls: Map void>; +}; + +function hostFailure(message: string): HostToolResult { + return { output: { error: message }, isError: true }; +} + +/** Text cut to the wire's limit for `message` and error fields. */ +function note(text: string): string { + return text.length > WIRE.maxNoteLength ? text.slice(0, WIRE.maxNoteLength) : text; +} + +function reason(error: unknown): string { + return note(error instanceof Error ? error.message : String(error)); +} + +function settleCalls(turn: TurnState, message: string): void { + for (let resolve of turn.calls.values()) resolve(hostFailure(message)); + turn.calls.clear(); +} + /** Attaches to one document as its Planner and serves the server's requests. */ export function connectPlannerLink(options: PlannerLinkOptions): Promise { let socket = linkSocket(plannerLinkUrl(options.url), options.token); - let turns = new Map(); - let hostCalls = new Map void>(); + let turns = new Map(); let asks = new Map) => void>(); let closing = Promise.withResolvers(); let attaching = Promise.withResolvers(); let attached = false; - function send(message: ClientMessage): void { + function send(message: ClientMessage): boolean { let encoded = JSON.stringify(message); if (new TextEncoder().encode(encoded).byteLength > WIRE.maxMessageBytes) { throw new Error("The message exceeds the Planner link's size limit."); } - if (socket.readyState === WebSocket.OPEN) socket.send(encoded); + if (socket.readyState !== WebSocket.OPEN) return false; + socket.send(encoded); + return true; } function runTurn(message: ServerMessageOf<"turn">): void { let key = `${message.session}/${message.turn}`; - let state = { controller: new AbortController(), ended: false }; + let state: TurnState = { controller: new AbortController(), ended: false, calls: new Map() }; turns.set(key, state); + state.controller.signal.addEventListener( + "abort", + () => settleCalls(state, "The turn was aborted."), + { once: true }, + ); let calls = 0; let context: TurnContext = { message, signal: state.controller.signal, emit: part => { - if (!state.ended) { - send({ type: "part", session: message.session, turn: message.turn, part }); - } + if (state.ended) return; + send({ + type: "part", + session: message.session, + turn: message.turn, + part: part.type === "error" ? { ...part, error: note(part.error) } : part, + }); }, offerTools: names => send({ @@ -167,30 +210,43 @@ export function connectPlannerLink(options: PlannerLinkOptions): Promise { + if (state.ended || state.controller.signal.aborted) { + return Promise.resolve(hostFailure("The turn has ended.")); + } let toolCallId = `host-${++calls}-${crypto.randomUUID()}`; - let result = new Promise<{ output: unknown; isError: boolean }>(resolve => { - hostCalls.set(`${key}/${toolCallId}`, resolve); - }); - send({ - type: "host-tool-call", - session: message.session, - turn: message.turn, - toolCallId, - toolName, - input: JSON.stringify(input ?? {}), - }); - return result; + let result = Promise.withResolvers(); + state.calls.set(toolCallId, result.resolve); + let sent: boolean; + try { + sent = send({ + type: "host-tool-call", + session: message.session, + turn: message.turn, + toolCallId, + toolName, + input: JSON.stringify(input ?? {}), + }); + } catch (error) { + state.calls.delete(toolCallId); + return Promise.reject(error); + } + if (!sent) { + state.calls.delete(toolCallId); + return Promise.resolve(hostFailure(LINK_CLOSED)); + } + return result.promise; }, end: (status, text) => { if (state.ended) return; state.ended = true; turns.delete(key); + settleCalls(state, "The turn has ended."); send({ type: "turn-end", session: message.session, turn: message.turn, status, - ...(text === undefined ? {} : { message: text }), + ...(text === undefined ? {} : { message: note(text) }), }); }, }; @@ -198,7 +254,7 @@ export function connectPlannerLink(options: PlannerLinkOptions): Promise options.handlers.turn(context)) .then( () => context.end(state.controller.signal.aborted ? "aborted" : "finished"), - error => context.end("failed", error instanceof Error ? error.message : String(error)), + error => context.end("failed", reason(error)), ); } @@ -212,7 +268,7 @@ export function connectPlannerLink(options: PlannerLinkOptions): Promise>(resolve => { - asks.set(request, resolve); - }); + let withdrawn = (text: string) => + Promise.resolve>({ + type: "input-result", + request, + status: "cancelled", + message: text, + }); + if (signal?.aborted) return withdrawn("The question was withdrawn before it was asked."); + let answered = Promise.withResolvers>(); + asks.set(request, answered.resolve); + let sent: boolean; + try { + sent = send({ type: "input-request", request, session, ...workflow, input }); + } catch (error) { + asks.delete(request); + return Promise.reject(error); + } + if (!sent) { + asks.delete(request); + return withdrawn(LINK_CLOSED); + } let cancel = () => send({ type: "input-cancel", request }); signal?.addEventListener("abort", cancel, { once: true }); - send({ type: "input-request", request, session, ...workflow, input }); - return answered.finally(() => signal?.removeEventListener("abort", cancel)); + return answered.promise.finally(() => signal?.removeEventListener("abort", cancel)); }, reportRuns(session, runs) { send({ type: "runs", session, ...runs }); @@ -312,14 +388,13 @@ export function connectPlannerLink(options: PlannerLinkOptions): Promise { - for (let turn of turns.values()) turn.controller.abort(); - turns.clear(); - for (let resolve of hostCalls.values()) { - resolve({ output: "The Planner link closed.", isError: true }); + for (let turn of turns.values()) { + settleCalls(turn, LINK_CLOSED); + turn.controller.abort(); } - hostCalls.clear(); + turns.clear(); for (let [request, resolve] of asks) { - resolve({ type: "input-result", request, status: "cancelled", message: "link closed" }); + resolve({ type: "input-result", request, status: "cancelled", message: LINK_CLOSED }); } asks.clear(); attaching.reject( diff --git a/packages/planner-link/src/protocol.test.ts b/packages/planner-link/src/protocol.test.ts index 67a1ae2ae..0864ecf23 100644 --- a/packages/planner-link/src/protocol.test.ts +++ b/packages/planner-link/src/protocol.test.ts @@ -4,6 +4,7 @@ import { plannerLinkUrl, WIRE } from "./client"; import { encodeMessage, MAX_LINK_MESSAGE_BYTES, + MAX_LINK_NOTE_LENGTH, parseClientMessage, parseServerMessage, PLANNER_LINK_PATH, @@ -138,6 +139,7 @@ describe("Planner link messages", () => { path: PLANNER_LINK_PATH, version: PLANNER_LINK_VERSION, maxMessageBytes: MAX_LINK_MESSAGE_BYTES, + maxNoteLength: MAX_LINK_NOTE_LENGTH, }); let source = await Bun.file(new URL("./client.ts", import.meta.url)).text(); let imports = [...source.matchAll(/^import\s+(type\s+)?/gm)]; diff --git a/packages/planner-link/src/protocol.ts b/packages/planner-link/src/protocol.ts index a5a282d75..07ecea1b2 100644 --- a/packages/planner-link/src/protocol.ts +++ b/packages/planner-link/src/protocol.ts @@ -21,9 +21,12 @@ export const LINK_CLOSE = { unsupportedVersion: 4426, } as const; +/** The longest `message`, error, title or question text, in UTF-16 code units. */ +export const MAX_LINK_NOTE_LENGTH = 4_000; + const MAX_ID = 128; const MAX_NAME = 128; -const MAX_NOTE = 4_000; +const MAX_NOTE = MAX_LINK_NOTE_LENGTH; const MAX_TOOLS = 256; const MAX_RUNS = 64; const MAX_STAGES = 64; From a7b667e4f5c76450f92568e8d8b0231802e3d127 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 10 Oct 2026 19:08:52 -0700 Subject: [PATCH 03/10] Close the Planner link on rejected tokens and agree on oversized host results A repository lookup that GitHub answers with 401 now counts as revoked access, so the link sends access-revoked and closes even while admission is cached; an outage still leaves it open. An oversized host-tool result becomes the same error for the server's turn and the client. The client closes on an unparseable server frame with 4400, which Node.js permits, and settles what waits at once. /planner-link answers 404 when the server does not run the remote Planner, and the wire docs state every field limit the schemas enforce. Assistant-model: Claude Opus 5.5 (fast) Assistant-workflow: hosted-planner-slices Assistant-duration: 8m converged, estimated 30m Assistant-verification: bun run types passed: every workspace package and e2e Assistant-verification: bun test passed: 4636 pass, 4 skip, 0 fail (GIT_CONFIG_GLOBAL=/dev/null bun test) Assistant-verification: bun run ci passed: dprint, oxlint (31 warnings, same as before), tokens, design contract Assistant-verification: regression tests passed: the repository-401 and oversized-result tests fail against the previous link.ts/endpoint.ts; the Node.js v24.21.0 malformed-frame test fails with InvalidAccessError against close code 1008 Co-authored-by: Alex Lavaee --- apps/server/src/harness/remote/endpoint.ts | 13 ++- apps/server/src/harness/remote/link.test.ts | 73 ++++++++++++++ apps/server/src/harness/remote/link.ts | 29 +++--- apps/server/src/main.ts | 5 +- docs/planner-link.md | 29 ++++-- packages/planner-link/src/client.test.ts | 100 ++++++++++++++++++++ packages/planner-link/src/client.ts | 40 ++++---- packages/planner-link/src/protocol.test.ts | 1 + 8 files changed, 246 insertions(+), 44 deletions(-) diff --git a/apps/server/src/harness/remote/endpoint.ts b/apps/server/src/harness/remote/endpoint.ts index 7662b19b6..90cccf450 100644 --- a/apps/server/src/harness/remote/endpoint.ts +++ b/apps/server/src/harness/remote/endpoint.ts @@ -50,11 +50,16 @@ function revoked(err: unknown): boolean { return err instanceof AdmissionDenied || err instanceof GitHubError && err.status === 401; } -/** The caller for an upgrade request, or the HTTP refusal; nothing is upgraded on refusal. */ +/** + * The caller for an upgrade request, or the HTTP refusal; nothing is upgraded on refusal. + * A server that does not run the remote Planner answers 404 rather than its web client. + */ export async function admitPlannerLink( request: Request, auth: PlannerLinkAuth, + enabled = true, ): Promise { + if (!enabled) return refusal(404, "This Chopin does not accept Planner links."); let origin = request.headers.get("origin"); if (origin !== null && origin !== auth.config.origin) { return refusal(403, "origin is not allowed"); @@ -122,13 +127,13 @@ export function plannerLink( if (!channelId) return false; try { await auth.admission.user(caller.oauthToken); + let located = await locateDocument(auth, caller, channelId); + return typeof located === "object" && located.channel.id === channelId + && !located.channel.archivedAt && !hooks.unavailable?.(channelId); } catch (err) { if (revoked(err)) return false; throw err; } - let located = await locateDocument(auth, caller, channelId); - return typeof located === "object" && located.channel.id === channelId - && !located.channel.archivedAt && !hooks.unavailable?.(channelId); } return new PlannerLink(socket, { attach, diff --git a/apps/server/src/harness/remote/link.test.ts b/apps/server/src/harness/remote/link.test.ts index 6e19ac1b5..674ef0627 100644 --- a/apps/server/src/harness/remote/link.test.ts +++ b/apps/server/src/harness/remote/link.test.ts @@ -22,6 +22,7 @@ import { scriptedPlanner, } from "../../testing/planner-link"; import { createRemoteAdapter } from "./adapter"; +import { admitPlannerLink } from "./endpoint"; import type { PlannerLinkClient, PlannerLinkHandlers } from "@chopin/planner-link/client"; import type { RemotePlanner } from "./registry"; @@ -149,6 +150,17 @@ describe("Planner link admission", () => { expect(server.links).toEqual([]); }); + it("answers 404 where the server does not run the remote Planner", async () => { + let { auth } = await linkAuth(); + let request = new Request("https://chopin.test/planner-link", { + headers: { authorization: `Bearer ${READER_TOKEN}`, upgrade: "websocket" }, + }); + let refused = await admitPlannerLink(request, auth, false); + expect(refused).toBeInstanceOf(Response); + expect((refused as Response).status).toBe(404); + expect(await admitPlannerLink(request, auth, true)).toMatchObject({ plannerLink: true }); + }); + it("refuses a repository the caller cannot read, a missing or archived document, and another version", async () => { let { server, channel, storage } = await setup(); let before = await storage.channels.get(channel.id); @@ -314,6 +326,36 @@ describe("Planner link protocol", () => { expect(isolated.errors.join("\n")).toContain("An isolated Planner turn cannot offer bash"); }); + it("gives the server and the client the same error when a host result cannot cross the link", async () => { + let seen: unknown[] = []; + let { server, channel } = await setup({ + async turn(context) { + seen.push(await context.callHostTool("read_plan", {})); + finishTurn(context); + }, + }); + let agent = new HarnessAgent({ + harness: adapterFor(server, { channelId: channel.id, isolated: false }), + tools: { + read_plan: tool({ + inputSchema: z.object({}), + execute: async () => "x".repeat(MAX_LINK_MESSAGE_BYTES), + }), + }, + activeTools: ["read_plan"], + permissionMode: "allow-reads", + }); + let session = await agent.createSession({ sandboxSession }); + cleanups.push(() => session.destroy()); + let outcome = await streamOn(agent, session, "large"); + let failure = { error: "read_plan returned more than the Planner link can carry." }; + expect(seen).toEqual([{ output: failure, isError: true }]); + expect(outcome.parts.filter(part => part.type === "tool-result" || part.type === "tool-error")) + .toMatchObject([{ toolName: "read_plan" }]); + expect(JSON.stringify(outcome.parts)).not.toContain("x".repeat(1_000)); + expect(JSON.stringify(outcome.parts)).toContain(failure.error); + }); + it("fails a session the client refuses or never starts, and tells it to let go", async () => { let destroyed: string[] = []; let { server, channel } = await setup({ @@ -402,6 +444,37 @@ describe("Planner link protocol", () => { expect(server.links).toEqual([]); }); + it("detaches a link whose token GitHub rejects while admission is still cached", async () => { + let { server, channel, auth, client } = await setup({}); + let agent = agentFor(adapterFor(server, { channelId: channel.id, isolated: false })); + let session = await agent.createSession({ sandboxSession }); + cleanups.push(() => session.destroy()); + auth.github.repository = async () => { + throw new GitHubError("Bad credentials", 401); + }; + let revoked = await streamOn(agent, session, "second"); + expect(revoked.errors.join("\n")).toContain("can no longer read this document"); + expect(await client.closed).toEqual({ code: LINK_CLOSE.forbidden, reason: "access-revoked" }); + expect(server.links).toEqual([]); + }); + + it("detaches on a rejected token at the periodic check but stays attached through an outage", async () => { + let { auth, channel } = await linkAuth(); + let server = linkServer(auth, 300, 20); + cleanups.push(server.stop); + let client = await attach(server.url, channel.id, scriptedPlanner().handlers); + auth.github.repository = async () => { + throw new GitHubError("Service Unavailable", 503); + }; + await Bun.sleep(80); + expect(server.links).toHaveLength(1); + auth.github.repository = async () => { + throw new GitHubError("Bad credentials", 401); + }; + expect(await client.closed).toEqual({ code: LINK_CLOSE.forbidden, reason: "access-revoked" }); + expect(server.links).toEqual([]); + }); + it("refuses a host call id the turn already used, even after that call finished", async () => { let { server, channel } = await setup(); let { raw, started } = await rawAttached(server, channel.id); diff --git a/apps/server/src/harness/remote/link.ts b/apps/server/src/harness/remote/link.ts index 91ce9fb4e..f6de9972e 100644 --- a/apps/server/src/harness/remote/link.ts +++ b/apps/server/src/harness/remote/link.ts @@ -403,27 +403,30 @@ class RemoteSession { let toolName = turn.hostCalls.get(toolCallId); if (!toolName || turn.ended) return; turn.hostCalls.delete(toolCallId); - let result = (output ?? null) as NonNullable; - turn.emit({ type: "tool-result", toolCallId, toolName, result, isError }); - if (turn.stopped) return; - let delivered = this.#port.send({ + let carried: ServerMessageOf<"host-tool-result"> = { type: "host-tool-result", session: this.id, turn: turn.id, toolCallId, - output: result as never, + output: (output ?? null) as never, ...(isError ? { isError } : {}), - }); - if (!delivered) { - this.#port.send({ - type: "host-tool-result", - session: this.id, - turn: turn.id, - toolCallId, + }; + if (!encodeMessage(carried)) { + carried = { + ...carried, output: hostError(`${toolName} returned more than the Planner link can carry.`), isError: true, - }); + }; } + let result = carried.output as NonNullable; + turn.emit({ + type: "tool-result", + toolCallId, + toolName, + result, + isError: carried.isError ?? isError, + }); + if (!turn.stopped) this.#port.send(carried); }, async submitToolApproval() { // The remote Planner asks no approvals; host tools are already allowed. diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index a415e5a1d..aebbbd425 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1147,8 +1147,9 @@ function listen(): Server { } return upgraded(req, self, outcome.data); } - if (url.pathname === PLANNER_LINK_PATH && config.agent && config.harness === "remote") { - let outcome = await admitPlannerLink(req, hostedAuth); + if (url.pathname === PLANNER_LINK_PATH) { + let enabled = config.agent && config.harness === "remote"; + let outcome = await admitPlannerLink(req, hostedAuth, enabled); if (outcome instanceof Response) return outcome; return upgraded(req, self, outcome); } diff --git a/docs/planner-link.md b/docs/planner-link.md index 0989dc5b9..7df65b448 100644 --- a/docs/planner-link.md +++ b/docs/planner-link.md @@ -18,7 +18,8 @@ runs it in Bun and as a separate Node.js process against a real socket. Open a WebSocket to `wss:///planner-link` (`ws:` only for a loopback development server). It shares the application's origin and port with `/ws` and `/mcp`, behind the same TLS-terminating proxy. The route exists only -when the server runs with `AGENT` on and `HARNESS=remote`; otherwise it is 404. +when the server runs with `AGENT` on and `HARNESS=remote`; otherwise every +request to it, upgrade or not, gets HTTP 404 before the bearer is checked. The upgrade request must carry `Authorization: Bearer `, the same bearer [local agent MCP](local-agent-mcp.md) uses. Bun's and Node.js's built-in @@ -31,6 +32,7 @@ so nothing is opened: | Status | Meaning | | ------ | ----------------------------------------------------------- | +| 404 | The server does not run `HARNESS=remote` with `AGENT` on | | 401 | Missing or malformed bearer, or GitHub rejected the token | | 403 | The account fails instance admission, or a foreign `Origin` | | 503 | GitHub is unavailable or rate limiting; retry later | @@ -44,12 +46,15 @@ a field this page does not list is refused. Binary frames are refused. Shared limits, in characters unless stated: -| Kind | Fields | Limit | -| ---- | ------------------------------------------------------------------------------------ | ---------- | -| id | `session`, `turn`, `request`, `toolCallId`, part `id`, run and stage `id`, `run` | 1 to 128 | -| name | tool names, `toolName`, `modelId`, question `header` | 1 to 128 | -| note | `message`, `title`, error text, questions, option labels and descriptions, run names | up to 4000 | -| text | `prompt`, `instructions`, `delta`, `input` | the frame | +| Kind | Fields | Limit | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| id | `session`, `turn`, `request`, `toolCallId`, part `id`, run and stage `id`, `run`, `workflowRunId`, `workflowStageId`, `rootRunId`, `active` and `paused` entries, `document.id` in `attached` | 1 to 128 | +| name | tool names, `toolName`, `names` entries, `modelId`, `responseFormat.name` | 1 to 128 | +| short | question `header`, `finishReason.raw` | up to 128 | +| note | `message`, every `title`, `error`, `turn.model`, `responseFormat.description`, question text, option `label` and `description`, `choices` entries, `placeholder`, `initial`, run and stage `name` | up to 4000 | +| text | `prompt`, `instructions`, `delta`, `input` | the frame | + +Other fields state their own limits where they appear. `?` marks an optional field. `json` is any JSON value. @@ -75,8 +80,9 @@ The server answers: { "type": "refused", "code": "", "message": "" } ``` -`attach` succeeds when the bearer's GitHub account has pull access to the -document's repository. After `refused` the server closes the link. +`attached.document.repository` is `owner/name`, at most 257 characters, and +`url` at most 2048. `attach` succeeds when the bearer's GitHub account has pull +access to the document's repository. After `refused` the server closes the link. The server checks the account's admission and pull access again before every `start` and every `turn`, and every 5 minutes while the link is attached. Once @@ -312,3 +318,8 @@ ids. When the link closes, the server shows the reported live runs as stopped. a repeated `input-request` id. When the link closes for any reason, the server ends a running turn with an error, withdraws open questions, and forgets the link; reattaching starts new sessions. A server never replays an interrupted turn. + +A client that receives a frame it cannot parse should close with 4400 and reason +`malformed-message`. Node.js's `WebSocket` lets a caller close only with 1000 or +3000 to 4999, so a client cannot answer with 1008. The package client does +this and settles everything still waiting on the link at once. diff --git a/packages/planner-link/src/client.test.ts b/packages/planner-link/src/client.test.ts index 8c6290222..2a76610fa 100644 --- a/packages/planner-link/src/client.test.ts +++ b/packages/planner-link/src/client.test.ts @@ -173,3 +173,103 @@ describe("Planner link client", () => { expect(client.send({ type: "detach" })).toBe(false); }); }); + +describe("Planner link client facing a faulty server", () => { + /** Answers attach, then a frame no client can parse in place of the question's answer. */ + function garblingServer() { + let closes: { code: number; reason: string }[] = []; + let server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request, self) { + return self.upgrade(request) ? undefined : new Response("upgrade failed", { status: 400 }); + }, + websocket: { + message(ws, raw) { + let parsed = parseClientMessage(String(raw)); + if (!parsed.ok) throw new Error(parsed.message); + if (parsed.message.type === "attach" && parsed.message.document === "garbled") { + ws.send("not JSON"); + } else if (parsed.message.type === "attach") { + ws.send(JSON.stringify({ + type: "attached", + version: 1, + document: { id: "d", title: "T", repository: "o/r" }, + })); + } else if (parsed.message.type === "input-request") { + ws.send(new Uint8Array([1, 2, 3])); + } + }, + close(_ws, code, reason) { + closes.push({ code, reason }); + }, + }, + }); + stops.push(() => server.stop(true)); + return { url: `http://127.0.0.1:${server.port}`, closes }; + } + + async function faced(url: string) { + let early = await connectPlannerLink({ + url, + token: "t", + document: "garbled", + handlers: { + turn() {}, + }, + }).then(() => "attached", (error: Error) => error.message); + let client = await connectPlannerLink({ + url, + token: "t", + document: "d", + handlers: { + turn() {}, + }, + }); + let answer = await client.ask("s", { method: "input", title: "Name?" }); + return { early, answer, closed: await client.closed }; + } + + const expected = { + early: "The Planner link closed before attaching (4400 malformed-message).", + answer: { + type: "input-result", + request: expect.any(String), + status: "cancelled", + message: "The Planner link closed.", + }, + closed: { code: 4400, reason: "malformed-message" }, + }; + + it("closes the link and settles what waits on it", async () => { + let server = garblingServer(); + expect(await faced(server.url) as unknown).toEqual(expected); + await Bun.sleep(50); + expect(server.closes).toEqual([expected.closed, expected.closed]); + }); + + it("does the same under Node.js's WebSocket", async () => { + let node = Bun.which("node"); + let probe = node && Bun.spawnSync([node, "-p", "Boolean(process.features.typescript)"]); + if (!probe || probe.stdout.toString().trim() !== "true") return; + let server = garblingServer(); + let script = ` + import { connectPlannerLink } from ${ + JSON.stringify(new URL("./client.ts", import.meta.url).href) + }; + ${faced.toString().replace("async function faced", "async function run")} + console.log(JSON.stringify(await run(process.argv[1]))); + `; + let child = Bun.spawn([node!, "--input-type=module", "-e", script, server.url], { + stdout: "pipe", + stderr: "pipe", + }); + let [stdout, stderr, exit] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + expect({ exit, stderr }).toEqual({ exit: 0, stderr: "" }); + expect(JSON.parse(stdout)).toEqual(expected); + }); +}); diff --git a/packages/planner-link/src/client.ts b/packages/planner-link/src/client.ts index aa0d90e46..b81a0a113 100644 --- a/packages/planner-link/src/client.ts +++ b/packages/planner-link/src/client.ts @@ -21,6 +21,7 @@ export const WIRE = { version: 1, maxMessageBytes: 2 * 1024 * 1024, maxNoteLength: 4_000, + malformedClose: 4400, } as const; export class PlannerLinkRefused extends Error { @@ -369,10 +370,31 @@ export function connectPlannerLink(options: PlannerLinkOptions): Promise { + if (released) return; let message = serverMessage(event.data); if (!message) { - socket.close(1008, "malformed-message"); + let closed = { code: WIRE.malformedClose, reason: "malformed-message" }; + socket.close(closed.code, closed.reason); + release(closed); return; } if (!attached) { @@ -387,20 +409,6 @@ export function connectPlannerLink(options: PlannerLinkOptions): Promise { - for (let turn of turns.values()) { - settleCalls(turn, LINK_CLOSED); - turn.controller.abort(); - } - turns.clear(); - for (let [request, resolve] of asks) { - resolve({ type: "input-result", request, status: "cancelled", message: LINK_CLOSED }); - } - asks.clear(); - attaching.reject( - new Error(`The Planner link closed before attaching (${event.code} ${event.reason}).`), - ); - closing.resolve({ code: event.code, reason: event.reason }); - }); + socket.addEventListener("close", event => release({ code: event.code, reason: event.reason })); return attaching.promise; } diff --git a/packages/planner-link/src/protocol.test.ts b/packages/planner-link/src/protocol.test.ts index 0864ecf23..21415b1ca 100644 --- a/packages/planner-link/src/protocol.test.ts +++ b/packages/planner-link/src/protocol.test.ts @@ -140,6 +140,7 @@ describe("Planner link messages", () => { version: PLANNER_LINK_VERSION, maxMessageBytes: MAX_LINK_MESSAGE_BYTES, maxNoteLength: MAX_LINK_NOTE_LENGTH, + malformedClose: 4400, }); let source = await Bun.file(new URL("./client.ts", import.meta.url)).text(); let imports = [...source.matchAll(/^import\s+(type\s+)?/gm)]; From 0b8923afd61378574fbf4e2fe5306c1125ee38ac Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 10 Oct 2026 19:25:07 -0700 Subject: [PATCH 04/10] Stop host tools and withdraw Decisions when a remote Planner turn or start fails A remote turn that ended while one of Chopin's host tools still ran left that tool waiting: the harness waits for outstanding host tools before it settles the turn, and they ran only under the caller's signal. A link that dropped during an `ask` therefore left the stream pending, Chat busy, and the Decision open. Each remote turn now carries its own host tool signal, which the session passes to the Planner's tools beside the caller's and the turn aborts whenever it ends: on link loss, protocol violation, detach, the client's turn-end, or destroy. The tool withdraws its cards through the normal cancellation path, and a dropped link still ends the turn with the disconnect error rather than as a stop. A session refused with start-failed or not started in time was dropped without withdrawing questions the client asked meanwhile. A failed start now closes the session like destroy, so those Decisions are withdrawn and the client's ask settles as cancelled. Assistant-model: Claude Opus 5.5 (fast) Assistant-workflow: hosted-planner-slices (run 8cf1bc0a) repair (worker subagent) Assistant-verification: bun run types passed: every workspace package and e2e Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test passed: 4639 pass, 4 skip, 0 fail across 673 files Assistant-verification: bun run ci passed: dprint, oxlint (31 warnings, 0 errors), tokens, design contract, Impeccable Assistant-verification: new link tests failed before the fix (timeouts, open Decision) and pass after it Co-authored-by: Alex Lavaee --- apps/server/src/harness/agents.ts | 19 ++- apps/server/src/harness/remote/link.test.ts | 123 ++++++++++++++++++++ apps/server/src/harness/remote/link.ts | 10 +- apps/server/src/harness/remote/registry.ts | 6 + apps/server/src/harness/session.ts | 11 +- docs/hosted-agent.md | 10 +- docs/planner-link.md | 12 +- 7 files changed, 177 insertions(+), 14 deletions(-) diff --git a/apps/server/src/harness/agents.ts b/apps/server/src/harness/agents.ts index ecea371cd..a15e1ea34 100644 --- a/apps/server/src/harness/agents.ts +++ b/apps/server/src/harness/agents.ts @@ -46,10 +46,18 @@ type PlannerCallOptions = { githubTools: ToolSet; instructions: string; model?: string; + /** + * Also stops the host tools, beside the caller's signal: a remote Planner's turn can + * end, or its link drop, while one of them still runs. + */ + toolSignal?: AbortSignal; }; -/** Tells the chat when a host tool's own execution ends; the harness reports its result only at step end. */ -function reporting(tools: ToolSet, room: DocumentRoom): ToolSet { +/** + * Tells the chat when a host tool's own execution ends; the harness reports its result + * only at step end. A `toolSignal` stops the tool as the caller's signal does. + */ +function reporting(tools: ToolSet, room: DocumentRoom, toolSignal?: AbortSignal): ToolSet { return Object.fromEntries( Object.entries(tools).map(([name, original]) => { let execute = original.execute; @@ -58,8 +66,12 @@ function reporting(tools: ToolSet, room: DocumentRoom): ToolSet { ...original, execute: async (input, options) => { let output: Awaited>; + let stops = [options.abortSignal, toolSignal].filter(signal => signal !== undefined); try { - output = await execute(input, options); + output = await execute( + input, + toolSignal ? { ...options, abortSignal: AbortSignal.any(stops) } : options, + ); } catch (error) { room.plan.chat?.toolFinished?.(options.toolCallId, error, false); throw error; @@ -98,6 +110,7 @@ export function createPlannerAgent( tools: reporting( scopedJobTools({ ...rest.tools, ...options.githubTools }, options.room), options.room, + options.toolSignal, ), toolsContext: Object.fromEntries(names.map(name => [name, { room: options.room, diff --git a/apps/server/src/harness/remote/link.test.ts b/apps/server/src/harness/remote/link.test.ts index 674ef0627..93d15c389 100644 --- a/apps/server/src/harness/remote/link.test.ts +++ b/apps/server/src/harness/remote/link.test.ts @@ -7,7 +7,9 @@ import { PlannerLinkRefused } from "@chopin/planner-link/client"; import { GitHubError } from "../../github/client"; import * as Store from "../../questions/store"; +import { createPlannerAgent } from "../agents"; import { createHumanInput } from "../atomic/human-input"; +import { openPlannerSession } from "../session"; import { untilUnpaused } from "../atomic/full"; import { hostInputRoom } from "../../testing/decisions"; import { @@ -23,8 +25,10 @@ import { } from "../../testing/planner-link"; import { createRemoteAdapter } from "./adapter"; import { admitPlannerLink } from "./endpoint"; +import { remotePlanner } from "./registry"; import type { PlannerLinkClient, PlannerLinkHandlers } from "@chopin/planner-link/client"; +import type { ActiveOwnerBinding } from "../../agent/active-owner"; import type { RemotePlanner } from "./registry"; let cleanups: Array<() => unknown> = []; @@ -661,4 +665,123 @@ describe("Planner link questions", () => { }); expect(Store.outstanding(f.plan.questions)).toHaveLength(0); }); + + it("withdraws a question asked while a session starts when the start fails or times out", async () => { + let f = await hostInputRoom(); + cleanups.push(f.close); + let asks: Promise<{ status: string }>[] = []; + let client: PlannerLinkClient | undefined; + let linked = await setup({ + async start(message) { + asks.push(client!.ask(message.session, { method: "confirm", title: "Ship?", message: "" })); + await f.cards(1); + if (message.session === "refused") throw new Error("busy elsewhere"); + await Bun.sleep(1_000); + }, + }); + client = linked.client; + let planner: RemotePlanner = { + channelId: f.room.id, + isolated: false, + humanInput: report => createHumanInput(f.room, undefined, undefined, report), + }; + let harness = adapterFor(linked.server, planner); + let start = (sessionId: string) => + harness.doStart({ sessionId, sandboxSession, sessionWorkDir: "/tmp" }); + await expect(start("refused")).rejects.toThrow("refused the session: busy elsewhere"); + expect(await asks[0]).toMatchObject({ status: "cancelled" }); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + await expect(start("silent")).rejects.toThrow("did not start the session"); + expect(await asks[1]).toMatchObject({ status: "cancelled" }); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + expect([...f.plan.records.values()].map(record => record.status)) + .toEqual(["cancelled", "cancelled"]); + }); + + /** Opens a real remote Planner session over the link whose turn waits in a host `ask`. */ + async function askingTurn(ending: "detach" | "violation" | "destroy" | "turn-end") { + let f = await hostInputRoom(); + cleanups.push(f.close); + let { server, client } = await setup({ + async turn(context) { + let asked = context.callHostTool("ask", { + revision: f.plan.revision, + questions: [{ + header: "Store", + question: "Which store?", + options: [{ label: "Postgres", description: "durable" }], + multiple: false, + blocks: [], + }], + }); + if (ending !== "turn-end") return void await asked; + await f.cards(1); + context.end("failed", "The client gave up."); + }, + }); + let agent = createPlannerAgent( + createRemoteAdapter(sessionId => ({ + link: server.links.at(-1)!, + planner: remotePlanner(sessionId)!, + })), + ); + let toolSignal: AbortSignal | undefined; + let owner = { + channelId: f.room.id, + token: "test-token", + repository: { id: "R_test", owner: "org", name: "repo", defaultBranch: "main" }, + signal: new AbortController().signal, + currentToken: () => "test-token", + revalidate: async () => true, + release() {}, + } as unknown as ActiveOwnerBinding; + let opened = await openPlannerSession(owner, { + room: f.room, + repository: owner.repository, + instructions: "Plan.", + harness: "remote", + }, { + githubTools: async () => ({ ok: true, value: {} }), + createSandbox: async () => sandboxSession, + registerCredential: () => () => {}, + agent: { + createSession: options => agent.createSession(options), + stream: call => { + toolSignal = (call.options as { toolSignal?: AbortSignal }).toolSignal; + return agent.stream(call); + }, + } as typeof agent, + }); + if (!opened.ok) throw new Error("Planner unavailable"); + let session = opened.value; + cleanups.push(() => session.destroy()); + let caller = new AbortController(); + let parts: { type: string; error?: unknown }[] = []; + let streaming = (async () => { + let result = await session.stream("ask", caller.signal); + for await (let part of result.fullStream) parts.push(part); + })(); + let [card] = await f.cards(1); + if (ending === "detach") await client.detach(); + if (ending === "violation") client.send({ type: "attach", version: 1, document: "again" }); + if (ending === "destroy") void session.destroy(); + await streaming; + expect(parts.some(part => part.type === "abort")).toBe(false); + expect(caller.signal.aborted).toBe(false); + expect(toolSignal?.aborted).toBe(true); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + expect(f.plan.records.get(card!.id)).toMatchObject({ status: "cancelled" }); + return parts.filter(part => part.type === "error").map(part => String(part.error)); + } + + it("stops a host ask, withdraws its Decision and fails the turn when the link drops mid-tool", async () => { + let disconnected = ["Error: The attached Planner disconnected during the turn."]; + expect(await askingTurn("detach")).toEqual(disconnected); + expect(await askingTurn("violation")).toEqual(disconnected); + }); + + it("stops a host ask and withdraws its Decision when its turn ends before the tool does", async () => { + expect(await askingTurn("turn-end")).toEqual(["Error: The client gave up."]); + await askingTurn("destroy"); + }); }); diff --git a/apps/server/src/harness/remote/link.ts b/apps/server/src/harness/remote/link.ts index f6de9972e..0d165ad66 100644 --- a/apps/server/src/harness/remote/link.ts +++ b/apps/server/src/harness/remote/link.ts @@ -148,6 +148,8 @@ type Turn = { stopped: boolean; ended: boolean; settle: PromiseWithResolvers; + /** Aborted when the turn ends, so the server's host tools still running for it stop too. */ + hostTools?: AbortController; grace?: ReturnType; listeners: (() => void)[]; }; @@ -385,6 +387,7 @@ class RemoteSession { clearTimeout(turn.grace); for (let release of turn.listeners.splice(0)) release(); if (this.#current === turn) this.#current = undefined; + turn.hostTools?.abort(error ?? new Error("The turn has ended.")); if (error) turn.settle.reject(error); else turn.settle.resolve(); } @@ -442,6 +445,8 @@ class RemoteSession { await this.#port.revalidate(); this.#ready(); let hostNames = options.tools.map(tool => tool.name); + let hostTools = this.planner.hostTools; + this.planner.hostTools = undefined; let turn: Turn = { id: crypto.randomUUID(), emit: options.emit, @@ -453,6 +458,7 @@ class RemoteSession { stopped: false, ended: false, settle: Promise.withResolvers(), + ...(hostTools ? { hostTools } : {}), listeners: [], }; let sent = this.#port.send({ @@ -580,8 +586,8 @@ export class PlannerLink { try { return await session.start(); } catch (error) { - this.#sessions.delete(sessionId); - this.#send({ type: "destroy", session: sessionId }); + // Withdraws any question the client asked while the session was starting. + await session.close(); throw error; } } diff --git a/apps/server/src/harness/remote/registry.ts b/apps/server/src/harness/remote/registry.ts index 2cff6cc07..a50ed83eb 100644 --- a/apps/server/src/harness/remote/registry.ts +++ b/apps/server/src/harness/remote/registry.ts @@ -17,6 +17,12 @@ export type RemotePlanner = humanInput?: (report: (status: HumanInputStatus) => void) => HostInput; /** Stops or resumes the client's workflow runs; set while the session is open. */ control?: (action: "stop" | "resume", runId?: string) => Promise; + /** + * Stops the server's host tools for the next turn. That turn aborts it when it + * ends without its caller stopping it, so a host tool still running, such as an + * open Decision, ends with the turn instead of holding it open. + */ + hostTools?: AbortController; }; let planners = new Map(); diff --git a/apps/server/src/harness/session.ts b/apps/server/src/harness/session.ts index 54f2fb200..b247c5483 100644 --- a/apps/server/src/harness/session.ts +++ b/apps/server/src/harness/session.ts @@ -274,8 +274,11 @@ export async function openPlannerSession( return planner?.activeTools ?? remote?.activeTools ?? fixedTools; }, ...runControl, - stream: (prompt, abortSignal) => - agent.stream({ + stream: (prompt, abortSignal) => { + // The remote turn aborts it when the turn ends or the link drops mid-tool. + let hostTools = remote ? new AbortController() : undefined; + if (remote) remote.hostTools = hostTools; + return agent.stream({ session: active, prompt, abortSignal, @@ -284,8 +287,10 @@ export async function openPlannerSession( instructions, owner, githubTools: tools.value, + ...(hostTools ? { toolSignal: hostTools.signal } : {}), }, - }), + }); + }, destroy: () => stopped ??= (async () => { try { diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index 7dad77fc6..d800024c0 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -304,9 +304,13 @@ reports them as provider-executed `tool-call` and `tool-result` parts. An undeclared tool fails the turn as a tool boundary failure. Background job sessions start in `isolated` mode, where the client may offer no tools of its own and ask nothing. A turn ends with `turn-end`. An abort waits briefly for the -client's `turn-end`, then ends the turn anyway. A link that closes mid-turn ends -the turn with an error, its open questions are withdrawn, and runs it reported -are shown stopped; nothing waits on a client that has gone. +client's `turn-end`, then ends the turn anyway. A turn that ends while one of +its host tools still runs, such as an `ask` waiting on Decisions, stops that tool +and withdraws the cards it opened. A link that closes mid-turn ends the turn +with an error rather than as a stop, its open questions are withdrawn, and runs +it reported are shown stopped; nothing waits on a client that has gone. A +session the client refuses or does not start in time is destroyed, and any +question it asked while starting is withdrawn. **Questions.** An `input-request` carries one Atomic `HostInput` call (`questionnaire`, `confirm`, `select`, `input`, or `editor`) and becomes Decisions diff --git a/docs/planner-link.md b/docs/planner-link.md index 7df65b448..8b07cf102 100644 --- a/docs/planner-link.md +++ b/docs/planner-link.md @@ -115,7 +115,10 @@ check leaves the link open. - `start` opens a Planner session. `mode` is `planner` for a Planner turn session or `isolated` for a background job's session, which may offer no - tools of its own and gets no Decisions. Answer within **10 seconds**. + tools of its own and gets no Decisions. Answer within **10 seconds**. A + session refused with `start-failed` or not answered in time gets `destroy`, + and any question asked for it meanwhile is withdrawn and answered + `cancelled`. - `turn` runs one turn. `tools` lists at most 256 of Chopin's host tools as `{ "name": "", "description?": "", "inputSchema?": {json schema} }`; `description` may be 64,000 characters. `instructions` is the Planner's @@ -169,7 +172,9 @@ check leaves the link open. running the tool again. A name outside the turn's `tools` is answered with an error result. - `turn-end` ends the turn: `finished`, `failed` (with `message` as the error - shown), or `aborted`. Every turn needs exactly one. + shown), or `aborted`. Every turn needs exactly one. A host tool still running + in the server when the turn ends is stopped, and a Decision it opened is + withdrawn. - `input-request` and `input-cancel` ask and withdraw questions. - `runs` reports the session's workflow runs whenever they change. Report them before `turn-end` so the server keeps the session while runs are live. Reports @@ -316,7 +321,8 @@ ids. When the link closes, the server shows the reported live runs as stopped. `protocol` covers a message before `attach` is answered, a second `attach`, and a repeated `input-request` id. When the link closes for any reason, the server -ends a running turn with an error, withdraws open questions, and forgets the +ends a running turn with an error, stops the turn's host tools still running, +withdraws open questions and the Decisions those tools opened, and forgets the link; reattaching starts new sessions. A server never replays an interrupted turn. A client that receives a frame it cannot parse should close with 4400 and reason From a25c93f6327cabc7b2d927dcf0db1d058c6267b3 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 10 Oct 2026 19:33:17 -0700 Subject: [PATCH 05/10] Refuse reused input-request and tool call ids after they settle A Planner link forgot an input-request id once its question settled, and never recorded an id it answered failed at once, so a client could reuse a completed id and get a second result. The link now remembers every open id and the last 1,024 it received, and a reuse it remembers is a protocol violation that closes the link with 1008. Tool call ids had the same gap within a turn: a host call refused for an unavailable tool did not record its id, and an own tool-call part could repeat its id. Both now fail the turn as a repeated call. Assistant-model: Claude Opus 5.5 (fast) Assistant-workflow: hosted-planner-slices (run 8cf1bc0a) repair (worker subagent) Assistant-verification: bun run types passed (exit 0) Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test passed (4642 pass, 4 skip, 0 fail) Assistant-verification: bun run ci passed (exit 0, 0 lint errors) Co-authored-by: Alex Lavaee --- apps/server/src/harness/remote/link.test.ts | 108 ++++++++++++++++++++ apps/server/src/harness/remote/link.ts | 29 ++++-- docs/planner-link.md | 25 +++-- 3 files changed, 145 insertions(+), 17 deletions(-) diff --git a/apps/server/src/harness/remote/link.test.ts b/apps/server/src/harness/remote/link.test.ts index 93d15c389..2120217cb 100644 --- a/apps/server/src/harness/remote/link.test.ts +++ b/apps/server/src/harness/remote/link.test.ts @@ -508,6 +508,56 @@ describe("Planner link protocol", () => { expect(readPlanRuns).toBe(1); }); + it("fails a turn that reuses a refused host call id or its own finished tool call id", async () => { + let { server, channel } = await setup(); + let harness = adapterFor(server, { channelId: channel.id, isolated: false }); + async function reuse( + script: ( + send: (message: object) => void, + raw: Awaited>, + ) => Promise, + ) { + let { raw, started } = await rawAttached(server, channel.id); + let driving = (async () => { + let session = await started; + let turn = (await raw.next("turn")).turn; + await script(message => raw.send({ session, turn, ...message }), raw); + raw.send({ type: "turn-end", session, turn, status: "finished" }); + await raw.next("abort"); + })(); + let outcome = await streamTurn(harness, "reuse"); + await driving; + return outcome.errors.join("\n"); + } + readPlanRuns = 0; + let refused = await reuse(async (send, raw) => { + let call = { type: "host-tool-call", toolCallId: "refused", input: "{}" }; + send({ ...call, toolName: "write_plan" }); + expect(await raw.next("host-tool-result")).toMatchObject({ + toolCallId: "refused", + output: { error: "write_plan is not available in this turn." }, + isError: true, + }); + send({ ...call, toolName: "read_plan" }); + }); + expect(refused).toContain("Tool call refused was repeated"); + expect(readPlanRuns).toBe(0); + let own = await reuse(async send => { + let call = { toolCallId: "own", toolName: "bash" }; + send({ type: "tools", names: ["bash"] }); + send({ + type: "part", + part: { type: "tool-call", ...call, input: "{}", providerExecuted: true }, + }); + send({ type: "part", part: { type: "tool-result", ...call, result: "done" } }); + send({ + type: "part", + part: { type: "tool-call", ...call, input: "{}", providerExecuted: true }, + }); + }); + expect(own).toContain("Tool call own was repeated"); + }); + it("answers a host call for a session that has ended with an error result", async () => { let { server, channel } = await setup(); let { raw, started } = await rawAttached(server, channel.id); @@ -666,6 +716,64 @@ describe("Planner link questions", () => { expect(Store.outstanding(f.plan.questions)).toHaveLength(0); }); + it("closes the link when an answered input-request id is reused", async () => { + let f = await hostInputRoom(); + cleanups.push(f.close); + let { server, channel } = await setup(); + let { raw, started } = await rawAttached(server, channel.id); + let opened = await adapterFor(server, { + channelId: channel.id, + isolated: false, + humanInput: report => createHumanInput(f.room, undefined, undefined, report), + }).doStart({ sessionId: crypto.randomUUID(), sandboxSession, sessionWorkDir: "/tmp" }); + cleanups.push(() => opened.doDestroy()); + let request = { + type: "input-request", + request: "asked", + session: await started, + input: { method: "confirm", title: "Ship?", message: "" }, + }; + raw.send(request); + let [card] = await f.cards(1); + await f.answer(card!.id, [0]); + expect(await raw.next("input-result")).toMatchObject({ request: "asked", status: "answered" }); + raw.send(request); + expect(await raw.next("error")).toMatchObject({ + code: "protocol", + message: "Input request asked was repeated.", + }); + expect((await raw.closed).code).toBe(LINK_CLOSE.malformed); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + expect(f.plan.records.size).toBe(1); + }); + + it("refuses a failed input-request id again until 1024 later ids have been used", async () => { + let { server, channel } = await setup(); + let raw = await rawLink(server.url); + raw.send({ type: "attach", version: 1, document: channel.id }); + await raw.next("attached"); + let ask = (request: string) => + raw.send({ + type: "input-request", + request, + session: "none", + input: { method: "confirm", title: "Ship?", message: "" }, + }); + let ids = ["first", ...Array.from({ length: 1_024 }, (_, index) => `later-${index}`)]; + for (let id of ids) ask(id); + for (let id of ids) { + expect(await raw.next("input-result")).toMatchObject({ request: id, status: "failed" }); + } + ask("first"); + expect(await raw.next("input-result")).toMatchObject({ request: "first", status: "failed" }); + ask("later-1"); + expect(await raw.next("error")).toMatchObject({ + code: "protocol", + message: "Input request later-1 was repeated.", + }); + expect((await raw.closed).code).toBe(LINK_CLOSE.malformed); + }); + it("withdraws a question asked while a session starts when the start fails or times out", async () => { let f = await hostInputRoom(); cleanups.push(f.close); diff --git a/apps/server/src/harness/remote/link.ts b/apps/server/src/harness/remote/link.ts index 0d165ad66..80679f2a2 100644 --- a/apps/server/src/harness/remote/link.ts +++ b/apps/server/src/harness/remote/link.ts @@ -34,6 +34,8 @@ import type { RemotePlanner } from "./registry"; const OPERATION_TIMEOUT_MS = 10_000; const REVALIDATE_EVERY_MS = 5 * 60_000; const MAX_OPEN_INPUTS = 16; +/** How many settled `input-request` ids a link remembers to refuse their reuse. */ +const REMEMBERED_INPUTS = 1_024; const LIVE_RUN = new Set(["running", "waiting", "paused"]); export type LinkSocket = { @@ -142,7 +144,7 @@ type Turn = { hostNames: Set; own: Set; ownCalls: Set; - /** Every host call id the turn has used, so none runs twice. */ + /** Every host call id the turn has used, refused ones too, so none runs twice. */ hostIds: Set; hostCalls: Map; stopped: boolean; @@ -285,13 +287,13 @@ class RemoteSession { } #hostCall(turn: Turn, message: ClientMessageOf<"host-tool-call">): void { - if (!turn.hostNames.has(message.toolName)) { - return this.#refuseHostCall(turn, message, "unavailable"); - } if (turn.hostIds.has(message.toolCallId) || turn.ownCalls.has(message.toolCallId)) { - return this.#fail(turn, new Error(`Tool call ${message.toolCallId} was repeated.`)); + return this.#repeated(turn, message.toolCallId); } turn.hostIds.add(message.toolCallId); + if (!turn.hostNames.has(message.toolName)) { + return this.#refuseHostCall(turn, message, "unavailable"); + } turn.hostCalls.set(message.toolCallId, message.toolName); turn.emit({ type: "tool-call", @@ -320,6 +322,10 @@ class RemoteSession { }); } + #repeated(turn: Turn, toolCallId: string): void { + this.#fail(turn, new Error(`Tool call ${toolCallId} was repeated.`)); + } + #part(turn: Turn, part: LinkPart): void { let boundary = (name: string) => this.#fail(turn, new ToolBoundaryError(`Planner tool boundary failure: ${name}`)); @@ -332,8 +338,9 @@ class RemoteSession { if (!turn.own.has(part.toolName)) return boundary(part.toolName); return turn.emit({ ...part, providerExecuted: true, dynamic: true }); case "tool-call": - if (!turn.own.has(part.toolName) || turn.hostIds.has(part.toolCallId)) { - return boundary(part.toolName); + if (!turn.own.has(part.toolName)) return boundary(part.toolName); + if (turn.hostIds.has(part.toolCallId) || turn.ownCalls.has(part.toolCallId)) { + return this.#repeated(turn, part.toolCallId); } turn.ownCalls.add(part.toolCallId); return turn.emit({ ...part, dynamic: true }); @@ -547,6 +554,8 @@ export class PlannerLink { #forget: (() => void) | undefined; #sessions = new Map(); #inputs = new Map(); + /** Recent `input-request` ids, oldest first, so a settled one cannot be reused. */ + #inputIds = new Set(); #controls = new Map>(); #watch: ReturnType | undefined; #port: Port; @@ -770,9 +779,13 @@ export class PlannerLink { ...(value === undefined ? {} : { value: value as never }), ...(text === undefined ? {} : { message: text }), }); - if (this.#inputs.has(message.request)) { + if (this.#inputs.has(message.request) || this.#inputIds.has(message.request)) { return this.#violate("protocol", `Input request ${message.request} was repeated.`); } + this.#inputIds.add(message.request); + if (this.#inputIds.size > REMEMBERED_INPUTS) { + this.#inputIds.delete(this.#inputIds.values().next().value!); + } let session = this.#sessions.get(message.session); let humanInput = session?.planner.humanInput; if (!session || !humanInput) { diff --git a/docs/planner-link.md b/docs/planner-link.md index 8b07cf102..6d8b9f054 100644 --- a/docs/planner-link.md +++ b/docs/planner-link.md @@ -168,14 +168,19 @@ check leaves the link open. - `part` streams the turn (see [Stream parts](#stream-parts)). - `host-tool-call` runs one of the turn's host tools in the server; `input` is its arguments as a JSON string. A `toolCallId` may be used once per turn, - including after its call has finished; reusing one fails the turn without - running the tool again. A name outside the turn's `tools` is answered with an - error result. + whether by a `host-tool-call` or a `tool-call` part, including after its call + has finished or was answered with an error result; reusing one fails the turn + without running the tool again. A name outside the turn's `tools` is answered + with an error result. - `turn-end` ends the turn: `finished`, `failed` (with `message` as the error shown), or `aborted`. Every turn needs exactly one. A host tool still running in the server when the turn ends is stopped, and a Decision it opened is withdrawn. -- `input-request` and `input-cancel` ask and withdraw questions. +- `input-request` and `input-cancel` ask and withdraw questions. A `request` id + may not repeat on the link, even after its question has its `input-result`. + The server remembers every open id and the last 1,024 ids it received; a + remembered id sent again is a `protocol` violation that closes the link. Use + fresh ids, such as UUIDs. - `runs` reports the session's workflow runs whenever they change. Report them before `turn-end` so the server keeps the session while runs are live. Reports only change run cards and Chat notices; they never start a Planner turn. @@ -212,7 +217,8 @@ fields: - `tool-call` and `tool-result` parts are for the client's own declared tools only, and `tool-call` must say `"providerExecuted": true`. A `tool-result` must follow its own `tool-call`. Anything else fails the turn as a tool - boundary failure. Host tools are called with `host-tool-call`; the server + boundary failure, and a `tool-call` reusing a `toolCallId` fails it as a + repeated call. Host tools are called with `host-tool-call`; the server emits their call and result parts itself. - `tool-input-start` may name a host tool (its input streams before the `host-tool-call`) or a declared own tool. @@ -320,10 +326,11 @@ ids. When the link closes, the server shows the reported live runs as stopped. | 4426 | `refused` | `unsupported-version` | `protocol` covers a message before `attach` is answered, a second `attach`, and -a repeated `input-request` id. When the link closes for any reason, the server -ends a running turn with an error, stops the turn's host tools still running, -withdraws open questions and the Decisions those tools opened, and forgets the -link; reattaching starts new sessions. A server never replays an interrupted turn. +a repeated `input-request` id the server remembers. When the link closes for +any reason, the server ends a running turn with an error, stops the turn's host +tools still running, withdraws open questions and the Decisions those tools +opened, and forgets the link; reattaching starts new sessions. A server never +replays an interrupted turn. A client that receives a frame it cannot parse should close with 4400 and reason `malformed-message`. Node.js's `WebSocket` lets a caller close only with 1000 or From c5b857b68571b4a98be229dcf78c5c660e657f30 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 10 Oct 2026 20:48:49 -0700 Subject: [PATCH 06/10] Ship the Planner link package in the Docker image The image build copied each workspace's manifest by name and missed packages/planner-link, so `bun install --frozen-lockfile` refused the pruned checkout and the image did not build. Copy its manifest and its production node_modules like the other server workspaces. Assistant-model: Claude Opus 5.5 (fast) Assistant-workflow: inline Assistant-verification: docker build passed: full image built locally from this commit Assistant-verification: image smoke passed: `@chopin/planner-link`, its client and apps/server harness/remote/endpoint.ts import inside the runtime image Assistant-verification: bun run ci passed Co-authored-by: Alex Lavaee --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index b4abd8471..4889946b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ COPY packages/draft/package.json ./packages/draft/package.json COPY packages/editor/package.json ./packages/editor/package.json COPY packages/experiment/package.json ./packages/experiment/package.json COPY packages/icons/package.json ./packages/icons/package.json +COPY packages/planner-link/package.json ./packages/planner-link/package.json COPY packages/protocol/package.json ./packages/protocol/package.json COPY packages/question/package.json ./packages/question/package.json COPY packages/viewport/package.json ./packages/viewport/package.json @@ -49,6 +50,7 @@ COPY --from=production-dependencies --chown=bun:bun /app/apps/server/node_module COPY --from=production-dependencies --chown=bun:bun /app/packages/dialect/node_modules ./packages/dialect/node_modules COPY --from=production-dependencies --chown=bun:bun /app/packages/draft/node_modules ./packages/draft/node_modules COPY --from=production-dependencies --chown=bun:bun /app/packages/experiment/node_modules ./packages/experiment/node_modules +COPY --from=production-dependencies --chown=bun:bun /app/packages/planner-link/node_modules ./packages/planner-link/node_modules COPY --from=production-dependencies --chown=bun:bun /app/packages/question/node_modules ./packages/question/node_modules COPY --chown=bun:bun package.json bun.lock ./ COPY --chown=bun:bun apps/server ./apps/server From 221cc21f1a86b5a70036132ab2ffd87d9004702e Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 10 Oct 2026 19:49:39 -0700 Subject: [PATCH 07/10] Make attaching the remote Planner own its document and refuse unattached requests Under HARNESS=remote, attaching a client now makes its account the document's Planner owner through the same generation-guarded claim every owner takes. The owner record names the account's live browser login, since Chopin's repository tools run with that login's GitHub App token and never with the link's bearer; an account without one is refused with sign-in-required (4401). Attaching needs the eligibility every Planner owner already needs, instance admission plus push or administration access, rather than the design's pull access, and the periodic recheck now holds a link to that too. A document has one Planner. Another attach while one is attached is refused with planner-attached (4409), naming the holder's login. A detach or dropped socket keeps the ownership for PLANNER_ATTACH_GRACE_MS (two minutes by default) so the same account can reattach under the same generation; then it is cleared under that generation. Attachments live in the process, and startup already clears every owner reference. Members, MCP callers and research requests no longer claim ownership under this harness. An `@chopin` message or invoke_planner call for a document with no attached client is refused with planner-not-attached before anything is posted or queued; the message names the document URL and says to connect a Planner client to it from a checkout of the repository. The browser shows that text in full on the request, and MCP returns it beside the code. With a client attached, both arrive as turns on its link, and Stop and Resume Planner reach it. Other harnesses are unchanged. Assistant-model: Claude Opus 5.5 (fast) Assistant-workflow: hosted-planner-slices Assistant-duration: 13m converged, estimated unmeasured Assistant-verification: bun run types passed: every workspace package and e2e Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test passed: 4657 pass, 0 fail Assistant-verification: bun run ci passed: dprint, oxlint (0 errors), tokens, design contract, Impeccable Assistant-verification: PostgreSQL contract passed: bun test apps/server/src/storage/postgres against a disposable postgres:17-alpine on port 55432, 83 pass, 0 fail Assistant-verification: mutation check passed: disabling the planner-not-attached refusal in processSend fails the new chat service test Co-authored-by: Alex Lavaee --- AGENTS.md | 7 +- apps/server/src/chat/remote-planner.test.ts | 227 +++++++++++++++++ apps/server/src/chat/service.ts | 21 +- apps/server/src/config.test.ts | 12 + apps/server/src/config.ts | 17 ++ .../conversation-plan/accepted-research.ts | 9 +- apps/server/src/harness/remote/attachments.ts | 240 ++++++++++++++++++ apps/server/src/harness/remote/endpoint.ts | 47 +++- apps/server/src/harness/remote/link.test.ts | 152 ++++++++++- apps/server/src/harness/remote/link.ts | 32 ++- apps/server/src/harness/remote/registry.ts | 15 +- apps/server/src/main.ts | 30 ++- apps/server/src/mcp.ts | 43 +++- apps/server/src/mcp/hosted.test.ts | 24 ++ apps/server/src/mcp/hosted.ts | 19 +- apps/server/src/mcp/invoke.test.ts | 20 ++ apps/server/src/storage/contract.ts | 2 + .../storage/planner-attachment-contract.ts | 168 ++++++++++++ apps/server/src/testing/config.ts | 1 + apps/server/src/testing/planner-link.ts | 117 +++++++-- apps/server/src/wire.ts | 11 +- apps/web/src/chat/references.test.ts | 7 + apps/web/src/chat/references.ts | 1 + apps/web/src/wire.ts | 16 +- docs/architecture.md | 7 +- docs/hosted-agent.md | 67 +++-- docs/local-agent-mcp.md | 21 +- docs/planner-link.md | 102 ++++++-- docs/self-hosting.md | 22 +- packages/planner-link/src/client.ts | 2 +- packages/planner-link/src/protocol.ts | 3 + packages/protocol/index.d.ts | 6 + 32 files changed, 1319 insertions(+), 149 deletions(-) create mode 100644 apps/server/src/chat/remote-planner.test.ts create mode 100644 apps/server/src/harness/remote/attachments.ts create mode 100644 apps/server/src/storage/planner-attachment-contract.ts diff --git a/AGENTS.md b/AGENTS.md index 7347d1556..87d7585ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,9 +138,12 @@ collaborative state and external implementation runs are durable. summary and research workers stay isolated. There is no flag for this; the harness is the choice. - **`HARNESS=remote` moves the model, not the authority.** Turns run in a client - attached at `/planner-link` with a GitHub bearer and pull access; Chopin's + attached at `/planner-link` with a GitHub bearer and push access; Chopin's host tools, Decisions, and room writes still run in the server. The attached - client sees what a turn sees and steers its document writes. See + client sees what a turn sees and steers its document writes. Attaching makes + the account's live browser login the document's Planner owner, one client per + document; nothing else claims ownership, and Planner requests to a document + without one fail with `planner-not-attached`. See [Hosted agent](docs/hosted-agent.md#remote-planner). - **Repository node IDs are authoritative.** Owner and repository names resolve GitHub requests but never replace the stored node identity. diff --git a/apps/server/src/chat/remote-planner.test.ts b/apps/server/src/chat/remote-planner.test.ts new file mode 100644 index 000000000..1104a16ca --- /dev/null +++ b/apps/server/src/chat/remote-planner.test.ts @@ -0,0 +1,227 @@ +import { afterEach, expect, test } from "bun:test"; + +import { ActiveOwnerBindings } from "../agent/active-owner"; +import { createPlannerAgent } from "../harness/agents"; +import { createRemoteAdapter } from "../harness/remote/adapter"; +import { notAttachedMessage } from "../harness/remote/attachments"; +import { openPlannerSession } from "../harness/session"; +import * as Plan from "../plan/service"; +import { configured } from "../testing/config"; +import { attach, finishTurn, linkAuth, linkServer, scriptedPlanner } from "../testing/planner-link"; +import * as Chat from "./service"; + +import type { Server } from "bun"; +import type { PlannerLinkClient } from "@chopin/planner-link/client"; +import type { HostedAuth } from "../auth/routes"; +import type { Socket, SocketData } from "../wire"; + +let cleanups: Array<() => unknown> = []; +afterEach(async () => { + for (let cleanup of cleanups.splice(0).toReversed()) await cleanup(); +}); + +const REMOTE = { HARNESS: "remote", BACKGROUND_JOBS: "off" }; +const RIVAL = { id: "U_rival", login: "rival" }; + +function card(status: "running" | "paused") { + return { + id: "run-1", + name: "plan-review", + status, + started: 1, + updated: 1, + stages: [], + waiting: 0, + }; +} + +/** + * A document's Chat under `HARNESS=remote`, served as `main.ts` serves it, + * with a real Planner link server. The rival sends from a browser with a + * login of their own; only the attached member may own the Planner. + */ +async function remoteRoom() { + let { auth, storage, channel, logins } = await linkAuth(); + let server = linkServer(auth); + cleanups.push(server.stop); + let lease = (await storage.leases.acquire("writer", "test", 60_000))!; + let events: Array<{ kind: string; [key: string]: unknown }> = []; + let publisher = { + publish(_topic: string, message: string) { + events.push(JSON.parse(message)); + }, + } as unknown as Server; + let plan = await Plan.open(channel.id, { + storage, + lease: () => lease, + fatal: error => { + throw error; + }, + }, publisher); + let owners = new ActiveOwnerBindings(auth as unknown as HostedAuth); + let url = `https://chopin.test/documents/octo-org/score/${channel.slug}`; + let agent = createPlannerAgent(createRemoteAdapter()); + let context: Chat.Room = { + chat: plan.chat, + plan, + room: channel.id, + server: publisher, + auth: auth as unknown as HostedAuth, + repository: { id: "R_score", owner: "octo-org", name: "score", defaultBranch: "main" }, + config: configured(REMOTE), + claimantSessionId: logins.get("rival"), + plannerNotAttached: async () => + server.attachments.attached(channel.id) + ? undefined + : notAttachedMessage(url, "octo-org/score"), + activeOwner: () => owners.resolve(channel.id), + persist: () => Plan.persist(plan), + openPlannerSession: (binding, options) => + openPlannerSession(binding, options, { + githubTools: async () => ({ ok: true, value: {} }), + createSandbox: async () => + ({ + defaultWorkingDirectory: "/tmp", + run: async () => ({ exitCode: 0, stdout: "", stderr: "" }), + destroy: async () => {}, + }) as never, + registerCredential: () => () => {}, + agent, + }), + }; + cleanups.push(async () => { + owners.revokeAll(); + await Chat.close(plan.chat); + await Plan.close(plan); + }); + let replies: Array<{ kind: string; rid?: string; [key: string]: unknown }> = []; + let ws = { + data: { handle: RIVAL.login, principalId: RIVAL.id, sessionId: logins.get("rival") }, + send(text: string) { + replies.push(JSON.parse(text)); + }, + } as unknown as Socket; + let send = async (text: string, to: "planner" | "room" = "planner") => { + let rid = crypto.randomUUID(); + await Chat.send(context, ws, { + kind: "chat:send", + rid, + requestId: crypto.randomUUID(), + to, + text, + ts: 0, + } as never); + return replies.find(reply => reply.rid === rid); + }; + let owner = async () => + (await storage.channels.readAgent(channel.id, new Date()))?.agent?.ownerSessionId; + return { context, server, channel, logins, url, ws, send, owner, events }; +} + +test("with no attached Planner, @chopin and invoke_planner are refused with how to attach one, and nothing is posted or queued", async () => { + let room = await remoteRoom(); + let { context } = room; + let saves = 0; + let persist = context.persist; + context.persist = async chat => { + saves++; + await persist(chat); + }; + let message = "No Planner is attached to this document. To attach one, connect a Planner client to " + + `${room.url} over the Planner link from a session in a checkout of octo-org/score; ` + + "that session becomes this document's Planner."; + + expect(await room.send("@chopin draft the plan")).toMatchObject({ + kind: "session:error", + code: "planner-not-attached", + message, + }); + context.chat.busy = true; + expect(await room.send("@chopin and then this")).toMatchObject({ code: "planner-not-attached" }); + context.chat.busy = false; + expect(await Chat.invoke(context, RIVAL, "Draft the plan")).toBe("planner-not-attached"); + expect(context.chat.entries).toEqual([]); + expect(context.chat.waiting).toEqual([]); + expect(saves).toBe(0); + expect(await room.owner()).toBeUndefined(); + + expect(await room.send("Just talking", "room")).toMatchObject({ kind: "chat:send" }); + expect(context.chat.entries.map(entry => entry.text)).toEqual(["Just talking"]); +}); + +test("other harnesses keep claiming ownership for the sender as before", async () => { + let room = await remoteRoom(); + let { context } = room; + context.config = configured({}); + context.plannerNotAttached = undefined; + let started: string[] = []; + context.openPlannerSession = async () => ({ + ok: true, + value: { + stream: async (prompt: string) => { + started.push(prompt); + return { fullStream: (async function*() {})() }; + }, + destroy: async () => {}, + } as never, + }); + expect(await room.send("@chopin draft the plan")).toMatchObject({ kind: "chat:send" }); + await context.chat.running; + expect(started).toHaveLength(1); + expect(await room.owner()).toBe(room.logins.get("rival")); +}); + +test("an attached Planner receives @chopin and invoke_planner turns as their owner, and Stop and Resume reach it", async () => { + let room = await remoteRoom(); + let { context } = room; + let script = scriptedPlanner(); + let controls: Array<{ type: string; run?: string }> = []; + let client: PlannerLinkClient; + client = await attach(room.server.url, room.channel.id, { + ...script.handlers, + async turn(turn) { + script.turns.push(turn.message); + if (turn.message.prompt.includes("Run the review")) { + client.reportRuns(turn.message.session, { + active: ["run-1"], + paused: [], + cards: [card("running")], + }); + } + finishTurn(turn); + }, + control(message) { + controls.push({ type: message.type, run: message.run }); + client.reportRuns( + message.session, + message.type === "stop" + ? { active: [], paused: ["run-1"], cards: [card("paused")] } + : { active: ["run-1"], paused: [], cards: [card("running")] }, + ); + }, + }); + cleanups.push(() => client.detach()); + expect(await room.owner()).toBe(room.logins.get("member")); + + expect(await room.send("@chopin draft the plan")).toMatchObject({ kind: "chat:send" }); + await context.chat.running; + expect(await Chat.invoke(context, RIVAL, "Run the review")).toBeUndefined(); + await context.chat.running; + expect(script.turns.map(turn => turn.prompt)).toEqual([ + expect.stringContaining("draft the plan"), + expect.stringContaining("Run the review"), + ]); + expect(await room.owner()).toBe(room.logins.get("member")); + expect(context.chat.retained).toBeDefined(); + + await Chat.abort(context, room.ws); + await Bun.sleep(20); + await Chat.resume(context, room.ws); + expect(controls).toEqual([{ type: "stop", run: undefined }, { type: "resume", run: undefined }]); + expect(context.chat.entries.map(entry => entry.text)).toContain( + "@rival stopped the Planner and paused its workflows.", + ); + expect(context.chat.entries.map(entry => entry.text)).toContain( + "@rival resumed the Planner's workflows.", + ); +}); diff --git a/apps/server/src/chat/service.ts b/apps/server/src/chat/service.ts index f35714372..eb4e0f79d 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -533,6 +533,12 @@ export type Room = { */ claimantSessionId: string | undefined; repository: HostedRepository; + /** + * Under `HARNESS=remote`, how to attach a Planner when none is attached, or + * undefined while one is. Planner requests are refused before anything is + * posted or queued, and only an attachment takes Planner ownership. + */ + plannerNotAttached?: () => Promise; activeOwner?: () => Promise; persist: (chat?: () => Pick) => Promise; commitRoomMessage?: (entry: Wire.Entry) => Promise; @@ -596,6 +602,7 @@ export async function invoke( checkout?: string, ): Promise< | "planner-unavailable" + | "planner-not-attached" | "planner-owner-unavailable" | "planner-queue-full" | "checkout-unverified" @@ -606,13 +613,14 @@ export async function invoke( chat.pendingSends++; let post = async () => { if (!context.config.agent || chat.closed) return "planner-unavailable" as const; + if (await context.plannerNotAttached?.()) return "planner-not-attached" as const; let verified: string | undefined; if (checkout !== undefined && context.config.harness === "atomic") { verified = await verifiedCheckout(context.repository, checkout); if (!verified) return "checkout-unverified" as const; } try { - await resolveOwner(context.auth, context.repository, room, context.claimantSessionId); + await resolveOwner(context.auth, context.repository, room, claimant(context)); } catch { return "planner-owner-unavailable" as const; } @@ -774,6 +782,10 @@ async function processSend(context: Room, ws: Socket, msg: Request): return; } + let unattached = await context.plannerNotAttached?.(); + if (unattached) return fail(ws, msg.rid, unattached, "planner-not-attached"); + if (chat.closed) return fail(ws, msg.rid, "chat is closed"); + if (chat.busy) { if (chat.waiting.length >= MAX_QUEUE) { say(chat, server, room, { @@ -1621,7 +1633,7 @@ async function repositorySession( context.auth, context.repository, context.room, - claimantSessionId, + context.plannerNotAttached ? undefined : claimantSessionId, ); if (context.ownerAvailable) void context.ownerAvailable().catch(() => {}); let { chat, auth } = context; @@ -1706,6 +1718,11 @@ async function repositorySession( } } +/** The login that may claim Planner ownership for the room; none where only an attachment may. */ +function claimant(context: Room): string | undefined { + return context.plannerNotAttached ? undefined : context.claimantSessionId; +} + /** * The channel's Planner owner, claimed for the claimant when it has none. * Without a claimant only an owner the channel already has will do. diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index 92a9ec59f..25762ca70 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -114,6 +114,18 @@ describe("configuration", () => { .toThrow("HARNESS=remote requires BACKGROUND_JOBS=off"); }); + it("keeps a detached remote Planner's ownership two minutes unless PLANNER_ATTACH_GRACE_MS says otherwise", () => { + let remote = { HARNESS: "remote", BACKGROUND_JOBS: "off" }; + expect(configured(remote).plannerAttachGraceMs).toBe(120_000); + expect(configured({ ...remote, PLANNER_ATTACH_GRACE_MS: "0" }).plannerAttachGraceMs).toBe(0); + expect(configured({ ...remote, PLANNER_ATTACH_GRACE_MS: "3600000" }).plannerAttachGraceMs) + .toBe(3_600_000); + for (let value of ["", "-1", "1.5", "3600001", "soon"]) { + expect(() => configured({ ...remote, PLANNER_ATTACH_GRACE_MS: value })) + .toThrow("PLANNER_ATTACH_GRACE_MS must be an integer between 0 and 3600000 milliseconds"); + } + }); + it("chooses a full Atomic Planner by harness alone, in hosted and local configuration", () => { let atomic = { HARNESS: "atomic", HARNESS_AUTH: "ai-gateway", MODEL: "stub/model" }; for (let mode of [{}, LOCAL]) { diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index d510778b2..0424b7637 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -25,6 +25,11 @@ export type Config = { * passed to the CLI's `--extension`. Split on the platform path delimiter. */ harnessExtensions: string[]; + /** + * Under `HARNESS=remote`, how long a detached or dropped Planner link keeps + * the document's Planner ownership for the same account to reattach. + */ + plannerAttachGraceMs: number; /** * Whether to run the agent at all. * @@ -59,6 +64,7 @@ export type Config = { }; const DEFAULT_PORT = 8787; +const DEFAULT_ATTACH_GRACE_MS = 120_000; const DEFAULT_MODEL = "gpt-6-luna"; /** The default is a Copilot model ID. Pi and Atomic resolve IDs against their @@ -139,6 +145,16 @@ export function load(): Config { ) { throw new Error("JEV_MODEL must be a valid model alias"); } + let graceRaw = process.env.PLANNER_ATTACH_GRACE_MS; + let plannerAttachGraceMs = graceRaw === undefined ? DEFAULT_ATTACH_GRACE_MS : Number(graceRaw); + if ( + (graceRaw !== undefined && !/^\d+$/.test(graceRaw)) + || !Number.isSafeInteger(plannerAttachGraceMs) || plannerAttachGraceMs > 3_600_000 + ) { + throw new Error( + "PLANNER_ATTACH_GRACE_MS must be an integer between 0 and 3600000 milliseconds", + ); + } let selection = harnessSelection(); if (selection.harness === "remote" && backgroundJobs) { throw new Error( @@ -150,6 +166,7 @@ export function load(): Config { ...selection, port: serverPort, model: model(selection.harness), + plannerAttachGraceMs, agent, backgroundJobs, webResearch: agent && backgroundJobs && process.env.WEB_RESEARCH !== "off", diff --git a/apps/server/src/conversation-plan/accepted-research.ts b/apps/server/src/conversation-plan/accepted-research.ts index 136f63f50..0bf0861ea 100644 --- a/apps/server/src/conversation-plan/accepted-research.ts +++ b/apps/server/src/conversation-plan/accepted-research.ts @@ -13,6 +13,8 @@ type AcceptedResearchDeps = { refreshAccess: (ws: Socket, force: boolean) => Promise; unavailable: (id: string) => boolean; ownerAvailable: (id: string) => Promise; + /** False under `HARNESS=remote`, where only an attached Planner takes ownership. */ + claimsOwnership?: boolean; placeReference: (channelId: string, workspaceId: string) => Promise<"placed" | "deferred">; scheduleRecovery: (deferred: number) => void; }; @@ -44,7 +46,12 @@ export async function startAcceptedResearch( || deps.unavailable(room.id) ) return false; try { - await Chat.resolveOwner(deps.auth, repository, room.id, ws.data.sessionId); + await Chat.resolveOwner( + deps.auth, + repository, + room.id, + deps.claimsOwnership === false ? undefined : ws.data.sessionId, + ); return true; } catch { return false; diff --git a/apps/server/src/harness/remote/attachments.ts b/apps/server/src/harness/remote/attachments.ts new file mode 100644 index 000000000..492f1d7a8 --- /dev/null +++ b/apps/server/src/harness/remote/attachments.ts @@ -0,0 +1,240 @@ +/** + * Which client is each document's remote Planner. + * + * Attaching makes the client's account the document's Planner owner through + * the same generation-guarded storage claim a browser owner takes elsewhere. + * The owner is the account's live browser login, because Chopin's repository + * tools act with that login's GitHub App token, never with the link's bearer. + * A dropped or detached link keeps its ownership for a grace period, so the + * same account can reattach without losing it; then the ownership is cleared. + */ + +import { GitHubError } from "../../github/client"; +import { attachLink } from "./registry"; + +import type { Admission } from "../../auth/admission"; +import type { AuthConfig } from "../../auth/config"; +import type { AuthenticatedSession, Sessions } from "../../auth/session"; +import type { GitHub } from "../../github/client"; +import type { StorageAdapter } from "../../storage/port"; +import type { PlannerLink } from "./link"; + +export type AttachmentAuth = { + config: Pick; + storage: Pick; + github: Pick; + admission: Pick; + sessions: Pick; + clock: () => Date; +}; + +export type AttachRequest = { + channelId: string; + repository: { id: string; owner: string; name: string }; + user: { id: string; login: string }; +}; + +export type AttachRefusal = { + ok: false; + code: "planner-attached" | "sign-in-required" | "repository-forbidden"; + message: string; +}; + +export type AttachGrant = { ok: true; detach: () => void }; + +type Hold = { + channelId: string; + userId: string; + login: string; + ownerSessionId: string; + generation: number; + link?: PlannerLink; + forget?: () => void; + timer?: ReturnType; + releaseAt?: Date; +}; + +/** What `@chopin` and `invoke_planner` say when no Planner is attached to the document. */ +export function notAttachedMessage(url: string, repository: string): string { + return `No Planner is attached to this document. To attach one, connect a Planner client to ` + + `${url} over the Planner link from a session in a checkout of ${repository}; ` + + `that session becomes this document's Planner.`; +} + +export function forbiddenMessage(repository: string): string { + return `Attaching as this document's Planner needs push or administration access to ${repository}.`; +} + +export class PlannerAttachments { + #auth: AttachmentAuth; + #graceMs: number; + #holds = new Map(); + #queues = new Map>(); + + constructor(auth: AttachmentAuth, graceMs: number) { + this.#auth = auth; + this.#graceMs = graceMs; + } + + /** Whether a client is attached to the document now, not merely holding it through a grace period. */ + attached(channelId: string): boolean { + return this.#holds.get(channelId)?.link !== undefined; + } + + /** The GitHub login holding the document's Planner, attached or within its grace period. */ + holder(channelId: string): string | undefined { + return this.#holds.get(channelId)?.login; + } + + /** Makes `link` the document's Planner and its account the owner, or says why not. */ + attach(request: AttachRequest, link: PlannerLink): Promise { + return this.#serialized(request.channelId, () => this.#attach(request, link)); + } + + /** Stops every grace timer. Ownership stays in storage, which startup clears. */ + close(): void { + for (let hold of this.#holds.values()) clearTimeout(hold.timer); + this.#holds.clear(); + } + + async #attach(request: AttachRequest, link: PlannerLink): Promise { + let { channelId, user } = request; + let held = this.#holds.get(channelId); + if (held && (held.link || held.userId !== user.id)) return attachedRefusal(held); + let login = await this.#eligibleLogin(request); + if ("ok" in login) return login; + let now = this.#auth.clock(); + if (held) { + let stored = await this.#auth.storage.channels.readAgent(channelId, now); + if ( + stored?.agent?.ownerSessionId === held.ownerSessionId + && stored.agent.generation === held.generation + ) return this.#bind(held, link); + this.#drop(held); + } + let claimed = await this.#claim(channelId, login.session.id, now); + if (!claimed) throw new Error("The document's Planner owner changed while attaching."); + let hold: Hold = { + channelId, + userId: user.id, + login: user.login, + ownerSessionId: login.session.id, + generation: claimed.generation, + }; + this.#holds.set(channelId, hold); + return this.#bind(hold, link); + } + + async #eligibleLogin(request: AttachRequest): Promise { + let { repository, user } = request; + let fullName = `${repository.owner}/${repository.name}`; + let login = await this.#auth.sessions.forUser(user.id); + if (!login) return signInRefusal(this.#auth.config.origin, user.login); + let checked; + try { + checked = await this.#auth.sessions.use( + login, + token => this.#auth.github.repositoryAccess(token, repository.owner, repository.name), + ); + } catch (error) { + if (error instanceof GitHubError && error.status === 401) { + return signInRefusal(this.#auth.config.origin, user.login); + } + throw error; + } + let access = checked.value; + if ( + !access || access.id !== repository.id + || (!access.permissions.push && !access.permissions.admin) + || !await this.#auth.admission.allowed(checked.authenticated.access.token, user.id) + ) return { ok: false, code: "repository-forbidden", message: forbiddenMessage(fullName) }; + return checked.authenticated; + } + + /** + * Claims the document for `sessionId`. Under the remote Planner only an + * attachment takes ownership, so an owner no attachment holds is stale and + * is cleared, guarded by its generation, before claiming again. + */ + async #claim(channelId: string, sessionId: string, now: Date) { + let channels = this.#auth.storage.channels; + let state = await channels.claimAgentOwner(channelId, sessionId, now); + if (state.ownerSessionId === sessionId) return state; + if (state.ownerSessionId) { + await channels.clearAgentOwner(channelId, state.ownerSessionId, state.generation, now); + } + state = await channels.claimAgentOwner(channelId, sessionId, now); + return state.ownerSessionId === sessionId ? state : undefined; + } + + #bind(hold: Hold, link: PlannerLink): AttachGrant { + clearTimeout(hold.timer); + hold.timer = undefined; + hold.releaseAt = undefined; + hold.link = link; + hold.forget = attachLink(hold.channelId, link); + return { ok: true, detach: () => this.#detached(hold, link) }; + } + + #detached(hold: Hold, link: PlannerLink): void { + if (hold.link !== link) return; + hold.link = undefined; + hold.forget?.(); + hold.forget = undefined; + if (this.#holds.get(hold.channelId) !== hold) return; + hold.releaseAt = new Date(this.#auth.clock().getTime() + this.#graceMs); + hold.timer = setTimeout(() => void this.#expire(hold), this.#graceMs); + hold.timer.unref?.(); + } + + #expire(hold: Hold): Promise { + return this.#serialized(hold.channelId, async () => { + if (this.#holds.get(hold.channelId) !== hold || hold.link) return; + this.#drop(hold); + try { + await this.#auth.storage.channels.clearAgentOwner( + hold.channelId, + hold.ownerSessionId, + hold.generation, + this.#auth.clock(), + ); + } catch (error) { + console.error("[planner-link] releasing the Planner owner failed:", error); + } + }); + } + + #drop(hold: Hold): void { + clearTimeout(hold.timer); + hold.timer = undefined; + if (this.#holds.get(hold.channelId) === hold) this.#holds.delete(hold.channelId); + } + + #serialized(channelId: string, action: () => Promise): Promise { + let previous = this.#queues.get(channelId) ?? Promise.resolve(); + let next = previous.then(action); + let settled = next.then(() => {}, () => {}); + this.#queues.set(channelId, settled); + void settled.then(() => { + if (this.#queues.get(channelId) === settled) this.#queues.delete(channelId); + }); + return next; + } +} + +function attachedRefusal(hold: Hold): AttachRefusal { + let message = hold.link || !hold.releaseAt + ? `${hold.login} is attached as this document's Planner. A document has one Planner at a time.` + : `${hold.login} detached from this document's Planner and holds it until ` + + `${hold.releaseAt.toISOString()} unless they reattach. A document has one Planner at a time.`; + return { ok: false, code: "planner-attached", message }; +} + +function signInRefusal(origin: string, login: string): AttachRefusal { + return { + ok: false, + code: "sign-in-required", + message: `Sign in to Chopin at ${origin} in a browser as ${login}, then attach again. ` + + `The Planner's repository tools act with that sign-in.`, + }; +} diff --git a/apps/server/src/harness/remote/endpoint.ts b/apps/server/src/harness/remote/endpoint.ts index 90cccf450..de2891c23 100644 --- a/apps/server/src/harness/remote/endpoint.ts +++ b/apps/server/src/harness/remote/endpoint.ts @@ -2,24 +2,30 @@ * `/planner-link`: where a client attaches as a document's Planner. * * The bearer is a GitHub token checked as hosted MCP checks it, before the - * socket upgrades. The `attach` that follows names a document its account must - * be able to read. Nothing is posted to the document by attaching or refusing. + * socket upgrades. The `attach` that follows names a document whose repository + * its account can push to or administer, and makes that account the document's + * Planner owner. Nothing is posted to the document by attaching or refusing. */ import { AdmissionDenied } from "../../auth/admission"; import { documentUrl } from "../../channels/document-url"; import { GitHubError } from "../../github/client"; import { locateDocument } from "../../mcp/hosted"; +import { forbiddenMessage } from "./attachments"; import { PlannerLink } from "./link"; -import { attachLink } from "./registry"; import type { ServerWebSocket } from "bun"; import type { HostedAuth } from "../../auth/routes"; import type { HostedCaller } from "../../mcp/hosted"; +import type { PlannerAttachments } from "./attachments"; import type { AttachOutcome, LinkOptions, LinkSocket } from "./link"; export type PlannerLinkAuth = Pick; +function writable(repository: { permissions: { push: boolean; admin: boolean } }): boolean { + return repository.permissions.push || repository.permissions.admin; +} + export type PlannerLinkSocketData = { plannerLink: true; sidebar?: undefined; @@ -28,8 +34,11 @@ export type PlannerLinkSocketData = { }; export type PlannerLinkHooks = { + /** One Planner per document, and its ownership. */ + attachments: PlannerAttachments; /** A document being deleted cannot be attached. */ unavailable?: (channelId: string) => boolean; + /** Observes attached links. */ register?: LinkOptions["register"]; timeoutMs?: number; revalidateEveryMs?: number; @@ -84,14 +93,15 @@ export async function admitPlannerLink( } } -/** A link for one admitted caller, attaching only documents the caller can read. */ +/** A link for one admitted caller, attaching only documents whose repository it can write. */ export function plannerLink( caller: HostedCaller, auth: PlannerLinkAuth, socket: LinkSocket, - hooks: PlannerLinkHooks = {}, + hooks: PlannerLinkHooks, ): PlannerLink { let channelId: string | undefined; + let link: PlannerLink; async function attach(message: { document: string }): Promise { let located = await locateDocument(auth, caller, message.document); if (located === "forbidden") { @@ -112,15 +122,22 @@ export function plannerLink( if (channel.archivedAt) { return { ok: false, code: "document-archived", message: "This document is archived." }; } + let fullName = `${repository.owner}/${repository.name}`; + if (!writable(repository)) { + return { ok: false, code: "repository-forbidden", message: forbiddenMessage(fullName) }; + } + let url = new URL(await documentUrl(channel, auth.storage.channels), auth.config.origin).href; + let granted = await hooks.attachments.attach({ + channelId: channel.id, + repository: { id: repository.id, owner: repository.owner, name: repository.name }, + user: { id: caller.user.id, login: caller.user.login }, + }, link); + if (!granted.ok) return granted; channelId = channel.id; return { ok: true, - document: { - id: channel.id, - title: channel.title, - repository: `${repository.owner}/${repository.name}`, - url: new URL(await documentUrl(channel, auth.storage.channels), auth.config.origin).href, - }, + document: { id: channel.id, title: channel.title, repository: fullName, url }, + detach: granted.detach, }; } async function revalidate(): Promise { @@ -129,19 +146,21 @@ export function plannerLink( await auth.admission.user(caller.oauthToken); let located = await locateDocument(auth, caller, channelId); return typeof located === "object" && located.channel.id === channelId + && writable(located.repository) && !located.channel.archivedAt && !hooks.unavailable?.(channelId); } catch (err) { if (revoked(err)) return false; throw err; } } - return new PlannerLink(socket, { + link = new PlannerLink(socket, { attach, revalidate, - register: hooks.register ?? attachLink, + register: hooks.register, timeoutMs: hooks.timeoutMs, revalidateEveryMs: hooks.revalidateEveryMs, }); + return link; } export function isPlannerLinkSocket( @@ -153,7 +172,7 @@ export function isPlannerLinkSocket( export function openPlannerLinkSocket( ws: ServerWebSocket, auth: PlannerLinkAuth, - hooks: PlannerLinkHooks = {}, + hooks: PlannerLinkHooks, ): void { ws.data.link = plannerLink(ws.data.caller, auth, { send: text => void ws.send(text), diff --git a/apps/server/src/harness/remote/link.test.ts b/apps/server/src/harness/remote/link.test.ts index 2120217cb..8f3427c0e 100644 --- a/apps/server/src/harness/remote/link.test.ts +++ b/apps/server/src/harness/remote/link.test.ts @@ -16,12 +16,15 @@ import { attach, DENIED_TOKEN, finishTurn, + GUEST_TOKEN, linkAuth, linkServer, + MEMBER_TOKEN, OUTSIDER_TOKEN, rawLink, - READER_TOKEN, + RIVAL_TOKEN, scriptedPlanner, + VIEWER_TOKEN, } from "../../testing/planner-link"; import { createRemoteAdapter } from "./adapter"; import { admitPlannerLink } from "./endpoint"; @@ -145,7 +148,7 @@ describe("Planner link admission", () => { .toBe(403); expect( (await upgradeRequest(server.url, { - authorization: `Bearer ${READER_TOKEN}`, + authorization: `Bearer ${MEMBER_TOKEN}`, origin: "https://elsewhere.test", })).status, ).toBe(403); @@ -157,7 +160,7 @@ describe("Planner link admission", () => { it("answers 404 where the server does not run the remote Planner", async () => { let { auth } = await linkAuth(); let request = new Request("https://chopin.test/planner-link", { - headers: { authorization: `Bearer ${READER_TOKEN}`, upgrade: "websocket" }, + headers: { authorization: `Bearer ${MEMBER_TOKEN}`, upgrade: "websocket" }, }); let refused = await admitPlannerLink(request, auth, false); expect(refused).toBeInstanceOf(Response); @@ -174,18 +177,18 @@ describe("Planner link admission", () => { (error: PlannerLinkRefused) => error.code, ); expect(await refusal(OUTSIDER_TOKEN, channel.id)).toBe("repository-forbidden"); - expect(await refusal(READER_TOKEN, crypto.randomUUID())).toBe("document-unavailable"); + expect(await refusal(MEMBER_TOKEN, crypto.randomUUID())).toBe("document-unavailable"); let raw = await rawLink(server.url); raw.send({ type: "attach", version: PLANNER_LINK_VERSION + 1, document: channel.id }); expect(await raw.next("refused")).toMatchObject({ code: "unsupported-version" }); expect((await raw.closed).code).toBe(LINK_CLOSE.unsupportedVersion); expect(await storage.channels.get(channel.id)).toEqual(before); await storage.channels.archive({ id: channel.id, now: new Date() }); - expect(await refusal(READER_TOKEN, channel.id)).toBe("document-archived"); + expect(await refusal(MEMBER_TOKEN, channel.id)).toBe("document-archived"); expect(server.links).toEqual([]); }); - it("attaches a reader to the document by id or URL", async () => { + it("attaches a member to the document by id or URL", async () => { let { server, channel } = await setup(); let client = await attach(server.url, channel.id, scriptedPlanner().handlers); expect(client.document).toEqual({ @@ -194,11 +197,136 @@ describe("Planner link admission", () => { repository: "octo-org/score", url: `https://chopin.test/documents/octo-org/score/${channel.slug}`, }); + await client.detach(); let byUrl = await attach(server.url, client.document.url!, scriptedPlanner().handlers); expect(byUrl.document.id).toBe(channel.id); - expect(server.links.map(link => link.channelId)).toEqual([channel.id, channel.id]); + expect(server.links.map(link => link.channelId)).toEqual([channel.id]); await byUrl.detach(); - await client.detach(); + expect(server.links).toEqual([]); + }); +}); + +describe("Planner link ownership", () => { + /** The code, message, and close code an attach is refused with. */ + async function refused(url: string, document: string, token: string) { + let raw = await rawLink(url, token); + raw.send({ type: "attach", version: PLANNER_LINK_VERSION, document }); + let refusal = await raw.next("refused"); + return { code: refusal.code, message: refusal.message, close: (await raw.closed).code }; + } + + async function owner( + storage: Awaited>["storage"], + channelId: string, + ) { + return (await storage.channels.readAgent(channelId, new Date()))?.agent; + } + + it("makes the attaching member the owner through their browser login, replacing an unattached owner", async () => { + let { auth, storage, channel, logins } = await linkAuth(); + let server = linkServer(auth); + cleanups.push(server.stop); + let stale = await storage.channels.claimAgentOwner( + channel.id, + logins.get("rival")!, + new Date(), + ); + expect(stale).toMatchObject({ ownerSessionId: logins.get("rival"), generation: 1 }); + let client = await attach(server.url, channel.id, scriptedPlanner().handlers); + cleanups.push(() => client.detach()); + expect(await owner(storage, channel.id)) + .toMatchObject({ ownerSessionId: logins.get("member"), generation: 2, status: "ready" }); + expect(server.attachments.attached(channel.id)).toBe(true); + expect(server.attachments.holder(channel.id)).toBe("member"); + }); + + it("refuses a second attach while a Planner is attached, naming the holder", async () => { + let { auth, storage, channel, logins } = await linkAuth(); + let server = linkServer(auth); + cleanups.push(server.stop); + let client = await attach(server.url, channel.id, scriptedPlanner().handlers); + cleanups.push(() => client.detach()); + let held = await owner(storage, channel.id); + expect(held).toMatchObject({ ownerSessionId: logins.get("member"), generation: 1 }); + for (let token of [RIVAL_TOKEN, MEMBER_TOKEN]) { + expect(await refused(server.url, channel.id, token)).toEqual({ + code: "planner-attached", + message: "member is attached as this document's Planner. " + + "A document has one Planner at a time.", + close: LINK_CLOSE.attached, + }); + } + expect(await owner(storage, channel.id)).toEqual(held); + expect(server.links).toHaveLength(1); + }); + + it("refuses an account that cannot push or has no browser login, and takes no ownership", async () => { + let { auth, storage, channel } = await linkAuth(); + let server = linkServer(auth); + cleanups.push(server.stop); + expect(await refused(server.url, channel.id, VIEWER_TOKEN)).toEqual({ + code: "repository-forbidden", + message: "Attaching as this document's Planner needs push or administration access to " + + "octo-org/score.", + close: LINK_CLOSE.forbidden, + }); + expect(await refused(server.url, channel.id, GUEST_TOKEN)).toEqual({ + code: "sign-in-required", + message: "Sign in to Chopin at https://chopin.test in a browser as guest, then attach " + + "again. The Planner's repository tools act with that sign-in.", + close: LINK_CLOSE.unauthorized, + }); + expect((await owner(storage, channel.id))?.ownerSessionId).toBeUndefined(); + expect(server.links).toEqual([]); + }); + + it("keeps ownership through the grace period for the same member, then releases it", async () => { + let { auth, storage, channel, logins } = await linkAuth(); + let server = linkServer(auth, 2_000, undefined, 400); + cleanups.push(server.stop); + let first = await attach(server.url, channel.id, scriptedPlanner().handlers); + await first.detach(); + expect(await owner(storage, channel.id)) + .toMatchObject({ ownerSessionId: logins.get("member"), generation: 1 }); + let refusal = await refused(server.url, channel.id, RIVAL_TOKEN); + expect(refusal).toMatchObject({ code: "planner-attached", close: LINK_CLOSE.attached }); + expect(refusal.message).toStartWith( + "member detached from this document's Planner and holds it until ", + ); + + let dropped = await rawLink(server.url); + dropped.send({ type: "attach", version: PLANNER_LINK_VERSION, document: channel.id }); + await dropped.next("attached"); + expect(await owner(storage, channel.id)) + .toMatchObject({ ownerSessionId: logins.get("member"), generation: 1 }); + dropped.socket.close(); + await dropped.closed; + await Bun.sleep(100); + expect((await owner(storage, channel.id))?.ownerSessionId).toBe(logins.get("member")); + for (let deadline = Date.now() + 3_000; Date.now() < deadline;) { + if (!(await owner(storage, channel.id))?.ownerSessionId) break; + await Bun.sleep(25); + } + expect(await owner(storage, channel.id)) + .toMatchObject({ ownerSessionId: undefined, generation: 1, status: "unavailable" }); + + let rival = await attach(server.url, channel.id, scriptedPlanner().handlers, RIVAL_TOKEN); + cleanups.push(() => rival.detach()); + expect(await owner(storage, channel.id)) + .toMatchObject({ ownerSessionId: logins.get("rival"), generation: 2 }); + }); + + it("detaches a link whose account loses push access while it stays attached", async () => { + let { auth, channel } = await linkAuth(); + let server = linkServer(auth, 300, 20); + cleanups.push(server.stop); + let client = await attach(server.url, channel.id, scriptedPlanner().handlers); + let repository = auth.github.repository; + auth.github.repository = async (token, owner, name) => ({ + ...await repository(token, owner, name), + permissions: { pull: true, push: false, admin: false }, + }); + expect(await client.closed).toEqual({ code: LINK_CLOSE.forbidden, reason: "access-revoked" }); expect(server.links).toEqual([]); }); }); @@ -416,7 +544,7 @@ describe("Planner link protocol", () => { throw new GitHubError("Not Found", 404); }; await expect(harness.doStart({ sessionId: "second", sandboxSession, sessionWorkDir: "/tmp" })) - .rejects.toThrow("can no longer read this document"); + .rejects.toThrow("can no longer act for this document"); expect(server.links).toEqual([]); }); @@ -430,7 +558,7 @@ describe("Planner link protocol", () => { throw new GitHubError("Not Found", 404); }; let revoked = await streamOn(agent, session, "second"); - expect(revoked.errors.join("\n")).toContain("can no longer read this document"); + expect(revoked.errors.join("\n")).toContain("can no longer act for this document"); expect(server.links).toEqual([]); }); @@ -457,7 +585,7 @@ describe("Planner link protocol", () => { throw new GitHubError("Bad credentials", 401); }; let revoked = await streamOn(agent, session, "second"); - expect(revoked.errors.join("\n")).toContain("can no longer read this document"); + expect(revoked.errors.join("\n")).toContain("can no longer act for this document"); expect(await client.closed).toEqual({ code: LINK_CLOSE.forbidden, reason: "access-revoked" }); expect(server.links).toEqual([]); }); @@ -527,6 +655,8 @@ describe("Planner link protocol", () => { })(); let outcome = await streamTurn(harness, "reuse"); await driving; + raw.socket.close(); + await raw.closed; return outcome.errors.join("\n"); } readPlanRuns = 0; diff --git a/apps/server/src/harness/remote/link.ts b/apps/server/src/harness/remote/link.ts index 80679f2a2..5a299e1d7 100644 --- a/apps/server/src/harness/remote/link.ts +++ b/apps/server/src/harness/remote/link.ts @@ -44,11 +44,16 @@ export type LinkSocket = { }; export type AttachOutcome = - | { ok: true; document: ServerMessageOf<"attached">["document"] } + | { + ok: true; + document: ServerMessageOf<"attached">["document"]; + /** Lets go of what attaching took, such as the document's Planner ownership. */ + detach?: () => void; + } | { ok: false; code: RefusalCode; message: string }; export type LinkOptions = { - /** Resolves the document the client asked for, refusing what its caller cannot read. */ + /** Resolves the document the client asked for and takes its Planner, or refuses. */ attach(message: ClientMessageOf<"attach">): Promise; /** Rechecks the caller's access; false closes the link. */ revalidate?(): Promise; @@ -65,6 +70,8 @@ const REFUSAL_CLOSE: Record = { "access-revoked": LINK_CLOSE.forbidden, "document-unavailable": LINK_CLOSE.unavailable, "document-archived": LINK_CLOSE.unavailable, + "planner-attached": LINK_CLOSE.attached, + "sign-in-required": LINK_CLOSE.unauthorized, unavailable: LINK_CLOSE.failed, }; @@ -601,11 +608,14 @@ export class PlannerLink { } } - /** Closes the link with `access-revoked` and throws when the caller can no longer read the document. */ + /** Closes the link with `access-revoked` and throws when the caller may no longer be the Planner. */ async #recheck(): Promise { if (this.#options.revalidate && !await this.#options.revalidate()) { - this.#refuse("access-revoked", "The attached account can no longer read this document."); - throw new Error("The attached Planner can no longer read this document."); + this.#refuse( + "access-revoked", + "The attached account can no longer act as this document's Planner.", + ); + throw new Error("The attached Planner can no longer act for this document."); } if (this.#state !== "attached") throw new Error("The Planner link closed."); } @@ -693,12 +703,20 @@ export class PlannerLink { console.error("[planner-link] attach failed:", error); outcome = { ok: false, code: "unavailable", message: "The document could not be checked." }; } - if (this.#state !== "attaching") return; + if (this.#state !== "attaching") { + if (outcome.ok) outcome.detach?.(); + return; + } if (!outcome.ok) return this.#refuse(outcome.code, outcome.message); this.#channelId = outcome.document.id; this.#state = "attached"; this.#send({ type: "attached", version: PLANNER_LINK_VERSION, document: outcome.document }); - this.#forget = this.#options.register?.(outcome.document.id, this); + let unregister = this.#options.register?.(outcome.document.id, this); + let detach = outcome.detach; + this.#forget = () => { + unregister?.(); + detach?.(); + }; this.#watch = setInterval( () => void this.#recheckAttached(), this.#options.revalidateEveryMs ?? REVALIDATE_EVERY_MS, diff --git a/apps/server/src/harness/remote/registry.ts b/apps/server/src/harness/remote/registry.ts index a50ed83eb..1b4075413 100644 --- a/apps/server/src/harness/remote/registry.ts +++ b/apps/server/src/harness/remote/registry.ts @@ -26,7 +26,7 @@ export type RemotePlanner = }; let planners = new Map(); -let links = new Map(); +let links = new Map(); export function registerRemotePlanner(sessionId: string, planner: RemotePlanner): () => void { if (planners.has(sessionId)) throw new Error("Remote Planner session is already registered"); @@ -40,19 +40,14 @@ export function remotePlanner(sessionId: string): RemotePlanner | undefined { return planners.get(sessionId); } -/** - * Records a link attached to a document. Until one Planner per document is - * enforced, the most recent attachment serves new sessions. - */ +/** Records the link attached as a document's Planner; `PlannerAttachments` admits one per document. */ export function attachLink(channelId: string, link: PlannerLink): () => void { - links.set(channelId, [...links.get(channelId) ?? [], link]); + links.set(channelId, link); return () => { - let remaining = (links.get(channelId) ?? []).filter(candidate => candidate !== link); - if (remaining.length) links.set(channelId, remaining); - else links.delete(channelId); + if (links.get(channelId) === link) links.delete(channelId); }; } export function attachedLink(channelId: string): PlannerLink | undefined { - return links.get(channelId)?.at(-1); + return links.get(channelId); } diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index aebbbd425..76e59f3e1 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -18,6 +18,8 @@ import { isPlannerLinkSocket, openPlannerLinkSocket, } from "./harness/remote/endpoint"; +import { notAttachedMessage, PlannerAttachments } from "./harness/remote/attachments"; +import { documentUrl } from "./channels/document-url"; import { ActiveOwnerBindings } from "./agent/active-owner"; import { registerAuthRoutes } from "./auth/routes"; import { registerExperimentRoutes } from "./experiments/routes"; @@ -79,7 +81,7 @@ import { StorageError } from "./storage/errors"; import { createStorage } from "./storage/registry"; import { broadcast, fail, relay, reply, repositoryTopic, tell, topic } from "./wire"; -import { PLANNER_LINK_PATH } from "@chopin/planner-link"; +import { LINK_CLOSE, PLANNER_LINK_PATH } from "@chopin/planner-link"; import type { Server, ServerWebSocket } from "bun"; import type { PlannerLinkSocketData } from "./harness/remote/endpoint"; @@ -128,6 +130,7 @@ let renewingLease: Promise | undefined; let sessionCleanup: ReturnType | undefined; let cleaningSessions: Promise | undefined; let ownerBindings: ActiveOwnerBindings | undefined; +let plannerAttachments: PlannerAttachments | undefined; let jobRunner: JobRunner | undefined; let researchService: ResearchWorkspaceService | undefined; let researchRecoveryTimer: ReturnType | undefined; @@ -307,6 +310,19 @@ function conversation( auth: hostedAuth, claimantSessionId, repository, + plannerNotAttached: plannerAttachments + ? async () => { + if (plannerAttachments!.attached(room.id)) return undefined; + let channel = await storage.channels.get(room.id); + let path = channel + ? await documentUrl(channel, storage.channels) + : `/channels/${room.id}`; + return notAttachedMessage( + new URL(path, hostedAuth.config.origin).href, + `${repository.owner}/${repository.name}`, + ); + } + : undefined, persist: chat => Service.persist(opened, chat), activeOwner: () => ownerBindings!.resolve(room.id), ownerAvailable: () => jobRunner?.ownerAvailable(room.id) ?? Promise.resolve(), @@ -674,6 +690,7 @@ async function receive(ws: Socket, raw: string): Promise { refreshAccess, unavailable: id => archivingChannels.has(id) || deletingChannels.has(id), ownerAvailable: id => jobRunner?.ownerAvailable(id) ?? Promise.resolve(), + claimsOwnership: !plannerAttachments, placeReference: placeResearchReference, scheduleRecovery: scheduleResearchRecovery, }), @@ -1148,8 +1165,7 @@ function listen(): Server { return upgraded(req, self, outcome.data); } if (url.pathname === PLANNER_LINK_PATH) { - let enabled = config.agent && config.harness === "remote"; - let outcome = await admitPlannerLink(req, hostedAuth, enabled); + let outcome = await admitPlannerLink(req, hostedAuth, !!plannerAttachments); if (outcome instanceof Response) return outcome; return upgraded(req, self, outcome); } @@ -1169,7 +1185,9 @@ function listen(): Server { websocket: { open(ws: AnySocket) { if (isPlannerLinkSocket(ws)) { + if (!plannerAttachments) return ws.close(LINK_CLOSE.failed, "unavailable"); openPlannerLinkSocket(ws, hostedAuth, { + attachments: plannerAttachments, unavailable: channelId => deletingChannels.has(channelId), }); } else if (isSidebarSocket(ws)) scheduleSidebarRecheck(ws); @@ -1234,6 +1252,7 @@ function drain(): Promise { summaryCoordinator?.close(); let stoppingJobs = jobRunner?.shutdown(); ownerBindings?.revokeAll(); + plannerAttachments?.close(); for (let result of await Promise.allSettled([stoppingJobs])) { if (result.status === "rejected") record(result.reason); } @@ -1672,6 +1691,9 @@ experiments = registerExperimentRoutes(router, hostedAuth, { }, }); ownerBindings = new ActiveOwnerBindings(hostedAuth); +if (config.agent && config.harness === "remote") { + plannerAttachments = new PlannerAttachments(hostedAuth, config.plannerAttachGraceMs); +} let definitions: JobDefinition[] = []; if (config.backgroundJobs) { definitions.push(researchBriefDefinition({ config })); @@ -1888,7 +1910,7 @@ registerResearchWorkspaceRoutes(router, hostedAuth, { defaultBranch: repository.defaultBranch, }, channel.id, - session.session.id, + plannerAttachments ? undefined : session.session.id, ); await jobRunner?.ownerAvailable(channel.id); }, diff --git a/apps/server/src/mcp.ts b/apps/server/src/mcp.ts index 00ed96b66..93c01a309 100644 --- a/apps/server/src/mcp.ts +++ b/apps/server/src/mcp.ts @@ -155,6 +155,7 @@ export type InvokePlannerError = | "document-unavailable" | "repository-forbidden" | "document-archived" + | "planner-not-attached" | "planner-owner-unavailable" | "checkout-unverified" | "planner-unavailable" @@ -163,7 +164,7 @@ export type InvokePlannerError = export type InvokePlanner = { invoke(caller: Caller, input: InvokePlannerInput): Promise< | { kind: "invoked"; document: DocumentSummary & { url: string } } - | { kind: "refused"; code: InvokePlannerError } + | { kind: "refused"; code: InvokePlannerError; message?: string } >; }; @@ -518,7 +519,10 @@ export const TOOLS: Tool[] = [ description: "Post an instruction to a document's Planner, attributed to the caller, and " + "return its URL without waiting for the turn. The turn runs under the document's " + "Planner owner; without one, the caller's live Chopin browser login becomes the " - + "owner, and otherwise the call is refused. Questions appear in Decisions.", + + "owner, and otherwise the call is refused. Where the Planner runs in an attached " + + "client, a document with no attached Planner refuses the call with " + + "planner-not-attached and a message saying how to attach one. Questions appear in " + + "Decisions.", inputSchema: { type: "object", properties: { @@ -554,15 +558,27 @@ export const TOOLS: Tool[] = [ required: ["id", "title", "url"], additionalProperties: false, }, - outcome([ - "document-unavailable", - "repository-forbidden", - "document-archived", - "planner-owner-unavailable", - "checkout-unverified", - "planner-unavailable", - "planner-queue-full", - ]), + { + type: "object", + properties: { + code: { + type: "string", + enum: [ + "document-unavailable", + "repository-forbidden", + "document-archived", + "planner-not-attached", + "planner-owner-unavailable", + "checkout-unverified", + "planner-unavailable", + "planner-queue-full", + ], + }, + message: { type: "string" }, + }, + required: ["code"], + additionalProperties: false, + }, ], }, }, @@ -1098,7 +1114,10 @@ export function handler( let result = await options.invoke.invoke(caller, args as InvokePlannerInput); return result.kind === "invoked" ? respond(text(result.document)) - : respond(text({ code: result.code }, true)); + : respond(text({ + code: result.code, + ...(result.message ? { message: result.message } : {}), + }, true)); } if (tool.name === "upload_image" && options.upload) { let prepared = prepareImage(tool.arguments); diff --git a/apps/server/src/mcp/hosted.test.ts b/apps/server/src/mcp/hosted.test.ts index fe33a4164..006a2ce39 100644 --- a/apps/server/src/mcp/hosted.test.ts +++ b/apps/server/src/mcp/hosted.test.ts @@ -276,6 +276,30 @@ describe("the hosted MCP adapter", () => { } }); + it("tells an invocation with no attached Planner how to attach one, naming the document URL", async () => { + let context = setup(); + context.github.repositoryValue.permissions.push = true; + let opened = await plan(context); + let adapter = hosted(context.auth, undefined, { + invokePlanner: async () => "planner-not-attached", + }); + let caller = (await adapter.caller(request("Bearer allowed")))!; + try { + expect( + await adapter.invoke!.invoke(caller, { id: opened.channel.id, instruction: "Plan it." }), + ).toEqual({ + kind: "refused", + code: "planner-not-attached", + message: "No Planner is attached to this document. To attach one, connect a Planner client to " + + "https://chopin.test/documents/octo-org/score/release-readiness over the Planner " + + "link from a session in a checkout of octo-org/score; that session becomes this " + + "document's Planner.", + }); + } finally { + await Service.close(opened.plan); + } + }); + it("passes a URL invocation's checkout through unverified for the harness to judge", async () => { let context = setup(); context.github.repositoryValue.permissions.admin = true; diff --git a/apps/server/src/mcp/hosted.ts b/apps/server/src/mcp/hosted.ts index 69a585c0f..6c574489c 100644 --- a/apps/server/src/mcp/hosted.ts +++ b/apps/server/src/mcp/hosted.ts @@ -7,6 +7,7 @@ import { } from "@chopin/protocol/document-url"; import { documentUrl } from "../channels/document-url"; +import { notAttachedMessage } from "../harness/remote/attachments"; import { deterministicChannelId, isChannelId } from "../channels/id"; import { documentSlug } from "../channels/slug"; import { GitHubError } from "../github/client"; @@ -336,15 +337,17 @@ export function hosted( instruction: input.instruction, ...(input.checkout === undefined ? {} : { checkout: input.checkout }), }); + let url = + new URL(await documentUrl(channel, auth.storage.channels), auth.config.origin).href; + if (code === "planner-not-attached") { + return { + kind: "refused", + code, + message: notAttachedMessage(url, `${repository.owner}/${repository.name}`), + }; + } if (code) return { kind: "refused", code }; - return { - kind: "invoked", - document: { - ...summary(channel), - url: - new URL(await documentUrl(channel, auth.storage.channels), auth.config.origin).href, - }, - }; + return { kind: "invoked", document: { ...summary(channel), url } }; }, } : undefined, diff --git a/apps/server/src/mcp/invoke.test.ts b/apps/server/src/mcp/invoke.test.ts index e774e92eb..831faf4dd 100644 --- a/apps/server/src/mcp/invoke.test.ts +++ b/apps/server/src/mcp/invoke.test.ts @@ -93,6 +93,7 @@ test("invoke_planner exposes each refusal as an object code", async () => { "document-unavailable", "repository-forbidden", "document-archived", + "planner-not-attached", "planner-owner-unavailable", "checkout-unverified", "planner-unavailable", @@ -107,3 +108,22 @@ test("invoke_planner exposes each refusal as an object code", async () => { expect(result.result).toMatchObject({ isError: true, structuredContent: { code } }); } }); + +test("invoke_planner carries a refusal's message beside its code", async () => { + let message = "No Planner is attached to this document. Connect a Planner client to attach one."; + let mcp = endpoint({ + invoke: async () => ({ kind: "refused", code: "planner-not-attached", message }), + }); + let result = await call(mcp, "tools/call", { + name: "invoke_planner", + arguments: { id: "document-id", instruction: "go" }, + }); + expect(result.result).toMatchObject({ + isError: true, + structuredContent: { code: "planner-not-attached", message }, + }); + expect(JSON.parse(result.result.content[0].text)).toEqual({ + code: "planner-not-attached", + message, + }); +}); diff --git a/apps/server/src/storage/contract.ts b/apps/server/src/storage/contract.ts index 36c7ce3f6..f9931735a 100644 --- a/apps/server/src/storage/contract.ts +++ b/apps/server/src/storage/contract.ts @@ -1,6 +1,7 @@ import { researchWorkspace } from "./research-inline-contract-fixtures"; import { experimentContract } from "./experiment-contract"; import { imageContract } from "./image-contract"; +import { plannerAttachmentContract } from "./planner-attachment-contract"; import { researchInlineContract } from "./research-inline-contract"; import { describe, expect, it } from "bun:test"; @@ -35,6 +36,7 @@ function attempt(action: () => Promise): Promise { export function storageContract(name: string, factory: Factory): void { experimentContract(name, factory); imageContract(name, factory); + plannerAttachmentContract(name, factory); describe(`${name} storage`, () => { it("supports expiring metadata-only sessions for local authentication", async () => { let storage = await opened(factory); diff --git a/apps/server/src/storage/planner-attachment-contract.ts b/apps/server/src/storage/planner-attachment-contract.ts new file mode 100644 index 000000000..047e18826 --- /dev/null +++ b/apps/server/src/storage/planner-attachment-contract.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from "bun:test"; + +import { Sessions } from "../auth/session"; +import { PlannerAttachments } from "../harness/remote/attachments"; +import { contractId as id, openedStorage } from "./contract-support"; + +import type { AttachmentAuth } from "../harness/remote/attachments"; +import type { PlannerLink } from "../harness/remote/link"; +import type { StorageFactory } from "./contract-support"; +import type { StorageAdapter } from "./port"; + +const GRANT = { accessExpiresIn: 28_800, refreshToken: "refresh", refreshExpiresIn: 86_400 }; + +async function attachable(storage: StorageAdapter) { + let now = new Date(); + let member = { id: id("member"), login: "member" }; + let rival = { id: id("rival"), login: "rival" }; + for (let user of [member, rival]) await storage.users.put({ ...user, avatarUrl: "", now }); + let channelId = id("channel"); + let repository = { id: id("repository"), owner: "octo-org", name: "score" }; + await storage.channels.create({ + id: channelId, + repositoryId: repository.id, + repositoryOwner: repository.owner, + repositoryName: repository.name, + title: "Attached document", + createdBy: member.id, + now, + }); + let sessions = new Sessions(storage, false); + let signIn = async (user: { id: string }) => + (await sessions.issue(user.id, { ...GRANT, accessToken: `${user.id}-login` })).id; + let logins = { member: await signIn(member), rival: await signIn(rival) }; + let auth: AttachmentAuth = { + config: { origin: "https://chopin.test" }, + storage, + sessions, + clock: () => new Date(), + admission: { allowed: async () => true }, + github: { + repositoryAccess: async () => ({ + ...repository, + fullName: "octo-org/score", + private: true, + url: "https://github.test/octo-org/score", + defaultBranch: "main", + permissions: { pull: true, push: true, admin: false }, + }), + }, + }; + let owner = async () => (await storage.channels.readAgent(channelId, new Date()))?.agent; + let request = (user: { id: string; login: string }) => ({ channelId, repository, user }); + return { auth, sessions, member, rival, logins, channelId, owner, request }; +} + +function link(): PlannerLink { + return {} as PlannerLink; +} + +async function released(owner: () => Promise<{ ownerSessionId?: string } | undefined>) { + for (let deadline = Date.now() + 3_000; Date.now() < deadline;) { + if (!(await owner())?.ownerSessionId) return; + await Bun.sleep(10); + } +} + +/** Remote Planner attachments over each storage adapter's generation-guarded ownership. */ +export function plannerAttachmentContract(name: string, factory: StorageFactory): void { + describe(`${name} Planner attachments`, () => { + test("an attachment owns the document, refuses another login, and survives its own reattach", async () => { + let storage = await openedStorage(factory); + let f = await attachable(storage); + let attachments = new PlannerAttachments(f.auth, 60_000); + try { + let granted = await attachments.attach(f.request(f.member), link()); + expect(granted.ok).toBe(true); + let held = await f.owner(); + expect(held).toMatchObject({ ownerSessionId: f.logins.member, status: "ready" }); + expect(await attachments.attach(f.request(f.rival), link())).toMatchObject({ + ok: false, + code: "planner-attached", + message: expect.stringContaining("member is attached"), + }); + if (granted.ok) granted.detach(); + expect(attachments.attached(f.channelId)).toBe(false); + expect(await f.owner()).toEqual(held); + expect(await attachments.attach(f.request(f.rival), link())).toMatchObject({ + ok: false, + code: "planner-attached", + message: expect.stringContaining("member detached"), + }); + expect((await attachments.attach(f.request(f.member), link())).ok).toBe(true); + expect(attachments.attached(f.channelId)).toBe(true); + expect(await f.owner()).toEqual(held); + } finally { + attachments.close(); + await storage.close(); + } + }); + + test("the grace period's end clears only the ownership the attachment still holds", async () => { + let storage = await openedStorage(factory); + let f = await attachable(storage); + let attachments = new PlannerAttachments(f.auth, 30); + try { + let first = await attachments.attach(f.request(f.member), link()); + let generation = (await f.owner())!.generation; + if (first.ok) first.detach(); + await released(f.owner); + expect(await f.owner()).toMatchObject({ + ownerSessionId: undefined, + generation, + status: "unavailable", + }); + expect(attachments.holder(f.channelId)).toBeUndefined(); + + let rival = await attachments.attach(f.request(f.rival), link()); + expect(await f.owner()) + .toMatchObject({ ownerSessionId: f.logins.rival, generation: generation + 1 }); + if (rival.ok) rival.detach(); + let channels = storage.channels; + expect( + await channels.clearAgentOwner(f.channelId, f.logins.rival, generation + 1, new Date()), + ) + .toBe(true); + await channels.claimAgentOwner(f.channelId, f.logins.member, new Date()); + await Bun.sleep(80); + expect(await f.owner()) + .toMatchObject({ ownerSessionId: f.logins.member, generation: generation + 2 }); + } finally { + attachments.close(); + await storage.close(); + } + }); + + test("startup clears an attachment's ownership and a new process starts unattached", async () => { + let storage = await openedStorage(factory); + let f = await attachable(storage); + let attachments = new PlannerAttachments(f.auth, 60_000); + try { + await attachments.attach(f.request(f.member), link()); + let generation = (await f.owner())!.generation; + let lease = await storage.leases.acquire(id("writer"), id("instance"), 60_000); + await storage.sessions.reset(new Date(), lease!, 60_000); + expect(await f.owner()).toMatchObject({ ownerSessionId: undefined, generation }); + + let sessions = new Sessions(storage, false); + let restarted = new PlannerAttachments({ ...f.auth, sessions }, 60_000); + try { + expect(restarted.attached(f.channelId)).toBe(false); + expect(await restarted.attach(f.request(f.member), link())).toMatchObject({ + ok: false, + code: "sign-in-required", + }); + let login = await sessions.issue(f.member.id, { ...GRANT, accessToken: "again" }); + expect((await restarted.attach(f.request(f.member), link())).ok).toBe(true); + expect(await f.owner()) + .toMatchObject({ ownerSessionId: login.id, generation: generation + 1 }); + } finally { + restarted.close(); + } + } finally { + attachments.close(); + await storage.close(); + } + }); + }); +} diff --git a/apps/server/src/testing/config.ts b/apps/server/src/testing/config.ts index cc6e64fd6..55cd854f6 100644 --- a/apps/server/src/testing/config.ts +++ b/apps/server/src/testing/config.ts @@ -29,6 +29,7 @@ export function configured(overrides: Record = {}) { JEV_API_KEY: undefined, JEV_MODEL: undefined, JEV_TIMEOUT_MS: undefined, + PLANNER_ATTACH_GRACE_MS: undefined, TYPESAFE_API_KEY: undefined, HARNESS: undefined, HARNESS_AUTH: undefined, diff --git a/apps/server/src/testing/planner-link.ts b/apps/server/src/testing/planner-link.ts index 42626f853..ce4acbee1 100644 --- a/apps/server/src/testing/planner-link.ts +++ b/apps/server/src/testing/planner-link.ts @@ -2,6 +2,8 @@ import { connectPlannerLink, linkSocket, plannerLinkUrl } from "@chopin/planner- import { AdmissionDenied } from "../auth/admission"; import { GitHubError } from "../github/client"; +import { Sessions } from "../auth/session"; +import { PlannerAttachments } from "../harness/remote/attachments"; import { admitPlannerLink, openPlannerLinkSocket } from "../harness/remote/endpoint"; import { MemoryStorage } from "../storage/memory/adapter"; @@ -11,64 +13,122 @@ import type { TurnContext, } from "@chopin/planner-link/client"; import type { Repository } from "../github/client"; +import type { AttachmentAuth } from "../harness/remote/attachments"; import type { PlannerLinkAuth, PlannerLinkSocketData } from "../harness/remote/endpoint"; import type { PlannerLink } from "../harness/remote/link"; -export const READER_TOKEN = "reader-token"; +export const MEMBER_TOKEN = "member-token"; +export const RIVAL_TOKEN = "rival-token"; +export const VIEWER_TOKEN = "viewer-token"; +export const GUEST_TOKEN = "guest-token"; export const OUTSIDER_TOKEN = "outsider-token"; export const DENIED_TOKEN = "denied-token"; +const ACCOUNTS: Record = { + [MEMBER_TOKEN]: { id: "U_member", login: "member", push: true, signedIn: true }, + [RIVAL_TOKEN]: { id: "U_rival", login: "rival", push: true, signedIn: true }, + [VIEWER_TOKEN]: { id: "U_viewer", login: "viewer", push: false, signedIn: true }, + [GUEST_TOKEN]: { id: "U_guest", login: "guest", push: true, signedIn: false }, +}; + +function loginToken(login: string): string { + return `${login}-browser-login`; +} + +function scoreRepository(owner: string, name: string, push: boolean): Repository { + return { + id: "R_score", + owner, + name, + fullName: `${owner}/${name}`, + private: true, + url: `https://github.test/${owner}/${name}`, + defaultBranch: "main", + permissions: { pull: true, push, admin: false }, + }; +} + /** - * GitHub as hosted MCP sees it: the reader can pull `octo-org/score`, the - * outsider is a valid account without access, the denied account fails - * admission, and every other token is rejected by GitHub. + * GitHub as hosted MCP sees it: the member and the rival can push to + * `octo-org/score` and are signed in to Chopin in a browser, the viewer can + * only pull, the guest can push but never signed in, the outsider is a valid + * account without access, the denied account fails admission, and every other + * token is rejected by GitHub. */ export async function linkAuth(channelId?: string) { let storage = new MemoryStorage(); let now = new Date(); - await storage.users.put({ id: "U_reader", login: "reader", avatarUrl: "", now }); + for (let account of Object.values(ACCOUNTS)) { + await storage.users.put({ id: account.id, login: account.login, avatarUrl: "", now }); + } + let sessions = new Sessions(storage, false); + let logins = new Map(); + for (let account of Object.values(ACCOUNTS)) { + if (!account.signedIn) continue; + let issued = await sessions.issue(account.id, { + accessToken: loginToken(account.login), + accessExpiresIn: 28_800, + refreshToken: "refresh-token", + refreshExpiresIn: 86_400, + }); + logins.set(account.login, issued.id); + } let channel = await storage.channels.create({ id: channelId ?? crypto.randomUUID(), repositoryId: "R_score", repositoryOwner: "octo-org", repositoryName: "score", title: "Linked document", - createdBy: "U_reader", + createdBy: "U_member", now, }); let auth = { config: { origin: "https://chopin.test" }, storage, + sessions, + clock: () => new Date(), admission: { async user(token: string) { if (token === DENIED_TOKEN) throw new AdmissionDenied(); - if (token === READER_TOKEN) return { id: "U_reader", login: "reader", avatarUrl: "" }; + let account = ACCOUNTS[token]; + if (account) return { id: account.id, login: account.login, avatarUrl: "" }; if (token === OUTSIDER_TOKEN) return { id: "U_outsider", login: "outsider", avatarUrl: "" }; throw new GitHubError("Bad credentials", 401); }, + async allowed(token: string) { + return token !== DENIED_TOKEN; + }, }, github: { async repository(token: string, owner: string, name: string): Promise { - if (token !== READER_TOKEN) throw new GitHubError("Not Found", 404); - return { - id: "R_score", - owner, - name, - fullName: `${owner}/${name}`, - private: true, - url: `https://github.test/${owner}/${name}`, - defaultBranch: "main", - permissions: { pull: true, push: false, admin: false }, - }; + let account = ACCOUNTS[token]; + if (!account) throw new GitHubError("Not Found", 404); + return scoreRepository(owner, name, account.push); + }, + async repositoryAccess(token: string, owner: string, name: string) { + let account = Object.values(ACCOUNTS).find(value => loginToken(value.login) === token); + return account ? scoreRepository(owner, name, account.push) : undefined; }, }, - } as unknown as PlannerLinkAuth; - return { auth, storage, channel }; + }; + return { + auth: auth as unknown as PlannerLinkAuth & AttachmentAuth & { github: typeof auth.github }, + storage, + sessions, + logins, + channel, + }; } /** A Bun server that serves only the Planner link, as the Chopin server does under HARNESS=remote. */ -export function linkServer(auth: PlannerLinkAuth, timeoutMs = 2_000, revalidateEveryMs?: number) { +export function linkServer( + auth: PlannerLinkAuth & AttachmentAuth, + timeoutMs = 2_000, + revalidateEveryMs?: number, + graceMs = 2_000, +) { let links: PlannerLink[] = []; + let attachments = new PlannerAttachments(auth, graceMs); let server = Bun.serve({ hostname: "127.0.0.1", port: 0, @@ -82,6 +142,7 @@ export function linkServer(auth: PlannerLinkAuth, timeoutMs = 2_000, revalidateE websocket: { open(ws) { openPlannerLinkSocket(ws, auth, { + attachments, timeoutMs, revalidateEveryMs, register(_channelId, link) { @@ -102,15 +163,19 @@ export function linkServer(auth: PlannerLinkAuth, timeoutMs = 2_000, revalidateE }); return { url: `http://127.0.0.1:${server.port}`, + attachments, get links() { return links; }, - stop: () => server.stop(true), + stop: () => { + attachments.close(); + return server.stop(true); + }, }; } -/** Raw frames over a socket that authenticated as the reader, for protocol violations. */ -export async function rawLink(url: string, token = READER_TOKEN) { +/** Raw frames over a socket that authenticated as the member, for protocol violations. */ +export async function rawLink(url: string, token = MEMBER_TOKEN) { let socket = linkSocket(plannerLinkUrl(url), token); let messages: any[] = []; let arrivals = new Set<() => void>(); @@ -205,7 +270,7 @@ export function attach( url: string, document: string, handlers: PlannerLinkHandlers, - token = READER_TOKEN, + token = MEMBER_TOKEN, ): Promise { return connectPlannerLink({ url, token, document, handlers }); } @@ -223,7 +288,7 @@ export async function nodeClient(url: string, document: string) { node, new URL("./planner-link-node-client.mjs", import.meta.url).pathname, url, - READER_TOKEN, + MEMBER_TOKEN, document, ], { stdin: "pipe", stdout: "pipe", stderr: "inherit" }); let reader = child.stdout.getReader(); diff --git a/apps/server/src/wire.ts b/apps/server/src/wire.ts index 314aced8a..d0edba037 100644 --- a/apps/server/src/wire.ts +++ b/apps/server/src/wire.ts @@ -11,7 +11,7 @@ */ import type { Server, ServerWebSocket } from "bun"; -import type { Frame, Outgoing } from "@chopin/protocol"; +import type { Frame, Outgoing, Session } from "@chopin/protocol"; import type { DecisionWatch } from "./socket/decision-watch"; export type Identity = { @@ -82,8 +82,13 @@ export function reply(ws: Sender, rid: string, frame: T): vo } /** Refuse one request, on the socket that made it. */ -export function fail(ws: Sender, rid: string, message: string): void { - ws.send(stamp({ kind: "session:error", ts: 0, message }, { rid })); +export function fail( + ws: Sender, + rid: string, + message: string, + code?: Session.Failure["code"], +): void { + ws.send(stamp({ kind: "session:error", ts: 0, message, ...(code ? { code } : {}) }, { rid })); } /** Tell one socket something it did not ask for. */ diff --git a/apps/web/src/chat/references.test.ts b/apps/web/src/chat/references.test.ts index ff4d39172..d72bb0dfb 100644 --- a/apps/web/src/chat/references.test.ts +++ b/apps/web/src/chat/references.test.ts @@ -18,6 +18,7 @@ import { selectedReplacement, withNoticesAfter, } from "./references"; +import { WireRefusal } from "../wire"; import type { ReferenceDraft, ReferenceTarget } from "./references"; @@ -366,4 +367,10 @@ describe("acknowledged composer drafts", () => { expect(bounded).toEndWith("..."); expect(boundedChatError(new Error("connection lost"))).toContain("Check the connection"); }); + + test("shows a missing remote Planner's instructions whole, however long", () => { + let message = `No Planner is attached to this document. ${"Connect a Planner client. ".repeat(20)}`.trim(); + expect(boundedChatError(new WireRefusal(message, "planner-not-attached"))).toBe(message); + expect(boundedChatError(new WireRefusal(message))).toEndWith("..."); + }); }); diff --git a/apps/web/src/chat/references.ts b/apps/web/src/chat/references.ts index 21b641a32..ffb666b07 100644 --- a/apps/web/src/chat/references.ts +++ b/apps/web/src/chat/references.ts @@ -364,6 +364,7 @@ export function acknowledgeDraft(current: ComposerDraft, submitted: ComposerDraf export function boundedChatError(error: unknown): string { let detail = error instanceof Error ? error.message.trim() : ""; + if (detail && (error as { code?: unknown }).code === "planner-not-attached") return detail; if (!detail || /^(connection lost|disposed|not connected)$/i.test(detail)) { return "Message not sent. Check the connection and try again."; } diff --git a/apps/web/src/wire.ts b/apps/web/src/wire.ts index f7accad90..7248aa37b 100644 --- a/apps/web/src/wire.ts +++ b/apps/web/src/wire.ts @@ -10,6 +10,8 @@ * same build work on localhost, over a LAN address, and through a tunnel. */ +import type { Session } from "@chopin/protocol"; + export type Status = | "connecting" | "connected" @@ -20,6 +22,17 @@ export type Status = export type Unsubscribe = () => void; +/** A request the server refused; `code` marks a refusal to present in full. */ +export class WireRefusal extends Error { + readonly code: Session.Failure["code"]; + + constructor(message: string, code?: Session.Failure["code"]) { + super(message); + this.name = "WireRefusal"; + this.code = code; + } +} + type Frame = { kind: string; ts: number; rid?: string; sender?: string }; type Listener = (frame: never) => void; @@ -267,7 +280,8 @@ export class Wire { if (waiting) { this.#pending.delete(frame.rid); if (frame.kind === "session:error") { - waiting.reject(new Error(String((frame as { message?: string }).message))); + let failure = frame as Partial; + waiting.reject(new WireRefusal(String(failure.message), failure.code)); } else { waiting.resolve(frame as never); } diff --git a/docs/architecture.md b/docs/architecture.md index ef0cacd61..6dba7cebf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -145,8 +145,11 @@ harness sessions; see [Background jobs](background-jobs.md). Under `HARNESS=remote`, `/planner-link` accepts a WebSocket whose upgrade carries a GitHub bearer, checked like `/mcp`. The client attaches to one document and -needs pull access to its repository, rechecked before every session and turn and -periodically while attached. Turns run on the client's model; Chopin's host +needs push or administration access to its repository, rechecked before every +session and turn and periodically while attached. Attaching makes the account's +live browser login the document's Planner owner, one client per document; a +document without one refuses Planner requests with `planner-not-attached`. +Turns run on the client's model; Chopin's host tools, Decisions, and room writes still run in the server under the document's Planner owner and the permission checks above. The client sees what a turn sees and steers its document writes during turns writers start; its run reports never diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index d800024c0..c13f07827 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -35,7 +35,8 @@ the default `copilot-sdk` harness, the Copilot entitlement. Under `HARNESS=pi` and `HARNESS=atomic` the owner supplies only the GitHub token; model access comes from the operator's `HARNESS_AUTH` mode. Under `HARNESS=remote` the owner also supplies only the GitHub token, and the model runs in the client attached -to the document. The user must pass instance +to the document; only [attaching](#remote-planner) makes an account the owner +there. The user must pass instance admission and have repository push or administration access. Ownership is assigned atomically in storage and guarded by a generation token. @@ -51,6 +52,10 @@ call follows the same rules. Its instruction is posted as the caller's own message; the turn runs under the channel's current owner, whoever that is. A channel without one is claimed for the caller's live browser login, hosted or local, and without such a login the call is refused and nothing is posted. +Under `HARNESS=remote` neither `invoke_planner`, `@chopin`, nor a research +request claims ownership: a document without an attached Planner refuses them +with `planner-not-attached`, and one with an attached Planner runs them under +its owner. Planner ownership stores only the owner session ID, referring to a login loaded in this process. Hosted logins separately persist encrypted credentials in @@ -286,14 +291,48 @@ checked exactly as hosted MCP checks it: the token must resolve through instance admission, a bad or missing token gets HTTP 401, a refused account 403, a foreign `Origin` 403, and GitHub outages 503, all before anything is upgraded. The first message must be `attach` with protocol version 1. Its document is resolved like -an MCP document locator, and the bearer's own account must have pull access to -the document's repository. An unsupported version, an unreadable repository, or -a missing or archived document is refused with a `refused` message and close -code 4426, 4403, or 4404. Neither attaching nor a refusal posts anything to the -document. The account and repository access are checked again before every -session and every turn the link starts, including turns of a session kept for -its runs, and every 5 minutes while the link stays attached; losing either -fails that session or turn and closes the link with `access-revoked`. +an MCP document locator, and the bearer's own account must have push or +administration access to the document's repository. An unsupported version, a +repository it cannot write, or a missing or archived document is refused with a +`refused` message and close code 4426, 4403, or 4404. Neither attaching nor a +refusal posts anything to the document. The account and repository access are +checked again before every session and every turn the link starts, including +turns of a session kept for its runs, and every 5 minutes while the link stays +attached; losing either fails that session or turn and closes the link with +`access-revoked`. + +**Ownership.** Attaching makes the account the document's Planner owner through +the same generation-guarded storage claim as any other owner +(`apps/server/src/harness/remote/attachments.ts`). The owner record names the +account's live browser login, not the link, because Chopin's repository tools +run with that login's GitHub App token and never with the link's bearer; an +account with no live sign-in since the server started is refused with +`sign-in-required` (4401). The login must pass the same eligibility as every +Planner owner: instance admission and push or administration access. This is +stricter than the design's "can read the repository", because an attached +client steers what the Planner writes and runs repository tools as the owner. +An owner left without an attachment, which only a race or an operator reset can +produce, is cleared under its generation and the document is claimed again. + +One client holds a document's Planner. While it is attached, every other +`attach` for that document is refused with `planner-attached` (4409) and a +message naming its GitHub login. A link that detaches or drops keeps the +ownership for `PLANNER_ATTACH_GRACE_MS` (2 minutes by default): the same account +can attach again within it and keeps the same ownership generation, and anyone +else is refused. When it ends, the ownership is cleared under its generation, so +a newer owner is never released by an old timer. Attachments live in the server +process; startup already clears every owner reference, which releases every +attachment. + +**Unattached documents.** Members, MCP callers, and research requests never claim +ownership under `HARNESS=remote`. An `@chopin` message or `invoke_planner` call +for a document with no attached client, including one within its grace period, +is refused with `planner-not-attached` before anything is posted or queued. The +refusal names the document URL and says to connect a Planner client to it over +the Planner link from a session in a checkout of the repository, which attaches +that session as the Planner; the browser shows that text on the `@chopin` +request. Room messages are unaffected. Once a client is attached, both arrive as +turns on its link and run under its ownership whoever sent them. **Turns and tools.** Chopin's host tools (document, repository, and job tools) still run in the server against the room, under the document's Planner owner and @@ -333,12 +372,10 @@ gains no shell or filesystem access from this harness. The client, in turn, receives everything a Planner turn sees: the conversation, the document, and the results of repository tools that run with the document Planner owner's GitHub App token, limited to the document's repository, which its own account -can already read. Attaching requires only pull access, yet the attached client -decides what the Planner writes to the document during turns writers start, so -attach only clients you would let edit the document. Until one Planner per -document is enforced, the most recently attached link serves new sessions and -earlier ones stay connected. Summary and research workers have no model in the -server, so `HARNESS=remote` requires `BACKGROUND_JOBS=off`. +can already read. The attached client decides what the Planner writes to the +document during turns writers start, which is why attaching requires the push +or administration access every Planner owner needs. Summary and research workers +have no model in the server, so `HARNESS=remote` requires `BACKGROUND_JOBS=off`. ## Permission checks diff --git a/docs/local-agent-mcp.md b/docs/local-agent-mcp.md index 1fa31cc87..d8d765252 100644 --- a/docs/local-agent-mcp.md +++ b/docs/local-agent-mcp.md @@ -92,6 +92,20 @@ browser `@chopin` message would: - Without either, the call is refused with `planner-owner-unavailable` and nothing is posted. +A server running `HARNESS=remote` claims no ownership here. Its Planner runs in +a client attached to the document over the [Planner link](planner-link.md), and +only attaching makes an account the owner. With a client attached the turn goes +to it and runs under its owner, whoever invoked it. Without one, including +while a detached Planner's grace period runs, the call is refused with +`planner-not-attached` and a `message`, and nothing is posted or queued: + +```json +{ + "code": "planner-not-attached", + "message": "No Planner is attached to this document. To attach one, connect a Planner client to https://chopin.example/documents/octo-org/score/release-readiness over the Planner link from a session in a checkout of octo-org/score; that session becomes this document's Planner." +} +``` + The MCP bearer never becomes the owner or supplies its credentials. The bearer needs repository write access; an owner must also pass the GitHub App installation check. @@ -131,8 +145,13 @@ browser need not already have the document open: Chopin keeps its room alive while the invoked turn and queue run. An interrupted turn is not replayed automatically on restart. -Refusals return an object with `code`: +Refusals return an object with `code`, and `planner-not-attached` also a +`message`: +- `planner-not-attached`: under `HARNESS=remote`, no client is attached as the + document's Planner. `message` names the document URL and says to connect a + Planner client to it over the Planner link from a session in a checkout of + the repository, which attaches that session as the Planner. - `planner-owner-unavailable`: the document has no usable Planner owner and the caller has no live browser login to claim it; sign in to Chopin in a browser as the MCP account, or ask the owner to sign in again. diff --git a/docs/planner-link.md b/docs/planner-link.md index 6d8b9f054..cde385fcb 100644 --- a/docs/planner-link.md +++ b/docs/planner-link.md @@ -81,23 +81,81 @@ The server answers: ``` `attached.document.repository` is `owner/name`, at most 257 characters, and -`url` at most 2048. `attach` succeeds when the bearer's GitHub account has pull -access to the document's repository. After `refused` the server closes the link. - -The server checks the account's admission and pull access again before every -`start` and every `turn`, and every 5 minutes while the link is attached. Once -either is lost it sends `refused` with `access-revoked` and closes the link with -4403; the turn that triggered the check fails. A GitHub outage during a periodic -check leaves the link open. - -| Refusal code | Close code | Meaning | -| ---------------------- | ---------- | ------------------------------------------------- | -| `unsupported-version` | 4426 | The server speaks another `version` | -| `repository-forbidden` | 4403 | The account cannot read the document's repository | -| `document-unavailable` | 4404 | No document matches, or it is being deleted | -| `document-archived` | 4404 | The document is archived | -| `access-revoked` | 4403 | Access was lost after attaching (see below) | -| `unavailable` | 1011 | The server could not check the document | +`url` at most 2048. After `refused` the server closes the link. + +### Ownership + +Attaching makes the client the document's Planner and its account the +document's Planner owner. `attach` succeeds when all of these hold: + +- The bearer's GitHub account has push or administration access to the + document's repository. Pull access alone is refused with + `repository-forbidden`. +- The same account is signed in to Chopin in a browser, on this server, since + the server last started. Chopin's repository tools run with that sign-in's + GitHub App token, never with the link's bearer. Without one, `attach` is + refused with `sign-in-required`; sign in at the server's origin and attach + again. +- That sign-in also passes instance admission and has push or administration + access to the repository. +- No other client holds the document's Planner (see below). + +These are the checks every Planner owner passes. A document has one Planner at a +time. While a client is attached, any other `attach` for that document is +refused with `planner-attached`, including one from the same account, and the +`message` names the holder's GitHub login: + +```text +octocat is attached as this document's Planner. A document has one Planner at a time. +``` + +When the link closes, whether by `detach` or a dropped socket, its account keeps +the ownership for a grace period, 2 minutes unless the operator sets +`PLANNER_ATTACH_GRACE_MS`. During it the same account can attach again and keeps +the ownership; another account is refused with `planner-attached`, and the +`message` says until when the holder keeps it: + +```text +octocat detached from this document's Planner and holds it until 2026-10-10T19:42:00.000Z unless they reattach. A document has one Planner at a time. +``` + +When the grace period ends the ownership is released and any eligible account +can attach. A server restart releases every attachment and ownership at once; +reattach after it. + +The server checks the account's admission and push or administration access +again before every `start` and every `turn`, and every 5 minutes while the link +is attached. Once either is lost, or the document is archived or deleted, it +sends `refused` with `access-revoked` and closes the link with 4403; the turn +that triggered the check fails. A GitHub outage during a periodic check leaves +the link open. + +| Refusal code | Close code | Meaning | +| ---------------------- | ---------- | ---------------------------------------------------------------------- | +| `unsupported-version` | 4426 | The server speaks another `version` | +| `repository-forbidden` | 4403 | The account cannot push to or administer the document's repository | +| `sign-in-required` | 4401 | The account has no live Chopin browser sign-in on this server | +| `planner-attached` | 4409 | Another client holds the document's Planner; `message` names its login | +| `document-unavailable` | 4404 | No document matches, or it is being deleted | +| `document-archived` | 4404 | The document is archived | +| `access-revoked` | 4403 | Access was lost after attaching (see above) | +| `unavailable` | 1011 | The server could not check the document | + +### Without an attached Planner + +On a server running `HARNESS=remote`, an `@chopin` message in the browser and +an MCP `invoke_planner` call for a document with no attached client are refused +with the code `planner-not-attached`, and nothing is posted or queued. A +document whose Planner is within its grace period counts as unattached. The +refusal's message names the document URL: + +```text +No Planner is attached to this document. To attach one, connect a Planner client to over the Planner link from a session in a checkout of ; that session becomes this document's Planner. +``` + +[Local agent MCP](local-agent-mcp.md#hand-an-instruction-to-the-planner) shows +how `invoke_planner` returns it. Once a client is attached, both arrive as +`turn`s on its link. ## Server to client @@ -185,7 +243,8 @@ check leaves the link open. before `turn-end` so the server keeps the session while runs are live. Reports only change run cards and Chat notices; they never start a Planner turn. - `control-result` answers `stop` or `resume`. -- `detach` closes the link with code 1000. +- `detach` closes the link with code 1000. The account keeps the document's + Planner for the grace period in [Ownership](#ownership). Messages for a session or turn that has ended are ignored, except a `host-tool-call`, which gets an error result (`The turn has ended.` or @@ -321,16 +380,19 @@ ids. When the link closes, the server shows the reported live runs as stopped. | 1008 | `error` with `malformed-message` or `protocol` | Unparseable, invalid, or out-of-order message | | 1009 | `error` with `message-too-large` | A frame over 2 MiB | | 1011 | `refused` with `unavailable` | The server could not check the document | +| 4401 | `refused` | `sign-in-required` | | 4403 | `refused` | `repository-forbidden` or `access-revoked` | | 4404 | `refused` | `document-unavailable` or `document-archived` | +| 4409 | `refused` | `planner-attached` | | 4426 | `refused` | `unsupported-version` | `protocol` covers a message before `attach` is answered, a second `attach`, and a repeated `input-request` id the server remembers. When the link closes for any reason, the server ends a running turn with an error, stops the turn's host tools still running, withdraws open questions and the Decisions those tools -opened, and forgets the link; reattaching starts new sessions. A server never -replays an interrupted turn. +opened, and forgets the link; reattaching starts new sessions. The account keeps +the document's Planner ownership for the grace period described in +[Ownership](#ownership). A server never replays an interrupted turn. A client that receives a frame it cannot parse should close with 4400 and reason `malformed-message`. Node.js's `WebSocket` lets a caller close only with 1000 or diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 203981da6..62da78f2c 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -255,15 +255,18 @@ bind to a public interface. Startup refuses `HARNESS_AUTH` and requires model. The route exists only with `AGENT` on and `HARNESS=remote`. The trust boundary moves to whoever attaches. A client authenticates with a -GitHub token bearer, checked like hosted MCP, and needs pull access to the -document's repository, rechecked before every session and turn and every 5 -minutes while it stays attached. It then receives what a Planner turn sees, -including repository reads made with the document Planner owner's token, and -decides what the Planner writes during turns writers start; its run reports -never start a turn. The server never runs the client's -tools and gains no shell or filesystem access from this harness. Until one -Planner per document is enforced, the most recently attached link serves new -sessions. Without an attached client, a Planner turn fails as unavailable. See +GitHub token bearer, checked like hosted MCP, and needs push or administration +access to the document's repository, rechecked before every session and turn and +every 5 minutes while it stays attached. The same account must have signed in to +this server in a browser: attaching makes that login the document's Planner +owner, one client per document, and a detached or dropped link keeps it for +`PLANNER_ATTACH_GRACE_MS` so the same account can reattach. The client then +receives what a Planner turn sees, including repository reads made with that +owner's token, and decides what the Planner writes during turns writers start; +its run reports never start a turn. The server never runs the client's tools and +gains no shell or filesystem access from this harness. Without an attached +client, `@chopin` and `invoke_planner` are refused with `planner-not-attached` +and instructions to attach one. See [Remote Planner](hosted-agent.md#remote-planner) for the behaviour and [Planner link](planner-link.md) for the wire format. The link shares the application's origin and port with `/ws` and `/mcp`, so a TLS-terminating proxy @@ -339,6 +342,7 @@ the image. | `PORT` | `8787` | Source-process HTTP and WebSocket port. The supplied image and health check expect internal port 8787. | | `MODEL` | `gpt-6-luna` | Model requested for hosted agent sessions. Required under `HARNESS=pi` and `HARNESS=atomic`; under `atomic` it must be `provider/model` from Atomic's catalog. Optional under `HARNESS=remote`, where the attached client chooses its model and a set value is only passed along as the turn's request. | | `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk`, `pi`, `atomic`, or `remote`). An unknown name refuses at startup. `remote` runs Planner turns in a client attached at `/planner-link`; see [The remote Planner](#the-remote-planner). `atomic` gives every Planner session shell and filesystem access as the server process's user, hosted instances included; see [Choose and trust a harness](#choose-and-trust-a-harness). | +| `PLANNER_ATTACH_GRACE_MS` | `120000` | Under `HARNESS=remote`, how long a detached or dropped Planner link keeps the document's Planner ownership so the same account can reattach, in milliseconds, an integer between 0 and 3600000. | | `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. For `atomic`, it is required and must be `auto`, which requires a loopback-only `SERVER_HOST`, or `ai-gateway`; every other value is refused. For `remote`, any value is refused. | | `HARNESS_EXTENSIONS` | unset | Extension or package paths every atomic Planner session loads, as Atomic's `--extension` flag would, separated by the platform path delimiter (`:`, or `;` on Windows). A package's extensions, skills, and workflows all register. Each path must be absolute and exist; any other harness refuses the variable at startup. Background workers never load them. The code runs in the server process as its user. | | `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | diff --git a/packages/planner-link/src/client.ts b/packages/planner-link/src/client.ts index b81a0a113..de6c5b061 100644 --- a/packages/planner-link/src/client.ts +++ b/packages/planner-link/src/client.ts @@ -67,7 +67,7 @@ export type PlannerLinkHandlers = { export type PlannerLinkOptions = { /** The Chopin origin, or the link's own `ws:`/`wss:` URL. */ url: string | URL; - /** A GitHub token whose account can read the document's repository. */ + /** A GitHub token whose account can push to or administer the document's repository. */ token: string; /** The document's id or URL. */ document: string; diff --git a/packages/planner-link/src/protocol.ts b/packages/planner-link/src/protocol.ts index 07ecea1b2..329805662 100644 --- a/packages/planner-link/src/protocol.ts +++ b/packages/planner-link/src/protocol.ts @@ -18,6 +18,7 @@ export const LINK_CLOSE = { unauthorized: 4401, forbidden: 4403, unavailable: 4404, + attached: 4409, unsupportedVersion: 4426, } as const; @@ -252,6 +253,8 @@ export const refusalCodeSchema = z.enum([ "document-unavailable", "document-archived", "access-revoked", + "planner-attached", + "sign-in-required", "unavailable", ]); diff --git a/packages/protocol/index.d.ts b/packages/protocol/index.d.ts index e789f2183..f042b0843 100644 --- a/packages/protocol/index.d.ts +++ b/packages/protocol/index.d.ts @@ -110,6 +110,12 @@ export declare namespace Session { /** A request could not be served. Carries the `rid` it answers. */ export type Failure = KIND<"session:error"> & { message: string; + /** + * Set for a refusal the client presents in full rather than as a generic + * failure. `planner-not-attached`: a Planner request under `HARNESS=remote` + * to a document no client is attached to; `message` says how to attach one. + */ + code?: "planner-not-attached"; }; /** Liveness, and the smallest thing that proves request correlation works. */ From e32b6cc11ad609dd71470e0c8418d9af8279c950 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 10 Oct 2026 20:03:30 -0700 Subject: [PATCH 08/10] Release a remote Planner attachment when its owner signs out or is reset An attachment trusted its in-memory link after the stored ownership under it was gone. Signing the owner's browser login out, its expiry, or a repository writer's Planner reset left the link attached, so `@chopin` and invoke_planner failed later with planner-owner-unavailable instead of planner-not-attached, and no one could attach until the old socket closed. Ending a login now closes every link it owns with sign-in-required (4401) and clears that ownership under its generation; a reset closes the link with access-revoked (4403). Neither starts a grace period, so any eligible account can attach at once. The checks before every start and turn, and the periodic one, also close a link whose stored owner no longer names its login and generation, or whose login expired without being revoked. The documentation now limits planner-not-attached to `@chopin` and invoke_planner, says how other Planner requests behave unattached, and says model-backed research is unavailable under the remote Planner. Assistant-model: Claude Opus 5.5 (fast) Assistant-workflow: hosted-planner-slices Assistant-duration: 6m converged, estimated 10m Assistant-verification: bun run types passed: every workspace package and e2e Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test passed: 4663 pass, 0 fail Assistant-verification: bun run ci passed: dprint, oxlint, tokens, design contract, Impeccable Assistant-verification: mutation check passed: disabling sessionRevoked, ownerReset and verify fails all six new link and chat service tests Co-authored-by: Alex Lavaee --- AGENTS.md | 5 +- apps/server/src/chat/remote-planner.test.ts | 58 +++++++++- apps/server/src/harness/remote/attachments.ts | 96 ++++++++++++++--- apps/server/src/harness/remote/endpoint.ts | 3 +- apps/server/src/harness/remote/link.test.ts | 101 ++++++++++++++++++ apps/server/src/harness/remote/link.ts | 6 ++ apps/server/src/main.ts | 7 +- apps/server/src/testing/planner-link.ts | 19 +++- docs/architecture.md | 4 +- docs/hosted-agent.md | 18 +++- docs/local-agent-mcp.md | 3 +- docs/planner-link.md | 25 ++++- 12 files changed, 317 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 87d7585ef..59c8fca4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,8 +142,9 @@ collaborative state and external implementation runs are durable. host tools, Decisions, and room writes still run in the server. The attached client sees what a turn sees and steers its document writes. Attaching makes the account's live browser login the document's Planner owner, one client per - document; nothing else claims ownership, and Planner requests to a document - without one fail with `planner-not-attached`. See + document; nothing else claims ownership. `@chopin` and `invoke_planner` on a + document without one fail with `planner-not-attached`. Signing out the + owner's login, its expiry, or a Planner reset closes the link. See [Hosted agent](docs/hosted-agent.md#remote-planner). - **Repository node IDs are authoritative.** Owner and repository names resolve GitHub requests but never replace the stored node identity. diff --git a/apps/server/src/chat/remote-planner.test.ts b/apps/server/src/chat/remote-planner.test.ts index 1104a16ca..4fb1e909e 100644 --- a/apps/server/src/chat/remote-planner.test.ts +++ b/apps/server/src/chat/remote-planner.test.ts @@ -7,7 +7,14 @@ import { notAttachedMessage } from "../harness/remote/attachments"; import { openPlannerSession } from "../harness/session"; import * as Plan from "../plan/service"; import { configured } from "../testing/config"; -import { attach, finishTurn, linkAuth, linkServer, scriptedPlanner } from "../testing/planner-link"; +import { + attach, + finishTurn, + linkAuth, + linkServer, + RIVAL_TOKEN, + scriptedPlanner, +} from "../testing/planner-link"; import * as Chat from "./service"; import type { Server } from "bun"; @@ -41,9 +48,10 @@ function card(status: "running" | "paused") { * login of their own; only the attached member may own the Planner. */ async function remoteRoom() { - let { auth, storage, channel, logins } = await linkAuth(); + let { auth, storage, channel, logins, revocations, signOut } = await linkAuth(); let server = linkServer(auth); cleanups.push(server.stop); + revocations.add(id => server.attachments.sessionRevoked(id)); let lease = (await storage.leases.acquire("writer", "test", 60_000))!; let events: Array<{ kind: string; [key: string]: unknown }> = []; let publisher = { @@ -115,7 +123,7 @@ async function remoteRoom() { }; let owner = async () => (await storage.channels.readAgent(channel.id, new Date()))?.agent?.ownerSessionId; - return { context, server, channel, logins, url, ws, send, owner, events }; + return { context, server, storage, channel, logins, url, ws, send, owner, events, signOut }; } test("with no attached Planner, @chopin and invoke_planner are refused with how to attach one, and nothing is posted or queued", async () => { @@ -225,3 +233,47 @@ test("an attached Planner receives @chopin and invoke_planner turns as their own "@rival resumed the Planner's workflows.", ); }); + +test("once the attached Planner's owner signs out or is reset, @chopin and invoke_planner are refused again, and another member can attach", async () => { + let room = await remoteRoom(); + let { context } = room; + let refusals = async () => { + expect(await room.send("@chopin draft the plan")).toMatchObject({ + kind: "session:error", + code: "planner-not-attached", + }); + expect(await Chat.invoke(context, RIVAL, "Draft the plan")).toBe("planner-not-attached"); + expect(context.chat.entries).toEqual([]); + expect(context.chat.waiting).toEqual([]); + }; + + let member = await attach(room.server.url, room.channel.id, scriptedPlanner().handlers); + expect(await room.owner()).toBe(room.logins.get("member")); + await room.signOut("member"); + expect(await member.closed).toEqual({ code: 4401, reason: "sign-in-required" }); + expect(await room.owner()).toBeUndefined(); + await refusals(); + + let script = scriptedPlanner(); + let rival = await attach(room.server.url, room.channel.id, script.handlers, RIVAL_TOKEN); + expect(await room.owner()).toBe(room.logins.get("rival")); + let held = (await room.storage.channels.readAgent(room.channel.id, new Date()))!.agent!; + await room.storage.channels.clearAgentOwner( + room.channel.id, + held.ownerSessionId!, + held.generation, + new Date(), + ); + room.server.attachments.ownerReset(room.channel.id); + expect(await rival.closed).toEqual({ code: 4403, reason: "access-revoked" }); + await refusals(); + expect(script.turns).toEqual([]); + + let again = await attach(room.server.url, room.channel.id, script.handlers, RIVAL_TOKEN); + cleanups.push(() => again.detach()); + expect(await room.send("@chopin draft the plan")).toMatchObject({ kind: "chat:send" }); + await context.chat.running; + expect(script.turns.map(turn => turn.prompt)).toEqual([ + expect.stringContaining("draft the plan"), + ]); +}); diff --git a/apps/server/src/harness/remote/attachments.ts b/apps/server/src/harness/remote/attachments.ts index 492f1d7a8..f2b9bbe71 100644 --- a/apps/server/src/harness/remote/attachments.ts +++ b/apps/server/src/harness/remote/attachments.ts @@ -7,6 +7,8 @@ * tools act with that login's GitHub App token, never with the link's bearer. * A dropped or detached link keeps its ownership for a grace period, so the * same account can reattach without losing it; then the ownership is cleared. + * Ending that login, resetting the document's Planner, or any other release of + * the stored ownership closes the link and frees the document at once. */ import { GitHubError } from "../../github/client"; @@ -24,7 +26,7 @@ export type AttachmentAuth = { storage: Pick; github: Pick; admission: Pick; - sessions: Pick; + sessions: Pick; clock: () => Date; }; @@ -36,7 +38,7 @@ export type AttachRequest = { export type AttachRefusal = { ok: false; - code: "planner-attached" | "sign-in-required" | "repository-forbidden"; + code: "planner-attached" | "sign-in-required" | "repository-forbidden" | "access-revoked"; message: string; }; @@ -91,6 +93,55 @@ export class PlannerAttachments { return this.#serialized(request.channelId, () => this.#attach(request, link)); } + /** The browser login ended: every document it owns through an attachment is released. */ + async sessionRevoked(sessionId: string): Promise { + let held = [...this.#holds.values()].filter(hold => hold.ownerSessionId === sessionId); + for (let hold of held) { + this.#invalidate(hold, signInEndedRefusal(this.#auth.config.origin, hold.login)); + } + await Promise.all(held.map(hold => this.#release(hold))); + } + + /** A repository writer reset the document's Planner, which already cleared its stored owner. */ + ownerReset(channelId: string): void { + let hold = this.#holds.get(channelId); + if (!hold) return; + this.#invalidate(hold, { + ok: false, + code: "access-revoked", + message: `A repository writer reset this document's Planner, so ${hold.login} is no longer ` + + `attached. Attach again to resume.`, + }); + } + + /** + * Whether `link` still holds the document: the stored owner is still its + * attachment's login and generation, and that login is still live. + * Otherwise the link closes and the document is free to attach. + */ + async verify(channelId: string, link: PlannerLink): Promise { + let hold = this.#holds.get(channelId); + if (hold?.link !== link) return false; + let stored = await this.#auth.storage.channels.readAgent(channelId, this.#auth.clock()); + if (this.#holds.get(channelId) !== hold || hold.link !== link) return false; + if ( + stored?.agent?.ownerSessionId !== hold.ownerSessionId + || stored.agent.generation !== hold.generation + ) { + this.#invalidate(hold, { + ok: false, + code: "access-revoked", + message: `This document's Planner ownership was released, so ${hold.login} is no longer ` + + `attached. Attach again to resume.`, + }); + return false; + } + if (await this.#auth.sessions.resolve(hold.ownerSessionId)) return true; + this.#invalidate(hold, signInEndedRefusal(this.#auth.config.origin, hold.login)); + await this.#release(hold); + return false; + } + /** Stops every grace timer. Ownership stays in storage, which startup clears. */ close(): void { for (let hold of this.#holds.values()) clearTimeout(hold.timer); @@ -191,19 +242,30 @@ export class PlannerAttachments { return this.#serialized(hold.channelId, async () => { if (this.#holds.get(hold.channelId) !== hold || hold.link) return; this.#drop(hold); - try { - await this.#auth.storage.channels.clearAgentOwner( - hold.channelId, - hold.ownerSessionId, - hold.generation, - this.#auth.clock(), - ); - } catch (error) { - console.error("[planner-link] releasing the Planner owner failed:", error); - } + await this.#release(hold); }); } + /** Forgets the hold before its link closes, so closing starts no grace period. */ + #invalidate(hold: Hold, refusal: AttachRefusal): void { + if (this.#holds.get(hold.channelId) !== hold) return; + this.#drop(hold); + hold.link?.revoke(refusal.code, refusal.message); + } + + async #release(hold: Hold): Promise { + try { + await this.#auth.storage.channels.clearAgentOwner( + hold.channelId, + hold.ownerSessionId, + hold.generation, + this.#auth.clock(), + ); + } catch (error) { + console.error("[planner-link] releasing the Planner owner failed:", error); + } + } + #drop(hold: Hold): void { clearTimeout(hold.timer); hold.timer = undefined; @@ -238,3 +300,13 @@ function signInRefusal(origin: string, login: string): AttachRefusal { + `The Planner's repository tools act with that sign-in.`, }; } + +function signInEndedRefusal(origin: string, login: string): AttachRefusal { + return { + ok: false, + code: "sign-in-required", + message: `The Chopin browser sign-in that made ${login} this document's Planner ended, so the ` + + `Planner was released. Sign in to Chopin at ${origin} in a browser as ${login}, then ` + + `attach again.`, + }; +} diff --git a/apps/server/src/harness/remote/endpoint.ts b/apps/server/src/harness/remote/endpoint.ts index de2891c23..59531b47e 100644 --- a/apps/server/src/harness/remote/endpoint.ts +++ b/apps/server/src/harness/remote/endpoint.ts @@ -147,7 +147,8 @@ export function plannerLink( let located = await locateDocument(auth, caller, channelId); return typeof located === "object" && located.channel.id === channelId && writable(located.repository) - && !located.channel.archivedAt && !hooks.unavailable?.(channelId); + && !located.channel.archivedAt && !hooks.unavailable?.(channelId) + && await hooks.attachments.verify(channelId, link); } catch (err) { if (revoked(err)) return false; throw err; diff --git a/apps/server/src/harness/remote/link.test.ts b/apps/server/src/harness/remote/link.test.ts index 8f3427c0e..b23f6d750 100644 --- a/apps/server/src/harness/remote/link.test.ts +++ b/apps/server/src/harness/remote/link.test.ts @@ -329,6 +329,107 @@ describe("Planner link ownership", () => { expect(await client.closed).toEqual({ code: LINK_CLOSE.forbidden, reason: "access-revoked" }); expect(server.links).toEqual([]); }); + + /** A member's raw link attached to the document, with ended logins reaching `attachments`. */ + async function attachedMember(revalidateEveryMs?: number) { + let linked = await linkAuth(); + let server = linkServer(linked.auth, 2_000, revalidateEveryMs, 2_000); + cleanups.push(server.stop); + linked.revocations.add(id => server.attachments.sessionRevoked(id)); + let raw = await rawLink(server.url); + raw.send({ type: "attach", version: PLANNER_LINK_VERSION, document: linked.channel.id }); + await raw.next("attached"); + expect(server.attachments.attached(linked.channel.id)).toBe(true); + return { ...linked, server, raw }; + } + + async function rivalAttaches( + url: string, + channelId: string, + storage: Awaited>["storage"], + rival: string, + ) { + let client = await attach(url, channelId, scriptedPlanner().handlers, RIVAL_TOKEN); + cleanups.push(() => client.detach()); + expect(await owner(storage, channelId)).toMatchObject({ ownerSessionId: rival }); + } + + it("releases the Planner at once when its owner signs out of the browser", async () => { + let { server, raw, channel, storage, logins, signOut } = await attachedMember(); + expect(await signOut("member")).toBe(logins.get("member")); + expect(await raw.next("refused")).toEqual({ + type: "refused", + code: "sign-in-required", + message: "The Chopin browser sign-in that made member this document's Planner ended, so " + + "the Planner was released. Sign in to Chopin at https://chopin.test in a browser as " + + "member, then attach again.", + }); + expect((await raw.closed).code).toBe(LINK_CLOSE.unauthorized); + expect(server.attachments.attached(channel.id)).toBe(false); + expect(server.attachments.holder(channel.id)).toBeUndefined(); + expect((await owner(storage, channel.id))?.ownerSessionId).toBeUndefined(); + await rivalAttaches(server.url, channel.id, storage, logins.get("rival")!); + }); + + it("releases the Planner when its owner's browser sign-in expires", async () => { + let { server, raw, channel, storage, sessions, clock } = await attachedMember(); + clock.skewMs = 31 * 24 * 60 * 60 * 1_000; + await sessions.cleanupExpired(); + expect(await raw.next("refused")).toMatchObject({ code: "sign-in-required" }); + expect((await raw.closed).code).toBe(LINK_CLOSE.unauthorized); + expect(server.attachments.attached(channel.id)).toBe(false); + expect((await owner(storage, channel.id))?.ownerSessionId).toBeUndefined(); + }); + + it("notices an expired owner sign-in on its periodic recheck when nothing revoked it", async () => { + let { server, raw, channel, storage, clock } = await attachedMember(20); + clock.skewMs = 31 * 24 * 60 * 60 * 1_000; + expect(await raw.next("refused")).toMatchObject({ code: "sign-in-required" }); + expect((await raw.closed).code).toBe(LINK_CLOSE.unauthorized); + expect(server.attachments.attached(channel.id)).toBe(false); + expect((await owner(storage, channel.id))?.ownerSessionId).toBeUndefined(); + }); + + it("releases the attachment when a repository writer resets the document's Planner", async () => { + let { server, raw, channel, storage, logins } = await attachedMember(); + let held = (await owner(storage, channel.id))!; + await storage.channels.clearAgentOwner( + channel.id, + held.ownerSessionId!, + held.generation, + new Date(), + ); + server.attachments.ownerReset(channel.id); + expect(await raw.next("refused")).toEqual({ + type: "refused", + code: "access-revoked", + message: "A repository writer reset this document's Planner, so member is no longer " + + "attached. Attach again to resume.", + }); + expect((await raw.closed).code).toBe(LINK_CLOSE.forbidden); + expect(server.attachments.attached(channel.id)).toBe(false); + await rivalAttaches(server.url, channel.id, storage, logins.get("rival")!); + }); + + it("closes a link whose stored ownership was released elsewhere on its next recheck", async () => { + let { server, raw, channel, storage, logins } = await attachedMember(20); + let held = (await owner(storage, channel.id))!; + await storage.channels.clearAgentOwner( + channel.id, + held.ownerSessionId!, + held.generation, + new Date(), + ); + expect(await raw.next("refused")).toEqual({ + type: "refused", + code: "access-revoked", + message: "This document's Planner ownership was released, so member is no longer " + + "attached. Attach again to resume.", + }); + expect((await raw.closed).code).toBe(LINK_CLOSE.forbidden); + expect(server.attachments.attached(channel.id)).toBe(false); + await rivalAttaches(server.url, channel.id, storage, logins.get("rival")!); + }); }); describe("Planner link protocol", () => { diff --git a/apps/server/src/harness/remote/link.ts b/apps/server/src/harness/remote/link.ts index 5a299e1d7..f84f370f3 100644 --- a/apps/server/src/harness/remote/link.ts +++ b/apps/server/src/harness/remote/link.ts @@ -643,6 +643,12 @@ export class PlannerLink { this.#dispatch(message); } + /** Tells the client why it is no longer the Planner, then closes the link. */ + revoke(code: RefusalCode, message: string): void { + if (this.#state === "closed") return; + this.#refuse(code, message); + } + /** Ends the link from the server side. */ close(code: number, reason: string): void { if (this.#state === "closed") return; diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 76e59f3e1..727abcd05 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1620,7 +1620,11 @@ async function sessionRevoked(sessionId: string): Promise { experiments?.connections.revokeSession(sessionId); let jobs = jobRunner?.ownerRevoked(sessionId); ownerBindings?.revokeSession(sessionId); - await Promise.all([resetOpenAgents(() => true, sessionId), jobs]); + await Promise.all([ + resetOpenAgents(() => true, sessionId), + jobs, + plannerAttachments?.sessionRevoked(sessionId), + ]); } async function credentialsWillRotate(sessionId: string, revision: number): Promise { @@ -1640,6 +1644,7 @@ async function credentialsWillRotate(sessionId: string, revision: number): Promi async function channelOwnerReset(channelId: string): Promise { let jobs = jobRunner?.channelOwnerReset(channelId); ownerBindings?.revokeChannel(channelId); + plannerAttachments?.ownerReset(channelId); await Promise.all([resetOpenAgents(room => room.id === channelId), jobs]); } diff --git a/apps/server/src/testing/planner-link.ts b/apps/server/src/testing/planner-link.ts index ce4acbee1..55a4b38a9 100644 --- a/apps/server/src/testing/planner-link.ts +++ b/apps/server/src/testing/planner-link.ts @@ -61,8 +61,15 @@ export async function linkAuth(channelId?: string) { for (let account of Object.values(ACCOUNTS)) { await storage.users.put({ id: account.id, login: account.login, avatarUrl: "", now }); } - let sessions = new Sessions(storage, false); + let clock = { skewMs: 0 }; + let revocations = new Set<(sessionId: string) => Promise>(); + let sessions = new Sessions(storage, false, () => new Date(Date.now() + clock.skewMs), { + onRevoked: async id => { + for (let revoked of revocations) await revoked(id); + }, + }); let logins = new Map(); + let cookies = new Map(); for (let account of Object.values(ACCOUNTS)) { if (!account.signedIn) continue; let issued = await sessions.issue(account.id, { @@ -72,6 +79,7 @@ export async function linkAuth(channelId?: string) { refreshExpiresIn: 86_400, }); logins.set(account.login, issued.id); + cookies.set(account.login, issued.cookie.split(";")[0]!); } let channel = await storage.channels.create({ id: channelId ?? crypto.randomUUID(), @@ -117,6 +125,15 @@ export async function linkAuth(channelId?: string) { sessions, logins, channel, + /** Moves the browser logins' clock, so they expire without the server's clock moving. */ + clock, + /** Called with every browser login that ends, as `main.ts` wires `onSessionRevoked`. */ + revocations, + /** Signs `login` out of the browser, as the logout route does. */ + async signOut(login: string) { + let cookie = cookies.get(login)!; + return sessions.revoke(new Request("https://chopin.test/", { headers: { cookie } })); + }, }; } diff --git a/docs/architecture.md b/docs/architecture.md index 6dba7cebf..0499232b3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -148,7 +148,9 @@ a GitHub bearer, checked like `/mcp`. The client attaches to one document and needs push or administration access to its repository, rechecked before every session and turn and periodically while attached. Attaching makes the account's live browser login the document's Planner owner, one client per document; a -document without one refuses Planner requests with `planner-not-attached`. +document without one refuses `@chopin` and `invoke_planner` with +`planner-not-attached`. The link closes when that login ends or the document's +Planner is reset. Turns run on the client's model; Chopin's host tools, Decisions, and room writes still run in the server under the document's Planner owner and the permission checks above. The client sees what a turn sees diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index c13f07827..17404ea7c 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -52,10 +52,20 @@ call follows the same rules. Its instruction is posted as the caller's own message; the turn runs under the channel's current owner, whoever that is. A channel without one is claimed for the caller's live browser login, hosted or local, and without such a login the call is refused and nothing is posted. -Under `HARNESS=remote` neither `invoke_planner`, `@chopin`, nor a research -request claims ownership: a document without an attached Planner refuses them -with `planner-not-attached`, and one with an attached Planner runs them under -its owner. +Under `HARNESS=remote` only attaching claims ownership. An `@chopin` message or +`invoke_planner` call for a document without an attached Planner is refused with +`planner-not-attached`, and with one it runs as a turn on that Planner under its +owner. Other Planner requests, such as sending a comment to the Planner or a +Build draft, claim nothing either: with a client attached they run on it, and +without one they fail as any request without a usable owner does, not with +`planner-not-attached`. Model-backed research is unavailable, because the remote +Planner requires `BACKGROUND_JOBS=off`; a research request claims no ownership +and is refused as not ready. + +An attachment lasts only as long as its owner. Signing that browser login out, +its expiry, or a repository writer's reset of the document's Planner closes the +attached link at once and frees the document for any eligible account to attach; +see [Planner link ownership](planner-link.md#ownership). Planner ownership stores only the owner session ID, referring to a login loaded in this process. Hosted logins separately persist encrypted credentials in diff --git a/docs/local-agent-mcp.md b/docs/local-agent-mcp.md index d8d765252..a2016211e 100644 --- a/docs/local-agent-mcp.md +++ b/docs/local-agent-mcp.md @@ -96,7 +96,8 @@ A server running `HARNESS=remote` claims no ownership here. Its Planner runs in a client attached to the document over the [Planner link](planner-link.md), and only attaching makes an account the owner. With a client attached the turn goes to it and runs under its owner, whoever invoked it. Without one, including -while a detached Planner's grace period runs, the call is refused with +while a detached Planner's grace period runs and once the attached Planner's +owner signs out of the browser or its Planner is reset, the call is refused with `planner-not-attached` and a `message`, and nothing is posted or queued: ```json diff --git a/docs/planner-link.md b/docs/planner-link.md index cde385fcb..8e0cc656a 100644 --- a/docs/planner-link.md +++ b/docs/planner-link.md @@ -130,15 +130,36 @@ sends `refused` with `access-revoked` and closes the link with 4403; the turn that triggered the check fails. A GitHub outage during a periodic check leaves the link open. +The ownership also ends, without a grace period, when the browser sign-in it +rests on ends or the document's Planner is reset. The server then sends +`refused` and closes an attached link at once, and any eligible account, +including the same one, can attach again immediately: + +| Cause | Refusal code | Close code | +| -------------------------------------------------------------- | ------------------ | ---------- | +| The owner's browser sign-in signs out or expires | `sign-in-required` | 4401 | +| A repository writer resets the document's Planner | `access-revoked` | 4403 | +| The stored ownership no longer names this attachment's sign-in | `access-revoked` | 4403 | + +The same checks before every `start` and `turn`, and the periodic one, also +find an expired sign-in or a released ownership that nothing reported. The +`message` says what happened: + +```text +The Chopin browser sign-in that made octocat this document's Planner ended, so the Planner was released. Sign in to Chopin at https://chopin.example in a browser as octocat, then attach again. +A repository writer reset this document's Planner, so octocat is no longer attached. Attach again to resume. +This document's Planner ownership was released, so octocat is no longer attached. Attach again to resume. +``` + | Refusal code | Close code | Meaning | | ---------------------- | ---------- | ---------------------------------------------------------------------- | | `unsupported-version` | 4426 | The server speaks another `version` | | `repository-forbidden` | 4403 | The account cannot push to or administer the document's repository | -| `sign-in-required` | 4401 | The account has no live Chopin browser sign-in on this server | +| `sign-in-required` | 4401 | The account has no live Chopin browser sign-in, or it ended | | `planner-attached` | 4409 | Another client holds the document's Planner; `message` names its login | | `document-unavailable` | 4404 | No document matches, or it is being deleted | | `document-archived` | 4404 | The document is archived | -| `access-revoked` | 4403 | Access was lost after attaching (see above) | +| `access-revoked` | 4403 | Access or the Planner ownership was lost after attaching (see above) | | `unavailable` | 1011 | The server could not check the document | ### Without an attached Planner From 6114363001cf81ed7fc4043f8c22a2e6e6a1b89e Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 10 Oct 2026 20:18:45 -0700 Subject: [PATCH 09/10] Refuse an attach whose ownership ended while it was attaching A grace reattach read the stored owner and then bound the hold it had captured, without checking whether a Planner reset or the owner's sign-out had released that hold during the read. A snapshot taken before the reset could still match, so the client reported attached while the server had no owner, refused attached turns, and kept an orphaned registry link. Each attach now records the sign-ins it rests on while it runs. A reset of its document or the end of one of those sign-ins marks it, and it binds only if nothing marked it and its owner sign-in is still live after its last await; otherwise it releases any ownership it claimed, under its generation, and is refused with the code an attached link would be closed with. The recheck of an attached link now asks whether the owner's sign-in is live before comparing the stored owner. PostgreSQL and memory storage report an expired sign-in's ownership as released, so an expiry that cleanup had not yet seen closed the link with access-revoked (4403) instead of sign-in-required (4401). Assistant-model: Claude Opus 5.5 (fast) Assistant-workflow: hosted-planner-slices Assistant-duration: 9m converged, estimated 15m Assistant-verification: bun run types passed: every workspace package and e2e Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test passed: 4668 pass, 4 skip, 0 fail Assistant-verification: bun run ci passed: dprint, oxlint, tokens, design contract, Impeccable Assistant-verification: PostgreSQL contract passed: bun test apps/server/src/storage/postgres against disposable postgres:17-alpine on 55432, 85 pass, container stopped Assistant-verification: mutation check passed: the previous attachments.ts fails all five new link and storage contract tests Co-authored-by: Alex Lavaee --- apps/server/src/harness/remote/attachments.ts | 112 +++++++++++++----- apps/server/src/harness/remote/link.test.ts | 89 ++++++++++++++ .../storage/planner-attachment-contract.ts | 90 +++++++++++++- docs/hosted-agent.md | 8 +- docs/planner-link.md | 8 +- 5 files changed, 272 insertions(+), 35 deletions(-) diff --git a/apps/server/src/harness/remote/attachments.ts b/apps/server/src/harness/remote/attachments.ts index f2b9bbe71..11028ec29 100644 --- a/apps/server/src/harness/remote/attachments.ts +++ b/apps/server/src/harness/remote/attachments.ts @@ -56,6 +56,13 @@ type Hold = { releaseAt?: Date; }; +type PendingAttach = { + login: string; + held?: Hold; + sessionIds: Set; + invalidated?: AttachRefusal; +}; + /** What `@chopin` and `invoke_planner` say when no Planner is attached to the document. */ export function notAttachedMessage(url: string, repository: string): string { return `No Planner is attached to this document. To attach one, connect a Planner client to ` @@ -71,6 +78,7 @@ export class PlannerAttachments { #auth: AttachmentAuth; #graceMs: number; #holds = new Map(); + #pending = new Map(); #queues = new Map>(); constructor(auth: AttachmentAuth, graceMs: number) { @@ -95,50 +103,52 @@ export class PlannerAttachments { /** The browser login ended: every document it owns through an attachment is released. */ async sessionRevoked(sessionId: string): Promise { - let held = [...this.#holds.values()].filter(hold => hold.ownerSessionId === sessionId); - for (let hold of held) { - this.#invalidate(hold, signInEndedRefusal(this.#auth.config.origin, hold.login)); + let origin = this.#auth.config.origin; + for (let pending of this.#pending.values()) { + if (!pending.sessionIds.has(sessionId)) continue; + pending.invalidated ??= pending.held?.ownerSessionId === sessionId + ? signInEndedRefusal(origin, pending.login) + : signInRefusal(origin, pending.login); } + let held = [...this.#holds.values()].filter(hold => hold.ownerSessionId === sessionId); + for (let hold of held) this.#invalidate(hold, signInEndedRefusal(origin, hold.login)); await Promise.all(held.map(hold => this.#release(hold))); } /** A repository writer reset the document's Planner, which already cleared its stored owner. */ ownerReset(channelId: string): void { + let pending = this.#pending.get(channelId); + if (pending) pending.invalidated ??= resetRefusal(pending.login); let hold = this.#holds.get(channelId); - if (!hold) return; - this.#invalidate(hold, { - ok: false, - code: "access-revoked", - message: `A repository writer reset this document's Planner, so ${hold.login} is no longer ` - + `attached. Attach again to resume.`, - }); + if (hold) this.#invalidate(hold, resetRefusal(hold.login)); } /** - * Whether `link` still holds the document: the stored owner is still its - * attachment's login and generation, and that login is still live. + * Whether `link` still holds the document: its login is still live and the + * stored owner is still its attachment's login and generation. * Otherwise the link closes and the document is free to attach. */ async verify(channelId: string, link: PlannerLink): Promise { let hold = this.#holds.get(channelId); if (hold?.link !== link) return false; let stored = await this.#auth.storage.channels.readAgent(channelId, this.#auth.clock()); + let live = await this.#auth.sessions.resolve(hold.ownerSessionId); if (this.#holds.get(channelId) !== hold || hold.link !== link) return false; - if ( - stored?.agent?.ownerSessionId !== hold.ownerSessionId - || stored.agent.generation !== hold.generation - ) { - this.#invalidate(hold, { - ok: false, - code: "access-revoked", - message: `This document's Planner ownership was released, so ${hold.login} is no longer ` - + `attached. Attach again to resume.`, - }); + if (!live) { + this.#invalidate(hold, signInEndedRefusal(this.#auth.config.origin, hold.login)); + await this.#release(hold); return false; } - if (await this.#auth.sessions.resolve(hold.ownerSessionId)) return true; - this.#invalidate(hold, signInEndedRefusal(this.#auth.config.origin, hold.login)); - await this.#release(hold); + if ( + stored?.agent?.ownerSessionId === hold.ownerSessionId + && stored.agent.generation === hold.generation + ) return true; + this.#invalidate(hold, { + ok: false, + code: "access-revoked", + message: `This document's Planner ownership was released, so ${hold.login} is no longer ` + + `attached. Attach again to resume.`, + }); return false; } @@ -152,17 +162,49 @@ export class PlannerAttachments { let { channelId, user } = request; let held = this.#holds.get(channelId); if (held && (held.link || held.userId !== user.id)) return attachedRefusal(held); + let pending: PendingAttach = { + login: user.login, + held, + sessionIds: new Set(held ? [held.ownerSessionId] : []), + }; + this.#pending.set(channelId, pending); + try { + return await this.#attachPending(request, link, pending); + } finally { + if (this.#pending.get(channelId) === pending) this.#pending.delete(channelId); + } + } + + /** + * Takes or keeps the document for `request` while `pending` collects every + * reset or ended login that lands during its awaits, so a hold binds only + * if nothing released its ownership in the meantime. + */ + async #attachPending( + request: AttachRequest, + link: PlannerLink, + pending: PendingAttach, + ): Promise { + let { channelId, user } = request; let login = await this.#eligibleLogin(request); if ("ok" in login) return login; + pending.sessionIds.add(login.session.id); + if (pending.invalidated) return pending.invalidated; let now = this.#auth.clock(); - if (held) { + let held = pending.held; + if (held && this.#holds.get(channelId) === held) { let stored = await this.#auth.storage.channels.readAgent(channelId, now); + let live = await this.#auth.sessions.resolve(held.ownerSessionId); + if (pending.invalidated) return pending.invalidated; if ( - stored?.agent?.ownerSessionId === held.ownerSessionId + live && this.#holds.get(channelId) === held + && stored?.agent?.ownerSessionId === held.ownerSessionId && stored.agent.generation === held.generation ) return this.#bind(held, link); this.#drop(held); + if (!live) await this.#release(held); } + if (pending.invalidated) return pending.invalidated; let claimed = await this.#claim(channelId, login.session.id, now); if (!claimed) throw new Error("The document's Planner owner changed while attaching."); let hold: Hold = { @@ -172,6 +214,13 @@ export class PlannerAttachments { ownerSessionId: login.session.id, generation: claimed.generation, }; + let live = await this.#auth.sessions.resolve(hold.ownerSessionId); + let refusal = pending.invalidated + ?? (live ? undefined : signInRefusal(this.#auth.config.origin, user.login)); + if (refusal) { + await this.#release(hold); + return refusal; + } this.#holds.set(channelId, hold); return this.#bind(hold, link); } @@ -284,6 +333,15 @@ export class PlannerAttachments { } } +function resetRefusal(login: string): AttachRefusal { + return { + ok: false, + code: "access-revoked", + message: `A repository writer reset this document's Planner, so ${login} is no longer ` + + `attached. Attach again to resume.`, + }; +} + function attachedRefusal(hold: Hold): AttachRefusal { let message = hold.link || !hold.releaseAt ? `${hold.login} is attached as this document's Planner. A document has one Planner at a time.` diff --git a/apps/server/src/harness/remote/link.test.ts b/apps/server/src/harness/remote/link.test.ts index b23f6d750..9255f1590 100644 --- a/apps/server/src/harness/remote/link.test.ts +++ b/apps/server/src/harness/remote/link.test.ts @@ -390,6 +390,95 @@ describe("Planner link ownership", () => { expect((await owner(storage, channel.id))?.ownerSessionId).toBeUndefined(); }); + it("releases the Planner as sign-in-required when storage already sees its owner's sign-in expired", async () => { + let { auth, server, raw, channel, storage, clock } = await attachedMember(20); + let held = (await owner(storage, channel.id))!; + auth.clock = () => new Date(Date.now() + clock.skewMs); + clock.skewMs = 31 * 24 * 60 * 60 * 1_000; + expect(await raw.next("refused")).toMatchObject({ + code: "sign-in-required", + message: expect.stringContaining("The Chopin browser sign-in that made member"), + }); + expect((await raw.closed).code).toBe(LINK_CLOSE.unauthorized); + expect(server.attachments.attached(channel.id)).toBe(false); + clock.skewMs = 0; + expect(await owner(storage, channel.id)).toMatchObject({ + ownerSessionId: undefined, + generation: held.generation, + status: "unavailable", + }); + }); + + /** Holds the next attach's ownership read until `resume`, once `reading` says it started. */ + function pauseOwnerRead(storage: Awaited>["storage"]) { + let readAgent = storage.channels.readAgent; + let reading = Promise.withResolvers(); + let resume = Promise.withResolvers(); + storage.channels.readAgent = async (channelId, now) => { + storage.channels.readAgent = readAgent; + let snapshot = await readAgent(channelId, now); + reading.resolve(); + await resume.promise; + return snapshot; + }; + return { reading: reading.promise, resume: () => resume.resolve() }; + } + + /** A member detached within the grace period, reattaching on a raw link paused at its ownership read. */ + async function reattachingMember() { + let linked = await linkAuth(); + let server = linkServer(linked.auth, 2_000, undefined, 2_000); + cleanups.push(server.stop); + linked.revocations.add(id => server.attachments.sessionRevoked(id)); + let first = await attach(server.url, linked.channel.id, scriptedPlanner().handlers); + await first.detach(); + let held = (await owner(linked.storage, linked.channel.id))!; + let paused = pauseOwnerRead(linked.storage); + let raw = await rawLink(server.url); + raw.send({ type: "attach", version: PLANNER_LINK_VERSION, document: linked.channel.id }); + await paused.reading; + return { ...linked, server, raw, held, resume: paused.resume }; + } + + it("refuses a grace reattach whose ownership a reset released while it was attaching", async () => { + let { server, raw, channel, storage, logins, held, resume } = await reattachingMember(); + await storage.channels.clearAgentOwner( + channel.id, + held.ownerSessionId!, + held.generation, + new Date(), + ); + server.attachments.ownerReset(channel.id); + resume(); + expect(await raw.next("refused")).toEqual({ + type: "refused", + code: "access-revoked", + message: "A repository writer reset this document's Planner, so member is no longer " + + "attached. Attach again to resume.", + }); + expect((await raw.closed).code).toBe(LINK_CLOSE.forbidden); + expect(server.attachments.attached(channel.id)).toBe(false); + expect(server.attachments.holder(channel.id)).toBeUndefined(); + expect(server.links).toEqual([]); + expect((await owner(storage, channel.id))?.ownerSessionId).toBeUndefined(); + await rivalAttaches(server.url, channel.id, storage, logins.get("rival")!); + }); + + it("refuses a grace reattach whose owner signed out while it was attaching", async () => { + let { server, raw, channel, storage, logins, signOut, resume } = await reattachingMember(); + expect(await signOut("member")).toBe(logins.get("member")); + resume(); + expect(await raw.next("refused")).toMatchObject({ + code: "sign-in-required", + message: expect.stringContaining("The Chopin browser sign-in that made member"), + }); + expect((await raw.closed).code).toBe(LINK_CLOSE.unauthorized); + expect(server.attachments.attached(channel.id)).toBe(false); + expect(server.links).toEqual([]); + expect((await owner(storage, channel.id))?.ownerSessionId).toBeUndefined(); + await rivalAttaches(server.url, channel.id, storage, logins.get("rival")!); + }); + it("releases the attachment when a repository writer resets the document's Planner", async () => { let { server, raw, channel, storage, logins } = await attachedMember(); let held = (await owner(storage, channel.id))!; diff --git a/apps/server/src/storage/planner-attachment-contract.ts b/apps/server/src/storage/planner-attachment-contract.ts index 047e18826..6b1745d48 100644 --- a/apps/server/src/storage/planner-attachment-contract.ts +++ b/apps/server/src/storage/planner-attachment-contract.ts @@ -27,7 +27,9 @@ async function attachable(storage: StorageAdapter) { createdBy: member.id, now, }); - let sessions = new Sessions(storage, false); + let skew = { ms: 0 }; + let clock = () => new Date(Date.now() + skew.ms); + let sessions = new Sessions(storage, false, clock); let signIn = async (user: { id: string }) => (await sessions.issue(user.id, { ...GRANT, accessToken: `${user.id}-login` })).id; let logins = { member: await signIn(member), rival: await signIn(rival) }; @@ -35,7 +37,7 @@ async function attachable(storage: StorageAdapter) { config: { origin: "https://chopin.test" }, storage, sessions, - clock: () => new Date(), + clock, admission: { allowed: async () => true }, github: { repositoryAccess: async () => ({ @@ -50,13 +52,19 @@ async function attachable(storage: StorageAdapter) { }; let owner = async () => (await storage.channels.readAgent(channelId, new Date()))?.agent; let request = (user: { id: string; login: string }) => ({ channelId, repository, user }); - return { auth, sessions, member, rival, logins, channelId, owner, request }; + return { auth, sessions, member, rival, logins, channelId, owner, request, skew }; } function link(): PlannerLink { return {} as PlannerLink; } +function revocable() { + let refusals: { code: string; message: string }[] = []; + let revocable = { revoke: (code: string, message: string) => refusals.push({ code, message }) }; + return { link: revocable as unknown as PlannerLink, refusals }; +} + async function released(owner: () => Promise<{ ownerSessionId?: string } | undefined>) { for (let deadline = Date.now() + 3_000; Date.now() < deadline;) { if (!(await owner())?.ownerSessionId) return; @@ -164,5 +172,81 @@ export function plannerAttachmentContract(name: string, factory: StorageFactory) await storage.close(); } }); + + test("an owner sign-in storage already sees expired releases the attachment as sign-in-required", async () => { + let storage = await openedStorage(factory); + let f = await attachable(storage); + let attachments = new PlannerAttachments(f.auth, 60_000); + try { + let attached = revocable(); + expect((await attachments.attach(f.request(f.member), attached.link)).ok).toBe(true); + let generation = (await f.owner())!.generation; + f.skew.ms = 31 * 24 * 60 * 60 * 1_000; + expect((await storage.channels.readAgent(f.channelId, f.auth.clock()))?.agent) + .toMatchObject({ ownerSessionId: undefined, generation }); + expect(await attachments.verify(f.channelId, attached.link)).toBe(false); + expect(attached.refusals).toEqual([{ + code: "sign-in-required", + message: expect.stringContaining("The Chopin browser sign-in that made member"), + }]); + f.skew.ms = 0; + expect(await f.owner()) + .toMatchObject({ ownerSessionId: undefined, generation, status: "unavailable" }); + expect(attachments.holder(f.channelId)).toBeUndefined(); + } finally { + attachments.close(); + await storage.close(); + } + }); + + test("a reset during a grace reattach refuses it and leaves the document free", async () => { + let storage = await openedStorage(factory); + let f = await attachable(storage); + let reading = Promise.withResolvers(); + let resume = Promise.withResolvers(); + let paused = false; + let channels = { + ...storage.channels, + readAgent: async (channelId: string, now: Date) => { + let snapshot = await storage.channels.readAgent(channelId, now); + if (!paused) return snapshot; + paused = false; + reading.resolve(); + await resume.promise; + return snapshot; + }, + }; + let attachments = new PlannerAttachments({ ...f.auth, storage: { channels } }, 60_000); + try { + let first = await attachments.attach(f.request(f.member), link()); + if (first.ok) first.detach(); + let held = (await f.owner())!; + paused = true; + let reattached = revocable(); + let reattaching = attachments.attach(f.request(f.member), reattached.link); + await reading.promise; + expect( + await storage.channels.clearAgentOwner( + f.channelId, + held.ownerSessionId!, + held.generation, + new Date(), + ), + ).toBe(true); + attachments.ownerReset(f.channelId); + resume.resolve(); + expect(await reattaching).toMatchObject({ ok: false, code: "access-revoked" }); + expect(attachments.attached(f.channelId)).toBe(false); + expect(attachments.holder(f.channelId)).toBeUndefined(); + expect(await f.owner()) + .toMatchObject({ ownerSessionId: undefined, generation: held.generation }); + expect((await attachments.attach(f.request(f.rival), link())).ok).toBe(true); + expect(await f.owner()) + .toMatchObject({ ownerSessionId: f.logins.rival, generation: held.generation + 1 }); + } finally { + attachments.close(); + await storage.close(); + } + }); }); } diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index 17404ea7c..36f557021 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -330,9 +330,11 @@ message naming its GitHub login. A link that detaches or drops keeps the ownership for `PLANNER_ATTACH_GRACE_MS` (2 minutes by default): the same account can attach again within it and keeps the same ownership generation, and anyone else is refused. When it ends, the ownership is cleared under its generation, so -a newer owner is never released by an old timer. Attachments live in the server -process; startup already clears every owner reference, which releases every -attachment. +a newer owner is never released by an old timer. An attach, including a grace +reattach, binds only if no sign-out, expiry, or Planner reset of its ownership +landed while it was checking; otherwise it is refused like an attached link +would be closed. Attachments live in the server process; startup already clears +every owner reference, which releases every attachment. **Unattached documents.** Members, MCP callers, and research requests never claim ownership under `HARNESS=remote`. An `@chopin` message or `invoke_planner` call diff --git a/docs/planner-link.md b/docs/planner-link.md index 8e0cc656a..10f8a2fcb 100644 --- a/docs/planner-link.md +++ b/docs/planner-link.md @@ -142,8 +142,12 @@ including the same one, can attach again immediately: | The stored ownership no longer names this attachment's sign-in | `access-revoked` | 4403 | The same checks before every `start` and `turn`, and the periodic one, also -find an expired sign-in or a released ownership that nothing reported. The -`message` says what happened: +find an expired sign-in or a released ownership that nothing reported; an +expired sign-in is reported as `sign-in-required` even when the stored +ownership already reads as released. An `attach` that is still in progress when +one of these causes lands, including a reattach within the grace period, is +refused with the same code and close code instead of attaching. The `message` +says what happened: ```text The Chopin browser sign-in that made octocat this document's Planner ended, so the Planner was released. Sign in to Chopin at https://chopin.example in a browser as octocat, then attach again. From 2cf7ee3d8f51720c4bb9a8360c9446f230abe380 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 10 Oct 2026 20:33:29 -0700 Subject: [PATCH 10/10] Confirm a remote Planner attachment before accepting a Planner request The Chat and invoke_planner preflight under HARNESS=remote accepted any in-memory attachment with a link, without asking whether its owner's browser sign-in was still live or storage still assigned that owner. If the sign-in expired before the link's periodic recheck, Chat saved and announced the member's message before owner resolution failed, and invoke_planner returned planner-owner-unavailable instead of planner-not-attached. Both now confirm the attachment as the periodic recheck does. A stale one is closed with the code an attached link would get (sign-in-required or access-revoked), its ownership is released, and the request is refused with planner-not-attached and the attach guidance, with nothing saved or queued. A fresh attach registered its sign-in with the attempt only after the repository and admission checks, so a sign-out during them went unnoticed and the ownership claim then threw once storage had deleted the session, refusing the client with unavailable (1011). The sign-in is now registered before those checks, and a claim that storage refuses because the sign-in ended is refused with sign-in-required (4401). Assistant-model: Claude Opus 5.5 (fast) Assistant-workflow: hosted-planner-slices (run b51d4e3c) repair (worker subagent) Assistant-verification: bun run types passed: every workspace package and e2e, 14 exited with code 0 Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test passed: 4672 pass, 4 skip, 0 fail Assistant-verification: bun run ci passed: dprint, oxlint 0 errors, tokens, design contract, Impeccable Assistant-verification: PostgreSQL contract passed: bun test apps/server/src/storage/postgres against disposable postgres:17-alpine on 55432, 85 pass, container stopped Assistant-verification: mutation check passed: the previous attachments.ts and main.ts preflight fail all four new tests; removing only the claim fallback fails the storage-expiry attach test Co-authored-by: Alex Lavaee --- apps/server/src/chat/remote-planner.test.ts | 72 +++++++++++++++++-- apps/server/src/harness/remote/attachments.ts | 35 +++++++-- apps/server/src/harness/remote/link.test.ts | 56 +++++++++++++++ apps/server/src/main.ts | 2 +- apps/server/src/mcp/hosted.test.ts | 3 +- apps/web/src/chat/references.test.ts | 4 +- docs/hosted-agent.md | 11 ++- docs/local-agent-mcp.md | 3 +- docs/planner-link.md | 17 +++-- 9 files changed, 182 insertions(+), 21 deletions(-) diff --git a/apps/server/src/chat/remote-planner.test.ts b/apps/server/src/chat/remote-planner.test.ts index 4fb1e909e..45f6804f1 100644 --- a/apps/server/src/chat/remote-planner.test.ts +++ b/apps/server/src/chat/remote-planner.test.ts @@ -48,7 +48,7 @@ function card(status: "running" | "paused") { * login of their own; only the attached member may own the Planner. */ async function remoteRoom() { - let { auth, storage, channel, logins, revocations, signOut } = await linkAuth(); + let { auth, storage, channel, logins, revocations, signOut, clock } = await linkAuth(); let server = linkServer(auth); cleanups.push(server.stop); revocations.add(id => server.attachments.sessionRevoked(id)); @@ -79,7 +79,7 @@ async function remoteRoom() { config: configured(REMOTE), claimantSessionId: logins.get("rival"), plannerNotAttached: async () => - server.attachments.attached(channel.id) + await server.attachments.confirmed(channel.id) ? undefined : notAttachedMessage(url, "octo-org/score"), activeOwner: () => owners.resolve(channel.id), @@ -123,7 +123,40 @@ async function remoteRoom() { }; let owner = async () => (await storage.channels.readAgent(channel.id, new Date()))?.agent?.ownerSessionId; - return { context, server, storage, channel, logins, url, ws, send, owner, events, signOut }; + return { + context, + server, + storage, + channel, + logins, + url, + ws, + send, + owner, + events, + signOut, + clock, + }; +} + +/** + * The room with the member attached as its Planner and every browser sign-in + * expired, before the link's periodic recheck or any cleanup has noticed, + * counting what Chat saves from then on. + */ +async function expiredOwnerRoom() { + let room = await remoteRoom(); + let member = await attach(room.server.url, room.channel.id, scriptedPlanner().handlers); + cleanups.push(() => member.detach()); + expect(await room.owner()).toBe(room.logins.get("member")); + room.clock.skewMs = 31 * 24 * 60 * 60 * 1_000; + let saves = 0; + let persist = room.context.persist; + room.context.persist = async chat => { + saves++; + await persist(chat); + }; + return { ...room, member, saves: () => saves }; } test("with no attached Planner, @chopin and invoke_planner are refused with how to attach one, and nothing is posted or queued", async () => { @@ -135,7 +168,8 @@ test("with no attached Planner, @chopin and invoke_planner are refused with how saves++; await persist(chat); }; - let message = "No Planner is attached to this document. To attach one, connect a Planner client to " + let message = + "No Planner is attached to this document. To attach one, connect a Planner client to " + `${room.url} over the Planner link from a session in a checkout of octo-org/score; ` + "that session becomes this document's Planner."; @@ -234,6 +268,36 @@ test("an attached Planner receives @chopin and invoke_planner turns as their own ); }); +test("once the attached Planner's owner sign-in expires, @chopin is refused with how to attach one before its periodic recheck, and nothing is posted or queued", async () => { + let room = await expiredOwnerRoom(); + let { context } = room; + expect(await room.send("@chopin draft the plan")).toMatchObject({ + kind: "session:error", + code: "planner-not-attached", + message: "No Planner is attached to this document. To attach one, connect a Planner client to " + + `${room.url} over the Planner link from a session in a checkout of octo-org/score; ` + + "that session becomes this document's Planner.", + }); + expect(context.chat.entries).toEqual([]); + expect(context.chat.waiting).toEqual([]); + expect(room.saves()).toBe(0); + expect(room.events).toEqual([]); + expect(await room.member.closed).toEqual({ code: 4401, reason: "sign-in-required" }); + expect(room.server.attachments.attached(room.channel.id)).toBe(false); + expect(await room.owner()).toBeUndefined(); +}); + +test("once the attached Planner's owner sign-in expires, invoke_planner is refused as planner-not-attached before its periodic recheck", async () => { + let room = await expiredOwnerRoom(); + let { context } = room; + expect(await Chat.invoke(context, RIVAL, "Draft the plan")).toBe("planner-not-attached"); + expect(context.chat.entries).toEqual([]); + expect(context.chat.waiting).toEqual([]); + expect(room.saves()).toBe(0); + expect(await room.member.closed).toEqual({ code: 4401, reason: "sign-in-required" }); + expect(await room.owner()).toBeUndefined(); +}); + test("once the attached Planner's owner signs out or is reset, @chopin and invoke_planner are refused again, and another member can attach", async () => { let room = await remoteRoom(); let { context } = room; diff --git a/apps/server/src/harness/remote/attachments.ts b/apps/server/src/harness/remote/attachments.ts index 11028ec29..ac7ec3e2a 100644 --- a/apps/server/src/harness/remote/attachments.ts +++ b/apps/server/src/harness/remote/attachments.ts @@ -91,6 +91,16 @@ export class PlannerAttachments { return this.#holds.get(channelId)?.link !== undefined; } + /** + * Whether a client is attached and its ownership still stands, checked as + * the link's periodic recheck does. A stale attachment is closed and + * released here, so a Planner request never proceeds on it. + */ + async confirmed(channelId: string): Promise { + let link = this.#holds.get(channelId)?.link; + return link !== undefined && await this.verify(channelId, link); + } + /** The GitHub login holding the document's Planner, attached or within its grace period. */ holder(channelId: string): string | undefined { return this.#holds.get(channelId)?.login; @@ -186,9 +196,8 @@ export class PlannerAttachments { pending: PendingAttach, ): Promise { let { channelId, user } = request; - let login = await this.#eligibleLogin(request); + let login = await this.#eligibleLogin(request, pending); if ("ok" in login) return login; - pending.sessionIds.add(login.session.id); if (pending.invalidated) return pending.invalidated; let now = this.#auth.clock(); let held = pending.held; @@ -205,7 +214,17 @@ export class PlannerAttachments { if (!live) await this.#release(held); } if (pending.invalidated) return pending.invalidated; - let claimed = await this.#claim(channelId, login.session.id, now); + let claimed; + try { + claimed = await this.#claim(channelId, login.session.id, now); + } catch (error) { + // Storage refuses a claim for a sign-in that ended after its last check. + if (pending.invalidated) return pending.invalidated; + if (!await this.#auth.sessions.resolve(login.session.id)) { + return signInRefusal(this.#auth.config.origin, user.login); + } + throw error; + } if (!claimed) throw new Error("The document's Planner owner changed while attaching."); let hold: Hold = { channelId, @@ -225,11 +244,19 @@ export class PlannerAttachments { return this.#bind(hold, link); } - async #eligibleLogin(request: AttachRequest): Promise { + /** + * The caller's browser login, registered with `pending` before its access + * checks so that its sign-out during them refuses the attach. + */ + async #eligibleLogin( + request: AttachRequest, + pending: PendingAttach, + ): Promise { let { repository, user } = request; let fullName = `${repository.owner}/${repository.name}`; let login = await this.#auth.sessions.forUser(user.id); if (!login) return signInRefusal(this.#auth.config.origin, user.login); + pending.sessionIds.add(login.session.id); let checked; try { checked = await this.#auth.sessions.use( diff --git a/apps/server/src/harness/remote/link.test.ts b/apps/server/src/harness/remote/link.test.ts index 9255f1590..70cb7a0c6 100644 --- a/apps/server/src/harness/remote/link.test.ts +++ b/apps/server/src/harness/remote/link.test.ts @@ -479,6 +479,62 @@ describe("Planner link ownership", () => { await rivalAttaches(server.url, channel.id, storage, logins.get("rival")!); }); + /** A member's fresh attach on a raw link, paused while GitHub checks its repository access. */ + async function checkingMember() { + let linked = await linkAuth(); + let server = linkServer(linked.auth); + cleanups.push(server.stop); + linked.revocations.add(id => server.attachments.sessionRevoked(id)); + let { github } = linked.auth; + let repositoryAccess = github.repositoryAccess; + let checking = Promise.withResolvers(); + let resume = Promise.withResolvers(); + github.repositoryAccess = async (token, repositoryOwner, name) => { + github.repositoryAccess = repositoryAccess; + checking.resolve(); + await resume.promise; + return repositoryAccess(token, repositoryOwner, name); + }; + let raw = await rawLink(server.url); + raw.send({ type: "attach", version: PLANNER_LINK_VERSION, document: linked.channel.id }); + await checking.promise; + return { ...linked, server, raw, resume: () => resume.resolve() }; + } + + async function refusedSignIn( + attempt: Awaited>, + ) { + let { server, raw, channel, storage } = attempt; + expect(await raw.next("refused")).toEqual({ + type: "refused", + code: "sign-in-required", + message: "Sign in to Chopin at https://chopin.test in a browser as member, then attach " + + "again. The Planner's repository tools act with that sign-in.", + }); + expect(await raw.closed).toEqual({ code: LINK_CLOSE.unauthorized, reason: "sign-in-required" }); + expect(server.attachments.attached(channel.id)).toBe(false); + expect(server.attachments.holder(channel.id)).toBeUndefined(); + expect(server.links).toEqual([]); + expect((await owner(storage, channel.id))?.ownerSessionId).toBeUndefined(); + } + + it("refuses a fresh attach whose owner signed out while its repository access was checked", async () => { + let attempt = await checkingMember(); + expect(await attempt.signOut("member")).toBe(attempt.logins.get("member")); + attempt.resume(); + await refusedSignIn(attempt); + let { server, channel, storage, logins } = attempt; + await rivalAttaches(server.url, channel.id, storage, logins.get("rival")!); + }); + + it("refuses a fresh attach whose sign-in storage saw expire before it claimed ownership", async () => { + let attempt = await checkingMember(); + attempt.auth.clock = () => new Date(Date.now() + attempt.clock.skewMs); + attempt.clock.skewMs = 31 * 24 * 60 * 60 * 1_000; + attempt.resume(); + await refusedSignIn(attempt); + }); + it("releases the attachment when a repository writer resets the document's Planner", async () => { let { server, raw, channel, storage, logins } = await attachedMember(); let held = (await owner(storage, channel.id))!; diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 727abcd05..e9de009c9 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -312,7 +312,7 @@ function conversation( repository, plannerNotAttached: plannerAttachments ? async () => { - if (plannerAttachments!.attached(room.id)) return undefined; + if (await plannerAttachments!.confirmed(room.id)) return undefined; let channel = await storage.channels.get(room.id); let path = channel ? await documentUrl(channel, storage.channels) diff --git a/apps/server/src/mcp/hosted.test.ts b/apps/server/src/mcp/hosted.test.ts index 006a2ce39..3096b8109 100644 --- a/apps/server/src/mcp/hosted.test.ts +++ b/apps/server/src/mcp/hosted.test.ts @@ -290,7 +290,8 @@ describe("the hosted MCP adapter", () => { ).toEqual({ kind: "refused", code: "planner-not-attached", - message: "No Planner is attached to this document. To attach one, connect a Planner client to " + message: + "No Planner is attached to this document. To attach one, connect a Planner client to " + "https://chopin.test/documents/octo-org/score/release-readiness over the Planner " + "link from a session in a checkout of octo-org/score; that session becomes this " + "document's Planner.", diff --git a/apps/web/src/chat/references.test.ts b/apps/web/src/chat/references.test.ts index d72bb0dfb..7096273c0 100644 --- a/apps/web/src/chat/references.test.ts +++ b/apps/web/src/chat/references.test.ts @@ -369,7 +369,9 @@ describe("acknowledged composer drafts", () => { }); test("shows a missing remote Planner's instructions whole, however long", () => { - let message = `No Planner is attached to this document. ${"Connect a Planner client. ".repeat(20)}`.trim(); + let message = `No Planner is attached to this document. ${ + "Connect a Planner client. ".repeat(20) + }`.trim(); expect(boundedChatError(new WireRefusal(message, "planner-not-attached"))).toBe(message); expect(boundedChatError(new WireRefusal(message))).toEndWith("..."); }); diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index 36f557021..879b8603f 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -332,14 +332,19 @@ can attach again within it and keeps the same ownership generation, and anyone else is refused. When it ends, the ownership is cleared under its generation, so a newer owner is never released by an old timer. An attach, including a grace reattach, binds only if no sign-out, expiry, or Planner reset of its ownership -landed while it was checking; otherwise it is refused like an attached link -would be closed. Attachments live in the server process; startup already clears +landed while it was checking, including the eligibility checks of the sign-in +it is about to claim with; otherwise it is refused like an attached link would +be closed. Attachments live in the server process; startup already clears every owner reference, which releases every attachment. **Unattached documents.** Members, MCP callers, and research requests never claim ownership under `HARNESS=remote`. An `@chopin` message or `invoke_planner` call for a document with no attached client, including one within its grace period, -is refused with `planner-not-attached` before anything is posted or queued. The +is refused with `planner-not-attached` before anything is posted or queued. +Before accepting either, the server rechecks an attached link as its periodic +recheck would: if the owner's browser sign-in has ended or storage no longer +assigns that owner, it closes the link, releases the ownership, and refuses the +request the same way. The refusal names the document URL and says to connect a Planner client to it over the Planner link from a session in a checkout of the repository, which attaches that session as the Planner; the browser shows that text on the `@chopin` diff --git a/docs/local-agent-mcp.md b/docs/local-agent-mcp.md index a2016211e..eaddde681 100644 --- a/docs/local-agent-mcp.md +++ b/docs/local-agent-mcp.md @@ -97,7 +97,8 @@ a client attached to the document over the [Planner link](planner-link.md), and only attaching makes an account the owner. With a client attached the turn goes to it and runs under its owner, whoever invoked it. Without one, including while a detached Planner's grace period runs and once the attached Planner's -owner signs out of the browser or its Planner is reset, the call is refused with +owner signs out of the browser, that sign-in expires, or its Planner is reset, +the call is refused with `planner-not-attached` and a `message`, and nothing is posted or queued: ```json diff --git a/docs/planner-link.md b/docs/planner-link.md index 10f8a2fcb..390ff3e2d 100644 --- a/docs/planner-link.md +++ b/docs/planner-link.md @@ -144,10 +144,13 @@ including the same one, can attach again immediately: The same checks before every `start` and `turn`, and the periodic one, also find an expired sign-in or a released ownership that nothing reported; an expired sign-in is reported as `sign-in-required` even when the stored -ownership already reads as released. An `attach` that is still in progress when -one of these causes lands, including a reattach within the grace period, is -refused with the same code and close code instead of attaching. The `message` -says what happened: +ownership already reads as released. An `@chopin` message or `invoke_planner` +call makes the same check first, so a link it finds stale is closed this way and +the request is refused as [without an attached Planner](#without-an-attached-planner). +An `attach` that is still in progress when one of these causes lands, including +a reattach within the grace period, is refused with the same code and close code +instead of attaching; a fresh attach whose sign-in ends while the server checks +it is refused with `sign-in-required` (4401). The `message` says what happened: ```text The Chopin browser sign-in that made octocat this document's Planner ended, so the Planner was released. Sign in to Chopin at https://chopin.example in a browser as octocat, then attach again. @@ -171,8 +174,10 @@ This document's Planner ownership was released, so octocat is no longer attached On a server running `HARNESS=remote`, an `@chopin` message in the browser and an MCP `invoke_planner` call for a document with no attached client are refused with the code `planner-not-attached`, and nothing is posted or queued. A -document whose Planner is within its grace period counts as unattached. The -refusal's message names the document URL: +document whose Planner is within its grace period counts as unattached, and so +does one whose attached link the request finds stale: its owner's browser +sign-in has ended or the stored ownership no longer names it. The request closes +that link before it is refused. The refusal's message names the document URL: ```text No Planner is attached to this document. To attach one, connect a Planner client to over the Planner link from a session in a checkout of ; that session becomes this document's Planner.