From 97a1c7a8a3479ba256282c79a04b2b0f4da6e1ef Mon Sep 17 00:00:00 2001 From: gasantiago16 Date: Mon, 31 Aug 2026 14:35:51 -0400 Subject: [PATCH] Harden multi-agent kernel and add local beta logger Allowlist project .env keys, jail persona files, and give background/detached children a non-interactive permission engine. Fix child-slot races, waitChild snapshots, Ctrl+B detach, loop interval overflow, and dashboard remount. Add pnpm beta plus a buddy playbook; logs stay gitignored under .polycode/beta-logs/. --- docs/beta-test.md | 78 ++++++ package.json | 1 + packages/core/src/agent.test.ts | 42 ++++ packages/core/src/agent.ts | 328 +++++++++++++++++++------- packages/core/src/env.test.ts | 23 +- packages/core/src/env.ts | 15 +- packages/core/src/loop.test.ts | 27 ++- packages/core/src/loop.ts | 43 +++- packages/core/src/paths.test.ts | 24 +- packages/core/src/paths.ts | 10 + packages/core/src/permissions.test.ts | 34 +++ packages/core/src/permissions.ts | 36 ++- packages/core/src/personas.test.ts | 41 +++- packages/core/src/personas.ts | 36 ++- packages/core/src/subagent.test.ts | 257 ++++++++++++++++++++ packages/secrets/src/index.test.ts | 45 +++- packages/secrets/src/index.ts | 61 ++++- packages/tui/src/app.tsx | 220 ++++++++++------- packages/tui/src/dashboard.tsx | 4 + scripts/beta-log.ts | 175 ++++++++++++++ 20 files changed, 1294 insertions(+), 206 deletions(-) create mode 100644 docs/beta-test.md create mode 100644 scripts/beta-log.ts diff --git a/docs/beta-test.md b/docs/beta-test.md new file mode 100644 index 0000000..2bdb7b0 --- /dev/null +++ b/docs/beta-test.md @@ -0,0 +1,78 @@ +# Beta test — buddy playbook + +polycode is a **terminal** coding agent (not a website). Your buddy runs it on their +machine, or sits at yours. Results of automated checks + a fill-in checklist are logged +**locally** under `.polycode/beta-logs/` (gitignored — never committed, no API keys). + +## You (host) + +From the repo: + +```powershell +corepack pnpm -C "C:\Users\gasan\OneDrive\Dev\Projects\polycode" install +corepack pnpm -C "C:\Users\gasan\OneDrive\Dev\Projects\polycode" beta -- --tester you +``` + +That runs typecheck + the unit suite and writes two local files (gitignored): + +- `.polycode/beta-logs/_you.md` +- `.polycode/beta-logs/LATEST.md` (last **automated** run — send this one; checklist-only does not overwrite it) + +Live model round-trip (needs a Grok key already saved): + +```powershell +corepack pnpm -C "C:\Users\gasan\OneDrive\Dev\Projects\polycode" beta -- --tester you --smoke xai:grok-4.3 +``` + +Checklist-only (no test run — just a blank log for a session): + +```powershell +corepack pnpm -C "C:\Users\gasan\OneDrive\Dev\Projects\polycode" beta -- --tester buddy --checklist-only +``` + +Send your buddy **that markdown file** after they fill the checkboxes. Not `.env`, not the keychain. + +## Your buddy (first 10 minutes) + +1. Node 20+ installed. +2. In the repo: + +```powershell +corepack pnpm install +corepack pnpm dev +``` + +3. Settings: arrow to **xAI (Grok)** (not Meta Muse). Enter → paste `xai-…` key → Enter. + Chat should open. If stuck, they have a Grok key in `.env` as `XAI_API_KEY=` or `GROK_API_KEY=` and press **i**. +4. Work through the checklist in the log file (or below). Mark pass/fail. No keys in notes. +5. Quit with `/exit`. + +### What to try + +| Try | Expect | +|---|---| +| `/help` | Overlay of commands, Esc closes | +| `/mo` + Tab | Completes toward `/mode` or `/model` | +| “what is this repo?” | Tools run; answer mentions polycode | +| `/mode plan` then “edit README” | Writes refused | +| `/mode ask` then a tiny edit | `y` / `a` / `n` prompt | +| `/team add a comment in README` | Explorers, then a worktree implement (git repo) | +| `/dashboard` | Child list; Enter peek; `a` attach | +| Long turn + **Ctrl+B** | Composer comes back; kids still in dashboard | +| `/loop 15s say ping` | Recurring line; `/loop stop` ends it | +| `/hepl` | Error “did you mean /help?” — not sent to Grok | + +## Safety for a buddy on your box + +- Use **ask** mode (`/mode ask`), not yolo. +- They should not need `--serve` (hosted HTTP). +- Their key: they paste their own Grok key, or you temporarily set one and `/settings` when they leave. +- Logs in `.polycode/beta-logs/` are local only. + +## After the session + +```powershell +corepack pnpm beta -- --tester buddy-name --checklist-only +``` + +Fill the checklist in the new file, keep it in `.polycode/beta-logs/`, and tell you what failed. diff --git a/package.json b/package.json index 9097971..e6d4458 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dev": "tsx packages/cli/src/index.ts", "serve": "tsx packages/cli/src/index.ts --serve", "smoke": "tsx scripts/smoke.ts", + "beta": "tsx scripts/beta-log.ts", "route-check": "tsx scripts/route-check.ts", "test": "vitest run", "test:watch": "vitest", diff --git a/packages/core/src/agent.test.ts b/packages/core/src/agent.test.ts index 04b19b7..990ac6a 100644 --- a/packages/core/src/agent.test.ts +++ b/packages/core/src/agent.test.ts @@ -385,6 +385,48 @@ describe("Agent", () => { expect(agent.history()).toHaveLength(0); }); + it("stamps worktree isolation on tool_call before the UI sees the call", async () => { + const task: ToolSpec = { + name: "task", + description: "task", + parameters: {}, + permission: "mutating", + parallelSafe: false, + async run(input: { description: string; prompt: string }, ctx) { + if (!ctx.spawnChild) return { output: "no spawn", isError: true }; + return ctx.spawnChild({ ...input, subagent_type: "general" }); + }, + }; + const provider = new ScriptedProvider([ + [ + { + type: "tool_call", + call: { type: "tool_call", id: "1", name: "task", input: { description: "a", prompt: "a" } }, + }, + { + type: "tool_call", + call: { type: "tool_call", id: "2", name: "task", input: { description: "b", prompt: "b" } }, + }, + { type: "stop", reason: "tool_use" }, + ], + [{ type: "text_delta", text: "ca" }, { type: "stop", reason: "end_turn" }], + [{ type: "text_delta", text: "cb" }, { type: "stop", reason: "end_turn" }], + [{ type: "text_delta", text: "parent" }, { type: "stop", reason: "end_turn" }], + ]); + const agent = new Agent(provider, [task], new PermissionEngine("yolo", async () => "once"), { + sandbox, + openWorktree: async () => ({ sandbox, path: "/tmp/wt-stamp" }), + }); + agent.pushUser("go"); + const events = await collect(agent); + const calls = events.filter((e) => e.type === "tool_call"); + expect(calls).toHaveLength(2); + for (const ev of calls) { + if (ev.type !== "tool_call") continue; + expect((ev.call.input as { isolation?: string }).isolation).toBe("worktree"); + } + }); + it("records worktree paths from spawnChild", async () => { const provider = new ScriptedProvider([ [{ type: "text_delta", text: "ok" }, { type: "stop", reason: "end_turn" }], diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 7940b0e..8170c41 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -14,7 +14,7 @@ import type { TodoItem, ChildRun, } from "./types.js"; -import { PermissionEngine } from "./permissions.js"; +import { PermissionEngine, type PermissionMode } from "./permissions.js"; import { compactMessages, contextBreakdown, @@ -74,10 +74,13 @@ export interface AgentOptions { } const MAX_RUNNING_CHILDREN = 8; +const MAX_WAIT_CHILD_MS = 10 * 60 * 1000; +const MAX_RESUME_MESSAGES = 24; +const MAX_FINISHED_CHILDREN = 32; interface ChildHandle { snap: ChildRun; - agent: Agent; + agent?: Agent; abort: AbortController; done: Promise; sandbox: Sandbox; @@ -111,6 +114,8 @@ export class Agent { private children = new Map(); private childSeq = 0; private demoteTurn = false; + /** Parallel `task` batches spawn on forkSilent so they cannot share the TUI prompt. */ + private silentChildSpawns = false; constructor( private provider: Provider, @@ -166,10 +171,16 @@ export class Agent { peekChild(id: string): ChildRun | null { const h = this.children.get(id); if (!h) return null; + if (!h.agent) return { ...h.snap }; const live = redactSecrets(clipChildOutput(finalAssistantText(h.agent) || h.snap.output || "")).text; return { ...h.snap, output: live }; } + replacePermissions(engine: PermissionEngine): void { + this.permissions.invalidatePrompts(); + this.permissions = engine; + } + /** * Unlink running children from the parent abort signal so Ctrl+B can free the * composer without killing the team. Returns ids still running. @@ -186,8 +197,16 @@ export class Agent { const readOnly = h.snap.subagentType === "explore" || h.snap.subagentType === "researcher"; if (!readOnly && !silentWrite) { h.abort.abort(); + if (h.snap.status === "running") { + h.snap.status = "killed"; + h.snap.error = true; + h.snap.output = "child interrupted"; + h.snap.endedAt = Date.now(); + } + this.hookChildSettled(h); continue; } + h.agent?.replacePermissions(this.permissions.forkSilent()); this.hookChildSettled(h); ids.push(h.snap.id); } @@ -336,10 +355,6 @@ export class Agent { return { output: `unknown persona "${input.persona}" (loaded: ${names})`, isError: true }; } - const running = [...this.children.values()].filter((h) => h.snap.status === "running").length; - if (running >= MAX_RUNNING_CHILDREN) { - return { output: `too many running children (${running}/${MAX_RUNNING_CHILDREN})`, isError: true }; - } const parentMode = this.permissions.getMode(); if ( input.background && @@ -352,74 +367,119 @@ export class Agent { isError: true, }; } + if ( + input.background && + type === "researcher" && + parentMode !== "yolo" && + parentMode !== "acceptEdits" + ) { + return { + output: "background researcher requires /mode acceptEdits or yolo (web_* cannot prompt)", + isError: true, + }; + } - let childSandbox = resume?.sandbox ?? this.opts.sandbox; - let worktreePath = resume?.snap.worktreePath; - let worktreeNote = ""; - const isolation = input.isolation === "worktree" || resume?.snap.isolation === "worktree" ? "worktree" : "none"; - if (isolation === "worktree" && !resume) { - if (!this.opts.openWorktree) { - return { output: "worktree isolation is not configured in this session", isError: true }; - } - try { - const wt = await this.opts.openWorktree(); - childSandbox = wt.sandbox; - worktreePath = wt.path; - this.createdWorktrees.push(wt.path); - worktreeNote = `\n\n[worktree ${wt.path} — not merged. /worktree apply to copy onto the parent tree]`; - } catch (e) { - return { output: `worktree failed: ${String(e)}`, isError: true }; - } - } else if (worktreePath) { - worktreeNote = `\n\n[worktree ${worktreePath} — not merged. /worktree apply to copy onto the parent tree]`; + const running = [...this.children.values()].filter((h) => h.snap.status === "running").length; + if (running >= MAX_RUNNING_CHILDREN) { + return { output: `too many running children (${running}/${MAX_RUNNING_CHILDREN})`, isError: true }; } - const id = this.newChildId(); - await this.fireHooks("SubagentStart", { prompt: input.prompt, subagentType: type }); - const childPerms = input.background ? this.permissions.forkSilent() : this.permissions; - const child = new Agent(this.provider, toolsForChild(type, this.tools), childPerms, { - sandbox: childSandbox, - system: systemForChild(type, this.opts.system, persona?.instructions), - compact: this.opts.compact, - maxSteps: 30, - isChild: true, - hooks: this.opts.hooks, - initialMessages: resume ? structuredClone([...resume.agent.history()]) : undefined, - }); - child.pushUser(input.prompt); + // Isolation is a property of the source child. Ignore input.isolation on resume. + const isolation: "none" | "worktree" = resume + ? resume.snap.isolation === "worktree" + ? "worktree" + : "none" + : input.isolation === "worktree" + ? "worktree" + : "none"; + const id = this.newChildId(); const abort = new AbortController(); - let unlinkParent: (() => void) | undefined; - if (signal && !input.background) { - const onAbort = () => abort.abort(); - if (signal.aborted) abort.abort(); - else { - signal.addEventListener("abort", onAbort); - unlinkParent = () => signal.removeEventListener("abort", onAbort); - } - } - + const gate = deferredVoid(); const snap: ChildRun = { id, description: input.description, subagentType: type, isolation, persona: persona?.name, - worktreePath, + worktreePath: resume?.snap.worktreePath, status: "running", output: "", startedAt: Date.now(), }; const handle: ChildHandle = { snap, - agent: child, abort, - sandbox: childSandbox, - done: Promise.resolve(), - unlinkParent, + sandbox: resume?.sandbox ?? this.opts.sandbox, + done: gate.promise, }; - handle.done = this.driveChild(handle, worktreeNote); + if (signal && !input.background) { + const onAbort = () => abort.abort(); + if (signal.aborted) abort.abort(); + else { + signal.addEventListener("abort", onAbort); + handle.unlinkParent = () => signal.removeEventListener("abort", onAbort); + } + } + // Reserve the slot before any await so parallel spawnChild calls share one cap. this.children.set(id, handle); + let committed = false; + try { + let childSandbox = handle.sandbox; + let worktreePath = snap.worktreePath; + let worktreeNote = ""; + if (isolation === "worktree" && !resume) { + if (!this.opts.openWorktree) { + return { output: "worktree isolation is not configured in this session", isError: true }; + } + try { + const wt = await this.opts.openWorktree(); + childSandbox = wt.sandbox; + worktreePath = wt.path; + snap.worktreePath = wt.path; + this.createdWorktrees.push(wt.path); + worktreeNote = `\n\n[worktree ${wt.path} — not merged. /worktree apply to copy onto the parent tree]`; + } catch (e) { + return { output: `worktree failed: ${String(e)}`, isError: true }; + } + } else if (worktreePath) { + worktreeNote = `\n\n[worktree ${worktreePath} — not merged. /worktree apply to copy onto the parent tree]`; + } + + await this.fireHooks("SubagentStart", { prompt: input.prompt, subagentType: type }); + if (handle.snap.status !== "running") { + committed = true; + gate.resolve(); + return { output: handle.snap.output || `child ${id} ${handle.snap.status}`, isError: true }; + } + const detachedFromParent = !!(signal && !input.background && !handle.unlinkParent); + const silent = input.background || this.silentChildSpawns || detachedFromParent; + const childPerms = silent ? this.permissions.forkSilent() : this.permissions.forkInteractive(); + const child = new Agent(this.provider, toolsForChild(type, this.tools), childPerms, { + sandbox: childSandbox, + system: systemForChild(type, this.opts.system, persona?.instructions), + compact: this.opts.compact, + maxSteps: 30, + isChild: true, + hooks: this.opts.hooks, + initialMessages: resume?.agent ? cloneResumeHistory(resume.agent.history()) : undefined, + }); + child.pushUser(input.prompt); + + handle.agent = child; + handle.sandbox = childSandbox; + const drive = this.driveChild(handle, worktreeNote); + void drive.then(gate.resolve, (err) => { + gate.resolve(); + void err; + }); + committed = true; + } finally { + if (!committed) { + gate.resolve(); + this.children.delete(id); + } + } if (input.background) { this.hookChildSettled(handle); @@ -429,11 +489,12 @@ export class Agent { `Use task_wait with id=${id} to collect, or /dashboard.`, }; } - if (signal) await Promise.race([handle.done, whenAborted(signal)]); + if (signal) await abortableRace(handle.done, signal); else await handle.done; if (handle.snap.status === "running") { handle.unlinkParent?.(); handle.unlinkParent = undefined; + handle.agent?.replacePermissions(this.permissions.forkSilent()); this.hookChildSettled(handle); return { output: @@ -444,7 +505,7 @@ export class Agent { return { output: handle.snap.output, isError: handle.snap.error }; } - async waitChild(id?: string, timeoutMs = 0): Promise { + async waitChild(id?: string, timeoutMs = 0, signal?: AbortSignal): Promise { if (!id) { const rows = this.listChildren(); if (!rows.length) return { output: "no child agents in this session" }; @@ -452,11 +513,19 @@ export class Agent { } const h = this.children.get(id); if (!h) return { output: `unknown child "${id}"`, isError: true }; - if (h.snap.status === "running" && timeoutMs > 0) { - await Promise.race([h.done, delay(timeoutMs)]); + if (h.snap.status === "running") { + const waitMs = clampWaitMs(timeoutMs); + if (waitMs > 0) { + await waitUntil(h.done, waitMs, signal); + } + if (h.snap.status === "running") { + const live = this.peekChild(id) ?? h.snap; + return { output: `${formatChildReport(live)}\n(still running)` }; + } } + const live = this.peekChild(id) ?? h.snap; return { - output: formatChildReport(h.snap), + output: formatChildReport(live), isError: !!h.snap.error, }; } @@ -476,19 +545,33 @@ export class Agent { private async driveChild(handle: ChildHandle, worktreeNote: string): Promise { try { - for await (const _ev of handle.agent.run(handle.abort.signal)) { - /* parent does not ingest child stream */ + if (!handle.agent) throw new Error("child agent missing"); + let failMsg = ""; + for await (const ev of handle.agent.run(handle.abort.signal)) { + if (ev.type === "error") failMsg = ev.error; + } + if (handle.snap.status === "running") { + if (failMsg) { + handle.snap.status = "failed"; + handle.snap.error = true; + handle.snap.output = redactSecrets(`child failed: ${failMsg}`).text; + } else { + handle.snap.status = handle.abort.signal.aborted ? "killed" : "completed"; + const raw = clipChildOutput(finalAssistantText(handle.agent) || "(child produced no text)"); + handle.snap.output = redactSecrets(raw).text + worktreeNote; + handle.snap.error = handle.snap.status !== "completed"; + } } - handle.snap.status = handle.abort.signal.aborted ? "killed" : "completed"; - const raw = clipChildOutput(finalAssistantText(handle.agent) || "(child produced no text)"); - handle.snap.output = redactSecrets(raw).text + worktreeNote; - handle.snap.error = handle.snap.status !== "completed"; } catch (e) { - handle.snap.status = handle.abort.signal.aborted ? "killed" : "failed"; - handle.snap.error = true; - handle.snap.output = handle.abort.signal.aborted ? "child interrupted" : `child failed: ${String(e)}`; + if (handle.snap.status === "running") { + handle.snap.status = handle.abort.signal.aborted ? "killed" : "failed"; + handle.snap.error = true; + handle.snap.output = redactSecrets( + handle.abort.signal.aborted ? "child interrupted" : `child failed: ${String(e)}`, + ).text; + } } finally { - handle.snap.endedAt = Date.now(); + if (!handle.snap.endedAt) handle.snap.endedAt = Date.now(); await this.fireHooks("SubagentStop", { prompt: handle.snap.description, subagentType: handle.snap.subagentType, @@ -499,9 +582,9 @@ export class Agent { private gcChildren(): void { const done = [...this.children.values()].filter((h) => h.snap.status !== "running"); - if (done.length <= 32) return; + if (done.length <= MAX_FINISHED_CHILDREN) return; done.sort((a, b) => (a.snap.endedAt ?? 0) - (b.snap.endedAt ?? 0)); - for (const h of done.slice(0, done.length - 32)) this.children.delete(h.snap.id); + for (const h of done.slice(0, done.length - MAX_FINISHED_CHILDREN)) this.children.delete(h.snap.id); } /** @@ -567,6 +650,7 @@ export class Agent { let textBuf = ""; let stop: StopReason = "end_turn"; let usage: { inputTokens: number; outputTokens: number } | undefined; + let sawStop = false; let fatal = false; // One model turn. Retry transient provider errors (rate limits, dropped @@ -599,12 +683,11 @@ export class Agent { pending.push(ev.call); assistantContent.push(ev.call); produced = true; - yield ev; break; case "stop": stop = ev.reason; usage = ev.usage; - yield ev; + sawStop = true; break; case "error": errored = ev.error; @@ -623,6 +706,9 @@ export class Agent { textBuf = ""; assistantContent.length = 0; pending.length = 0; + sawStop = false; + usage = undefined; + stop = "end_turn"; continue; // re-stream the same turn } @@ -634,6 +720,11 @@ export class Agent { if (fatal) return; if (textBuf) assistantContent.unshift({ type: "text", text: textBuf }); + stampParallelTaskWorktrees(pending); + for (const call of pending) { + yield { type: "tool_call", call }; + } + if (sawStop) yield { type: "stop", reason: stop, usage }; this.messages.push({ role: "assistant", content: assistantContent }); // Settled turn → emit usage exactly once. The retry loop above has already @@ -675,7 +766,7 @@ export class Agent { }; if (!this.opts.isChild) { ctx.spawnChild = (input) => this.spawnChild(input, signal); - ctx.waitChild = (id, ms) => this.waitChild(id, ms); + ctx.waitChild = (id, ms) => this.waitChild(id, ms, signal); ctx.killChild = (id) => this.killChild(id); ctx.listChildren = () => this.listChildren(); } @@ -745,21 +836,21 @@ export class Agent { return runTool(call, g.tool); }; - stampParallelTaskWorktrees(pending); let idx = 0; let backgrounded = false; + const mode = this.permissions.getMode(); while (idx < pending.length) { if (signal?.aborted && this.demoteTurn) { backgrounded = true; break; } // Consecutive read-only tools AND fan-out `task` children run together. - // Permission prompts stay sequential; only execution is parallel. + // Only children that will not prompt may share a batch. const batch: ToolCallPart[] = []; while (idx < pending.length) { const call = pending[idx]; const t = toolMap.get(call.name); - if (canRunParallel(t, call)) batch.push(pending[idx++]); + if (canRunParallel(t, call, mode)) batch.push(pending[idx++]); else break; } @@ -772,10 +863,16 @@ export class Agent { results.push(g.result); } else allowed.push({ call, tool: g.tool }); } - const outcomes = await Promise.all(allowed.map(({ call, tool }) => runTool(call, tool))); - for (const o of outcomes) { - for (const ev of o.events) yield ev; - results.push(o.result); + const hadTask = allowed.some((a) => a.call.name === "task"); + if (hadTask) this.silentChildSpawns = true; + try { + const outcomes = await Promise.all(allowed.map(({ call, tool }) => runTool(call, tool))); + for (const o of outcomes) { + for (const ev of o.events) yield ev; + results.push(o.result); + } + } finally { + this.silentChildSpawns = false; } } else { const call = batch.length === 1 ? batch[0] : pending[idx++]; @@ -801,12 +898,15 @@ export class Agent { // loop: feed tool results back to the model on the next turn } } finally { + this.demoteTurn = false; + this.silentChildSpawns = false; if (!this.opts.isChild) await this.fireHooks("Stop", {}); } } } -function finalAssistantText(agent: Agent): string { +function finalAssistantText(agent: Agent | undefined): string { + if (!agent) return ""; const last = [...agent.history()].reverse().find((m) => m.role === "assistant"); if (!last) return ""; return last.content @@ -832,10 +932,17 @@ function formatChildReport(run: ChildRun): string { return `${formatChildLine(run)}\n${run.output || "(no output yet)"}`; } -function canRunParallel(tool: ToolSpec | undefined, call: ToolCallPart): boolean { +function canRunParallel(tool: ToolSpec | undefined, call: ToolCallPart, mode: PermissionMode): boolean { if (!tool) return false; if (tool.parallelSafe && tool.permission === "safe") return true; - if (tool.name === "task" && (isReadOnlyTask(call.input) || taskWantsWorktree(call.input))) return true; + if (tool.name !== "task") return false; + const silentOk = mode === "yolo" || mode === "acceptEdits"; + if (isReadOnlyTask(call.input)) { + const type = String((call.input as { subagent_type?: string }).subagent_type ?? "general").toLowerCase(); + if (type === "researcher") return silentOk; + return true; + } + if (taskWantsWorktree(call.input)) return silentOk; return false; } @@ -869,13 +976,54 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -function whenAborted(signal?: AbortSignal): Promise { +function clampWaitMs(ms: number): number { + if (!Number.isFinite(ms) || ms <= 0) return 0; + return Math.min(Math.max(Math.floor(ms), 1), MAX_WAIT_CHILD_MS); +} + +function cloneResumeHistory(messages: readonly CanonicalMessage[]): CanonicalMessage[] { + let slice = messages.length > MAX_RESUME_MESSAGES ? messages.slice(-MAX_RESUME_MESSAGES) : [...messages]; + while (slice.length && slice[0].role === "tool") slice = slice.slice(1); + return structuredClone(slice); +} + +function deferredVoid(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +/** Race `done` against abort; always remove the abort listener. */ +function abortableRace(done: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(); return new Promise((resolve) => { - if (!signal) return; - if (signal.aborted) { + const finish = () => { + signal.removeEventListener("abort", onAbort); resolve(); - return; - } - signal.addEventListener("abort", () => resolve(), { once: true }); + }; + const onAbort = () => finish(); + signal.addEventListener("abort", onAbort); + done.then(finish, finish); + }); +} + +function waitUntil(done: Promise, timeoutMs: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.resolve(); + return new Promise((resolve) => { + let settled = false; + let timer: ReturnType | undefined; + const finish = () => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + resolve(); + }; + const onAbort = () => finish(); + done.then(finish, finish); + if (timeoutMs > 0) timer = setTimeout(finish, timeoutMs); + if (signal) signal.addEventListener("abort", onAbort); }); } diff --git a/packages/core/src/env.test.ts b/packages/core/src/env.test.ts index 9a8871d..9552d19 100644 --- a/packages/core/src/env.test.ts +++ b/packages/core/src/env.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { childProcessEnv, isSecretEnvName } from "./env.js"; +import { childProcessEnv, isSecretEnvName, isUnsafeChildEnvName } from "./env.js"; const injected: string[] = []; function setEnv(k: string, v: string) { @@ -30,7 +30,22 @@ describe("isSecretEnvName", () => { }); }); +describe("isUnsafeChildEnvName", () => { + it.each(["NODE_OPTIONS", "NODE_PATH", "NODE_EXTRA_CA_CERTS", "LD_PRELOAD", "DYLD_LIBRARY_PATH"])( + "flags %s", + (name) => { + expect(isUnsafeChildEnvName(name)).toBe(true); + }, + ); +}); + describe("childProcessEnv", () => { + it("strips NODE_OPTIONS so a repo .env cannot RCE child node", () => { + setEnv("NODE_OPTIONS", "--require ./evil.js"); + const env = childProcessEnv(); + expect(env.NODE_OPTIONS).toBeUndefined(); + }); + it("strips secrets and applies non-auth overrides", () => { setEnv("OPENAI_API_KEY", "sk-test"); setEnv("POLYCODE_AUTH_TOKEN", "hosted-token-value"); @@ -45,4 +60,10 @@ describe("childProcessEnv", () => { expect(env.MCP_TOKEN).toBe("mcp-ok"); if (process.env.PATH) expect(env.PATH).toBe(process.env.PATH); }); + + it("refuses NODE_OPTIONS even when passed as an MCP override", () => { + const env = childProcessEnv({ NODE_OPTIONS: "--require ./pwn.js", FOO: "ok" }); + expect(env.NODE_OPTIONS).toBeUndefined(); + expect(env.FOO).toBe("ok"); + }); }); diff --git a/packages/core/src/env.ts b/packages/core/src/env.ts index 0f80f31..7825dd7 100644 --- a/packages/core/src/env.ts +++ b/packages/core/src/env.ts @@ -6,6 +6,18 @@ export function isSecretEnvName(name: string): boolean { return /(_SECRET|_TOKEN|_PASSWORD|_PASSWD|_PRIVATE_KEY|_CREDENTIALS?|_AUTHORIZATION|_KEY)$/.test(n); } +/** Loader/process-injection names that must not reach spawned node/npm. */ +export function isUnsafeChildEnvName(name: string): boolean { + const n = name.toUpperCase(); + return ( + n === "NODE_OPTIONS" || + n === "NODE_PATH" || + n === "NODE_EXTRA_CA_CERTS" || + n.startsWith("LD_") || + n.startsWith("DYLD_") + ); +} + /** * Copy `process.env` minus secret-looking names. * `overrides` are applied last (explicit MCP env) except the hosted auth tokens, @@ -14,7 +26,7 @@ export function isSecretEnvName(name: string): boolean { export function childProcessEnv(overrides?: Record): NodeJS.ProcessEnv { const out: NodeJS.ProcessEnv = {}; for (const [k, v] of Object.entries(process.env)) { - if (v == null || isSecretEnvName(k)) continue; + if (v == null || isSecretEnvName(k) || isUnsafeChildEnvName(k)) continue; out[k] = v; } if (overrides) { @@ -22,6 +34,7 @@ export function childProcessEnv(overrides?: Record): if (v == null) continue; const n = k.toUpperCase(); if (n === "POLYCODE_AUTH_TOKEN" || n === "POLYCODE_AUTH_TOKENS") continue; + if (isUnsafeChildEnvName(k)) continue; out[k] = v; } } diff --git a/packages/core/src/loop.test.ts b/packages/core/src/loop.test.ts index abecbbc..5700a2b 100644 --- a/packages/core/src/loop.test.ts +++ b/packages/core/src/loop.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { MIN_LOOP_MS, formatLoopInterval, parseLoopCommand, parseLoopInterval } from "./loop.js"; +import { + MAX_LOOP_MS, + MIN_LOOP_MS, + formatLoopInterval, + nextLoopId, + parseLoopCommand, + parseLoopInterval, +} from "./loop.js"; describe("parseLoopInterval", () => { it("parses s/m/h/d", () => { @@ -33,4 +40,22 @@ describe("parseLoopCommand", () => { expect(r.op).toBe("usage"); expect(formatLoopInterval(MIN_LOOP_MS)).toBe("15s"); }); + + it("rejects intervals that would overflow setInterval", () => { + const r = parseLoopCommand("/loop 25d say ping"); + expect(r).toMatchObject({ op: "usage" }); + expect(parseLoopInterval("25d")).toBeNull(); + expect(parseLoopInterval("24h")).toBe(MAX_LOOP_MS); + expect(parseLoopCommand("/loop 1d tick")).toMatchObject({ op: "start", intervalMs: MAX_LOOP_MS }); + }); +}); + +describe("nextLoopId", () => { + it("never reuses an id after stop", () => { + const seq = { current: 0 }; + expect(nextLoopId(seq)).toBe("l1"); + expect(nextLoopId(seq)).toBe("l2"); + // stop l1 — seq is monotonic so the next start is l3, not a second l2 + expect(nextLoopId(seq)).toBe("l3"); + }); }); diff --git a/packages/core/src/loop.ts b/packages/core/src/loop.ts index 143b53b..5319044 100644 --- a/packages/core/src/loop.ts +++ b/packages/core/src/loop.ts @@ -1,18 +1,38 @@ -/** Parse `60s` / `5m` / `2h` / `1d`. Returns ms or null. */ +/** Node `setInterval` treats delays above this as 1ms. */ +export const MAX_SAFE_INTERVAL_MS = 2_147_483_647; +/** Product cap: 24h. */ +export const MAX_LOOP_MS = 86_400_000; +export const MIN_LOOP_MS = 15_000; + +const INTERVAL_FACTOR: Record = { + ms: 1, + s: 1000, + m: 60_000, + h: 3_600_000, + d: 86_400_000, +}; + +/** Parse `60s` / `5m` / `2h` / `1d`. Returns ms or null (unknown / overflow / above cap). */ export function parseLoopInterval(raw: string): number | null { const m = raw.trim().match(/^(\d+)(ms|s|m|h|d)$/i); if (!m) return null; const n = Number(m[1]); if (!Number.isFinite(n) || n <= 0) return null; - const u = m[2].toLowerCase(); - if (u === "ms") return n; - if (u === "s") return n * 1000; - if (u === "m") return n * 60_000; - if (u === "h") return n * 3_600_000; - return n * 86_400_000; + const factor = INTERVAL_FACTOR[m[2].toLowerCase()]; + const ms = n * factor; + if (!Number.isFinite(ms) || ms > MAX_LOOP_MS || ms > MAX_SAFE_INTERVAL_MS) return null; + return ms; } -export const MIN_LOOP_MS = 15_000; +export function looksLikeLoopInterval(raw: string): boolean { + return /^\d+(ms|s|m|h|d)$/i.test(raw.trim()); +} + +/** Monotonic loop ids (`l1`, `l2`, …) that never reuse after stop. */ +export function nextLoopId(seq: { current: number }): string { + seq.current += 1; + return `l${seq.current}`; +} export function formatLoopInterval(ms: number): string { if (ms % 86_400_000 === 0) return `${ms / 86_400_000}d`; @@ -36,8 +56,11 @@ export function parseLoopCommand(line: string): LoopParse { if (first.toLowerCase() === "stop" || first.toLowerCase() === "off") { return { op: "stop", id: more[0] }; } - const ms = parseLoopInterval(first); - if (ms != null) { + if (looksLikeLoopInterval(first)) { + const ms = parseLoopInterval(first); + if (ms == null) { + return { op: "usage", error: `interval must be at most ${formatLoopInterval(MAX_LOOP_MS)}` }; + } const prompt = more.join(" ").trim(); if (!prompt) return { op: "usage", error: "usage: /loop 5m " }; if (ms < MIN_LOOP_MS) { diff --git a/packages/core/src/paths.test.ts b/packages/core/src/paths.test.ts index 80fa3d2..c4488e2 100644 --- a/packages/core/src/paths.test.ts +++ b/packages/core/src/paths.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { commandTouchesProtected, isPolycodeToolPathAllowed, isProtectedProjectPath } from "./paths.js"; +import { + commandTouchesProtected, + isPolycodeToolPathAllowed, + isProtectedProjectPath, + polycodeConfigDir, +} from "./paths.js"; describe("isProtectedProjectPath", () => { it.each([".env", ".env.local", "apps/.env", ".ENV", ".git/config", ".ssh/id_rsa", ".polycode/sessions/x.json"])( @@ -22,6 +27,23 @@ describe("isProtectedProjectPath", () => { }); }); +describe("polycodeConfigDir", () => { + it("uses APPDATA on Windows when POLYCODE_CONFIG_DIR is unset", () => { + const prevCfg = process.env.POLYCODE_CONFIG_DIR; + const prevApp = process.env.APPDATA; + delete process.env.POLYCODE_CONFIG_DIR; + process.env.APPDATA = "C:\\Users\\tester\\AppData\\Roaming"; + try { + expect(polycodeConfigDir().replace(/\\/g, "/").toLowerCase()).toMatch(/appdata\/roaming\/polycode$/); + } finally { + if (prevCfg == null) delete process.env.POLYCODE_CONFIG_DIR; + else process.env.POLYCODE_CONFIG_DIR = prevCfg; + if (prevApp == null) delete process.env.APPDATA; + else process.env.APPDATA = prevApp; + } + }); +}); + describe("commandTouchesProtected", () => { it.each(["cat .env", "type .env.local", "python -c \"open('.env')\"", "cat .git/config", "ls .polycode/sessions"])( "flags %s", diff --git a/packages/core/src/paths.ts b/packages/core/src/paths.ts index 6263f7e..d6aaa9c 100644 --- a/packages/core/src/paths.ts +++ b/packages/core/src/paths.ts @@ -1,8 +1,18 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + /** Project-relative path with `/` separators, no leading `./`. */ export function normalizeRelPath(rel: string): string { return rel.replace(/\\/g, "/").replace(/^\.\//, ""); } +/** User config dir: POLYCODE_CONFIG_DIR, else %APPDATA%/polycode, else ~/.config/polycode. */ +export function polycodeConfigDir(): string { + if (process.env.POLYCODE_CONFIG_DIR) return process.env.POLYCODE_CONFIG_DIR; + if (process.env.APPDATA) return join(process.env.APPDATA, "polycode"); + return join(homedir(), ".config", "polycode"); +} + /** * `.polycode/` is jailed (sessions, worktrees, audit) except these tool-visible * files the agent is allowed to read/write through the sandbox. diff --git a/packages/core/src/permissions.test.ts b/packages/core/src/permissions.test.ts index dce10e9..9da6600 100644 --- a/packages/core/src/permissions.test.ts +++ b/packages/core/src/permissions.test.ts @@ -102,6 +102,40 @@ describe("PermissionEngine", () => { expect(prompt).not.toHaveBeenCalled(); }); + it("forkSilent acceptEdits auto-allows dangerous tools without prompting", async () => { + const prompt = vi.fn(async () => "deny" as const); + const silent = new PermissionEngine("acceptEdits", prompt).forkSilent(); + expect(silent.isSilent()).toBe(true); + expect((await silent.check(tool("write", "mutating"), { path: "a.ts" })).allow).toBe(true); + expect((await silent.check(tool("bash", "dangerous"), { command: "npm test" })).allow).toBe(true); + expect(prompt).not.toHaveBeenCalled(); + }); + + it("forkSilent ask becomes plan and cannot prompt", async () => { + const prompt = vi.fn(async () => "once" as const); + const silent = new PermissionEngine("ask", prompt).forkSilent(); + expect(silent.getMode()).toBe("plan"); + expect((await silent.check(tool("write", "mutating"), { path: "a.ts" })).allow).toBe(false); + expect((await silent.check(tool("bash", "dangerous"), { command: "ls" })).allow).toBe(false); + expect(prompt).not.toHaveBeenCalled(); + }); + + it("invalidatePrompts denies an in-flight prompt", async () => { + let release!: (c: PermissionChoice) => void; + const prompt = vi.fn( + () => + new Promise((r) => { + release = r; + }), + ); + const engine = new PermissionEngine("ask", prompt); + const pending = engine.check(tool("write", "mutating"), { path: "a.ts" }); + await vi.waitFor(() => expect(prompt).toHaveBeenCalled()); + engine.invalidatePrompts(); + release("once"); + expect(await pending).toEqual({ allow: false, reason: "background child cannot prompt" }); + }); + it("allows .polycode/memory.md the same way as reviews", async () => { const prompt = vi.fn(async () => "once" as const); const engine = new PermissionEngine("ask", prompt); diff --git a/packages/core/src/permissions.ts b/packages/core/src/permissions.ts index e29d1af..f64cab8 100644 --- a/packages/core/src/permissions.ts +++ b/packages/core/src/permissions.ts @@ -116,6 +116,8 @@ export function isProtectedInput(input: unknown): boolean { export class PermissionEngine { /** Tools the user chose to allow for the rest of the session ("always"). */ private sessionAllowed = new Set(); + private silent = false; + private promptGen = 0; constructor( private mode: PermissionMode, @@ -130,14 +132,34 @@ export class PermissionEngine { return this.mode; } + isSilent(): boolean { + return this.silent; + } + + /** In-flight `prompt()` calls resolve as deny after this. */ + invalidatePrompts(): void { + this.promptGen += 1; + } + /** - * Silent engine for background children (cannot pop the TUI prompt). - * yolo/acceptEdits stay writable; ask/plan become plan (read-only). + * Silent engine for background / detached / parallel children (cannot pop the TUI). + * yolo stays yolo. acceptEdits stays acceptEdits but auto-allows dangerous + * (non-interactive). ask/plan become plan (read-only). */ forkSilent(): PermissionEngine { const mode: PermissionMode = this.mode === "yolo" || this.mode === "acceptEdits" ? this.mode : "plan"; - return new PermissionEngine(mode, async () => "deny", this.rules); + const child = new PermissionEngine(mode, async () => "deny", this.rules); + child.silent = true; + for (const t of this.sessionAllowed) child.sessionAllowed.add(t); + return child; + } + + /** Isolated interactive engine that can still prompt (sequential foreground children). */ + forkInteractive(): PermissionEngine { + const child = new PermissionEngine(this.mode, this.prompt, this.rules); + for (const t of this.sessionAllowed) child.sessionAllowed.add(t); + return child; } /** Tool names granted "always allow" this session (for UI display). */ @@ -173,8 +195,14 @@ export class PermissionEngine { return { allow: true }; } - // ask mode (or a dangerous tool in any non-yolo mode): defer to the user. + // ask mode (or a dangerous tool in acceptEdits): defer to the user, unless silent. + if (this.silent) { + if (this.mode === "acceptEdits") return { allow: true }; + return { allow: false, reason: "background child cannot prompt" }; + } + const gen = this.promptGen; const choice = await this.prompt({ tool, input }); + if (gen !== this.promptGen) return { allow: false, reason: "background child cannot prompt" }; if (choice === "always") { this.sessionAllowed.add(tool.name); return { allow: true }; diff --git a/packages/core/src/personas.test.ts b/packages/core/src/personas.test.ts index 2aee8be..2fd14ae 100644 --- a/packages/core/src/personas.test.ts +++ b/packages/core/src/personas.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, describe, expect, it } from "vitest"; @@ -22,4 +22,43 @@ describe("loadPersonas", () => { expect(lookupPersona(list, "terse")?.instructions).toMatch(/3 bullets/); expect(lookupPersona(list, "Terse")?.description).toBe("Terse"); }); + + it("skips directories pretending to be persona files", () => { + const cwd = join(tmpdir(), `poly-persona-dir-${Date.now()}`); + dirs.push(cwd); + mkdirSync(join(cwd, ".polycode", "personas", "nested.md"), { recursive: true }); + expect(lookupPersona(loadPersonas(cwd), "nested")).toBeUndefined(); + }); + + it("does not follow a persona symlink out of the personas dir", () => { + const cwd = join(tmpdir(), `poly-persona-link-${Date.now()}`); + dirs.push(cwd); + mkdirSync(cwd, { recursive: true }); + const secret = join(cwd, "id_rsa"); + writeFileSync(secret, "SSH SECRET KEY MATERIAL\n"); + mkdirSync(join(cwd, ".polycode", "personas"), { recursive: true }); + try { + symlinkSync(secret, join(cwd, ".polycode", "personas", "helpful.md")); + } catch { + return; // Windows without symlink privilege — directory case above still covers non-files + } + expect(lookupPersona(loadPersonas(cwd), "helpful")).toBeUndefined(); + }); + + it("loads user personas from POLYCODE_CONFIG_DIR", () => { + const prev = process.env.POLYCODE_CONFIG_DIR; + const cfg = join(tmpdir(), `poly-cfg-${Date.now()}`); + const cwd = join(tmpdir(), `poly-empty-${Date.now()}`); + dirs.push(cfg, cwd); + process.env.POLYCODE_CONFIG_DIR = cfg; + try { + mkdirSync(join(cfg, "personas"), { recursive: true }); + mkdirSync(cwd, { recursive: true }); + writeFileSync(join(cfg, "personas", "user.md"), "# U\n\nFrom user config.\n"); + expect(lookupPersona(loadPersonas(cwd), "user")?.instructions).toMatch(/user config/); + } finally { + if (prev == null) delete process.env.POLYCODE_CONFIG_DIR; + else process.env.POLYCODE_CONFIG_DIR = prev; + } + }); }); diff --git a/packages/core/src/personas.ts b/packages/core/src/personas.ts index 1671b50..7708804 100644 --- a/packages/core/src/personas.ts +++ b/packages/core/src/personas.ts @@ -1,6 +1,6 @@ -import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; +import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs"; import { join } from "node:path"; +import { polycodeConfigDir } from "./paths.js"; export interface Persona { name: string; @@ -27,8 +27,29 @@ function parsePersonaFile(name: string, text: string, source: Persona["source"]) return { name: id, description, instructions, source }; } +function jailedPersonaPath(dir: string, name: string): string | null { + if (name.includes("..") || name.includes("/") || name.includes("\\")) return null; + if (!/^[A-Za-z0-9._-]+\.(md|txt)$/i.test(name)) return null; + const file = join(dir, name); + try { + const st = lstatSync(file); + if (!st.isFile() || st.isSymbolicLink()) return null; + const root = realpathSync(dir).replace(/\\/g, "/").toLowerCase(); + const resolved = realpathSync(file).replace(/\\/g, "/").toLowerCase(); + if (resolved !== root && !resolved.startsWith(root.endsWith("/") ? root : `${root}/`)) return null; + return file; + } catch { + return null; + } +} + function readDir(dir: string, source: Persona["source"]): Persona[] { if (!existsSync(dir)) return []; + try { + if (lstatSync(dir).isSymbolicLink()) return []; + } catch { + return []; + } let names: string[] = []; try { names = readdirSync(dir); @@ -38,11 +59,10 @@ function readDir(dir: string, source: Persona["source"]): Persona[] { const out: Persona[] = []; for (const n of names) { if (!/\.(md|txt)$/i.test(n)) continue; - if (n.includes("..") || n.includes("/") || n.includes("\\") || !/^[A-Za-z0-9._-]+\.(md|txt)$/i.test(n)) { - continue; - } + const file = jailedPersonaPath(dir, n); + if (!file) continue; try { - const raw = readFileSync(join(dir, n), "utf8"); + const raw = readFileSync(file, "utf8"); const p = parsePersonaFile(n, raw.length > 16_384 ? raw.slice(0, 16_384) : raw, source); if (p) out.push(p); } catch { @@ -52,10 +72,10 @@ function readDir(dir: string, source: Persona["source"]): Persona[] { return out; } -/** Project `.polycode/personas/` overrides `~/.config/polycode/personas/`. */ +/** Project `.polycode/personas/` overrides user-config `personas/`. */ export function loadPersonas(cwd: string): Persona[] { const byName = new Map(); - const user = join(process.env.POLYCODE_CONFIG_DIR ?? join(homedir(), ".config", "polycode"), "personas"); + const user = join(polycodeConfigDir(), "personas"); for (const p of readDir(user, "user")) byName.set(p.name, p); for (const p of readDir(join(cwd, ".polycode", "personas"), "project")) byName.set(p.name, p); return [...byName.values()]; diff --git a/packages/core/src/subagent.test.ts b/packages/core/src/subagent.test.ts index 94fadc4..83b8b8a 100644 --- a/packages/core/src/subagent.test.ts +++ b/packages/core/src/subagent.test.ts @@ -320,6 +320,263 @@ describe("Agent.spawnChild", () => { expect(waited.output).toContain("survived"); expect(parent.listChildren()[0].status).toBe("completed"); }); + + it("caps parallel background explores at 8", async () => { + let release!: () => void; + const hold = new Promise((r) => { + release = r; + }); + const provider: Provider = { + id: "test", + model: "slow", + capabilities: () => capabilities, + async *stream() { + await hold; + yield { type: "text_delta", text: "x" }; + yield { type: "stop", reason: "end_turn" }; + }, + }; + const parent = new Agent(provider, [read], new PermissionEngine("yolo", async () => "once"), { sandbox }); + const results = await Promise.all( + Array.from({ length: 10 }, (_, i) => + parent.spawnChild({ + description: `e${i}`, + prompt: "go", + subagent_type: "explore", + background: true, + }), + ), + ); + expect(results.filter((r) => !r.isError)).toHaveLength(8); + expect(results.filter((r) => r.isError).every((r) => /too many running children/.test(r.output))).toBe(true); + expect(parent.listChildren().filter((c) => c.status === "running")).toHaveLength(8); + release(); + }); + + it("refuses background researchers in ask mode", async () => { + const provider = new ScriptedProvider([[{ type: "stop", reason: "end_turn" }]]); + const parent = new Agent(provider, [read], new PermissionEngine("ask", async () => "deny"), { sandbox }); + const r = await parent.spawnChild({ + description: "cite", + prompt: "x", + subagent_type: "researcher", + background: true, + }); + expect(r.isError).toBe(true); + expect(r.output).toMatch(/acceptEdits or yolo/); + }); + + it("Ctrl+B kills writers in ask instead of reporting them backgrounded", async () => { + const provider: Provider = { + id: "test", + model: "slow", + capabilities: () => capabilities, + async *stream() { + await new Promise((r) => setTimeout(r, 80)); + yield { type: "text_delta", text: "should-not-survive" }; + yield { type: "stop", reason: "end_turn" }; + }, + }; + const parent = new Agent(provider, [read], new PermissionEngine("ask", async () => "deny"), { sandbox }); + const ac = new AbortController(); + const pending = parent.spawnChild( + { description: "edit", prompt: "go", subagent_type: "general" }, + ac.signal, + ); + await new Promise((r) => setTimeout(r, 10)); + const ids = parent.detachRunningChildren(); + ac.abort(); + const r = await pending; + expect(ids).toEqual([]); + expect(r.output).not.toMatch(/backgrounded/); + expect(parent.listChildren()[0].status).toBe("killed"); + }); + + it("ignores isolation=worktree on resume of a non-worktree child", async () => { + const provider = new ScriptedProvider([ + [{ type: "text_delta", text: "first" }, { type: "stop", reason: "end_turn" }], + [{ type: "text_delta", text: "second" }, { type: "stop", reason: "end_turn" }], + ]); + const parent = new Agent(provider, [read], new PermissionEngine("ask", async () => "deny"), { + sandbox, + openWorktree: async () => ({ sandbox, path: "/tmp/wt-should-not-open" }), + }); + await parent.spawnChild({ description: "e", prompt: "one", subagent_type: "explore" }); + const id = parent.listChildren()[0].id; + await parent.spawnChild({ + description: "e2", + prompt: "two", + resume_from: id, + isolation: "worktree", + }); + const child = parent.listChildren()[1]; + expect(child.isolation).toBe("none"); + expect(child.worktreePath).toBeUndefined(); + expect(parent.sessionWorktrees()).toEqual([]); + }); + + it("redacts secrets on the child failure path", async () => { + const provider: Provider = { + id: "test", + model: "boom", + capabilities: () => capabilities, + async *stream() { + throw new Error("upstream Bearer sk-abcdefghijklmnopqrstuvwxyz123456"); + }, + }; + const parent = new Agent(provider, [read], new PermissionEngine("ask", async () => "deny"), { sandbox }); + const r = await parent.spawnChild({ description: "e", prompt: "go", subagent_type: "explore" }); + expect(r.isError).toBe(true); + expect(r.output).not.toContain("sk-abcdefghijklmnopqrstuvwxyz123456"); + expect(r.output).toContain("[REDACTED]"); + }); + + it("waitChild timeout 0 is a snapshot even when a parent signal is live", async () => { + let release!: () => void; + const hold = new Promise((r) => { + release = r; + }); + const provider: Provider = { + id: "test", + model: "slow", + capabilities: () => capabilities, + async *stream() { + await hold; + yield { type: "text_delta", text: "late" }; + yield { type: "stop", reason: "end_turn" }; + }, + }; + const parent = new Agent(provider, [read], new PermissionEngine("ask", async () => "deny"), { sandbox }); + await parent.spawnChild({ + description: "look", + prompt: "go", + subagent_type: "explore", + background: true, + }); + const ac = new AbortController(); + const r = await parent.waitChild("c1", 0, ac.signal); + expect(r.output).toMatch(/still running/); + expect(parent.listChildren()[0].status).toBe("running"); + release(); + }); + + it("Ctrl+B during worktree open does not re-bind the aborted parent signal", async () => { + let release!: () => void; + const hold = new Promise<{ sandbox: Sandbox; path: string }>((r) => { + release = () => r({ sandbox, path: "/tmp/wt-detach" }); + }); + const provider: Provider = { + id: "test", + model: "slow", + capabilities: () => capabilities, + async *stream() { + yield { type: "text_delta", text: "survived-wt" }; + yield { type: "stop", reason: "end_turn" }; + }, + }; + const parent = new Agent(provider, [read], new PermissionEngine("ask", async () => "deny"), { + sandbox, + openWorktree: () => hold, + }); + const ac = new AbortController(); + const pending = parent.spawnChild( + { description: "e", prompt: "go", subagent_type: "explore", isolation: "worktree" }, + ac.signal, + ); + await new Promise((r) => setTimeout(r, 10)); + const ids = parent.detachRunningChildren(); + ac.abort(); + release(); + const r = await pending; + expect(ids).toEqual(["c1"]); + expect(r.output).toMatch(/survived-wt|backgrounded/); + expect(parent.listChildren()[0].status).not.toBe("killed"); + }); + + it("waitChild honors abort and returns live peek while still running", async () => { + const provider: Provider = { + id: "test", + model: "slow", + capabilities: () => capabilities, + async *stream() { + await new Promise((r) => setTimeout(r, 200)); + yield { type: "text_delta", text: "late" }; + yield { type: "stop", reason: "end_turn" }; + }, + }; + const parent = new Agent(provider, [read], new PermissionEngine("ask", async () => "deny"), { sandbox }); + await parent.spawnChild({ + description: "look", + prompt: "go", + subagent_type: "explore", + background: true, + }); + const ac = new AbortController(); + const pending = parent.waitChild("c1", 60_000, ac.signal); + ac.abort(); + const r = await pending; + expect(r.output).toMatch(/still running/); + expect(parent.listChildren()[0].status).toBe("running"); + }); + + it("clears demoteTurn after abort during stream so the next tool turn runs", async () => { + let release!: () => void; + const gate = new Promise((r) => { + release = r; + }); + let calls = 0; + const ran = vi.fn(async () => ({ output: "ok" })); + const readTool: ToolSpec = { ...read, run: ran }; + const provider: Provider = { + id: "test", + model: "x", + capabilities: () => capabilities, + async *stream(req) { + calls++; + if (calls === 1) { + await gate; + if (req.signal?.aborted) throw new Error("aborted"); + yield { type: "text_delta", text: "one" }; + yield { type: "stop", reason: "end_turn" }; + return; + } + if (calls === 2) { + yield { + type: "tool_call", + call: { type: "tool_call", id: "1", name: "read", input: { path: "a.ts" } }, + }; + yield { type: "stop", reason: "tool_use" }; + return; + } + yield { type: "text_delta", text: "two" }; + yield { type: "stop", reason: "end_turn" }; + }, + }; + const parent = new Agent(provider, [readTool], new PermissionEngine("ask", async () => "deny"), { sandbox }); + parent.pushUser("one"); + const ac = new AbortController(); + const first = parent.run(ac.signal); + const waiter = (async () => { + try { + for await (const _ of first) { + /* drain */ + } + } catch { + /* abort */ + } + })(); + await new Promise((r) => setTimeout(r, 5)); + parent.detachRunningChildren(); + ac.abort(); + release(); + await waiter; + + parent.pushUser("two"); + const events = []; + for await (const ev of parent.run()) events.push(ev); + expect(ran).toHaveBeenCalled(); + expect(events.some((e) => e.type === "text_delta" && e.text === "two")).toBe(true); + }); }); describe("parseReviewVerdict", () => { diff --git a/packages/secrets/src/index.test.ts b/packages/secrets/src/index.test.ts index 4aaddf8..37b55a5 100644 --- a/packages/secrets/src/index.test.ts +++ b/packages/secrets/src/index.test.ts @@ -2,9 +2,18 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { mkdirSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { ENV_VAR, getKey, hydrateEnv, loadDotEnvFiles } from "./index.js"; +import { ENV_VAR, getKey, hydrateEnv, isDotEnvKeyAllowed, loadDotEnvFiles } from "./index.js"; -const KEYS = ["XAI_API_KEY", "GROK_API_KEY", "MODEL_API_KEY", "MUSE_API_KEY", "OPENAI_API_KEY"] as const; +const KEYS = [ + "XAI_API_KEY", + "GROK_API_KEY", + "MODEL_API_KEY", + "MUSE_API_KEY", + "OPENAI_API_KEY", + "NODE_OPTIONS", + "HTTPS_PROXY", + "POLYCODE_AUTH_TOKEN", +] as const; let snapshot: Record = {}; beforeEach(() => { snapshot = {}; @@ -42,6 +51,38 @@ describe("loadDotEnvFiles", () => { expect(process.env.OPENAI_API_KEY).toBe("keep-me"); rmSync(dir, { recursive: true, force: true }); }); + + it("refuses NODE_OPTIONS, proxies, and hosted auth from a project .env", () => { + const dir = join(tmpdir(), `poly-dotenv-unsafe-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, ".env"), + [ + "NODE_OPTIONS=--require ./pwn.js", + "HTTPS_PROXY=http://evil.test:8080", + "POLYCODE_AUTH_TOKEN=hosted-secret", + "XAI_API_KEY=xai-ok", + "PATH=/tmp/evil", + ].join("\n"), + ); + loadDotEnvFiles([join(dir, ".env")]); + expect(process.env.NODE_OPTIONS).toBeUndefined(); + expect(process.env.HTTPS_PROXY).toBeUndefined(); + expect(process.env.POLYCODE_AUTH_TOKEN).toBeUndefined(); + expect(process.env.XAI_API_KEY).toBe("xai-ok"); + expect(process.env.PATH).not.toBe("/tmp/evil"); + rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe("isDotEnvKeyAllowed", () => { + it("allows provider keys and rejects loader injection", () => { + expect(isDotEnvKeyAllowed("XAI_API_KEY")).toBe(true); + expect(isDotEnvKeyAllowed("GROK_API_KEY")).toBe(true); + expect(isDotEnvKeyAllowed("NODE_OPTIONS")).toBe(false); + expect(isDotEnvKeyAllowed("HTTPS_PROXY")).toBe(false); + expect(isDotEnvKeyAllowed("POLYCODE_AUTH_TOKEN")).toBe(false); + }); }); describe("hydrateEnv aliases", () => { diff --git a/packages/secrets/src/index.ts b/packages/secrets/src/index.ts index abdc067..aa20512 100644 --- a/packages/secrets/src/index.ts +++ b/packages/secrets/src/index.ts @@ -1,7 +1,15 @@ import { createRequire } from "node:module"; -import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync } from "node:fs"; +import { + mkdirSync, + readFileSync, + writeFileSync, + chmodSync, + existsSync, + lstatSync, + realpathSync, +} from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; export type ProviderId = | "openai" @@ -40,6 +48,47 @@ export const ENV_ALIASES: Partial> = { qwen: ["QWEN_API_KEY"], }; +/** Names a project `.env` is allowed to inject. Provider keys only. */ +export function dotenvAllowlist(): Set { + const s = new Set(Object.values(ENV_VAR)); + for (const aliases of Object.values(ENV_ALIASES)) { + for (const a of aliases ?? []) s.add(a); + } + return s; +} + +export function isDotEnvKeyAllowed(name: string): boolean { + if (!dotenvAllowlist().has(name)) return false; + const n = name.toUpperCase(); + if (n.startsWith("POLYCODE_AUTH_TOKEN")) return false; + if ( + n === "NODE_OPTIONS" || + n === "NODE_PATH" || + n === "NODE_EXTRA_CA_CERTS" || + n === "PATH" || + n.startsWith("LD_") || + n.startsWith("DYLD_") || + n.startsWith("PYTHON") || + n === "HTTP_PROXY" || + n === "HTTPS_PROXY" || + n === "ALL_PROXY" || + n === "NO_PROXY" + ) { + return false; + } + return true; +} + +function resolvedInside(file: string, root: string): boolean { + try { + const a = realpathSync(root).replace(/\\/g, "/").toLowerCase(); + const b = realpathSync(file).replace(/\\/g, "/").toLowerCase(); + return b === a || b.startsWith(a.endsWith("/") ? a : `${a}/`); + } catch { + return false; + } +} + const LABELS: Record = { openai: "OpenAI", google: "Google Gemini", @@ -234,6 +283,13 @@ export function loadDotEnvFiles(files: string[]): string[] { const loaded: string[] = []; for (const f of files) { if (!existsSync(f)) continue; + try { + const st = lstatSync(f); + if (st.isDirectory() || st.isSocket?.() || st.isFIFO?.()) continue; + } catch { + continue; + } + if (!resolvedInside(f, dirname(f))) continue; let text: string; try { text = readFileSync(f, "utf8"); @@ -246,6 +302,7 @@ export function loadDotEnvFiles(files: string[]): string[] { if (!t || t.startsWith("#")) continue; const m = t.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/); if (!m) continue; + if (!isDotEnvKeyAllowed(m[1])) continue; let val = m[2].trim(); if ( (val.startsWith('"') && val.endsWith('"')) || diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index b97d952..1b6891d 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -15,6 +15,7 @@ import { compactCostUsd, parseLoopCommand, formatLoopInterval, + nextLoopId, type CanonicalMessage, collectGitDiff, parseReviewVerdict, @@ -177,7 +178,6 @@ export function App({ const [input, setInput] = useState(""); const [busy, setBusy] = useState(false); const busyRef = useRef(false); - busyRef.current = busy; const demoteRef = useRef(false); const [elapsed, setElapsed] = useState(0); const [verb, setVerb] = useState(WORK_VERBS[0]); @@ -185,7 +185,7 @@ export function App({ const [showSettings, setShowSettings] = useState(false); const [showHelp, setShowHelp] = useState(false); const [showDashboard, setShowDashboard] = useState(false); - const [dashTick, setDashTick] = useState(0); + const [dashRuns, setDashRuns] = useState([]); const [exitHint, setExitHint] = useState(false); const [modelLabel, setModelLabel] = useState(`${provider.id}:${provider.model}`); const [mode, setMode] = useState("ask"); @@ -333,6 +333,21 @@ export function App({ const lastCtrlC = useRef(0); const booted = useRef(false); const loopsRef = useRef; fires: number }>>([]); + const loopSeqRef = useRef(0); + + const acquireBusy = (): boolean => { + if (busyRef.current) return false; + busyRef.current = true; + setBusy(true); + return true; + }; + const releaseBusy = () => { + busyRef.current = false; + setBusy(false); + }; + const refreshDash = useCallback(() => { + setDashRuns(agentRef.current.listChildren()); + }, []); // elapsed-time ticker while busy useEffect(() => { @@ -434,13 +449,15 @@ export function App({ useInput( (ch, key) => { - if (key.ctrl && (ch === "\\" || ch === "|")) setShowDashboard((v) => !v); + if (key.ctrl && (ch === "\\" || ch === "|")) { + setDashRuns(agentRef.current.listChildren()); + setShowDashboard((v) => !v); + } }, { isActive: !perm && !showSettings }, ); const drive = async (signal: AbortSignal) => { - setBusy(true); const paint = createPaintBuffer((chunk) => appendAssistant(chunk), 100); try { for await (const ev of agentRef.current.run(signal)) { @@ -492,7 +509,6 @@ export function App({ paint.flush(); const demoted = demoteRef.current; demoteRef.current = false; - setBusy(false); commit(); const history = agentRef.current.history() as CanonicalMessage[]; if ((!signal.aborted || demoted) && isResumable(history)) persist(); @@ -506,29 +522,33 @@ export function App({ }; const kickTurn = async (text: string, loopId?: string) => { - if (busyRef.current) return; - const shown = loopId ? `[${loopId}] ${text}` : text; - add({ kind: "user", text: shown }); - if (autoRoute && route) { - try { - const r = await route(text); - switchTo(r.provider, r.label); - add({ kind: "system", text: `routed → ${r.tier} (${r.label})` }); - } catch (e) { - add({ kind: "error", text: `route failed: ${String(e)}` }); + if (!acquireBusy()) return; + try { + const shown = loopId ? `[${loopId}] ${text}` : text; + add({ kind: "user", text: shown }); + if (autoRoute && route) { + try { + const r = await route(text); + switchTo(r.provider, r.label); + add({ kind: "system", text: `routed → ${r.tier} (${r.label})` }); + } catch (e) { + add({ kind: "error", text: `route failed: ${String(e)}` }); + } } + commit(); + setVerb(WORK_VERBS[Math.floor(Math.random() * WORK_VERBS.length)]); + const expanded = await expandUserMessage(text, sandbox); + const blocked = await agentRef.current.submitPrompt(expanded.text, expanded.extras); + if (blocked.blocked) { + add({ kind: "error", text: `prompt blocked: ${blocked.reason}` }); + return; + } + const controller = new AbortController(); + abortRef.current = controller; + await drive(controller.signal); + } finally { + releaseBusy(); } - commit(); - setVerb(WORK_VERBS[Math.floor(Math.random() * WORK_VERBS.length)]); - const expanded = await expandUserMessage(text, sandbox); - const blocked = await agentRef.current.submitPrompt(expanded.text, expanded.extras); - if (blocked.blocked) { - add({ kind: "error", text: `prompt blocked: ${blocked.reason}` }); - return; - } - const controller = new AbortController(); - abortRef.current = controller; - await drive(controller.signal); }; const kickTurnRef = useRef(kickTurn); kickTurnRef.current = kickTurn; @@ -543,7 +563,7 @@ export function App({ await agentRef.current.endSession(); return exit(); } - if (busy) return; // a turn is streaming — ignore other submits (esc to interrupt) + if (busyRef.current) return; // a turn is streaming — ignore other submits (esc to interrupt) if (v && historyRef.current[historyRef.current.length - 1] !== v) historyRef.current.push(v); histIdx.current = -1; if (v === "/help" || v === "/") { @@ -551,6 +571,7 @@ export function App({ return; } if (v === "/dashboard" || v === "/agents") { + setDashRuns(agentRef.current.listChildren()); setShowDashboard(true); return; } @@ -602,7 +623,7 @@ export function App({ add({ kind: "error", text: "too many loops (max 4) · /loop stop" }); return; } - const id = `l${loopsRef.current.length + 1}`; + const id = nextLoopId(loopSeqRef); const timer = setInterval(() => { const job = loopsRef.current.find((j) => j.id === id); if (!job || busyRef.current) return; @@ -658,26 +679,31 @@ export function App({ return; } if (v === "/review" || v === "/cranky") { - const diff = await collectGitDiff(sandbox); - if (!diff) { - add({ kind: "system", text: "cranky: no local git changes to review" }); - return; + if (!acquireBusy()) return; + try { + const diff = await collectGitDiff(sandbox); + if (!diff) { + add({ kind: "system", text: "cranky: no local git changes to review" }); + return; + } + const path = `.polycode/reviews/${new Date().toISOString().replace(/[:.]/g, "-")}.md`; + add({ kind: "system", text: `cranky review → ${path}` }); + const result = await agentRef.current.spawnChild({ + description: "cranky review", + subagent_type: "review", + prompt: `Review the git diff below. Write the full review markdown to ${path}.\n\n${diff}`, + }); + const vrd = parseReviewVerdict(result.output); + add({ + kind: result.isError ? "error" : "system", + text: result.isError + ? result.output + : `cranky ${vrd.verdict} · ${vrd.bugs} bugs, ${vrd.suggestions} suggestions, ${vrd.nits} nits`, + }); + persist(); + } finally { + releaseBusy(); } - const path = `.polycode/reviews/${new Date().toISOString().replace(/[:.]/g, "-")}.md`; - add({ kind: "system", text: `cranky review → ${path}` }); - const result = await agentRef.current.spawnChild({ - description: "cranky review", - subagent_type: "review", - prompt: `Review the git diff below. Write the full review markdown to ${path}.\n\n${diff}`, - }); - const vrd = parseReviewVerdict(result.output); - add({ - kind: result.isError ? "error" : "system", - text: result.isError - ? result.output - : `cranky ${vrd.verdict} · ${vrd.bugs} bugs, ${vrd.suggestions} suggestions, ${vrd.nits} nits`, - }); - persist(); return; } if (v === "/explore" || v.startsWith("/explore ")) { @@ -686,13 +712,18 @@ export function App({ add({ kind: "system", text: "usage: /explore " }); return; } - add({ kind: "system", text: `explore: ${q}` }); - const result = await agentRef.current.spawnChild({ - description: "explore", - subagent_type: "explore", - prompt: q, - }); - add({ kind: result.isError ? "error" : "assistant", text: result.output }); + if (!acquireBusy()) return; + try { + add({ kind: "system", text: `explore: ${q}` }); + const result = await agentRef.current.spawnChild({ + description: "explore", + subagent_type: "explore", + prompt: q, + }); + add({ kind: result.isError ? "error" : "assistant", text: result.output }); + } finally { + releaseBusy(); + } return; } if (v === "/hooks") { @@ -781,6 +812,7 @@ export function App({ .replace(/^-|-$/g, "") .slice(0, 48) || name; add({ kind: "system", text: `workflow ${wf.name}: ${q || "(no query)"}` }); + if (!acquireBusy()) return; try { const host = hostFromSpawn((job) => agentRef.current.spawnChild(job)); const { synthesis, parts } = await runWorkflowFile(host, wf, vars); @@ -788,6 +820,8 @@ export function App({ add({ kind: synthesis?.isError ? "error" : "assistant", text }); } catch (e) { add({ kind: "error", text: String(e) }); + } finally { + releaseBusy(); } persist(); return; @@ -861,6 +895,7 @@ export function App({ .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") .slice(0, 48) || "task"; + if (!acquireBusy()) return; try { const host = hostFromSpawn((job) => agentRef.current.spawnChild(job)); const { synthesis, parts } = await runWorkflowFile(host, teamWorkflow(), { query: q, slug }); @@ -869,6 +904,8 @@ export function App({ add({ kind: synthesis?.isError ? "error" : "assistant", text }); } catch (e) { add({ kind: "error", text: String(e) }); + } finally { + releaseBusy(); } persist(); return; @@ -881,12 +918,15 @@ export function App({ } add({ kind: "system", text: `deep-research: ${q}` }); const slug = q.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48) || "query"; + if (!acquireBusy()) return; try { const host = hostFromSpawn((job) => agentRef.current.spawnChild(job)); const { synthesis } = await runWorkflowFile(host, deepResearchWorkflow(), { query: q, slug }); add({ kind: synthesis?.isError ? "error" : "assistant", text: synthesis?.output ?? "(no synthesis)" }); } catch (e) { add({ kind: "error", text: String(e) }); + } finally { + releaseBusy(); } persist(); return; @@ -899,17 +939,22 @@ export function App({ add({ kind: "error", text: `skill ${skillHit.name} failed to expand` }); return; } - add({ kind: "user", text: v }); - commit(); - setVerb(WORK_VERBS[Math.floor(Math.random() * WORK_VERBS.length)]); - const blocked = await agentRef.current.submitPrompt(body); - if (blocked.blocked) { - add({ kind: "error", text: `prompt blocked: ${blocked.reason}` }); - return; + if (!acquireBusy()) return; + try { + add({ kind: "user", text: v }); + commit(); + setVerb(WORK_VERBS[Math.floor(Math.random() * WORK_VERBS.length)]); + const blocked = await agentRef.current.submitPrompt(body); + if (blocked.blocked) { + add({ kind: "error", text: `prompt blocked: ${blocked.reason}` }); + return; + } + const controller = new AbortController(); + abortRef.current = controller; + await drive(controller.signal); + } finally { + releaseBusy(); } - const controller = new AbortController(); - abortRef.current = controller; - await drive(controller.signal); return; } if (v === "/cost") { @@ -958,6 +1003,7 @@ export function App({ } const [imgPath, ...capParts] = rest.split(/\s+/); const caption = capParts.join(" ").trim() || `See attached image (${imgPath}).`; + if (!acquireBusy()) return; try { const image = await loadImagePart(imgPath, sandbox); add({ kind: "user", text: `${caption} [image ${image.path}]` }); @@ -977,6 +1023,8 @@ export function App({ await drive(controller.signal); } catch (e) { add({ kind: "error", text: String(e) }); + } finally { + releaseBusy(); } return; } @@ -993,20 +1041,21 @@ export function App({ if (v === "/deep-research-review" || v.startsWith("/deep-research-review ")) { const target = v.slice("/deep-research-review".length).trim() || "the current design / claims"; add({ kind: "system", text: `deep-research-review: ${target}` }); - const extract = await agentRef.current.spawnChild({ - description: "extract claims", - subagent_type: "explore", - prompt: `Read ${target} if it is a path, else use the user framing. Extract numbered atomic testable claims (C1, C2, …). Return at most 8.`, - }); - const review = await agentRef.current.spawnChild({ - description: "refute claims", - subagent_type: "researcher", - prompt: `For each claim below, try to REFUTE it with web_search + web_fetch primaries. Verdict holds | holds-with-caveat | fails | unknown. Cite URLs.\n\n${extract.output}`, - }); - const path = `.polycode/reviews/research-review-${Date.now()}.md`; - add({ kind: "user", text: v }); - commit(); - { + if (!acquireBusy()) return; + try { + const extract = await agentRef.current.spawnChild({ + description: "extract claims", + subagent_type: "explore", + prompt: `Read ${target} if it is a path, else use the user framing. Extract numbered atomic testable claims (C1, C2, …). Return at most 8.`, + }); + const review = await agentRef.current.spawnChild({ + description: "refute claims", + subagent_type: "researcher", + prompt: `For each claim below, try to REFUTE it with web_search + web_fetch primaries. Verdict holds | holds-with-caveat | fails | unknown. Cite URLs.\n\n${extract.output}`, + }); + const path = `.polycode/reviews/research-review-${Date.now()}.md`; + add({ kind: "user", text: v }); + commit(); const blocked = await agentRef.current.submitPrompt( `Write ANNOTATED bibliography + fidelity scorecard to ${path} from these notes. Lead with what died vs survived.\n\n## Claims\n${extract.output}\n\n## Research\n${review.output}`, ); @@ -1014,10 +1063,12 @@ export function App({ add({ kind: "error", text: `prompt blocked: ${blocked.reason}` }); return; } + const controller = new AbortController(); + abortRef.current = controller; + await drive(controller.signal); + } finally { + releaseBusy(); } - const controller = new AbortController(); - abortRef.current = controller; - await drive(controller.signal); return; } if (v === "/context") { @@ -1167,14 +1218,13 @@ export function App({ setShowHelp(false)} /> ) : showDashboard ? ( setDashTick((n) => n + 1)} + onRefresh={refreshDash} onKill={(id) => { const r = agentRef.current.killChild(id); add({ kind: "system", text: r.output }); - setDashTick((n) => n + 1); + refreshDash(); }} onPeek={(id) => agentRef.current.peekChild(id)} onAttach={(id) => { diff --git a/packages/tui/src/dashboard.tsx b/packages/tui/src/dashboard.tsx index bfaad83..1eb9c61 100644 --- a/packages/tui/src/dashboard.tsx +++ b/packages/tui/src/dashboard.tsx @@ -29,6 +29,10 @@ export function Dashboard({ const n = runs.length; const peek = peekId ? onPeek(peekId) : null; + useEffect(() => { + if (n === 0) setSel(0); + else if (sel >= n) setSel(n - 1); + }, [n, sel]); useInput((ch, key) => { if (peekId) { diff --git a/scripts/beta-log.ts b/scripts/beta-log.ts new file mode 100644 index 0000000..438a550 --- /dev/null +++ b/scripts/beta-log.ts @@ -0,0 +1,175 @@ +/** + * Local beta / functionality log. Writes under `.polycode/beta-logs/` (gitignored). + * + * corepack pnpm beta + * corepack pnpm beta -- --tester alex --smoke xai:grok-4.3 + * + * Never prints API keys. Safe to share the log file with a buddy. + */ +import { execSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { loadDotEnvFiles, envFileCandidates, hydrateEnv, getKey, PROVIDERS } from "@polycode/secrets"; + +const repo = join(import.meta.dirname, ".."); +process.chdir(repo); +loadDotEnvFiles(envFileCandidates(repo)); +hydrateEnv(); + +function arg(flag: string): string | undefined { + const i = process.argv.indexOf(flag); + if (i < 0) return undefined; + return process.argv[i + 1]; +} +function has(flag: string): boolean { + return process.argv.includes(flag); +} + +function git(cmd: string): string { + try { + return execSync(`git ${cmd}`, { encoding: "utf8", cwd: repo }).trim(); + } catch { + return "(unknown)"; + } +} + +function run(label: string, cmd: string): { ok: boolean; output: string; ms: number } { + const t0 = Date.now(); + try { + const output = execSync(cmd, { + encoding: "utf8", + cwd: repo, + maxBuffer: 4_000_000, + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + }); + return { ok: true, output, ms: Date.now() - t0 }; + } catch (e: unknown) { + const err = e as { stdout?: string; stderr?: string; message?: string }; + return { + ok: false, + output: `${err.stdout ?? ""}${err.stderr ?? err.message ?? String(e)}`, + ms: Date.now() - t0, + }; + } +} + +function tail(s: string, lines = 40): string { + const rows = s.replace(/\r\n/g, "\n").trim().split("\n"); + return rows.slice(-lines).join("\n"); +} + +function configuredProviders(): string[] { + return PROVIDERS.filter((p) => !!getKey(p)); +} + +const CHECKLIST = ` +## Manual TUI (buddy) + +Copy this file, mark each item pass/fail, add notes. Do not paste API keys. + +- [ ] Install: \`corepack pnpm install\` then \`corepack pnpm dev\` +- [ ] First-run: arrow to **xAI (Grok)**, Enter, paste key, Enter — lands in chat (not stuck on settings) +- [ ] \`/help\` overlay; type \`/mo\` + Tab completes +- [ ] Ask: "what is this repo?" — read/grep tools run, answer cites files +- [ ] \`/mode plan\` then ask to edit a file — writes refused +- [ ] \`/mode ask\` then a small edit — y/a/n prompt works +- [ ] \`/team add a comment to README\` — two explorers, then worktree implement (or a clear skip if not a git repo) +- [ ] \`/dashboard\` (or Ctrl+\\) — list children; Enter peek; \`a\` attach; Esc close +- [ ] Start a long turn, **Ctrl+B** — composer returns, children still in dashboard +- [ ] \`/loop 15s say ping\` — fires; \`/loop stop\` cancels +- [ ] \`/exit\` then \`corepack pnpm dev -- --continue\` — session resumes +- [ ] Unknown \`/hepl\` is **not** sent to the model (suggests /help) + +### Notes + +(what broke, what felt good, OS/terminal: Windows Terminal / macOS Terminal / …) + +`; + +function safeTester(raw: string): string { + const cleaned = raw.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, ""); + return (cleaned || "anonymous").slice(0, 40); +} + +async function main(): Promise { + const tester = safeTester(arg("--tester") ?? process.env.USERNAME ?? process.env.USER ?? "anonymous"); + const skipTests = has("--checklist-only"); + const smokeModel = has("--smoke") ? (arg("--smoke") ?? "xai:grok-4.3") : undefined; + + const dir = join(repo, ".polycode", "beta-logs"); + mkdirSync(dir, { recursive: true }); + const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const file = join(dir, `${stamp}_${tester}.md`); + + const lines: string[] = []; + const push = (s = "") => lines.push(s); + + push(`# polycode beta log`); + push(); + push(`- **When:** ${new Date().toISOString()}`); + push(`- **Tester:** ${tester}`); + push(`- **Host:** ${process.platform} ${process.arch} · Node ${process.version}`); + push(`- **Cwd:** ${repo}`); + push(`- **Commit:** ${git("rev-parse --short HEAD")} (${git("log -1 --format=%s")})`); + push(`- **Branch:** ${git("branch --show-current")}`); + push(`- **Keys configured:** ${configuredProviders().join(", ") || "(none)"}`); + push(); + push(`Logs stay in \`.polycode/beta-logs/\` (gitignored). Safe to send this file — it has no key values.`); + push(); + + const results: Array<{ name: string; ok: boolean; ms: number }> = []; + + if (!skipTests) { + push(`## Automated`); + push(); + const jobs: Array<[string, string]> = [ + ["typecheck", "corepack pnpm typecheck"], + ["unit tests", "corepack pnpm test"], + ]; + if (smokeModel) { + jobs.push(["smoke " + smokeModel, `corepack pnpm smoke -- ${smokeModel}`]); + } + for (const [name, cmd] of jobs) { + process.stderr.write(`beta-log: ${name} …\n`); + const r = run(name, cmd); + results.push({ name, ok: r.ok, ms: r.ms }); + push(`### ${name}: ${r.ok ? "PASS" : "FAIL"} (${(r.ms / 1000).toFixed(1)}s)`); + push(); + push("```"); + push(tail(r.output, 50)); + push("```"); + push(); + } + const failed = results.filter((r) => !r.ok); + push(`**Automated summary:** ${results.filter((r) => r.ok).length}/${results.length} passed${failed.length ? ` · failed: ${failed.map((f) => f.name).join(", ")}` : ""}`); + push(); + } else { + push(`## Automated`); + push(); + push(`(skipped — \`--checklist-only\`)`); + push(); + } + + push(CHECKLIST.trim()); + push(); + push(`---`); + push(`Share this file with Gabriel. Keep secrets out of Notes.`); + + const body = lines.join("\n"); + writeFileSync(file, body, "utf8"); + console.log(`wrote ${file}`); + if (!skipTests) { + writeFileSync(join(dir, "LATEST.md"), body, "utf8"); + console.log(`also ${join(dir, "LATEST.md")} (send this — last automated run)`); + } + if (results.length) { + for (const r of results) console.log(` ${r.ok ? "PASS" : "FAIL"} ${r.name} ${(r.ms / 1000).toFixed(1)}s`); + } + process.exit(results.some((r) => !r.ok) ? 1 : 0); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +});