diff --git a/CLAUDE.md b/CLAUDE.md index e981e412..eecdb848 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -254,6 +254,8 @@ codeoid worktree trust [dir] [--off] [--yes] # trust the setup command of a rep codeoid workstream ls | show | match [dir] [--workstream ] | import [--root ] # named lines of work, what a folder resolves to, a proposal from disk (docs/workstreams-design.md) codeoid new --workstream [--override] # a session's home workstream; default: the folder's, else workstreams.default codeoid mcp signin --credential # a named OAuth slot: a second sign-in to one server (a workstream's own) +codeoid new --ref [:] # another workstream it may read (its folders read-only, its memory), by that one's share policy +codeoid workstream ref add|rm [:] | move # references and moves (§5.3, §7) ``` `/mcp/control` (off by default, `mcpControl.*`) lets external agents drive @@ -551,6 +553,25 @@ server's named credential is a REFERENCE on its transport, resolved by call against the live grant. Overrides: `OverrideTokens` (G3); the folder record is `workstream_folders`. OAuth slots: `mcp_oauth_credentials.credential` in the key, `withSlot`/`slotOf` in mcp/oauth.ts. +W3 (under isolation): the read fence `WorkstreamService.fenceFor` (home's and +references' folders, every other workstream's as `foreign`; folders from +`workstreams/folders.ts` and recorded homes), read live by Session through +`workstreamFence(binding)`: the sandbox's project roots and `extraHidden` +(`SessionSandbox` `workstreamDirs`), the gate's `workstreams/gate.ts` +`fenceRefusal` in every mode (and Claude's PreToolUse hook), and recall +(`MemoryEngine.scopeSession`; `episodes.workstream_id`). References +(`WorkstreamBinding.references`, `readFrom`) by the read workstream's `share` +(`referenceVerdict`), moves by `moveVerdict`; `session.add_reference` / +`remove_reference` / `set_workstream` in `#sessionWorkstreamEdit`, +`Session.setWorkstream`; the env, grant and fence closures take the binding, +so a change applies at the next turn. +W4: the conductor's fleet tools name each result's workstream and take a +`workstream` filter; `FleetDeps.textWithheld` keeps a `closed` workstream's +episode text out of fleet_find / fleet_summary / fleet_recall and the dispatch +digest. `fleet_spawn.workstream` → `checkWorkstream` (`#fleetSpawnHome`) before +queueing, `dispatch_tasks.workstream` judged again at claim; send-class cards +carry `targetWorkstream` (`#fleetCardWorkstream`). `workstreams/egress.ts` is +the egress stub. Routines (`src/daemon/routines/`, docs/routines.md) run sessions on a schedule or an API call. `RoutineService` owns the scheduler and runs; it reaches diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 12ab4f45..09ec3220 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -463,6 +463,49 @@ A workstream with `allowOverride: false` can't be overridden at all. A folder's workstream is remembered: if its repository later matches a different one (its `origin` was changed), the next session there must choose. +### Reading another workstream, and moving a session + +With `isolateGlobalServers: true` a session is fenced off from every other workstream's folders and memory: +- its sandbox shows its home's folders read-only as its project roots (never the whole `@parent`), and hides other workstreams' folders; +- a file tool (Read, Grep, Glob, Write, Edit, on every backend, in every mode) in another workstream's folder is refused, naming that workstream; +- `recall`, `timeline` and `get_episode` leave out episodes another workstream wrote (episodes from before workstreams stay visible in their repository). + +A workstream's folders are the directories its path rules name on this machine (a glob such as `~/Workspace/acme-*` expands to the folders it matches, never to `~/Workspace`), plus the folders where its sessions were started by their folder's own match. +The more specific rule decides: a catch-all workstream over `~/Workspace` is fenced off from `~/Workspace/acme-api` when that is another's, and inside a folder of another workstream's, only what the session may read (its own workdir, its home's folders, what a reference grants) stays readable. +The home itself is never a workstream's folder. + +Limits: a repository matched only by a remote rule is fenced once a session has been started there (its folder is recorded then); one never opened is not. A session's commands in an unsandboxed session (`--sandbox off`) can still read other workstreams' folders; only its file tools are fenced. + +A **reference** lets a session read another workstream: its folders read-only (or only some), and its memory. Never its servers, credentials or accounts, and never a write. +The workstream being read decides, by its `share`: + +| `share` | A reference to it is… | +| --- | --- | +| `open` | granted. | +| `ask` | granted when its owner asks with `session:approve` on an undelegated token; asked of the owner on the session otherwise. | +| `closed` | refused. A reference granted before it was closed gives nothing. | + +```bash +codeoid new --ref pub --ref acme:~/Workspace/acme-api # at create +codeoid workstream ref add pub # later (the session header's chip too) +codeoid workstream ref rm pub +codeoid workstream move # another home +``` + +Moving a session takes its whole conversation with it, so it follows the `share` of its old home and of every workstream it has read from (for its lifetime, even after the reference is gone; each home it leaves counts too). Moving from the default workstream, or from no home, is always allowed; past another workstream's exclusive folder it is an override (needs `session:approve`). +A session homed in another workstream's folder (chosen at create, or moved) reads that folder, so the folder's workstream's `share` decides there too: `closed` refuses it, `ask` needs `session:approve`. +Each change is saved at once and applies at the session's next turn (a turn running keeps what it started with): its backend is rebuilt with the new servers, credentials and sandbox. + +A search with no folder (Grep or Glob over the workdir) is refused when another workstream's folder is inside the workdir: search a narrower folder. + + +### The conductor and workstreams + +The conductor is homed in a reserved workstream of its own: no workstream's servers, env or folders. It reads across all of them through its fleet tools, which take a `workstream` filter and name each result's workstream. +With `isolateGlobalServers: true`, a `closed` workstream gives the conductor its sessions' status and metadata only (in `fleet_list`), never their text: `fleet_find` and `fleet_recall` never return them (not even that something matched), `fleet_summary` gives status only, `machine_map` leaves out their branches, and a worker of a closed workstream reports that it finished, not what it found. Episodes from before workstreams are judged by their session's home (as recorded; for one that no longer runs, its stored binding, else its folder's record, else its folder now). A workstream deleted from the config has no policy left: its sessions' text is no longer withheld. Closing a workstream doesn't reach back: what the conductor was told while it was open (its own transcript, a task's digest on `fleet_tasks`) stays. + +The conductor acts only through sessions homed where the work belongs. `fleet_spawn` takes `workstream` (else the folder's; a folder with none, or one whose repository's remote now matches another workstream than before, must be given one) and follows the same rules as a create: past a workstream's exclusive folder, or into an `ask` workstream's folder, the spawn's approval card says so (naming the workstream it passes), and only the session owner's own approval of that card confirms it: the approval adds a one-time token to that very call, which its handler accepts only if the spawn would still be judged exactly as the card showed, at the config revision it was shown at. A closed workstream's folder never takes another's worker, and a closed workstream's worker works only in its own folders. Every send-class card (web, Telegram, the TUI, `codeoid approve`, the terminal) names the workstream the action runs in. + ### Moving an existing setup `codeoid workstream import [--root ]` proposes workstreams from your repositories' remotes and lists Claude's global MCP servers to assign. It writes nothing; isolation stays off until you turn it on. diff --git a/docs/workstreams-design.md b/docs/workstreams-design.md index fb614904..d92d94ce 100644 --- a/docs/workstreams-design.md +++ b/docs/workstreams-design.md @@ -441,7 +441,7 @@ The conductor is the one session whose job crosses workstreams, and this design - That worker has only the target workstream's servers, credentials and memory. - A Highflame Slack reply is drafted by a worker that has never seen a Sentisum episode. - The approval card shows the workstream as well as recipient, repo and content. -- **`fleet_spawn`** takes `workstream` and follows §5. A conflicting spawn returns `workstream_conflict` to the conductor, and it has to choose instead of guessing. +- **`fleet_spawn`** takes `workstream` and follows §5. A conflict the owner can override is shown on the spawn's approval card, and the owner's own approval of that card confirms it (G3); one that can't be overridden, or wasn't confirmed, comes back to the conductor, which has to choose instead of guessing. - **Intake.** When the conductor's Slack and GitHub intake arrives (Slack front end, a later phase), each inbox source is bound to one workstream: Highflame Slack to `highflame`, Sentisum Slack to `sentisum`. A signal is born with its workstream, and its reply worker inherits it. @@ -494,6 +494,33 @@ codeoid new --workstream [--ref [:]]... codeoid workstream move ``` +### 12.1 W3 as built (2026-11-11) + +- **Enforcement switch.** Everything W3 enforces (the fence, recall scoping) is on only with `isolateGlobalServers`, like W1's servers. Without it, episodes are still tagged with `workstream_id` and references are recorded, but nothing is fenced. +- **What is fenced.** Another workstream's folders, not everything outside the home: a blanket deny would refuse toolchains and system headers, and the cross-workstream read is the risk. A workstream's folders are its path rules expanded on disk (`workstreams/folders.ts`: one segment at a time, links followed only where the rule names a segment literally, at most 512) plus its recorded homes' repository tops (never the home, nor a top at or above it). Every one is fenced, nested in the home's or holding it: what the session may read inside one (`WorkstreamFence.readable`: its workdir and repository, its home's folders, exactly what its references grant) is carved out of it (the gate: a path in a foreign folder and in none of those; the sandbox: every other entry on the way to them, `carveOut`, else the folder whole). +- **Sandbox.** The fence's readable folders replace `@parent` as the project roots unless the session's own setting names roots; other workstreams' folders go into the spec's hidden paths (`SpecInputs.extraHidden`), and a root inside one is dropped. A change of fence is a change of directories, applied between turns (`SessionSandbox.dirsPending`). +- **Gate.** `workstreams/gate.ts` judges every tool call's read, search and write paths (secret-paths.ts and held-writes.ts extractors), in every mode; Claude's PreToolUse hook refuses the same calls, since the CLI skips `canUseTool` for some. +- **Memory.** `episodes.workstream_id`, tagged with the home at write time. Recall stays the repository's (`workspace_id`), filtered to the home and references; untagged episodes stay visible. Cross-repository recall over a whole workstream is deferred. +- **`ask` grants.** The owner asking with `session:approve`, undelegated, is the approval. Anyone else (a delegated or send-only token) is asked of the owner on the session, and the verdict is judged again when they answer. +- **Read-from record.** `readFrom` is recorded when a reference is granted (it may be read from then). +- **Monorepos.** A session's repository top is its own only where no other workstream's folder holds it; a recorded home is widened to its repository's top only where no other workstream has a folder inside that top. A granted reference path counts only where it really is (written and real) inside the granting workstream's folders, at grant (resolved then) and at every fence. +- **Turn boundary.** `Session.setWorkstream` saves the new binding at once and applies it at the next turn's start (`#applyPendingWorkstream`); a running turn keeps its gate, servers, env and memory scope together. +- **Homed in another's folder.** `folderVerdict`: a home chosen in, or moved into, a folder another workstream holds (any claim) reads it, judged by that workstream's `share`, and it is added to `readFrom`. A move adds the old home (but the default) to `readFrom`. +- **Carve limit.** A carve past `CARVE_LIMIT` is tried at ten times it, then the folder is hidden whole; one holding the session's workspace can't be hidden whole, and is then left visible to its commands (said in `limits`), the gate still fencing its file tools. +- **Sealed.** A stored binding that can't be read back gives no home and `sealed: true`: what it read from is unknown, so it never moves. +- **Known limits:** a reference with `paths` gives the workstream's whole memory (recall isn't filtered by folder yet); an episode is tagged with the home that wrote it, so what a session read through a reference stays in its home's recall after that workstream closes; the conductor's fleet reads (`fleet_summary`, the dispatch digest) are W4's. +- **Deferred:** `Bash` commands naming another workstream's path in an unsandboxed session (§14 q4) are not yet asked about; fork *into* another workstream; skillDirs. + +### 12.2 W4 as built (2026-11-11) + +- **Fleet reads.** `FleetSessionView.workstream`; `fleet_list`, `fleet_find` and `fleet_recall` take a `workstream` filter and name each result's. `FleetDeps.textWithheld` (the manager: a `closed` workstream, under isolation) drops a closed workstream's episode text from `fleet_find` snippets, `fleet_summary` and `fleet_recall` (counted, not shown) and from the dispatch digest (`buildWorkerDigest`: status only), which is also what `fleet_tasks` shows. +- **No rolling summaries yet.** §10 promises a closed workstream's session cards and summaries; nothing writes session cards today (`memory/cards.ts` has no caller), so a closed workstream gives status and metadata only until cards exist (backlog). +- **Spawns.** `fleet_spawn.workstream`; `FleetDispatchDeps.checkWorkstream` before queueing (`#fleetSpawnHome`: `forCreate` with the folder required under isolation, `folderVerdict`), stored on the task (`dispatch_tasks.workstream`, `workstream_override`) and judged again at claim. The approval card (`ToolWaitingConfirmationState.targetWorkstream`, from `Session` `fleetTargetWorkstream`, the manager's `#fleetCardWorkstream`) names the workstream and warns of the override approving it confirms (G3), or of a refusal. +- **Egress.** `workstreams/egress.ts` `planEgress`: a tested stub (G6) — a message names its workstream and is sent by a worker homed there, never by the conductor; wired when P6's Slack and email egress lands. +- **The conductor's own memory.** Its recall includes its reserved workstream's episodes (it couldn't reach them under isolation before). +- **Phones.** The fleet rail docks beside the chat from `md` up; below, the chat (and its approval card) has the width. The rail's board has no phone view yet (backlog); `fleet_list` and `fleet_tasks` answer in chat. +- **Audit round 1.** Untagged episodes are judged by their session (live, else its stored folder; unknown: withheld while any workstream is closed). A closed workstream's hits are dropped from queries before anything is counted. A worker's digest is withheld from the conductor only (an orchestrator's children share its workstream); the conductor's tenant-wide board withholds others'. A confirmation is a one-time token (round 2): issued when the session owner's own approval (`isOwnApprover`) settles a card that needed one, added to that call's input by the gate (`#withFleetConfirmation`, `confirmation`), and accepted by its handler only for the tenant, workdir, workstream, conflicting workstream, `ask` folders and config revision the card was judged on (`#spendFleetToken`); the revision is stored on the task and checked at claim. A backend that drops the gate's input change gets no token: the spawn is refused. An unnamed spawn is stored without a workstream and judged again at claim by `#fleetSpawnHome` (remote-changed guard included). + ## 13. Phases | Phase | Ships | Enforced by the end | diff --git a/e2e/specs/workstreams-w3.spec.ts b/e2e/specs/workstreams-w3.spec.ts new file mode 100644 index 00000000..709f0ca4 --- /dev/null +++ b/e2e/specs/workstreams-w3.spec.ts @@ -0,0 +1,103 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { expect, openUi, test } from "../harness"; + +const VIEWPORTS = [ + [375, 667], + [390, 844], + [768, 1024], + [1280, 800], + [1470, 830], + [1920, 1080], + [1280, 300], +] as const; +const SHOTS = join(process.cwd(), "test-results", "workstreams-shots"); + +function configureW3(codeoid: { root: string; workdir: string; configure(p: Record): void }): void { + for (const d of ["pub", "hf", "acme"]) mkdirSync(join(codeoid.root, d), { recursive: true }); + codeoid.configure({ + workstreams: { + isolateGlobalServers: true, + items: [ + { id: "mine", name: "Mine", share: "open", match: { paths: [codeoid.workdir] } }, + { id: "pub", name: "Pub", share: "open", match: { paths: [join(codeoid.root, "pub")] } }, + { id: "acme", name: "Acme", share: "ask", match: { paths: [join(codeoid.root, "acme")] } }, + { id: "hf", name: "HF", share: "closed", match: { paths: [join(codeoid.root, "hf")] } }, + ], + }, + }); +} + +// Workstreams W3 (§5.3, §7): references by each workstream's share policy, +// and moving a session, from the CLI. +test("references follow the share policy; a session moves; a closed workstream is never read", async ({ codeoid }) => { + configureW3(codeoid); + await codeoid.restart(); + + expect(await codeoid.cliOk("new", "r1", codeoid.workdir, "--ref", "pub")).toContain("workstream: Mine (mine)"); + const closed = await codeoid.cli("new", "r2", codeoid.workdir, "--ref", "hf"); + expect(closed.code).not.toBe(0); + expect(closed.output).toContain('"hf" is closed'); + // The owner's own token, undelegated: an `ask` workstream is granted at once. + const asked = await codeoid.cliOk("workstream", "ref", "add", "r1", "acme"); + expect(asked).toContain("reads: pub; acme"); + expect(await codeoid.cliOk("workstream", "ref", "rm", "r1", "pub")).toContain("reads: acme"); + // r1 read from acme (ask): the owner's token is the approval. + expect(await codeoid.cliOk("workstream", "move", "r1", "pub")).toContain("workstream: Pub (pub)"); + await codeoid.restart(); + expect(await codeoid.cliOk("ls")).toContain("workstream: Pub (pub)"); +}); + +// The same in the web UI: the dialog offers what the session may read, the +// header chip opens what it reads and where it lives. +test("the web UI: references in the new-session dialog, the chip's panel at every viewport", async ({ page, codeoid }) => { + mkdirSync(SHOTS, { recursive: true }); + configureW3(codeoid); + await codeoid.restart(); + await openUi(page, codeoid); + + await page.getByRole("button", { name: /new session/ }).click(); + await page.getByRole("textbox", { name: "Name", exact: true }).fill("reader"); + await page.getByRole("textbox", { name: /^Workdir/ }).fill(codeoid.workdir); + await expect(page.getByLabel("Workstream")).toContainText("The folder's: mine", { timeout: 10_000 }); + const reads = page.getByRole("group", { name: "Also reads" }); + await expect(reads).toContainText("Pub"); + await expect(reads).toContainText("Acme (asks)"); + await expect(reads).not.toContainText("HF"); + await reads.getByLabel(/^Pub/).check(); + for (const [w, h] of VIEWPORTS) { + await page.setViewportSize({ width: w, height: h }); + await reads.scrollIntoViewIfNeeded(); + const box = await reads.boundingBox(); + expect(box && box.x >= 0 && box.x + box.width <= w && box.height > 20).toBe(true); + await page.screenshot({ path: join(SHOTS, `reads-${w}x${h}.png`), fullPage: false }); + } + await page.setViewportSize({ width: 1280, height: 800 }); + await page.getByRole("button", { name: "create", exact: true }).click(); + const header = page.getByRole("heading", { name: "reader", level: 2 }).locator("xpath=.."); + const chip = header.getByTestId("workstream-chip"); + await expect(chip).toContainText("Mine +1"); + // A fresh session's sandbox is no change: nothing said about its directories. + await expect(page.getByText("Sandbox directories applied")).toHaveCount(0); + + await chip.click(); + const panel = page.getByRole("dialog", { name: "Workstream" }); + await expect(panel).toContainText("Pub"); + for (const [w, h] of VIEWPORTS) { + await page.setViewportSize({ width: w, height: h }); + // Re-opened at each size: it is placed from the chip's box when it opens. + await page.keyboard.press("Escape"); + await chip.click(); + const box = await panel.boundingBox(); + expect(box && box.x >= 0 && box.x + box.width <= w && box.width > 200 && box.height > 40).toBe(true); + const move = await panel.getByRole("button", { name: "Move", exact: true }).boundingBox(); + expect(move && move.x + move.width <= w).toBe(true); + await page.screenshot({ path: join(SHOTS, `chip-panel-${w}x${h}.png`), fullPage: false }); + } + await page.setViewportSize({ width: 1280, height: 800 }); + await panel.getByLabel("Add a workstream to read").selectOption("acme"); + await panel.getByRole("button", { name: "Add", exact: true }).click(); + await expect(chip).toContainText("Mine +2"); + await panel.getByRole("button", { name: "Remove" }).first().click(); + await expect(chip).toContainText("Mine +1"); +}); diff --git a/e2e/specs/workstreams-w4.spec.ts b/e2e/specs/workstreams-w4.spec.ts new file mode 100644 index 00000000..f49b6797 --- /dev/null +++ b/e2e/specs/workstreams-w4.spec.ts @@ -0,0 +1,67 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { expect, openUi, test } from "../harness"; + +const VIEWPORTS = [ + [375, 667], + [390, 844], + [768, 1024], + [1280, 800], + [1470, 830], + [1920, 1080], + [1280, 300], +] as const; +const SHOTS = join(process.cwd(), "test-results", "workstreams-shots"); + +// Workstreams W4 (§10): the conductor's send-class card names the workstream +// the action runs in, and warns when approving it confirms an override. +test("the conductor's spawn card names the workstream, and warns of an override", async ({ page, codeoid }) => { + mkdirSync(SHOTS, { recursive: true }); + const acme = join(codeoid.root, "acme"); + mkdirSync(acme, { recursive: true }); + codeoid.configure({ + conductor: { enabled: true, provider: "scripted" }, + workstreams: { + isolateGlobalServers: true, + items: [ + { id: "mine", match: { paths: [codeoid.workdir] } }, + { id: "acme", claim: "exclusive", match: { paths: [acme] } }, + ], + }, + }); + codeoid.setScript([ + { + match: "^spawn it", + steps: [ + { text: "Spawning a worker." }, + { tool: "mcp__codeoid_fleet__fleet_spawn", input: { workdir: acme, task: "look around", workstream: "mine" } }, + { text: "Done." }, + ], + }, + ]); + await codeoid.restart(); + // `conductor` is made on first use; the send leaves its turn waiting on the spawn's card. + await codeoid.cliOk("send", "conductor", "spawn it"); + await openUi(page, codeoid); + await page.getByRole("button", { name: "Conductor", exact: true }).click(); + const line = page.getByTestId("approval-workstream"); + await expect(line).toContainText("workstream mine", { timeout: 15_000 }); + await expect(line).toContainText('Past workstream "acme"'); + for (const [w, h] of VIEWPORTS) { + await page.setViewportSize({ width: w, height: h }); + // The bar re-renders on a resize: wait for the line as it is now, never a node swapped out. + await expect(line).toBeVisible(); + for (let i = 0; i < 3; i++) { + try { + await line.scrollIntoViewIfNeeded({ timeout: 2_000 }); + break; + } catch {} + } + await page.screenshot({ path: join(SHOTS, `conductor-card-${w}x${h}.png`), fullPage: false }); + const box = await line.boundingBox(); + // On screen, not only in the DOM: inside the window both ways. + expect([w, h, box && box.x >= 0 && box.x + box.width <= w && box.y >= 0 && box.y + box.height <= h && box.height > 8]).toEqual([w, h, true]); + const approve = await page.getByRole("button", { name: "approve", exact: true }).boundingBox(); + expect([w, h, approve !== null && approve.x + approve.width <= w]).toEqual([w, h, true]); + } +}); diff --git a/packages/protocol/src/approvals.ts b/packages/protocol/src/approvals.ts index 524a6b33..b85ed6a8 100644 --- a/packages/protocol/src/approvals.ts +++ b/packages/protocol/src/approvals.ts @@ -42,6 +42,8 @@ export interface PendingToolApproval { sandboxNote?: string; /** The sandbox lets commands reach the container runtime (Docker access). */ sandboxDocker?: boolean; + /** A send-class fleet call's workstream, and what approving it confirms (workstreams §10; as on the card). */ + targetWorkstream?: { workstream?: string; warning?: string }; } export interface SessionApprovalsResultMsg { diff --git a/packages/protocol/src/schemas.test.ts b/packages/protocol/src/schemas.test.ts index 841efe9b..90d34e59 100644 --- a/packages/protocol/src/schemas.test.ts +++ b/packages/protocol/src/schemas.test.ts @@ -96,6 +96,9 @@ const samples: { [T in ClientTypes]: Extract } = { "session.set_mode": { type: "session.set_mode", id: "r10", sessionId: "s1", mode: "autonomous", maxTurns: 5 }, "session.set_sandbox": { type: "session.set_sandbox", id: "r10s", sessionId: "s1", sandbox: { provider: "bwrap", network: "allowlist", allowedHosts: ["github.com"] } }, "session.sandbox_dir": { type: "session.sandbox_dir", id: "r10d", sessionId: "s1", action: "add", path: "/home/u/Workspace/b" }, + "session.add_reference": { type: "session.add_reference", id: "r10e", sessionId: "s1", workstream: "acme", paths: ["/w/acme-api"] }, + "session.remove_reference": { type: "session.remove_reference", id: "r10f", sessionId: "s1", workstream: "acme" }, + "session.set_workstream": { type: "session.set_workstream", id: "r10g", sessionId: "s1", workstream: "acme" }, "sandbox.providers": { type: "sandbox.providers", id: "r10p" }, "session.pin": { type: "session.pin", id: "r11", sessionId: "s1", path: "SPEC.md" }, "session.unpin": { type: "session.unpin", id: "r12", sessionId: "s1", path: "SPEC.md" }, diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 0469af12..1be987aa 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -32,6 +32,15 @@ const base = { id: idField }; /** A workstream id, bounded only: a malformed one must PARSE so the daemon answers `workstream_unknown`. */ const workstreamIdField = z.string().min(1).max(64); +/** A reference asked for (§7.2): absolute folders only, bounded. */ +export const workstreamReferenceInputSchema = z + .object({ + workstream: workstreamIdField, + paths: z.array(z.string().min(1).max(4096)).max(64).optional(), + }) + .strict(); + + // ── Collaboration ───────────────────────────────────────────────────────────── /** @@ -172,6 +181,7 @@ export const sessionCreateSchema = z.object({ */ workstream: workstreamIdField.optional(), override: z.object({ token: z.string().min(1).max(128) }).strict().optional(), + references: z.array(workstreamReferenceInputSchema).max(32).optional(), }); export const sessionListSchema = z.object({ ...base, type: z.literal("session.list") }); @@ -288,6 +298,28 @@ export const sessionSetSandboxSchema = z.object({ sandbox: sessionSandboxSettingSchema, }); +export const sessionAddReferenceSchema = z.object({ + ...base, + type: z.literal("session.add_reference"), + sessionId: sessionIdField, + workstream: workstreamIdField, + paths: z.array(z.string().min(1).max(4096)).max(64).optional(), +}); + +export const sessionRemoveReferenceSchema = z.object({ + ...base, + type: z.literal("session.remove_reference"), + sessionId: sessionIdField, + workstream: workstreamIdField, +}); + +export const sessionSetWorkstreamSchema = z.object({ + ...base, + type: z.literal("session.set_workstream"), + sessionId: sessionIdField, + workstream: workstreamIdField, +}); + export const sessionSandboxDirSchema = z.object({ ...base, type: z.literal("session.sandbox_dir"), @@ -1324,6 +1356,9 @@ export const clientMessageSchema = z.discriminatedUnion("type", [ sessionSetModeSchema, sessionSetSandboxSchema, sessionSandboxDirSchema, + sessionAddReferenceSchema, + sessionRemoveReferenceSchema, + sessionSetWorkstreamSchema, sandboxProvidersSchema, sessionPinSchema, sessionUnpinSchema, diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 0f08e754..d02e71f6 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -1126,6 +1126,14 @@ export interface ToolWaitingConfirmationState { * roll-up could not be computed. */ collaborationCost?: CollaborationCost; + /** + * Present only for a send-class fleet dispatch with workstreams: the + * workstream the action runs in (the target session's, or the spawned + * worker's home), and a warning when the spawn's home is chosen past the + * folder's own exclusive workstream (approving the card confirms it, G3) + * or can't be decided (the spawn will be refused). docs/workstreams-design.md §10. + */ + targetWorkstream?: { workstream?: string; warning?: string }; /** * A shell command's risk, judged from what it runs (#431); for a file-tool * write into what the OS sandbox holds read-only, that reason first @@ -1330,6 +1338,9 @@ export type ClientMessage = | SessionSetModeMsg | SessionSetSandboxMsg | SessionSandboxDirMsg + | SessionAddReferenceMsg + | SessionRemoveReferenceMsg + | SessionSetWorkstreamMsg | SandboxProvidersMsg | SessionPinMsg | SessionUnpinMsg @@ -1620,6 +1631,13 @@ export interface SessionCreateMsg extends BaseClientMsg { * undelegated token. */ override?: { token: string }; + /** + * Other workstreams the session may read (docs/workstreams-design.md §7.2): + * their folders read-only and their memory. Each follows the share policy + * of the workstream read: `open` is granted, `ask` needs `session:approve` + * on an undelegated token (the creator's confirm), `closed` is refused. + */ + references?: WorkstreamReferenceInput[]; } /** @@ -1938,6 +1956,45 @@ export interface SessionSetSandboxMsg extends BaseClientMsg { * (`data: { pending: true }`), and lands on a yes. It takes effect at the * session's next turn with no background work running. */ +/** A reference asked for (§7.2): a workstream, and optionally folders inside it (absolute; absent = all of them). */ +export interface WorkstreamReferenceInput { + workstream: string; + paths?: string[]; +} + +/** + * Give a session read access to another workstream (§7.1, §7.2). Owner-only. + * `open`: granted; `ask`: granted at once when the owner asks with + * `session:approve` on an undelegated token, else the owner confirms on the + * session (`response.ok` with `pending`); `closed`: refused. + */ +export interface SessionAddReferenceMsg extends BaseClientMsg { + type: "session.add_reference"; + sessionId: string; + workstream: string; + paths?: string[]; +} + +/** Take a reference away (owner-only). The session still counts as having read from it (§14 q3). */ +export interface SessionRemoveReferenceMsg extends BaseClientMsg { + type: "session.remove_reference"; + sessionId: string; + workstream: string; +} + +/** + * Move a session to another home workstream (§5.3): a share of its whole + * transcript, so it follows the share policy of its old home and of every + * workstream it has read from (`ask` as for a reference; `closed` refuses). + * Past another workstream's exclusive claim on the folder it needs + * `session:approve` on an undelegated token. Applied at the next turn. + */ +export interface SessionSetWorkstreamMsg extends BaseClientMsg { + type: "session.set_workstream"; + sessionId: string; + workstream: string; +} + export interface SessionSandboxDirMsg extends BaseClientMsg { type: "session.sandbox_dir"; sessionId: string; diff --git a/packages/protocol/src/workstreams.ts b/packages/protocol/src/workstreams.ts index 3708a26f..3ef50eca 100644 --- a/packages/protocol/src/workstreams.ts +++ b/packages/protocol/src/workstreams.ts @@ -36,6 +36,10 @@ export interface SessionWorkstreamInfo { matchedBy?: string; /** The home was deleted from the config: the session needs a new one. */ orphaned?: boolean; + /** Other workstreams it may read (§7.2); `paths` absent = all their folders. */ + references?: Array<{ workstream: string; paths?: string[] }>; + /** Every workstream it has read from, for its lifetime (a move follows each one's policy). */ + readFrom?: string[]; } export interface WorkstreamServerInfo { diff --git a/src/cli.ts b/src/cli.ts index 970702d7..d7b398cc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1090,6 +1090,12 @@ program "Home workstream (docs/workstreams-design.md): decides the session's servers, credentials and defaults. Default: the folder's (`codeoid workstream match `), else the configured default.", ) .option("--override", "with --workstream: confirm it past the folder's own exclusive workstream without asking (needs session:approve)") + .option( + "--ref ", + "Another workstream the session may read: its folders read-only (or only ) and its memory, repeatable. By its share policy: open, ask (needs session:approve), closed (refused).", + (v: string, p: string[] = []) => [...p, v], + [] as string[], + ) .option( "--pack ", "Activate an installed SDLC pack on the session (inject its constitution, expose its skills/subagents). With --collaborate, the collaboration ADOPTS the pack: role names bind strictly to the pack's declared roles.", @@ -1133,9 +1139,17 @@ program projectRoots?: boolean; workstream?: string; override?: boolean; + ref: string[]; }, ) => { const config = loadConfig(); + let references: Array<{ workstream: string; paths?: string[] }>; + try { + references = referenceFlags(opts.ref); + } catch (e) { + console.error(e instanceof Error ? e.message : String(e)); + process.exit(1); + } if (opts.override && !opts.workstream) { console.error("--override confirms a --workstream choice; give one (codeoid workstream match shows the folder's)."); process.exit(1); @@ -1210,6 +1224,7 @@ program collaboration, ...(sandbox ? { sandbox } : {}), ...(opts.workstream ? { workstream: opts.workstream } : {}), + ...(references.length > 0 ? { references } : {}), confirmOverride: async (warning: string) => { console.error(warning); return confirmOnTerminal("Start it anyway? [y/N] ", opts.override, "Not started: pass --override to confirm without a terminal."); @@ -1219,6 +1234,22 @@ program }, ); +/** `--ref acme`, `--ref acme:~/Workspace/acme-api` → references, one per workstream (folders gathered). */ +export function referenceFlags(flags: readonly string[]): Array<{ workstream: string; paths?: string[] }> { + const out = new Map(); + for (const f of flags) { + const at = f.indexOf(":"); + const workstream = (at < 0 ? f : f.slice(0, at)).trim(); + const dir = at < 0 ? "" : f.slice(at + 1).trim(); + if (!workstream) throw new Error(`--ref ${f}: name a workstream (--ref [:])`); + const cur = out.get(workstream) ?? { workstream, whole: false }; + if (!dir) cur.whole = true; + else cur.paths = [...(cur.paths ?? []), dirFlag(dir)]; + out.set(workstream, cur); + } + return [...out.values()].map((r) => (r.whole || !r.paths ? { workstream: r.workstream } : { workstream: r.workstream, paths: r.paths })); +} + /** A directory as the daemon keeps it: `~` left to it (its home), a relative one this shell's. */ function dirFlag(d: string): string { return d === "~" || d.startsWith("~/") || isAbsolute(d) ? d : resolve(process.cwd(), d); @@ -1708,6 +1739,43 @@ workstreamCmd }); }); +workstreamCmd + .command("move ") + .description("Move a session to another home workstream: its whole conversation goes with it, so the share policy of its old home (and of each workstream it read) decides; applied at its next turn") + .action(async (session: string, workstream: string) => { + const client = new TerminalClient(loadConfig()); + await client.connect(); + try { + await client.sessionWorkstream(session, { kind: "set_workstream", workstream }); + } finally { + client.disconnect(); + } + }); + +workstreamCmd + .command("ref ") + .description("add | rm: let a session read another workstream (its folders read-only, or only , and its memory), or stop") + .action(async (action: string, session: string, target: string) => { + if (action !== "add" && action !== "rm") { + console.error("codeoid workstream ref add|rm [:]"); + process.exit(1); + } + let ref: { workstream: string; paths?: string[] }; + try { + [ref] = referenceFlags([target]) as [{ workstream: string; paths?: string[] }]; + } catch (e) { + console.error(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + const client = new TerminalClient(loadConfig()); + await client.connect(); + try { + await client.sessionWorkstream(session, action === "add" ? { kind: "add_reference", ...ref } : { kind: "remove_reference", workstream: ref.workstream }); + } finally { + client.disconnect(); + } + }); + workstreamCmd .command("match [dir]") .description("Which workstream a folder belongs to, and why — what a new session there would get") diff --git a/src/daemon/dispatch.ts b/src/daemon/dispatch.ts index ecb6f535..09578cfa 100644 --- a/src/daemon/dispatch.ts +++ b/src/daemon/dispatch.ts @@ -359,6 +359,10 @@ export class Dispatcher { provider?: string; /** Per-child model, already resolved against `provider`. */ model?: string; + /** A spawn's target workstream (workstreams §10), checked by the caller; and whether its card confirmed an override. */ + workstream?: string; + workstreamOverride?: boolean; + workstreamRevision?: string; createdBy: string; }): string { const isAttempt = input.kind === "attempt"; @@ -385,6 +389,7 @@ export class Dispatcher { "dispatch.enqueued", `task=${id} kind=${input.kind} shape=${input.shape} target=${input.targetSession ?? input.workdir ?? "-"}` + `${input.provider ? ` provider=${input.provider}` : ""}${input.model ? ` model=${input.model}` : ""}` + + `${input.workstream ? ` workstream=${input.workstream}${input.workstreamOverride ? " override" : ""}` : ""}` + `${isAttempt ? ` run=${input.runId} attempt=${input.attemptId}` : ""}`, ); this.#signalBoardChange(); diff --git a/src/daemon/fleet.ts b/src/daemon/fleet.ts index 070c6d0e..c237ac8a 100644 --- a/src/daemon/fleet.ts +++ b/src/daemon/fleet.ts @@ -44,6 +44,8 @@ export interface FleetSessionView { model?: string; attachedClients: number; createdAt: string; + /** Its home workstream (workstreams §10); absent with none configured, or no home. */ + workstream?: string; } /** A dispatch task as the conductor sees it on the fleet_tasks board. */ @@ -74,7 +76,18 @@ export interface FleetDispatchDeps { prompt: string; provider?: string; model?: string; + workstream?: string; + workstreamOverride?: boolean; + workstreamRevision?: string; }): string; + /** + * A spawn's home (workstreams §10, §5): the workstream asked for, else the + * folder's — checked before the task is queued, so a conflict reaches the + * conductor while it can still choose. `override`: past the folder's + * exclusive claim, which the spawn's approval card said (G3). Absent = no + * workstreams on this daemon. + */ + checkWorkstream?(workdir: string, workstream?: string, confirmation?: string): { ok: true; workstream?: string; override?: boolean; revision?: string } | { ok: false; error: string }; /** Interrupt a running session immediately (post-approval). */ interrupt(sessionId: string): Promise; /** Normalize + validate a spawn workdir; null when unusable. */ @@ -122,6 +135,14 @@ export interface FleetDeps { conductorSessionId(): string; /** Send-class dispatch (P4). Absent = read-only conductor. */ dispatch?: FleetDispatchDeps; + /** + * Whether text is withheld from the conductor (workstreams §10: a + * `closed` workstream gives status, never text): judged by the episode's + * workstream, else its session's (an untagged episode, from before + * workstreams), else withheld while any workstream is closed. Absent = + * nothing withheld (no workstreams, or isolation off). + */ + textWithheld?(ref: { workstream?: string; sessionId?: string }): boolean; } /** @@ -189,9 +210,13 @@ function ago(iso: string | number): string { function sessionLine(s: FleetSessionView): string { const marker = s.role === "conductor" ? " [conductor — you]" : ""; const model = s.model ? ` model=${s.model}` : ""; - return `- ${s.name} (${s.id.slice(0, 8)})${marker} — ${s.status}, ${s.attachedClients} client(s), provider=${s.providerId}${model}, workdir=${s.workdir}, created ${ago(s.createdAt)}`; + const ws = s.workstream ? `, workstream=${s.workstream}` : ""; + return `- ${s.name} (${s.id.slice(0, 8)})${marker} — ${s.status}, ${s.attachedClients} client(s), provider=${s.providerId}${model}${ws}, workdir=${s.workdir}, created ${ago(s.createdAt)}`; } +/** What the conductor is told in place of a closed workstream's text. */ +const WITHHELD = (ws: string | undefined) => `(workstream ${ws ?? "?"} is closed: its text isn't shared — status only)`; + /** Resolve a user-supplied session reference (name or id/prefix) to a view. */ function resolveSession( sessions: FleetSessionView[], @@ -211,8 +236,8 @@ function resolveSession( */ export function createFleetHandlers(deps: FleetDeps) { return { - async fleet_list(): Promise { - const sessions = deps.listSessions(); + async fleet_list(args: { workstream?: string } = {}): Promise { + const sessions = deps.listSessions().filter((s) => !args.workstream || s.workstream === args.workstream); deps.audit("fleet.list", `sessions=${sessions.length}`); if (sessions.length === 0) return "No sessions in the fleet."; // Group by workdir so the fleet reads as a machine map, not a flat list. @@ -229,13 +254,14 @@ export function createFleetHandlers(deps: FleetDeps) { return `${sessions.length} session(s) across ${byWorkdir.size} workspace(s):\n\n${blocks.join("\n\n")}`; }, - async fleet_find(args: { query: string; limit?: number }): Promise { + async fleet_find(args: { query: string; limit?: number; workstream?: string }): Promise { deps.audit("fleet.find", `query=${args.query.slice(0, 200)}`); if (!deps.memory) { return "Memory is disabled on this daemon — fleet_find needs the memory engine. Use fleet_list instead."; } const sessions = deps.listSessions(); const sessionNames = new Map(sessions.map((s) => [s.id, s.name])); + const sessionWorkstreams = new Map(sessions.map((s) => [s.id, s.workstream])); const conductorId = deps.conductorSessionId(); const hits = ( await deps.memory.searchSessions({ @@ -245,17 +271,23 @@ export function createFleetHandlers(deps: FleetDeps) { limit: Math.min(args.limit ?? 5, MAX_LIMIT) + 1, sessionNames, }) - ).filter((h) => h.sessionId !== conductorId); + ) + .filter((h) => h.sessionId !== conductorId) + .map((h) => ({ ...h, workstream: sessionWorkstreams.get(h.sessionId) ?? h.snippets.find((sn) => sn.workstreamId)?.workstreamId })) + .filter((h) => !args.workstream || h.workstream === args.workstream) + // A closed workstream's sessions never come back from a query: that they matched is text too (§10). + .filter((h) => !deps.textWithheld?.({ sessionId: h.sessionId, ...(h.workstream ? { workstream: h.workstream } : {}) }) && !h.snippets.some((sn) => deps.textWithheld?.({ sessionId: h.sessionId, ...(sn.workstreamId ? { workstream: sn.workstreamId } : {}) }))); if (hits.length === 0) { return `No session matched "${args.query}". It may predate memory, or try different terms.`; } const lines = hits.slice(0, Math.min(args.limit ?? 5, MAX_LIMIT)).map((h, i) => { const name = sessionNames.get(h.sessionId) ?? "(no longer running)"; + // A closed workstream's snippets are its text: never shown (§10). const evidence = h.snippets .slice(0, 2) .map((sn) => ` · [${sn.kind}] ${sn.summary}`) .join("\n"); - return `${i + 1}. ${name} (${h.sessionId.slice(0, 8)}) — ${h.matchCount} match(es), last activity ${ago(h.lastMatchAt)}\n${evidence}`; + return `${i + 1}. ${name} (${h.sessionId.slice(0, 8)})${h.workstream ? ` [${h.workstream}]` : ""} — ${h.matchCount} match(es), last activity ${ago(h.lastMatchAt)}\n${evidence}`; }); return `Top session(s) for "${args.query}":\n${lines.join("\n")}`; }, @@ -268,12 +300,13 @@ export function createFleetHandlers(deps: FleetDeps) { return `No session matches "${args.session}". Use fleet_list to see the fleet.`; } const head = sessionLine(target); + if (deps.textWithheld?.({ sessionId: target.id, ...(target.workstream ? { workstream: target.workstream } : {}) })) return `${head}\n${WITHHELD(target.workstream)}`; if (!deps.memory) return `${head}\n(no memory engine — activity digest unavailable)`; // Compressed digest: the session's recent episode SUMMARIES (one line // each), never raw scrollback/transcript — the never-OOC guarantee. const episodes = deps.memory .timeline(target.workspaceId, 60) - .filter((e) => e.sessionId === target.id) + .filter((e) => e.sessionId === target.id && !deps.textWithheld?.({ sessionId: e.sessionId, ...(e.workstreamId ? { workstream: e.workstreamId } : {}) })) .slice(0, 12); if (episodes.length === 0) return `${head}\n(no recorded activity yet)`; const lines = episodes.map( @@ -282,24 +315,28 @@ export function createFleetHandlers(deps: FleetDeps) { return `${head}\n\nRecent activity (${episodes.length} episode(s), newest first):\n${lines.join("\n")}`; }, - async fleet_recall(args: { query: string; limit?: number }): Promise { + async fleet_recall(args: { query: string; limit?: number; workstream?: string }): Promise { deps.audit("fleet.recall", `query=${args.query.slice(0, 200)}`); if (!deps.memory) { return "Memory is disabled on this daemon — fleet_recall needs the memory engine."; } const sessionNames = new Map(deps.listSessions().map((s) => [s.id, s.name])); - const hits = await deps.memory.recallGlobal({ - query: args.query, - tenant: deps.tenant, - limit: Math.min(args.limit ?? 6, MAX_LIMIT), - }); + const limit = Math.min(args.limit ?? 6, MAX_LIMIT); + // Asked for more than shown: a closed workstream's episodes are dropped, never shown (§10). + const all = await deps.memory.recallGlobal({ query: args.query, tenant: deps.tenant, limit: limit * 3 }); + const inScope = all.filter((h) => !args.workstream || h.episode.workstreamId === args.workstream); + // Dropped before anything is counted or said: even "N more were withheld" would answer the query. + const hits = inScope + .filter((h) => !deps.textWithheld?.({ sessionId: h.episode.sessionId, ...(h.episode.workstreamId ? { workstream: h.episode.workstreamId } : {}) })) + .slice(0, limit); + const note = ""; if (hits.length === 0) return `Nothing recalled for "${args.query}".`; const lines = hits.map((h) => { const e = h.episode; const name = sessionNames.get(e.sessionId) ?? e.sessionId.slice(0, 8); - return `- [${name}] ${e.kind}${e.toolName ? `/${e.toolName}` : ""}: ${e.summary}`; + return `- [${name}${e.workstreamId ? ` · ${e.workstreamId}` : ""}] ${e.kind}${e.toolName ? `/${e.toolName}` : ""}: ${e.summary}`; }); - return `Recalled ${hits.length} episode(s) across the fleet:\n${lines.join("\n")}`; + return `Recalled ${hits.length} episode(s) across the fleet:\n${lines.join("\n")}${note}`; }, async machine_map(): Promise { @@ -314,7 +351,9 @@ export function createFleetHandlers(deps: FleetDeps) { } const blocks = await Promise.all( [...byWorkdir.entries()].map(async ([workdir, group]) => { - const git = await probeGit(workdir); + // A closed workstream's branches are its text too (feat/acme-acquisition). + const closed = group.some((s) => deps.textWithheld?.({ sessionId: s.id, ...(s.workstream ? { workstream: s.workstream } : {}) })); + const git = closed ? "(a closed workstream's folder)" : await probeGit(workdir); const members = group .map((s) => `${s.name} (${s.status}${s.role === "conductor" ? ", conductor" : ""})`) .join(", "); @@ -350,7 +389,7 @@ export function createFleetHandlers(deps: FleetDeps) { targetSession: target.id, prompt: args.message, }); - return `Queued task ${taskId.slice(0, 8)}: send to ${target.name} (${target.workdir}). Delivery happens on the next dispatcher tick — track it with fleet_tasks.`; + return `Queued task ${taskId.slice(0, 8)}: send to ${target.name} (${target.workdir}${target.workstream ? `, workstream ${target.workstream}` : ""}). Delivery happens on the next dispatcher tick — track it with fleet_tasks.`; }, /** @@ -409,6 +448,9 @@ export function createFleetHandlers(deps: FleetDeps) { shape?: "ship" | "scout"; provider?: string; model?: string; + workstream?: string; + /** Set by codeoid on an approved call whose card confirmed something (workstreams §10); never the model's. */ + confirmation?: string; }): Promise { if (!deps.dispatch) return "Dispatch is disabled on this daemon."; const shape = args.shape ?? "scout"; @@ -425,6 +467,11 @@ export function createFleetHandlers(deps: FleetDeps) { return `Workdir not usable: ${args.workdir} (missing, protected, or outside the allowed root).`; } if (!backend.ok) return backend.error; + // Its home (§10): the workstream named, else the folder's — a conflict is the conductor's to resolve now. + const home: { ok: true; workstream?: string; override?: boolean; revision?: string } | { ok: false; error: string } = deps.dispatch.checkWorkstream + ? deps.dispatch.checkWorkstream(workdir, args.workstream, args.confirmation) + : { ok: true }; + if (!home.ok) return home.error; const taskId = deps.dispatch.enqueue({ kind: "spawn", shape, @@ -432,11 +479,16 @@ export function createFleetHandlers(deps: FleetDeps) { prompt: args.task, provider: backend.provider, model: backend.model, + // Stored only when named: the folder's own is decided again at claim, as for any spawn (its + // remote may have been changed meanwhile: SessionManager #workstreamForFolder). + ...(args.workstream && home.workstream ? { workstream: home.workstream } : {}), + ...(home.override ? { workstreamOverride: true, ...(home.revision ? { workstreamRevision: home.revision } : {}) } : {}), }); const on = backend.provider ? ` on ${backend.provider}${backend.model ? `/${backend.model}` : ""}` : ""; - return `Queued task ${taskId.slice(0, 8)}: spawn ${shape} worker${on} in ${workdir}. You'll receive a digest when it finishes — track it with fleet_tasks.`; + const ws = home.workstream ? `, homed in workstream ${home.workstream}${home.override ? " (past the folder's own, as approved)" : ""}` : ""; + return `Queued task ${taskId.slice(0, 8)}: spawn ${shape} worker${on} in ${workdir}${ws}. You'll receive a digest when it finishes — track it with fleet_tasks.`; }, async fleet_interrupt(args: { session: string }): Promise { @@ -568,9 +620,9 @@ export function buildFleetMcpServer( const registered = pick([ tool( "fleet_list", - "List every session in the fleet, grouped by workspace — names, status, provider, attached clients. Your view of what exists right now.", - {}, - async () => text(await handlers.fleet_list()), + "List every session in the fleet, grouped by workspace — names, status, provider, workstream, attached clients. Your view of what exists right now.", + { workstream: z.string().max(64).optional().describe("Only this workstream's sessions (each result names its workstream)"), }, + async ({ workstream }) => text(await handlers.fleet_list({ ...(workstream ? { workstream } : {}) })), ), tool( "fleet_find", @@ -578,8 +630,9 @@ export function buildFleetMcpServer( { query: z.string().describe("Natural-language description of the work/session to find"), limit: z.number().int().min(1).max(MAX_LIMIT).optional().describe("Max sessions to return (default 5)"), + workstream: z.string().max(64).optional().describe("Only this workstream's sessions (each result names its workstream)"), }, - async ({ query, limit }) => text(await handlers.fleet_find({ query, limit })), + async ({ query, limit, workstream }) => text(await handlers.fleet_find({ query, limit, ...(workstream ? { workstream } : {}) })), ), tool( "fleet_summary", @@ -595,8 +648,9 @@ export function buildFleetMcpServer( { query: z.string().describe("What to recall"), limit: z.number().int().min(1).max(MAX_LIMIT).optional().describe("Max episodes (default 6)"), + workstream: z.string().max(64).optional().describe("Only this workstream's episodes. A closed workstream's are never returned"), }, - async ({ query, limit }) => text(await handlers.fleet_recall({ query, limit })), + async ({ query, limit, workstream }) => text(await handlers.fleet_recall({ query, limit, ...(workstream ? { workstream } : {}) })), ), tool( "machine_map", @@ -654,9 +708,15 @@ export function buildFleetMcpServer( shape: z.enum(["ship", "scout"]).optional().describe("scout = investigate/report (default, read-only identity); ship = deliver a change"), provider: z.string().optional().describe("Backend the worker runs on (e.g. claude, gemini, openai, codex, pi). Omit for the daemon default. An unregistered id is rejected before the task is queued."), model: z.string().optional().describe("Model for the worker, valid for the chosen provider. Omit for that provider's default."), + workstream: z + .string() + .max(64) + .optional() + .describe("The workstream the worker is homed in: it gets that workstream's servers, credentials and memory only. Omit for the folder's own; required where the folder has none. A conflict with the folder's exclusive workstream is shown on the approval card."), + confirmation: z.string().max(64).optional().describe("Set by codeoid when the owner approves; never set it yourself."), }, - async ({ workdir, task, shape, provider, model }) => - text(await handlers.fleet_spawn({ workdir, task, shape, provider, model })), + async ({ workdir, task, shape, provider, model, workstream, confirmation }) => + text(await handlers.fleet_spawn({ workdir, task, shape, provider, model, ...(workstream ? { workstream } : {}), ...(confirmation ? { confirmation } : {}) })), ), tool( "fleet_interrupt", diff --git a/src/daemon/memory/engine.ts b/src/daemon/memory/engine.ts index 9853b868..be2665d0 100644 --- a/src/daemon/memory/engine.ts +++ b/src/daemon/memory/engine.ts @@ -13,7 +13,7 @@ import type { Embedder } from "./embedder.js"; import { normalize } from "./embedder.js"; -import type { SqliteEpisodeStore, WorkspaceTenant } from "./store.js"; +import { type SqliteEpisodeStore, type WorkspaceTenant, workstreamReadable } from "./store.js"; import { DEFAULT_WEIGHTS, rank, type RankerWeights } from "./ranker.js"; import type { Reranker } from "./reranker.js"; import { embedWindows, indexView } from "./lossless.js"; @@ -55,6 +55,8 @@ export interface SessionSearchHit { createdAt: number; score: number; filePaths: string[]; + /** The workstream that wrote it (workstreams §6.4); absent when untagged. */ + workstreamId?: string; }>; } @@ -212,8 +214,8 @@ export class MemoryEngine { } /** The verbatim body of an episode in `workspaceId` — not just its index view. */ - getFullContentScoped(id: string, workspaceId: string): string | null { - if (!this.#store.getEpisodeInWorkspace(id, workspaceId)) return null; + getFullContentScoped(id: string, workspaceId: string, workstreams?: readonly string[]): string | null { + if (!this.getEpisodeScoped(id, workspaceId, workstreams)) return null; return this.#store.getFullContent(id); } @@ -228,6 +230,26 @@ export class MemoryEngine { } } + /** Each session's workstream filter (workstreams §6.4), read at every call by the memory tools. */ + #workstreamScopes = new Map readonly string[] | undefined>(); + + /** + * Scope a session's memory tools to the workstreams it may read (its home + * and references), read live; returns the unregister. Every transport's + * tools carry the session id, so this one seam covers them all. + */ + scopeSession(sessionId: string, workstreams: () => readonly string[] | undefined): () => void { + this.#workstreamScopes.set(sessionId, workstreams); + return () => { + if (this.#workstreamScopes.get(sessionId) === workstreams) this.#workstreamScopes.delete(sessionId); + }; + } + + /** The workstreams a session's memory tools may read; undefined = no filter. */ + workstreamsOf(sessionId: string): readonly string[] | undefined { + return this.#workstreamScopes.get(sessionId)?.(); + } + /** Hybrid retrieval. */ async recall(q: RecallQuery): Promise { const limit = q.limit ?? 8; @@ -348,14 +370,16 @@ export class MemoryEngine { } /** Tenant-scoped fetch by id — the only variant safe to expose to a model. */ - getEpisodeScoped(id: string, workspaceId: string): Episode | null { - return this.#store.getEpisodeInWorkspace(id, workspaceId); + getEpisodeScoped(id: string, workspaceId: string, workstreams?: readonly string[]): Episode | null { + const ep = this.#store.getEpisodeInWorkspace(id, workspaceId); + return ep && workstreamReadable(ep.workstreamId, workstreams) ? ep : null; } /** List recent episodes (for the warm-tier index / timeline UI). `offset` - * enables ordered pagination over the full workspace history. */ - timeline(workspaceId: string, limit = 40, offset = 0): Episode[] { - return this.#store.listRecent(workspaceId, limit, offset); + * enables ordered pagination over the full workspace history; `workstreams` + * keeps it to what they may read (RecallQuery.workstreams). */ + timeline(workspaceId: string, limit = 40, offset = 0, workstreams?: readonly string[]): Episode[] { + return this.#store.listRecent(workspaceId, limit, offset, workstreams); } /** @@ -481,6 +505,7 @@ export class MemoryEngine { createdAt: h.episode.createdAt, score: h.score, filePaths: h.episode.filePaths, + ...(h.episode.workstreamId ? { workstreamId: h.episode.workstreamId } : {}), })), }); } diff --git a/src/daemon/memory/store.ts b/src/daemon/memory/store.ts index e302ae26..71b70870 100644 --- a/src/daemon/memory/store.ts +++ b/src/daemon/memory/store.ts @@ -59,6 +59,12 @@ interface EpisodeRow { embedding_model: string | null; created_at: number; created_by: string; + workstream_id?: string | null; +} + +/** Whether an episode's workstream is one `allowed` lets the caller read (RecallQuery.workstreams). */ +export function workstreamReadable(episodeWorkstream: string | undefined, allowed: readonly string[] | undefined): boolean { + return allowed === undefined || episodeWorkstream === undefined || allowed.includes(episodeWorkstream); } /** The tenant a workspace id is scoped to. Every episode query filters by @@ -434,6 +440,11 @@ export class SqliteEpisodeStore { this.#db.prepare("SELECT COUNT(*) AS n FROM pragma_table_info('turn_usage') WHERE name = 'cost_source'").get() as { n: number } | undefined )?.n ?? 0; if (!hasCostSource) this.#db.exec("ALTER TABLE turn_usage ADD COLUMN cost_source TEXT"); + // The writing session's home workstream (workstreams §6.4); NULL on episodes from before it. + const hasWorkstream = ( + this.#db.prepare("SELECT COUNT(*) AS n FROM pragma_table_info('episodes') WHERE name = 'workstream_id'").get() as { n: number } | undefined + )?.n ?? 0; + if (!hasWorkstream) this.#db.exec("ALTER TABLE episodes ADD COLUMN workstream_id TEXT"); } // ── Writes ──────────────────────────────────────────────────────────── @@ -453,8 +464,8 @@ export class SqliteEpisodeStore { .prepare( `INSERT INTO episodes ( id, workspace_id, session_id, kind, tool_name, summary, content, - file_paths, token_estimate, embedding, embedding_model, created_at, created_by - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + file_paths, token_estimate, embedding, embedding_model, created_at, created_by, workstream_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( id, @@ -470,6 +481,7 @@ export class SqliteEpisodeStore { episode.embeddingModel ?? null, episode.createdAt, episode.createdBy, + episode.workstreamId ?? null, ); if (episode.fullContent !== undefined && episode.fullContent !== episode.content) { @@ -1008,7 +1020,26 @@ export class SqliteEpisodeStore { return row ? this.#rowToEpisode(row) : null; } - listRecent(workspaceId: string, limit: number, offset = 0): Episode[] { + /** Whether `workspaceId` holds an episode a workstream outside `allowed` wrote (its index would show it). */ + hasOtherWorkstreams(workspaceId: string, allowed: readonly string[]): boolean { + const marks = allowed.map(() => "?").join(", "); + const row = this.#db + .prepare(`SELECT 1 FROM episodes WHERE workspace_id = ? AND workstream_id IS NOT NULL${allowed.length > 0 ? ` AND workstream_id NOT IN (${marks})` : ""} LIMIT 1`) + .get(workspaceId, ...allowed); + return row !== null && row !== undefined; + } + + listRecent(workspaceId: string, limit: number, offset = 0, workstreams?: readonly string[]): Episode[] { + if (workstreams !== undefined) { + // Only what the caller's workstreams may read (RecallQuery.workstreams), paged over that. + const marks = workstreams.map(() => "?").join(", "); + const rows = this.#db + .prepare( + `SELECT * FROM episodes WHERE workspace_id = ? AND (workstream_id IS NULL${workstreams.length > 0 ? ` OR workstream_id IN (${marks})` : ""}) ORDER BY created_at DESC LIMIT ? OFFSET ?`, + ) + .all(workspaceId, ...workstreams, limit, offset) as EpisodeRow[]; + return rows.map((r) => this.#rowToEpisode(r)); + } const rows = this.#db .prepare( "SELECT * FROM episodes WHERE workspace_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?", @@ -1580,6 +1611,7 @@ export class SqliteEpisodeStore { embeddingModel: row.embedding_model ?? undefined, createdAt: row.created_at, createdBy: row.created_by, + ...(row.workstream_id ? { workstreamId: row.workstream_id } : {}), }; } } @@ -1624,6 +1656,7 @@ function sanitizeFtsQuery(q: string): string { } function matchesQuery(ep: Episode, q: RecallQuery): boolean { + if (!workstreamReadable(ep.workstreamId, q.workstreams)) return false; if (q.sessionId && ep.sessionId !== q.sessionId) return false; if (q.excludeSessionId && ep.sessionId === q.excludeSessionId) return false; if (q.toolName && ep.toolName !== q.toolName) return false; diff --git a/src/daemon/memory/tools.test.ts b/src/daemon/memory/tools.test.ts index f707d77b..da00f650 100644 --- a/src/daemon/memory/tools.test.ts +++ b/src/daemon/memory/tools.test.ts @@ -135,3 +135,38 @@ describe("memory tool registry", () => { expect(typeof r).toBe("string"); }); }); + +describe("workstream scoping (workstreams §6.4)", () => { + async function scoped() { + const store = new SqliteEpisodeStore(":memory:"); + const engine = new MemoryEngine({ store, embedder: new FakeEmbedder() }); + await engine.init(); + const ids = { + acme: engine.ingest(ep("wsA", "s1", "acme unicorn", "unicorn acme deploy", { workstreamId: "acme" })).id, + hf: engine.ingest(ep("wsA", "s2", "hf unicorn", "unicorn hf deploy", { workstreamId: "hf" })).id, + legacy: engine.ingest(ep("wsA", "s3", "old unicorn", "unicorn old deploy")).id, + }; + await engine.drain(); + return { engine, ids }; + } + + test("episodes keep their workstream; a scoped session reads its home's and the untagged, never another's", async () => { + const { engine, ids } = await scoped(); + expect(engine.getEpisodeScoped(ids.acme, "wsA")?.workstreamId).toBe("acme"); + const off = engine.scopeSession("me", () => ["acme"]); + const ctx: MemoryToolContext = { engine, workspaceId: "wsA", sessionId: "me" }; + const recall = await defs().recall!.run({ query: "unicorn deploy", limit: 10 }, ctx); + expect(recall).toContain("acme unicorn"); + expect(recall).toContain("old unicorn"); + expect(recall).not.toContain("hf unicorn"); + const timeline = await defs().timeline!.run({ limit: 10 }, ctx); + expect(timeline).not.toContain("hf unicorn"); + expect(timeline).toContain("acme unicorn"); + expect(await defs().get_episode!.run({ episode_id: ids.hf }, ctx)).toContain("not found"); + // A reference widens it; unscoped (isolation off), everything as before. + off(); + engine.scopeSession("me", () => ["acme", "hf"]); + expect(await defs().recall!.run({ query: "unicorn deploy", limit: 10 }, ctx)).toContain("hf unicorn"); + expect(await defs().recall!.run({ query: "unicorn deploy", limit: 10 }, { ...ctx, sessionId: "other" })).toContain("hf unicorn"); + }); +}); diff --git a/src/daemon/memory/tools.ts b/src/daemon/memory/tools.ts index 1b9c7648..2f014467 100644 --- a/src/daemon/memory/tools.ts +++ b/src/daemon/memory/tools.ts @@ -25,6 +25,11 @@ export interface MemoryToolContext { workspaceId: string; /** Excluded from recall by default so the caller doesn't recall its own turns. */ sessionId: string; + /** + * The workstreams the calling session may read (workstreams §6.4: its home + * and references), read at each call; undefined = no filter (isolation off). + */ + workstreams?: () => readonly string[] | undefined; } export interface MemoryToolDef { @@ -58,6 +63,12 @@ const clampInt = (v: unknown, min: number, max: number, dflt: number): number => return Math.min(max, Math.max(min, Math.floor(n))); }; +/** The caller's workstream filter for a query (MemoryToolContext.workstreams). */ +function readable(ctx: MemoryToolContext): { workstreams?: readonly string[] } { + const w = ctx.workstreams ? ctx.workstreams() : ctx.engine.workstreamsOf(ctx.sessionId); + return w === undefined ? {} : { workstreams: w }; +} + /** The four defs. A function (not a const) so each call gets fresh zod objects. */ export function memoryToolDefs(): MemoryToolDef[] { const recall: MemoryToolDef = { @@ -92,6 +103,7 @@ export function memoryToolDefs(): MemoryToolDef[] { limit: clampInt(args.limit, 1, 20, 6), toolName: str(args.tool_name), excludeSessionId: includeCurrent ? undefined : ctx.sessionId, + ...readable(ctx), }); return formatHits(hits, ctx.sessionId, includeCurrent); }, @@ -117,6 +129,7 @@ export function memoryToolDefs(): MemoryToolDef[] { workspaceId: ctx.workspaceId, filePaths: [path], limit: 3, + ...readable(ctx), }); if (hits.length === 0) return `No prior reads of ${path} in this workspace.`; return `Found ${hits.length} prior read(s) of ${path}:\n\n${formatHits(hits, ctx.sessionId, true)}`; @@ -143,7 +156,7 @@ export function memoryToolDefs(): MemoryToolDef[] { async run(args, ctx) { const limit = clampInt(args.limit, 1, 60, 20); const offset = clampInt(args.offset, 0, Number.MAX_SAFE_INTEGER, 0); - const episodes = ctx.engine.timeline(ctx.workspaceId, limit, offset); + const episodes = ctx.engine.timeline(ctx.workspaceId, limit, offset, readable(ctx).workstreams); return formatTimeline(episodes, ctx.sessionId, offset); }, }; @@ -185,9 +198,10 @@ export function memoryToolDefs(): MemoryToolDef[] { }, async run(args, ctx) { const id = String(args.episode_id ?? ""); - const ep = ctx.engine.getEpisodeScoped(id, ctx.workspaceId); + const { workstreams } = readable(ctx); + const ep = ctx.engine.getEpisodeScoped(id, ctx.workspaceId, workstreams); if (!ep) return `Episode ${id} not found in this workspace.`; - const full = ctx.engine.getFullContentScoped(id, ctx.workspaceId) ?? ep.content; + const full = ctx.engine.getFullContentScoped(id, ctx.workspaceId, workstreams) ?? ep.content; const offset = clampInt(args.offset, 0, Number.MAX_SAFE_INTEGER, 0); const maxChars = clampInt(args.max_chars, 1000, GET_EPISODE_MAX_PAGE, GET_EPISODE_DEFAULT_PAGE); return formatEpisode({ ...ep, content: full }, ctx.sessionId, { offset, maxChars }); diff --git a/src/daemon/memory/types.ts b/src/daemon/memory/types.ts index f2ea65f0..b88b497e 100644 --- a/src/daemon/memory/types.ts +++ b/src/daemon/memory/types.ts @@ -34,6 +34,8 @@ export interface Episode { embeddingModel?: string; createdAt: number; createdBy: string; + /** The home workstream of the session that wrote it (workstreams §6.4); absent before workstreams, or with none. */ + workstreamId?: string; } /** Result of a recall query. */ @@ -67,6 +69,12 @@ export interface RecallQuery { before?: number; /** Exclude episodes older than this (unix ms). */ after?: number; + /** + * The workstreams the caller may read (workstreams §6.4: its home and its + * references). Set: an episode another workstream wrote is left out; + * one with none (from before workstreams) stays. Absent: no filter. + */ + workstreams?: readonly string[]; } /** File read cache entry — enables silent reuse across sessions in a workspace. */ diff --git a/src/daemon/providers/claude/index.ts b/src/daemon/providers/claude/index.ts index a70b2954..443cd23c 100644 --- a/src/daemon/providers/claude/index.ts +++ b/src/daemon/providers/claude/index.ts @@ -165,6 +165,8 @@ export interface ClaudeProviderInit { onForeignAgentDenied?: (agentId: string, toolName: string) => void; /** The session is in autonomous mode now (ProviderSessionInit.isAutonomous); absent: treated as so. */ isAutonomous?: () => boolean; + /** The workstream fence's refusal of a call (ProviderSessionInit.workstreamFenceRefusal). */ + workstreamFenceRefusal?: (toolName: string, input: unknown, cwd: string | null | undefined) => string | null; /** The session's OS sandbox while on (ProviderSessionInit.sandbox, #448). */ sandbox?: () => SandboxRuntime | null; /** codeoid session id — used as the initial backing id and for audit/logging. */ @@ -1411,6 +1413,19 @@ export class ClaudeProvider implements SessionProvider { // Fail-closed too (#secretPathDecision). const secret = this.#secretPathDecision(input, opts.workdir); if (secret) return secret; + // The workstream fence (workstreams §7.3): a call in another + // workstream's folders is refused here, before any allow rule. + // Fail-closed: a throw refuses. + if (init.workstreamFenceRefusal) { + let fenced: string | null; + try { + fenced = init.workstreamFenceRefusal(input.tool_name, input.tool_input, hookShellCwd(input)); + } catch (err) { + console.error(`[claude-provider ${this.#init.sessionId.slice(0, 8)}] checking a call against the workstream fence failed, refusing it: ${err instanceof Error ? err.message : String(err)}`); + fenced = "codeoid couldn't check this call against the session's workstream, so it was refused."; + } + if (fenced) return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: fenced } }; + } // A shell command is judged by what it runs (#431): a risky one, // one the gate can't read, or one naming a credential file never // runs on an allow rule — it goes to canUseTool, which decides. diff --git a/src/daemon/providers/registry.ts b/src/daemon/providers/registry.ts index caa22ffe..14465baa 100644 --- a/src/daemon/providers/registry.ts +++ b/src/daemon/providers/registry.ts @@ -149,6 +149,13 @@ export interface ProviderSessionInit { * Absent: treated as autonomous (routed). */ isAutonomous?: () => boolean; + /** + * The workstream fence's refusal of a tool call (workstreams/gate.ts), or + * null. Claude only: its PreToolUse hook refuses such a call itself, since + * the CLI runs some calls (reads in the workdir, allow rules) without + * asking the gate. Throws fail closed (the hook refuses). + */ + workstreamFenceRefusal?: (toolName: string, input: unknown, cwd: string | null | undefined) => string | null; /** * The session's OS sandbox while it is on (#448, sandbox/session-sandbox.ts), * read each time the backend is started: null when it is off, unavailable, @@ -474,6 +481,7 @@ export function createDefaultProviderRegistry( isOwnTask: init.isOwnTask, onForeignAgentDenied: init.onForeignAgentDenied, ...(init.isAutonomous ? { isAutonomous: init.isAutonomous } : {}), + ...(init.workstreamFenceRefusal ? { workstreamFenceRefusal: init.workstreamFenceRefusal } : {}), sandbox: init.sandbox, }), }); diff --git a/src/daemon/sandbox/policy.ts b/src/daemon/sandbox/policy.ts index 5682ea5c..746820b3 100644 --- a/src/daemon/sandbox/policy.ts +++ b/src/daemon/sandbox/policy.ts @@ -405,6 +405,12 @@ function within(child: string, parent: string): boolean { export interface SpecInputs { sessionId: string; + /** + * More paths hidden for this session alone: other workstreams' folders + * under its read fence (workstreams §7.3). Never the workspace or what + * holds it (SessionSandbox leaves those out). + */ + extraHidden?: readonly string[]; workspace: string; /** * The session's cwd, when it is below `workspace` (a monorepo's package, @@ -1204,7 +1210,11 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec { const baseHidden = hiddenPathsFor(inputs.config, inputs.hidden); // What the hidden list hides, the home aside: no root or added dir is inside it (validated; again here). const inHidden = (p: string) => [...baseHidden, ...(root ? [root] : [])].some((h) => within(p, h) || within(realOr(p), realOr(h))); - const addedKept = added.filter((a) => !inHidden(a.dir) && !userHidden.some((u) => within(a.dir, u))); + // An added dir inside another workstream's folder isn't kept either: adding it can't open the fence. + const extraHidden = (inputs.extraHidden ?? []).map((h) => resolve(h)); + const addedKept = added.filter( + (a) => !inHidden(a.dir) && !userHidden.some((u) => within(a.dir, u)) && !extraHidden.some((h) => within(a.dir, h) || within(realOr(a.dir), realOr(h))), + ); // The session's own: its workspace and the dirs its owner added (their credentials are its to use, as the // workspace's are). A dir the agent added itself (autonomous `request_directory`) keeps the hiding a root has. // Its repository's git dir too (a fork's worktree uses its main checkout's): git there reads its config. @@ -1219,7 +1229,15 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec { if (record) refuse(pinProblem(r, "project root", { stored: record })); return dirIdentity(r) !== null ? r : realOr(r); }) - .filter((r, i, all) => dirIdentity(r) !== null && all.indexOf(r) === i && !userHidden.some((u) => within(r, u)) && !inHidden(r)); + .filter( + (r, i, all) => + dirIdentity(r) !== null && + all.indexOf(r) === i && + !userHidden.some((u) => within(r, u)) && + !inHidden(r) && + // A root inside another workstream's folder: the session's own setting can't show it past its fence. + !(inputs.extraHidden ?? []).some((h) => within(r, resolve(h)) || within(realOr(r), realOr(resolve(h)))), + ); // The credential files anywhere in a root and in an agent's own added dir (not the session's own); a root or such // a dir too big to look through whole isn't shown at all, nor written (fail closed: resolveSandbox says so). const scanned = [...rootCandidates, ...agentDirs]; @@ -1249,6 +1267,7 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec { ...rootSecrets.hidden, ...locationSecrets.hidden, ...(root ? [root] : []), + ...(inputs.extraHidden ?? []).map((p) => resolve(p)), ]; // The session's worktree folders: writable, but never inside something hidden (a root configured in a hidden // path), and, unlike the backend's own state, never taking a hidden path inside them out of the hidden list. diff --git a/src/daemon/sandbox/session-sandbox.ts b/src/daemon/sandbox/session-sandbox.ts index c6e197f6..0f7109d5 100644 --- a/src/daemon/sandbox/session-sandbox.ts +++ b/src/daemon/sandbox/session-sandbox.ts @@ -167,6 +167,12 @@ export interface EffectiveSandbox { agentDirs: string[]; } +/** A session's workstream fence as the sandbox takes it: the roots shown read-only, the folders hidden. */ +export interface WorkstreamDirs { + roots: string[]; + hidden: string[]; +} + /** The setting a session ends up with: its own fields over the config's. */ export function effectiveSetting(config: SandboxConfig, setting: SessionSandboxSetting | undefined): EffectiveSandbox { const choice = setting?.provider ?? config.provider; @@ -596,6 +602,31 @@ export class SessionSandbox { readonly #onWarning: ((text: string) => void) | undefined; readonly #onWorktreeEntries: ((common: string, findings: WorktreeEntryFinding[]) => void) | undefined; readonly #name: () => string; + readonly #workstreamDirs: (() => WorkstreamDirs | undefined) | undefined; + /** The fence the last resolution was made with, to see when it changed. */ + #workstreamKeyApplied = ""; + + /** The fence now, with what would hide the workspace or an added dir left out (those are the session's own). */ + #workstreamNow(): WorkstreamDirs | undefined { + const wd = this.#workstreamDirs?.(); + if (!wd) return undefined; + const workspace = this.#workspace(); + const holds = (h: string, p: string) => h === "/" || p === h || p.startsWith(`${h}/`); + // An added dir doesn't un-hide one: one inside a hidden folder is dropped (policy.ts), one holding it keeps it hidden. + const hidden = wd.hidden.filter((h) => !holds(h, workspace) && !holds(h, this.#workdir)); + return { roots: wd.roots, hidden }; + } + + #workstreamKey(): string { + const wd = this.#workstreamNow(); + return wd ? JSON.stringify(wd) : ""; + } + + /** The setting with the fence's folders as its project roots, when it names none of its own. */ + #withWorkstream(setting: SessionSandboxSetting | undefined, wd: WorkstreamDirs | undefined = this.#workstreamNow()): SessionSandboxSetting | undefined { + if (!wd || setting?.projectRoots !== undefined) return setting; + return { ...(setting ?? {}), projectRoots: wd.roots }; + } constructor(opts: { sessionId: string; @@ -609,7 +640,15 @@ export class SessionSandbox { onWarning?: (text: string) => void; /** What a look at the repository's worktree entries found (checkWorktreeEntries): said on the session and audited. */ onWorktreeEntries?: (common: string, findings: WorktreeEntryFinding[]) => void; + /** + * The session's workstream fence (workstreams §7.3), read live: its + * readable folders replace the default project roots (never a root the + * session's own setting names), and other workstreams' are hidden. + * Undefined result = no fence. + */ + workstreamDirs?: () => WorkstreamDirs | undefined; }) { + this.#workstreamDirs = opts.workstreamDirs; this.#onWarning = opts.onWarning; this.#onWorktreeEntries = opts.onWorktreeEntries; this.#sessionId = opts.sessionId; @@ -637,7 +676,7 @@ export class SessionSandbox { /** What `setting` means with the config filled in. */ effective(setting: SessionSandboxSetting | undefined = this.#setting): EffectiveSandbox { - return effectiveSetting(this.config, setting); + return effectiveSetting(this.config, this.#withWorkstream(setting)); } /** The last resolution, or null before the first. */ @@ -795,7 +834,10 @@ export class SessionSandbox { * rebuilt mid-work would end it; a process backend is restarted). */ get dirsPending(): boolean { - return this.#dirsStale || this.#pinsBroken; + // The fence changed since the last resolution (a reference, a move, a folder made): none before the + // first, which is no change of anything (it resolves with the fence as it is then). + const fenceChanged = this.#workstreamDirs !== undefined && this.#info !== null && this.#workstreamKey() !== this.#workstreamKeyApplied; + return this.#dirsStale || this.#pinsBroken || fenceChanged; } /** The added directories the running sandbox has (SandboxSpec.addedDirs as resolved): what host-side file tools are held to. */ @@ -854,13 +896,15 @@ export class SessionSandbox { async #refreshNow(backendId: string): Promise { let r: Awaited>; + let wsKey = ""; for (;;) { const generation = this.#generation; this.#pinWorkspace(); + wsKey = this.#workstreamKey(); r = await resolveSandbox({ registry: this.#registry, config: this.config, - setting: this.#setting, + setting: this.#withWorkstream(this.#setting), backendId, workspace: this.#workdir, top: this.#workspace(), @@ -880,6 +924,7 @@ export class SessionSandbox { this.#resolvedFor = backendId; this.#stale = false; this.#dirsStale = false; + this.#workstreamKeyApplied = wsKey; const dirs = { roots: [...(r.info.projectRoots ?? [])], added: [...(r.info.addedDirs ?? [])], ...(r.rootScan ? { scan: r.rootScan } : {}) }; this.#dirs = dirs; this.#droppedDirs = r.droppedDirs ?? []; @@ -1075,6 +1120,8 @@ export class SessionSandbox { for (const d of dirs.added) this.#checkEntriesOf(d); } const agent = new Set(this.effective().agentDirs); + // Other workstreams' folders, as at the last resolution (a change since is a dirs change, applied between turns). + const hiddenForWorkstream = this.#workstreamKeyApplied ? (JSON.parse(this.#workstreamKeyApplied) as WorkstreamDirs).hidden : []; const build = () => buildSandboxSpec({ backendReadable: readable, sessionId: this.#sessionId, @@ -1088,6 +1135,7 @@ export class SessionSandbox { dockerSocket, hideLocalServices, projectRoots: dirs.roots, + ...(hiddenForWorkstream.length > 0 ? { extraHidden: hiddenForWorkstream } : {}), addedDirs: dirs.added.map((dir) => { const guard = this.#guardFor(dir); return { dir, ...(agent.has(dir) ? { agent: true } : {}), ...(folder && guard ? { ownWorktreeEntries: guard.ownEntries() } : {}) }; diff --git a/src/daemon/secret-paths.ts b/src/daemon/secret-paths.ts index d87c2137..2edcba8e 100644 --- a/src/daemon/secret-paths.ts +++ b/src/daemon/secret-paths.ts @@ -68,7 +68,9 @@ const PATH_LIST_KEYS = ["paths", "file_paths", "absolute_paths", "include", "dir /** Tools whose `pattern` is a path glob (not a regex) and whose absent root means the workdir. */ const GLOB_TOOLS = new Set(["Glob", "glob"]); /** Search tools: an absent root means the workdir. */ -const SEARCH_TOOLS = new Set(["Glob", "glob", "Grep", "grep", "grep_search", "search_file_content", "LS", "list_directory"]); +const SEARCH_TOOLS = new Set(["Glob", "glob", "Grep", "grep", "grep_search", "search_file_content", "LS", "list_directory", "find", "ls", "acp_search"]); +/** pi's file tools: a path's leading `@` is dropped, as pi drops it. */ +const PI_PATH_TOOLS = new Set(["read", "find", "ls", "grep", "write", "edit"]); /** Files in codeoid's config dir that hold credentials. */ function isSecretConfigFile(name: string): boolean { @@ -247,6 +249,25 @@ function within(child: string, parent: string): boolean { return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); } +/** + * The paths a tool call reads or searches, as written (relative ones from its + * cwd), backend-neutral: also what the workstream fence judges (workstreams/gate.ts). + */ +export function toolReadPaths(toolName: string, input: unknown): string[] { + return candidatePaths(toolName, input); +} + +/** Whether a tool searches (an absent root: the workdir): one whose root holds a fenced folder reads into it. */ +export function isSearchTool(toolName: string): boolean { + return SEARCH_TOOLS.has(toolName); +} + +/** A tool's path made absolute from `cwd`, as the kernel resolves it, and its real path: the forms a check judges. */ +export function toolPathForms(p: string, cwd: string): string[] { + const literal = expand(p, cwd); + return [...new Set([literal, real(literal)])]; +} + function candidatePaths(toolName: string, input: unknown): string[] { if (!input || typeof input !== "object") return []; const obj = input as Record; @@ -256,12 +277,29 @@ function candidatePaths(toolName: string, input: unknown): string[] { const v = obj[k]; if (Array.isArray(v)) for (const p of v) if (typeof p === "string" && p) out.push(p); } + // ACP's `locations` (acp/index.ts acpToolInput): paths, or `{path}` as the agent sent them. + if (Array.isArray(obj.locations)) { + for (const l of obj.locations) { + const p = typeof l === "string" ? l : l && typeof l === "object" ? (l as { path?: unknown }).path : undefined; + if (typeof p === "string" && p) out.push(p); + } + } + if (PI_PATH_TOOLS.has(toolName)) { + // Only before an absolute or `~` path: another backend's relative `@x` (a file so named) stays as written. + for (const [i, p] of out.entries()) if (p.startsWith("@/") || p.startsWith("@~")) out[i] = p.slice(1); + } // A glob's pattern can be absolute (`/home/u/.codeoid/*.json`): judge its // literal prefix, up to the first wildcard. Only for glob tools — Grep's // `pattern` is a regex, and searching code for "/proc/self/environ" is fine. if (GLOB_TOOLS.has(toolName) && typeof obj.pattern === "string" && (isAbsolute(obj.pattern) || obj.pattern.startsWith("~"))) { const literal = obj.pattern.split(/[*?[{]/, 1)[0] ?? ""; - if (literal) out.push(literal.endsWith("/") ? literal.slice(0, -1) || "/" : literal); + if (literal.endsWith("/")) out.push(literal.slice(0, -1) || "/"); + else if (literal) { + // A wildcard inside the last segment (`/w/h?/*`, `~/.codeoid*`): judged where it starts (a credential + // dir it names is refused, as before) and at the dir it searches (`/w`: any `h?` there). + out.push(literal); + if (obj.pattern !== literal) out.push(dirname(literal)); + } } // A search with no root searches the workdir. if (out.length === 0 && SEARCH_TOOLS.has(toolName)) out.push("."); diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index e33a511c..8e4d6c3e 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -9,7 +9,8 @@ */ import { addedDirProblem, type OtherCheckout, PROJECT_ROOT_PARENT, projectRootProblem } from "./sandbox/project-roots.js"; -import { WorkstreamService, type WorkstreamBinding } from "./workstreams/service.js"; +import { WorkstreamService, type WorkstreamBinding, type WorkstreamFence, type WorkstreamReference } from "./workstreams/service.js"; +import { READ_FROM_LIMIT, REFERENCE_LIMIT, readWorkstreamBinding, referencePathsProblem } from "./workstreams/binding.js"; import { grantRefusal, type McpGrant } from "./workstreams/grant.js"; import { OverrideTokens } from "./workstreams/override.js"; import { withSlot } from "./mcp/oauth.js"; @@ -19,10 +20,10 @@ import { credentialsSchema } from "./credentials/schema.js"; import { workstreamsSchema } from "./workstreams/schema.js"; import { registerSecretPaths } from "./secret-paths.js"; import { type RequestDirectoryInput, type SandboxMcpMount, SandboxMcpHttp } from "./sandbox/request-directory.js"; -import { existsSync, mkdirSync, realpathSync, statSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve, sep } from "node:path"; -import { type ForkPoint, RewindError, type SendHold, SendStoppedError, SHUTDOWN_AUTH, Session, type AttachedClient, type WindowScope } from "./session.js"; +import { type FleetCardTarget, type ForkPoint, RewindError, type SendHold, SendStoppedError, SHUTDOWN_AUTH, Session, type AttachedClient, type WindowScope } from "./session.js"; import type { CanonicalTurn } from "./providers/canonical.js"; import { sweepCheckpoints } from "./checkpoints.js"; import type { CompareRunRow } from "./store.js"; @@ -158,7 +159,7 @@ import { mcpOAuthBindingCookieName, type McpOAuth, type McpOAuthProof, type McpT import { isOAuthServer, type McpServerSpec, OAUTH_UNSUPPORTED_BACKENDS } from "./mcp/types.js"; import { atomicWrite, type CodeoidConfig, DEFAULT_PROVIDER_ENV, dotEnvShadowed, getConfigDir, loadConfig, mutateConfigFile, validateConfigObject } from "../config.js"; import type { CompressionRegistry } from "./compress/index.js"; -import { endpointNameOf, endpointProviderId, ORCHESTRATOR_ROLE, type ProviderInfo } from "../protocol/types.js"; +import { endpointNameOf, endpointProviderId, FLEET_TOOL_PREFIX, ORCHESTRATOR_ROLE, type ProviderInfo } from "../protocol/types.js"; import type { AuthContext, ClaudeConfigMcpServer, @@ -1202,6 +1203,12 @@ mcpHub: this.#mcpHub, // Conductor gets the tenant-wide surface; a collaboration // orchestrator gets the role-aware one scoped to its own children. // Its id is already known here, so the thunk is trivial. + ...(meta.role === "conductor" + ? { + fleetTargetWorkstream: this.#fleetCardWorkstream(meta.accountId, meta.projectId), + fleetConfirm: (target: FleetCardTarget) => this.#issueFleetToken({ accountId: meta.accountId, projectId: meta.projectId }, target), + } + : {}), fleet: meta.role === "conductor" ? this.#buildFleetServer(meta.accountId, meta.projectId) @@ -1547,6 +1554,10 @@ mcpHub: this.#mcpHub, return this.#loopWorkerGuard(msg, "change the sandbox of") ?? this.#setSandbox(msg, auth); case "session.sandbox_dir": return this.#loopWorkerGuard(msg, "change the sandbox of") ?? this.#sandboxDir(msg, auth); + case "session.add_reference": + case "session.remove_reference": + case "session.set_workstream": + return this.#loopWorkerGuard(msg, "change the workstream of") ?? this.#sessionWorkstreamEdit(msg, auth); case "sandbox.providers": return this.#sandboxProviders(msg, auth); case "session.pin": @@ -2331,8 +2342,9 @@ mcpHub: this.#mcpHub, #workstreamOpts(binding: WorkstreamBinding | undefined): { workstream?: WorkstreamBinding; describeWorkstream: (b: WorkstreamBinding | undefined) => ReturnType; - workstreamEnv?: () => Readonly>; - mcpGrant: () => McpGrant | undefined; + workstreamEnv?: (b: WorkstreamBinding | undefined) => Readonly>; + mcpGrant: (b: WorkstreamBinding | undefined) => McpGrant | undefined; + workstreamFence: (b: WorkstreamBinding | undefined, own: readonly string[]) => WorkstreamFence | undefined; } { const { missing } = this.#workstreams.envFor(binding, this.#secrets); if (missing.length > 0) { @@ -2341,14 +2353,38 @@ mcpHub: this.#mcpHub, return { ...(binding ? { workstream: binding } : {}), describeWorkstream: this.#describeWorkstream, - // Read live: an edit to the workstream or a credential reaches the - // session's next backend start (its next turn), never only a restart. - ...(binding?.id ? { workstreamEnv: () => this.#workstreams.envFor(binding, this.#secrets).env } : {}), - // Read live: the grant follows the config as it is at each listing. - mcpGrant: () => this.#workstreams.grantFor(binding), + // Each read live, for the session's binding as it is then: an edit to + // the workstream or a credential, or a move to another (§5.3), reaches + // the session's next backend start (its next turn), never only a restart. + ...(this.#workstreams.configured || binding ? { workstreamEnv: (b: WorkstreamBinding | undefined) => (b?.id ? this.#workstreams.envFor(b, this.#secrets).env : {}) } : {}), + // The grant follows the config as it is at each listing. + mcpGrant: (b) => this.#workstreams.grantFor(b), + workstreamFence: (b, own) => this.#workstreamFence(b, own), }; } + /** Fences by binding and config revision, briefly: the gate reads one on every file tool call. */ + #fenceCache = new Map(); + + /** + * A session's read fence (workstreams §7.3), or undefined when isolation is + * off. Recorded home folders count as their workstream's (a remote rule + * names no path), as repository tops. Cached 5s: folders made meanwhile + * (a new clone) are fenced within that. + */ + #workstreamFence(binding: WorkstreamBinding | undefined, own: readonly string[] = []): WorkstreamFence | undefined { + if (!this.#workstreams.configured || !this.#workstreams.isolateGlobalServers) return undefined; + const key = `${this.#workstreams.revision}\u0000${JSON.stringify(binding ?? null)}\u0000${own.join("\u0000")}`; + const now = Date.now(); + const hit = this.#fenceCache.get(key); + if (hit && now - hit.at < 5_000) return hit.fence; + const recorded = this.#store.workstreamFoldersByWorkstream(); + const fence = this.#workstreams.fenceFor(binding, (id) => recorded.get(id) ?? [], own); + if (this.#fenceCache.size > 256) this.#fenceCache.clear(); + this.#fenceCache.set(key, { at: now, fence }); + return fence; + } + /** * The home of a session the daemon starts itself in `workdir` (an import, a * loop attempt, a routine run, a dispatch worker): its folder's, else the @@ -4815,7 +4851,42 @@ mcpHub: this.#mcpHub, } } for (const w of homing.warnings) warn(w); - const createWorkstream = homing.binding; + let createWorkstream = homing.binding; + // A home chosen in another workstream's folder reads that folder (its workdir stays readable to it): + // judged by that workstream's share, as a reference is, and counted as read from. + if (createWorkstream?.id) { + const fv = this.#workstreams.folderVerdict(workdir, createWorkstream.id, givenWorkdir); + if (!fv.ok) return { type: "response.error", requestId: msg.id, error: fv.error, code: "forbidden" }; + if (fv.ask.length > 0 && !(hasScope(auth.scopes as string[], SCOPES.SESSION_APPROVE) && auth.delegationDepth === 0)) { + return { type: "response.error", requestId: msg.id, error: `This folder is workstream "${fv.folder}"'s, which shares only with approval: a session of another workstream here needs session:approve on an undelegated token`, code: "forbidden" }; + } + if (fv.folder) createWorkstream = { ...createWorkstream, readFrom: [...new Set([...(createWorkstream.readFrom ?? []), fv.folder])] }; + } + // References asked at create (§7.2): each by the share policy of the + // workstream read. The creator asking with session:approve, undelegated, + // is the confirm an `ask` workstream wants; anyone else gets `open` only. + if (msg.references && msg.references.length > 0) { + if (!this.#workstreams.configured || !createWorkstream) { + return { type: "response.error", requestId: msg.id, error: "No workstreams are configured: a session can't reference one", code: "invalid_request" }; + } + const approves = hasScope(auth.scopes as string[], SCOPES.SESSION_APPROVE) && auth.delegationDepth === 0; + const recorded = this.#store.workstreamFoldersByWorkstream(); + const refs: WorkstreamReference[] = []; + for (const asked of msg.references) { + const paths = asked.paths?.map(referenceFolder); + const pathProblem = referencePathsProblem(paths); + if (pathProblem) return { type: "response.error", requestId: msg.id, error: pathProblem, code: "invalid_request" }; + const v = this.#workstreams.referenceVerdict(createWorkstream.id, { workstream: asked.workstream, ...(paths ? { paths } : {}) }, recorded.get(asked.workstream) ?? []); + if (!v.ok) return { type: "response.error", requestId: msg.id, error: v.error, code: "forbidden" }; + if (v.ask.length > 0 && !approves) { + return { type: "response.error", requestId: msg.id, error: `Workstream "${asked.workstream}" shares only with approval: referencing it needs session:approve on an undelegated token`, code: "forbidden" }; + } + refs.push({ workstream: asked.workstream, ...(paths ? { paths } : {}), grantedBy: auth.sub, grantedAt: new Date().toISOString() }); + } + const unique = [...new Map(refs.map((r) => [r.workstream, r])).values()]; + createWorkstream = { ...createWorkstream, references: unique, readFrom: [...new Set([...(createWorkstream.readFrom ?? []), ...unique.map((r) => r.workstream)])] }; + this.#store.audit(auth.sub, "workstream.reference_added", "", `workdir=${workdir} references=${unique.map((r) => r.workstream).join(",")} at=create`); + } if (createWorkstream?.source === "override") { const c = homing.resolution?.conflict; this.#store.audit(auth.sub, "workstream.override", "", `workdir=${workdir} folder=${c?.folder ?? "?"} chosen=${createWorkstream.id} revision=${createWorkstream.revision}`); @@ -6219,6 +6290,8 @@ mcpHub: this.#mcpHub, providerId, defaultModel: conductorConfig?.model, fleet: this.#buildFleetServer(auth.accountId, auth.projectId), + fleetTargetWorkstream: this.#fleetCardWorkstream(auth.accountId, auth.projectId), + fleetConfirm: (target) => this.#issueFleetToken({ accountId: auth.accountId, projectId: auth.projectId }, target), auth, store: this.#store, transcriptStore: this.#transcriptStore, @@ -7931,9 +8004,24 @@ mcpHub: this.#mcpHub, `provider "${task.provider}" is not registered on this daemon — available: ${this.#providers.ids().join(", ")}`, ); } + // Its home: the workstream the spawn named, judged again now (the config may have changed since it + // was queued), else the folder's as before W4. + let home: WorkstreamBinding | undefined; + if (task.workstream) { + // A confirmation the card gave holds only at the config revision it was given at. + const confirmed = task.workstreamOverride && task.workstreamRevision === this.#workstreams.revision; + const h = this.#fleetSpawnHome(workdir, task.workstream, confirmed, { accountId: task.accountId, projectId: task.projectId }); + if (!h.ok) throw new NonRetryableDispatchError(`workstream: ${h.error}`); + home = h.binding; + } else if (this.#workstreams.configured && task.createdBy && !goalIdFromCreatedBy(task.createdBy)) { + // The conductor's unnamed spawn: the folder's own, judged as when it was queued (remote guard included). + const h = this.#fleetSpawnHome(workdir, undefined, false, { accountId: task.accountId, projectId: task.projectId }); + if (!h.ok) throw new NonRetryableDispatchError(`workstream: ${h.error}`); + home = h.binding; + } else home = this.#workstreamForFolder(workdir, this.#dispatchSenderAuth(task)); const budget = this.#dispatcher.config.workerToolBudget; const session = new Session({ - ...this.#workstreamOpts(this.#workstreamForFolder(workdir, this.#dispatchSenderAuth(task))), + ...this.#workstreamOpts(home), name: `worker-${task.shape}-${task.id.slice(0, 8)}`, workdir, role: "worker", @@ -8021,6 +8109,11 @@ mcpHub: this.#mcpHub, const worker = this.#sessionForTask(task.workerSessionId, task); const header = `task ${task.id.slice(0, 8)} (${task.kind}/${task.shape}) in ${task.workdir ?? task.targetSession ?? "?"}`; if (!worker) return `${header} — worker session unavailable; check the workdir/git state for artifacts.`; + // A closed workstream's worker reports its status, never its text (workstreams §10). + // Withheld from the conductor only: an orchestrator's children share its workstream (they inherit it). + if (!goalIdFromCreatedBy(task.createdBy) && this.#workstreamTextWithheld({ sessionId: worker.id, ...(worker.workstream?.id ? { workstream: worker.workstream.id } : {}) })) { + return `${header} — done in workstream ${worker.workstream?.id}, which is closed: its result isn't shared with the conductor. Its owner sees it in the session ${worker.name}.`; + } const parts: string[] = [header]; const finalText = worker.lastAssistantText; if (finalText) { @@ -8332,9 +8425,208 @@ mcpHub: this.#mcpHub, /** The conductor's tenant-wide deps. Extracted from `#buildFleetServer` so a * test can compare them against the orchestrator's scoped ones. */ + /** Whether any workstream is closed while isolation is on: then text whose workstream can't be told is withheld. */ + #anyClosedWorkstream(): boolean { + return this.#workstreams.configured && this.#workstreams.isolateGlobalServers && this.#workstreams.anyClosed; + } + + /** #workstreamTextWithheld's answers, briefly: a fleet tool asks once per episode. */ + #withheldMemo = new Map(); + + /** A session's home as last recorded, for one not running: its meta's binding, else its folder's record, else its folder now. */ + #storedSessionWorkstream(sessionId: string): string | undefined | null { + const stored = this.#store.getSession(sessionId); + if (!stored) return null; + try { + const meta = JSON.parse(readFileSync(this.#transcriptStore.metaPath(sessionId), "utf8")) as { workstream?: unknown }; + const b = meta.workstream !== undefined ? readWorkstreamBinding(meta.workstream) : undefined; + if (b) return b.sealed ? null : b.id; + } catch {} + const owner = stored as { accountId?: string; projectId?: string }; + if (owner.accountId && owner.projectId) { + const recorded = this.#store.workstreamFolder({ accountId: owner.accountId, projectId: owner.projectId, workdir: stored.workdir }); + if (recorded) return recorded; + } + return this.#workstreams.resolve(stored.workdir).home; + } + + /** + * Whether text is withheld from the conductor and its digests (workstreams + * §10): a `closed` workstream's, under isolation. Judged by the episode's + * workstream; an untagged one (written before workstreams) by its + * session's — live, else the folder the session was stored with —; one + * whose workstream can't be told at all is withheld while any is closed. + */ + #workstreamTextWithheld(ref: { workstream?: string; sessionId?: string }): boolean { + if (!this.#anyClosedWorkstream()) return false; + const key = `${this.#workstreams.revision}\u0000${ref.workstream ?? ""}\u0000${ref.sessionId ?? ""}`; + const hit = this.#withheldMemo.get(key); + if (hit && Date.now() - hit.at < 2_000) return hit.withheld; + const withheld = this.#judgeWithheld(ref); + if (this.#withheldMemo.size > 2_000) this.#withheldMemo.clear(); + this.#withheldMemo.set(key, { at: Date.now(), withheld }); + return withheld; + } + + #judgeWithheld(ref: { workstream?: string; sessionId?: string }): boolean { + let ws = ref.workstream; + if (!ws && ref.sessionId) { + const live = this.#sessions.get(ref.sessionId); + if (live) { + ws = live.workstream?.id; + if (!ws) return false; // a session with no home: nothing of a closed workstream's is in it + if (live.workstream?.source === "reserved") return false; + } else { + const recorded = this.#storedSessionWorkstream(ref.sessionId); + if (recorded === null) return true; // unknown, or its record unreadable: withheld while any is closed + ws = recorded; + if (!ws) return false; + } + } + if (!ws) return true; + return this.#workstreams.get(ws)?.share === "closed"; + } + + /** + * A dispatched worker's home (workstreams §10, §5): `workstream` if named, + * else the folder's — as a create judges it, a folder with none refused + * under isolation, and a folder whose repository's remote now matches + * another workstream than it did refused too (an agent can edit `origin`: + * #workstreamForFolder). `confirmed`: approving the spawn's card confirmed + * what it warned of — an override past the folder's exclusive workstream + * (G3), a home in an `ask` workstream's folder. A closed workstream's + * folder never takes another's worker, and a closed workstream's worker + * works only in its own folders (what it writes elsewhere is no one's). + */ + #fleetSpawnHome( + workdir: string, + workstream: string | undefined, + confirmed: boolean, + tenant?: { accountId: string; projectId: string }, + ): { ok: true; binding: WorkstreamBinding | undefined; override: boolean; conflict?: string; asks: string[] } | { ok: false; error: string } { + if (!this.#workstreams.configured) { + return workstream ? { ok: false, error: "No workstreams are configured on this daemon: omit workstream" } : { ok: true, binding: undefined, override: false, asks: [] }; + } + const r = this.#workstreams.forCreate(workdir, workstream, undefined, true, confirmed); + if (!r.ok) { + const c = r.kind === "workstream_conflict" ? r.resolution.conflict : undefined; + if (c && !c.overridable) return { ok: false, error: `${r.error} That workstream allows no override.` }; + if (c) return { ok: false, error: `${r.error} Approving the spawn's card confirms it; this one wasn't confirmed (it changed since it was shown): ask again.` }; + return { ok: false, error: r.kind === "workstream_required" ? `${r.error} Name it with workstream.` : r.error }; + } + if (!workstream && tenant && r.binding?.source === "matched" && r.binding.id && r.binding.matchedBy?.startsWith("remote ")) { + const before = this.#store.workstreamFolder({ ...tenant, workdir }); + if (before && before !== r.binding.id) { + return { ok: false, error: `This folder matched workstream "${before}", but its repository now matches "${r.binding.id}": its remote may have been changed. Name the workstream.` }; + } + } + const fv = this.#workstreams.folderVerdict(workdir, r.binding?.id); + if (!fv.ok) return { ok: false, error: fv.error }; + if (fv.ask.length > 0 && !confirmed) { + return { ok: false, error: `This folder is workstream "${fv.folder}"'s, which shares only with approval: approving the spawn's card confirms it; this one wasn't confirmed: ask again.` }; + } + const home = r.binding?.id ? this.#workstreams.get(r.binding.id) : undefined; + if (home?.share === "closed") { + const recorded = this.#store.workstreamFoldersByWorkstream().get(home.id) ?? []; + const { folders } = this.#workstreams.foldersOf(home.id, recorded); + // Its own: a folder of its, or one it matches (a remote rule names no folder until one is recorded). + const own = folders.some((f) => workdir === f || workdir.startsWith(`${f}/`)) || this.#workstreams.resolve(workdir).home === home.id; + if (!own) return { ok: false, error: `Workstream "${home.id}" is closed: its workers work only in its own folders` }; + } + const binding = r.binding && fv.folder ? { ...r.binding, readFrom: [fv.folder] } : r.binding; + const override = r.binding?.source === "override"; + return { ok: true, binding, override, ...(override && r.resolution?.conflict ? { conflict: r.resolution.conflict.folder } : {}), asks: fv.ask }; + } + + /** + * Confirmation tokens (workstreams §10, G3): one per approved card that + * needed one, issued when the owner's own approval settled it and added to + * that call's input; spent once by its handler, which re-judges the spawn + * and accepts only the very verdict the card showed, at the revision it was + * shown at. Ten minutes. + */ + #fleetTokens = new Map }>(); + + #issueFleetToken(tenant: { accountId: string; projectId: string }, target: FleetCardTarget): string | undefined { + if (!target.confirm) return undefined; + if (this.#fleetTokens.size > 256) this.#fleetTokens.clear(); + const token = randomUUID(); + this.#fleetTokens.set(token, { at: Date.now(), tenant, confirm: target.confirm }); + return token; + } + + /** Spend `token` for a spawn: valid only for this tenant, call and verdict, unchanged since the card. */ + #spendFleetToken( + token: string | undefined, + tenant: { accountId: string; projectId: string }, + workdir: string, + workstream: string | undefined, + verdict: { conflict?: string; asks: string[] }, + ): boolean { + if (!token) return false; + const t = this.#fleetTokens.get(token); + this.#fleetTokens.delete(token); + if (!t || Date.now() - t.at > 10 * 60_000) return false; + const c = t.confirm; + return ( + t.tenant.accountId === tenant.accountId && + t.tenant.projectId === tenant.projectId && + c.workdir === workdir && + (c.workstream ?? "") === (workstream ?? "") && + c.revision === this.#workstreams.revision && + (c.conflict ?? "") === (verdict.conflict ?? "") && + JSON.stringify([...c.asks].sort()) === JSON.stringify([...verdict.asks].sort()) + ); + } + + /** + * What a send-class fleet call's approval card says of its workstream + * (workstreams §10): the target session's home (send, interrupt, a + * panel's members), or the spawned worker's — with the warning approving + * it confirms (past the folder's exclusive workstream), or why it will be + * refused. Undefined without workstreams. + */ + #fleetCardWorkstream(accountId: string, projectId: string): (toolName: string, input: unknown) => FleetCardTarget | undefined { + return (toolName, input) => { + if (!this.#workstreams.configured) return undefined; + const tool = toolName.startsWith(FLEET_TOOL_PREFIX) ? toolName.slice(FLEET_TOOL_PREFIX.length) : toolName; + const i = input && typeof input === "object" ? (input as Record) : {}; + const mine = [...this.#sessions.values()].filter((x) => x.accountId === accountId && x.projectId === projectId); + const find = (ref: unknown) => + typeof ref === "string" ? (mine.find((x) => x.id === ref) ?? mine.find((x) => x.name === ref) ?? mine.find((x) => x.id.startsWith(ref))) : undefined; + if (tool === "fleet_spawn") { + const workdir = typeof i.workdir === "string" ? normalizeWorkdir(i.workdir) : null; + if (!workdir) return undefined; + const ws = typeof i.workstream === "string" ? i.workstream : undefined; + const tenant = { accountId, projectId }; + const plain = this.#fleetSpawnHome(workdir, ws, false, tenant); + if (plain.ok) return plain.binding?.id ? { workstream: plain.binding.id } : undefined; + const confirmed = this.#fleetSpawnHome(workdir, ws, true, tenant); + if (confirmed.ok && (confirmed.override || confirmed.asks.length > 0)) { + const what = confirmed.override + ? `Past workstream "${confirmed.conflict ?? "?"}"'s own folder (exclusive): approving this confirms a worker of "${confirmed.binding?.id}" there.` + : `It reads workstream "${confirmed.asks.join(", ")}"'s folder, which shares only with approval: approving this confirms it.`; + return { + ...(confirmed.binding?.id ? { workstream: confirmed.binding.id } : {}), + warning: what, + confirm: { workdir, ...(ws ? { workstream: ws } : {}), revision: this.#workstreams.revision, ...(confirmed.conflict ? { conflict: confirmed.conflict } : {}), asks: confirmed.asks }, + }; + } + return { warning: `It will be refused: ${confirmed.ok ? plain.error : confirmed.error}` }; + } + if (tool === "fleet_panel" && Array.isArray(i.sessions)) { + const homes = [...new Set(i.sessions.map((r) => find(r)?.workstream?.id ?? "none"))]; + return { workstream: homes.join(", ") }; + } + const target = find(i.session); + return target?.workstream?.id ? { workstream: target.workstream.id } : undefined; + }; + } + #conductorFleetDeps(accountId: string, projectId: string): FleetDeps { return { tenant: { accountId, projectId }, + textWithheld: (ref) => this.#workstreamTextWithheld(ref), listSessions: (): FleetSessionView[] => { const views: FleetSessionView[] = []; for (const s of this.#sessions.values()) { @@ -8350,6 +8642,7 @@ mcpHub: this.#mcpHub, model: s.toInfo().model, attachedClients: s.attachedClientCount, createdAt: s.createdAt, + ...(s.workstream?.id ? { workstream: s.workstream.id } : {}), }); } return views; @@ -8407,6 +8700,21 @@ mcpHub: this.#mcpHub, const n = normalizeWorkdir(path); return n && !this.#loopWorktreeProblem(n) ? n : null; }, + // Run after the owner approved the spawn's card, which showed any conflict: confirmed. + checkWorkstream: (workdir: string, workstream?: string, token?: string) => { + if (!this.#workstreams.configured && !workstream) return { ok: true }; + const tenant = { accountId, projectId }; + // Confirmed only by the token the owner's approval of this very call's card put in its input, for + // exactly the verdict that card showed (#spendFleetToken). + let home = this.#fleetSpawnHome(workdir, workstream, false, tenant); + if (!home.ok) { + const confirmed = this.#fleetSpawnHome(workdir, workstream, true, tenant); + if (confirmed.ok && this.#spendFleetToken(token, tenant, workdir, workstream, { ...(confirmed.conflict ? { conflict: confirmed.conflict } : {}), asks: confirmed.asks })) home = confirmed; + } else if (token) this.#fleetTokens.delete(token); + if (!home.ok) return home; + const confirmed = home.override || home.asks.length > 0; + return { ok: true, ...(home.binding?.id ? { workstream: home.binding.id } : {}), ...(confirmed ? { override: true, revision: this.#workstreams.revision } : {}) }; + }, resolveBackend: (provider?: string, model?: string) => { // Fail closed on an unknown provider — same rule and same wording as // session.create / fork / set_provider. Rejecting here (pre-queue) @@ -8477,7 +8785,12 @@ mcpHub: this.#mcpHub, target: t.targetSession ?? t.workdir, createdAt: t.createdAt, error: t.error, - resultDigest: t.resultDigest, + // Another's task (an orchestrator's) carries its full digest: not the conductor's to read when + // its worker was a closed workstream's (§10). The conductor's own were withheld when built. + resultDigest: + goalIdFromCreatedBy(t.createdBy) !== undefined && this.#workstreamTextWithheld({ ...(t.workerSessionId ? { sessionId: t.workerSessionId } : t.targetSession ? { sessionId: t.targetSession } : {}) }) + ? (t.resultDigest ? "(a closed workstream's result: not shared with the conductor)" : null) + : t.resultDigest, })), }; } @@ -8534,6 +8847,15 @@ mcpHub: this.#mcpHub, * "the orchestrator doesn't get X" test proves nothing unless something in * the same run shows X was actually available to give. */ + /** The token the owner's approval of `target`'s card would add to the call, for tests. */ + _issueFleetTokenForTest(accountId: string, projectId: string, target: FleetCardTarget): string | undefined { + return this.#issueFleetToken({ accountId, projectId }, target); + } + + _fleetCardWorkstreamForTest(accountId: string, projectId: string): (toolName: string, input: unknown) => FleetCardTarget | undefined { + return this.#fleetCardWorkstream(accountId, projectId); + } + _fleetDepsForTest(accountId: string, projectId: string): FleetDeps { return this.#conductorFleetDeps(accountId, projectId); } @@ -9681,6 +10003,13 @@ mcpHub: this.#mcpHub, return { type: "response.error", requestId: msg.id, error: "A delegated token can't approve a loop worker's tool calls", code: "forbidden" }; } + // A card warning of a workstream override or an `ask` folder (workstreams §10, G3) is the owner's own to + // confirm, as an override at create is; and that approval is what the spawn handler may count as confirmed. + const card = session.approvalWorkstream(msg.approvalId); + if (msg.approved && card?.target.confirm && !session.isOwnApprover(auth)) { + this.#store.audit(auth.sub, "workstream.override_refused", session.id, `approvalId=${msg.approvalId} reason=not_owner`); + return { type: "response.error", requestId: msg.id, error: "Only the session's owner can confirm what this card warns of", code: "forbidden" }; + } // Still `response.ok` for a stale id — the web ApprovalBar sends one to // dismiss a leftover prompt — but say so, for callers that report it. const outcome = session.approve(msg.approvalId, msg.approved, auth, msg.updatedInput); @@ -9948,6 +10277,170 @@ mcpHub: this.#mcpHub, return { type: "response.ok", requestId: msg.id, data: { ...(result.pending ? { pending: true } : {}), message: result.message, sandbox: session.sandboxInfo } }; } + /** + * `session.add_reference` / `remove_reference` / `set_workstream` + * (workstreams §5.3, §7): owner-only. A grant or a move follows the share + * policy of each workstream it reads from (WorkstreamService + * `referenceVerdict` / `moveVerdict`): `closed` refuses; `ask` is granted + * at once to the owner asking with session:approve on an undelegated token, + * else asked of the owner on the session. Applied at the next turn. + */ + async #sessionWorkstreamEdit( + msg: Extract, + auth: AuthContext, + ): Promise { + const err = (error: string, code: "forbidden" | "invalid_request" | "not_found" = "invalid_request"): DaemonMessage => ({ type: "response.error", requestId: msg.id, error, code }); + if (!hasScope(auth.scopes as string[], SCOPES.SESSION_SEND)) return err("Missing scope: session:send", "forbidden"); + const session = this.#getOwnedSession(msg.sessionId, auth); + if (!session) return err("Session not found", "not_found"); + if (session.createdBy !== auth.sub) return err("Only the session's owner can change what workstreams it reads", "forbidden"); + const binding = session.workstreamAhead; + if (!this.#workstreams.configured || !binding) return err("No workstreams are configured"); + if (binding.source === "reserved") return err("The conductor's workstream can't be changed"); + // The owner asking with approval rights, undelegated, is the confirm an `ask` policy wants. + const approves = hasScope(auth.scopes as string[], SCOPES.SESSION_APPROVE) && auth.delegationDepth === 0; + const reply = (data: Record): DaemonMessage => ({ type: "response.ok", requestId: msg.id, data: { ...data, workstream: this.#describeWorkstream(session.workstreamAhead) } }); + + if (msg.type === "session.remove_reference") { + const refs = binding.references ?? []; + if (!refs.some((r) => r.workstream === msg.workstream)) return err(`This session has no reference to "${msg.workstream}"`); + const rest = refs.filter((r) => r.workstream !== msg.workstream); + session.setWorkstream({ ...withoutKey(binding, "references"), ...(rest.length > 0 ? { references: rest } : {}) }, `No longer reads workstream "${msg.workstream}" (from the next turn)`); + this.#store.audit(auth.sub, "workstream.reference_removed", session.id, `workstream=${msg.workstream}`); + return reply({ message: `removed the reference to ${msg.workstream}` }); + } + + // What is asked, as it will be granted; null = refused (with why). + let decide: + | { ask: string[]; recheck: () => string | undefined; apply: (by: string) => { next: WorkstreamBinding; said: string; audit: [string, string] } } + | { error: string }; + if (msg.type === "session.add_reference") { + const paths = msg.paths?.map(referenceFolder); + // Held to what a binding is read back with: one stored that can't be read would seal the session. + const pathProblem = referencePathsProblem(paths); + if (pathProblem) return err(pathProblem); + const others = (binding.references ?? []).filter((r) => r.workstream !== msg.workstream); + if (others.length >= REFERENCE_LIMIT) return err(`A session reads at most ${REFERENCE_LIMIT} other workstreams`); + const recorded = this.#store.workstreamFoldersByWorkstream().get(msg.workstream) ?? []; + const v = this.#workstreams.referenceVerdict(binding.id, { workstream: msg.workstream, ...(paths ? { paths } : {}) }, recorded); + const again = () => { + // Judged again as things are now, the bounds a binding is read back with included (several asks may be pending). + const cur = session.workstreamAhead; + if ((cur?.references ?? []).filter((r) => r.workstream !== msg.workstream).length >= REFERENCE_LIMIT) return `A session reads at most ${REFERENCE_LIMIT} other workstreams`; + if (new Set([...(cur?.readFrom ?? []), msg.workstream]).size > READ_FROM_LIMIT) return "This session has read from too many workstreams to record another"; + const w = this.#workstreams.referenceVerdict(cur?.id, { workstream: msg.workstream, ...(paths ? { paths } : {}) }, recorded); + return w.ok ? undefined : w.error; + }; + const tooMany = again(); + if (tooMany && v.ok) return err(tooMany); + decide = !v.ok + ? { error: v.error } + : { + ask: v.ask, + recheck: again, + apply: (by) => { + const cur = session.workstreamAhead ?? binding; + const ref = { workstream: msg.workstream, ...(paths ? { paths } : {}), grantedBy: by, grantedAt: new Date().toISOString() }; + const next: WorkstreamBinding = { + ...cur, + references: [...(cur.references ?? []).filter((r) => r.workstream !== msg.workstream), ref], + readFrom: [...new Set([...(cur.readFrom ?? []), msg.workstream])], + }; + return { next, said: `Reads workstream "${msg.workstream}"${paths ? ` (${paths.join(", ")})` : ""} from the next turn: its folders read-only, and its memory`, audit: ["workstream.reference_added", `workstream=${msg.workstream}${paths ? ` paths=${paths.join(",")}` : ""} by=${by}`] }; + }, + }; + } else { + if (msg.workstream === binding.id) return err(`The session is already in "${msg.workstream}"`); + // Everything a move is judged by, as things are when asked — and again when the owner answers. + const judge = (cur: WorkstreamBinding | undefined): { error: string } | { ask: string[]; conflict: boolean; folder?: string } => { + const v = this.#workstreams.moveVerdict(cur, msg.workstream); + if (!v.ok) return { error: v.error }; + if ((cur?.readFrom?.length ?? 0) + 2 > READ_FROM_LIMIT) return { error: "This session has read from too many workstreams to record another move" }; + const fv = this.#workstreams.folderVerdict(session.workdir, msg.workstream); + if (!fv.ok) return { error: fv.error }; + // Past another workstream's exclusive claim on the folder: an override (§5.2), never asked on the session. + const conflict = this.#workstreams.resolve(session.workdir, msg.workstream).conflict; + if (conflict && !conflict.overridable) return { error: `This folder belongs to workstream "${conflict.folder}", which allows no override` }; + return { ask: [...new Set([...v.ask, ...fv.ask])], conflict: conflict !== undefined, ...(fv.folder ? { folder: fv.folder } : {}) }; + }; + const first = judge(binding); + if ("error" in first) return err(first.error, "forbidden"); + if (first.conflict && !approves) return err("This folder belongs to another workstream (exclusive): moving the session past it needs session:approve on an undelegated token", "forbidden"); + let answer = first; + decide = { + ask: first.ask, + recheck: () => { + const now = judge(session.workstreamAhead); + if ("error" in now) return now.error; + answer = now; + return undefined; + }, + apply: (by) => { + const cur = session.workstreamAhead ?? binding; + const refs = (cur.references ?? []).filter((r) => r.workstream !== msg.workstream); + // The conversation goes with it: the old home (but the default) and the folder's workstream are read from now. + const readFrom = [...new Set([...(cur.readFrom ?? []), ...(cur.id && cur.id !== this.#workstreams.defaultId ? [cur.id] : []), ...(answer.folder ? [answer.folder] : [])])].filter((w) => w !== msg.workstream); + const next: WorkstreamBinding = { + id: msg.workstream, + source: answer.conflict ? "override" : "explicit", + revision: this.#workstreams.revision, + ...(refs.length > 0 ? { references: refs } : {}), + ...(readFrom.length > 0 ? { readFrom } : {}), + }; + return { next, said: `Moved to workstream "${msg.workstream}"${cur.id ? ` from "${cur.id}"` : ""}: its servers, credentials and sandbox from the next turn`, audit: [answer.conflict ? "workstream.override" : "workstream.moved", `from=${cur.id ?? "none"} to=${msg.workstream} by=${by}`] }; + }, + }; + } + if ("error" in decide) return err(decide.error, "forbidden"); + const applyNow = (by: string) => { + const { next, said, audit } = decide.apply(by); + session.setWorkstream(next, said); + this.#store.audit(by, audit[0], session.id, audit[1]); + }; + if (decide.ask.length === 0 || approves) { + applyNow(auth.sub); + return reply({ message: "done" }); + } + // An `ask` workstream, and the asker can't approve it: the owner confirms on the session. + const what = msg.type === "session.add_reference" ? `read workstream "${msg.workstream}"` : `move to workstream "${msg.workstream}"`; + this.#store.audit(auth.sub, "workstream.share_ask", session.id, `${msg.type} ${msg.workstream} ask=${decide.ask.join(",")}`); + let answeredBy: string | undefined; + void session + .requestUserInput( + { + method: "confirm", + title: `Let "${session.name}" ${what}?`, + message: + msg.type === "session.add_reference" + ? `"${msg.workstream}" shares only with approval. The session could read its folders and recall its memory; it gets none of its servers or credentials, and writes nothing there.` + : `${decide.ask.map((w) => `"${w}"`).join(" and ")} share${decide.ask.length === 1 ? "s" : ""} only with approval, and the session's whole conversation would go with it.`, + }, + { + ownerOnly: true, + onAnswer: (sub) => { + answeredBy = sub; + }, + }, + ) + .then( + (answer) => { + if (answer.cancelled || answer.confirmed !== true) { + this.#store.audit(answeredBy ?? auth.sub, "workstream.share_denied", session.id, `${msg.type} ${msg.workstream} ${answer.reason ?? "declined"}`); + return; + } + // Judged again as it is now: a policy tightened while the owner was asked wins. + const now = decide.recheck(); + if (now) { + this.#store.audit(answeredBy ?? auth.sub, "workstream.share_denied", session.id, `${msg.type} ${msg.workstream} ${now}`); + return; + } + applyNow(answeredBy ?? auth.sub); + }, + () => {}, + ); + return reply({ pending: true, message: `asked on the session: ${what}?` }); + } + /** `sandbox.providers` (#448): what the daemon has and whether each runs here. */ async #sandboxProviders(msg: Extract, auth: AuthContext): Promise { if (!hasScope(auth.scopes as string[], SCOPES.SESSION_CREATE)) { @@ -10406,3 +10899,27 @@ function daemonSandboxRoot(): string[] { const root = sandboxRootIfMade(); return root ? [root] : []; } + +/** `obj` without `key` (an optional field taken away). */ +function withoutKey(obj: T, key: K): Omit { + const { [key]: _, ...rest } = obj; + return rest; +} + +/** + * A reference's folder as it is granted: `~` expanded, made absolute and + * resolved to where it really is (a `..` or a link never names one + * workstream's path for another's). One that doesn't exist is kept as + * written, which referencePathsProblem refuses unless it is absolute (and + * the fence counts it only where it really is inside the workstream). + */ +function referenceFolder(p: string): string { + const expanded = p === "~" ? homedir() : p.startsWith("~/") ? join(homedir(), p.slice(2)) : p; + if (!expanded.startsWith("/")) return p; + const abs = resolve(expanded); + try { + return realpathSync(abs); + } catch { + return abs; + } +} diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 960736a7..f59fc670 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -103,6 +103,9 @@ import { type PinnedDir, PinnedPathError, pinDir, pinRecord } from "./sandbox/pi import { applyRewinds, REWIND_EVENT, type TurnIndexEntry } from "./transcript.js"; import type { EffortChangeResult, EffortLevel, RewindFiles, RewindIrreversible, SessionWorkstreamInfo, TurnSummary } from "../protocol/types.js"; import type { WorkstreamBinding } from "./workstreams/binding.js"; +import type { WorkstreamFence } from "./workstreams/service.js"; +import { type FenceRefusal, fenceRefusal, fenceRefusalText } from "./workstreams/gate.js"; +import { repositoryTopOf } from "./workstreams/folders.js"; import type { McpGrant } from "./workstreams/grant.js"; import { LIMITS } from "../protocol/types.js"; import { hasScope, SCOPES } from "../protocol/scopes.js"; @@ -434,6 +437,18 @@ export interface WindowScope { workdir: string; } +/** + * What a send-class fleet call's card says of its workstream (workstreams + * §10), and — when approving it confirms something — what it was judged on, + * which the manager checks a confirmation against. `confirm` never leaves the + * daemon (the card shows `workstream` and `warning`). + */ +export interface FleetCardTarget { + workstream?: string; + warning?: string; + confirm?: { workdir: string; workstream?: string; revision: string; conflict?: string; asks: string[] }; +} + export interface SessionCreateOptions { name: string; workdir: string; @@ -565,13 +580,20 @@ export interface SessionCreateOptions { * and held to the same denylist (the schema already refused anything it * would). Never persisted: recomputed from the config on every resume. */ - workstreamEnv?: () => Readonly>; + workstreamEnv?: (binding: WorkstreamBinding | undefined) => Readonly>; + /** + * The home's MCP grant under isolation (workstreams/grant.ts), read live + * for the session's binding as it is now; undefined result = no isolation. + * Applied by the session's MCP view, so every backend sees only what the + * home selects. + */ + mcpGrant?: (binding: WorkstreamBinding | undefined) => McpGrant | undefined; /** - * The home's MCP grant under isolation (workstreams/grant.ts), read live; - * undefined result = no isolation. Applied by the session's MCP view, so - * every backend sees only what the home selects. + * The session's read fence under isolation (workstreams §7.3), read live + * for its binding: its home's and references' folders, and every other + * workstream's. Undefined result = no fence. */ - mcpGrant?: () => McpGrant | undefined; + workstreamFence?: (binding: WorkstreamBinding | undefined, own: readonly string[]) => WorkstreamFence | undefined; /** * Role-scoped goal-blackboard mount for a collaboration child: the endpoint * URL plus a bearer token that IS the scope (one goal, this role's read/write @@ -627,6 +649,19 @@ export interface SessionCreateOptions { * role-child. Returns undefined when there is nothing to report. */ collaborationCost?: () => CollaborationCost | undefined; + /** + * The workstream a send-class fleet call acts in, for its approval card + * (workstreams §10). Injected by the manager (the conductor's): a session + * can't see its targets' homes. Undefined = nothing to say. + */ + fleetTargetWorkstream?: (toolName: string, input: unknown) => FleetCardTarget | undefined; + /** + * The owner approved a card whose target needs confirming (an override, an + * `ask` folder): a one-time token for that very call, which the gate adds + * to its input (`confirmation`) for the fleet handler to present. Absent + * or undefined: none (the handler then refuses what needed it). + */ + fleetConfirm?: (target: FleetCardTarget, input: unknown) => string | undefined; /** * Initial execution mode + autonomous tool budget. Spawned workers start * "autonomous" with a bounded budget so they can work unattended; when the @@ -1085,6 +1120,42 @@ export class Session { return this.#workstream; } + /** + * Change the session's binding (workstreams W3: a reference granted or + * taken away, a move to another home, §5.3, §7). Authorized and judged by + * the manager. Saved at once; what it gives is read live, so the backend + * is rebuilt with it at the next turn (its servers, env, sandbox roots and + * recall), never mid-turn. Said on the session. + */ + setWorkstream(next: WorkstreamBinding, said: string): void { + // Saved at once (a restart resumes with it), applied at the next turn's start: a turn running now + // keeps the binding it started with whole — its gate, servers, env and memory together. + this.#pendingWorkstream = next; + this.#saveMetaNow(); + if (!this.#mcpBusy()) this.#applyPendingWorkstream(); + const info = this.#makeMessage("info", `🧭 ${said}`, SYSTEM_IDENTITY, undefined, undefined, { event: "workstream.changed", workstream: next.id ?? null }); + this.#persistAndBuffer(info); + this.#broadcastRaw(info); + this.#broadcastInfoUpdate(); + } + + /** A binding change waiting for the turn boundary (setWorkstream). */ + #pendingWorkstream: WorkstreamBinding | undefined; + + /** The binding as it will be: the pending one, else the one in use. What is saved and shown. */ + get workstreamAhead(): WorkstreamBinding | undefined { + return this.#pendingWorkstream ?? this.#workstream; + } + + #applyPendingWorkstream(): void { + const next = this.#pendingWorkstream; + if (!next) return; + this.#pendingWorkstream = undefined; + this.#workstream = next; + // The frozen memory index was judged against the old scope. + this.#frozenMemoryIndex = null; + } + /** * Goal + role→backend bindings when this session was created with the * Collaborative toggle (set from opts / restored from meta). Readonly: the @@ -1386,6 +1457,8 @@ export class Session { #mode: SessionMode = "guarded"; #onStatusChange?: (sessionId: string, status: SessionStatus, opts?: { settled: true }) => void; #collaborationCost?: () => CollaborationCost | undefined; + #fleetTargetWorkstream?: (toolName: string, input: unknown) => FleetCardTarget | undefined; + #fleetConfirm?: (target: FleetCardTarget, input: unknown) => string | undefined; #workerShape?: "ship" | "scout"; #turnsRemaining: number | undefined = undefined; /** See SessionCreateOptions.onBudgetExhausted. */ @@ -1405,11 +1478,39 @@ export class Session { #extraEnvStatic: Readonly> | undefined; /** The home workstream's env, read live (SessionCreateOptions.workstreamEnv). */ #workstreamEnv: (() => Readonly>) | undefined; + /** The session's read fence (SessionCreateOptions.workstreamFence), for its binding as it is now. */ + #workstreamFence: (() => WorkstreamFence | undefined) | undefined; /** What a backend gets as extra env: a live view over both (see #liveExtraEnv). */ #extraEnv: Readonly> | undefined; /** The extra env the current backend was built with, to rebuild it when the workstream's changes. */ #envBuiltWith: string | undefined; + /** + * The read fence as the sandbox takes it (workstreams §7.3): the home's and + * references' folders shown read-only (the project roots), every other + * workstream's hidden. + */ + /** Lets go of the session's recall scope (MemoryEngine.scopeSession), at destroy. */ + #unscopeMemory: (() => void) | undefined; + + /** The workstreams this session's recall may read: its home and references; undefined without a fence. */ + #memoryWorkstreams(): readonly string[] | undefined { + const fence = this.#workstreamFence?.(); + if (!fence) return undefined; + // A reserved home (the conductor's) is no workstream's folders, but its own memory is its own. + const reserved = this.#workstream?.source === "reserved" && this.#workstream.id ? [this.#workstream.id] : []; + return [...new Set([...(fence.workstream ? [fence.workstream] : []), ...reserved, ...fence.references.map((r) => r.workstream)])]; + } + + #sandboxFence(): { roots: string[]; hidden: string[] } | undefined { + const fence = this.#workstreamFence?.(); + if (!fence) return undefined; + return { + roots: [...new Set([...fence.home, ...fence.references.flatMap((r) => r.folders)])], + hidden: fence.hidden, + }; + } + /** The workstream's env under the caller's extra env, held to the extra-env denylist. */ #composedEnv(): Record { const out: Record = { ...(this.#workstreamEnv?.() ?? {}), ...(this.#extraEnvStatic ?? {}) }; @@ -1794,9 +1895,17 @@ export class Session { this.#onStatusChange = opts.onStatusChange; this.#workerShape = opts.workerShape; this.#collaborationCost = opts.collaborationCost; + this.#fleetTargetWorkstream = opts.fleetTargetWorkstream; + this.#fleetConfirm = opts.fleetConfirm; this.#maxBudgetUsd = opts.maxBudgetUsd; this.#onBudgetExhausted = opts.onBudgetExhausted ?? "park"; - this.#workstreamEnv = opts.workstreamEnv; + const envOf = opts.workstreamEnv; + this.#workstreamEnv = envOf ? () => envOf(this.#workstream) : undefined; + const fenceOf = opts.workstreamFence; + // The session's own folder stays readable whatever its home (one moved, or homed past a claim): its + // workdir and the repository holding it (the sandbox's workspace in a monorepo). + const own = [...new Set([opts.workdir, repositoryTopOf(opts.workdir, homedir())])]; + this.#workstreamFence = fenceOf ? () => fenceOf(this.#workstream, own) : undefined; const extra = opts.extraEnv; if (extra) { // Before any side effect: a refused key must fail the create, not run a @@ -1820,6 +1929,7 @@ export class Session { ...(opts.sandbox ? { setting: opts.sandbox } : {}), onWarning: (text) => this.#sandboxWarning(text, "sandbox.login_not_saved"), onWorktreeEntries: (common, findings) => this.#worktreeEntriesFound(common, findings), + ...(this.#workstreamFence ? { workstreamDirs: () => this.#sandboxFence() } : {}), }); } this.#requireSandbox = opts.requireSandbox === true; @@ -1871,7 +1981,7 @@ export class Session { workdir: this.workdir, tenant: { accountId: this.accountId, projectId: this.projectId }, approvals: () => this.#store.workdirMcpApprovals(scope), - ...(opts.mcpGrant ? { grant: opts.mcpGrant } : {}), + ...(opts.mcpGrant ? { grant: () => opts.mcpGrant?.(this.#workstream) } : {}), }); } this.#config = opts.config; @@ -1988,6 +2098,8 @@ export class Session { if (this.#memory) { const memory = this.#memory; + // Recall kept to the home and its references under isolation (workstreams §6.4). + if (this.#workstreamFence) this.#unscopeMemory = memory.scopeSession(this.id, () => this.#memoryWorkstreams()); this.#indexScheduler = new IndexScheduler({ store: memory.store, workspaceId: this.#workspaceId, @@ -2007,7 +2119,9 @@ export class Session { // Retry a registration that failed at construction; until it // succeeds the workspace is invisible to cross-workspace search. if (!this.#workspaceRegistered) this.#registerWorkspace(); - memory.ingest(episode); + // Tagged with the home as it is now (workstreams §6.4): a session moved since writes into its new one. + const home = this.#workstream?.id; + memory.ingest(home ? { ...episode, workstreamId: home } : episode); scheduler.onEpisode(); } catch (err) { console.error( @@ -2050,7 +2164,7 @@ export class Session { worktree: this.worktree, collaboration: this.collaboration, collaborationRole: this.collaborationRole, - ...(this.#workstream ? { workstream: this.#workstream } : {}), + ...(this.workstreamAhead ? { workstream: this.workstreamAhead } : {}), // Fire-and-forget: saveMeta's write chain owns the failure log; an // unconsumed rejection here would be an unhandled-rejection crash. }).catch(() => {}); @@ -2133,6 +2247,14 @@ export class Session { isOwnTask: (taskId) => this.#isOwnTask(taskId), onForeignAgentDenied: (agentId, toolName) => this.#noteForeignAgentRefused(agentId, toolName), isAutonomous: () => this.#mode === "autonomous", + ...(this.#workstreamFence + ? { + workstreamFenceRefusal: (toolName: string, input: unknown, cwd: string | null | undefined) => { + const r = this.#fenceRefusal(toolName, input, cwd); + return r ? fenceRefusalText(r) : null; + }, + } + : {}), // Read each time the backend starts, and only what was resolved for THIS backend. sandbox: () => this.#sandbox?.runtimeFor(factory.id) ?? null, ...(this.#extraEnv ? { extraEnv: this.#extraEnv } : {}), @@ -2470,6 +2592,38 @@ export class Session { } } + /** The workstream fence's verdict on a tool call (workstreams/gate.ts); null without a fence. Fails closed. */ + #fenceRefusal(toolName: string, input: unknown, shellCwd: string | null | undefined): FenceRefusal | null { + let fence: WorkstreamFence | undefined; + try { + fence = this.#workstreamFence?.(); + } catch (e) { + console.error(`[codeoid/session ${this.id}] workstream fence: ${e instanceof Error ? e.message : String(e)}`); + return { path: "(couldn't be judged)", workstream: this.#workstream?.id ?? "?", kind: "read" }; + } + if (!fence) return null; + // paths: judging a tool call's input (fenceRefusal): no use of the path + return fenceRefusal(toolName, input, { workdir: this.workdir, ...(shellCwd !== undefined ? { cwd: shellCwd } : {}), fence }); + } + + /** Refuse a call the workstream fence holds: its card, the audit, and an info line. */ + #refuseFenced(approvalId: string, toolName: string, r: FenceRefusal, sender: AuthContext): { behavior: "deny"; message: string } { + const reason = fenceRefusalText(r); + this.#approvalPatchKeys.delete(approvalId); + this.#resolveToolCallMessage(approvalId, { phase: "cancelled", reason: "denied", message: reason }); + this.#store.audit(sender.sub, "session.workstream_fence_refuse", this.id, `tool=${toolName} kind=${r.kind} workstream=${r.workstream}`); + const said = r.kind === "unresolved" ? `couldn't tell where ${r.path} is` : `${r.path} is workstream "${r.workstream}"'s`; + const infoMsg = this.#makeMessage("info", `⛔ Refused ${toolName}: ${said}`, SYSTEM_IDENTITY, undefined, undefined, { + event: "workstream.fenced", + tool: toolName, + workstream: r.workstream, + kind: r.kind, + }); + this.#persistAndBuffer(infoMsg); + this.#broadcastRaw(infoMsg); + return { behavior: "deny", message: reason }; + } + /** A host-side file tool beyond the sandbox, in autonomous mode: refused (#outsideSandbox). Null in every other mode. */ #outsideSandboxRefusal(toolName: string, input: Record, shellCwd: string | null | undefined): HostToolBreach | null { return this.#mode === "autonomous" ? this.#outsideSandbox(toolName, input, shellCwd) : null; @@ -3656,7 +3810,11 @@ export class Session { const root = sandboxRootIfMade(); const hidden = [...hiddenPathsFor(this.#config), ...(root ? [root] : [])]; const problem = addedDirProblem(written, { home, configDir: getConfigDir(), hidden }); - return problem ? { error: problem } : { dir, written }; + if (problem) return { error: problem }; + // Another workstream's folder (workstreams §7.3): writable would open the fence; reading it is a reference. + const fenced = this.#fenceRefusal("Write", { file_path: join(dir, ".codeoid-probe") }, null); + if (fenced) return { error: `${dir} is in workstream "${fenced.workstream}"'s folders: add a reference to read it (codeoid workstream ref add), never a writable directory` }; + return { dir, written }; } /** @@ -5290,6 +5448,8 @@ export class Session { // The session's MCP servers: read before its first turn, and a refresh // asked for meanwhile applied (#395) — before the stop and adopted-turn // checks below, which must come after every await. + // A workstream change made during the last turn applies now, before the servers and env are read. + if (!this.#mcpBusy()) this.#applyPendingWorkstream(); await this.#applyMcpView(); // A thinking effort the backend takes only at startup (#417): rebuild it now, between turns. await this.#applyEffortRestart(); @@ -6365,7 +6525,15 @@ export class Session { const gated = this.#approvalInputs.get(approvalId); if (!gated) continue; const risk = this.#shellRisk(gated.toolName, gated.input as Record, gated.shellCwd); - out.push({ approvalId, toolName: gated.toolName, input: gated.input, ...this.#cardRisk(risk, this.#heldWrite(gated.toolName, gated.input)), ...this.#cardSandbox(gated.toolName, gated.input, gated.shellCwd) }); + const ws = this.#approvalWorkstreams.get(approvalId); + out.push({ + approvalId, + toolName: gated.toolName, + input: gated.input, + ...this.#cardRisk(risk, this.#heldWrite(gated.toolName, gated.input)), + ...this.#cardSandbox(gated.toolName, gated.input, gated.shellCwd), + ...(ws ? { targetWorkstream: { ...(ws.target.workstream ? { workstream: ws.target.workstream } : {}), ...(ws.target.warning ? { warning: ws.target.warning } : {}) } } : {}), + }); } return out; } @@ -6454,6 +6622,7 @@ export class Session { // the commit look's git is bounded, and a destroy waits no longer. await Promise.race([this.#checkGitControl(sender.sub), new Promise((r) => setTimeout(r, DESTROY_COMMIT_CHECK_MS).unref?.())]); this.#destroyed = true; + this.#unscopeMemory?.(); // This session's own servers' clients in the hub: let go (the last user closes them). for (const spec of [...this.#mcpRetained.values(), ...this.#mcpToRelease.values()]) this.#mcpHub?.release(spec, this.#mcpTenant()); this.#mcpRetained.clear(); @@ -7733,6 +7902,52 @@ export class Session { * Never throws. A roll-up that could fail an approval would be worse than no * roll-up at all — the dispatch path must not be wedgeable by a cost display. */ + /** What each pending card said of its workstream, with the input it was judged on; let go when it settles. */ + #approvalWorkstreams = new Map(); + + /** What a pending approval's card said of its workstream (workstreams §10), or undefined. */ + approvalWorkstream(approvalId: string): { toolName: string; input: unknown; target: FleetCardTarget } | undefined { + return this.#approvalWorkstreams.get(approvalId); + } + + /** Whether `sender` answers as this session's owner (its own user token), as an owner-only confirm needs. */ + isOwnApprover(sender: AuthContext): boolean { + return this.#isOwnApproval(sender); + } + + /** The workstream a send-class fleet call acts in, for its card (never throws: a display can't wedge an approval). */ + #targetWorkstreamFor(toolName: string, input: unknown, approvalId?: string): { targetWorkstream?: { workstream?: string; warning?: string } } { + if (!this.#fleetTargetWorkstream || !isFleetSendTool(toolName)) return {}; + try { + const t = this.#fleetTargetWorkstream(toolName, input); + if (t && approvalId) { + if (this.#approvalWorkstreams.size > 64) this.#approvalWorkstreams.delete(this.#approvalWorkstreams.keys().next().value!); + this.#approvalWorkstreams.set(approvalId, { toolName, input, target: t }); + } + // The card shows what it says; what it was judged on stays here. + return t ? { targetWorkstream: { ...(t.workstream ? { workstream: t.workstream } : {}), ...(t.warning ? { warning: t.warning } : {}) } } : {}; + } catch { + return {}; + } + } + + /** + * The approved call's input with a confirmation token, when its card needed one (workstreams §10, G3): only + * for the owner's own approval of that very card, on the input the card was judged on. Never throws. + */ + #withFleetConfirmation(approvalId: string, input: Record): Record { + const card = this.#approvalWorkstreams.get(approvalId); + this.#approvalWorkstreams.delete(approvalId); + if (!card?.target.confirm || !this.#fleetConfirm || !this.#ownerApproved.has(approvalId)) return input; + try { + if (JSON.stringify(card.input) !== JSON.stringify(input)) return input; + const token = this.#fleetConfirm(card.target, input); + return token ? { ...input, confirmation: token } : input; + } catch { + return input; + } + } + #collaborationCostFor(toolName: string): { collaborationCost?: CollaborationCost } { if (!this.#collaborationCost || !isFleetSendTool(toolName)) return {}; try { @@ -7883,7 +8098,9 @@ export class Session { worktree: this.worktree, collaboration: this.collaboration, collaborationRole: this.collaborationRole, - ...(this.#workstream ? { workstream: this.#describeWorkstream?.(this.#workstream) ?? { ...(this.#workstream.id ? { id: this.#workstream.id } : {}), source: this.#workstream.source } } : {}), + ...(this.workstreamAhead + ? { workstream: this.#describeWorkstream?.(this.workstreamAhead) ?? { ...(this.workstreamAhead.id ? { id: this.workstreamAhead.id } : {}), source: this.workstreamAhead.source } } + : {}), // Ambient pack driving this session (docs/pack-loading.md) — id, or // "id (role)" when a capability role is active. ...(this.#pack @@ -8874,7 +9091,7 @@ export class Session { let timelineEpisodes: ReturnType = []; if (this.#memory) { try { - timelineEpisodes = this.#memory.timeline(this.#workspaceId, 30); + timelineEpisodes = this.#memory.timeline(this.#workspaceId, 30, 0, this.#memoryWorkstreams()); } catch { /* graceful — the map still works without the page table */ } @@ -9013,7 +9230,11 @@ export class Session { // live scheduler every turn churns the append by a few bytes (relative times, // counts) and forces a per-turn query-loop rebuild that aborts the turn. if (this.#frozenMemoryIndex === null) { - this.#frozenMemoryIndex = this.#indexScheduler?.get() ?? ""; + // The index is the repository's whole memory: left out where another workstream wrote some of it + // (a repository two workstreams share); recall, filtered, still reaches the session's own. + const scope = this.#memoryWorkstreams(); + const mixed = scope !== undefined && (this.#memory?.store.hasOtherWorkstreams(this.#workspaceId, scope) ?? false); + this.#frozenMemoryIndex = mixed ? "" : (this.#indexScheduler?.get() ?? ""); } const index = this.#frozenMemoryIndex; if (!index) return MEMORY_SYSTEM_PROMPT_APPEND; @@ -9680,6 +9901,11 @@ export class Session { return { behavior: "deny" as const, message: reason }; } + // The workstream fence (workstreams §7.3), every mode: a file tool in + // another workstream's folders, or writing a reference, is refused. + const fenced = this.#fenceRefusal(toolName, effectiveInput, shellCwd); + if (fenced) return this.#refuseFenced(approvalId, toolName, fenced, sender); + // A file tool running outside the OS sandbox (Claude's, opencode's) is // held to the sandbox's own policy in autonomous mode (#448 rounds 8, 9): // beyond it, refused, never asked. Guarded and interactive mode decide @@ -9781,8 +10007,9 @@ export class Session { this.#store.audit(sender.sub, "session.approve", this.id, `tool=${toolName} approvalId=${approvalId}`); if (unsandboxed) this.#store.audit(sender.sub, "session.unsandboxed_approve", this.id, `tool=${toolName} approvalId=${approvalId}`); const merged: Record = sanitizedPatch ? { ...effectiveInput, ...sanitizedPatch } : effectiveInput; - return { behavior: "allow" as const, updatedInput: merged }; + return { behavior: "allow" as const, updatedInput: sanitizedPatch ? merged : this.#withFleetConfirmation(approvalId, merged) }; } + this.#approvalWorkstreams.delete(approvalId); this.#store.audit(sender.sub, "session.deny", this.id, `tool=${toolName} approvalId=${approvalId}`); return { behavior: "deny" as const, message: "Denied by user" }; }; @@ -10385,11 +10612,21 @@ export class Session { const budgetDenied = this.#budgetExhausted || this.#budgetSpentFor(event.name); // Autonomous mode refuses it outright (#autonomousRefusal): no approval card either. const deniedFirst = networkAsk || budgetDenied || secret?.action === "deny" || risk?.secret?.action === "deny"; + // The workstream fence refuses it in every mode (#fenceRefusal): no approval card. + const fenced = deniedFirst ? null : this.#fenceRefusal(event.name, event.input, event.shellCwd); // A pinned directory changed (#448 round 13): the gate refuses every call (#sandboxPinRefusal). const pinned = budgetDenied ? null : this.#sandboxPinRefusal(); // Beyond the sandbox, a host-side file tool is refused in autonomous mode (#outsideSandbox). const outside = deniedFirst || pinned ? null : this.#outsideSandboxRefusal(event.name, event.input, event.shellCwd); - const refused = pinned ? { reason: pinned } : deniedFirst ? null : outside ? { reason: hostFileBreachReason(outside) } : this.#autonomousRefusal(event.name, event.input, risk, secret, held); + const refused = pinned + ? { reason: pinned } + : deniedFirst + ? null + : fenced + ? { reason: fenceRefusalText(fenced) } + : outside + ? { reason: hostFileBreachReason(outside) } + : this.#autonomousRefusal(event.name, event.input, risk, secret, held); const toolMsg = this.#makeMessage( "tool_call", `${event.name}(${Object.keys(event.input).join(", ")})`, @@ -10411,6 +10648,7 @@ export class Session { description: `${event.name}(${Object.keys(event.input).join(", ")})`, approvalId: event.approvalId, ...this.#collaborationCostFor(event.name), + ...this.#targetWorkstreamFor(event.name, event.input, event.approvalId), ...this.#cardRisk(risk, held), ...this.#cardSandbox(event.name, event.input, event.shellCwd), } as unknown as ToolState), @@ -12201,7 +12439,7 @@ export class Session { collaboration: this.collaboration, collaborationRole: this.collaborationRole, // Both meta writes, for the same reason as the collaboration above. - ...(this.#workstream ? { workstream: this.#workstream } : {}), + ...(this.workstreamAhead ? { workstream: this.workstreamAhead } : {}), }).catch(() => {}); } } diff --git a/src/daemon/store.ts b/src/daemon/store.ts index 7dd61827..ad90d51a 100644 --- a/src/daemon/store.ts +++ b/src/daemon/store.ts @@ -51,6 +51,15 @@ export interface DispatchTaskRow { provider: string | null; /** Per-child model, already resolved against `provider`. NULL = provider default. */ model: string | null; + /** + * A spawn's target home workstream (workstreams §10), checked when queued + * and again at claim. NULL = the folder's (every task before W4). + */ + workstream: string | null; + /** The spawn's card confirmed an override or an `ask` folder (its approval card said so). */ + workstreamOverride: boolean; + /** The config revision that confirmation was given at: it holds only at that one. */ + workstreamRevision: string | null; status: DispatchTaskStatus; attempts: number; failureLimit: number; @@ -139,6 +148,9 @@ interface RawDispatchRow { attempt_id: string | null; generation: number; held_until: number | null; + workstream?: string | null; + workstream_override?: number | null; + workstream_revision?: string | null; created_at: number; updated_at: number; } @@ -157,6 +169,9 @@ function rowToDispatchTask(r: RawDispatchRow): DispatchTaskRow { // missing-column undefined and a stored NULL to the same "use default". provider: r.provider ?? null, model: r.model ?? null, + workstream: r.workstream ?? null, + workstreamOverride: r.workstream_override === 1, + workstreamRevision: r.workstream_revision ?? null, status: r.status, attempts: r.attempts, failureLimit: r.failure_limit, @@ -682,6 +697,10 @@ export class Store { this.#addColumnIfMissing("dispatch_tasks", "attempt_id", "TEXT"); this.#addColumnIfMissing("dispatch_tasks", "generation", "INTEGER NOT NULL DEFAULT 0"); this.#addColumnIfMissing("dispatch_tasks", "held_until", "INTEGER"); + // A spawn's target workstream (workstreams W4); NULL = the folder's. + this.#addColumnIfMissing("dispatch_tasks", "workstream", "TEXT"); + this.#addColumnIfMissing("dispatch_tasks", "workstream_override", "INTEGER"); + this.#addColumnIfMissing("dispatch_tasks", "workstream_revision", "TEXT"); // AFTER the ALTERs, never inside the CREATE block above. On an existing // database `CREATE TABLE IF NOT EXISTS` is a no-op, so an index declared // there would run against a table that does not have the column yet and @@ -1145,6 +1164,10 @@ export class Store { provider?: string; /** Per-child model, already resolved against `provider`. */ model?: string; + /** A spawn's target workstream, and whether it was confirmed past the folder's claim. */ + workstream?: string; + workstreamOverride?: boolean; + workstreamRevision?: string; failureLimit: number; createdBy: string; /** Dispatch group for a barrier fan-out; omit for a standalone task. */ @@ -1164,8 +1187,8 @@ export class Store { `INSERT INTO dispatch_tasks (id, account_id, project_id, kind, shape, target_session, workdir, prompt, provider, model, failure_limit, created_by, group_id, - group_ordinal, owner_daemon, run_id, attempt_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + group_ordinal, owner_daemon, run_id, attempt_id, workstream, workstream_override, workstream_revision, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( task.id, @@ -1185,6 +1208,9 @@ export class Store { task.ownerDaemon ?? null, task.runId ?? null, task.attemptId ?? null, + task.workstream ?? null, + task.workstreamOverride ? 1 : null, + task.workstreamRevision ?? null, task.now, task.now, ); @@ -1985,6 +2011,18 @@ export class Store { return row?.workstream; } + /** Every recorded folder by workstream, across tenants (the read fence: workstreams are daemon-wide config). */ + workstreamFoldersByWorkstream(): Map { + const rows = this.#db.prepare("SELECT DISTINCT workstream, workdir FROM workstream_folders").all() as { workstream: string; workdir: string }[]; + const out = new Map(); + for (const r of rows) { + const list = out.get(r.workstream) ?? []; + list.push(r.workdir); + out.set(r.workstream, list); + } + return out; + } + setWorkstreamFolder(scope: { accountId: string; projectId: string; workdir: string }, workstream: string): void { this.#db .prepare( diff --git a/src/daemon/workstreams/binding.ts b/src/daemon/workstreams/binding.ts index cb55651c..5480f67a 100644 --- a/src/daemon/workstreams/binding.ts +++ b/src/daemon/workstreams/binding.ts @@ -6,6 +6,20 @@ import type { WorkstreamSource } from "../../protocol/types.js"; +/** + * Another workstream a session may read (§7.2): its folders through file + * tools and the sandbox (read-only), its memory through recall. Never its + * servers, env or accounts, and never a write. + */ +export interface WorkstreamReference { + workstream: string; + /** Folders inside it; absent = all of its folders. */ + paths?: string[]; + /** The subject that granted it (an `ask` workstream: the owner who confirmed). */ + grantedBy: string; + grantedAt: string; +} + /** A session's home, as persisted in its meta (TranscriptMeta.workstream). */ export interface WorkstreamBinding { /** Absent = no home (`source: "none"`). */ @@ -14,8 +28,57 @@ export interface WorkstreamBinding { matchedBy?: string; /** The config revision the binding was made under. */ revision: string; + references?: WorkstreamReference[]; + /** + * Every workstream the session has read from through a reference, for its + * lifetime (§14 q3): a later move or fork follows each one's share policy + * as it is then, even after the reference is gone. + */ + readFrom?: string[]; + /** + * The stored binding couldn't be read back, so what the session read from + * is unknown: it gets no home, and is never moved to another workstream + * (that would carry its conversation past a policy nobody can check). + */ + sealed?: true; +} + +const ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; +const MAX_REFERENCES = 32; +const MAX_REFERENCE_PATHS = 64; + +function readReference(raw: unknown): WorkstreamReference | undefined { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined; + const r = raw as Record; + if (typeof r.workstream !== "string" || !ID.test(r.workstream)) return undefined; + if (typeof r.grantedBy !== "string" || r.grantedBy.length === 0 || r.grantedBy.length > 512) return undefined; + if (typeof r.grantedAt !== "string" || r.grantedAt.length > 64) return undefined; + if (r.paths !== undefined) { + if (!Array.isArray(r.paths) || r.paths.length > MAX_REFERENCE_PATHS) return undefined; + if (!r.paths.every((p) => typeof p === "string" && p.startsWith("/") && p.length <= 4096 && !p.includes("\0"))) return undefined; + } + return { + workstream: r.workstream, + ...(r.paths !== undefined ? { paths: [...(r.paths as string[])] } : {}), + grantedBy: r.grantedBy, + grantedAt: r.grantedAt, + }; } +/** Why a reference's folders can't be stored (the same bounds a binding is read back with), or undefined. */ +export function referencePathsProblem(paths: readonly string[] | undefined): string | undefined { + if (paths === undefined) return undefined; + if (paths.length === 0) return "A reference names its folders, or none for all of them"; + if (paths.length > MAX_REFERENCE_PATHS) return `A reference names at most ${MAX_REFERENCE_PATHS} folders`; + const bad = paths.find((p) => !p.startsWith("/") || p.length > 4096 || p.includes("\0")); + return bad === undefined ? undefined : `Not an absolute folder: ${JSON.stringify(bad)}`; +} + +/** Most references one session holds. */ +export const REFERENCE_LIMIT = MAX_REFERENCES; +/** Most workstreams one session's read-from record holds. */ +export const READ_FROM_LIMIT = 256; + const SOURCES: ReadonlySet = new Set(["explicit", "matched", "default", "override", "migrated", "reserved", "none"]); /** @@ -29,13 +92,34 @@ export function readWorkstreamBinding(raw: unknown): WorkstreamBinding | undefin const r = raw as Record; if (typeof r.source !== "string" || !SOURCES.has(r.source)) return undefined; if (typeof r.revision !== "string" || r.revision.length > 128) return undefined; - if (r.id !== undefined && (typeof r.id !== "string" || !/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(r.id))) return undefined; + if (r.id !== undefined && (typeof r.id !== "string" || !ID.test(r.id))) return undefined; if (r.matchedBy !== undefined && (typeof r.matchedBy !== "string" || r.matchedBy.length > 1024)) return undefined; if ((r.source === "none") !== (r.id === undefined)) return undefined; + // References and the read-from record fail the whole binding when malformed: + // a reference dropped silently would be harmless, but a read-from entry + // dropped would let a later move skip a share policy. + let references: WorkstreamReference[] | undefined; + if (r.references !== undefined) { + if (!Array.isArray(r.references) || r.references.length > MAX_REFERENCES) return undefined; + references = []; + for (const raw of r.references) { + const ref = readReference(raw); + if (!ref) return undefined; + references.push(ref); + } + } + let readFrom: string[] | undefined; + if (r.readFrom !== undefined) { + if (!Array.isArray(r.readFrom) || r.readFrom.length > READ_FROM_LIMIT || !r.readFrom.every((x) => typeof x === "string" && ID.test(x))) return undefined; + readFrom = [...new Set(r.readFrom as string[])]; + } return { ...(typeof r.id === "string" ? { id: r.id } : {}), source: r.source as WorkstreamSource, ...(typeof r.matchedBy === "string" ? { matchedBy: r.matchedBy } : {}), revision: r.revision, + ...(references && references.length > 0 ? { references } : {}), + ...(readFrom && readFrom.length > 0 ? { readFrom } : {}), + ...(r.sealed === true ? { sealed: true as const } : {}), }; } diff --git a/src/daemon/workstreams/egress.ts b/src/daemon/workstreams/egress.ts new file mode 100644 index 00000000..5ca08e79 --- /dev/null +++ b/src/daemon/workstreams/egress.ts @@ -0,0 +1,45 @@ +/** + * Workstream-homed egress (docs/workstreams-design.md §10, G6) — the stub W4 + * leaves until P6's Slack and email egress exists. + * + * The rule it holds: anything the conductor sends to the world names a + * target workstream and is carried out by a worker homed there, so a reply in + * one line of work is never drafted by a session that has seen another's. + * `planEgress` is that decision, pure; nothing calls it yet but its tests. + * When an egress verb lands it plans first, then spawns the worker it names + * (fleet_spawn's path: `#fleetSpawnHome`), with the plan on the approval card. + */ + +import type { WorkstreamService } from "./service.js"; + +export interface EgressRequest { + /** The workstream the message belongs to: required, never inferred. */ + workstream?: string; + /** Where it goes: a channel kind (`slack`, `email`, …) and its recipient. */ + channel: string; + recipient: string; + /** Who asks: only the conductor plans egress. */ + from: { role?: string; workstream?: string }; +} + +export type EgressPlan = + | { ok: true; worker: { workstream: string }; card: { workstream: string; channel: string; recipient: string } } + | { ok: false; error: string }; + +/** + * Where an outbound message is drafted and sent: by a worker homed in its + * workstream, never by the conductor itself, never in a workstream not named. + */ +export function planEgress(service: Pick, req: EgressRequest): EgressPlan { + if (req.from.role !== "conductor") return { ok: false, error: "Only the conductor plans egress; a worker sends what it was spawned to send" }; + if (!service.configured) return { ok: false, error: "Egress needs workstreams: each message is sent by a worker homed in its workstream" }; + if (!req.workstream) return { ok: false, error: "Name the workstream this message belongs to: egress is never sent from a guessed one" }; + const problem = service.choiceProblem(req.workstream); + if (problem) return { ok: false, error: problem }; + if (!req.channel || !req.recipient) return { ok: false, error: "Name the channel and the recipient" }; + return { + ok: true, + worker: { workstream: req.workstream }, + card: { workstream: req.workstream, channel: req.channel, recipient: req.recipient }, + }; +} diff --git a/src/daemon/workstreams/folders.ts b/src/daemon/workstreams/folders.ts new file mode 100644 index 00000000..8978e85e --- /dev/null +++ b/src/daemon/workstreams/folders.ts @@ -0,0 +1,162 @@ +/** + * A workstream's folders on this machine (docs/workstreams-design.md §7): the + * concrete directories its path rules name, plus the folders recorded as its + * home (`workstream_folders`: a remote rule names no path). What the read + * fence is built from — the sandbox's roots and hidden paths, the gate's + * check. + * + * Bounded and link-free: a rule is expanded one segment at a time with + * `readdir`, never `**`, at most `FOLDER_LIMIT` folders, and a symlinked + * entry is never followed (a link an agent can plant must not place another + * workstream's folder, nor make one look like the home's). What can't be + * expanded is said in `limits`, never guessed at. + */ + +import { lstatSync, readdirSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** Most folders one workstream's rules expand to. */ +export const FOLDER_LIMIT = 512; + +const GLOB_CHARS = /[*?[\]{}]/; + +export interface ExpandedFolders { + folders: string[]; + /** Rules that couldn't be expanded fully, and why. */ + limits: string[]; +} + +/** A directory reached through no link (an entry a glob matched). */ +function isDir(path: string): boolean { + try { + return lstatSync(path).isDirectory(); + } catch { + return false; + } +} + +/** A directory, links followed: a segment the rule names literally (the user wrote it). */ +function isDirFollowed(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +/** + * The existing directories a path rule (absolute, `~` expanded, normalized) + * names. A literal rule is its own folder when it is a directory. + */ +export function expandPathRule(rule: string, limit = FOLDER_LIMIT): ExpandedFolders { + const limits: string[] = []; + const segments = rule.split("/").filter((s) => s.length > 0); + let level = ["/"]; + for (const seg of segments) { + if (seg === "**") { + // `/**` at the end is normalized away; one in the middle names any depth. + limits.push(`${rule}: a \`**\` in the middle isn't expanded`); + return { folders: [], limits }; + } + const next: string[] = []; + if (!GLOB_CHARS.test(seg)) { + for (const dir of level) if (isDirFollowed(join(dir, seg))) next.push(join(dir, seg)); + } else { + const glob = new Bun.Glob(seg); + for (const dir of level) { + let names: string[]; + try { + names = readdirSync(dir); + } catch { + continue; + } + for (const name of names) { + if (!glob.match(name)) continue; + const path = join(dir, name); + if (isDir(path)) next.push(path); + if (next.length > limit) { + limits.push(`${rule}: more than ${limit} folders`); + return { folders: [], limits }; + } + } + } + } + level = next; + if (level.length === 0) break; + } + return { folders: segments.length === 0 ? ["/"] : level, limits }; +} + +/** + * The repository top of `dir`: the nearest ancestor holding `.git` (a dir or + * a file), or `dir` itself when there is none. A recorded home folder is a + * session's workdir, which may be a package inside a repository: the fence is + * the whole repository. Never `stopAt` (the home) or anything holding it: a + * home that is itself a repository (dotfiles) must not make every folder in + * it one workstream's. + */ +export function repositoryTopOf(dir: string, stopAt?: string): string { + let at = dir; + for (;;) { + if (at === "/" || (stopAt !== undefined && within(stopAt, at))) return dir; + try { + lstatSync(join(at, ".git")); + return at; + } catch {} + const up = dirname(at); + if (up === at) return dir; + at = up; + } +} + +/** Whether `path` is `folder` or inside it (both absolute, normalized). */ +export function within(path: string, folder: string): boolean { + return folder === "/" || path === folder || path.startsWith(`${folder}/`); +} + +/** The folders with any folder inside another dropped (the outer one kept). */ +export function outermost(folders: Iterable): string[] { + const sorted = [...new Set(folders)].sort((a, b) => a.length - b.length); + const out: string[] = []; + for (const f of sorted) if (!out.some((o) => within(f, o))) out.push(f); + return out; +} + +/** Most entries {@link carveOut} hides before it gives up (and the folder is hidden whole). */ +export const CARVE_LIMIT = 2000; + +/** + * What to hide of `folder` so that only `keep` (paths inside it) stays + * visible: every entry on the way to a kept path that doesn't lead to one, + * at each level. Null when that takes more than `limit` entries or a level + * can't be listed: then the folder is hidden whole (the kept paths with it — + * failing closed). + */ +export function carveOut(folder: string, keep: readonly string[], limit = CARVE_LIMIT): string[] | null { + const out: string[] = []; + const walk = (dir: string): boolean => { + let names: string[]; + try { + names = readdirSync(dir); + } catch { + return false; + } + for (const name of names) { + const path = join(dir, name); + if (keep.some((k) => within(path, k))) continue; // kept (or inside one) + if (keep.some((k) => within(k, path))) { + // Leads to a kept path: hide its other entries, never follow a link. + let real = false; + try { + real = lstatSync(path).isDirectory(); + } catch {} + if (!real || !walk(path)) return false; + continue; + } + out.push(path); + if (out.length > limit) return false; + } + return true; + }; + return walk(folder) ? out : null; +} diff --git a/src/daemon/workstreams/gate.ts b/src/daemon/workstreams/gate.ts new file mode 100644 index 00000000..b0684bb2 --- /dev/null +++ b/src/daemon/workstreams/gate.ts @@ -0,0 +1,122 @@ +/** + * The workstream fence at the tool gate (docs/workstreams-design.md §7.3): + * a file tool reading or searching another workstream's folder, or writing + * one or a reference (read-only), is refused — in every mode, on every + * backend, sandboxed or not. The sandbox fences commands (its hidden paths); + * this fences the file tools that run outside it and every tool of a session + * without one. + * + * Pure: the fence comes from the session (WorkstreamService.fenceFor). + */ + +import { realpathSync } from "node:fs"; +import { writeTargets } from "../held-writes.js"; +import { isSearchTool, toolPathForms, toolReadPaths } from "../secret-paths.js"; +import { within } from "./folders.js"; +import type { WorkstreamFence } from "./service.js"; + +export interface FenceRefusal { + /** The path as the call named it. */ + path: string; + /** The workstream whose folder it is. */ + workstream: string; + kind: "read" | "search" | "write" | "write_reference" | "unresolved"; +} + +function realOr(p: string): string { + try { + return realpathSync(p); + } catch { + return p; + } +} + +/** Each fenced folder in both spellings: as found and real. */ +function forms(folders: readonly string[]): string[] { + return [...new Set(folders.flatMap((f) => [f, realOr(f)]))]; +} + +/** + * The fence's verdict on one call: null when it stays out of other + * workstreams' folders (and writes no reference), else why not. + */ +interface Prepared { + readable: string[]; + foreign: Array<{ workstream: string; folders: Array<{ d: string; keep: string[] }> }>; + references: Array<{ workstream: string; folders: string[] }>; + mine: string[]; +} + +/** Each fence's folders in both spellings, worked out once per fence (the gate runs on every call). */ +const prepared = new WeakMap(); + +function prepare(fence: WorkstreamFence): Prepared { + const hit = prepared.get(fence); + if (hit) return hit; + const readable = forms(fence.readable); + const p: Prepared = { + readable, + // Each foreign folder with what is carved out of it: the readable paths inside it (never one holding it). + foreign: fence.foreign.map((f) => ({ workstream: f.workstream, folders: forms(f.folders).map((d) => ({ d, keep: readable.filter((r) => within(r, d)) })) })), + references: fence.references.map((r) => ({ workstream: r.workstream, folders: forms(r.folders) })), + // Where a write is the session's own: its home's folders and its own folder. + mine: forms([...fence.home, ...(fence.own ?? [])]), + }; + prepared.set(fence, p); + return p; +} + +export function fenceRefusal(toolName: string, input: unknown, opts: { workdir: string; cwd?: string | null; fence: WorkstreamFence }): FenceRefusal | null { + const base = typeof opts.cwd === "string" ? opts.cwd : opts.workdir; + // A relative path where the backend didn't say where it runs (null: it may have moved) can't be + // judged: refused while there is something to fence, never judged from a guess. + if (opts.cwd === null && opts.fence.foreign.length > 0) { + const writesRaw = input && typeof input === "object" ? (writeTargets(toolName, input as Record) ?? []) : []; + const relative = [...toolReadPaths(toolName, input), ...writesRaw.filter((p): p is string => typeof p === "string")].find((p) => !p.startsWith("/") && p !== "~" && !p.startsWith("~/")); + if (relative !== undefined) return { path: relative, workstream: opts.fence.workstream ?? "", kind: "unresolved" }; + } + const { foreign, references, mine } = prepare(opts.fence); + const inForeign = (p: string) => foreign.find((f) => f.folders.some(({ d, keep }) => within(p, d) && !keep.some((k) => within(p, k)))); + // Writes first: a write tool's path is also a read key, and the refusal should say it writes. + const writes = input && typeof input === "object" ? writeTargets(toolName, input as Record) : null; + for (const raw of writes ?? []) { + if (!raw) continue; + for (const p of toolPathForms(raw, base)) { + const hit = inForeign(p); + if (hit) return { path: raw, workstream: hit.workstream, kind: "write" }; + // A reference is read-only; a folder of the home's inside one is the home's. + const ref = references.find((r) => r.folders.some((d) => within(p, d))); + if (ref && !mine.some((h) => within(p, h))) return { path: raw, workstream: ref.workstream, kind: "write_reference" }; + } + } + const search = isSearchTool(toolName); + for (const raw of toolReadPaths(toolName, input)) { + for (const p of toolPathForms(raw, base)) { + const hit = inForeign(p); + if (hit) return { path: raw, workstream: hit.workstream, kind: "read" }; + // A search whose root holds a fenced folder reads into it. + if (search) { + // (A folder kept whole, a reference's, is no more fenced than any other readable one.) + const holds = foreign.find((f) => f.folders.some(({ d, keep }) => within(d, p) && !keep.some((k) => within(d, k)))); + if (holds) return { path: raw, workstream: holds.workstream, kind: "search" }; + } + } + } + return null; +} + +/** What the agent is told. */ +export function fenceRefusalText(r: FenceRefusal): string { + switch (r.kind) { + case "read": + return `${r.path} is in workstream "${r.workstream}"'s folders, which this session's workstream may not read. Ask the owner to add a reference to "${r.workstream}" if it should.`; + case "search": + return `Searching ${r.path} would read workstream "${r.workstream}"'s folders, which this session's workstream may not read: search a narrower folder.`; + case "write": + return `${r.path} is in workstream "${r.workstream}"'s folders: this session's workstream may not write there.`; + case "unresolved": + return `codeoid can't tell where ${r.path} is (a relative path, and where the call runs isn't known), so it can't be checked against other workstreams' folders: use an absolute path.`; + case "write_reference": + return `${r.path} is in workstream "${r.workstream}", which this session references read-only: write in the session's own workstream instead.`; + } +} diff --git a/src/daemon/workstreams/service.ts b/src/daemon/workstreams/service.ts index 7e2a873e..ff2b4670 100644 --- a/src/daemon/workstreams/service.ts +++ b/src/daemon/workstreams/service.ts @@ -14,6 +14,7 @@ import { createHash } from "node:crypto"; import { realpathSync } from "node:fs"; import type { SessionWorkstreamInfo, WorkstreamResolution, WorkstreamSummary } from "../../protocol/types.js"; import { readWorkstreamBinding, type WorkstreamBinding } from "./binding.js"; +import { CARVE_LIMIT, carveOut, expandPathRule, repositoryTopOf, within } from "./folders.js"; import { effectiveWorkstreams, type EffectiveWorkstream } from "./effective.js"; import { FolderReader } from "./folder.js"; import { expandHome, type MatchableWorkstream, matchFolder, normalizePathRule } from "./match.js"; @@ -22,7 +23,45 @@ import type { SecretStore } from "../credentials/store.js"; import type { CredentialDecl } from "../credentials/schema.js"; import type { GrantCredential, McpGrant } from "./grant.js"; -export { readWorkstreamBinding, type WorkstreamBinding } from "./binding.js"; +export { readWorkstreamBinding, type WorkstreamBinding, type WorkstreamReference } from "./binding.js"; + +/** + * A session's read fence (§7.3): the folders it may read (its home's, and + * its references'), and every other workstream's, which it may not. Built + * from the folders on this machine; `limits` says what couldn't be expanded. + */ +export interface WorkstreamFence { + workstream?: string; + /** The home's folders: readable and, as the sandbox allows, writable. */ + home: string[]; + /** The session's own folder (its workdir; its repository's top where no other workstream holds it). */ + own: string[]; + /** Readable only, never written. */ + references: Array<{ workstream: string; folders: string[] }>; + /** + * Every other workstream's folders: neither read nor written, but for what + * `readable` keeps inside them (a folder of another's holding the home, a + * reference's own folder, the session's workdir). + */ + foreign: Array<{ workstream: string; folders: string[] }>; + /** + * What the session may read wherever it is: its workdir and workspace, its + * home's folders, and its references' (as granted: a reference's paths, + * else its folders). Inside a foreign folder these stay readable; the + * rest of that folder doesn't. + */ + readable: string[]; + /** + * What a sandbox hides for it: each foreign folder whole, or, where + * something readable is inside one, every other entry on the way to it + * (folders.ts `carveOut`; hidden whole when that can't be done). + */ + hidden: string[]; + limits: string[]; +} + +/** What a share policy says to a read or a move: allowed, the owner asked, or refused (with why). */ +export type ShareVerdict = { ok: true; ask: string[] } | { ok: false; error: string }; /** The reserved home of the conductor (§10). */ export const CONDUCTOR_WORKSTREAM = "conductor"; @@ -43,6 +82,15 @@ export interface ResumeMetaFacts { parentId?: string; } +/** The real path, or the path itself when it can't be resolved (missing). */ +function realOr(p: string): string { + try { + return realpathSync(p); + } catch { + return p; + } +} + export class WorkstreamService { #cfg: WorkstreamsConfig; #effective: Map; @@ -149,6 +197,11 @@ export class WorkstreamService { return this.#cfg.isolateGlobalServers; } + /** Whether any workstream is `closed` (its text withheld from the conductor, under isolation). */ + get anyClosed(): boolean { + return [...this.#effective.values()].some((w) => !w.abstract && w.share === "closed"); + } + get defaultId(): string | undefined { return this.#cfg.default; } @@ -157,6 +210,194 @@ export class WorkstreamService { return this.#effective.get(id); } + /** + * A workstream's folders on this machine: the directories its path rules + * name (each spelling: as written and, for `~`, the home's real path), plus + * `recorded` (folders recorded as its home, repository tops). + */ + /** Each workstream's path-rule folders on disk (no recorded ones), once per fence. */ + #ruleFolders(): Map { + const out = new Map(); + for (const m of this.#matchables) { + if (m.abstract) continue; + const folders: string[] = []; + const limits: string[] = []; + for (const rule of m.paths) { + const e = expandPathRule(rule); + folders.push(...e.folders); + limits.push(...e.limits); + } + out.set(m.id, { folders, limits }); + } + return out; + } + + /** + * A workstream's folders on this machine: the directories its path rules + * name (each spelling: as written and, for `~`, the home's real path), plus + * `recorded` (workdirs its sessions were homed in by their folder's match), + * each as its repository's top — unless another workstream has a folder + * inside that top (a monorepo split by path rules): then only the workdir. + * Never the home or what holds it. Folders are kept as found, nested ones + * too: each is its own (a nested one may be fenced where its parent isn't). + */ + foldersOf(id: string, recorded: readonly string[] = [], rules: Map = this.#ruleFolders()): { folders: string[]; limits: string[] } { + const m = this.#matchables.find((x) => x.id === id); + if (!m || m.abstract) return { folders: [], limits: [] }; + const own = rules.get(id) ?? { folders: [], limits: [] }; + const limits: string[] = [...own.limits]; + const others = [...rules.entries()].filter(([k]) => k !== id).flatMap(([, v]) => v.folders); + const found: string[] = [...own.folders]; + for (const w of recorded) { + const top = repositoryTopOf(w, this.#reader.home); + found.push(top !== w && others.some((f) => within(f, top)) ? w : top); + } + // The home (or what holds it) is never one workstream's folder: fencing it would fence everything. + const homes = [this.#reader.home, this.#realHome()]; + const kept = found.filter((f) => { + const holds = homes.some((h) => within(h, f)); + if (holds) limits.push(`${id}: ${f} is the home or holds it, so it isn't fenced`); + return !holds; + }); + return { folders: [...new Set(kept)], limits }; + } + + /** + * The read fence of a session homed by `binding`, or undefined when + * isolation is off (nothing is fenced then, as before W3). `recorded(id)` + * gives a workstream's recorded home folders. + */ + fenceFor( + binding: WorkstreamBinding | undefined, + recorded: (id: string) => readonly string[] = () => [], + own: readonly string[] = [], + ): WorkstreamFence | undefined { + if (!this.configured || !this.#cfg.isolateGlobalServers) return undefined; + const limits: string[] = []; + const homeId = binding?.id && binding.source !== "reserved" && this.#effective.has(binding.id) ? binding.id : undefined; + const rules = this.#ruleFolders(); + const cache = new Map(); + const of = (id: string) => { + let folders = cache.get(id); + if (!folders) { + const r = this.foldersOf(id, recorded(id), rules); + limits.push(...r.limits); + folders = r.folders; + cache.set(id, folders); + } + return folders; + }; + const home = homeId ? of(homeId) : []; + const references: WorkstreamFence["references"] = []; + for (const ref of binding?.references ?? []) { + const ws = this.#effective.get(ref.workstream); + // A reference whose workstream is gone or closed now gives nothing (§8). + if (!ws || ws.abstract || ws.share === "closed" || ref.workstream === homeId) continue; + const all = of(ref.workstream); + // A granted path counts only where it really is inside that workstream's folders, as written and + // as it resolves now: a link put there later (or a `..`) never leads the grant elsewhere. + const inside = (p: string) => all.some((f) => within(p, f) || within(p, realOr(f))); + const folders = ref.paths ? ref.paths.filter((p) => inside(p) && inside(realOr(p))) : all; + // Kept with no folders here too: it still gives its memory (§7.2). + references.push({ workstream: ref.workstream, folders }); + } + // The session's own: its workdir always (a session moved, or homed past a claim, keeps its folder); + // the rest (its repository's top) only where no other workstream's folder holds it — a package of a + // monorepo another workstream holds doesn't make the whole repository its own. + const [workdir, ...extra] = own; + const othersFolders = [...this.#effective.values()].filter((w) => !w.abstract && w.id !== homeId).flatMap((w) => of(w.id)); + const ownKept = [...(workdir ? [workdir] : []), ...extra.filter((o) => !othersFolders.some((f) => within(o, f)))]; + // What stays readable even inside another workstream's folder: the session's own, its home's + // folders, and what its references grant — exactly that, never the folder holding a granted path. + const readable = [...new Set([...ownKept, ...home, ...references.flatMap((r) => r.folders)])]; + const foreign: WorkstreamFence["foreign"] = []; + for (const ws of this.#effective.values()) { + if (ws.abstract || ws.id === homeId) continue; + // Every one of its folders, nested in the home's or holding it (the + // more specific rule decides): what is readable inside one is carved + // out of it (gate.ts, the sandbox). A reference's workstream is here + // too: only what the reference grants is carved out. + const folders = of(ws.id); + if (folders.length > 0) foreign.push({ workstream: ws.id, folders }); + } + const hidden: string[] = []; + for (const d of foreign.flatMap((f) => f.folders)) { + const keep = readable.filter((r) => within(r, d)); + if (keep.length === 0) { + hidden.push(d); + continue; + } + const carved = keep.some((k) => k === d) ? [] : (carveOut(d, keep) ?? carveOut(d, keep, CARVE_LIMIT * 10)); + if (carved === null) { + limits.push(`${d}: too much to carve around what this session reads there, so it is hidden whole`); + hidden.push(d); + } else hidden.push(...carved); + } + return { ...(homeId ? { workstream: homeId } : {}), home, own: ownKept, references, foreign, readable, hidden: [...new Set(hidden)], limits: [...new Set(limits)] }; + } + + /** + * Whether a session homed in `home` may read `target` (a reference, + * §7.1): `open` yes, `ask` with its owner's confirm, `closed` never. + */ + referenceVerdict(home: string | undefined, ref: { workstream: string; paths?: readonly string[] }, recorded: readonly string[] = []): ShareVerdict { + const ws = this.#effective.get(ref.workstream); + if (!ws) return { ok: false, error: `Unknown workstream "${ref.workstream}"` }; + if (ws.abstract) return { ok: false, error: `"${ws.id}" is a base workstream: it has no folders of its own` }; + if (ws.id === home) return { ok: false, error: `"${ws.id}" is this session's own workstream` }; + if (ws.share === "closed") return { ok: false, error: `Workstream "${ws.id}" is closed: other workstreams can't read it` }; + if (ref.paths) { + const { folders } = this.foldersOf(ws.id, recorded); + const outside = ref.paths.filter((p) => !folders.some((f) => within(p, f))); + if (outside.length > 0) return { ok: false, error: `Not ${ws.id}'s folders: ${outside.join(", ")}` }; + } + return { ok: true, ask: ws.share === "ask" ? [ws.id] : [] }; + } + + /** + * Whether a session homed in `chosen` may live in `workdir` when the + * folder is another workstream's (its own match, a claim of any kind): its + * workdir stays readable to it, so that is a read of the folder's + * workstream, judged by its `share` like a reference. Undefined `folder`: + * the folder is no other workstream's. Only under isolation. + */ + folderVerdict(workdir: string, chosen: string | undefined, given?: string): ShareVerdict & { folder?: string } { + if (!this.configured || !this.#cfg.isolateGlobalServers || !chosen) return { ok: true, ask: [] }; + const folder = this.resolve(workdir, undefined, given).home; + if (!folder || folder === chosen) return { ok: true, ask: [] }; + const ws = this.#effective.get(folder); + if (!ws) return { ok: true, ask: [] }; + if (ws.share === "closed") return { ok: false, error: `This folder is workstream "${folder}"'s, which is closed: a session of another workstream can't work in it` }; + return { ok: true, ask: ws.share === "ask" ? [folder] : [], folder }; + } + + /** + * Whether a session may move to `target` (§5.3): a move carries its whole + * transcript, so it is a share from the old home and from every workstream + * it has read from. From no home or the default workstream it is always + * allowed. + */ + moveVerdict(binding: WorkstreamBinding | undefined, target: string): ShareVerdict { + const problem = this.choiceProblem(target); + if (problem) return { ok: false, error: problem }; + if (binding?.source === "reserved") return { ok: false, error: "The conductor's home can't be changed" }; + if (binding?.sealed) return { ok: false, error: "This session's workstream record couldn't be read back, so what it read from is unknown: it can't move to another workstream" }; + const from = new Set(binding?.readFrom ?? []); + if (binding?.id && binding.id !== this.#cfg.default) from.add(binding.id); + from.delete(target); + const ask: string[] = []; + for (const id of [...from].sort()) { + const ws = this.#effective.get(id); + // A workstream since deleted has no policy left to keep. + if (!ws) continue; + if (ws.share === "closed") { + return { ok: false, error: id === binding?.id ? `Workstream "${id}" is closed: its sessions can't move to another` : `This session read from "${id}", which is closed: it can't move to another workstream` }; + } + if (ws.share === "ask") ask.push(id); + } + return { ok: true, ask }; + } + /** Why `id` can't be chosen as a home, or undefined when it can. */ choiceProblem(id: string): string | undefined { const ws = this.#effective.get(id); @@ -375,7 +616,8 @@ export class WorkstreamService { parsed.source === "reserved" || parsed.id === CONDUCTOR_WORKSTREAM || (parsed.id !== undefined && this.#effective.get(parsed.id)?.abstract === true); - result = bad ? { binding: none(), persist: true } : { binding: parsed, persist: false }; + // Unreadable: no home, and sealed (what it read from is unknown, so it never moves). + result = bad ? { binding: { ...none(), sealed: true }, persist: true } : { binding: parsed, persist: false }; } else { const parent = m.parentId && depth < 32 ? byId.get(m.parentId) : undefined; const inherited = parent ? decide(parent, depth + 1).binding : undefined; @@ -442,7 +684,11 @@ export class WorkstreamService { */ info(binding: WorkstreamBinding | undefined): SessionWorkstreamInfo | undefined { if (!binding || !this.configured) return undefined; - if (binding.id === undefined) return { source: binding.source }; + const reads = { + ...(binding.references?.length ? { references: binding.references.map((r) => ({ workstream: r.workstream, ...(r.paths ? { paths: [...r.paths] } : {}) })) } : {}), + ...(binding.readFrom?.length ? { readFrom: [...binding.readFrom] } : {}), + }; + if (binding.id === undefined) return { source: binding.source, ...reads }; if (binding.id === CONDUCTOR_WORKSTREAM && binding.source === "reserved") { return { id: CONDUCTOR_WORKSTREAM, name: "Conductor", source: "reserved" }; } @@ -452,6 +698,7 @@ export class WorkstreamService { ...(ws ? { name: ws.name, ...(ws.color ? { color: ws.color } : {}) } : { orphaned: true }), source: binding.source, ...(binding.matchedBy ? { matchedBy: binding.matchedBy } : {}), + ...reads, }; } } diff --git a/src/daemon/workstreams/workstreams.test.ts b/src/daemon/workstreams/workstreams.test.ts index 3fb55231..577e86e4 100644 --- a/src/daemon/workstreams/workstreams.test.ts +++ b/src/daemon/workstreams/workstreams.test.ts @@ -9,6 +9,8 @@ import { ancestry, expandHome, matchFolder, normalizePathRule, normalizeRemote, import { workstreamsSchema } from "./schema.js"; import { WorkstreamService } from "./service.js"; import { proposeWorkstreams } from "./import.js"; +import { expandPathRule, repositoryTopOf } from "./folders.js"; +import { fenceRefusal } from "./gate.js"; import { EnvFileSecretStore } from "../credentials/store.js"; const ws = (id: string, over: Partial = {}): MatchableWorkstream => ({ @@ -552,3 +554,232 @@ describe("proposeWorkstreams (codeoid workstream import)", () => { }); }); + +describe("W3: folders, the fence, share verdicts", () => { + const service = (items: unknown[], extra: Record = {}) => + new WorkstreamService(workstreamsSchema.parse({ isolateGlobalServers: true, items, ...extra }), new FolderReader({ home: tmp, sshConfig: "" })); + + test("a glob rule expands to the folders it names, never its literal head; a link is never followed", () => { + for (const d of ["W/acme-api", "W/acme-web", "W/other", "elsewhere"]) mkdirSync(join(tmp, d), { recursive: true }); + writeFileSync(join(tmp, "W", "acme-file"), ""); + symlinkSync(join(tmp, "elsewhere"), join(tmp, "W", "acme-link")); + expect(expandPathRule(join(tmp, "W", "acme-*")).folders.sort()).toEqual([join(tmp, "W", "acme-api"), join(tmp, "W", "acme-web")]); + expect(expandPathRule(join(tmp, "W", "missing")).folders).toEqual([]); + expect(expandPathRule(join(tmp, "W", "**", "x")).limits[0]).toContain("**"); + expect(expandPathRule(join(tmp, "W", "*"), 1).limits[0]).toContain("more than 1"); + }); + + test("the fence: every other workstream's folders are foreign; what is readable inside one is carved out", () => { + for (const d of ["W/acme-api/src", "W/acme-web", "W/hf", "W/mine/inner", "W/stray"]) mkdirSync(join(tmp, d), { recursive: true }); + writeFileSync(join(tmp, "W", "acme-api", ".env"), "x"); + const s = service([ + { id: "acme", share: "ask", match: { paths: ["~/W/acme-*"] } }, + { id: "hf", share: "closed", match: { paths: ["~/W/hf"] } }, + { id: "mine", share: "open", match: { paths: ["~/W/mine"] } }, + { id: "broad", match: { paths: ["~/W"] } }, + ]); + const src = join(tmp, "W", "acme-api", "src"); + const fence = s.fenceFor({ id: "mine", source: "matched", revision: "r", references: [{ workstream: "acme", paths: [src], grantedBy: "u", grantedAt: "t" }] }, () => [], [join(tmp, "W", "mine")])!; + expect(fence.home).toEqual([join(tmp, "W", "mine")]); + expect(fence.references).toEqual([{ workstream: "acme", folders: [src] }]); + expect(fence.readable.sort()).toEqual([join(tmp, "W", "mine"), src].sort()); + // acme's folder holding the granted path is foreign still (only `src` is carved out), and `broad`, + // which holds the home, is foreign with the home carved out of it. + expect(fence.foreign.map((f) => f.workstream).sort()).toEqual(["acme", "broad", "hf"]); + expect(fence.hidden).toContain(join(tmp, "W", "acme-web")); + expect(fence.hidden).toContain(join(tmp, "W", "hf")); + expect(fence.hidden).toContain(join(tmp, "W", "acme-api", ".env")); + expect(fence.hidden).toContain(join(tmp, "W", "stray")); + expect(fence.hidden).not.toContain(src); + expect(fence.hidden).not.toContain(join(tmp, "W", "mine")); + // A catch-all home: every narrower workstream inside it is fenced (the more specific rule decides). + const wide = s.fenceFor({ id: "broad", source: "matched", revision: "r" }, () => [], [join(tmp, "W")])!; + expect(wide.foreign.find((f) => f.workstream === "hf")?.folders).toEqual([join(tmp, "W", "hf")]); + expect(fenceRefusal("Read", { file_path: join(tmp, "W", "hf", "x") }, { workdir: join(tmp, "W"), fence: wide })).toMatchObject({ workstream: "hf" }); + // A closed workstream's reference gives nothing, even one granted before. + const closed = s.fenceFor({ id: "mine", source: "matched", revision: "r", references: [{ workstream: "hf", grantedBy: "u", grantedAt: "t" }] })!; + expect(closed.references).toEqual([]); + // Recorded homes (a remote-rule workstream) count as its folders. + expect(s.fenceFor(undefined, (id) => (id === "hf" ? [join(tmp, "W", "hf"), "/srv/hf-clone"] : []))!.foreign.find((f) => f.workstream === "hf")?.folders.sort()).toEqual(["/srv/hf-clone", join(tmp, "W", "hf")].sort()); + // Isolation off: no fence. + expect(new WorkstreamService(workstreamsSchema.parse({ items: [{ id: "a" }] }), new FolderReader({ home: tmp, sshConfig: "" })).fenceFor(undefined)).toBeUndefined(); + }); + + test("a session's own workdir stays readable whatever its home (moved, or homed past a claim)", () => { + mkdirSync(join(tmp, "W", "mine"), { recursive: true }); + const s = service([{ id: "mine", match: { paths: ["~/W/mine"] } }, { id: "pub" }]); + const fence = s.fenceFor({ id: "pub", source: "explicit", revision: "r" }, () => [], [join(tmp, "W", "mine")])!; + expect(fenceRefusal("Read", { file_path: join(tmp, "W", "mine", "own.ts") }, { workdir: join(tmp, "W", "mine"), fence })).toBeNull(); + expect(fenceRefusal("Grep", { pattern: "x" }, { workdir: join(tmp, "W", "mine"), fence })).toBeNull(); + }); + + test("the home is never a workstream's folder, nor a repository top for one (a dotfiles home)", () => { + mkdirSync(join(tmp, ".git"), { recursive: true }); + mkdirSync(join(tmp, "plain", "pkg"), { recursive: true }); + expect(repositoryTopOf(join(tmp, "plain", "pkg"), tmp)).toBe(join(tmp, "plain", "pkg")); + const s = service([{ id: "all", match: { paths: ["~"] } }, { id: "mine" }]); + expect(s.foldersOf("all").folders).toEqual([]); + expect(s.foldersOf("all").limits[0]).toContain("home"); + }); + + test("a malformed stored binding seals the session: no home, and never moved", () => { + const s = service([{ id: "a" }, { id: "b" }]); + const r = s.forResume([{ sessionId: "x", workdir: tmp, stored: { id: "a", source: "matched", revision: "r", references: "bad" } }])("x"); + expect(r.binding).toMatchObject({ source: "none", sealed: true }); + expect(s.moveVerdict(r.binding, "b")).toMatchObject({ ok: false }); + expect(readWorkstreamBinding(r.binding)).toMatchObject({ sealed: true }); + }); + + test("reference verdicts follow the share policy of the workstream read", () => { + mkdirSync(join(tmp, "W", "acme-api"), { recursive: true }); + const s = service([ + { id: "acme", share: "ask", match: { paths: ["~/W/acme-*"] } }, + { id: "hf", share: "closed" }, + { id: "pub", share: "open" }, + { id: "base", abstract: true }, + ]); + expect(s.referenceVerdict("pub", { workstream: "acme" })).toEqual({ ok: true, ask: ["acme"] }); + expect(s.referenceVerdict("acme", { workstream: "pub" })).toEqual({ ok: true, ask: [] }); + expect(s.referenceVerdict("pub", { workstream: "hf" })).toMatchObject({ ok: false }); + expect(s.referenceVerdict("pub", { workstream: "base" })).toMatchObject({ ok: false }); + expect(s.referenceVerdict("pub", { workstream: "pub" })).toMatchObject({ ok: false }); + expect(s.referenceVerdict("pub", { workstream: "nope" })).toMatchObject({ ok: false }); + expect(s.referenceVerdict("pub", { workstream: "acme", paths: [join(tmp, "W", "acme-api", "src")] })).toEqual({ ok: true, ask: ["acme"] }); + expect(s.referenceVerdict("pub", { workstream: "acme", paths: ["/etc"] })).toMatchObject({ ok: false }); + }); + + test("move verdicts: the old home's policy and every workstream read from; from the default, always", () => { + const s = service([{ id: "acme", share: "ask" }, { id: "hf", share: "closed" }, { id: "pub", share: "open" }, { id: "scratch", share: "closed" }], { default: "scratch" }); + const b = (id: string, readFrom?: string[]) => ({ id, source: "matched" as const, revision: "r", ...(readFrom ? { readFrom } : {}) }); + expect(s.moveVerdict(b("pub"), "acme")).toEqual({ ok: true, ask: [] }); + expect(s.moveVerdict(b("acme"), "pub")).toEqual({ ok: true, ask: ["acme"] }); + expect(s.moveVerdict(b("hf"), "pub")).toMatchObject({ ok: false }); + expect(s.moveVerdict(b("pub", ["hf"]), "acme")).toMatchObject({ ok: false }); + expect(s.moveVerdict(b("pub", ["hf"]), "hf")).toEqual({ ok: true, ask: [] }); + expect(s.moveVerdict(b("scratch"), "acme")).toEqual({ ok: true, ask: [] }); + expect(s.moveVerdict(undefined, "acme")).toEqual({ ok: true, ask: [] }); + expect(s.moveVerdict({ id: "conductor", source: "reserved", revision: "r" }, "pub")).toMatchObject({ ok: false }); + }); + + test("a binding's references and read-from record round-trip; a malformed one fails the binding", () => { + const good = { id: "a", source: "matched" as const, revision: "r", references: [{ workstream: "b", paths: ["/x"], grantedBy: "u", grantedAt: "t" }], readFrom: ["b", "b"] }; + expect(readWorkstreamBinding(good)).toEqual({ ...good, readFrom: ["b"] }); + expect(readWorkstreamBinding({ ...good, references: [{ workstream: "B!", grantedBy: "u", grantedAt: "t" }] })).toBeUndefined(); + expect(readWorkstreamBinding({ ...good, references: [{ workstream: "b", paths: ["rel"], grantedBy: "u", grantedAt: "t" }] })).toBeUndefined(); + expect(readWorkstreamBinding({ ...good, readFrom: ["ok", 3] })).toBeUndefined(); + }); +}); + +describe("W3: the fence at the gate", () => { + const fence = () => ({ + workstream: "mine", + home: [join(tmp, "mine")], + references: [{ workstream: "pub", folders: [join(tmp, "pub")] }], + foreign: [ + { workstream: "hf", folders: [join(tmp, "hf")] }, + { workstream: "nested", folders: [join(tmp, "mine", "vendor-x")] }, + { workstream: "pub", folders: [join(tmp, "pub")] }, + ], + readable: [join(tmp, "mine"), join(tmp, "pub")], + own: [join(tmp, "mine")], + hidden: [], + limits: [], + }); + beforeEach(() => { + for (const d of ["mine/vendor-x", "pub", "hf", "elsewhere"]) mkdirSync(join(tmp, d), { recursive: true }); + }); + const judge = (tool: string, input: Record, cwd?: string) => fenceRefusal(tool, input, { workdir: join(tmp, "mine"), ...(cwd ? { cwd } : {}), fence: fence() }); + + test("reads and writes in another workstream's folders are refused; the home and elsewhere aren't", () => { + expect(judge("Read", { file_path: join(tmp, "hf", "secret.ts") })).toMatchObject({ workstream: "hf", kind: "read" }); + expect(judge("Read", { file_path: join(tmp, "mine", "a.ts") })).toBeNull(); + expect(judge("Read", { file_path: "/etc/hosts" })).toBeNull(); + expect(judge("Read", { file_path: "vendor-x/x.ts" })).toMatchObject({ workstream: "nested" }); + expect(judge("Read", { file_path: "../hf/x" })).toMatchObject({ workstream: "hf" }); + expect(judge("Write", { file_path: join(tmp, "hf", "x") })).toMatchObject({ kind: "write" }); + expect(judge("write_file", { file_path: join(tmp, "hf", "x") })).toMatchObject({ kind: "write" }); + }); + + test("a reference is read, never written", () => { + expect(judge("Read", { file_path: join(tmp, "pub", "x") })).toBeNull(); + expect(judge("Edit", { file_path: join(tmp, "pub", "x") })).toMatchObject({ workstream: "pub", kind: "write_reference" }); + }); + + test("a search whose root holds a fenced folder reads into it", () => { + expect(judge("Grep", { path: tmp, pattern: "x" })).toMatchObject({ workstream: "hf", kind: "search" }); + expect(judge("Grep", { pattern: "x" })).toMatchObject({ workstream: "nested", kind: "search" }); + expect(judge("Grep", { path: join(tmp, "pub"), pattern: "x" })).toBeNull(); + expect(judge("Glob", { pattern: `${join(tmp, "hf")}/**/*.ts` })).toMatchObject({ workstream: "hf" }); + }); + + test("what other backends name is judged too: a glob's partial segment, ACP locations, pi's @ and find/ls", () => { + expect(judge("Glob", { pattern: `${join(tmp, "h")}?/*` })).toMatchObject({ workstream: "hf" }); + expect(judge("Read", { title: "read", locations: [join(tmp, "hf", "secret.ts")] })).toMatchObject({ workstream: "hf" }); + expect(judge("Read", { locations: [{ path: join(tmp, "hf", "secret.ts") }] })).toMatchObject({ workstream: "hf" }); + expect(judge("read", { path: `@${join(tmp, "hf", "secret.ts")}` })).toMatchObject({ workstream: "hf" }); + expect(judge("find", { path: tmp })).toMatchObject({ kind: "search" }); + expect(judge("ls", { path: tmp })).toMatchObject({ kind: "search" }); + }); + + test("a relative path where the cwd isn't known is refused, never guessed", () => { + expect(fenceRefusal("Read", { file_path: "x.ts" }, { workdir: join(tmp, "mine"), cwd: null, fence: fence() })).toMatchObject({ kind: "unresolved" }); + expect(fenceRefusal("Read", { file_path: join(tmp, "mine", "x.ts") }, { workdir: join(tmp, "mine"), cwd: null, fence: fence() })).toBeNull(); + }); + + test("a link into a fenced folder is judged where it leads", () => { + symlinkSync(join(tmp, "hf"), join(tmp, "mine", "link")); + expect(judge("Read", { file_path: join(tmp, "mine", "link", "x") })).toMatchObject({ workstream: "hf" }); + expect(judge("Read", { file_path: "x" }, join(tmp, "hf"))).toMatchObject({ workstream: "hf" }); + }); +}); + +describe("W3 audit round 2: monorepos and granted paths", () => { + const service = (items: unknown[]) => + new WorkstreamService(workstreamsSchema.parse({ isolateGlobalServers: true, items }), new FolderReader({ home: tmp, sshConfig: "" })); + + test("a session in one workstream's package of another's monorepo reads its package, not the repository", () => { + for (const d of ["W/mono/.git", "W/mono/sdk", "W/mono/core"]) mkdirSync(join(tmp, d), { recursive: true }); + writeFileSync(join(tmp, "W", "mono", "core", "x.ts"), "x"); + const s = service([ + { id: "a", share: "closed", claim: "exclusive", match: { paths: ["~/W/mono"] } }, + { id: "b", claim: "exclusive", match: { paths: ["~/W/mono/sdk"] } }, + ]); + const sdk = join(tmp, "W", "mono", "sdk"); + const fence = s.fenceFor({ id: "b", source: "matched", revision: "r" }, () => [], [sdk, repositoryTopOf(sdk, tmp)])!; + expect(fence.own).toEqual([sdk]); + expect(fenceRefusal("Read", { file_path: join(tmp, "W", "mono", "core", "x.ts") }, { workdir: sdk, fence })).toMatchObject({ workstream: "a" }); + expect(fenceRefusal("Write", { file_path: join(tmp, "W", "mono", "core", "x.ts") }, { workdir: sdk, fence })).toMatchObject({ kind: "write" }); + expect(fenceRefusal("Read", { file_path: join(sdk, "y.ts") }, { workdir: sdk, fence })).toBeNull(); + expect(fence.hidden).toContain(join(tmp, "W", "mono", "core")); + expect(fence.hidden).not.toContain(sdk); + }); + + test("a recorded session in a monorepo another workstream splits by path stays its package, never the top", () => { + for (const d of ["W/mono/.git", "W/mono/a", "W/mono/b"]) mkdirSync(join(tmp, d), { recursive: true }); + const s = service([ + { id: "a", share: "closed", match: { paths: ["~/W/mono/a"] } }, + { id: "b", match: { paths: ["~/W/mono/b"] } }, + ]); + expect(s.foldersOf("a", [join(tmp, "W", "mono", "a")]).folders).toEqual([join(tmp, "W", "mono", "a")]); + const b = join(tmp, "W", "mono", "b"); + const fence = s.fenceFor({ id: "b", source: "matched", revision: "r" }, (id) => (id === "a" ? [join(tmp, "W", "mono", "a")] : []), [b, repositoryTopOf(b, tmp)])!; + expect(fenceRefusal("Read", { file_path: join(tmp, "W", "mono", "a", "k") }, { workdir: b, fence })).toMatchObject({ workstream: "a" }); + }); + + test("a granted path counts only where it really is: no `..`, no link into another workstream", () => { + for (const d of ["W/b", "W/c/s"]) mkdirSync(join(tmp, d), { recursive: true }); + writeFileSync(join(tmp, "W", "c", "s", "key.txt"), "k"); + symlinkSync(join(tmp, "W", "c"), join(tmp, "W", "b", "future")); + const s = service([ + { id: "home", match: { paths: ["~/W/home"] } }, + { id: "b", share: "open", match: { paths: ["~/W/b"] } }, + { id: "c", share: "closed", match: { paths: ["~/W/c"] } }, + ]); + const ref = (p: string) => ({ id: "home", source: "explicit" as const, revision: "r", references: [{ workstream: "b", paths: [p], grantedBy: "u", grantedAt: "t" }] }); + for (const p of [join(tmp, "W", "b", "future"), `${join(tmp, "W", "b")}/../c/s`]) { + const fence = s.fenceFor(ref(p))!; + expect(fence.references[0]?.folders).toEqual([]); + expect(fenceRefusal("Read", { file_path: join(tmp, "W", "c", "s", "key.txt") }, { workdir: tmp, fence })).toMatchObject({ workstream: "c" }); + } + }); +}); diff --git a/src/frontends/telegram/index.ts b/src/frontends/telegram/index.ts index 6f0dfc1b..9adedea2 100644 --- a/src/frontends/telegram/index.ts +++ b/src/frontends/telegram/index.ts @@ -1342,6 +1342,9 @@ export class TelegramFrontend implements Frontend { "collaborationCost" in tool.state && tool.state.collaborationCost ? `\n◇ ${formatCollaborationCost(tool.state.collaborationCost)}` : ""; + // The workstream it acts in (workstreams §10): an approval is never made without knowing which line of work. + const tw = "targetWorkstream" in tool.state ? tool.state.targetWorkstream : undefined; + const workstream = tw ? `${tw.workstream ? `\n⌂ workstream ${tw.workstream}` : ""}${tw.warning ? `\n⚠ ${tw.warning}` : ""}` : ""; // A shell command's risk (#431), in the same words as every other surface. const riskText = "risk" in tool.state ? formatToolRisk(tool.state.risk) : ""; const risk = riskText ? `\n▲ ${riskText}` : ""; @@ -1354,7 +1357,7 @@ export class TelegramFrontend implements Frontend { this.#bot.api .sendMessage( chatId, - `⚠️ Permission needed — ${tool.name}\n${String(desc).slice(0, 800)}${risk}${sandbox}${cost}`, + `⚠️ Permission needed — ${tool.name}\n${String(desc).slice(0, 800)}${workstream}${risk}${sandbox}${cost}`, { reply_markup: kb } as Record, ) .catch(() => {}); diff --git a/src/terminal/client.ts b/src/terminal/client.ts index b6e39106..20fd9492 100644 --- a/src/terminal/client.ts +++ b/src/terminal/client.ts @@ -158,7 +158,7 @@ export function renderStreamMessage(msg: DaemonMessage, state: StreamRenderState } case "session.message": { - const sm = msg as { role?: string; content?: string; messageId?: string; tool?: { name?: string; state?: { phase?: string; approvalId?: string; description?: string; risk?: ToolRisk; sandboxed?: boolean; sandboxNote?: string; sandboxDocker?: boolean } }; identity?: { name?: string; type?: string } }; + const sm = msg as { role?: string; content?: string; messageId?: string; tool?: { name?: string; state?: { phase?: string; approvalId?: string; description?: string; risk?: ToolRisk; sandboxed?: boolean; sandboxNote?: string; sandboxDocker?: boolean; targetWorkstream?: { workstream?: string; warning?: string } } }; identity?: { name?: string; type?: string } }; const id = sm.identity?.name ? `${DIM}${S(sm.identity.name)}${RESET} ` : ""; switch (sm.role) { case "user": @@ -189,7 +189,9 @@ export function renderStreamMessage(msg: DaemonMessage, state: StreamRenderState state.lastPrompt = "tool"; const risk = formatToolRisk(sm.tool?.state?.risk); const sandbox = approvalSandboxLine(sm.tool?.state ?? {}); - return `\n${id}${RED}⚡ ${name}: ${S(sm.tool?.state?.description)}${RESET}\n${risk ? ` ${RED}▲ ${S(risk)}${RESET}\n` : ""}${sandbox ? ` ${S(sandbox)}\n` : ""} Type 'yes' to approve, 'no' to deny\n`; + const tw = sm.tool?.state?.targetWorkstream; + const ws = `${tw?.workstream ? ` ⌂ workstream ${S(tw.workstream)}\n` : ""}${tw?.warning ? ` ${YELLOW}⚠ ${S(tw.warning)}${RESET}\n` : ""}`; + return `\n${id}${RED}⚡ ${name}: ${S(sm.tool?.state?.description)}${RESET}\n${ws}${risk ? ` ${RED}▲ ${S(risk)}${RESET}\n` : ""}${sandbox ? ` ${S(sandbox)}\n` : ""} Type 'yes' to approve, 'no' to deny\n`; } return `\n${id}${YELLOW}⚡ ${name} [${phase}]${RESET}\n${reshow}`; } @@ -483,6 +485,38 @@ export class TerminalClient { if (line) console.log(` now: ${line}`); } + /** + * Change what workstreams a session reads, or move it to another + * (workstreams §5.3, §7): `session.add_reference` / `remove_reference` / + * `set_workstream`. An `ask` workstream may be asked of the owner on the session. + */ + async sessionWorkstream( + sessionIdOrName: string, + change: { kind: "add_reference"; workstream: string; paths?: string[] } | { kind: "remove_reference"; workstream: string } | { kind: "set_workstream"; workstream: string }, + ): Promise { + const sessionId = await this.#resolveSession(sessionIdOrName); + if (!sessionId) return; + const base = { id: randomUUID(), sessionId, workstream: change.workstream }; + const resp = await this.#request( + change.kind === "add_reference" + ? { type: "session.add_reference", ...base, ...(change.paths?.length ? { paths: change.paths } : {}) } + : change.kind === "remove_reference" + ? { type: "session.remove_reference", ...base } + : { type: "session.set_workstream", ...base }, + ); + if (resp.type !== "response.ok") { + this.#printError(resp); + return; + } + const data = resp.data as { pending?: boolean; message?: string; workstream?: SessionInfo["workstream"] }; + if (data.pending) console.log(`Asked on the session: it applies once its owner says yes (codeoid approve ${S(sessionIdOrName)}, or the web UI), from its next turn.`); + else console.log(change.kind === "set_workstream" ? "Moved: its servers, credentials and sandbox follow from its next turn." : "Done, from its next turn."); + const ws = workstreamSummary(data.workstream); + if (ws) console.log(` ${sanitizeTerminalOutput(ws)}`); + const refs = data.workstream?.references ?? []; + if (refs.length > 0) console.log(` reads: ${sanitizeTerminalOutput(refs.map((r) => `${r.workstream}${r.paths ? ` (${r.paths.join(", ")})` : ""}`).join("; "))}`); + } + /** The daemon's sandbox providers and whether each runs here (#448). */ async sandboxProviders(): Promise { const resp = await this.#request({ type: "sandbox.providers", id: randomUUID() }); @@ -521,6 +555,8 @@ export class TerminalClient { sandbox?: SessionSandboxSetting; /** Home workstream (docs/workstreams-design.md); absent = the folder's. */ workstream?: string; + /** Other workstreams it may read (§7.2). */ + references?: Array<{ workstream: string; paths?: string[] }>; /** * Asked when the choice conflicts with the folder's exclusive workstream * and may be overridden (G3): shown what would change; true = confirm. @@ -549,6 +585,7 @@ export class TerminalClient { ...(opts.sandbox ? { sandbox: opts.sandbox } : {}), ...(opts.workstream ? { workstream: opts.workstream } : {}), ...(opts.overrideToken ? { override: { token: opts.overrideToken } } : {}), + ...(opts.references?.length ? { references: opts.references } : {}), }); const conflict = resp.type === "response.error" && resp.detail?.kind === "workstream_conflict" ? resp.detail.resolution.conflict : undefined; @@ -1293,6 +1330,9 @@ export class TerminalClient { if (risk) console.log(`▲ ${oneLine(risk)}`); const sandbox = approvalSandboxLine(pending); if (sandbox) console.log(oneLine(sandbox)); + // The workstream it acts in, and what approving confirms (workstreams §10), as the card says. + if (pending.targetWorkstream?.workstream) console.log(`⌂ workstream ${oneLine(pending.targetWorkstream.workstream)}`); + if (pending.targetWorkstream?.warning) console.log(`⚠ ${oneLine(pending.targetWorkstream.warning)}`); const shown = plainText(pending.input === undefined ? "" : JSON.stringify(pending.input, null, 2) ?? ""); const cut = shown.length > APPROVE_SHOW_MAX; console.log(cut ? shown.slice(0, APPROVE_SHOW_MAX) : shown); diff --git a/src/tests/collaboration.test.ts b/src/tests/collaboration.test.ts index ba9e0471..7dd8a22f 100644 --- a/src/tests/collaboration.test.ts +++ b/src/tests/collaboration.test.ts @@ -2711,7 +2711,7 @@ describe("the orchestrator's fleet surface is scoped to its own children", () => // because the shared harness injects no engine at all — a mutation that // handed `this.#memory` straight to the orchestrator went undetected until // this test was written this way. - const stub = { marker: "memory-engine" } as unknown as MemoryEngine; + const stub = { marker: "memory-engine", scopeSession: () => () => {} } as unknown as MemoryEngine; manager.setMemory(stub); const mine = await createGoal("sf5"); diff --git a/src/tests/fleet.test.ts b/src/tests/fleet.test.ts index 9c1b58e2..fac1c9de 100644 --- a/src/tests/fleet.test.ts +++ b/src/tests/fleet.test.ts @@ -470,3 +470,99 @@ describe("fleet handlers — send-class (P4, post-approval)", () => { expect(await handlers.fleet_tasks({})).toBe("Dispatch is disabled on this daemon."); }); }); + +describe("fleet handlers — workstreams (W4, §10)", () => { + /** authz-fix is in `hf` (closed), migration-work in `pub` (open). */ + const wsFleet = () => + fleet().map((s) => (s.id === "sess-authz-0001" ? { ...s, workstream: "hf" } : s.id === "sess-migr-0002" ? { ...s, workstream: "pub" } : { ...s, workstream: "conductor" })); + const withheld = (ref: { workstream?: string; sessionId?: string }) => (ref.workstream ?? (ref.sessionId === "sess-authz-0001" ? "hf" : undefined)) === "hf"; + beforeEach(() => { + memory.ingest({ + workspaceId: "ws-authz", + sessionId: "sess-authz-0001", + kind: "assistant_turn", + summary: "the authz secret customer detail", + content: "the authz secret customer detail in the closed workstream", + filePaths: [], + tokenEstimate: 10, + createdAt: Date.now() - 1000, + createdBy: "user:owner", + workstreamId: "hf", + }); + memory.ingest({ + workspaceId: "ws-admin", + sessionId: "sess-migr-0002", + kind: "assistant_turn", + summary: "the authz migration note in pub", + content: "the authz migration note in the open workstream", + filePaths: [], + tokenEstimate: 10, + createdAt: Date.now() - 500, + createdBy: "user:owner", + workstreamId: "pub", + }); + }); + + test("a closed workstream's text never reaches the conductor; status does, and results name workstreams", async () => { + const h = createFleetHandlers(makeDeps({ listSessions: wsFleet, textWithheld: withheld })); + const list = await h.fleet_list(); + expect(list).toContain("workstream=hf"); + // A closed workstream's session never comes back from a query: that it matched is text too. + const found = await h.fleet_find({ query: "authz secret customer" }); + expect(found).not.toContain("authz-fix"); + expect(found).not.toContain("secret customer detail"); + expect(found).toContain("[pub]"); + const summary = await h.fleet_summary({ session: "authz-fix" }); + expect(summary).toContain("idle"); + expect(summary).not.toContain("secret customer"); + const recalled = await h.fleet_recall({ query: "authz secret customer migration" }); + expect(recalled).not.toContain("secret customer detail"); + expect(recalled).not.toContain("hf"); + expect(recalled).toContain("· pub"); + }); + + test("an untagged episode (from before workstreams) is judged by its session: a closed one's never shows", async () => { + memory.ingest({ + workspaceId: "ws-authz", + sessionId: "sess-authz-0001", + kind: "assistant_turn", + summary: "pre-workstreams zebra secret", + content: "pre-workstreams zebra secret", + filePaths: [], + tokenEstimate: 5, + createdAt: Date.now() - 100, + createdBy: "user:owner", + }); + const h = createFleetHandlers(makeDeps({ listSessions: wsFleet, textWithheld: withheld })); + expect(await h.fleet_recall({ query: "zebra secret" })).not.toContain("zebra"); + expect(await h.fleet_summary({ session: "authz-fix" })).not.toContain("zebra"); + }); + + test("a workstream filter on list, find and recall", async () => { + const h = createFleetHandlers(makeDeps({ listSessions: wsFleet, textWithheld: withheld })); + expect(await h.fleet_list({ workstream: "pub" })).not.toContain("authz-fix"); + expect(await h.fleet_find({ query: "authz", workstream: "pub" })).not.toContain("authz-fix"); + expect(await h.fleet_recall({ query: "authz migration", workstream: "pub" })).not.toContain("[authz-fix"); + }); + + test("fleet_spawn is homed where checkWorkstream says, and a refusal reaches the conductor before anything is queued", async () => { + const enqueued: Array> = []; + const dispatch: FleetDispatchDeps = { + enqueue: (input) => { + enqueued.push(input); + return "task-ws-0001"; + }, + interrupt: async () => {}, + checkWorkdir: (p) => p, + resolveBackend: (provider, model) => ({ ok: true, provider, model }), + listTasks: () => [], + checkWorkstream: (_w, ws) => (ws === "nope" ? { ok: false, error: "Unknown workstream" } : ws === "acme" ? { ok: true, workstream: "acme", override: true } : { ok: true, workstream: "pub" }), + }; + const h = createFleetHandlers(makeDeps({ dispatch })); + expect(await h.fleet_spawn({ workdir: "/ok/x", task: "t", workstream: "nope" })).toContain("Unknown workstream"); + expect(enqueued).toEqual([]); + expect(await h.fleet_spawn({ workdir: "/ok/x", task: "t", workstream: "acme" })).toContain("homed in workstream acme (past the folder's own"); + expect(enqueued[0]).toMatchObject({ workstream: "acme", workstreamOverride: true }); + expect(await h.fleet_spawn({ workdir: "/ok/x", task: "t" })).toContain("homed in workstream pub"); + }); +}); diff --git a/src/tests/sandbox-workstream-fence.test.ts b/src/tests/sandbox-workstream-fence.test.ts new file mode 100644 index 00000000..2bbc8165 --- /dev/null +++ b/src/tests/sandbox-workstream-fence.test.ts @@ -0,0 +1,74 @@ +/** + * The workstream fence in the sandbox spec (workstreams §7.3): another + * workstream's folder under a shown root is hidden, and a root inside one + * isn't shown, even when the session's own setting names it. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { buildSandboxSpec } from "../daemon/sandbox/policy.js"; + +let base: string; +beforeEach(() => { + const parent = process.env.CODEOID_TEST_SANDBOX_DIR ?? join(homedir(), ".cache"); + mkdirSync(parent, { recursive: true }); + base = realpathSync(mkdtempSync(join(parent, "codeoid-ws-fence-"))); +}); +afterEach(() => rmSync(base, { recursive: true, force: true })); + +describe("extraHidden", () => { + test("another workstream's folder under a shown root is hidden; a root inside one is dropped", () => { + const home = join(base, "home"); + const root = join(home, "Workspace"); + const mine = join(root, "mine"); + const theirs = join(root, "theirs"); + const inside = join(theirs, "pkg"); + for (const d of [mine, inside]) mkdirSync(d, { recursive: true }); + const tmp = join(base, "tmp"); + mkdirSync(tmp); + const spec = buildSandboxSpec({ + sessionId: "s", + workspace: mine, + tempDir: tmp, + network: { mode: "off" }, + hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" }, + projectRoots: [root, inside], + extraHidden: [theirs], + }); + expect(spec.hidden).toContain(theirs); + expect(spec.reexposed).toContain(root); + expect(spec.reexposed).not.toContain(inside); + // Without the fence, as before. + const open = buildSandboxSpec({ + sessionId: "s", + workspace: mine, + tempDir: tmp, + network: { mode: "off" }, + hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" }, + projectRoots: [root, inside], + }); + expect(open.hidden).not.toContain(theirs); + expect(open.reexposed).toContain(inside); + }); + + test("an added dir inside another workstream's folder isn't kept: adding it can't open the fence", () => { + const home = join(base, "home"); + const mine = join(home, "mine"); + const theirs = join(home, "theirs"); + for (const d of [mine, join(theirs, "sub")]) mkdirSync(d, { recursive: true }); + const tmp = join(base, "tmp"); + mkdirSync(tmp); + const spec = buildSandboxSpec({ + sessionId: "s", + workspace: mine, + tempDir: tmp, + network: { mode: "off" }, + hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" }, + addedDirs: [{ dir: join(theirs, "sub") }], + extraHidden: [theirs], + }); + expect(spec.addedDirs ?? []).not.toContain(join(theirs, "sub")); + expect(spec.writable).not.toContain(join(theirs, "sub")); + }); +}); diff --git a/src/tests/sandbox-workstream-manager.test.ts b/src/tests/sandbox-workstream-manager.test.ts new file mode 100644 index 00000000..d49e7580 --- /dev/null +++ b/src/tests/sandbox-workstream-manager.test.ts @@ -0,0 +1,137 @@ +/** + * The workstream fence through a real SessionSandbox (workstreams §7.3): + * under isolation a session's project roots are its home's and references' + * folders (never the bare @parent), every other workstream's folder under a + * shown root is hidden, and a reference added later reaches the next build. + */ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { CodeoidConfig } from "../config.js"; +import type { ProviderEvent } from "../daemon/providers/interface.js"; +import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js"; +import { ProviderRegistry } from "../daemon/providers/registry.js"; +import { LocalSandboxHandle } from "../daemon/sandbox/local-handle.js"; +import { SandboxRegistry } from "../daemon/sandbox/registry.js"; +import type { SandboxProvider, SandboxSpec } from "../daemon/sandbox/types.js"; +import { SessionManager } from "../daemon/session-manager.js"; +import { Store } from "../daemon/store.js"; +import { TranscriptStore } from "../daemon/transcript.js"; +import { FolderReader } from "../daemon/workstreams/folder.js"; +import { workstreamsSchema } from "../daemon/workstreams/schema.js"; +import { ALL_SCOPES } from "../protocol/scopes.js"; +import type { AuthContext, DaemonMessage, SessionInfo, SessionMessage } from "../protocol/types.js"; + +const AUTH: AuthContext = { sub: "user:fence", scopes: [...ALL_SCOPES] as AuthContext["scopes"], delegationDepth: 0, accountId: "acc", projectId: "proj" }; +const CLIENT = { id: "cli", auth: AUTH, send: () => {} }; +const DONE: ProviderEvent = { type: "turn_done", result: { providerId: "claude", model: "m", inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0, durationMs: 1 } }; + +let specs: SandboxSpec[] = []; +void specs; +function fake(): SandboxProvider { + const open = (spec: SandboxSpec) => { + specs.push(spec); + return new LocalSandboxHandle("bwrap", spec, (argv, o) => ({ argv: [...argv], env: o.env })); + }; + return { + id: "bwrap", + displayName: "fake-bwrap", + capabilities: { isolation: "os", placement: "local", workspace: "mount", stdioProcess: true, commands: true, wrapsArgv: true, network: ["on", "off"], platforms: ["linux"] }, + probe: async () => ({ available: true }), + open: async (s) => open(s), + openSync: open, + }; +} + +let tmp: string; +let home: string; +let ws: string; +let store: Store; +let transcript: TranscriptStore; +const realHome = process.env.HOME; + +beforeEach(() => { + specs = []; + const parent = process.env.CODEOID_TEST_SANDBOX_DIR ?? join(homedir(), ".cache"); + mkdirSync(parent, { recursive: true }); + tmp = realpathSync(mkdtempSync(join(parent, "codeoid-ws-fence-mgr-"))); + home = join(tmp, "home"); + ws = join(home, "Workspace"); + process.env.HOME = home; + for (const d of ["mine", "pub", "hf"]) mkdirSync(join(ws, d), { recursive: true }); + mkdirSync(join(tmp, "data")); + store = new Store(join(tmp, "data", "codeoid.db")); + transcript = new TranscriptStore(join(tmp, "data", "transcripts")); +}); + +afterEach(async () => { + try { + await transcript.flush(); + } catch {} + try { + store.close(); + } catch {} + process.env.HOME = realHome; + rmSync(tmp, { recursive: true, force: true }); +}); + +function newManager(): SessionManager { + const config = { + daemonUrl: "ws://127.0.0.1:7400", + dbPath: join(tmp, "data", "codeoid.db"), + transcriptDir: join(tmp, "data", "transcripts"), + auth: { baseUrl: "http://localhost:8899" }, + zeroidUrl: "http://localhost:8899", + workspaceIndex: { enabled: false, episodeThreshold: 5, timeThresholdMs: 60_000, debounceMs: 15_000 }, + compress: { enabled: false, excludeCommands: [], excludePatterns: [], compressPipes: false, minBytes: 1024 }, + labeling: {}, + telemetry: { osc8: "auto" }, + autoRotate: { enabled: false, warnPct: 0.6, rotatePct: 0.8, hardRotatePct: 0.9, minTurnsBeforeRotate: 3, strategy: "task-anchor" }, + session: {}, + conductor: { enabled: false, name: "conductor", provider: "claude" }, + workstreams: workstreamsSchema.parse({ + isolateGlobalServers: true, + items: [ + { id: "mine", match: { paths: ["~/Workspace/mine"] } }, + { id: "pub", share: "open", match: { paths: ["~/Workspace/pub"] } }, + { id: "hf", share: "closed", match: { paths: ["~/Workspace/hf"] } }, + ], + }), + } as unknown as CodeoidConfig; + const registry = new ProviderRegistry("claude"); + registry.register({ id: "claude", displayName: "claude", create: () => new MockSessionProvider("claude", [[DONE], [DONE], [DONE]]) }); + const sandboxRegistry = new SandboxRegistry("linux", (n) => `/usr/bin/${n}`); + sandboxRegistry.register(fake()); + return new SessionManager(store, transcript, undefined, undefined, undefined, { config, providers: registry, sandboxRegistry, _testFolderReader: new FolderReader({ home, sshConfig: "" }) }); +} + +describe("the fence in the sandbox", () => { + it("roots are the home's and references' folders, another workstream's is hidden, and a fresh session says no change", async () => { + const m = newManager(); + const made = (await m.handle({ type: "session.create", id: "c", name: "s", workdir: join(ws, "mine") } as never, AUTH, CLIENT)) as DaemonMessage & { data?: SessionInfo }; + expect(made.data?.workstream?.id).toBe("mine"); + const session = m.findByName("s", AUTH)!; + const infos: string[] = []; + session.attach({ id: "w", auth: AUTH, send: (msg: { type: string; role?: string; content?: string; metadata?: Record }) => void (msg.type === "session.message" && msg.role === "info" && infos.push(String((msg as unknown as SessionMessage).content))) } as never); + await session.ensureSandbox(); + await session.ensureSandbox(); + expect(session.sandboxInfo?.projectRoots).toEqual([join(ws, "mine")]); + expect(infos.filter((i) => i.includes("Sandbox directories applied"))).toEqual([]); + + await m.handle({ type: "session.add_reference", id: "r", sessionId: session.id, workstream: "pub" } as never, AUTH, CLIENT); + await session.ensureSandbox(); + expect(session.sandboxInfo?.projectRoots?.sort()).toEqual([join(ws, "mine"), join(ws, "pub")].sort()); + expect(infos.filter((i) => i.includes("Sandbox directories applied")).length).toBe(1); + await m.drain(2_000); + }); + + it("a session's own project roots stand (the fence then only hides: sandbox-workstream-fence.test.ts)", async () => { + const m = newManager(); + await m.handle({ type: "session.create", id: "c", name: "t", workdir: join(ws, "mine"), sandbox: { projectRoots: [ws] } } as never, AUTH, CLIENT); + const session = m.findByName("t", AUTH)!; + await session.ensureSandbox(); + expect(session.sandboxInfo?.projectRoots).toEqual([ws]); + await m.drain(2_000); + }); +}); diff --git a/src/tests/workstreams-conductor.test.ts b/src/tests/workstreams-conductor.test.ts new file mode 100644 index 00000000..be199797 --- /dev/null +++ b/src/tests/workstreams-conductor.test.ts @@ -0,0 +1,205 @@ +/** + * Workstreams W4 (docs/workstreams-design.md §10): the conductor's spawns are + * homed in a named workstream (a conflict is the conductor's to resolve, an + * override is what the approval card says), their cards name the workstream, + * a closed workstream's worker reports status only, and egress is planned + * into a worker homed where the message belongs. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CodeoidConfig } from "../config.js"; +import type { ProviderEvent } from "../daemon/providers/interface.js"; +import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js"; +import { SessionManager } from "../daemon/session-manager.js"; +import { Store } from "../daemon/store.js"; +import { TranscriptStore } from "../daemon/transcript.js"; +import { planEgress } from "../daemon/workstreams/egress.js"; +import { FolderReader } from "../daemon/workstreams/folder.js"; +import { workstreamsSchema } from "../daemon/workstreams/schema.js"; +import { WorkstreamService } from "../daemon/workstreams/service.js"; +import { ALL_SCOPES } from "../protocol/scopes.js"; +import type { AuthContext, SessionInfo } from "../protocol/types.js"; + +const AUTH: AuthContext = { sub: "user:cond", scopes: [...ALL_SCOPES] as AuthContext["scopes"], delegationDepth: 0, accountId: "acc-c", projectId: "proj-c" }; +const CLIENT = { id: "client-c", auth: AUTH, send: () => {} }; +const done: ProviderEvent = { type: "turn_done", result: { providerId: "mock", model: "m", inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0, durationMs: 1 } } as ProviderEvent; +const say = (text: string): ProviderEvent[] => [{ type: "text_done", content: text } as ProviderEvent, done]; + +let tmp: string; +let home: string; +let store: Store; +let transcript: TranscriptStore; +let manager: SessionManager; +let script: ProviderEvent[][] | undefined; +let providers: MockSessionProvider[] = []; + +function items() { + return [ + { id: "mine", share: "open", match: { paths: ["~/mine"] } }, + { id: "acme", share: "ask", claim: "exclusive", match: { paths: ["~/acme"] } }, + { id: "hf", share: "closed", match: { paths: ["~/hf"] } }, + ]; +} + +beforeEach(() => { + script = undefined; + providers = []; + tmp = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-ws-cond-"))); + home = join(tmp, "home"); + for (const d of ["mine", "acme", "hf", "loose"]) mkdirSync(join(home, d), { recursive: true }); + store = new Store(join(tmp, "codeoid.db")); + transcript = new TranscriptStore(join(tmp, "transcripts")); + const config = { + daemonUrl: "ws://127.0.0.1:7400", + dbPath: join(tmp, "codeoid.db"), + transcriptDir: join(tmp, "transcripts"), + auth: { baseUrl: "http://localhost:8899" }, + zeroidUrl: "http://localhost:8899", + workspaceIndex: { enabled: false, episodeThreshold: 5, timeThresholdMs: 60_000, debounceMs: 15_000 }, + compress: { enabled: false, excludeCommands: [], excludePatterns: [], compressPipes: false, minBytes: 1024 }, + labeling: {}, + telemetry: { osc8: "auto" }, + autoRotate: { enabled: false, warnPct: 0.6, rotatePct: 0.8, hardRotatePct: 0.9, minTurnsBeforeRotate: 3, strategy: "task-anchor" }, + session: {}, + conductor: { enabled: true, name: "conductor", provider: "claude" }, + dispatch: { enabled: true, tickMs: 999_999, leaseMs: 60_000, failureLimit: 1, maxConcurrentWorkers: 2, workerToolBudget: 7, retryBaseMs: 0 }, + workstreams: workstreamsSchema.parse({ isolateGlobalServers: true, items: items() }), + } as unknown as CodeoidConfig; + manager = new SessionManager(store, transcript, undefined, undefined, undefined, { + config, + _testProviderFactory: () => { + const p = new MockSessionProvider("mock", script ?? [say("the closed customer secret result"), say("two"), say("three")]); + providers.push(p); + return p; + }, + _testFolderReader: new FolderReader({ home, sshConfig: "" }), + }); +}); + +afterEach(async () => { + manager.stopDispatcher(); + try { + await manager.drain(3_000); + } catch {} + try { + await transcript.flush(); + } catch {} + try { + store.close(); + } catch {} + rmSync(tmp, { recursive: true, force: true }); +}); + +const deps = () => manager._fleetDispatchDeps(AUTH.accountId, AUTH.projectId); + +async function until(cond: () => boolean, ms = 3_000): Promise { + const deadline = Date.now() + ms; + while (!cond()) { + if (Date.now() > deadline) throw new Error("condition not reached"); + await Bun.sleep(5); + } +} + +describe("a spawn's home", () => { + test("named or the folder's; none refused; past an exclusive claim an override; a closed folder never", () => { + const check = deps().checkWorkstream!; + expect(check(join(home, "mine"), undefined)).toEqual({ ok: true, workstream: "mine" }); + expect(check(join(home, "loose"), undefined)).toMatchObject({ ok: false }); + expect(check(join(home, "loose"), "mine")).toEqual({ ok: true, workstream: "mine" }); + // Past acme's exclusive claim: refused unless the owner approved this very call's card. + expect(check(join(home, "acme"), "mine")).toMatchObject({ ok: false }); + const card = manager._fleetCardWorkstreamForTest(AUTH.accountId, AUTH.projectId)("mcp__codeoid_fleet__fleet_spawn", { workdir: join(home, "acme"), workstream: "mine" })!; + const token = manager._issueFleetTokenForTest(AUTH.accountId, AUTH.projectId, card)!; + expect(check(join(home, "acme"), "mine", "a-made-up-token")).toMatchObject({ ok: false }); + expect(check(join(home, "acme"), "mine", token)).toMatchObject({ ok: true, workstream: "mine", override: true }); + // Spent: a second spawn needs its own approval. + expect(check(join(home, "acme"), "mine", token)).toMatchObject({ ok: false }); + expect(check(join(home, "hf"), "mine")).toMatchObject({ ok: false }); + // A closed workstream's worker works only in its own folders. + expect(check(join(home, "loose"), "hf")).toMatchObject({ ok: false }); + expect(check(join(home, "hf"), "hf")).toEqual({ ok: true, workstream: "hf" }); + expect(check(join(home, "mine"), "nope")).toMatchObject({ ok: false }); + }); + + test("the approval card names the workstream, and warns of an override or a refusal", () => { + const card = manager._fleetCardWorkstreamForTest(AUTH.accountId, AUTH.projectId); + expect(card("mcp__codeoid_fleet__fleet_spawn", { workdir: join(home, "mine"), task: "t" })).toEqual({ workstream: "mine" }); + expect(card("mcp__codeoid_fleet__fleet_spawn", { workdir: join(home, "acme"), task: "t", workstream: "mine" })).toMatchObject({ workstream: "mine", warning: expect.stringContaining('Past workstream "acme"') }); + expect(card("mcp__codeoid_fleet__fleet_spawn", { workdir: join(home, "loose"), task: "t" })?.warning).toContain("refused"); + }); + + test("the worker is homed in the named workstream, an override as such", async () => { + const card = manager._fleetCardWorkstreamForTest(AUTH.accountId, AUTH.projectId)("mcp__codeoid_fleet__fleet_spawn", { workdir: join(home, "acme"), workstream: "mine" })!; + const ok = deps().checkWorkstream!(join(home, "acme"), "mine", manager._issueFleetTokenForTest(AUTH.accountId, AUTH.projectId, card)); + if (!ok.ok) throw new Error(ok.error); + const id = deps().enqueue({ kind: "spawn", shape: "scout", workdir: join(home, "acme"), prompt: "go", workstream: "mine", workstreamOverride: true, ...(ok.revision ? { workstreamRevision: ok.revision } : {}) }); + expect(id).toBeTruthy(); + await manager.dispatcher.tick(); + const listed = (await manager.handle({ type: "session.list", id: "l" }, AUTH, CLIENT)) as { sessions: SessionInfo[] }; + const worker = listed.sessions.find((s) => s.role === "worker"); + expect(worker?.workstream).toMatchObject({ id: "mine", source: "override" }); + }); + + test("a closed workstream's worker reports its status, never its text", async () => { + deps().enqueue({ kind: "spawn", shape: "scout", workdir: join(home, "hf"), prompt: "go" }); + await manager.dispatcher.tick(); + const board = () => manager._fleetDepsForTest(AUTH.accountId, AUTH.projectId).dispatch!.listTasks(5); + await until(() => board()[0]?.status === "done"); + const digest = board()[0]?.resultDigest ?? ""; + expect(digest).toContain("closed"); + expect(digest).not.toContain("customer secret"); + }); +}); + +describe("approving a spawn card that warns", () => { + test("is the owner's own: a delegated token is refused; the owner's approval is what the handler counts", async () => { + script = [[{ type: "tool_start", toolId: "t1", sdkToolUseId: "s1", name: "mcp__codeoid_fleet__fleet_spawn", input: { workdir: join(home, "acme"), task: "t", workstream: "mine" }, approvalId: "ap-1" } as ProviderEvent, done]]; + const made = (await manager.handle({ type: "session.create", id: "c", name: "conductor", workdir: ".", role: "conductor" } as never, AUTH, CLIENT)) as { data: SessionInfo }; + const conductor = manager._sessionForTest(made.data.id)!; + void conductor.send("spawn", AUTH); + await until(() => conductor.pendingToolApprovals().length > 0); + const pending = conductor.pendingToolApprovals()[0]!; + expect(pending.targetWorkstream).toMatchObject({ workstream: "mine", warning: expect.stringContaining('Past workstream "acme"') }); + const delegated = { ...AUTH, delegationDepth: 1 }; + expect(await manager.handle({ type: "session.approve", id: "a1", sessionId: conductor.id, approvalId: pending.approvalId, approved: true } as never, delegated, { ...CLIENT, auth: delegated })).toMatchObject({ code: "forbidden" }); + // Another member's own token isn't the owner's either. + const member = { ...AUTH, sub: "user:other-member" }; + expect(await manager.handle({ type: "session.approve", id: "a3", sessionId: conductor.id, approvalId: pending.approvalId, approved: true } as never, member, { ...CLIENT, auth: member })).toMatchObject({ code: "forbidden" }); + expect(await manager.handle({ type: "session.approve", id: "a2", sessionId: conductor.id, approvalId: pending.approvalId, approved: true } as never, AUTH, CLIENT)).toMatchObject({ type: "response.ok" }); + // The approval put a token in the call's own input: the handler would present it. + await until(() => (providers.at(-1)?.canUseToolResults.length ?? 0) > 0); + const allowed = providers.at(-1)!.canUseToolResults[0]!; + expect(allowed.behavior).toBe("allow"); + const token = allowed.updatedInput?.confirmation as string; + expect(typeof token).toBe("string"); + expect(deps().checkWorkstream!(join(home, "acme"), "mine", token)).toMatchObject({ ok: true, override: true }); + }); +}); + +describe("a confirmation holds only where it was given", () => { + test("an override queued at one revision is refused at claim after the workstreams changed", async () => { + deps().enqueue({ kind: "spawn", shape: "scout", workdir: join(home, "acme"), prompt: "go", workstream: "mine", workstreamOverride: true, workstreamRevision: "an-old-revision" }); + await manager.dispatcher.tick(); + const listed = (await manager.handle({ type: "session.list", id: "l" }, AUTH, CLIENT)) as { sessions: SessionInfo[] }; + expect(listed.sessions.find((s) => s.role === "worker")).toBeUndefined(); + const board = manager._fleetDepsForTest(AUTH.accountId, AUTH.projectId).dispatch!.listTasks(5); + expect(board[0]?.error ?? "").toContain("workstream"); + }); +}); + +describe("egress (stub)", () => { + test("planned into a worker homed in the named workstream, never guessed, never by a worker", () => { + const s = new WorkstreamService(workstreamsSchema.parse({ items: items() }), new FolderReader({ home, sshConfig: "" })); + expect(planEgress(s, { workstream: "acme", channel: "slack", recipient: "#eng", from: { role: "conductor" } })).toEqual({ + ok: true, + worker: { workstream: "acme" }, + card: { workstream: "acme", channel: "slack", recipient: "#eng" }, + }); + expect(planEgress(s, { channel: "slack", recipient: "#eng", from: { role: "conductor" } })).toMatchObject({ ok: false }); + expect(planEgress(s, { workstream: "nope", channel: "slack", recipient: "#eng", from: { role: "conductor" } })).toMatchObject({ ok: false }); + expect(planEgress(s, { workstream: "acme", channel: "slack", recipient: "#eng", from: { role: "worker" } })).toMatchObject({ ok: false }); + expect(planEgress(new WorkstreamService(undefined), { workstream: "acme", channel: "slack", recipient: "x", from: { role: "conductor" } })).toMatchObject({ ok: false }); + }); +}); diff --git a/src/tests/workstreams-references.test.ts b/src/tests/workstreams-references.test.ts new file mode 100644 index 00000000..e3f45176 --- /dev/null +++ b/src/tests/workstreams-references.test.ts @@ -0,0 +1,200 @@ +/** + * Workstreams W3 through the manager: references at create and mid-session + * by each workstream's share policy, moves (§5.3), and the read fence + * refusing a file tool in another workstream's folders at the gate (§7.3). + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig } from "../config.js"; +import type { ProviderEvent } from "../daemon/providers/interface.js"; +import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js"; +import { SessionManager } from "../daemon/session-manager.js"; +import { Store } from "../daemon/store.js"; +import { TranscriptStore } from "../daemon/transcript.js"; +import { FolderReader } from "../daemon/workstreams/folder.js"; +import { ALL_SCOPES, SCOPES } from "../protocol/scopes.js"; +import type { AuthContext, DaemonMessage, SessionInfo } from "../protocol/types.js"; + +const DONE: ProviderEvent = { type: "turn_done", result: { providerId: "mock", model: "m", inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0, durationMs: 1 } }; +const OWNER: AuthContext = { sub: "user:ref", scopes: [...ALL_SCOPES] as AuthContext["scopes"], delegationDepth: 0, accountId: "acc", projectId: "proj" }; +const SENDER: AuthContext = { ...OWNER, scopes: [SCOPES.SESSION_CREATE, SCOPES.SESSION_SEND, SCOPES.SESSION_LIST] as AuthContext["scopes"] }; +const client = { id: "c-ref", auth: OWNER, send: (_m: DaemonMessage) => {} }; + +let tmp: string; +let home: string; +let store: Store; +let transcript: TranscriptStore; +let savedXdg: string | undefined; +let manager: SessionManager; +let providers: MockSessionProvider[]; +let script: ProviderEvent[][]; + +beforeEach(() => { + tmp = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-ws-ref-"))); + home = join(tmp, "home"); + savedXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = join(tmp, "config"); + mkdirSync(join(tmp, "config", "codeoid"), { recursive: true }); + for (const d of ["mine", "acme-api", "acme-web", "hf", "pub"]) mkdirSync(join(home, d), { recursive: true }); + writeFileSync(join(home, "hf", "secret.ts"), "x"); + writeFileSync( + join(tmp, "config", "codeoid", "config.json"), + JSON.stringify({ + dbPath: join(tmp, "codeoid.db"), + transcriptDir: join(tmp, "transcripts"), + workstreams: { + isolateGlobalServers: true, + items: [ + { id: "mine", share: "open", match: { paths: ["~/mine"] } }, + { id: "acme", share: "ask", match: { paths: ["~/acme-*"] } }, + { id: "hf", share: "closed", claim: "exclusive", match: { paths: ["~/hf"] } }, + { id: "pub", share: "open", match: { paths: ["~/pub"] } }, + ], + }, + }), + ); + store = new Store(join(tmp, "codeoid.db")); + transcript = new TranscriptStore(join(tmp, "transcripts")); + providers = []; + script = [[DONE]]; + manager = new SessionManager(store, transcript, undefined, undefined, undefined, { + config: loadConfig({ env: {}, quiet: true }), + _testProviderFactory: () => { + const p = new MockSessionProvider("mock", script); + providers.push(p); + return p; + }, + _testFolderReader: new FolderReader({ home, sshConfig: "" }), + }); +}); + +afterEach(async () => { + try { + await manager.drain(2_000); + } catch {} + try { + await transcript.flush(); + } catch {} + store.close(); + if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = savedXdg; + rmSync(tmp, { recursive: true, force: true }); +}); + +const h = (msg: Record, auth: AuthContext = OWNER) => manager.handle(msg as never, auth, { ...client, auth }); +const create = async (name: string, dir: string, extra: Record = {}, auth: AuthContext = OWNER) => + (await h({ type: "session.create", id: `c-${name}`, name, workdir: join(home, dir), ...extra }, auth)) as DaemonMessage & { data?: SessionInfo }; + +describe("references at create", () => { + it("open is granted; ask needs the creator's approval rights; closed never", async () => { + const open = await create("a", "mine", { references: [{ workstream: "pub" }] }); + expect(open.type).toBe("response.ok"); + expect(open.data?.workstream).toMatchObject({ id: "mine", references: [{ workstream: "pub" }], readFrom: ["pub"] }); + const ask = await create("b", "mine", { references: [{ workstream: "acme" }] }, SENDER); + expect(ask).toMatchObject({ type: "response.error", code: "forbidden" }); + const asked = await create("c", "mine", { references: [{ workstream: "acme", paths: [join(home, "acme-api")] }] }); + expect(asked.data?.workstream?.references).toEqual([{ workstream: "acme", paths: [join(home, "acme-api")] }]); + expect(await create("d", "mine", { references: [{ workstream: "hf" }] })).toMatchObject({ type: "response.error", code: "forbidden" }); + expect(await create("e", "mine", { references: [{ workstream: "acme", paths: ["/etc"] }] })).toMatchObject({ type: "response.error" }); + }); +}); + +describe("references and moves mid-session", () => { + it("add and remove a reference; an `ask` one from a sender without approval rights is asked on the session", async () => { + const made = await create("s", "mine", {}, SENDER); + const id = made.data!.id; + const added = await h({ type: "session.add_reference", id: "r1", sessionId: id, workstream: "pub" }, SENDER); + expect(added).toMatchObject({ type: "response.ok", data: { workstream: { references: [{ workstream: "pub" }] } } }); + const pending = await h({ type: "session.add_reference", id: "r2", sessionId: id, workstream: "acme" }, SENDER); + expect(pending).toMatchObject({ type: "response.ok", data: { pending: true } }); + expect(manager.findByName("s", OWNER)?.workstream?.references?.map((r) => r.workstream)).toEqual(["pub"]); + expect(await h({ type: "session.add_reference", id: "r3", sessionId: id, workstream: "hf" }, SENDER)).toMatchObject({ code: "forbidden" }); + const removed = await h({ type: "session.remove_reference", id: "r4", sessionId: id, workstream: "pub" }, SENDER); + expect(removed).toMatchObject({ type: "response.ok" }); + // Gone, but still counted as read from (a later move follows its policy). + expect(manager.findByName("s", OWNER)?.workstreamAhead).toMatchObject({ id: "mine", readFrom: ["pub"] }); + expect(manager.findByName("s", OWNER)?.workstreamAhead?.references).toBeUndefined(); + }); + + it("only the owner", async () => { + const made = await create("o", "mine"); + const other = { ...OWNER, sub: "user:other" }; + expect(await h({ type: "session.add_reference", id: "x", sessionId: made.data!.id, workstream: "pub" }, other)).toMatchObject({ type: "response.error" }); + }); + + it("a move follows the old home's policy and every workstream read from", async () => { + const fromMine = await create("m1", "mine"); + expect(await h({ type: "session.set_workstream", id: "w1", sessionId: fromMine.data!.id, workstream: "pub" })).toMatchObject({ type: "response.ok" }); + expect(manager.findByName("m1", OWNER)?.workstream).toMatchObject({ id: "pub", source: "explicit" }); + // Read from a closed workstream once: that conversation never leaves. + const read = await create("m2", "mine", { references: [{ workstream: "pub" }] }); + const sid = read.data!.id; + const s = manager.findByName("m2", OWNER)!; + s.setWorkstream({ ...s.workstream!, readFrom: ["pub", "hf"] }, "test"); + expect(await h({ type: "session.set_workstream", id: "w2", sessionId: sid, workstream: "acme" })).toMatchObject({ code: "forbidden" }); + // From an `ask` home, a sender without approval rights is asked on the session. + const fromAcme = await create("m3", "acme-api", {}, SENDER); + expect(await h({ type: "session.set_workstream", id: "w3", sessionId: fromAcme.data!.id, workstream: "pub" }, SENDER)).toMatchObject({ data: { pending: true } }); + // Into a folder another workstream holds exclusively: an override, needing approval rights. + const inHf = await create("m4", "hf"); + expect(inHf.data?.workstream?.id).toBe("hf"); + expect(await h({ type: "session.set_workstream", id: "w4", sessionId: inHf.data!.id, workstream: "pub" })).toMatchObject({ code: "forbidden" }); + }); +}); + +describe("the read fence at the gate", () => { + it("a file tool in another workstream's folder is refused, naming it; a reference is readable", async () => { + script = [[{ type: "tool_start", toolId: "t1", sdkToolUseId: "sdk-t1", name: "Read", input: { file_path: join(home, "hf", "secret.ts") }, approvalId: "ap-1" } as ProviderEvent, DONE]]; + const made = await create("g", "mine"); + const session = manager.findByName("g", OWNER)!; + await session.send("read it", OWNER); + for (let i = 0; i < 200 && providers.at(-1)?.canUseToolResults.length === 0; i++) await Bun.sleep(10); + const result = providers.at(-1)!.canUseToolResults[0]; + expect(result).toMatchObject({ behavior: "deny" }); + expect(result?.message).toContain('workstream "hf"'); + expect(made.data?.workstream?.id).toBe("mine"); + }); + + it("a session moved to another workstream still reads its own folder", async () => { + script = [[{ type: "tool_start", toolId: "t2", sdkToolUseId: "sdk-t2", name: "Read", input: { file_path: join(home, "mine", "own.ts") }, approvalId: "ap-2" } as ProviderEvent, DONE]]; + writeFileSync(join(home, "mine", "own.ts"), "x"); + const made = await create("mv", "mine"); + await h({ type: "session.set_workstream", id: "w", sessionId: made.data!.id, workstream: "pub" }); + const session = manager.findByName("mv", OWNER)!; + expect(session.workstream?.id).toBe("pub"); + await session.send("read", OWNER); + for (let i = 0; i < 100 && providers.at(-1)?.canUseToolResults.length === 0; i++) await Bun.sleep(10); + const r = providers.at(-1)?.canUseToolResults[0]; + // Not refused by the fence (a guarded Read may still wait on its card: either way, never fenced). + expect(r?.message ?? "").not.toContain("workstream"); + }); +}); + +describe("reference inputs are held to what a binding is read back with", () => { + it("a NUL in a folder, or one reference too many, is refused before it is stored", async () => { + const made = await create("v", "mine"); + const id = made.data!.id; + expect(await h({ type: "session.add_reference", id: "n", sessionId: id, workstream: "pub", paths: [`${join(home, "pub")}/\u0000x`] })).toMatchObject({ type: "response.error" }); + expect(await create("v2", "mine", { references: [{ workstream: "pub", paths: ["relative"] }] })).toMatchObject({ type: "response.error" }); + expect(manager.findByName("v", OWNER)?.workstream?.references).toBeUndefined(); + }); + + it("a move counts the old home as read from: moving on follows its policy", async () => { + const made = await create("chain", "acme-api"); + const id = made.data!.id; + expect(await h({ type: "session.set_workstream", id: "m1", sessionId: id, workstream: "pub" })).toMatchObject({ type: "response.ok" }); + expect(manager.findByName("chain", OWNER)?.workstreamAhead?.readFrom).toContain("acme"); + // From pub (open) to mine: acme (ask) was read, so a sender without approval rights is asked. + expect(await h({ type: "session.set_workstream", id: "m2", sessionId: id, workstream: "mine" }, SENDER)).toMatchObject({ data: { pending: true } }); + }); + + it("a home chosen in a closed workstream's folder is refused; in an `ask` one it needs approval rights", async () => { + expect(await create("in-hf", "hf", { workstream: "pub" })).toMatchObject({ type: "response.error" }); + expect(await create("in-acme", "acme-api", { workstream: "pub" }, SENDER)).toMatchObject({ type: "response.error", code: "forbidden" }); + const ok = await create("in-acme2", "acme-api", { workstream: "pub" }); + expect(ok.data?.workstream).toMatchObject({ id: "pub", readFrom: ["acme"] }); + }); +}); diff --git a/src/tui/ansi/render-message.ts b/src/tui/ansi/render-message.ts index c579a99b..d73736ce 100644 --- a/src/tui/ansi/render-message.ts +++ b/src/tui/ansi/render-message.ts @@ -277,6 +277,10 @@ function renderToolRow( if (cost) lines.push(dim(` \u25c7 ${formatCollaborationCost(cost)}`)); const risk = formatToolRisk((tool.state as { risk?: ToolRisk }).risk); if (risk) lines.push(red(` \u25b2 ${risk}`)); + // The workstream a fleet dispatch acts in, and what approving confirms (workstreams §10). + const tw = (tool.state as { targetWorkstream?: { workstream?: string; warning?: string } }).targetWorkstream; + if (tw?.workstream) lines.push(dim(` \u2302 workstream ${tw.workstream}`)); + if (tw?.warning) lines.push(red(` \u26a0 ${tw.warning}`)); } if (isEdit) { diff --git a/src/tui/components/MessageRow.tsx b/src/tui/components/MessageRow.tsx index f4e993e0..3914743f 100644 --- a/src/tui/components/MessageRow.tsx +++ b/src/tui/components/MessageRow.tsx @@ -230,6 +230,15 @@ function ToolRow({ {(tool.state as { description: string }).description} )} + {/* The workstream a fleet dispatch acts in, and what approving confirms (workstreams §10). */} + {phase === "waiting_confirmation" && (tool.state as { targetWorkstream?: { workstream?: string } }).targetWorkstream?.workstream && ( + ⌂ workstream {(tool.state as { targetWorkstream?: { workstream?: string } }).targetWorkstream?.workstream} + )} + {phase === "waiting_confirmation" && (tool.state as { targetWorkstream?: { warning?: string } }).targetWorkstream?.warning && ( + + ⚠ {(tool.state as { targetWorkstream?: { warning?: string } }).targetWorkstream?.warning} + + )} {phase === "waiting_confirmation" && formatToolRisk((tool.state as { risk?: ToolRisk }).risk) && ( ▲ {formatToolRisk((tool.state as { risk?: ToolRisk }).risk)} diff --git a/src/tui/store.tsx b/src/tui/store.tsx index a6a3e450..68a8b580 100644 --- a/src/tui/store.tsx +++ b/src/tui/store.tsx @@ -319,6 +319,7 @@ function applyMessage( toolName: msg.tool.name, description: st.description, ...(formatToolRisk(st.risk) ? { risk: formatToolRisk(st.risk) } : {}), + ...(st.targetWorkstream?.warning ? { risk: `${st.targetWorkstream.warning}${formatToolRisk(st.risk) ? ` · ${formatToolRisk(st.risk)}` : ""}` } : {}), }; } diff --git a/web/src/components/CenterPane.tsx b/web/src/components/CenterPane.tsx index 7eec877b..3d339d8d 100644 --- a/web/src/components/CenterPane.tsx +++ b/web/src/components/CenterPane.tsx @@ -120,7 +120,7 @@ const SessionHeader: Component = () => ( {s().model} - + diff --git a/web/src/components/FleetRail.tsx b/web/src/components/FleetRail.tsx index 94ef5955..5510d2e4 100644 --- a/web/src/components/FleetRail.tsx +++ b/web/src/components/FleetRail.tsx @@ -109,8 +109,10 @@ const FleetRail: Component = () => { return fleetEconomics(b.conductor ? [b.conductor, ...b.workers] : b.workers); }); + // Docked beside the chat from a tablet up; on a phone it would leave the chat (and the approval card + // naming what a dispatch does) a sliver, so there the chat has the width and the fleet is in fleet_list. return ( -