diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index cb8c6c3b..84132a48 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -46,6 +46,13 @@ export const collaborationRoleSchema = z.object({ purpose: z.string().max(500).optional(), /** Opt-in write authority. Absent = read-only (least privilege). */ write: z.boolean().optional(), + /** + * Blackboard artifact scoping. Bounded string arrays here; whether each name + * is a real kind is checked by the daemon, so an unknown kind gets a specific + * error naming the valid set instead of an opaque schema rejection. + */ + reads: z.array(z.string().min(1).max(64)).max(16).optional(), + writes: z.array(z.string().min(1).max(64)).max(16).optional(), }); export const collaborationConfigSchema = z.object({ diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index cd97fb88..11698136 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -883,6 +883,30 @@ export interface CollaborationRole { * `false` → "scout". */ write?: boolean; + /** + * Blackboard artifact kinds this role may READ — `spec`, `research`, `adr`, + * `task-list`, `diff`, `findings`, or `extra/` + * (docs/collaborative-session-design.md §4). + * + * Absent = the default profile for this role name from §3. A role name with + * no profile and no declaration reads NOTHING — fail-closed in both + * directions, because the design's standing rule is that an unenforced field + * is false security. + * + * This is what makes reviewer independence structural rather than polite: + * `review` reads `diff`+`spec` and NOT `research` (the implementer's + * reasoning by proxy) or `findings` (its peers' opinions). + */ + reads?: string[]; + /** + * Blackboard artifact kinds this role may WRITE. Absent = the §3 default + * profile for this role name; an unprofiled role that declares nothing + * writes nothing. + * + * A role writing a multi-writer kind (`findings`) writes into its OWN slot, + * chosen by the daemon — so one reviewer can never overwrite another's. + */ + writes?: string[]; } /** The role name that must be present exactly once in a collaboration, and diff --git a/src/daemon/blackboard/mcp-http.ts b/src/daemon/blackboard/mcp-http.ts new file mode 100644 index 00000000..3d8d0d44 --- /dev/null +++ b/src/daemon/blackboard/mcp-http.ts @@ -0,0 +1,284 @@ +/** + * Goal blackboard — MOUNTABLE MCP endpoint + * (docs/collaborative-session-design.md §4; mountability is #245). + * + * Deliberately an HTTP Streamable MCP server rather than the in-process + * Claude-SDK object (`createSdkMcpServer`) that backs the fleet tools. The + * fleet's in-process server is exactly why the orchestrator is claude-only in + * v1: no other backend can mount it. The blackboard must not inherit that + * limitation, because the whole premise is that a *gemini* reviewer and an + * *openai* reasoner hand work to each other. Anything that can mount an MCP + * URL — claude, codex, gemini, openai, pi — gets the same surface. + * + * ## The token is the scope + * + * `mint()` binds a token to one `RoleBlackboard` handle: one goal, one role + * identity, one read/write set. Every call resolves through that handle, so a + * child physically cannot address another goal or widen its own scope — there + * is no tool parameter that would let it try. An unknown or revoked token + * fails closed with 401, same contract as the memory mount. + * + * This is why slice 1 put enforcement in the service: these tools are a + * transport over `RoleBlackboard`, not a second place where scoping decisions + * get made and could disagree. + */ + +import { randomUUID } from "node:crypto"; +import { + DEFAULT_MCP_PROTOCOL_VERSION, + ok, + rpcErr, + tokenFrom, + type JsonRpcMessage, + type JsonRpcResponse, +} from "../mcp/jsonrpc-http.js"; +import type { RoleBlackboard } from "./service.js"; +import { CORE_ARTIFACT_KINDS } from "./types.js"; + +/** Path the endpoint is mounted at on the daemon's HTTP server. */ +export const BLACKBOARD_MCP_PATH = "/mcp/blackboard"; + +/** + * The MCP server name backends mount this under. Tool calls arrive namespaced + * by it (e.g. `codeoid_blackboard__blackboard_read`), which is what lets the + * tool-safety layer recognize them. + */ +export const BLACKBOARD_MCP_SERVER_NAME = "codeoid_blackboard"; + +/** + * Env var carrying the bearer token for backends that mount by config rather + * than by header (codex reads `bearer_token_env_var`). Keeping the token out of + * the `-c` args matters: process argv is world-readable on Linux, and these + * tokens are the scope. + */ +export const BLACKBOARD_MCP_TOKEN_ENV = "CODEOID_BLACKBOARD_TOKEN"; + +const SERVER_INFO = { name: BLACKBOARD_MCP_SERVER_NAME, version: "0.1.0" } as const; + +/** What a mount hands to a session so it can reach the blackboard. */ +export interface BlackboardMcpMount { + endpoint: BlackboardMcpHttp; + url: string; +} + +const KIND_DESC = `Artifact kind: ${CORE_ARTIFACT_KINDS.join(", ")}, or extra/`; + +interface ToolDef { + name: string; + description: string; + jsonSchema: Record; + run(args: Record, bb: RoleBlackboard): string; +} + +const str = (v: unknown): string => (typeof v === "string" ? v : ""); + +/** + * The four tools. Note what is absent: nothing takes a goal id, and `write` + * takes no slot. Both omissions are the security model — a child cannot name + * another goal, and a reviewer cannot name another reviewer's slot. + */ +const TOOLS: ToolDef[] = [ + { + name: "blackboard_index", + description: + "List which artifacts exist on this goal, at what version, written by whom, and how large — without their contents. Start here to see what is ready.", + jsonSchema: { type: "object", properties: {}, additionalProperties: false }, + run: (_args, bb) => { + const idx = bb.index(); + if (idx.length === 0) return "The blackboard is empty — no artifacts written yet."; + const lines = idx.map( + (e) => + `- ${e.kind}${e.slot ? ` [${e.slot}]` : ""} v${e.version} · ${e.bytes} bytes · by ${e.authorRole ?? e.authorSub}`, + ); + return [ + `${idx.length} artifact(s) on this goal:`, + ...lines, + "", + `You may read: ${bb.reads.join(", ") || "(nothing)"}`, + `You may write: ${bb.writes.join(", ") || "(nothing)"}`, + ].join("\n"); + }, + }, + { + name: "blackboard_read", + description: + "Read the latest version of one artifact. Denied if your role's read scope doesn't include it.", + jsonSchema: { + type: "object", + properties: { kind: { type: "string", description: KIND_DESC } }, + required: ["kind"], + additionalProperties: false, + }, + run: (args, bb) => { + const r = bb.read(str(args.kind)); + if (!r.ok) throw new Error(r.error); + if (!r.value) return `No "${str(args.kind)}" has been written on this goal yet.`; + const a = r.value; + return `${a.kind} v${a.version} (by ${a.authorRole ?? a.authorSub}):\n\n${a.content}`; + }, + }, + { + name: "blackboard_read_all", + description: + "Read every writer's latest entry for one artifact kind — e.g. all reviewers' findings. Denied if the kind is outside your read scope.", + jsonSchema: { + type: "object", + properties: { kind: { type: "string", description: KIND_DESC } }, + required: ["kind"], + additionalProperties: false, + }, + run: (args, bb) => { + const r = bb.readAll(str(args.kind)); + if (!r.ok) throw new Error(r.error); + if (r.value.length === 0) return `No "${str(args.kind)}" entries on this goal yet.`; + return r.value + .map( + (a) => + `── ${a.kind}${a.slot ? ` [${a.slot}]` : ""} v${a.version} by ${a.authorRole ?? a.authorSub} ──\n${a.content}`, + ) + .join("\n\n"); + }, + }, + { + name: "blackboard_write", + description: + "Publish your output as an artifact. Appends a new version — it never overwrites, and for multi-writer kinds you write your own entry, so you cannot clobber a peer. Denied if the kind is outside your write scope.", + jsonSchema: { + type: "object", + properties: { + kind: { type: "string", description: KIND_DESC }, + content: { type: "string", description: "The artifact body" }, + }, + required: ["kind", "content"], + additionalProperties: false, + }, + run: (args, bb) => { + const w = bb.write(str(args.kind), str(args.content)); + if (!w.ok) throw new Error(w.error); + const a = w.value; + return `Wrote ${a.kind}${a.slot ? ` [${a.slot}]` : ""} v${a.version} (${a.content.length} bytes).`; + }, + }, +]; + +export class BlackboardMcpHttp { + /** token → the role-scoped handle it authorizes. */ + readonly #bindings = new Map(); + + /** + * Mint a bearer token bound to one role's view of one goal. The token IS the + * scope — there is no wider handle reachable from it. + */ + mint(handle: RoleBlackboard): string { + const token = `bbt_${randomUUID().replace(/-/g, "")}`; + this.#bindings.set(token, handle); + return token; + } + + revoke(token: string): void { + this.#bindings.delete(token); + } + + /** Live token count — for teardown assertions + telemetry. */ + get activeTokens(): number { + return this.#bindings.size; + } + + /** Bun.serve fetch handler for {@link BLACKBOARD_MCP_PATH}. */ + async handle(req: Request): Promise { + if (req.method !== "POST") { + // No server-initiated SSE stream; some clients probe GET first. + return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } }); + } + + const token = tokenFrom(req); + const bb = token ? this.#bindings.get(token) : undefined; + if (!bb) { + // Fail closed — never run a tool without a resolved role scope. + return new Response(JSON.stringify({ error: "unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json", "WWW-Authenticate": "Bearer" }, + }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return Response.json(rpcErr(null, -32700, "Parse error"), { status: 400 }); + } + + const batch = Array.isArray(body); + const messages = (batch ? body : [body]) as JsonRpcMessage[]; + const responses: JsonRpcResponse[] = []; + let sawInitialize = false; + for (const m of messages) { + if (m && m.method === "initialize") sawInitialize = true; + const res = this.#dispatch(m, bb); + if (res) responses.push(res); + } + + if (responses.length === 0) return new Response(null, { status: 202 }); + + const headers: Record = { "Content-Type": "application/json" }; + if (sawInitialize && token) headers["Mcp-Session-Id"] = token; + return new Response(JSON.stringify(batch ? responses : responses[0]), { + status: 200, + headers, + }); + } + + #dispatch(msg: JsonRpcMessage | null, bb: RoleBlackboard): JsonRpcResponse | null { + const id = msg?.id ?? null; + // JSON-RPC notifications carry no id — acknowledge with no response. + if (msg?.id === undefined) return null; + + switch (msg?.method) { + case "initialize": { + const requested = msg?.params?.protocolVersion; + return ok(id, { + protocolVersion: + typeof requested === "string" ? requested : DEFAULT_MCP_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: SERVER_INFO, + }); + } + case "ping": + return ok(id, {}); + case "tools/list": + return ok(id, { + tools: TOOLS.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.jsonSchema, + })), + }); + case "tools/call": { + const name = msg?.params?.name; + const args = (msg?.params?.arguments ?? {}) as Record; + const def = TOOLS.find((t) => t.name === name); + if (!def) { + return ok(id, { + content: [{ type: "text", text: `Unknown tool: ${String(name)}` }], + isError: true, + }); + } + try { + return ok(id, { content: [{ type: "text", text: def.run(args, bb) }], isError: false }); + } catch (e) { + // A scope denial surfaces as an MCP tool error, not a transport + // error: the agent should see *why* it was refused and adapt, not + // get an opaque failure it might retry forever. + return ok(id, { + content: [ + { type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }, + ], + isError: true, + }); + } + } + default: + return rpcErr(id, -32601, `Method not found: ${String(msg?.method)}`); + } + } +} diff --git a/src/daemon/blackboard/service.ts b/src/daemon/blackboard/service.ts new file mode 100644 index 00000000..7ed8a05b --- /dev/null +++ b/src/daemon/blackboard/service.ts @@ -0,0 +1,241 @@ +/** + * Goal blackboard — the role-scoped access layer + * (docs/collaborative-session-design.md §4, §6). + * + * Every artifact read and write in a collaboration goes through a handle + * obtained from `Blackboard.forRole()`. The handle is the gate: it knows which + * kinds its role may read and write, and it refuses anything else. + * + * Enforcement lives HERE rather than in the tool layer on purpose. The design's + * standing rule is that "an unenforced field is false security", and the + * reserved `reads`/`writes` comment in pipeline/interface.ts repeats it. If + * scoping lived only in the MCP tool wrappers, then any second caller — a + * future frontend, the pipeline engine, a test helper — would silently get + * unscoped access. With the service owning it, the tool surface cannot expose a + * path that bypasses scoping because there isn't one to expose. + * + * The property this protects (§6): a reviewer may read `diff`+`spec` and write + * its own `findings`. It may NOT read `research` (the implementer's reasoning + * by proxy) and may NOT read `findings` — not even another reviewer's. A panel + * whose members can read each other is not a panel; it's an echo. That is why + * `review`'s default read set is exactly two kinds. + */ + +import type { BlackboardStore, GoalScope } from "./store.js"; +import type { Artifact, ArtifactIndexEntry } from "./types.js"; +import { ARTIFACT_CONTENT_MAX, isValidArtifactKind } from "./types.js"; + +/** What a role may read and write. Both default to EMPTY — fail closed. */ +export interface RoleIo { + reads: readonly string[]; + writes: readonly string[]; +} + +/** + * The default role→artifact profile from §3's table. A *default*, not a + * closed list: §3 is explicit that the five named roles are a starting profile + * and that adding a role must stay a config change. A role absent from this + * table and declaring nothing gets nothing — the fail-closed direction. + * + * Note what `review` deliberately lacks: `research` (the implementer's + * reasoning by proxy) and `findings` (its peers' opinions). Independence is a + * consequence of the read set, not of asking nicely. + */ +export const DEFAULT_ROLE_IO: Readonly> = { + orchestrator: { reads: ["spec", "findings"], writes: ["spec", "task-list"] }, + search: { reads: ["spec"], writes: ["research"] }, + architecture: { reads: ["spec", "research"], writes: ["adr", "task-list"] }, + reasoning: { reads: ["spec", "adr", "task-list"], writes: ["diff"] }, + review: { reads: ["spec", "diff"], writes: ["findings"] }, +}; + +/** + * Kinds where each writer gets its own slot. + * + * §4 has *each* reviewer write a `findings` entry. Without per-writer slots, + * reviewer #2's write would become version 2 of the same artifact and a reader + * taking "latest" would see one opinion — a panel silently collapsed to a + * single voice, with no error anywhere. Everything else is a singleton with + * version history. + */ +export const MULTI_WRITER_KINDS: ReadonlySet = new Set(["findings"]); + +/** + * Resolve a role's effective artifact scope: what it declared, else the §3 + * default profile for its name, else nothing. + * + * Exported and shared with `childBrief` on purpose. The brief TELLS a child + * what it may touch and this function DECIDES it; if those were computed + * separately they would eventually disagree, and the agent would be told it + * can read something the fence then refuses — the most confusing possible + * failure for a model to recover from. + */ +export function resolveRoleIo( + roleName: string, + declared?: { reads?: readonly string[]; writes?: readonly string[] }, +): RoleIo { + const fallback = DEFAULT_ROLE_IO[roleName]; + return { + reads: declared?.reads ?? fallback?.reads ?? [], + writes: declared?.writes ?? fallback?.writes ?? [], + }; +} + +export type BlackboardDenial = { ok: false; error: string }; +export type BlackboardResult = { ok: true; value: T } | BlackboardDenial; + +/** Identity of the agent behind a handle — for attribution on every write. */ +export interface RoleIdentity { + /** Role name (already lowercased by validateCollaboration). */ + roleName: string; + /** 1-based fan-out index; 1 for a singleton role. */ + ordinal: number; + /** ZeroID subject of the agent. */ + authorSub: string; +} + +/** + * A role's view of one goal's blackboard. Obtained from `Blackboard.forRole`; + * cannot widen its own scope. + */ +export class RoleBlackboard { + #store: BlackboardStore; + #scope: GoalScope; + #identity: RoleIdentity; + #io: RoleIo; + + constructor(store: BlackboardStore, scope: GoalScope, identity: RoleIdentity, io: RoleIo) { + this.#store = store; + this.#scope = scope; + this.#identity = identity; + this.#io = io; + } + + get reads(): readonly string[] { + return this.#io.reads; + } + get writes(): readonly string[] { + return this.#io.writes; + } + + /** This role's own slot for a multi-writer kind — never another role's. */ + #ownSlot(kind: string): string | null { + if (!MULTI_WRITER_KINDS.has(kind)) return null; + return this.#identity.ordinal > 1 + ? `${this.#identity.roleName}#${this.#identity.ordinal}` + : this.#identity.roleName; + } + + #denyRead(kind: string): BlackboardDenial | null { + if (!isValidArtifactKind(kind)) { + return { ok: false, error: `Unknown artifact kind "${kind}"` }; + } + if (!this.#io.reads.includes(kind)) { + return { + ok: false, + error: `Role "${this.#identity.roleName}" may not read "${kind}" — it reads: ${this.#io.reads.join(", ") || "(nothing)"}`, + }; + } + return null; + } + + /** Latest version of a readable artifact. `null` value = not written yet. */ + read(kind: string, slot?: string | null): BlackboardResult { + const denied = this.#denyRead(kind); + if (denied) return denied; + return { ok: true, value: this.#store.latest(this.#scope, kind, slot ?? null) }; + } + + /** + * Every slot of a readable multi-writer kind — how the orchestrator collects + * all N reviewers' findings for synthesis. + */ + readAll(kind: string): BlackboardResult { + const denied = this.#denyRead(kind); + if (denied) return denied; + return { ok: true, value: this.#store.latestAllSlots(this.#scope, kind) }; + } + + /** + * Append a new version of a writable artifact. + * + * The slot is chosen by the SERVICE, never by the caller: a reviewer writes + * into its own slot and has no way to name someone else's. Letting a caller + * pass a slot would hand one reviewer the ability to overwrite another's + * findings, which is the whole thing slots exist to prevent. + */ + write(kind: string, content: string): BlackboardResult { + if (!isValidArtifactKind(kind)) { + return { ok: false, error: `Unknown artifact kind "${kind}"` }; + } + if (!this.#io.writes.includes(kind)) { + return { + ok: false, + error: `Role "${this.#identity.roleName}" may not write "${kind}" — it writes: ${this.#io.writes.join(", ") || "(nothing)"}`, + }; + } + if (content.length > ARTIFACT_CONTENT_MAX) { + return { + ok: false, + error: `Artifact "${kind}" is ${content.length} bytes — max ${ARTIFACT_CONTENT_MAX}. Put large output in the workspace and reference it.`, + }; + } + return { + ok: true, + value: this.#store.append({ + scope: this.#scope, + kind, + slot: this.#ownSlot(kind), + content, + authorSub: this.#identity.authorSub, + authorRole: this.#identity.roleName, + now: Date.now(), + }), + }; + } + + /** + * The index. Deliberately NOT scoped by `reads`: knowing that a `diff` exists + * at v3 is not the same as reading it, the orchestrator needs the whole + * picture to schedule (§4), and bodies never appear here. Reading still + * requires the read scope. + */ + index(): ArtifactIndexEntry[] { + return this.#store.index(this.#scope); + } +} + +/** The daemon-owned blackboard: one store, many goal-and-role-scoped views. */ +export class Blackboard { + #store: BlackboardStore; + + constructor(store: BlackboardStore) { + this.#store = store; + } + + /** + * A role's handle on one goal. + * + * `declared` comes from the role's own `reads`/`writes`. When it declares + * neither, the §3 default profile for that role name applies; when the name + * isn't in the profile either, the handle can do nothing — a new role must + * say what it touches before it touches anything. + */ + forRole( + scope: GoalScope, + identity: RoleIdentity, + declared?: { reads?: readonly string[]; writes?: readonly string[] }, + ): RoleBlackboard { + return new RoleBlackboard( + this.#store, + scope, + identity, + resolveRoleIo(identity.roleName, declared), + ); + } + + /** Drop a goal's artifacts. Called on collaboration teardown. */ + deleteGoal(scope: GoalScope): number { + return this.#store.deleteGoal(scope); + } +} diff --git a/src/daemon/blackboard/store.ts b/src/daemon/blackboard/store.ts new file mode 100644 index 00000000..b5bb0c60 --- /dev/null +++ b/src/daemon/blackboard/store.ts @@ -0,0 +1,292 @@ +/** + * Durable storage for goal-blackboard artifacts + * (docs/collaborative-session-design.md §4). + * + * Shares the daemon's SQLite connection rather than opening a second handle — + * same contract as the pipeline store (`Store.database`). + * + * Two invariants this layer owns: + * + * 1. **Every query is tenant-scoped** on `account_id` AND `project_id`. A + * goal id alone is not a permission: reading by goal without the tenant + * would let one account's agent read another's artifacts if a session id + * ever leaked or collided. + * 2. **Writes never overwrite.** A write appends version N+1, so the history + * of a handoff is intact and a reviewer's findings can't be silently + * replaced by a later run. Readers ask for "latest" explicitly. + */ + +import type { Database } from "bun:sqlite"; +import { randomUUID } from "node:crypto"; +import type { Artifact, ArtifactIndexEntry } from "./types.js"; + +interface RawArtifactRow { + id: string; + goal_session_id: string; + kind: string; + slot: string | null; + version: number; + content: string; + author_sub: string; + author_role: string | null; + created_at: number; +} + +function toArtifact(r: RawArtifactRow): Artifact { + return { + id: r.id, + goalSessionId: r.goal_session_id, + kind: r.kind, + slot: r.slot, + version: r.version, + content: r.content, + authorSub: r.author_sub, + authorRole: r.author_role, + createdAt: r.created_at, + }; +} + +/** Tenant + goal scope carried on every call — never derived from a client. */ +export interface GoalScope { + accountId: string; + projectId: string; + goalSessionId: string; +} + +export class BlackboardStore { + #db: Database; + + constructor(db: Database) { + this.#db = db; + this.#migrate(); + } + + #migrate(): void { + this.#db.exec(` + CREATE TABLE IF NOT EXISTS collaboration_artifacts ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + project_id TEXT NOT NULL, + -- Goal scope = the orchestrating parent session's id. Artifacts die + -- with the goal, so this cascades on session delete. + goal_session_id TEXT NOT NULL, + kind TEXT NOT NULL, -- core kind | 'extra/' + slot TEXT, -- multi-writer discriminator; NULL = singleton + version INTEGER NOT NULL, -- 1-based, monotonic per (goal, kind, slot) + content TEXT NOT NULL, + author_sub TEXT NOT NULL, -- producing ZeroID subject + author_role TEXT, -- collaboration role name + created_at INTEGER NOT NULL, + -- Makes the append-only contract a storage guarantee, not a convention: + -- a racing double-write on the same version fails loudly instead of one + -- silently winning. + UNIQUE (goal_session_id, kind, slot, version) + ); + -- The read path is always (tenant, goal) then kind/slot, newest first. + CREATE INDEX IF NOT EXISTS idx_artifacts_goal + ON collaboration_artifacts(account_id, project_id, goal_session_id, kind, slot, version DESC); + `); + } + + /** + * Append the next version of one artifact and return it. + * + * The read-max-then-insert pair runs inside an IMMEDIATE transaction so two + * concurrent writers can't compute the same next version — one of them would + * otherwise lose its write to the UNIQUE constraint. Children on different + * backends genuinely do write concurrently, so this is a real race, not a + * theoretical one. + */ + append(input: { + scope: GoalScope; + kind: string; + slot?: string | null; + content: string; + authorSub: string; + authorRole?: string | null; + now: number; + }): Artifact { + const slot = input.slot ?? null; + const run = this.#db.transaction((): Artifact => { + const row = this.#db + .prepare( + `SELECT COALESCE(MAX(version), 0) AS v + FROM collaboration_artifacts + WHERE account_id = ? AND project_id = ? AND goal_session_id = ? + AND kind = ? AND slot IS ?`, + ) + .get( + input.scope.accountId, + input.scope.projectId, + input.scope.goalSessionId, + input.kind, + slot, + ) as { v: number }; + const version = row.v + 1; + const id = randomUUID(); + this.#db + .prepare( + `INSERT INTO collaboration_artifacts + (id, account_id, project_id, goal_session_id, kind, slot, version, + content, author_sub, author_role, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + id, + input.scope.accountId, + input.scope.projectId, + input.scope.goalSessionId, + input.kind, + slot, + version, + input.content, + input.authorSub, + input.authorRole ?? null, + input.now, + ); + return { + id, + goalSessionId: input.scope.goalSessionId, + kind: input.kind, + slot, + version, + content: input.content, + authorSub: input.authorSub, + authorRole: input.authorRole ?? null, + createdAt: input.now, + }; + }); + // IMMEDIATE: take the write lock up front rather than upgrading mid-txn, + // which under WAL is what produces SQLITE_BUSY between two writers. + return run.immediate(); + } + + /** Latest version of one artifact, or null. */ + latest(scope: GoalScope, kind: string, slot?: string | null): Artifact | null { + const row = this.#db + .prepare( + `SELECT * FROM collaboration_artifacts + WHERE account_id = ? AND project_id = ? AND goal_session_id = ? + AND kind = ? AND slot IS ? + ORDER BY version DESC LIMIT 1`, + ) + .get( + scope.accountId, + scope.projectId, + scope.goalSessionId, + kind, + slot ?? null, + ) as RawArtifactRow | undefined; + return row ? toArtifact(row) : null; + } + + /** One specific version — for auditing a handoff after the fact. */ + version( + scope: GoalScope, + kind: string, + version: number, + slot?: string | null, + ): Artifact | null { + const row = this.#db + .prepare( + `SELECT * FROM collaboration_artifacts + WHERE account_id = ? AND project_id = ? AND goal_session_id = ? + AND kind = ? AND slot IS ? AND version = ?`, + ) + .get( + scope.accountId, + scope.projectId, + scope.goalSessionId, + kind, + slot ?? null, + version, + ) as RawArtifactRow | undefined; + return row ? toArtifact(row) : null; + } + + /** + * Every slot of one kind at its latest version — how a synthesizing + * orchestrator collects all N reviewers' `findings` in one call. + */ + latestAllSlots(scope: GoalScope, kind: string): Artifact[] { + const rows = this.#db + .prepare( + `SELECT a.* FROM collaboration_artifacts a + JOIN ( + SELECT slot, MAX(version) AS v + FROM collaboration_artifacts + WHERE account_id = ? AND project_id = ? AND goal_session_id = ? AND kind = ? + GROUP BY slot + ) m ON m.v = a.version AND m.slot IS a.slot + WHERE a.account_id = ? AND a.project_id = ? AND a.goal_session_id = ? AND a.kind = ? + ORDER BY a.slot IS NULL DESC, a.slot ASC`, + ) + .all( + scope.accountId, + scope.projectId, + scope.goalSessionId, + kind, + scope.accountId, + scope.projectId, + scope.goalSessionId, + kind, + ) as RawArtifactRow[]; + return rows.map(toArtifact); + } + + /** + * The index: what exists at what version, no bodies. `bytes` is computed in + * SQL so a large artifact is never loaded just to report its size. + */ + index(scope: GoalScope): ArtifactIndexEntry[] { + const rows = this.#db + .prepare( + `SELECT a.kind, a.slot, a.version, a.author_sub, a.author_role, + a.created_at, LENGTH(a.content) AS bytes + FROM collaboration_artifacts a + JOIN ( + SELECT kind, slot, MAX(version) AS v + FROM collaboration_artifacts + WHERE account_id = ? AND project_id = ? AND goal_session_id = ? + GROUP BY kind, slot + ) m ON m.kind = a.kind AND m.slot IS a.slot AND m.v = a.version + WHERE a.account_id = ? AND a.project_id = ? AND a.goal_session_id = ? + ORDER BY a.kind ASC, a.slot IS NULL DESC, a.slot ASC`, + ) + .all( + scope.accountId, + scope.projectId, + scope.goalSessionId, + scope.accountId, + scope.projectId, + scope.goalSessionId, + ) as Array<{ + kind: string; + slot: string | null; + version: number; + author_sub: string; + author_role: string | null; + created_at: number; + bytes: number; + }>; + return rows.map((r) => ({ + kind: r.kind, + slot: r.slot, + version: r.version, + authorSub: r.author_sub, + authorRole: r.author_role, + updatedAt: r.created_at, + bytes: r.bytes, + })); + } + + /** Drop a goal's artifacts (goal end). Tenant-scoped like everything else. */ + deleteGoal(scope: GoalScope): number { + return this.#db + .prepare( + `DELETE FROM collaboration_artifacts + WHERE account_id = ? AND project_id = ? AND goal_session_id = ?`, + ) + .run(scope.accountId, scope.projectId, scope.goalSessionId).changes; + } +} diff --git a/src/daemon/blackboard/types.ts b/src/daemon/blackboard/types.ts new file mode 100644 index 00000000..1c22876c --- /dev/null +++ b/src/daemon/blackboard/types.ts @@ -0,0 +1,99 @@ +/** + * Goal blackboard — the typed-artifact vocabulary + * (docs/collaborative-session-design.md §4). + * + * The blackboard is how role-children hand work to each other WITHOUT the + * orchestrator re-serializing it as prose. A searcher writes `research`; an + * architect reads `research`+`spec` and writes `adr`; each reviewer writes its + * own `findings` entry. The orchestrator holds an index of what exists at what + * version — never the artifact bodies. + * + * Why a fixed core plus a scoped escape hatch, settled in the 2026-07-25 + * grill: a wholly free-form key space makes access scoping meaningless (you + * cannot grant "read the spec" if `spec` isn't a real name), while a closed + * enum makes a new pack a code change. So: six core kinds that scoping and + * tooling can rely on, and `extra/` for anything else. + */ + +/** + * The fixed core artifact kinds. Order is the natural SDLC flow, which is also + * how an index renders. + */ +export const CORE_ARTIFACT_KINDS = [ + "spec", + "research", + "adr", + "task-list", + "diff", + "findings", +] as const; + +export type CoreArtifactKind = (typeof CORE_ARTIFACT_KINDS)[number]; + +/** `extra/` — the scoped escape hatch. Lowercase, bounded, no nesting. */ +const EXTRA_PREFIX = "extra/"; +const EXTRA_KEY_RE = /^[a-z0-9][a-z0-9-]{0,39}$/; + +/** Max stored body per artifact version. A handoff is a document, not a blob; + * anything larger belongs in the workspace with the artifact pointing at it. */ +export const ARTIFACT_CONTENT_MAX = 256 * 1024; + +/** Max `extra/` length including the prefix, for column sizing sanity. */ +export const ARTIFACT_KIND_MAX = 64; + +export function isCoreArtifactKind(kind: string): kind is CoreArtifactKind { + return (CORE_ARTIFACT_KINDS as readonly string[]).includes(kind); +} + +/** + * Validate an artifact kind — a core name, or a well-formed `extra/`. + * + * Rejects rather than normalizing: a typo'd kind that silently became a new + * `extra/` slot would look like a successful handoff while the intended reader + * waits forever on an artifact nobody wrote. + */ +export function isValidArtifactKind(kind: string): boolean { + if (isCoreArtifactKind(kind)) return true; + if (!kind.startsWith(EXTRA_PREFIX)) return false; + if (kind.length > ARTIFACT_KIND_MAX) return false; + return EXTRA_KEY_RE.test(kind.slice(EXTRA_PREFIX.length)); +} + +/** One stored version of one artifact. */ +export interface Artifact { + id: string; + /** Goal scope: the orchestrating (parent) session's id. */ + goalSessionId: string; + /** A core kind or `extra/`. */ + kind: string; + /** + * Discriminator within a kind, for the genuinely multi-writer case: §4 has + * *each* reviewer write a `findings` entry, and without a slot reviewer #2 + * would overwrite reviewer #1 — silently collapsing a panel to one opinion. + * NULL/absent = the singleton slot. + */ + slot: string | null; + /** 1-based, monotonic per (goal, kind, slot). Writes never overwrite. */ + version: number; + content: string; + /** ZeroID subject of the producing agent — every contribution attributable. */ + authorSub: string; + /** Collaboration role that produced it, when written by a role-child. */ + authorRole: string | null; + createdAt: number; +} + +/** An index row: what exists, at what version, by whom — no bodies. This is + * all the orchestrator ever needs (§4: "holds an index, not the artifacts"). */ +export interface ArtifactIndexEntry { + kind: string; + slot: string | null; + /** Latest version present. */ + version: number; + authorSub: string; + authorRole: string | null; + updatedAt: number; + /** Body size of the latest version, so the orchestrator can reason about + * cost before asking a child to read it. */ + bytes: number; +} diff --git a/src/daemon/collaboration.ts b/src/daemon/collaboration.ts index f7638d94..d2e925dd 100644 --- a/src/daemon/collaboration.ts +++ b/src/daemon/collaboration.ts @@ -18,6 +18,8 @@ import type { CollaborationConfig, CollaborationRole } from "../protocol/types.js"; import { LIMITS, ORCHESTRATOR_ROLE } from "../protocol/types.js"; import { CLAUDE_PROVIDER_ID, resolveModelIdForProvider } from "./models.js"; +import { CORE_ARTIFACT_KINDS, isValidArtifactKind } from "./blackboard/types.js"; +import { resolveRoleIo } from "./blackboard/service.js"; /** The provider-registry surface this module needs — kept narrow so tests * can pass a stub instead of building a real registry. */ @@ -86,6 +88,25 @@ export function validateCollaboration( }; } + // Blackboard scoping: an unknown kind must reject here. Left to pass, a + // typo like `reads: ["diffs"]` would produce a role that appears scoped but + // can never read the artifact it needs, and the failure would surface much + // later as an agent inexplicably waiting on a handoff. + for (const [field, kinds] of [ + ["reads", raw.reads], + ["writes", raw.writes], + ] as const) { + if (!kinds) continue; + for (const kind of kinds) { + if (!isValidArtifactKind(kind)) { + return { + ok: false, + error: `Role "${name}" ${field} unknown artifact kind "${kind}" — valid: ${CORE_ARTIFACT_KINDS.join(", ")}, or extra/`, + }; + } + } + } + const count = raw.count ?? 1; if (!Number.isInteger(count) || count < 1) { return { ok: false, error: `Role "${name}" count must be a positive integer` }; @@ -128,6 +149,13 @@ export function validateCollaboration( // Normalize to an explicit boolean so downstream code never has to // re-decide what "absent" means for write authority. write: raw.write === true, + // Left ABSENT when undeclared, deliberately — the blackboard service + // distinguishes "declared nothing" (fall back to the §3 default profile + // for this role name) from "declared an empty list" (reads/writes + // nothing). Defaulting to [] here would erase that distinction and + // silently strip every default profile. + ...(raw.reads !== undefined ? { reads: [...raw.reads] } : {}), + ...(raw.writes !== undefined ? { writes: [...raw.writes] } : {}), }); } @@ -206,6 +234,9 @@ export interface PlannedChild { shape: "ship" | "scout"; write: boolean; purpose?: string; + /** Declared blackboard scope, if the role set one; absent = §3 default. */ + reads?: readonly string[]; + writes?: readonly string[]; } /** @@ -239,6 +270,8 @@ export function planChildren( shape: role.write === true ? "ship" : "scout", write: role.write === true, ...(role.purpose !== undefined ? { purpose: role.purpose } : {}), + ...(role.reads !== undefined ? { reads: role.reads } : {}), + ...(role.writes !== undefined ? { writes: role.writes } : {}), }); } } @@ -274,12 +307,25 @@ export function childBrief( const contract = child.write ? "You MAY modify files in your workdir. Keep the diff minimal and verify your work." : "You are READ-ONLY: your identity holds no write scope, so file edits will be denied. Investigate and report — your written findings are the deliverable."; + const io = resolveRoleIo(child.roleName, { reads: child.reads, writes: child.writes }); return [ ` 1 ? ` member="${child.ordinal}"` : ""}>`, `You are the "${child.roleName}" role in a collaborative session working one shared goal.`, child.purpose ? `Your purpose: ${child.purpose}` : null, contract, "You are one of several agents on this goal, possibly on different model backends. You cannot see the others' work or the orchestrator's reasoning — that is deliberate, so your contribution stays independent.", + "", + "## Handing work off", + "", + "Shared state lives on the goal BLACKBOARD, not in chat. Use the blackboard tools:", + "- `blackboard_index` — what exists, at what version, written by whom (no contents).", + "- `blackboard_read` / `blackboard_read_all` — read an artifact you are scoped for.", + "- `blackboard_write` — publish YOUR output. It appends a version; it never overwrites, and for multi-writer kinds you write your own entry.", + "", + `You can READ: ${io.reads.length > 0 ? io.reads.join(", ") : "(nothing — you work only from the task you are sent)"}`, + `You can WRITE: ${io.writes.length > 0 ? io.writes.join(", ") : "(nothing — report back in your reply instead)"}`, + "Anything outside that is refused by the daemon, not by your own judgement — don't work around it, and don't ask another agent to fetch it for you.", + "", "Wait for instructions from the orchestrator before acting; it will send you a specific task.", "", "", diff --git a/src/daemon/mcp/jsonrpc-http.ts b/src/daemon/mcp/jsonrpc-http.ts new file mode 100644 index 00000000..24a31b2e --- /dev/null +++ b/src/daemon/mcp/jsonrpc-http.ts @@ -0,0 +1,52 @@ +/** + * Shared JSON-RPC + bearer-auth plumbing for the daemon's in-process MCP + * endpoints (memory, goal blackboard). + * + * Extracted rather than copied: `tokenFrom` is the authorization boundary for + * every one of these mounts, and two hand-maintained copies of bearer parsing + * is precisely the kind of duplication that drifts — one gets a fix, the other + * quietly keeps the hole. + */ + +export type JsonRpcId = string | number | null; + +export interface JsonRpcMessage { + jsonrpc?: string; + id?: JsonRpcId; + method?: string; + params?: Record; +} + +export interface JsonRpcResponse { + jsonrpc: "2.0"; + id: JsonRpcId; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +export function ok(id: JsonRpcId, result: unknown): JsonRpcResponse { + return { jsonrpc: "2.0", id, result }; +} + +export function rpcErr(id: JsonRpcId, code: number, message: string): JsonRpcResponse { + return { jsonrpc: "2.0", id, error: { code, message } }; +} + +/** + * Bearer token from the Authorization header, falling back to a `token` query + * param for clients that can't set headers on an MCP mount. + */ +export function tokenFrom(req: Request): string | null { + const auth = req.headers.get("authorization"); + if (auth && auth.length > 7 && auth.slice(0, 7).toLowerCase() === "bearer ") { + const t = auth.slice(7).trim(); + if (t) return t; + } + // Fallback base so a relative req.url (some test/client setups) can't throw; + // Bun.serve hands us absolute URLs, the base is only used to parse the query. + const q = new URL(req.url, "http://localhost").searchParams.get("token"); + return q && q.length > 0 ? q : null; +} + +/** Echoed only when the client doesn't propose its own protocolVersion. */ +export const DEFAULT_MCP_PROTOCOL_VERSION = "2025-06-18"; diff --git a/src/daemon/memory/mcp-http.ts b/src/daemon/memory/mcp-http.ts index b59d734f..86e2b14f 100644 --- a/src/daemon/memory/mcp-http.ts +++ b/src/daemon/memory/mcp-http.ts @@ -25,6 +25,14 @@ */ import { randomUUID } from "node:crypto"; +import { + DEFAULT_MCP_PROTOCOL_VERSION, + ok, + rpcErr, + tokenFrom, + type JsonRpcMessage, + type JsonRpcResponse, +} from "../mcp/jsonrpc-http.js"; import type { MemoryEngine } from "./engine.js"; import { memoryToolDefs, type MemoryToolContext, type MemoryToolDef } from "./tools.js"; @@ -58,42 +66,11 @@ export interface MemoryMcpMount { } const SERVER_INFO = { name: "codeoid-memory", version: "0.1.0" } as const; -/** Echoed only when the client doesn't propose its own protocolVersion. */ -const DEFAULT_PROTOCOL_VERSION = "2025-06-18"; - -type JsonRpcId = string | number | null; -interface JsonRpcMessage { - jsonrpc?: string; - id?: JsonRpcId; - method?: string; - params?: Record; -} -interface JsonRpcResponse { - jsonrpc: "2.0"; - id: JsonRpcId; - result?: unknown; - error?: { code: number; message: string; data?: unknown }; -} -function ok(id: JsonRpcId, result: unknown): JsonRpcResponse { - return { jsonrpc: "2.0", id, result }; -} -function rpcErr(id: JsonRpcId, code: number, message: string): JsonRpcResponse { - return { jsonrpc: "2.0", id, error: { code, message } }; -} + + /** Bearer token from the Authorization header, or a `?token=` query fallback. */ -function tokenFrom(req: Request): string | null { - const auth = req.headers.get("authorization"); - if (auth && auth.length > 7 && auth.slice(0, 7).toLowerCase() === "bearer ") { - const t = auth.slice(7).trim(); - if (t) return t; - } - // Fallback base so a relative req.url (some test/client setups) can't throw; - // Bun.serve hands us absolute URLs, the base is only used to parse the query. - const q = new URL(req.url, "http://localhost").searchParams.get("token"); - return q && q.length > 0 ? q : null; -} export class MemoryMcpHttp { readonly #engine: MemoryEngine; @@ -188,7 +165,7 @@ export class MemoryMcpHttp { case "initialize": { const requested = msg?.params?.protocolVersion; return ok(id, { - protocolVersion: typeof requested === "string" ? requested : DEFAULT_PROTOCOL_VERSION, + protocolVersion: typeof requested === "string" ? requested : DEFAULT_MCP_PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER_INFO, }); diff --git a/src/daemon/providers/acp/index.ts b/src/daemon/providers/acp/index.ts index 4ad12450..81d33754 100644 --- a/src/daemon/providers/acp/index.ts +++ b/src/daemon/providers/acp/index.ts @@ -39,6 +39,7 @@ import { renderHistorySeed, type CanonicalTurn, type HistorySeedResult } from ". import { buildGeminiCliEnv } from "../env.js"; import { StdioJsonRpcProcess } from "../jsonrpc-stdio.js"; import { MEMORY_MCP_SERVER_NAME, type MemoryMcpMount } from "../../memory/mcp-http.js"; +import { BLACKBOARD_MCP_SERVER_NAME } from "../../blackboard/mcp-http.js"; import type { McpRegistry } from "../../mcp/registry.js"; import { resolveEnvMap } from "../../mcp/types.js"; @@ -57,6 +58,16 @@ export interface GeminiAcpProviderInit { * store on demand — the precondition for the Verbatim Working Set strategy. */ memoryMcp?: MemoryMcpMount; + /** + * Role-scoped goal-blackboard mount, resolved LAZILY. + * + * A getter rather than a value because the orchestrator's own mount is + * scoped to a goal id that IS its session id — so it cannot exist until + * after the Session is constructed. Providers read it when they build their + * server list (per turn for claude), by which time it is set. + */ + blackboardMcp?: () => { url: string; token: string } | undefined; + /** Cross-backend MCP registry — external servers mount on session/new * (gemini-cli owns its client); approval flows through canUseTool. */ mcpRegistry?: McpRegistry; @@ -77,6 +88,8 @@ export class GeminiAcpProvider implements SessionProvider { #argsPrefix: string[]; #workspaceId: string; #memoryMcp: MemoryMcpMount | null; + /** Role-scoped blackboard mount; token minted+revoked by the SessionManager. */ + readonly #blackboardMcp: (() => { url: string; token: string } | undefined) | null; #mcpRegistry: McpRegistry | null; /** Live scoped token for the mounted memory endpoint; revoked on teardown. */ #memoryToken: string | null = null; @@ -110,6 +123,7 @@ export class GeminiAcpProvider implements SessionProvider { this.#argsPrefix = init.argsPrefix ?? []; this.#workspaceId = init.workspaceId ?? init.sessionId; this.#memoryMcp = init.memoryMcp ?? null; + this.#blackboardMcp = init.blackboardMcp ?? null; this.#mcpRegistry = init.mcpRegistry ?? null; } @@ -348,6 +362,21 @@ export class GeminiAcpProvider implements SessionProvider { headers: [{ name: "Authorization", value: `Bearer ${this.#memoryToken}` }], }); } + // Role-scoped goal blackboard (collaboration children). Deliberately NO + // mint/revoke here, unlike memory above: the SessionManager owns this + // token's lifetime, because the scope it encodes belongs to the + // collaboration rather than to a backing session that resetToNewSession + // may recreate. Re-minting per backing session would hand this child a + // second live token the manager can't revoke at teardown. + const bb = this.#blackboardMcp?.(); + if (bb) { + servers.push({ + type: "http", + name: BLACKBOARD_MCP_SERVER_NAME, + url: bb.url, + headers: [{ name: "Authorization", value: `Bearer ${bb.token}` }], + }); + } // Registry servers — native mount (gemini-cli owns its MCP client). ACP's // McpServer shape: http → {type,url,headers[]}; stdio → {command,args,env[]} // (env/headers are {name,value} pairs, mirroring the memory http mount). diff --git a/src/daemon/providers/claude/index.ts b/src/daemon/providers/claude/index.ts index a750f4f4..8395918c 100644 --- a/src/daemon/providers/claude/index.ts +++ b/src/daemon/providers/claude/index.ts @@ -33,6 +33,7 @@ import { MEMORY_TOOL_NAMES, type MemoryEngine, } from "../../memory/index.js"; +import { BLACKBOARD_MCP_SERVER_NAME } from "../../blackboard/mcp-http.js"; import type { McpRegistry } from "../../mcp/registry.js"; import { resolveEnvMap } from "../../mcp/types.js"; import type { CompressionRegistry } from "../../compress/index.js"; @@ -48,6 +49,21 @@ import type { PackSubagent } from "../../pipeline/subagents.js"; /** Map ambient-pack subagents to the Claude Agent SDK's programmatic `agents` * option (keyed by name). Empty → no `agents` key at all. */ +/** `{ codeoid_blackboard: … }` when a mount is present, else `{}`. Split out so + * the spread never introduces an `undefined`-valued key. */ +function blackboardServerEntry( + mount: { url: string; token: string } | undefined, +): Record { + if (!mount) return {}; + return { + [BLACKBOARD_MCP_SERVER_NAME]: { + type: "http", + url: mount.url, + headers: { Authorization: `Bearer ${mount.token}` }, + } as unknown as McpServerConfig, + }; +} + function packAgentsOption( subs?: readonly PackSubagent[], ): { agents?: Record } { @@ -79,6 +95,15 @@ export interface ClaudeProviderInit { memory?: MemoryEngine; /** codeoid_fleet MCP server — conductor sessions only (read-only fleet view). */ fleet?: McpSdkServerConfigWithInstance; + /** + * Role-scoped goal-blackboard mount, resolved LAZILY. + * + * A getter rather than a value because the orchestrator's own mount is + * scoped to a goal id that IS its session id — so it cannot exist until + * after the Session is constructed. Providers read it when they build their + * server list (per turn for claude), by which time it is set. + */ + blackboardMcp?: () => { url: string; token: string } | undefined; /** Cross-backend MCP registry — external servers are mounted natively on the * SDK (claude owns its own MCP client); approval flows through canUseTool. */ mcpRegistry?: McpRegistry; @@ -388,6 +413,12 @@ export class ClaudeProvider implements SessionProvider { // Conductor sessions only — the read-only fleet view (P3). In-process, // so the external-server timeout doesn't apply. ...(init.fleet ? { codeoid_fleet: init.fleet } : {}), + // Collaboration children only — the role-scoped goal blackboard. Mounted + // over HTTP rather than in-process precisely so the SAME surface works on + // gemini/codex; claude deliberately gets no privileged path here (#245). + // Resolved per query build, not captured at construction — the + // orchestrator's mount is attached after its Session exists. + ...blackboardServerEntry(init.blackboardMcp?.()), // Registry servers — mounted natively on the SDK (claude owns its client); // tool calls surface as `mcp____` and gate via canUseTool. ...withMcpToolTimeout(registryServersForClaude(init.mcpRegistry), mcpToolTimeoutMs), diff --git a/src/daemon/providers/codex/index.ts b/src/daemon/providers/codex/index.ts index ec3f1e6f..5d207189 100644 --- a/src/daemon/providers/codex/index.ts +++ b/src/daemon/providers/codex/index.ts @@ -61,6 +61,10 @@ import { buildCodexEnv } from "../env.js"; import { CodexRpcProcess } from "./rpc.js"; import type { SessionMode } from "../../../protocol/types.js"; import { MEMORY_MCP_SERVER_NAME, MEMORY_MCP_TOKEN_ENV, type MemoryMcpMount } from "../../memory/mcp-http.js"; +import { + BLACKBOARD_MCP_SERVER_NAME, + BLACKBOARD_MCP_TOKEN_ENV, +} from "../../blackboard/mcp-http.js"; import type { McpRegistry } from "../../mcp/registry.js"; import { resolveEnvMap } from "../../mcp/types.js"; @@ -89,6 +93,16 @@ export interface CodexProviderInit { * codex can page the verbatim store on demand — the precondition for VWS. */ memoryMcp?: MemoryMcpMount; + /** + * Role-scoped goal-blackboard mount, resolved LAZILY. + * + * A getter rather than a value because the orchestrator's own mount is + * scoped to a goal id that IS its session id — so it cannot exist until + * after the Session is constructed. Providers read it when they build their + * server list (per turn for claude), by which time it is set. + */ + blackboardMcp?: () => { url: string; token: string } | undefined; + /** Cross-backend MCP registry — external servers mount natively via `-c * mcp_servers.*` (codex owns its client); approval flows through canUseTool. */ mcpRegistry?: McpRegistry; @@ -198,6 +212,8 @@ export class CodexProvider implements SessionProvider { #onModels?: CodexProviderInit["onModels"]; #workspaceId: string; #memoryMcp: MemoryMcpMount | null; + /** Role-scoped blackboard mount; token minted+revoked by the SessionManager. */ + readonly #blackboardMcp: (() => { url: string; token: string } | undefined) | null; #mcpRegistry: McpRegistry | null; /** Live scoped token for the mounted memory endpoint; revoked on teardown. */ #memoryToken: string | null = null; @@ -241,6 +257,7 @@ export class CodexProvider implements SessionProvider { this.#onModels = init.onModels; this.#workspaceId = init.workspaceId ?? init.sessionId; this.#memoryMcp = init.memoryMcp ?? null; + this.#blackboardMcp = init.blackboardMcp ?? null; this.#mcpRegistry = init.mcpRegistry ?? null; } @@ -315,6 +332,27 @@ export class CodexProvider implements SessionProvider { }; } + /** + * `-c mcp_servers.codeoid_blackboard.*` — the role-scoped goal blackboard for + * a collaboration child, same TOML shape as the memory mount above. + * + * No mint/revoke here: the SessionManager owns this token, because the scope + * it carries belongs to the collaboration rather than to a codex backing + * session that may be recreated mid-goal. + */ + #blackboardMcpSpawn(): { args: string[]; env: Record } { + const mount = this.#blackboardMcp?.(); + if (!mount) return { args: [], env: {} }; + const key = `mcp_servers.${BLACKBOARD_MCP_SERVER_NAME}`; + return { + args: [ + "-c", `${key}.url=${JSON.stringify(mount.url)}`, + "-c", `${key}.bearer_token_env_var=${JSON.stringify(BLACKBOARD_MCP_TOKEN_ENV)}`, + ], + env: { [BLACKBOARD_MCP_TOKEN_ENV]: mount.token }, + }; + } + /** * `-c mcp_servers.*` overrides mounting the registry's external servers on the * codex app-server — a native mount, since codex owns its own MCP client. @@ -452,13 +490,14 @@ export class CodexProvider implements SessionProvider { // demand. No CODEX_HOME/auth.json juggling — the default ~/.codex keeps // the user's auth + config; these just add the one server. const mcp = this.#memoryMcpSpawn(); + const bb = this.#blackboardMcpSpawn(); const reg = this.#registryMcpArgs(); this.#proc = new CodexRpcProcess({ command: this.#command, argsPrefix: this.#argsPrefix, - args: [...mcp.args, ...reg.args], + args: [...mcp.args, ...bb.args, ...reg.args], cwd: opts.workdir, - env: { ...buildCodexEnv(), ...mcp.env, ...reg.env }, + env: { ...buildCodexEnv(), ...mcp.env, ...bb.env, ...reg.env }, onNotification: (method, params) => this.#onNotification(method, params), onServerRequest: (method, params) => this.#onServerRequest(method, params), onExit: ({ code, signal, stderrTail }) => { diff --git a/src/daemon/providers/registry.ts b/src/daemon/providers/registry.ts index a6658ea8..6c8760e3 100644 --- a/src/daemon/providers/registry.ts +++ b/src/daemon/providers/registry.ts @@ -52,6 +52,9 @@ export interface ProviderSessionInit { /** Shared in-daemon memory MCP endpoint + URL — mounted by URL-based backends * (gemini-cli, later codex). Present only when memory is enabled. */ memoryMcp?: MemoryMcpMount; + /** Role-scoped goal-blackboard mount, resolved lazily — see the provider + * inits for why it is a getter and not a value. */ + blackboardMcp?: () => { url: string; token: string } | undefined; /** Cross-backend MCP registry — the servers to mount on this session's backend. */ mcpRegistry?: McpRegistry; /** Daemon-owned MCP client pool backing the registry (Model-B backends execute @@ -173,6 +176,7 @@ export function createDefaultProviderRegistry(config?: CodeoidConfig): ProviderR identityManager: init.identityManager, memory: init.memory, fleet: init.fleet, + blackboardMcp: init.blackboardMcp, mcpRegistry: init.mcpRegistry, config: init.config, compressionRegistry: init.compressionRegistry, @@ -282,6 +286,7 @@ export function createDefaultProviderRegistry(config?: CodeoidConfig): ProviderR store: init.store, workspaceId: init.workspaceId, memoryMcp: init.memoryMcp, + blackboardMcp: init.blackboardMcp, mcpRegistry: init.mcpRegistry, onModels: init.onModels, }), @@ -311,6 +316,7 @@ export function createDefaultProviderRegistry(config?: CodeoidConfig): ProviderR store: init.store, workspaceId: init.workspaceId, memoryMcp: init.memoryMcp, + blackboardMcp: init.blackboardMcp, mcpRegistry: init.mcpRegistry, onModels: init.onModels, }), diff --git a/src/daemon/providers/tool-safety.ts b/src/daemon/providers/tool-safety.ts index af1709ca..cbe57b5f 100644 --- a/src/daemon/providers/tool-safety.ts +++ b/src/daemon/providers/tool-safety.ts @@ -5,6 +5,7 @@ * widen: an over-broad match here is a prompt-bypass, so it's security-relevant. */ +import { BLACKBOARD_MCP_SERVER_NAME } from "../blackboard/mcp-http.js"; import { MEMORY_MCP_SERVER_NAME } from "../memory/mcp-http.js"; import { MEMORY_TOOL_NAMES } from "../memory/tools.js"; @@ -17,6 +18,27 @@ const MEMORY_TOOL_PREFIXES = [ `${MEMORY_MCP_SERVER_NAME}__`, // gemini-cli / codex URL mount ] as const; +/** Same two namespacing conventions, for the goal-blackboard mount. */ +const BLACKBOARD_TOOL_PREFIXES = [ + `mcp__${BLACKBOARD_MCP_SERVER_NAME}__`, + `${BLACKBOARD_MCP_SERVER_NAME}__`, +] as const; + +/** + * Blackboard tools that may run unprompted. + * + * READS ONLY. `blackboard_write` is deliberately absent even though the service + * already scope-checks it: a write publishes into shared state other agents act + * on, so it stays on the same footing as any other write tool. The role's write + * scope decides whether it is *permitted*; this decides whether it happens + * *without anyone looking*, and those are different questions. + */ +const BLACKBOARD_SAFE_TOOLS = [ + "blackboard_index", + "blackboard_read", + "blackboard_read_all", +] as const; + /** * True for read-only tools safe to run unprompted. The memory recall tools are * read-only, but a backend namespaces them (`mcp__codeoid_memory__recall`, @@ -33,6 +55,11 @@ export function isSafeTool(name: string): boolean { return (MEMORY_TOOL_NAMES as readonly string[]).includes(name.slice(prefix.length)); } } + for (const prefix of BLACKBOARD_TOOL_PREFIXES) { + if (name.startsWith(prefix)) { + return (BLACKBOARD_SAFE_TOOLS as readonly string[]).includes(name.slice(prefix.length)); + } + } return false; } diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 381c5a28..110b0542 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -32,6 +32,7 @@ import { MEMORY_MCP_PATH, type MemoryEngine, } from "./memory/index.js"; +import { BLACKBOARD_MCP_PATH } from "./blackboard/mcp-http.js"; import { McpRegistry } from "./mcp/registry.js"; import { McpHub } from "./mcp/hub.js"; import { importClaudeMcpServers } from "./mcp/import-claude.js"; @@ -449,6 +450,12 @@ export class DaemonServer { daemonEnv: process.env, }); for (const w of this.#mcpRegistry.warnings) console.warn(`[codeoid] ${w}`); + // Goal blackboard: loopback URL regardless of bind address — the agent + // subprocess runs on this host, and the endpoint must not become + // reachable off-box just because the daemon binds wide. + this.#manager.setBlackboardUrl( + `http://127.0.0.1:${this.#config.port}${BLACKBOARD_MCP_PATH}`, + ); this.#manager.setMcp(this.#mcpRegistry, this.#mcpHub); const mcpCount = this.#mcpRegistry.list().filter((s) => !s.builtin).length; if (mcpCount > 0) console.log(`[codeoid] mcp: ${mcpCount} external server(s) registered`); @@ -503,6 +510,15 @@ export class DaemonServer { return ep.handle(req); } + // Goal-blackboard MCP endpoint. Same contract as memory: the bearer + // token minted per role-child IS the scope (one goal, one role's + // read/write set), and an unknown token fails closed. Always mounted — + // unlike memory it has no enable flag, and with no minted tokens every + // request 401s anyway. + if (url.pathname === BLACKBOARD_MCP_PATH) { + return self.#manager.blackboardMcp.handle(req); + } + if (url.pathname === "/config") { return Response.json({ zeroid_url: authConfig.baseUrl, diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 1b178e9a..49af9d8f 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -38,6 +38,9 @@ import { resolveAgainstList, resolveModelIdForProvider, } from "./models.js"; +import { Blackboard } from "./blackboard/service.js"; +import { BlackboardStore } from "./blackboard/store.js"; +import { BlackboardMcpHttp } from "./blackboard/mcp-http.js"; import { childBrief, childSessionName, @@ -93,6 +96,7 @@ import type { AuthContext, ClientMessage, CollaborationConfig, + CollaborationRole, DaemonMessage, McpServerStatus, ModelInfo, @@ -204,6 +208,14 @@ export class SessionManager { #rateLimiter: RateLimiter; #memory?: MemoryEngine; #memoryMcp?: MemoryMcpMount; + /** Goal-blackboard MCP endpoint + the loopback URL children mount it from. + * Always constructed: with no minted tokens every request fails closed. */ + readonly #blackboardMcp = new BlackboardMcpHttp(); + #blackboardUrl?: string; + /** Per-goal artifact store, lazily built on the shared DB connection. */ + #blackboard?: Blackboard; + /** child session id → its blackboard bearer token, revoked on teardown. */ + readonly #blackboardTokens = new Map(); #mcpRegistry?: McpRegistry; #mcpHub?: McpHub; /** Live model catalogs by provider id (via each backend's supportedModels @@ -1321,6 +1333,21 @@ mcpHub: this.#mcpHub, this.#memoryMcp = mount; } + /** The goal-blackboard MCP endpoint, routed by the HTTP server. */ + get blackboardMcp(): BlackboardMcpHttp { + return this.#blackboardMcp; + } + + /** + * The URL a role-child mounts the blackboard from. Loopback regardless of the + * daemon's bind address — the agent subprocess runs on this host, and the + * endpoint must not become reachable off-box merely because the daemon binds + * wide. Same reasoning as the memory mount. + */ + setBlackboardUrl(url: string): void { + this.#blackboardUrl = url; + } + /** Inject the cross-backend MCP registry + daemon-owned client pool. Sessions * hand both to every provider so the registry's servers mount on all backends. */ setMcp(registry: McpRegistry, hub: McpHub): void { @@ -1567,6 +1594,31 @@ mcpHub: this.#mcpHub, this.#sessions.set(session.id, session); this.#rateLimiter.recordCreation(auth.sub); + // The orchestrator needs the blackboard too, and needs it MOST: §4 has it + // holding the index of artifact states, and §7 has it reading every + // reviewer's findings to synthesize. Without a mount it cannot see a + // single thing its children publish, and the coordination loop never + // closes. Attached here rather than passed to the constructor because the + // goal id it is scoped to IS this session's id. + if (collaboration) { + const orchestrator = orchestratorRole(collaboration); + const mount = this.#blackboardMountFor( + session, + { + roleName: ORCHESTRATOR_ROLE, + ordinal: 1, + providerId: session.providerId, + shape: "scout", + write: false, + }, + orchestrator, + ); + if (mount) { + session.attachBlackboard(mount); + this.#blackboardTokens.set(session.id, mount.token); + } + } + if (collaboration && planned.length > 0) { const spawned = await this.#spawnCollaborationChildren(session, collaboration, planned, auth); if (!spawned.ok) { @@ -1617,6 +1669,65 @@ mcpHub: this.#mcpHub, * pack constitution instead, so bringing up a fleet of N costs zero tokens * and no child burns a turn just to learn it should wait. */ + /** + * Revoke a collaboration child's blackboard token. Idempotent, and safe to + * call for any session id. + * + * Must be called from EVERY path that removes a child from `#sessions`, not + * just goal teardown. The token lives in the endpoint's binding map, not on + * the Session, so dropping the session without revoking leaves a credential + * that still authorizes reads and writes against the goal — anything still + * holding the URL (a wedged subprocess, a leaked env var) keeps working after + * the child is gone. Destroying a child directly used to do exactly that. + */ + #revokeBlackboardToken(sessionId: string): void { + const token = this.#blackboardTokens.get(sessionId); + if (!token) return; + this.#blackboardMcp.revoke(token); + this.#blackboardTokens.delete(sessionId); + } + + /** Lazily build the blackboard over the daemon's existing DB connection. */ + #goalBlackboard(): Blackboard { + if (!this.#blackboard) { + this.#blackboard = new Blackboard(new BlackboardStore(this.#store.database)); + } + return this.#blackboard; + } + + /** + * Mount config for one role-child's blackboard access, or undefined when the + * URL isn't known yet (the HTTP server sets it at startup; unit tests that + * construct a bare SessionManager legitimately have none). + * + * The minted token carries the role's scope, so the child's mount is its + * permission — there is no wider handle reachable from it. + */ + #blackboardMountFor( + parent: Session, + child: PlannedChild, + role: CollaborationRole | undefined, + ): { url: string; token: string } | undefined { + if (!this.#blackboardUrl) return undefined; + const handle = this.#goalBlackboard().forRole( + { + accountId: parent.accountId, + projectId: parent.projectId, + goalSessionId: parent.id, + }, + { + roleName: child.roleName, + ordinal: child.ordinal, + // Attribution keyed to the ROLE within the goal, not the child's + // session id: a role-child replaced after a restart is still the same + // contributor, and its earlier artifacts should keep reading that way. + authorSub: `agent:${parent.id}:${child.roleName}#${child.ordinal}`, + }, + role ? { reads: role.reads, writes: role.writes } : undefined, + ); + return { url: this.#blackboardUrl, token: this.#blackboardMcp.mint(handle) }; + } + async #spawnCollaborationChildren( parent: Session, collaboration: CollaborationConfig, @@ -1625,6 +1736,13 @@ mcpHub: this.#mcpHub, ): Promise<{ ok: true } | { ok: false; error: string }> { for (const child of planned) { try { + // Minted before construction so the child's provider can mount it from + // the start — the token carries this role's read/write scope. + const blackboard = this.#blackboardMountFor( + parent, + child, + collaboration.roles.find((r) => r.name === child.roleName), + ); const childSession = new Session({ name: childSessionName(parent.name, child), workdir: parent.workdir, @@ -1665,6 +1783,21 @@ mcpHub: this.#mcpHub, ordinal: child.ordinal, write: child.write, }, + // Autonomous with a bounded budget — the same posture dispatch gives + // its workers, and for the same reason: NOBODY ATTACHES TO A CHILD. + // The owner's approval happens once at dispatch time (the R3 gate on + // fleet_send/fleet_spawn), not per tool call. Left interactive, a + // child's first non-safe tool call parks it at waiting_approval with + // zero clients and the collaboration deadlocks on its very first + // handoff — observed live before this was set. + initialMode: { + mode: "autonomous", + maxTurns: this.#dispatcher.config.workerToolBudget, + }, + // Role-scoped goal blackboard, mountable by ANY backend (#245) — + // this is how a gemini reviewer and an openai reasoner hand work to + // each other without the orchestrator relaying it as prose. + blackboardMcp: blackboard, identityManager: this.#identityManager, memory: this.#memory, memoryMcp: this.#memoryMcp, @@ -1677,6 +1810,7 @@ mcpHub: this.#mcpHub, onModels: (providerId, m) => this._cacheModels(providerId, m), }); this.#sessions.set(childSession.id, childSession); + if (blackboard) this.#blackboardTokens.set(childSession.id, blackboard.token); // No rate-limiter charge: the human called session.create once, and // the child count is already bounded by MAX_COLLABORATION_CHILDREN. // Mirrors spawnWorker, which charges nothing for the same reason. @@ -1715,6 +1849,7 @@ mcpHub: this.#mcpHub, `[codeoid/collaboration] child teardown failed (${reason}): ${err instanceof Error ? err.message : String(err)}`, ); } + this.#revokeBlackboardToken(child.id); this.#sessions.delete(child.id); this.#store.audit( "system:collaboration", @@ -2617,6 +2752,16 @@ mcpHub: this.#mcpHub, `target session ${task.targetSession ?? "?"} no longer exists`, ); } + // Re-arm a collaboration child's autonomous budget on every dispatch. + // A child is long-lived across the whole goal (unlike a disposable + // spawn worker), so one initial budget is spent down across successive + // dispatches and the child would silently wedge at waiting_approval + // partway through — with nobody attached to approve. Same reasoning as + // continueWorker re-arming after a restart; the approval that + // authorizes this work already happened at the R3 dispatch gate. + if (target.collaborationRole) { + target.setMode("autonomous", this.#dispatcher.config.workerToolBudget); + } await target.send( `[conductor dispatch ${task.id.slice(0, 8)} — owner-approved]\n\n${task.prompt}`, this.#dispatchSenderAuth(task), @@ -2762,6 +2907,9 @@ mcpHub: this.#mcpHub, `[codeoid/dispatch] worker teardown failed (${reason}): ${err instanceof Error ? err.message : String(err)}`, ); } + // A collaboration child is role:"worker", so it can reach this path. + // Revoke before dropping, same reason as the destroy handler. + this.#revokeBlackboardToken(sessionId); this.#sessions.delete(sessionId); }, @@ -3627,9 +3775,28 @@ mcpHub: this.#mcpHub, // reach them by. if (session.collaboration) { await this.#teardownCollaborationChildren(msg.sessionId, "collaboration goal ended"); + // ...and the orchestrator's own mount. + this.#revokeBlackboardToken(msg.sessionId); + // Artifacts are goal-scoped, so they die with the goal. Dropped AFTER the + // children so a child mid-write can't recreate rows behind the delete. + try { + this.#goalBlackboard().deleteGoal({ + accountId: session.accountId, + projectId: session.projectId, + goalSessionId: session.id, + }); + } catch (err) { + console.error( + `[codeoid/collaboration] artifact cleanup failed for ${session.id}: ${err instanceof Error ? err.message : String(err)}`, + ); + } } await session.destroy(auth); + // Covers destroying a CHILD directly (not via goal teardown) — without + // this its token stays live in the endpoint's binding map and keeps + // authorizing reads/writes on the goal after the session is gone. + this.#revokeBlackboardToken(msg.sessionId); this.#sessions.delete(msg.sessionId); return { type: "response.ok", requestId: msg.id }; } diff --git a/src/daemon/session.ts b/src/daemon/session.ts index f26f63c7..cb80f1fb 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -249,6 +249,14 @@ export interface SessionCreateOptions { * orphan an agent subprocess. */ collaborationRole?: SessionInfo["collaborationRole"]; + /** + * 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 + * set). Handed to the provider like `memoryMcp`, so any backend able to mount + * an MCP URL gets it — which is the point of making the blackboard mountable + * rather than an in-process Claude-SDK server (#245). + */ + blackboardMcp?: { url: string; token: string }; /** * Pre-built codeoid_fleet MCP server (conductor sessions only). Built by * the SessionManager because its tools close over the manager's tenant- @@ -331,6 +339,10 @@ export class Session { readonly collaboration?: CollaborationConfig; /** Which collaboration + role this session serves, when it is a child. */ readonly collaborationRole?: SessionInfo["collaborationRole"]; + /** Role-scoped blackboard mount. NOT readonly: the orchestrator's own mount + * is scoped to a goal id that IS this session's id, so it can only be + * attached after construction (see attachBlackboard). */ + #blackboardMcp?: { url: string; token: string }; readonly createdBy: string; readonly createdAt: string; /** @@ -631,6 +643,7 @@ export class Session { this.worktree = opts.worktree; this.collaboration = opts.collaboration; this.collaborationRole = opts.collaborationRole; + this.#blackboardMcp = opts.blackboardMcp; this.#onStatusChange = opts.onStatusChange; this.#workerShape = opts.workerShape; if (opts.initialMode) { @@ -824,6 +837,9 @@ export class Session { identityManager: this.#identityManager, memory: this.#memory, memoryMcp: this.#memoryMcp, + // A getter, so a mount attached after construction still reaches the + // provider when it next builds its server list. + blackboardMcp: () => this.#blackboardMcp, mcpRegistry: this.#mcpRegistry, mcpHub: this.#mcpHub, fleet: this.#fleet, @@ -2232,6 +2248,17 @@ export class Session { } } + /** + * Attach this session's role-scoped blackboard mount. + * + * Used for the ORCHESTRATOR, whose goal id is its own session id and so + * cannot be known before construction. Providers resolve the mount lazily, + * so one attached before the first turn is picked up normally. + */ + attachBlackboard(mount: { url: string; token: string }): void { + this.#blackboardMcp = mount; + } + toInfo(): SessionInfo { return { id: this.id, diff --git a/src/tests/blackboard-mcp.test.ts b/src/tests/blackboard-mcp.test.ts new file mode 100644 index 00000000..bc267d65 --- /dev/null +++ b/src/tests/blackboard-mcp.test.ts @@ -0,0 +1,271 @@ +/** + * Goal-blackboard MCP endpoint — the mountable surface (#245). + * + * The property under test is that THE TOKEN IS THE SCOPE. A mount cannot + * address another goal or widen its own read/write set, because no tool takes + * a goal id and `blackboard_write` takes no slot. If that ever stops being + * true, a gemini reviewer could read the implementer's reasoning or overwrite + * a peer's findings, and the independence guarantee in §6 evaporates. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BlackboardMcpHttp, BLACKBOARD_MCP_PATH } from "../daemon/blackboard/mcp-http.js"; +import { Blackboard, type RoleIdentity } from "../daemon/blackboard/service.js"; +import { BlackboardStore, type GoalScope } from "../daemon/blackboard/store.js"; +import { isSafeTool } from "../daemon/providers/tool-safety.js"; +import { Store } from "../daemon/store.js"; + +let tmp: string; +let store: Store; +let bb: Blackboard; +let mcp: BlackboardMcpHttp; + +const GOAL: GoalScope = { accountId: "acc", projectId: "proj", goalSessionId: "goal-1" }; +const ident = (roleName: string, ordinal = 1): RoleIdentity => ({ + roleName, + ordinal, + authorSub: `agent:${roleName}#${ordinal}`, +}); + +const URL_ = `http://127.0.0.1:7400${BLACKBOARD_MCP_PATH}`; + +/** POST one JSON-RPC message with a bearer token. */ +async function rpc( + token: string | null, + method: string, + params?: Record, +): Promise<{ status: number; body: any }> { + const headers: Record = { "Content-Type": "application/json" }; + if (token) headers.Authorization = `Bearer ${token}`; + const res = await mcp.handle( + new Request(URL_, { + method: "POST", + headers, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }), + ); + const text = await res.text(); + return { status: res.status, body: text ? JSON.parse(text) : null }; +} + +const call = (token: string, name: string, args: Record = {}) => + rpc(token, "tools/call", { name, arguments: args }); + +/** The text payload of a tools/call result. */ +const textOf = (body: any): string => body?.result?.content?.[0]?.text ?? ""; +const isErr = (body: any): boolean => body?.result?.isError === true; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "codeoid-bbmcp-")); + store = new Store(join(tmp, "codeoid.db")); + bb = new Blackboard(new BlackboardStore(store.database)); + mcp = new BlackboardMcpHttp(); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +describe("transport + auth", () => { + // Each auth test mints an unrelated VALID token first. Without that, the map + // is empty and a "reject unless the token resolves" test passes even against + // an endpoint that falls back to whatever binding happens to exist — proving + // only "401 when nothing is minted". Mutation testing caught exactly that. + const mintOther = () => mcp.mint(bb.forRole({ ...GOAL, goalSessionId: "other" }, ident("review"))); + + test("fails closed with no token, even when other mounts are live", async () => { + mintOther(); + const r = await rpc(null, "tools/list"); + expect(r.status).toBe(401); + }); + + test("fails closed on an unknown token, even when other mounts are live", async () => { + mintOther(); + const r = await rpc("bbt_nope", "tools/list"); + expect(r.status).toBe(401); + }); + + test("a revoked token stops working while its siblings keep working", async () => { + const sibling = mintOther(); + const token = mcp.mint(bb.forRole(GOAL, ident("review"))); + expect((await rpc(token, "tools/list")).status).toBe(200); + + mcp.revoke(token); + // Revoked one is dead... + expect((await rpc(token, "tools/list")).status).toBe(401); + // ...and the endpoint did NOT silently fall through to the live sibling. + expect((await rpc(sibling, "tools/list")).status).toBe(200); + expect(mcp.activeTokens).toBe(1); + }); + + test("GET is rejected — POST-only Streamable HTTP", async () => { + const res = await mcp.handle(new Request(URL_, { method: "GET" })); + expect(res.status).toBe(405); + }); + + test("initialize + tools/list advertise the four tools", async () => { + const token = mcp.mint(bb.forRole(GOAL, ident("review"))); + const init = await rpc(token, "initialize", { protocolVersion: "2025-06-18" }); + expect(init.body.result.serverInfo.name).toBe("codeoid_blackboard"); + const list = await rpc(token, "tools/list"); + expect(list.body.result.tools.map((t: { name: string }) => t.name).sort()).toEqual([ + "blackboard_index", + "blackboard_read", + "blackboard_read_all", + "blackboard_write", + ]); + }); + + test("a notification (no id) gets 202 and no body", async () => { + const token = mcp.mint(bb.forRole(GOAL, ident("review"))); + const res = await mcp.handle( + new Request(URL_, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }), + }), + ); + expect(res.status).toBe(202); + }); +}); + +describe("the token carries the role's scope", () => { + test("a reviewer can read diff and write findings", async () => { + bb.forRole(GOAL, ident("reasoning")).write("diff", "the change"); + const token = mcp.mint(bb.forRole(GOAL, ident("review"))); + + const read = await call(token, "blackboard_read", { kind: "diff" }); + expect(isErr(read.body)).toBe(false); + expect(textOf(read.body)).toContain("the change"); + + const write = await call(token, "blackboard_write", { + kind: "findings", + content: "looks good", + }); + expect(isErr(write.body)).toBe(false); + expect(textOf(write.body)).toMatch(/Wrote findings \[review\] v1/); + }); + + // The §6 guarantee, exercised through the transport an actual agent uses. + test("a reviewer is refused research, with a reason it can act on", async () => { + bb.forRole(GOAL, ident("search")).write("research", "how I got here"); + const token = mcp.mint(bb.forRole(GOAL, ident("review"))); + const r = await call(token, "blackboard_read", { kind: "research" }); + // An MCP tool error, not a transport error: the agent should see WHY and + // adapt, not get an opaque failure it retries forever. + expect(r.status).toBe(200); + expect(isErr(r.body)).toBe(true); + expect(textOf(r.body)).toMatch(/may not read "research"/); + }); + + test("a reviewer is refused writing outside its lane", async () => { + const token = mcp.mint(bb.forRole(GOAL, ident("review"))); + const r = await call(token, "blackboard_write", { kind: "diff", content: "sneaky" }); + expect(isErr(r.body)).toBe(true); + expect(textOf(r.body)).toMatch(/may not write "diff"/); + }); + + // No tool takes a goal id, so a child cannot address another goal even by + // guessing one — the mount is the boundary. + test("no tool accepts a goal id, a session id, or a slot", async () => { + const token = mcp.mint(bb.forRole(GOAL, ident("review"))); + const list = await rpc(token, "tools/list"); + // Assert on PARAMETER names, not the serialized blob — the descriptions + // legitimately mention "this goal" in prose. + const params = (list.body.result.tools as Array<{ inputSchema: { properties: object } }>) + .flatMap((t) => Object.keys(t.inputSchema.properties ?? {})) + .sort(); + // The full parameter vocabulary of the surface is exactly two names. + expect([...new Set(params)]).toEqual(["content", "kind"]); + for (const forbidden of ["goal", "goalSessionId", "sessionId", "slot", "accountId"]) { + expect(params).not.toContain(forbidden); + } + }); + + // Two goals, two tokens: neither can see the other's artifacts. + test("a token cannot reach another goal's artifacts", async () => { + const other: GoalScope = { ...GOAL, goalSessionId: "goal-2" }; + bb.forRole(GOAL, ident("reasoning")).write("diff", "goal one diff"); + bb.forRole(other, ident("reasoning")).write("diff", "goal two diff"); + + const t1 = mcp.mint(bb.forRole(GOAL, ident("review"))); + const t2 = mcp.mint(bb.forRole(other, ident("review"))); + + expect(textOf((await call(t1, "blackboard_read", { kind: "diff" })).body)).toContain( + "goal one diff", + ); + expect(textOf((await call(t2, "blackboard_read", { kind: "diff" })).body)).toContain( + "goal two diff", + ); + }); + + test("blackboard_write takes no slot, so a peer's entry is unreachable", async () => { + const t1 = mcp.mint(bb.forRole(GOAL, ident("review", 1))); + const t2 = mcp.mint(bb.forRole(GOAL, ident("review", 2))); + await call(t1, "blackboard_write", { kind: "findings", content: "from one" }); + // Even passing a slot explicitly cannot redirect the write — the schema + // rejects unknown properties and the service picks the slot regardless. + await call(t2, "blackboard_write", { kind: "findings", content: "from two", slot: "review" }); + + const all = new BlackboardStore(store.database).latestAllSlots(GOAL, "findings"); + expect(all).toHaveLength(2); + expect(all.map((a) => a.content).sort()).toEqual(["from one", "from two"]); + }); + + test("the index is readable and carries no bodies", async () => { + bb.forRole(GOAL, ident("search")).write("research", "SECRET-RESEARCH-BODY"); + const token = mcp.mint(bb.forRole(GOAL, ident("review"))); + const r = await call(token, "blackboard_index"); + const text = textOf(r.body); + expect(text).toContain("research"); + // Knowing it exists is not reading it. + expect(text).not.toContain("SECRET-RESEARCH-BODY"); + // And the mount tells the agent its own scope, so it can plan. + expect(text).toMatch(/You may read: spec, diff/); + expect(text).toMatch(/You may write: findings/); + }); + + test("an unknown tool is an error result, not a crash", async () => { + const token = mcp.mint(bb.forRole(GOAL, ident("review"))); + const r = await call(token, "blackboard_nope"); + expect(isErr(r.body)).toBe(true); + expect(textOf(r.body)).toMatch(/Unknown tool/); + }); + + test("an unknown method is a JSON-RPC error", async () => { + const token = mcp.mint(bb.forRole(GOAL, ident("review"))); + const r = await rpc(token, "resources/list"); + expect(r.body.error.code).toBe(-32601); + }); +}); + +// ── Tool-safety classification ────────────────────────────────────────────── + +describe("blackboard tool safety", () => { + test("reads auto-approve under both namespacing conventions", () => { + for (const p of ["mcp__codeoid_blackboard__", "codeoid_blackboard__"]) { + for (const t of ["blackboard_index", "blackboard_read", "blackboard_read_all"]) { + expect(isSafeTool(`${p}${t}`)).toBe(true); + } + } + }); + + // Scope decides whether a write is PERMITTED; this decides whether it happens + // without anyone looking. A write publishes into shared state peers act on. + test("blackboard_write never auto-approves", () => { + expect(isSafeTool("mcp__codeoid_blackboard__blackboard_write")).toBe(false); + expect(isSafeTool("codeoid_blackboard__blackboard_write")).toBe(false); + }); + + test("a look-alike server name does not auto-approve", () => { + expect(isSafeTool("x_codeoid_blackboard__blackboard_read")).toBe(false); + expect(isSafeTool("mcp__codeoid_blackboard_evil__blackboard_read")).toBe(false); + }); + + test("an unknown blackboard tool does not auto-approve", () => { + expect(isSafeTool("mcp__codeoid_blackboard__blackboard_wipe")).toBe(false); + }); +}); diff --git a/src/tests/blackboard.test.ts b/src/tests/blackboard.test.ts new file mode 100644 index 00000000..f95fcf25 --- /dev/null +++ b/src/tests/blackboard.test.ts @@ -0,0 +1,318 @@ +/** + * Goal blackboard — store + role-scoped access + * (docs/collaborative-session-design.md §4, §6). + * + * The properties under test, in priority order: + * 1. TENANT ISOLATION — a goal id is not a permission. Two tenants can hold + * the same goal session id and must not see each other's artifacts. + * 2. INDEPENDENCE — a reviewer reads `diff`+`spec` and cannot reach + * `research` or `findings`, not even a peer reviewer's. A panel whose + * members can read each other is an echo, not a panel. + * 3. NO SILENT COLLAPSE — each reviewer writes its own slot, so reviewer #2 + * cannot overwrite reviewer #1 and quietly reduce a panel to one voice. + * 4. APPEND-ONLY — writes version, never overwrite, so a handoff's history + * survives. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Blackboard, DEFAULT_ROLE_IO, type RoleIdentity } from "../daemon/blackboard/service.js"; +import { BlackboardStore, type GoalScope } from "../daemon/blackboard/store.js"; +import { + ARTIFACT_CONTENT_MAX, + CORE_ARTIFACT_KINDS, + isValidArtifactKind, +} from "../daemon/blackboard/types.js"; +import { Store } from "../daemon/store.js"; + +let tmp: string; +let store: Store; +let bbStore: BlackboardStore; +let bb: Blackboard; + +const GOAL: GoalScope = { accountId: "acc", projectId: "proj", goalSessionId: "goal-1" }; +/** Same goal id, different tenant — the isolation probe. */ +const OTHER_TENANT: GoalScope = { ...GOAL, accountId: "acc-2" }; +/** Same tenant + account, different project — projects are a boundary too. */ +const OTHER_PROJECT: GoalScope = { ...GOAL, projectId: "proj-2" }; + +const ident = (roleName: string, ordinal = 1): RoleIdentity => ({ + roleName, + ordinal, + authorSub: `agent:${roleName}${ordinal}`, +}); + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "codeoid-bb-")); + store = new Store(join(tmp, "codeoid.db")); + bbStore = new BlackboardStore(store.database); + bb = new Blackboard(bbStore); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +// ── Kind vocabulary ───────────────────────────────────────────────────────── + +describe("artifact kinds", () => { + test("accepts every core kind", () => { + for (const k of CORE_ARTIFACT_KINDS) expect(isValidArtifactKind(k)).toBe(true); + }); + + test("accepts a well-formed extra/", () => { + expect(isValidArtifactKind("extra/bench-results")).toBe(true); + expect(isValidArtifactKind("extra/x")).toBe(true); + }); + + // Rejecting matters: a typo'd kind that became a fresh extra/ slot would look + // like a successful handoff while the intended reader waits forever. + test.each([ + "diffs", + "Spec", + "extra/", + "extra/UPPER", + "extra/has space", + "extra/nested/key", + "extra/-leading", + "", + "findings ", + ])("rejects %p", (kind) => { + expect(isValidArtifactKind(kind)).toBe(false); + }); +}); + +// ── Store: versioning + tenant isolation ──────────────────────────────────── + +describe("BlackboardStore", () => { + test("appends versions instead of overwriting", () => { + const a = bbStore.append({ scope: GOAL, kind: "spec", content: "v1", authorSub: "a", now: 1 }); + const b = bbStore.append({ scope: GOAL, kind: "spec", content: "v2", authorSub: "a", now: 2 }); + expect(a.version).toBe(1); + expect(b.version).toBe(2); + expect(bbStore.latest(GOAL, "spec")?.content).toBe("v2"); + // History intact — the point of append-only. + expect(bbStore.version(GOAL, "spec", 1)?.content).toBe("v1"); + }); + + test("versions independently per slot", () => { + bbStore.append({ scope: GOAL, kind: "findings", slot: "review", content: "r1", authorSub: "a", now: 1 }); + bbStore.append({ scope: GOAL, kind: "findings", slot: "review#2", content: "r2", authorSub: "b", now: 2 }); + bbStore.append({ scope: GOAL, kind: "findings", slot: "review", content: "r1b", authorSub: "a", now: 3 }); + expect(bbStore.latest(GOAL, "findings", "review")?.content).toBe("r1b"); + expect(bbStore.latest(GOAL, "findings", "review#2")?.content).toBe("r2"); + const all = bbStore.latestAllSlots(GOAL, "findings"); + expect(all).toHaveLength(2); + expect(all.map((a) => a.content).sort()).toEqual(["r1b", "r2"]); + }); + + test("a null slot is distinct from a named one", () => { + bbStore.append({ scope: GOAL, kind: "spec", content: "singleton", authorSub: "a", now: 1 }); + bbStore.append({ scope: GOAL, kind: "spec", slot: "odd", content: "slotted", authorSub: "a", now: 2 }); + expect(bbStore.latest(GOAL, "spec")?.content).toBe("singleton"); + expect(bbStore.latest(GOAL, "spec", "odd")?.content).toBe("slotted"); + }); + + // A goal id is not a permission. If it were, one leaked/colliding session id + // would expose another account's artifacts. + test("isolates tenants that share a goal session id", () => { + bbStore.append({ scope: GOAL, kind: "spec", content: "ours", authorSub: "a", now: 1 }); + bbStore.append({ scope: OTHER_TENANT, kind: "spec", content: "theirs", authorSub: "z", now: 1 }); + + expect(bbStore.latest(GOAL, "spec")?.content).toBe("ours"); + expect(bbStore.latest(OTHER_TENANT, "spec")?.content).toBe("theirs"); + expect(bbStore.index(GOAL)).toHaveLength(1); + expect(bbStore.index(OTHER_TENANT)).toHaveLength(1); + // Both start at version 1 — neither tenant's write advanced the other's. + expect(bbStore.latest(GOAL, "spec")?.version).toBe(1); + expect(bbStore.latest(OTHER_TENANT, "spec")?.version).toBe(1); + }); + + test("isolates projects within one account", () => { + bbStore.append({ scope: GOAL, kind: "spec", content: "p1", authorSub: "a", now: 1 }); + expect(bbStore.latest(OTHER_PROJECT, "spec")).toBeNull(); + }); + + test("deleteGoal removes only that tenant's goal", () => { + bbStore.append({ scope: GOAL, kind: "spec", content: "ours", authorSub: "a", now: 1 }); + bbStore.append({ scope: OTHER_TENANT, kind: "spec", content: "theirs", authorSub: "z", now: 1 }); + expect(bbStore.deleteGoal(GOAL)).toBe(1); + expect(bbStore.latest(GOAL, "spec")).toBeNull(); + expect(bbStore.latest(OTHER_TENANT, "spec")?.content).toBe("theirs"); + }); + + test("the index reports version, author and size without bodies", () => { + bbStore.append({ scope: GOAL, kind: "spec", content: "hello", authorSub: "a", authorRole: "orchestrator", now: 5 }); + bbStore.append({ scope: GOAL, kind: "spec", content: "hello there", authorSub: "a", authorRole: "orchestrator", now: 6 }); + const [entry] = bbStore.index(GOAL); + expect(entry).toMatchObject({ + kind: "spec", + slot: null, + version: 2, + authorSub: "a", + authorRole: "orchestrator", + bytes: "hello there".length, + }); + // No `content` key at all — the orchestrator holds an index, not bodies. + expect(entry as unknown as Record).not.toHaveProperty("content"); + }); + + test("index lists one row per (kind, slot) at its latest version", () => { + bbStore.append({ scope: GOAL, kind: "findings", slot: "review", content: "a", authorSub: "a", now: 1 }); + bbStore.append({ scope: GOAL, kind: "findings", slot: "review#2", content: "b", authorSub: "b", now: 2 }); + bbStore.append({ scope: GOAL, kind: "diff", content: "d", authorSub: "c", now: 3 }); + const idx = bbStore.index(GOAL); + expect(idx).toHaveLength(3); + expect(idx.map((e) => `${e.kind}/${e.slot ?? "-"}`).sort()).toEqual([ + "diff/-", + "findings/review", + "findings/review#2", + ]); + }); +}); + +// ── Service: role scoping ─────────────────────────────────────────────────── + +describe("role scoping is fail-closed", () => { + test("an unprofiled role that declares nothing can do nothing", () => { + const h = bb.forRole(GOAL, ident("mystery-role")); + expect(h.reads).toEqual([]); + expect(h.writes).toEqual([]); + const r = h.read("spec"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/may not read "spec"/); + const w = h.write("spec", "x"); + expect(w.ok).toBe(false); + if (!w.ok) expect(w.error).toMatch(/may not write "spec"/); + }); + + test("an explicit empty declaration also grants nothing", () => { + const h = bb.forRole(GOAL, ident("review"), { reads: [], writes: [] }); + // Declared-empty must NOT fall through to the default profile. + expect(h.reads).toEqual([]); + expect(h.read("diff").ok).toBe(false); + }); + + test("a declaration overrides the default profile", () => { + const h = bb.forRole(GOAL, ident("review"), { reads: ["research"], writes: ["extra/notes"] }); + expect(h.read("research").ok).toBe(true); + expect(h.read("diff").ok).toBe(false); // not declared, despite the profile + expect(h.write("extra/notes", "n").ok).toBe(true); + expect(h.write("findings", "f").ok).toBe(false); + }); + + test("an unknown kind is rejected even when scoping would allow it", () => { + const h = bb.forRole(GOAL, ident("review"), { reads: ["diffs"], writes: ["findings"] }); + const r = h.read("diffs"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/Unknown artifact kind/); + }); + + test("oversized content is refused rather than truncated", () => { + const h = bb.forRole(GOAL, ident("search")); + const w = h.write("research", "x".repeat(ARTIFACT_CONTENT_MAX + 1)); + expect(w.ok).toBe(false); + if (!w.ok) expect(w.error).toMatch(/max \d+/); + }); +}); + +// This is the §6 guarantee, and the reason review's read set is exactly two +// kinds: a reviewer is unbiased BECAUSE it cannot see the author's reasoning or +// its peers' verdicts — not because a prompt asked it not to look. +describe("reviewer independence", () => { + test("the default review profile reads diff + spec and nothing else", () => { + expect(DEFAULT_ROLE_IO.review).toEqual({ reads: ["spec", "diff"], writes: ["findings"] }); + }); + + test("a reviewer cannot read research (implementer reasoning by proxy)", () => { + bb.forRole(GOAL, ident("search")).write("research", "how I approached it"); + const r = bb.forRole(GOAL, ident("review")).read("research"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/may not read "research"/); + }); + + test("a reviewer cannot read findings — not even a peer's", () => { + const r1 = bb.forRole(GOAL, ident("review", 1)); + const r2 = bb.forRole(GOAL, ident("review", 2)); + expect(r1.write("findings", "looks fine").ok).toBe(true); + expect(r2.read("findings").ok).toBe(false); + expect(r2.readAll("findings").ok).toBe(false); + }); + + test("each reviewer writes its own slot, so a panel cannot collapse", () => { + expect(bb.forRole(GOAL, ident("review", 1)).write("findings", "from one").ok).toBe(true); + expect(bb.forRole(GOAL, ident("review", 2)).write("findings", "from two").ok).toBe(true); + expect(bb.forRole(GOAL, ident("review", 3)).write("findings", "from three").ok).toBe(true); + + // Three distinct opinions survive, each at version 1 of its own slot. + const all = bbStore.latestAllSlots(GOAL, "findings"); + expect(all).toHaveLength(3); + expect(all.every((a) => a.version === 1)).toBe(true); + expect(all.map((a) => a.content).sort()).toEqual(["from one", "from three", "from two"]); + expect(all.map((a) => a.slot).sort()).toEqual(["review", "review#2", "review#3"]); + }); + + // A caller-supplied slot would hand one reviewer the ability to overwrite + // another's findings, which is exactly what slots exist to prevent — so the + // write API takes no slot at all. + test("a reviewer has no way to name another reviewer's slot", () => { + const h = bb.forRole(GOAL, ident("review", 2)); + expect(h.write.length).toBe(2); // (kind, content) — no slot parameter + }); + + test("the orchestrator can read every reviewer's findings for synthesis", () => { + bb.forRole(GOAL, ident("review", 1)).write("findings", "one"); + bb.forRole(GOAL, ident("review", 2)).write("findings", "two"); + const all = bb.forRole(GOAL, ident("orchestrator")).readAll("findings"); + expect(all.ok).toBe(true); + if (all.ok) expect(all.value.map((a) => a.content).sort()).toEqual(["one", "two"]); + }); +}); + +describe("the default profile wires the §3 handoff chain", () => { + test("search → architecture → reasoning → review flows through artifacts", () => { + const search = bb.forRole(GOAL, ident("search")); + const arch = bb.forRole(GOAL, ident("architecture")); + const reason = bb.forRole(GOAL, ident("reasoning")); + const review = bb.forRole(GOAL, ident("review")); + const orch = bb.forRole(GOAL, ident("orchestrator")); + + expect(orch.write("spec", "SPEC").ok).toBe(true); + expect(search.read("spec").ok).toBe(true); + expect(search.write("research", "RESEARCH").ok).toBe(true); + + expect(arch.read("research").ok).toBe(true); + expect(arch.write("adr", "ADR").ok).toBe(true); + + expect(reason.read("adr").ok).toBe(true); + // The reasoner never reads raw research — it works from the decided ADR. + expect(reason.read("research").ok).toBe(false); + expect(reason.write("diff", "DIFF").ok).toBe(true); + + expect(review.read("diff").ok).toBe(true); + expect(review.write("findings", "FINDINGS").ok).toBe(true); + + // Nobody wrote outside their lane. + expect(search.write("diff", "x").ok).toBe(false); + expect(review.write("diff", "x").ok).toBe(false); + expect(arch.write("diff", "x").ok).toBe(false); + }); + + test("every artifact is stamped with its producing identity and role", () => { + bb.forRole(GOAL, ident("search")).write("research", "R"); + const a = bbStore.latest(GOAL, "research"); + expect(a?.authorSub).toBe("agent:search1"); + expect(a?.authorRole).toBe("search"); + }); + + // Knowing a diff EXISTS is not reading it; the orchestrator needs the whole + // picture to schedule, and the index carries no bodies. + test("the index is visible regardless of read scope", () => { + bb.forRole(GOAL, ident("search")).write("research", "R"); + const idx = bb.forRole(GOAL, ident("review")).index(); + expect(idx.map((e) => e.kind)).toContain("research"); + expect(bb.forRole(GOAL, ident("review")).read("research").ok).toBe(false); + }); +}); diff --git a/src/tests/collaboration.test.ts b/src/tests/collaboration.test.ts index 32acc8da..61857887 100644 --- a/src/tests/collaboration.test.ts +++ b/src/tests/collaboration.test.ts @@ -740,6 +740,79 @@ describe("collaboration children come up and are torn down", () => { }); }); +// ── Regressions found by the live smoke test + audit ──────────────────────── +// +// Every one of these passed CI and the unit suite while the feature was +// actually broken end to end. They exist because "the tests are green" was not +// the same as "an agent can complete a handoff". +describe("live-verified wiring", () => { + const CONFIG: CollaborationConfig = { + goal: "Ship it", + roles: [ + { name: "orchestrator", providerId: "claude" }, + { name: "search", providerId: "claude" }, + ], + }; + + const createCollab = async (id: string, name: string) => { + manager.setBlackboardUrl("http://127.0.0.1:7400/mcp/blackboard"); + const resp = await run({ + type: "session.create", + id, + name, + workdir, + collaboration: CONFIG, + }); + if (resp.type !== "response.ok") throw new Error("create failed"); + return resp.data as SessionInfo; + }; + + // Observed live: children spawned interactive, `blackboard_write` needs + // approval, and NOBODY attaches to a child — so the first handoff parked at + // waiting_approval with zero clients and the collaboration deadlocked. + test("children spawn autonomous, because no one is there to approve", async () => { + const parent = await createCollab("lv1", "lv1"); + const kids = childrenOf(await allSessions(), parent.id); + expect(kids).toHaveLength(1); + expect(kids[0]!.mode).toBe("autonomous"); + expect(kids[0]!.turnsRemaining).toBeGreaterThan(0); + }); + + // Observed by audit: only children got a mount, so the orchestrator could + // not call blackboard_index or read findings — §4's index and §7's synthesis + // were both impossible and the coordination loop never closed. + // The orchestrator is the one that MOST needs the blackboard (§4 index, §7 + // synthesis) and originally got no mount at all. Paired with the teardown + // test below, which fails if the mount is minted but never registered. + test("a token is minted for the orchestrator as well as each child", async () => { + const before = manager.blackboardMcp.activeTokens; + const parent = await createCollab("lv2", "lv2"); + const kids = childrenOf(await allSessions(), parent.id); + expect(manager.blackboardMcp.activeTokens).toBe(before + kids.length + 1); + }); + + // Observed by audit: destroying a child directly skipped revocation, leaving + // a credential that still authorized reads/writes on the goal. + test("destroying a child directly revokes its token", async () => { + const parent = await createCollab("lv3", "lv3"); + const kid = childrenOf(await allSessions(), parent.id)[0]!; + const withChild = manager.blackboardMcp.activeTokens; + + const destroyed = await run({ type: "session.destroy", id: "lv3d", sessionId: kid.id }); + expect(destroyed.type).toBe("response.ok"); + expect(manager.blackboardMcp.activeTokens).toBe(withChild - 1); + }); + + test("goal teardown revokes the orchestrator's token too", async () => { + const before = manager.blackboardMcp.activeTokens; + const parent = await createCollab("lv4", "lv4"); + expect(manager.blackboardMcp.activeTokens).toBeGreaterThan(before); + + await run({ type: "session.destroy", id: "lv4d", sessionId: parent.id }); + expect(manager.blackboardMcp.activeTokens).toBe(before); + }); +}); + // A collaborative session IS its orchestrator, so the claude-only rule has to // bind THIS session's backend — not just a config row that nothing runs on. describe("the session is its orchestrator", () => { diff --git a/web/src/components/NewSessionModal.test.tsx b/web/src/components/NewSessionModal.test.tsx index d47f5de3..590e4a2c 100644 --- a/web/src/components/NewSessionModal.test.tsx +++ b/web/src/components/NewSessionModal.test.tsx @@ -40,7 +40,11 @@ const runPipelineMock = vi.hoisted(() => vi.fn(() => Promise.resolve())); vi.mock("../state/pipelines", () => ({ runPipeline: runPipelineMock })); import type { PackWire } from "../protocol/types"; -import NewSessionModal, { openNewSessionModal, openPipelineModal } from "./NewSessionModal"; +import NewSessionModal, { + openCollaborateModal, + openNewSessionModal, + openPipelineModal, +} from "./NewSessionModal"; import { _resetSessionsForTest } from "../state/sessions"; /** Minimal installed PackWire for the modal's pack/role selectors. */ @@ -274,3 +278,129 @@ describe("NewSessionModal pipeline mode", () => { expect((getByText("start run") as HTMLButtonElement).disabled).toBe(true); }); }); + + +describe("NewSessionModal collaborative mode", () => { + /** Open in collaborative mode with a name filled in. */ + function openCollab(providers = ["claude", "gemini"]) { + authMock.mockReturnValue(authOk(providers)); + requestMock.mockResolvedValue({ id: "s-collab", name: "demo", workdir: "/w" }); + const r = render(() => ); + openCollaborateModal(); + fireEvent.input(r.getByPlaceholderText("e.g. shield-refactor"), { + target: { value: "demo" }, + }); + return r; + } + + const goalBox = (r: ReturnType) => + r.getByPlaceholderText( + "The one goal every role works on — e.g. Add rate limiting to the public API", + ); + + it("sends a collaboration with the default orchestrator + worker profile", async () => { + const r = openCollab(); + fireEvent.input(goalBox(r), { target: { value: "Ship rate limiting" } }); + fireEvent.click(r.getByText("create collaboration")); + + await waitFor(() => expect(requestMock).toHaveBeenCalled()); + const sent = requestMock.mock.calls[0]![0] as Record; + expect(sent.type).toBe("session.create"); + const collab = sent.collaboration as { goal: string; roles: Array> }; + expect(collab.goal).toBe("Ship rate limiting"); + expect(collab.roles.map((x) => x.name)).toEqual(["orchestrator", "search"]); + // The orchestrator is claude-pinned in v1 (#245). + expect(collab.roles[0]!.providerId).toBe("claude"); + }); + + // A collaborative session IS its orchestrator, so the daemon derives the + // backend from that role and REJECTS a conflicting explicit providerId. + // Sending one would turn every create into an error. + it("never sends a top-level providerId", async () => { + const r = openCollab(); + fireEvent.input(goalBox(r), { target: { value: "g" } }); + fireEvent.click(r.getByText("create collaboration")); + await waitFor(() => expect(requestMock).toHaveBeenCalled()); + const sent = requestMock.mock.calls[0]![0] as Record; + expect("providerId" in sent).toBe(false); + }); + + it("omits optional fields rather than sending empty values", async () => { + const r = openCollab(); + fireEvent.input(goalBox(r), { target: { value: "g" } }); + fireEvent.click(r.getByText("create collaboration")); + await waitFor(() => expect(requestMock).toHaveBeenCalled()); + const sent = requestMock.mock.calls[0]![0] as Record; + const roles = (sent.collaboration as { roles: Array> }).roles; + // count:1 and write:false are the daemon's defaults — sending them adds + // noise, and an empty-string model would fail provider-aware validation. + for (const role of roles) { + expect("model" in role).toBe(false); + expect("count" in role).toBe(false); + expect("write" in role).toBe(false); + } + }); + + it("blocks submit until a goal is given, and says why", async () => { + const r = openCollab(); + expect(r.getByText("a goal is required")).toBeTruthy(); + expect((r.getByText("create collaboration") as HTMLButtonElement).disabled).toBe(true); + fireEvent.input(goalBox(r), { target: { value: "g" } }); + await waitFor(() => + expect((r.getByText("create collaboration") as HTMLButtonElement).disabled).toBe(false), + ); + }); + + it("rejects duplicate role names before hitting the daemon", async () => { + const r = openCollab(); + fireEvent.input(goalBox(r), { target: { value: "g" } }); + fireEvent.click(r.getByText("+ add role")); + const nameInputs = r.getAllByLabelText("Role name") as HTMLInputElement[]; + // Row 0 is the orchestrator (locked); rename the two workers to collide. + fireEvent.input(nameInputs[1]!, { target: { value: "review" } }); + fireEvent.input(nameInputs[2]!, { target: { value: "Review" } }); + await waitFor(() => expect(r.getByText('duplicate role "Review"')).toBeTruthy()); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it("keeps the orchestrator row non-removable and claude-pinned", () => { + const r = openCollab(); + const removes = r.getAllByLabelText("Remove role") as HTMLButtonElement[]; + expect(removes[0]!.disabled).toBe(true); + const backends = r.getAllByLabelText("Backend") as HTMLSelectElement[]; + expect(backends[0]!.disabled).toBe(true); + expect(backends[0]!.value).toBe("claude"); + }); + + it("carries model, count and write when set", async () => { + const r = openCollab(); + fireEvent.input(goalBox(r), { target: { value: "g" } }); + const models = r.getAllByLabelText("Model") as HTMLInputElement[]; + fireEvent.input(models[1]!, { target: { value: "gemini-2.5-pro" } }); + const counts = r.getAllByLabelText("Count") as HTMLInputElement[]; + fireEvent.input(counts[0]!, { target: { value: "3" } }); + const writes = r.container.querySelectorAll('input[type="checkbox"]'); + fireEvent.click(writes[0]!); + + fireEvent.click(r.getByText("create collaboration")); + await waitFor(() => expect(requestMock).toHaveBeenCalled()); + const sent = requestMock.mock.calls[0]![0] as Record; + const worker = (sent.collaboration as { roles: Array> }).roles[1]!; + expect(worker.model).toBe("gemini-2.5-pro"); + expect(worker.count).toBe(3); + expect(worker.write).toBe(true); + }); + + it("a plain session still sends no collaboration", async () => { + authMock.mockReturnValue(authOk(["claude"])); + requestMock.mockResolvedValue({ id: "s", name: "n", workdir: "/w" }); + const r = render(() => ); + openNewSessionModal(); + fireEvent.input(r.getByPlaceholderText("e.g. shield-refactor"), { + target: { value: "plain" }, + }); + fireEvent.click(r.getByText("create")); + await waitFor(() => expect(requestMock).toHaveBeenCalled()); + expect("collaboration" in (requestMock.mock.calls[0]![0] as object)).toBe(false); + }); +}); diff --git a/web/src/components/NewSessionModal.tsx b/web/src/components/NewSessionModal.tsx index 8b5bcd8c..75e5cf29 100644 --- a/web/src/components/NewSessionModal.tsx +++ b/web/src/components/NewSessionModal.tsx @@ -28,9 +28,36 @@ import { focusSession, mergeSession, sessionList } from "../state/sessions"; import type { PackWire, SessionInfo } from "../protocol/types"; import DirectoryPicker from "./files/DirectoryPicker"; -/** The modal serves two flows from one dialog (docs/pipeline-run.md): a plain - * session, or a governed pipeline run (adds a goal box + requires a pack). */ -type Mode = "session" | "pipeline"; +/** The modal serves three flows from one dialog: a plain session, a governed + * pipeline run (docs/pipeline-run.md — adds a goal box + requires a pack), or + * a COLLABORATIVE session (docs/collaborative-session-design.md §9 — a goal + * plus role→backend bindings, which the daemon compiles to an ephemeral + * one-goal pack; pack vocabulary deliberately stays hidden on that path). */ +type Mode = "session" | "pipeline" | "collaborate"; + +/** One editable role row. Kept as strings so partially-typed input renders; + * it is normalized into the wire shape at submit. */ +interface CollabRoleRow { + name: string; + /** "" = the daemon default backend. */ + providerId: string; + /** "" = that backend's own default model. */ + model: string; + count: number; + /** Opt-in write authority. Default OFF — §3 gives review/search no repo + * write, and a read-only child's identity carries no write scope at all. */ + write: boolean; +} + +/** The §3 starting profile: an orchestrator plus one worker. The orchestrator + * is claude-pinned in v1 (#245) — it is the only backend that mounts the + * fleet MCP server, and the daemon rejects anything else. */ +function defaultRoles(): CollabRoleRow[] { + return [ + { name: "orchestrator", providerId: "claude", model: "", count: 1, write: false }, + { name: "search", providerId: "", model: "", count: 1, write: false }, + ]; +} const [openSignal, setOpenSignal] = createSignal(false); const [mode, setMode] = createSignal("session"); @@ -45,6 +72,14 @@ export function openNewSessionModal(): void { /** Open the SAME dialog in pipeline mode: a goal / feature box + a required pack. * Submitting starts a governed run and focuses its bound session (the run shows * up as a normal chat). Wired to `/pipeline` and the Pack Browser's Run action. */ +/** Open the dialog in COLLABORATIVE mode: one goal worked by several + * role-children on their own backends. */ +export function openCollaborateModal(goal?: string): void { + setMode("collaborate"); + setGoalPrefill(goal ?? ""); + setOpenSignal(true); +} + export function openPipelineModal(goal?: string): void { setMode("pipeline"); setGoalPrefill(goal ?? ""); @@ -66,6 +101,38 @@ const NewSessionModal: Component = () => { // Capability role declared by the chosen pack; "" = no role restriction. const [packRole, setPackRole] = createSignal(""); + // ── Collaborative mode ──────────────────────────────────────────────────── + // One row per role. The orchestrator is always present and is NOT removable: + // the daemon requires exactly one, and the session being created IS it. + const [roles, setRoles] = createSignal(defaultRoles()); + + const updateRole = (i: number, patch: Partial): void => { + setRoles((rs) => rs.map((r, j) => (j === i ? { ...r, ...patch } : r))); + }; + const addRole = (): void => { + setRoles((rs) => [...rs, { name: "", providerId: "", model: "", count: 1, write: false }]); + }; + const removeRole = (i: number): void => { + setRoles((rs) => rs.filter((_, j) => j !== i)); + }; + + /** Client-side pre-flight. The daemon re-validates everything — this only + * spares a round-trip on the mistakes that are obvious locally. */ + const collabProblem = createMemo(() => { + if (mode() !== "collaborate") return null; + if (!goal().trim()) return "a goal is required"; + const named = roles().filter((r) => r.name.trim()); + if (named.length !== roles().length) return "every role needs a name"; + const seen = new Set(); + for (const r of named) { + const key = r.name.trim().toLowerCase(); + if (seen.has(key)) return `duplicate role "${r.name.trim()}"`; + seen.add(key); + } + if (!seen.has("orchestrator")) return 'one role must be named "orchestrator"'; + return null; + }); + // Backends this daemon registered (auth.ok `providers`, default first). // Older daemons don't advertise — hide the picker, sessions stay claude. const providers = createMemo(() => authIdentity()?.providers ?? []); @@ -141,7 +208,8 @@ const NewSessionModal: Component = () => { if (v) { setBusy(false); setError(null); - if (mode() === "pipeline") setGoal(goalPrefill()); + if (mode() === "pipeline" || mode() === "collaborate") setGoal(goalPrefill()); + if (mode() === "collaborate") setRoles(defaultRoles()); // Refresh the pack list every open. fetchPacks swallows its own // errors (it sets pack-state.error rather than rejecting), but guard // anyway so a rejected read can never break opening the modal. @@ -195,6 +263,63 @@ const NewSessionModal: Component = () => { return; } + // ── Collaborative session ───────────────────────────────────────────────── + if (mode() === "collaborate") { + const problem = collabProblem(); + if (problem) { + setError(problem); + return; + } + if (!n) { + setError("name required"); + return; + } + setBusy(true); + setError(null); + try { + const data = (await request({ + type: "session.create", + id: newRequestId(), + name: n, + workdir: workdir().trim() || ".", + collaboration: { + goal: goal().trim(), + roles: roles().map((r) => ({ + name: r.name.trim(), + // A blank picker means "daemon default"; the wire field is + // required, so resolve it to the advertised default here. + providerId: r.providerId || providers()[0] || "claude", + ...(r.model.trim() ? { model: r.model.trim() } : {}), + ...(r.count > 1 ? { count: r.count } : {}), + ...(r.write ? { write: true } : {}), + })), + }, + // providerId is deliberately NOT sent: a collaborative session IS its + // orchestrator, so the daemon derives the backend from that role and + // rejects a conflicting explicit value. + })) as SessionInfo | undefined; + if (data && typeof data === "object" && "id" in data) { + mergeSession(data); + focusSession(data.id); + } else { + await refreshSessions().catch(() => []); + } + setBusy(false); + setOpenSignal(false); + setName(""); + setWorkdir(""); + setGoal(""); + setRoles(defaultRoles()); + } catch (err) { + // The daemon's message is the useful one here — it names the exact + // rule broken (unknown provider, non-claude orchestrator, over the + // child ceiling), so surface it verbatim rather than paraphrasing. + setError(err instanceof Error ? err.message : String(err)); + setBusy(false); + } + return; + } + // ── Plain session ───────────────────────────────────────────────────────── if (!n) { setError("name required"); @@ -251,14 +376,46 @@ const NewSessionModal: Component = () => { onSubmit={submit} class="mt-[16vh] w-full max-w-md space-y-4 rounded-lg border border-border bg-bg-elev p-5 shadow-2xl" > -
+

- {mode() === "pipeline" ? "Start a pipeline run" : "New session"} + {mode() === "pipeline" + ? "Start a pipeline run" + : mode() === "collaborate" + ? "New collaborative session" + : "New session"}

+ {/* Plain ↔ collaborative is a toggle on the SAME dialog (§9): a + collaboration is a session plus a goal and role bindings, not a + separate object. Pipeline mode is entered from /pipeline, so it + isn't offered here. */} + +
+ + {(m) => ( + + )} + +
+

{mode() === "pipeline" ? "Run an installed pack against a goal. It creates a session, auto-advances through the pack's phases, and halts at each boundary for you to Approve / Revise / Reject." - : "A session is one Claude conversation rooted at a workdir. The daemon registers a per-session ZeroID agent identity automatically."} + : mode() === "collaborate" + ? "One goal, several agents in named roles — each on its own backend. This session is the orchestrator; the others come up as its children and hand work to each other through a shared goal blackboard." + : "A session is one Claude conversation rooted at a workdir. The daemon registers a per-session ZeroID agent identity automatically."}

@@ -335,14 +492,18 @@ const NewSessionModal: Component = () => {

- +