diff --git a/packages/cli/src/headless.ts b/packages/cli/src/headless.ts index 5a601ab..6c97744 100644 --- a/packages/cli/src/headless.ts +++ b/packages/cli/src/headless.ts @@ -520,25 +520,30 @@ export function runHeadless(options: HeadlessOptions): AsyncGenerator `${await factory.summary(state)}\n${headline(state, await readLiveness(options.root, state))}\nContinue the factory run from its current stage. This is a headless run: no human will answer questions.`, - finished: async (state) => { - if (state.stage === "DONE") - return { type: "done", ...(state.release?.prUrl ? { prUrl: state.release.prUrl } : {}) } - const pending = await pendingApprovals(options.root) - if (pending.length) - return { - type: "waiting", - reason: "a human must approve (es approve )", - stages: pending.map((item) => item.stage), - } - if (state.stage === "GRILL") - return { type: "waiting", reason: "the grill needs a human; run /grill interactively" } - return undefined - }, + finished: (state) => headlessFinished(options.root, state), // Research artifacts and seat outcomes count as progress, not just the stage machine. progress: async (state) => progressPrint(state, await readLiveness(options.root, state)), }) } +/** + * One headless end condition (#247.1): DONE carries the release PR url when + * the run released one, a pending approval waits on a human, and the grill + * needs a human. Anything else keeps driving. + */ +export async function headlessFinished(root: string, state: FactoryState): Promise { + if (state.stage === "DONE") return { type: "done", ...(state.release?.prUrl ? { prUrl: state.release.prUrl } : {}) } + const pending = await pendingApprovals(root) + if (pending.length) + return { + type: "waiting", + reason: "a human must approve (es approve )", + stages: pending.map((item) => item.stage), + } + if (state.stage === "GRILL") return { type: "waiting", reason: "the grill needs a human; run /grill interactively" } + return undefined +} + export const LOG_LEVELS = ["quiet", "info", "debug"] as const export type LogLevel = (typeof LOG_LEVELS)[number] diff --git a/packages/cli/src/mcp.ts b/packages/cli/src/mcp.ts index de3b26f..319cba8 100644 --- a/packages/cli/src/mcp.ts +++ b/packages/cli/src/mcp.ts @@ -2,10 +2,13 @@ // harness-neutral factory operations to harnesses without a native plugin and // to headless runs. It has no approve, trust, waive or resume tool: those are // human-only. Caller identity is PINNED, never piped: the harness adapter sets -// one agent id per server in its environment (ES_MCP_AGENT, else the legacy -// ES_AGENT/defaultAgent, both written by the human in the adapter config), and -// the per-call `agent` argument is ignored. Without an adapter identity the -// caller is "mcp", which maps to no seat, so every seat-checked tool refuses. +// one agent id per server in its environment (`ES_MCP_AGENT`, else the legacy +// `ES_AGENT`, else the `defaultAgent` option — all written by the human in the +// adapter config), and the per-call `agent` argument is ignored. Without an +// adapter identity the caller is "mcp", which maps to no seat, so every +// seat-checked tool refuses. Only a registry agent id (`factory`, +// `es-manager`, `es-programmer`, …) holds a seat: any other pinned string is +// still the caller, but a seat-less one, refused the same way as "mcp". import { stateDir as defaultStateDir, esTools, Factory, gateRunner } from "@heretek-ai/es-core" import { VERSION } from "./version.ts" @@ -26,7 +29,9 @@ export function createMcpServer(options: McpOptions) { const env = options.env ?? process.env // Pinned at construction: the adapter's environment, never the caller's // arguments. An agent piping JSON-RPC into this server cannot claim a seat. - const caller = env.ES_MCP_AGENT || options.defaultAgent || "mcp" + // Library consumers get the same order as the CLI wrapper: the adapter's + // ES_MCP_AGENT wins, then the legacy ES_AGENT, then the defaultAgent option. + const caller = env.ES_MCP_AGENT || env.ES_AGENT || options.defaultAgent || "mcp" const factory = new Factory(options.root, { gates: gateRunner(options.stateDir ? { stateDir: options.stateDir } : {}), ...(options.stateDir ? { stateDir: options.stateDir } : {}), @@ -35,7 +40,7 @@ export function createMcpServer(options: McpOptions) { const byName = new Map(tools.map((tool) => [tool.name.replace(/^es_/, ""), tool])) const listed = [...byName].map(([name, tool]) => ({ name, - description: `${tool.description}${name === "status" || name === "spec_validate" ? "" : " Caller identity is pinned by the adapter (ES_MCP_AGENT); a per-call `agent` argument is accepted but ignored."}`, + description: `${tool.description}${name === "status" || name === "spec_validate" ? "" : " Caller identity is pinned by the adapter (ES_MCP_AGENT, else ES_AGENT, else the default agent); a per-call `agent` argument is accepted but ignored."}`, inputSchema: { ...tool.input, properties: { @@ -43,7 +48,7 @@ export function createMcpServer(options: McpOptions) { agent: { type: "string", description: - "Ignored: the caller is the adapter's pinned ES_MCP_AGENT (or the default agent), never this argument.", + "Ignored: the caller is the adapter's pinned ES_MCP_AGENT (else ES_AGENT, else the default agent), never this argument.", }, }, }, @@ -61,7 +66,7 @@ export function createMcpServer(options: McpOptions) { capabilities: { tools: { listChanged: false } }, serverInfo: { name: "epistemic-swarm", version: options.version ?? VERSION }, instructions: - "Epistemic Swarm factory operations. Approvals, trust, waivers and resume are human-only (TUI or `es` CLI) and have no MCP tool. Caller identity is pinned: the adapter sets ES_MCP_AGENT in this server's environment (one server per agent); without it the caller is `mcp`, which holds no seat, so seat-checked tools refuse. A per-call `agent` argument is ignored.", + "Epistemic Swarm factory operations. Approvals, trust, waivers and resume are human-only (TUI or `es` CLI) and have no MCP tool. Caller identity is pinned: the adapter sets ES_MCP_AGENT in this server's environment (one server per agent), else the legacy ES_AGENT, else the default agent; without any of them the caller is `mcp`, which holds no seat, so seat-checked tools refuse. A per-call `agent` argument is ignored.", }) case "ping": return reply({}) diff --git a/packages/cli/test/headless.test.ts b/packages/cli/test/headless.test.ts index a53a40b..9fdb251 100644 --- a/packages/cli/test/headless.test.ts +++ b/packages/cli/test/headless.test.ts @@ -1,16 +1,19 @@ // Headless fleet probes (#120): per-turn tool metrics carry duration and seat, // and stay out of the default human output. import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { mkdtemp, rm } from "node:fs/promises" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" -import { Factory, gateRunner } from "@heretek-ai/es-core" +import { Factory, type FactoryState, factoryLayout, gateRunner } from "@heretek-ai/es-core" import { driveHeadless, type HarnessDriver, type HeadlessEvent, HeadlessJsonlSchema, + headlessFinished, + opencodeDriver, presentEvent, + runHeadless, toJsonl, } from "../src/headless.ts" @@ -117,3 +120,190 @@ describe("turn-metrics human output (#120)", () => { expect(presentEvent(metrics, "debug")).toEqual(metrics) }) }) + +describe("runHeadless end conditions (#247)", () => { + test("a research run drives to done, and done carries no prUrl without a release", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "es-headless-done-")) + const dirState = await mkdtemp(path.join(tmpdir(), "es-headless-done-state-")) + try { + const factory = new Factory(dir, { gates: gateRunner({ stateDir: dirState }), stateDir: dirState }) + await factory.beginResearchRun({ objective: "what queue", ceilingUSD: 5 }) + const seen: Array<{ agent: string; prompt: string }> = [] + const driver: HarnessDriver = { + id: "fake", + available: async () => true, + async *turn(input) { + seen.push({ agent: input.agent, prompt: input.prompt }) + yield { text: "working" } + // The coordinator flow, played against the real tools and gate. + const { researchTools } = await import("@heretek-ai/es-core") + const stubFetch = (async () => + new Response("

Queues decouple workers from producers durably.

", { + headers: { "content-type": "text/html" }, + })) as unknown as typeof fetch + const tools = researchTools({ + root: dir, + fetch: stubFetch, + stateDir: dirState, + policy: async () => ({ root: dir }), + }) + const fetched = await tools + .find((tool) => tool.name === "es_research_fetch")! + .execute({ url: "https://192.0.2.1/queues" }, { agent: "es-research-alpha" }) + const sha = /sha256:([0-9a-f]{64})/.exec(fetched)?.[1] + await writeFile( + path.join(dir, ".factory/research/REPORT.md"), + `# Report\n\n- Queues decouple workers [VERIFIED: sha256:${sha} "decouple workers from producers"]\n`, + ) + await new Factory(dir, { gates: gateRunner({ stateDir: dirState }), stateDir: dirState }).completeResearch( + "deep-researcher", + ) + return "ses_fake" + }, + } + const events: HeadlessEvent[] = [] + for await (const event of runHeadless({ root: dir, driver, maxTurns: 5, stallTurns: 99, stateDir: dirState })) + events.push(event) + // The driver saw the factory seat identity and the headless prompt (#247.2). + expect(seen).toHaveLength(1) + expect(seen[0]?.agent).toBe("factory") + expect(seen[0]?.prompt).toContain("headless run") + expect(events[0]).toMatchObject({ type: "start", driver: "fake" }) + expect(events.filter((event) => event.type === "turn")).toHaveLength(1) + const done = events.at(-1)! + expect(done).toEqual({ type: "done" }) + expect("prUrl" in done).toBe(false) + } finally { + await rm(dir, { recursive: true, force: true }) + await rm(dirState, { recursive: true, force: true }) + } + }) + + test("headlessFinished maps DONE (with prUrl spread), pending approvals and the grill", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "es-headless-finished-")) + try { + const done = (release?: FactoryState["release"]) => + ({ stage: "DONE", ...(release ? { release } : {}) }) as unknown as FactoryState + // No release: no prUrl key. With one: the url spreads onto done (#247.1). + expect(await headlessFinished(dir, done())).toEqual({ type: "done" }) + expect(await headlessFinished(dir, done({ prUrl: "https://example.test/pr/9" }))).toEqual({ + type: "done", + prUrl: "https://example.test/pr/9", + }) + // A pending approval waits on a human, with its stages. + await mkdir(path.dirname(factoryLayout(dir).pending), { recursive: true }) + await writeFile(factoryLayout(dir).pending, JSON.stringify([{ stage: "spec", requestedBy: "factory", at: "x" }])) + expect(await headlessFinished(dir, { stage: "RESEARCH" } as unknown as FactoryState)).toEqual({ + type: "waiting", + reason: "a human must approve (es approve )", + stages: ["spec"], + }) + await writeFile(factoryLayout(dir).pending, "[]") + // The grill (no pending) waits on a human; a mid-build stage keeps driving. + expect(await headlessFinished(dir, { stage: "GRILL" } as unknown as FactoryState)).toMatchObject({ + type: "waiting", + reason: expect.stringContaining("grill"), + }) + expect(await headlessFinished(dir, { stage: "RESEARCH" } as unknown as FactoryState)).toBeUndefined() + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("a STOP file halts at headless level without granting anything", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "es-headless-stop-")) + const dirState = await mkdtemp(path.join(tmpdir(), "es-headless-stop-state-")) + try { + await writeFile(path.join(dir, "README.md"), "# x\n") + await new Factory(dir, { gates: gateRunner({ stateDir: dirState }), stateDir: dirState }).provisionRun( + "tester", + 5, + ) + await mkdir(path.dirname(factoryLayout(dir).stop), { recursive: true }) + await writeFile(factoryLayout(dir).stop, "lunch\n") + const driver: HarnessDriver = { + id: "fake", + available: async () => true, + async *turn() { + yield { text: "unused" } + return "ses_fake" + }, + } + const halted: HeadlessEvent[] = [] + for await (const event of runHeadless({ root: dir, driver, maxTurns: 3, stateDir: dirState })) halted.push(event) + expect(halted[0]).toMatchObject({ type: "start" }) + expect(halted.at(-1)).toMatchObject({ type: "halted", reason: expect.stringContaining("STOP file") }) + expect(halted.some((event) => event.type === "turn")).toBe(false) + } finally { + await rm(dir, { recursive: true, force: true }) + await rm(dirState, { recursive: true, force: true }) + } + }) + + test("an unknown --driver is refused before anything launches", async () => { + const { main } = await import("../src/main.ts") + const printed: string[] = [] + const code = await main(["factory", "run", "--headless", "--driver", "nope"], { + print: (text) => printed.push(text), + confirm: { interactive: false, write: () => {}, readLine: async () => "y", readSecret: async () => "" }, + cwd: root, + stateDir: state, + }) + expect(code).toBe(2) + expect(printed.join("\n")).toContain("Unknown driver") + }) +}) + +describe("opencode driver argv shape (#247.2)", () => { + test("run --standalone --format json --agent, with model/session/prompt passthrough", async () => { + const { chmod } = await import("node:fs/promises") + const bin = await mkdtemp(path.join(tmpdir(), "es-driver-argv-")) + const previous = process.env.PATH + try { + await writeFile( + path.join(bin, "opencode"), + `#!/bin/sh\nprintf '%s\\n' "$@" > "$ES_ARGV_LOG"\necho '{"type":"text","sessionID":"ses_argv","part":{"text":"hi"}}'\n`, + ) + await chmod(path.join(bin, "opencode"), 0o755) + process.env.PATH = `${bin}${path.delimiter}${previous}` + const argvOf = async (input: Parameters[0]): Promise => { + const log = path.join(bin, `argv-${Math.random().toString(36).slice(2)}.txt`) + process.env.ES_ARGV_LOG = log + try { + const turn = opencodeDriver.turn(input) + let step = await turn.next() + while (!step.done) step = await turn.next() + expect(step.value).toBe("ses_argv") + return (await Bun.file(log).text()).split("\n").filter(Boolean) + } finally { + delete process.env.ES_ARGV_LOG + } + } + expect(await argvOf({ root, agent: "factory", prompt: "go" })).toEqual([ + "run", + "--standalone", + "--format", + "json", + "--agent", + "factory", + "go", + ]) + expect(await argvOf({ root, agent: "factory", prompt: "again", model: "prov/model", session: "ses_1" })).toEqual([ + "run", + "--standalone", + "--format", + "json", + "--agent", + "factory", + "--model", + "prov/model", + "--session", + "ses_1", + "again", + ]) + } finally { + process.env.PATH = previous + await rm(bin, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/cli/test/mcp-identity.test.ts b/packages/cli/test/mcp-identity.test.ts new file mode 100644 index 0000000..fe5131e --- /dev/null +++ b/packages/cli/test/mcp-identity.test.ts @@ -0,0 +1,191 @@ +// MCP caller-identity pinning (#230, #232): the adapter pins one agent id per +// server in the server environment — `ES_MCP_AGENT`, else the legacy +// `ES_AGENT`, else the `defaultAgent` option — and the per-call `agent` +// argument is ignored. Without an adapter identity the caller is "mcp", which +// maps to no seat, so every seat-checked tool refuses. A pinned string that is +// not a registry agent id (e.g. `programmer`) pins to a seat-less identity: +// the caller string is still pinned, it just holds no seat. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { Factory, gateRunner, HUMAN_VERBS, readApproval } from "@heretek-ai/es-core" +import { createMcpServer } from "../src/mcp.ts" + +let root: string +let state: string + +beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), "es-mcp-id-")) + state = await mkdtemp(path.join(tmpdir(), "es-mcp-id-state-")) + const proc = Bun.spawn(["git", "init", "-q", "-b", "main"], { cwd: root, stdout: "pipe", stderr: "pipe" }) + await proc.exited + await writeFile(path.join(root, "README.md"), "# x\n") +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) + await rm(state, { recursive: true, force: true }) +}) + +const call = (server: ReturnType, name: string, args: Record = {}, id = 1) => + server.handle({ jsonrpc: "2.0", id, method: "tools/call", params: { name, arguments: args } }) + +describe("mcp identity pin order (mcp.ts:29)", () => { + test("the legacy ES_AGENT pins the caller when ES_MCP_AGENT is absent", async () => { + const server = createMcpServer({ root, stateDir: state, env: { ES_AGENT: "factory" } }) + const refused = await call(server, "complete", {}) + expect(refused?.result.isError).toBe(true) + expect(refused?.result.content[0].text).toContain("called by factory") + }) + + test("ES_MCP_AGENT beats ES_AGENT beats defaultAgent", async () => { + const top = createMcpServer({ + root, + stateDir: state, + env: { ES_MCP_AGENT: "es-qa-functional", ES_AGENT: "factory" }, + defaultAgent: "factory", + }) + const pinned = await call(top, "complete", { agent: "factory" }) + expect(pinned?.result.isError).toBe(true) + expect(pinned?.result.content[0].text).toContain("called by es-qa-functional") + + const legacy = createMcpServer({ + root, + stateDir: state, + env: { ES_AGENT: "factory" }, + defaultAgent: "es-qa-functional", + }) + const viaLegacy = await call(legacy, "complete", {}) + expect(viaLegacy?.result.isError).toBe(true) + expect(viaLegacy?.result.content[0].text).toContain("called by factory") + + const fallback = createMcpServer({ root, stateDir: state, env: {}, defaultAgent: "factory" }) + const viaDefault = await call(fallback, "complete", {}) + expect(viaDefault?.result.isError).toBe(true) + expect(viaDefault?.result.content[0].text).toContain("called by factory") + }) + + test("a pinned string without a registry seat stays seat-less but pinned", async () => { + const server = createMcpServer({ root, stateDir: state, env: { ES_MCP_AGENT: "programmer" } }) + const refused = await call(server, "complete", {}) + expect(refused?.result.isError).toBe(true) + // Registry ids are `es-programmer`/`es-manager`/`factory`: the literal + // `programmer` holds no seat, yet the caller string is still the pin. + expect(refused?.result.content[0].text).toContain("called by programmer") + }) +}) + +describe("as-mcp refusal across every seat-gated tool", () => { + // Every tool whose execute path opens with requireSeat, called with an + // empty adapter environment (caller "mcp", no seat). + const seatGated: Array<{ tool: string; args: Record }> = [ + { tool: "frontier_write", args: { frontier: {} } }, + { tool: "research_complete", args: {} }, + { tool: "build_start", args: {} }, + { tool: "qa_verdict", args: { verdict: "pass", notes: "n" } }, + { tool: "tiebreak", args: { verdict: "pass", notes: "n" } }, + { tool: "replan", args: { notes: "n" } }, + { tool: "release", args: {} }, + { tool: "complete", args: {} }, + ] + for (const { tool, args } of seatGated) { + test(`${tool} refuses the unpinned caller as mcp`, async () => { + const server = createMcpServer({ root, stateDir: state, env: {} }) + const refused = await call(server, tool, args) + expect(refused?.result.isError).toBe(true) + expect(refused?.result.content[0].text).toContain("called by mcp") + }) + } +}) + +describe("no MCP tool grants a human-only verb (derived from HUMAN_VERBS)", () => { + test("the coarse tool list is exactly the 12 read/request/seat tools", async () => { + const server = createMcpServer({ root, stateDir: state }) + const list = await server.handle({ jsonrpc: "2.0", id: 1, method: "tools/list" }) + const names = list?.result.tools.map((tool: { name: string }) => tool.name).sort() + // A future human-only verb gaining an MCP tool changes this list and fails loudly here. + expect(names).toEqual( + [ + "build_start", + "complete", + "frontier_write", + "gates_run", + "qa_verdict", + "release", + "replan", + "request_approval", + "research_complete", + "spec_validate", + "status", + "tiebreak", + ].sort(), + ) + }) + + test("no listed tool (except the request-only approval) matches a HUMAN_VERBS rule", async () => { + const server = createMcpServer({ root, stateDir: state }) + const list = await server.handle({ jsonrpc: "2.0", id: 1, method: "tools/list" }) + const names = list?.result.tools.map((tool: { name: string }) => tool.name) as string[] + const humanOnly = (verb: string, sub: string | undefined): boolean => { + const rule = HUMAN_VERBS.find(([name]) => name === verb) + if (!rule) return false + const [, predicate] = rule + return predicate === undefined ? true : predicate(sub) + } + for (const name of names) { + if (name === "request_approval") continue // request-only: proven below, never grants. + const tokens = name.split(/[_-]/) + const matched = HUMAN_VERBS.some(([verb, predicate]) => { + if (!tokens.includes(verb)) return false + if (predicate === undefined) return true + return tokens.some((token) => token !== verb && predicate(token)) + }) + expect([name, matched, humanOnly("never-a-verb", undefined)]).toEqual([name, false, false]) + } + }) + + test("request_approval records a request without granting anything", async () => { + const factory = new Factory(root, { gates: gateRunner({ stateDir: state }), stateDir: state }) + await factory.writeFrontier("grill", { + version: "1.1", + idea: "mcp test", + spendCeiling: { currency: "USD", maxAmount: 3 }, + settled: true, + nodes: [{ id: "a", question: "q?", answer: "yes", status: "settled" }], + }) + const server = createMcpServer({ root, stateDir: state, env: { ES_MCP_AGENT: "factory" } }) + const requested = await call(server, "request_approval", { stage: "frontier" }) + expect(requested?.result.isError).toBeUndefined() + expect(requested?.result.content[0].text).toContain("Requested human approval") + // Request-only: the approval file is still absent (a human grants it elsewhere). + expect(await readApproval(root, "frontier")).toBeUndefined() + }) +}) + +describe("mcp identity over real stdio", () => { + test("serveStdio pins ES_MCP_AGENT: a piped agent claim is refused as the pin", async () => { + const proc = Bun.spawn(["bun", path.join(import.meta.dir, "../src/main.ts"), "mcp", "--cwd", root], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + // The literal `programmer` holds no registry seat (#232.1), so the + // seat check itself names the pin — the strongest stdio proof. + env: { ...process.env, ES_MCP_AGENT: "programmer" }, + }) + proc.stdin.write( + `${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "complete", arguments: { agent: "factory" } } })}\n`, + ) + proc.stdin.end() + const lines = (await new Response(proc.stdout).text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + await proc.exited + expect(lines.map((line) => line.id)).toEqual([1]) + expect(lines[0].result.isError).toBe(true) + // The per-call `agent: "factory"` is ignored: the refusal names the pin. + expect(lines[0].result.content[0].text).toContain("called by programmer") + expect(await readFile(path.join(root, "README.md"), "utf8")).toContain("# x") + }) +}) diff --git a/packages/fleet/src/telemetry.ts b/packages/fleet/src/telemetry.ts index 4ee59a0..2664d69 100644 --- a/packages/fleet/src/telemetry.ts +++ b/packages/fleet/src/telemetry.ts @@ -285,7 +285,11 @@ interface HttpResponder { const jsonBody = (res: HttpResponder, code: number, value: unknown): void => { const text = JSON.stringify(value) - res.writeHead(code, { "content-type": "application/json", "content-length": Buffer.byteLength(text) }) + res.writeHead(code, { + ...WEB_SECURITY_HEADERS, + "content-type": "application/json", + "content-length": Buffer.byteLength(text), + }) res.end(text) } @@ -430,7 +434,7 @@ export class TelemetryServer { if ((field(headers, "upgrade") ?? "").toLowerCase() === "websocket" && target === "/fleet/ws") return this.handleUpgrade(socket, headers, res) if (!this.hostOk(field(headers, "host"))) { - res.writeHead(403, { "content-length": 0, connection: "close" }) + res.writeHead(403, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" }) res.end() return } @@ -440,7 +444,7 @@ export class TelemetryServer { // (bearer or session), submit is browser-only (cookie + Origin + CSRF). if (method === "POST" && target === "/fleet/approve/preview") return this.approvePreview(headers, rest, res) if (method === "POST" && target === "/fleet/approve") return this.approveSubmit(headers, rest, res) - res.writeHead(404, { "content-length": 0, connection: "close" }) + res.writeHead(404, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" }) res.end() return } @@ -448,7 +452,7 @@ export class TelemetryServer { // A presented session cookie that cannot authorize is a forbidden // CSRF/origin failure (403); wholly missing credentials are 401. const code = parseSessionCookie(field(headers, "cookie")) !== undefined ? 403 : 401 - res.writeHead(code, { "content-length": 0, connection: "close" }) + res.writeHead(code, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" }) res.end() return } @@ -509,7 +513,7 @@ export class TelemetryServer { res.end() } if (this.options.webRoot === undefined) { - res.writeHead(404, { "content-length": 0, connection: "close" }) + res.writeHead(404, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" }) res.end() return } @@ -580,7 +584,7 @@ export class TelemetryServer { // Same convention as read-only RPC: a presented session cookie that // cannot authorize is a forbidden CSRF/origin failure (403). const code = parseSessionCookie(field(headers, "cookie")) !== undefined ? 403 : 401 - res.writeHead(code, { "content-length": 0, connection: "close" }) + res.writeHead(code, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" }) res.end() return } @@ -637,12 +641,12 @@ export class TelemetryServer { private async approveSubmit(headers: Map, rest: Buffer, res: HttpResponder): Promise { const session = parseSessionCookie(field(headers, "cookie")) ?? "" if (!this.sessions.valid(session)) { - res.writeHead(401, { "content-length": 0, connection: "close" }) + res.writeHead(401, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" }) res.end() return } if (!loopbackOriginOk(field(headers, "origin")) || !this.csrf.check(session, field(headers, "x-csrf"))) { - res.writeHead(403, { "content-length": 0, connection: "close" }) + res.writeHead(403, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" }) res.end() return } @@ -730,13 +734,21 @@ export class TelemetryServer { private ok(res: HttpResponder, value: unknown): void { const text = JSON.stringify(value) - res.writeHead(200, { "content-type": "application/json", "content-length": Buffer.byteLength(text) }) + res.writeHead(200, { + ...WEB_SECURITY_HEADERS, + "content-type": "application/json", + "content-length": Buffer.byteLength(text), + }) res.end(text) } private fail(res: HttpResponder, code: number, message: string, extra: Record = {}): void { const text = JSON.stringify({ error: message, ...extra }) - res.writeHead(code, { "content-type": "application/json", "content-length": Buffer.byteLength(text) }) + res.writeHead(code, { + ...WEB_SECURITY_HEADERS, + "content-type": "application/json", + "content-length": Buffer.byteLength(text), + }) res.end(text) } @@ -818,7 +830,7 @@ export class TelemetryServer { (!this.bearerOk(field(headers, "authorization")) && !cookieOk) ) { const code = !this.hostOk(field(headers, "host")) || !this.originOk(field(headers, "origin")) ? 403 : 401 - res.writeHead(code, { "content-length": 0, connection: "close" }) + res.writeHead(code, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" }) res.end() return } diff --git a/packages/fleet/test/approve.test.ts b/packages/fleet/test/approve.test.ts index d6ec7e2..d5d5eb3 100644 --- a/packages/fleet/test/approve.test.ts +++ b/packages/fleet/test/approve.test.ts @@ -22,7 +22,7 @@ const frontier = { nodes: [{ id: "lang", question: "Language?", answer: "TypeScript", status: "settled" }], } -async function rig() { +async function rig(extra: { approveTicketTtlMs?: number } = {}) { const state = await mkdtemp(path.join(tmpdir(), "es-approve-")) const repo = await mkdtemp(path.join(tmpdir(), "es-approve-repo-")) await mkdir(path.join(repo, ".factory"), { recursive: true }) @@ -56,6 +56,7 @@ async function rig() { stateRoot: state, port: 0, token: TOKEN, + ...extra, getSnapshot: (): FleetSnapshot => ({ daemon: { running: true, pid: 1, maxUsd: 10, concurrency: 1 }, tasks: [], @@ -301,6 +302,47 @@ describe("approve submit", () => { } }) + test("checklist 4b: an expired preview ticket fails the submit (#283.2)", async () => { + const { state, repo, server } = await rig({ approveTicketTtlMs: 0 }) + try { + const cookie = await session(server.port, state) + const origin = `http://127.0.0.1:${server.port}` + const preview = json( + ( + await post( + server.port, + "/fleet/approve/preview", + { runId: "t1", stage: "frontier" }, + { Cookie: cookie, Origin: origin }, + ) + ).body, + ) + // A zero TTL expires the ticket before the submit even runs: no sleep needed. + const res = await approveAs(server.port, cookie, preview.csrf as string, { + ticket: preview.ticket, + passphrase: PASS, + }) + expect(res.status).toBe(403) + expect(res.body).toContain("expired") + const { readApproval } = await import("@heretek-ai/es-core") + expect(await readApproval(repo, "frontier")).toBeUndefined() + } finally { + await server.stop() + await rm(state, { recursive: true, force: true }) + await rm(repo, { recursive: true, force: true }) + } + }) + + test("approve tickets expire by TTL at unit level (#283.2)", async () => { + const { ApproveTickets } = await import("../src/approve.ts") + const expired = new ApproveTickets(0) + expect(expired.redeem(expired.issue({ repoDir: "r", stage: "frontier", subjectHash: "h" }))).toBeUndefined() + const live = new ApproveTickets() + const ticket = live.issue({ repoDir: "r", stage: "frontier", subjectHash: "h" }) + expect(live.redeem(ticket)).toEqual({ repoDir: "r", stage: "frontier", subjectHash: "h" }) + expect(live.redeem(ticket)).toBeUndefined() + }) + test("checklist 5: an artifact changed after the preview refuses the sign", async () => { const { state, repo, server } = await rig() try { diff --git a/packages/fleet/test/headers.test.ts b/packages/fleet/test/headers.test.ts index e929700..47d732b 100644 --- a/packages/fleet/test/headers.test.ts +++ b/packages/fleet/test/headers.test.ts @@ -1,6 +1,9 @@ // S5.4 (CodeQL #84) remote property injection (repair-151): request headers // parse into a Map (never a plain `{}` keyed by remote names), non-token -// names are rejected, and `field()` is actually case-insensitive. +// names are rejected, and `field()` is actually case-insensitive. This file +// probes request-header parser hygiene only — web security headers (CSP) are +// proven in `web.test.ts` ("every web response carries the strict security +// headers", #283.1). import { describe, expect, test } from "bun:test" import { field, parseHeaders } from "../src/telemetry.ts" diff --git a/packages/fleet/test/web.test.ts b/packages/fleet/test/web.test.ts index 478f7b3..1a6e26b 100644 --- a/packages/fleet/test/web.test.ts +++ b/packages/fleet/test/web.test.ts @@ -36,9 +36,13 @@ async function rig() { return { state, webRoot, server, token } } -const raw = (port: number, head: string): Promise<{ status: number; headers: Record; body: string }> => +const raw = ( + port: number, + head: string, + body = "", +): Promise<{ status: number; headers: Record; body: string }> => new Promise((resolve, reject) => { - const socket = createConnection(port, "127.0.0.1", () => socket.write(head)) + const socket = createConnection(port, "127.0.0.1", () => socket.write(head + body)) let buffer = Buffer.alloc(0) socket.on("data", (chunk: Buffer) => { buffer = Buffer.concat([buffer, chunk]) @@ -67,6 +71,24 @@ const get = (port: number, target: string, headers: Record = {}) .join("")}\r\n`, ) +const postRpc = (port: number, payload: unknown, extra: Record = {}) => { + const body = JSON.stringify(payload) + return raw( + port, + `POST /fleet/rpc HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n${Object.entries( + extra, + ) + .map(([k, v]) => `${k}: ${v}\r\n`) + .join("")}\r\n`, + body, + ) +} + +const expectSecurityHeaders = (headers: Record, where: string): void => { + for (const [name, value] of Object.entries(WEB_SECURITY_HEADERS)) + expect(headers[name], `${where}: ${name}`).toBe(value) +} + describe("web tickets", () => { test("a ticket redeems once, then is dead", async () => { const state = await mkdtemp(path.join(tmpdir(), "es-fleet-web-")) @@ -91,7 +113,7 @@ describe("web tickets", () => { } }) - test("malformed and expired tickets are refused", async () => { + test("malformed and missing tickets are refused", async () => { const state = await mkdtemp(path.join(tmpdir(), "es-fleet-web-")) try { expect(await redeemWebTicket(state, "not-hex")).toBe(false) @@ -102,6 +124,35 @@ describe("web tickets", () => { } }) + test("a minted-then-expired ticket is refused and consumed (#283.2)", async () => { + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-web-")) + try { + const ticket = await mintWebTicket(state) + const { fleetDir } = await import("../src/state.ts") + // Age the ticket file past its TTL instead of sleeping for it. + await writeFile(path.join(fleetDir(state), "web-tickets", ticket), `${Date.now() - 1_000}\n`) + expect(await redeemWebTicket(state, ticket)).toBe(false) + expect(await redeemWebTicket(state, ticket)).toBe(false) + } finally { + await rm(state, { recursive: true, force: true }) + } + }) + + test("an expired ticket redeems to 403 without a cookie at HTTP level (#283.2)", async () => { + const { state, server } = await rig() + try { + const ticket = await mintWebTicket(state) + const { fleetDir } = await import("../src/state.ts") + await writeFile(path.join(fleetDir(state), "web-tickets", ticket), `${Date.now() - 1_000}\n`) + const res = await get(server.port, `/?t=${ticket}`) + expect(res.status).toBe(403) + expect(res.headers["set-cookie"]).toBeUndefined() + } finally { + await server.stop() + await rm(state, { recursive: true, force: true }) + } + }) + test("cookie parsing extracts the session id", () => { const id = "ab".repeat(32) expect(parseSessionCookie(`a=1; ${WEB_SESSION_COOKIE}=${id}; b=2`)).toBe(id) @@ -164,7 +215,7 @@ describe("web serving", () => { }) test("every web response carries the strict security headers", async () => { - const { state, server } = await rig() + const { state, server, token } = await rig() try { const ticket = await mintWebTicket(state) const login = await get(server.port, `/?t=${ticket}`) @@ -174,6 +225,26 @@ describe("web serving", () => { expect(login.headers["x-content-type-options"]).toBe("nosniff") expect(login.headers["referrer-policy"]).toBe("no-referrer") expect(WEB_SECURITY_HEADERS["content-security-policy"]).toContain("connect-src 'self'") + // JSON responders carry them too (#282): success, error envelopes, and + // the bare 401/403/404 paths. + const authed = { Authorization: `Bearer ${token}` } + const ok = await postRpc(server.port, { method: "fleet.status", params: {} }, authed) + expect(ok.status).toBe(200) + expect(ok.headers["content-type"]).toContain("application/json") + expectSecurityHeaders(ok.headers, "fleet.status 200") + const unknown = await postRpc(server.port, { method: "nope", params: {} }, authed) + expect(unknown.status).toBe(200) + expect(unknown.body).toContain("-32601") + expectSecurityHeaders(unknown.headers, "unknown method") + const anon = await postRpc(server.port, { method: "fleet.status", params: {} }) + expect(anon.status).toBe(401) + expectSecurityHeaders(anon.headers, "anonymous rpc 401") + const missing = await raw( + server.port, + `GET /fleet/rpc HTTP/1.1\r\nHost: 127.0.0.1:${server.port}\r\nConnection: close\r\n\r\n`, + ) + expect(missing.status).toBe(404) + expectSecurityHeaders(missing.headers, "wrong path 404") } finally { await server.stop() await rm(state, { recursive: true, force: true }) diff --git a/packages/opencode/src/policy.ts b/packages/opencode/src/policy.ts index 9983281..7f74bd0 100644 --- a/packages/opencode/src/policy.ts +++ b/packages/opencode/src/policy.ts @@ -174,7 +174,10 @@ export function createPolicyHooks(runtime: Runtime, seats?: SeatTracker) { ? patchTargets(input.patchText) : [] if (!files.length) return - if (runtime.options.lspAfterEdit !== false) { + // The post-edit diagnostics are a language-server channel (#277.2): seats + // scoped to `lsp: "none"` never get them (their tool rules already hide + // `lsp_*`). Navigating seats and the user's own agents keep the channel. + if (runtime.options.lspAfterEdit !== false && agentSpec(event.agent)?.lsp !== "none") { const lines: string[] = [] for (const file of files) { const absolute = path.resolve(runtime.root, file) diff --git a/packages/opencode/src/runtime.ts b/packages/opencode/src/runtime.ts index 12f069c..7776e65 100644 --- a/packages/opencode/src/runtime.ts +++ b/packages/opencode/src/runtime.ts @@ -1,8 +1,9 @@ // Per-location runtime shared by the plugin's hooks, tools, commands and RPC. -// Options are layered: the canonical config (global file → project file) with -// plugin options as the most specific override. Plugin options are config -// keys validated by the same strict schema; unknown keys are ignored with a -// warning the TUI shows once. +// The server only wires option layering (parseOptions → createRuntime); the +// merge itself lives in core (loadEsConfig/mergeConfig): the canonical config +// (global file → project file) with plugin options as the most specific +// override. Plugin options are config keys validated by the same strict +// schema; unknown keys are ignored with a warning the TUI shows once. import { stateDir as defaultStateDir, type EsConfig, @@ -68,6 +69,9 @@ export async function createRuntime(root: string, parsed: ParsedOptions): Promis ...(config.searchProvider ? { searchProvider: config.searchProvider } : {}), } const stateDir = options.stateDir ?? defaultStateDir() + // One manager per project (#275.1): servers start lazily per (server, root) + // and are reaped by the idle sweeper between sessions, stopping only on + // plugin unload (see server.ts) — long-lived servers outlive sessions. const lsp = new LspManager({ root, stateDir }) const factory = new Factory(root, { gates: gateRunner({ stateDir }), diff --git a/packages/opencode/src/server.ts b/packages/opencode/src/server.ts index 38aba59..90e99bf 100644 --- a/packages/opencode/src/server.ts +++ b/packages/opencode/src/server.ts @@ -1,6 +1,9 @@ // Epistemic Swarm for OpenCode v2 (server plugin). Additive: registers our // agents, tools, skills, commands, hooks and RPC through runtime transforms; -// writes no config files and never touches built-in agents or defaults. +// setup writes no project-tree files and no config files (the engine key goes +// to the masked state dir; spend and seat tracking later write +// .factory/runtime/* while sessions run). It never touches built-in agents or +// defaults. import { readdir, readFile } from "node:fs/promises" import path from "node:path" import { @@ -27,7 +30,7 @@ import { createPolicyHooks } from "./policy.ts" import { createWebCache, PendingSearches, registerWebsearch } from "./research.ts" import { createRpcHandlers } from "./rpc.ts" import { EsRpc } from "./rpc-def.ts" -import { createRuntime, parseOptions } from "./runtime.ts" +import { createRuntime, parseOptions, type Runtime } from "./runtime.ts" import { createSeatTracker } from "./seats.ts" import { createSessionHooks, createSpendTracker } from "./session.ts" import { registerTools } from "./tools.ts" @@ -47,6 +50,346 @@ export function parseDeepResearchArgs(text: string): DeepResearchArgs { return { query, ceiling } } +/** The session surface slash commands use (the host's `ctx.session`; stubbed in tests). */ +export interface CommandSession { + switchAgent(sessionID: string, agent: string): Promise + sendPrompt(sessionID: string, prompt: Record, text: string, delivery: unknown): Promise + synthetic(sessionID: string, text: string): Promise +} + +/** One slash command: its name, description and host-independent body. */ +export interface CommandSpec { + readonly name: string + readonly description: string + readonly execute: (args: { + readonly sessionID: string + readonly prompt: { readonly text?: string } & Record + readonly delivery?: unknown + }) => Promise +} + +/** The hook-bridge surface slash commands read (status for `/hooks`). */ +export interface CommandBridge { + readonly engine: { + refresh(): Promise + status(): { + readonly handlers: number + readonly projectHandlers: number + readonly trusted: boolean + readonly projectLines: readonly string[] + readonly diagnostics: readonly string[] + } + readonly recent: readonly string[] + } +} + +/** + * Every slash command the plugin registers, as data: the host loop below + * adds each one, and tests invoke each body against a stub session to prove + * routing (seat/verb per command) and name uniqueness (one `research`). + */ +export function esCommandSpecs( + io: CommandSession, + runtime: Runtime, + bridge: CommandBridge, + runners: { readonly runGates?: typeof runGates; readonly git?: typeof git } = {}, +): CommandSpec[] { + const specs: CommandSpec[] = [] + const gates = runners.runGates ?? runGates + const gitStatus = runners.git ?? git + specs.push({ + name: "grill", + description: "Grill an idea until the design and spend ceiling are settled (Epistemic Swarm)", + execute: async ({ sessionID, prompt, delivery }) => { + await io.switchAgent(sessionID, "grill") + await io.sendPrompt( + sessionID, + prompt, + prompt.text?.trim() ? `Grill me on: ${prompt.text}` : "Start grilling me on my idea.", + delivery, + ) + }, + }) + specs.push({ + name: "brainstorm", + description: "Fan out divergent lenses on an idea and return a diversified shortlist (Epistemic Swarm)", + execute: async ({ sessionID, prompt, delivery }) => { + await io.switchAgent(sessionID, "brainstormer") + await io.sendPrompt( + sessionID, + prompt, + prompt.text?.trim() + ? `Brainstorm: ${prompt.text}` + : "Ask me for the idea to brainstorm (one sentence), then run the lens fan-out.", + delivery, + ) + }, + }) + specs.push({ + name: "harvest", + description: "Tear down competitor projects: licences, feature matrix, clean-room specs (Epistemic Swarm)", + execute: async ({ sessionID, prompt, delivery }) => { + await io.switchAgent(sessionID, "harvester") + await io.sendPrompt( + sessionID, + prompt, + prompt.text?.trim() + ? `Darkharvest: ${prompt.text}` + : "Ask me for the teardown objective and the candidate projects, then run the harvest.", + delivery, + ) + }, + }) + specs.push({ + name: "design", + description: "Interview me into a design system: tokens, contrast gates, style guide (Epistemic Swarm)", + execute: async ({ sessionID, prompt, delivery }) => { + await io.switchAgent(sessionID, "designer") + await io.sendPrompt( + sessionID, + prompt, + prompt.text?.trim() + ? `Run the design interview. Product: ${prompt.text}` + : "Ask me for the product name and start the design interview, one question at a time.", + delivery, + ) + }, + }) + specs.push({ + name: "scout", + description: "Find and vet open-source candidates for a feature: licenses, maintenance, CVEs (Epistemic Swarm)", + execute: async ({ sessionID, prompt, delivery }) => { + await io.switchAgent(sessionID, "scout") + await io.sendPrompt( + sessionID, + prompt, + prompt.text?.trim() + ? `Scout: ${prompt.text}` + : "Ask me for the feature to scout and our license posture, then plan the candidates.", + delivery, + ) + }, + }) + specs.push({ + name: "research", + // One name, one body (#274): a leading `deep` runs the adversarial deep + // flow (switching to the deep-researcher); anything else reads the + // research stage (report, audit coverage, cached sources). + description: + "Deep research to a grounded report, or the research stage: report, audit coverage and cached sources (Epistemic Swarm)", + execute: async ({ sessionID, prompt, delivery }) => { + const say = async (text: string) => { + await io.synthetic(sessionID, text) + } + const text = prompt.text?.trim() ?? "" + if (text.split(/\s+/)[0]?.toLowerCase() !== "deep") { + const layout = factoryLayout(runtime.root) + const report = await readFile(layout.researchReport, "utf8").catch(() => undefined) + const coverage = await readJson<{ + claims?: number + grounded?: number + untagged?: number + ungrounded?: number + malformed?: number + sources?: number + passed?: boolean + }>(path.join(layout.research, "coverage.json")).catch(() => undefined) + const sources = (await readdir(researchSourcesDir(runtime.root)).catch(() => [])).filter((file) => + file.endsWith(".md"), + ) + const lines = [ + report === undefined + ? "No research report yet (.factory/research/REPORT.md)." + : `Report: ${report.length} characters in .factory/research/REPORT.md.`, + coverage + ? `Audit: ${coverage.grounded ?? 0}/${coverage.claims ?? 0} claims grounded (untagged ${coverage.untagged ?? 0}, ungrounded ${coverage.ungrounded ?? 0}, malformed ${coverage.malformed ?? 0}); cited sources ${coverage.sources ?? 0}; passed ${coverage.passed ? "yes" : "no"}.` + : "No coverage.json yet (es_research_complete writes it).", + `Cached sources: ${sources.length}.`, + ] + return say(lines.join("\n")) + } + const parsed = parseDeepResearchArgs(text) + if ("error" in parsed && parsed.error === "usage") { + return say("Usage: /research deep --max-usd N") + } + if ("error" in parsed) { + return say( + "Deep research needs a spend ceiling you set: /research deep --max-usd N. Seats halt when the spend reaches it.", + ) + } + const { query, ceiling } = parsed + const existing = await runtime.factory.read().catch(() => undefined) + if (existing && existing.mode !== "research") { + return say( + "This project holds a build run. Start deep research in a directory without one (`es research deep --output --max-usd N` at a terminal).", + ) + } + if (existing?.stage === "HALTED") { + return say( + "This research run is halted; resume it first (`es factory resume` at a terminal, or /es-resume to preview).", + ) + } + if (existing?.stage === "DONE") { + return say( + "This research run is done. For a new question, start one in a fresh directory (`es research deep --output --max-usd N` at a terminal).", + ) + } + if (!existing) { + try { + // The human typed /research deep: beginning the run is their action. + await runtime.factory.beginResearchRun({ objective: query, ceilingUSD: Number(ceiling) }, "human:tui") + } catch (error) { + return say(`Could not start the research run: ${error instanceof Error ? error.message : String(error)}`) + } + } + await io.switchAgent(sessionID, "deep-researcher") + await io.sendPrompt( + sessionID, + prompt, + `${await runtime.factory.summary()}\nDeep research runs adversarially, with no human answering questions mid-flow; follow your Flow through es_research_complete.`, + delivery, + ) + }, + }) + specs.push({ + name: "audit", + description: "Open a code audit of the active phase or a path and run the auditor pair (Epistemic Swarm)", + execute: async ({ sessionID, prompt, delivery }) => { + const target = prompt.text?.trim() + const say = async (text: string) => { + await io.synthetic(sessionID, text) + } + if (!target) return say("Usage: /audit ") + const state = await runtime.factory.read().catch(() => undefined) + if (state?.spendCeilingUSD === undefined) + return say( + "Audits run inside a factory run with a spend ceiling. Start one at a terminal with `es audit --max-usd N`, or run /grill first.", + ) + let opened: Awaited> + try { + // The human typed /audit: opening it is their action. + opened = await runtime.factory.openAudit( + "human:tui", + parseAuditTarget( + target, + state.phases.map((phase) => phase.id), + ), + ) + } catch (error) { + return say(`Could not open the audit: ${error instanceof Error ? error.message : String(error)}`) + } + const { audit } = opened + await io.switchAgent(sessionID, "factory") + await io.sendPrompt( + sessionID, + prompt, + `${await runtime.factory.summary()}\nThe human opened code audit ${audit.id} (round ${audit.round}) on ${describeTarget(audit.target)}. Launch es-auditor-thesis and es-auditor-antithesis in parallel with the audit id and target; if they split, have es-manager break the tie (es_tiebreak with audit). Report the verdicts and the report path when the audit settles.`, + delivery, + ) + }, + }) + specs.push({ + name: "factory", + description: "Drive the build factory from its current stage (Epistemic Swarm)", + execute: async ({ sessionID, prompt, delivery }) => { + const state = await runtime.factory.read() + if (!state) { + await io.synthetic(sessionID, "No factory run here yet — run /grill first.") + return + } + await io.switchAgent(sessionID, "factory") + await io.sendPrompt( + sessionID, + prompt, + `${await runtime.factory.summary(state)}\nContinue the factory run from its current stage.${prompt.text?.trim() ? `\nHuman note: ${prompt.text}` : ""}`, + delivery, + ) + }, + }) + specs.push({ + name: "gates", + description: "Run the mechanical gates on changed files (Epistemic Swarm)", + execute: async ({ sessionID }) => { + const changed = (await gitStatus(runtime.root, ["status", "--porcelain=v1", "-uall"], { allowFail: true })).stdout + .split("\n") + .filter(Boolean) + .map((line) => line.slice(3).split(" -> ").at(-1)!) + const report = await gates({ + root: runtime.root, + scope: changed.length ? "touched" : "full", + touched: changed, + stateDir: runtime.stateDir, + }) + await io.synthetic(sessionID, formatReport(report)) + }, + }) + specs.push({ + name: "lsp", + description: "Language servers: configured, available and running (Epistemic Swarm)", + execute: async ({ sessionID }) => { + const status = await runtime.lsp.status() + const settings = await runtime.lsp.settings() + const lines = [status.enabled ? "LSP is on." : "LSP is disabled (lsp: false)."] + for (const server of settings.servers) { + const command = await runtime.lsp.command(server, runtime.root) + const running = status.running.filter((item) => item.id === server.id) + lines.push( + `${server.id} [${server.extensions.join(" ")}]: ${Array.isArray(command) ? `available (${command.join(" ")})` : command.unavailable}${running.length ? ` · running for ${running.length} root(s)` : ""}`, + ) + } + lines.push(...status.diagnostics.map((item) => `! ${item}`)) + await io.synthetic(sessionID, lines.join("\n")) + }, + }) + specs.push({ + name: "hooks", + description: "Show the hook bridge: sources, handlers, trust and diagnostics (Epistemic Swarm)", + execute: async ({ sessionID }) => { + await bridge.engine.refresh() + const status = bridge.engine.status() + const trust = + status.projectHandlers === 0 + ? "nothing to trust" + : status.trusted + ? "trusted" + : "NOT trusted: run /es-trust or `es trust`" + const lines = [ + `Hook bridge: ${status.handlers} handler(s), ${status.projectHandlers} from the project (${trust}).`, + ...status.projectLines, + ...status.diagnostics.map((item) => `! ${item}`), + ...bridge.engine.recent.slice(-10).map((item) => `recent error: ${item}`), + "Stop hooks are emulated after the turn ends (advisory for plain chat).", + ] + await io.synthetic(sessionID, lines.join("\n")) + }, + }) + specs.push({ + name: "status", + description: "Show the factory state (Epistemic Swarm)", + execute: async ({ sessionID }) => { + const state = await runtime.factory.read() + const liveness = await readLiveness(runtime.root, state) + const text = [await runtime.factory.summary(state), ...livenessLines(state, liveness)].join("\n") + await io.synthetic(sessionID, text) + }, + }) + specs.push({ + name: "config", + description: "Show the effective Epistemic Swarm config and where it came from (Epistemic Swarm)", + execute: async ({ sessionID }) => { + const lines = [ + runtime.configSources.length + ? `Layers: ${runtime.configSources.join(" → ")} → plugin options` + : "Layers: defaults only (no config files)", + ...runtime.warnings.map((warning) => `Warning: ${warning}`), + JSON.stringify(runtime.config, null, 2), + ] + await io.synthetic(sessionID, lines.join("\n")) + }, + }) + return specs +} + export default Plugin.define({ id: PLUGIN_ID, async setup(ctx) { @@ -183,301 +526,13 @@ export default Plugin.define({ }) as any) await ctx.command.transform((editor) => { - editor.add({ - name: "grill", - description: "Grill an idea until the design and spend ceiling are settled (Epistemic Swarm)", - execute: async ({ sessionID, prompt, delivery }) => { - await ctx.session.switchAgent({ sessionID, agent: "grill" } as any) - await ctx.session.prompt({ - ...prompt, - sessionID, - text: prompt.text?.trim() ? `Grill me on: ${prompt.text}` : "Start grilling me on my idea.", - delivery, - } as any) - }, - }) - editor.add({ - name: "brainstorm", - description: "Fan out divergent lenses on an idea and return a diversified shortlist (Epistemic Swarm)", - execute: async ({ sessionID, prompt, delivery }) => { - await ctx.session.switchAgent({ sessionID, agent: "brainstormer" } as any) - await ctx.session.prompt({ - ...prompt, - sessionID, - text: prompt.text?.trim() - ? `Brainstorm: ${prompt.text}` - : "Ask me for the idea to brainstorm (one sentence), then run the lens fan-out.", - delivery, - } as any) - }, - }) - editor.add({ - name: "harvest", - description: "Tear down competitor projects: licences, feature matrix, clean-room specs (Epistemic Swarm)", - execute: async ({ sessionID, prompt, delivery }) => { - await ctx.session.switchAgent({ sessionID, agent: "harvester" } as any) - await ctx.session.prompt({ - ...prompt, - sessionID, - text: prompt.text?.trim() - ? `Darkharvest: ${prompt.text}` - : "Ask me for the teardown objective and the candidate projects, then run the harvest.", - delivery, - } as any) - }, - }) - editor.add({ - name: "design", - description: "Interview me into a design system: tokens, contrast gates, style guide (Epistemic Swarm)", - execute: async ({ sessionID, prompt, delivery }) => { - await ctx.session.switchAgent({ sessionID, agent: "designer" } as any) - await ctx.session.prompt({ - ...prompt, - sessionID, - text: prompt.text?.trim() - ? `Run the design interview. Product: ${prompt.text}` - : "Ask me for the product name and start the design interview, one question at a time.", - delivery, - } as any) - }, - }) - editor.add({ - name: "scout", - description: "Find and vet open-source candidates for a feature: licenses, maintenance, CVEs (Epistemic Swarm)", - execute: async ({ sessionID, prompt, delivery }) => { - await ctx.session.switchAgent({ sessionID, agent: "scout" } as any) - await ctx.session.prompt({ - ...prompt, - sessionID, - text: prompt.text?.trim() - ? `Scout: ${prompt.text}` - : "Ask me for the feature to scout and our license posture, then plan the candidates.", - delivery, - } as any) - }, - }) - editor.add({ - name: "research", - description: "Adversarial deep research to a grounded report: thesis, antithesis, synthesis (Epistemic Swarm)", - execute: async ({ sessionID, prompt, delivery }) => { - const say = async (text: string) => { - await ctx.session.synthetic({ sessionID, text } as any) - } - const text = prompt.text?.trim() ?? "" - const parsed = parseDeepResearchArgs(text) - if ("error" in parsed && parsed.error === "usage") { - return say("Usage: /research deep --max-usd N") - } - if ("error" in parsed) { - return say( - "Deep research needs a spend ceiling you set: /research deep --max-usd N. Seats halt when the spend reaches it.", - ) - } - const { query, ceiling } = parsed - const existing = await runtime.factory.read().catch(() => undefined) - if (existing && existing.mode !== "research") { - return say( - "This project holds a build run. Start deep research in a directory without one (`es research deep --output --max-usd N` at a terminal).", - ) - } - if (existing?.stage === "HALTED") { - return say( - "This research run is halted; resume it first (`es factory resume` at a terminal, or /es-resume to preview).", - ) - } - if (existing?.stage === "DONE") { - return say( - "This research run is done. For a new question, start one in a fresh directory (`es research deep --output --max-usd N` at a terminal).", - ) - } - if (!existing) { - try { - // The human typed /research deep: beginning the run is their action. - await runtime.factory.beginResearchRun({ objective: query, ceilingUSD: Number(ceiling) }, "human:tui") - } catch (error) { - return say(`Could not start the research run: ${error instanceof Error ? error.message : String(error)}`) - } - } - await ctx.session.switchAgent({ sessionID, agent: "deep-researcher" } as any) - await ctx.session.prompt({ - ...prompt, - sessionID, - // The coordinator flow is the deep-researcher seat's system prompt - // (single-sourced in core); this only adds the run state. - text: `${await runtime.factory.summary()}\nDeep research runs adversarially, with no human answering questions mid-flow; follow your Flow through es_research_complete.`, - delivery, - } as any) - }, - }) - editor.add({ - name: "audit", - description: "Open a code audit of the active phase or a path and run the auditor pair (Epistemic Swarm)", - execute: async ({ sessionID, prompt, delivery }) => { - const target = prompt.text?.trim() - const say = async (text: string) => { - await ctx.session.synthetic({ sessionID, text } as any) - } - if (!target) return say("Usage: /audit ") - const state = await runtime.factory.read().catch(() => undefined) - if (state?.spendCeilingUSD === undefined) - return say( - "Audits run inside a factory run with a spend ceiling. Start one at a terminal with `es audit --max-usd N`, or run /grill first.", - ) - let opened: Awaited> - try { - // The human typed /audit: opening it is their action. - opened = await runtime.factory.openAudit( - "human:tui", - parseAuditTarget( - target, - state.phases.map((phase) => phase.id), - ), - ) - } catch (error) { - return say(`Could not open the audit: ${error instanceof Error ? error.message : String(error)}`) - } - const { audit } = opened - await ctx.session.switchAgent({ sessionID, agent: "factory" } as any) - await ctx.session.prompt({ - ...prompt, - sessionID, - text: `${await runtime.factory.summary()}\nThe human opened code audit ${audit.id} (round ${audit.round}) on ${describeTarget(audit.target)}. Launch es-auditor-thesis and es-auditor-antithesis in parallel with the audit id and target; if they split, have es-manager break the tie (es_tiebreak with audit). Report the verdicts and the report path when the audit settles.`, - delivery, - } as any) - }, - }) - editor.add({ - name: "factory", - description: "Drive the build factory from its current stage (Epistemic Swarm)", - execute: async ({ sessionID, prompt, delivery }) => { - const state = await runtime.factory.read() - if (!state) { - await ctx.session.synthetic({ - sessionID, - text: "No factory run here yet — run /grill first.", - } as any) - return - } - await ctx.session.switchAgent({ sessionID, agent: "factory" } as any) - await ctx.session.prompt({ - ...prompt, - sessionID, - text: `${await runtime.factory.summary(state)}\nContinue the factory run from its current stage.${prompt.text?.trim() ? `\nHuman note: ${prompt.text}` : ""}`, - delivery, - } as any) - }, - }) - editor.add({ - name: "gates", - description: "Run the mechanical gates on changed files (Epistemic Swarm)", - execute: async ({ sessionID }) => { - const changed = (await git(runtime.root, ["status", "--porcelain=v1", "-uall"], { allowFail: true })).stdout - .split("\n") - .filter(Boolean) - .map((line) => line.slice(3).split(" -> ").at(-1)!) - const report = await runGates({ - root: runtime.root, - scope: changed.length ? "touched" : "full", - touched: changed, - stateDir: runtime.stateDir, - }) - await ctx.session.synthetic({ sessionID, text: formatReport(report) } as any) - }, - }) - editor.add({ - name: "lsp", - description: "Language servers: configured, available and running (Epistemic Swarm)", - execute: async ({ sessionID }) => { - const status = await runtime.lsp.status() - const settings = await runtime.lsp.settings() - const lines = [status.enabled ? "LSP is on." : "LSP is disabled (lsp: false)."] - for (const server of settings.servers) { - const command = await runtime.lsp.command(server, runtime.root) - const running = status.running.filter((item) => item.id === server.id) - lines.push( - `${server.id} [${server.extensions.join(" ")}]: ${Array.isArray(command) ? `available (${command.join(" ")})` : command.unavailable}${running.length ? ` · running for ${running.length} root(s)` : ""}`, - ) - } - lines.push(...status.diagnostics.map((item) => `! ${item}`)) - await ctx.session.synthetic({ sessionID, text: lines.join("\n") } as any) - }, - }) - editor.add({ - name: "hooks", - description: "Show the hook bridge: sources, handlers, trust and diagnostics (Epistemic Swarm)", - execute: async ({ sessionID }) => { - await bridge.engine.refresh() - const status = bridge.engine.status() - const trust = - status.projectHandlers === 0 - ? "nothing to trust" - : status.trusted - ? "trusted" - : "NOT trusted: run /es-trust or `es trust`" - const lines = [ - `Hook bridge: ${status.handlers} handler(s), ${status.projectHandlers} from the project (${trust}).`, - ...status.projectLines, - ...status.diagnostics.map((item) => `! ${item}`), - ...bridge.engine.recent.slice(-10).map((item) => `recent error: ${item}`), - "Stop hooks are emulated after the turn ends (advisory for plain chat).", - ] - await ctx.session.synthetic({ sessionID, text: lines.join("\n") } as any) - }, - }) - editor.add({ - name: "status", - description: "Show the factory state (Epistemic Swarm)", - execute: async ({ sessionID }) => { - const state = await runtime.factory.read() - const liveness = await readLiveness(runtime.root, state) - const text = [await runtime.factory.summary(state), ...livenessLines(state, liveness)].join("\n") - await ctx.session.synthetic({ sessionID, text } as any) - }, - }) - editor.add({ - name: "config", - description: "Show the effective Epistemic Swarm config and where it came from (Epistemic Swarm)", - execute: async ({ sessionID }) => { - const lines = [ - runtime.configSources.length - ? `Layers: ${runtime.configSources.join(" → ")} → plugin options` - : "Layers: defaults only (no config files)", - ...runtime.warnings.map((warning) => `Warning: ${warning}`), - JSON.stringify(runtime.config, null, 2), - ] - await ctx.session.synthetic({ sessionID, text: lines.join("\n") } as any) - }, - }) - editor.add({ - name: "research", - description: "Research stage: report, audit coverage and cached sources (Epistemic Swarm)", - execute: async ({ sessionID }) => { - const layout = factoryLayout(runtime.root) - const report = await readFile(layout.researchReport, "utf8").catch(() => undefined) - const coverage = await readJson<{ - claims?: number - grounded?: number - untagged?: number - ungrounded?: number - malformed?: number - sources?: number - passed?: boolean - }>(path.join(layout.research, "coverage.json")).catch(() => undefined) - const sources = (await readdir(researchSourcesDir(runtime.root)).catch(() => [])).filter((file) => - file.endsWith(".md"), - ) - const lines = [ - report === undefined - ? "No research report yet (.factory/research/REPORT.md)." - : `Report: ${report.length} characters in .factory/research/REPORT.md.`, - coverage - ? `Audit: ${coverage.grounded ?? 0}/${coverage.claims ?? 0} claims grounded (untagged ${coverage.untagged ?? 0}, ungrounded ${coverage.ungrounded ?? 0}, malformed ${coverage.malformed ?? 0}); cited sources ${coverage.sources ?? 0}; passed ${coverage.passed ? "yes" : "no"}.` - : "No coverage.json yet (es_research_complete writes it).", - `Cached sources: ${sources.length}.`, - ] - await ctx.session.synthetic({ sessionID, text: lines.join("\n") } as any) - }, - }) + const io: CommandSession = { + switchAgent: (sessionID, agent) => ctx.session.switchAgent({ sessionID, agent } as any), + sendPrompt: (sessionID, prompt, text, delivery) => + ctx.session.prompt({ ...prompt, sessionID, text, delivery } as any), + synthetic: (sessionID, text) => ctx.session.synthetic({ sessionID, text } as any), + } + for (const spec of esCommandSpecs(io, runtime, bridge as unknown as CommandBridge)) editor.add(spec as any) }) // Spend: per-step host cost for factory-agent sessions (estimated from tokens when cost is 0). @@ -495,6 +550,10 @@ export default Plugin.define({ // stream closed on unload } })() + // Language-server lifecycle (#275.1): servers stop here on plugin unload; + // between sessions only the manager's idle sweeper (600 s default) reaps + // them, so a long-lived server outlives its session by design. The running + // set stays visible through `/lsp` and the LSP panel. return async () => { abort.abort() if (queued) clearTimeout(queued) diff --git a/packages/opencode/test/compaction.test.ts b/packages/opencode/test/compaction.test.ts new file mode 100644 index 0000000..49a4d62 --- /dev/null +++ b/packages/opencode/test/compaction.test.ts @@ -0,0 +1,46 @@ +// Compaction (#276.2): only factory seats get the factory-state block +// injected; any other agent compacts without it. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { createRuntime, parseOptions, type Runtime } from "../src/runtime.ts" +import { createSessionHooks } from "../src/session.ts" + +let root: string +let state: string +let runtime: Runtime + +beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), "es-compaction-")) + state = await mkdtemp(path.join(tmpdir(), "es-compaction-state-")) + runtime = await createRuntime(root, parseOptions({ stateDir: state })) +}) + +afterEach(async () => { + await runtime.lsp.stopAll().catch(() => undefined) + await rm(root, { recursive: true, force: true }) + await rm(state, { recursive: true, force: true }) +}) + +describe("compaction is factory-only", () => { + test("a factory seat compacts with the verbatim state block", async () => { + const hooks = createSessionHooks(runtime) + const event = { agent: "factory", system: [] as unknown[] } + await hooks.compaction(event as never) + expect(event.system).toHaveLength(1) + const text = (event.system[0] as { text: string }).text + expect(text).toContain("Preserve this factory state verbatim") + expect(text).toContain("") + }) + + test("a non-registry agent compacts without the state block", async () => { + const hooks = createSessionHooks(runtime) + for (const agent of ["build", "es-programmer", "grill"]) { + const event = { agent, system: [] as unknown[] } + await hooks.compaction(event as never) + if (agent === "build") expect(event.system).toEqual([]) + else expect(event.system).toHaveLength(1) + } + }) +}) diff --git a/packages/opencode/test/config-warning.test.ts b/packages/opencode/test/config-warning.test.ts new file mode 100644 index 0000000..56ee3fb --- /dev/null +++ b/packages/opencode/test/config-warning.test.ts @@ -0,0 +1,20 @@ +// Legacy option wording (#240.1): 0.7-era options are ignored, not loaded — +// plugin-options only, while unknown keys in config files are +// strict-rejected; the unknowns collapse into one warning the TUI shows once. +import { describe, expect, test } from "bun:test" +import { parseOptions, unknownOptionsWarning } from "../src/runtime.ts" + +describe("legacy option wording", () => { + test("unknown plugin options are ignored (never loaded), naming the 0.7 removal", () => { + const warning = unknownOptionsWarning(["search_engine", "max_iterations", "mode"]) + expect(warning).toContain("Ignored unknown plugin option") + expect(warning).toContain("were removed in 1.0") + expect(warning).not.toMatch(/load/i) + }) + + test("unknowns collapse into a single warning string", () => { + const parsed = parseOptions({ search_engine: "x", max_iterations: 2, pr: "off" }) + expect(parsed.unknown).toEqual(["max_iterations", "search_engine"]) + expect(parsed.overrides).toEqual({ pr: "off" }) + }) +}) diff --git a/packages/opencode/test/lsp-scoping.test.ts b/packages/opencode/test/lsp-scoping.test.ts new file mode 100644 index 0000000..03480f6 --- /dev/null +++ b/packages/opencode/test/lsp-scoping.test.ts @@ -0,0 +1,62 @@ +// After-edit diagnostics scoping (#277.2): the `` injection is a +// language-server channel, so seats with `lsp: "none"` (harvester, designer, +// scout, brainstormer, …) must not receive it; seats that may navigate (and +// the user's own agents) keep it. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { createPolicyHooks } from "../src/policy.ts" +import type { Runtime } from "../src/runtime.ts" + +let root: string + +beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), "es-lsp-scope-")) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +const diagnostic = { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, + severity: 1, + message: "boom", +} + +const runtime = (root: string) => + ({ + root, + options: { lspAfterEdit: true, afterEdit: "off" }, + lsp: { diagnostics: async () => [diagnostic] }, + policy: async () => ({ root }), + }) as unknown as Runtime + +const edited = (agent: string) => ({ + tool: "write", + agent, + id: "1", + input: { path: "src/a.ts" }, + status: "completed", + result: {}, +}) + +describe("after-edit diagnostics respect lsp scoping", () => { + test("an lsp:none seat (harvester) gets no diagnostics channel", async () => { + const { after } = createPolicyHooks(runtime(root)) + const event = edited("harvester") + await after(event as never) + expect(event.result).toEqual({}) + }) + + test("a navigating seat (QA) and the user's own agent keep the injection", async () => { + const { after } = createPolicyHooks(runtime(root)) + const qa = edited("es-qa-functional") + await after(qa as never) + expect(JSON.stringify((qa.result as { content: unknown }).content)).toContain("") + const user = edited("build") + await after(user as never) + expect(JSON.stringify((user.result as { content: unknown }).content)).toContain("") + }) +}) diff --git a/packages/opencode/test/slash.test.ts b/packages/opencode/test/slash.test.ts new file mode 100644 index 0000000..d53e81e --- /dev/null +++ b/packages/opencode/test/slash.test.ts @@ -0,0 +1,184 @@ +// Slash commands (#274 duplicate `/research`, #276 routing gaps, #277 `/lsp` +// coverage): one name per command, and per-command routing (seat/verb) proven +// by invoking each body against a stub session — no host needed. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { formatReport } from "@heretek-ai/es-core" +import { createRuntime, parseOptions, type Runtime } from "../src/runtime.ts" +import { type CommandSession, esCommandSpecs } from "../src/server.ts" + +let root: string +let state: string +let runtime: Runtime + +const stubSession = () => { + const switched: Array<{ sessionID: string; agent: string }> = [] + const prompted: Array<{ sessionID: string; prompt: Record; text: string; delivery: unknown }> = [] + const said: Array<{ sessionID: string; text: string }> = [] + const io: CommandSession = { + switchAgent: async (sessionID, agent) => { + switched.push({ sessionID, agent }) + }, + sendPrompt: async (sessionID, prompt, text, delivery) => { + prompted.push({ sessionID, prompt, text, delivery }) + }, + synthetic: async (sessionID, text) => { + said.push({ sessionID, text }) + }, + } + return { io, switched, prompted, said } +} + +const stubBridge = () => ({ + engine: { + refresh: async () => {}, + status: () => ({ + handlers: 2, + projectHandlers: 1, + trusted: false, + projectLines: ["project hook line"], + diagnostics: ["bridge diagnostic"], + }), + recent: ["recent failure"], + }, +}) + +beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), "es-slash-")) + state = await mkdtemp(path.join(tmpdir(), "es-slash-state-")) + runtime = await createRuntime(root, parseOptions({ stateDir: state })) +}) + +afterEach(async () => { + await runtime.lsp.stopAll().catch(() => undefined) + await rm(root, { recursive: true, force: true }) + await rm(state, { recursive: true, force: true }) +}) + +const specs = () => esCommandSpecs(stubSession().io, runtime, stubBridge()) +const byName = () => new Map(specs().map((spec) => [spec.name, spec])) + +describe("slash command registration (#274)", () => { + test("13 unique names, with a single research", () => { + const names = specs().map((spec) => spec.name) + expect(names.filter((name) => name === "research")).toHaveLength(1) + expect([...new Set(names)].sort()).toEqual( + [ + "audit", + "brainstorm", + "config", + "design", + "factory", + "gates", + "grill", + "harvest", + "hooks", + "lsp", + "research", + "scout", + "status", + ].sort(), + ) + }) +}) + +describe("per-command routing: seat and verb (#276.2)", () => { + test("grill / brainstorm / harvest / design / scout switch to their seat with a verb", async () => { + const routes: Array<[string, string, string]> = [ + ["grill", "grill", "Grill me on: q"], + ["brainstorm", "brainstormer", "Brainstorm: q"], + ["harvest", "harvester", "Darkharvest: q"], + ["design", "designer", "Run the design interview. Product: q"], + ["scout", "scout", "Scout: q"], + ] + for (const [name, seat, verb] of routes) { + const stub = stubSession() + const spec = new Map(esCommandSpecs(stub.io, runtime, stubBridge()).map((item) => [item.name, item])).get(name)! + await spec.execute({ sessionID: "ses_1", prompt: { text: "q" }, delivery: undefined }) + expect([name, stub.switched]).toEqual([name, [{ sessionID: "ses_1", agent: seat }]]) + expect([name, stub.prompted[0]?.text]).toEqual([name, verb]) + } + }) + + test("research deep starts a research run and routes to the deep-researcher", async () => { + const stub = stubSession() + const spec = new Map(esCommandSpecs(stub.io, runtime, stubBridge()).map((item) => [item.name, item])).get( + "research", + )! + await spec.execute({ sessionID: "ses_1", prompt: { text: "deep what queue --max-usd 5" }, delivery: undefined }) + const begun = await runtime.factory.read() + expect(begun).toMatchObject({ mode: "research", stage: "RESEARCH" }) + expect(stub.switched).toEqual([{ sessionID: "ses_1", agent: "deep-researcher" }]) + expect(stub.prompted[0]?.text).toContain("es_research_complete") + }) + + test("research without deep shows the report reader, not usage", async () => { + const stub = stubSession() + const spec = new Map(esCommandSpecs(stub.io, runtime, stubBridge()).map((item) => [item.name, item])).get( + "research", + )! + await spec.execute({ sessionID: "ses_1", prompt: { text: "" }, delivery: undefined }) + expect(stub.switched).toEqual([]) + expect(stub.said[0]?.text).toContain("No research report yet") + // A bare `deep` still gets the deep flow's usage, not the report. + const usage = stubSession() + const usageSpec = new Map(esCommandSpecs(usage.io, runtime, stubBridge()).map((item) => [item.name, item])).get( + "research", + )! + await usageSpec.execute({ sessionID: "ses_1", prompt: { text: "deep" }, delivery: undefined }) + expect(usage.said[0]?.text).toContain("Usage: /research deep") + }) + + test("audit usage and ceiling refusals stay synthetic", async () => { + const stub = stubSession() + const spec = byName().get("audit")! + // Rebind the spec to this stub's session: rebuild with the same stub. + const bound = new Map(esCommandSpecs(stub.io, runtime, stubBridge()).map((item) => [item.name, item])).get("audit")! + expect(spec.name).toBe(bound.name) + await bound.execute({ sessionID: "ses_1", prompt: { text: "" }, delivery: undefined }) + expect(stub.said[0]?.text).toContain("Usage: /audit") + await bound.execute({ sessionID: "ses_1", prompt: { text: "src/x.ts" }, delivery: undefined }) + expect(stub.said[1]?.text).toContain("spend ceiling") + expect(stub.switched).toEqual([]) + }) + + test("factory with no run is synthetic; gates renders the gate report", async () => { + const stub = stubSession() + const commands = new Map(esCommandSpecs(stub.io, runtime, stubBridge()).map((item) => [item.name, item])) + await commands.get("factory")!.execute({ sessionID: "ses_1", prompt: {}, delivery: undefined }) + expect(stub.said[0]?.text).toContain("No factory run here yet") + const canned = { summary: "Gates passed (stubbed).", findings: [], totalFindings: 0, logDir: "stub" } + const gated = stubSession() + const gatedCommands = new Map( + esCommandSpecs(gated.io, runtime, stubBridge(), { + git: (async () => ({ stdout: "" })) as never, + runGates: (async () => canned) as never, + }).map((item) => [item.name, item]), + ) + await gatedCommands.get("gates")!.execute({ sessionID: "ses_1", prompt: {}, delivery: undefined }) + expect(gated.said[0]?.text).toBe(formatReport(canned as never)) + }) + + test("lsp reports status and availability lines (#277.1)", async () => { + const stub = stubSession() + const commands = new Map(esCommandSpecs(stub.io, runtime, stubBridge()).map((item) => [item.name, item])) + await commands.get("lsp")!.execute({ sessionID: "ses_1", prompt: {}, delivery: undefined }) + const text = stub.said[0]?.text ?? "" + expect(text).toMatch(/LSP is (on|disabled)/) + expect(text).toContain("typescript") + }) + + test("hooks / status / config render server content synthetically", async () => { + const stub = stubSession() + const commands = new Map(esCommandSpecs(stub.io, runtime, stubBridge()).map((item) => [item.name, item])) + await commands.get("hooks")!.execute({ sessionID: "ses_1", prompt: {}, delivery: undefined }) + expect(stub.said[0]?.text).toContain("Hook bridge: 2 handler(s)") + expect(stub.said[0]?.text).toContain("NOT trusted") + await commands.get("status")!.execute({ sessionID: "ses_1", prompt: {}, delivery: undefined }) + expect(stub.said[1]?.text).toContain("") + await commands.get("config")!.execute({ sessionID: "ses_1", prompt: {}, delivery: undefined }) + expect(stub.said[2]?.text).toContain("Layers:") + }) +}) diff --git a/packages/opencode/test/visibility.test.ts b/packages/opencode/test/visibility.test.ts new file mode 100644 index 0000000..5c9234a --- /dev/null +++ b/packages/opencode/test/visibility.test.ts @@ -0,0 +1,95 @@ +// Visibility proofs (#276): the plugin registers additively (built-in tools +// keep working; only invisible es_* tools are pruned), the status footer is +// server-computed content, and setup writes no project-tree files. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtemp, readdir, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { AGENTS, ensureEngineKey, footerLine, loadSkills, readLiveness } from "@heretek-ai/es-core" +import { compileAgents } from "../src/agents.ts" +import { createRpcHandlers } from "../src/rpc.ts" +import { createRuntime, parseOptions, type Runtime } from "../src/runtime.ts" +import { createSessionHooks } from "../src/session.ts" + +let root: string +let state: string +let runtime: Runtime + +beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), "es-visibility-")) + state = await mkdtemp(path.join(tmpdir(), "es-visibility-state-")) + runtime = await createRuntime(root, parseOptions({ stateDir: state })) +}) + +afterEach(async () => { + await runtime.lsp.stopAll().catch(() => undefined) + await rm(root, { recursive: true, force: true }) + await rm(state, { recursive: true, force: true }) +}) + +describe("additive registration", () => { + test("we only add registry agents (never built-ins or defaults)", async () => { + const ids = new Set(AGENTS.map((agent) => agent.id)) + for (const compiled of await compileAgents(runtime.options, [])) expect(ids.has(compiled.spec.id)).toBe(true) + }) + + test("context prunes only invisible es_* tools; built-ins and state blocks are additive", async () => { + const hooks = createSessionHooks(runtime) + const tools = () => ({ + read: {}, + write: {}, + shell: {}, + question: {}, + es_status: {}, + es_gates_run: {}, + es_complete: {}, + es_build_start: {}, + }) + const user = { agent: "build", system: [] as unknown[], tools: tools() } + await hooks.context(user as never) + // Built-ins are untouched; only the read-only es tools stay visible. + expect(Object.keys(user.tools).sort()).toEqual(["es_gates_run", "es_status", "question", "read", "shell", "write"]) + expect(user.system).toEqual([]) + + const factory = { agent: "factory", system: [] as unknown[], tools: tools() } + await hooks.context(factory as never) + expect(Object.keys(factory.tools)).toContain("es_build_start") + expect(factory.system).toHaveLength(1) + expect((factory.system[0] as { text: string }).text).toContain("") + }) +}) + +describe("footer server-content wiring", () => { + test("status().footer is footerLine(state, liveness, paused)", async () => { + const handlers = createRpcHandlers(runtime, async () => {}) + const seen = await handlers.status() + const state = await runtime.factory.read() + const liveness = await readLiveness(root, state) + expect(seen.footer ?? null).toBe(footerLine(state, liveness, undefined) ?? null) + expect(seen.headline).toContain("No factory run") + }) +}) + +describe("setup writes no project-tree files (#276.1)", () => { + test("runtime creation, engine key, agents and skills leave the project tree alone", async () => { + const fresh = await mkdtemp(path.join(tmpdir(), "es-setup-diff-")) + const freshState = await mkdtemp(path.join(tmpdir(), "es-setup-diff-state-")) + try { + const before = await readdir(fresh) + const staged = await createRuntime(fresh, parseOptions({ stateDir: freshState })) + try { + // The engine key seals cached sources: created at setup in the masked + // state dir, never in the project tree. + await ensureEngineKey(staged.stateDir) + await compileAgents(staged.options, []) + await loadSkills() + } finally { + await staged.lsp.stopAll().catch(() => undefined) + } + expect(await readdir(fresh)).toEqual(before) + } finally { + await rm(fresh, { recursive: true, force: true }) + await rm(freshState, { recursive: true, force: true }) + } + }) +})