diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 75c945f..17861a9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,7 +57,7 @@ jobs: run: npx tsc --noEmit - name: Security audit - run: npm audit --audit-level=moderate + run: npx audit-ci --config audit-ci.jsonc # Unit suite (unit tests + snapshot tests + property-based tests) - name: Unit tests diff --git a/.gitignore b/.gitignore index ebc8ac7..50f8fa6 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,12 @@ web_modules/ # TypeScript cache *.tsbuildinfo +# TypeScript compilation output +*.js +*.d.ts +*.d.cts +*.d.mts + # Optional npm cache directory .npm @@ -148,3 +154,10 @@ vite.config.ts.timestamp-* .chunkhound.json .chunkhound/ .mcp.json + +# macOS +.DS_Store + +# PR review artifacts +AGENT_REVIEW.md +HUMAN_REVIEW.md diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..8160734 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npm run typecheck diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..231fa84 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +# TUI Safety + +**Never use `console.debug/warn/error/log`** — writes to stdout/stderr corrupt pi's TUI ANSI rendering. Extension host runs in the same process. + +Use `ctx.ui.notify()` / `setStatus()` / `setWidget()` instead. For diagnostics, remove entirely. diff --git a/audit-ci.jsonc b/audit-ci.jsonc new file mode 100644 index 0000000..fdb63dd --- /dev/null +++ b/audit-ci.jsonc @@ -0,0 +1,19 @@ +{ + "$schema": "https://github.com/IBM/audit-ci/raw/main/docs/schema.json", + "moderate": true, + "allowlist": [ + // Upstream: pi-coding-agent ships a shrinkwrap that pins vulnerable transitive + // deps (ws, protobufjs, undici). The fix must happen in pi-coding-agent itself + // — nothing to do from this extension. + { "GHSA-96hv-2xvq-fx4p": { "active": true, "expiry": "2026-09-01", "notes": "ws <8.21.0 via pi-coding-agent shrinkwrap" } }, + { "GHSA-f38q-mgvj-vph7": { "active": true, "expiry": "2026-09-01", "notes": "protobufjs <=7.6.2 via pi-coding-agent shrinkwrap" } }, + { "GHSA-wcpc-wj8m-hjx6": { "active": true, "expiry": "2026-09-01", "notes": "protobufjs <=7.6.0 via pi-coding-agent shrinkwrap" } }, + { "GHSA-vmh5-mc38-953g|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } }, + { "GHSA-pr7r-676h-xcf6|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } }, + { "GHSA-38rv-x7px-6hhq|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } }, + { "GHSA-p88m-4jfj-68fv|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } }, + { "GHSA-vxpw-j846-p89q|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } } + // Low-severity undici advisories (GHSA-35p6-xmwp-9g52, GHSA-g8m3-5g58-fq7m) are + // not allowlisted — the config gates moderate+ only, so they don't block CI. + ] +} diff --git a/handoff/command.ts b/handoff/command.ts index 314a389..2d06fd4 100644 --- a/handoff/command.ts +++ b/handoff/command.ts @@ -6,6 +6,12 @@ */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { HANDOFF_REQUESTED_STATUS, HANDOFF_REQUIRED_STATUS } from "./copy.js"; +import { isHandoffEligible } from "./eligibility.js"; +import { + READONLY_HANDOFF_EXCEPTION_NOTIFICATION, + buildReadonlyHandoffCommandNotice, +} from "../readonly-copy.js"; import type { AgenticodingState } from "../state.js"; import { STATUS_KEY_HANDOFF } from "../tui.js"; @@ -22,22 +28,42 @@ export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingStat return; } + if (state.handoffCompactionGeneration !== null) { + throw new Error("Handoff compaction already in progress; wait for it to complete before requesting another handoff."); + } + // Invalidate queued work from an earlier request before replacing its intent. + state.handoffGeneration++; + state.pendingHandoff = null; state.pendingRequestedHandoff = { - direction, - enforcementAttempts: 0, toolCalled: false, + resumeReadonlyAfterHandoff: state.readonlyEnabled, + enforcementAttempts: 0, }; + if (ctx.hasUI && state.readonlyEnabled) { + ctx.ui.notify( + READONLY_HANDOFF_EXCEPTION_NOTIFICATION, + "info", + ); + } + // Show live progress indicator in footer if (ctx.hasUI && ctx.ui.theme) { + const status = isHandoffEligible(ctx.getContextUsage()) + ? HANDOFF_REQUIRED_STATUS + : HANDOFF_REQUESTED_STATUS; ctx.ui.setStatus( STATUS_KEY_HANDOFF, - ctx.ui.theme.fg("accent", "\uD83E\uDD1D Handoff in progress"), + ctx.ui.theme.fg("accent", status), ); } + const readonlyNotice = state.readonlyEnabled + ? buildReadonlyHandoffCommandNotice() + : "\n\nA real handoff is required in the current session. Do not continue normal work instead."; + pi.sendUserMessage( - `Handoff direction: ${direction}\n\nPrepare a handoff in the current session. First, save any durable reusable knowledge that aligns with the direction above to the notebook: findings worth keeping, constraints discovered, decisions made, or other grounding future contexts will need. Then draft a concise but sufficiently detailed handoff brief capturing only the remaining situational context: current state, blockers, unresolved questions, failed paths worth avoiding, and next steps. The next context will read the notebook on demand, so do not duplicate notebook content in the brief. Use any structure that makes the next work unambiguous. Reference notebook pages by name when relevant.`, + `Handoff direction: ${direction}\n\nPrepare a handoff in the current session now. First, save any durable reusable knowledge that aligns with the direction above to the notebook: findings worth keeping, constraints discovered, decisions made, or other grounding future contexts will need. Then draft a concise but sufficiently detailed handoff brief capturing only the remaining situational context: current state, blockers, unresolved questions, failed paths worth avoiding, and next steps. The next context will read the notebook on demand, so do not duplicate notebook content in the brief. Use any structure that makes the next work unambiguous. Reference notebook pages by name when relevant.${readonlyNotice}`, ctx.isIdle() ? undefined : { deliverAs: "followUp" }, ); }, diff --git a/handoff/compact.ts b/handoff/compact.ts index fb014f2..a379629 100644 --- a/handoff/compact.ts +++ b/handoff/compact.ts @@ -6,9 +6,8 @@ */ import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent"; +import { buildEnrichedTask } from "./format.js"; import type { AgenticodingState } from "../state.js"; -import { clearActiveNotebookTopic } from "../notebook/topic.js"; -import { STATUS_KEY_HANDOFF } from "../tui.js"; function getImpossibleKeptId(branchEntries: SessionEntry[]): string { const leaf = branchEntries[branchEntries.length - 1]; @@ -16,27 +15,29 @@ function getImpossibleKeptId(branchEntries: SessionEntry[]): string { } export function registerHandoffCompaction(pi: ExtensionAPI, state: AgenticodingState): void { - pi.on("session_before_compact", async (event, ctx: ExtensionContext) => { + pi.on("session_before_compact", async (event, _ctx: ExtensionContext) => { const pending = state.pendingHandoff; - if (!pending) { + if (!pending || pending.generation !== state.handoffGeneration) { return; } state.pendingHandoff = null; - state.pendingRequestedHandoff = null; - clearActiveNotebookTopic(state); - - // Clear the handoff progress indicator now that compaction is consuming it - if (ctx.hasUI) { - ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); - } + // Two-phase clear contract: + // pendingHandoff — cleared here (the compaction hook consumed the queued task) + // pendingRequestedHandoff — kept; cleared later by completeHandoff in tool.ts + // (on success) or preserved for retry (on error). + // Read readonlyEnabled at the cut so the brief reflects a toggle made after + // the handoff tool was called but before Pi consumes the queued task. + const task = buildEnrichedTask(pending.task, { + resumeReadonlyAfterHandoff: state.readonlyEnabled, + }); return { compaction: { - summary: pending.task, + summary: task, firstKeptEntryId: getImpossibleKeptId(event.branchEntries), tokensBefore: event.preparation.tokensBefore, - details: { handoff: true, task: pending.task }, + details: { handoff: true, task }, }, }; }); diff --git a/handoff/copy.ts b/handoff/copy.ts new file mode 100644 index 0000000..cae1b34 --- /dev/null +++ b/handoff/copy.ts @@ -0,0 +1,8 @@ +/** Shared handoff status copy used by command and tool lifecycle paths. */ + +/** Status shown after a handoff is requested but is not yet eligible. */ +export const HANDOFF_REQUESTED_STATUS = "🤝 Handoff requested — waiting for eligible context"; +/** Status shown when a topic boundary requires an eligible handoff. */ +export const HANDOFF_REQUIRED_STATUS = "🤝 Handoff required — ready to compact"; +/** Status shown while compaction is queued or running. */ +export const HANDOFF_IN_PROGRESS_STATUS = "🤝 Handoff in progress"; diff --git a/handoff/eligibility.ts b/handoff/eligibility.ts new file mode 100644 index 0000000..a31ad7c --- /dev/null +++ b/handoff/eligibility.ts @@ -0,0 +1,46 @@ +/** Minimum estimated context size required before a handoff compaction is useful. */ +export const MIN_HANDOFF_TOKENS = 30_000; + +export type HandoffContextUsage = { + tokens?: number | null; + percent?: number | null; + contextWindow?: number | null; +} | null | undefined; + +function isFiniteNonNegative(value: number | null | undefined): value is number { + return value !== null && value !== undefined && Number.isFinite(value) && value >= 0; +} + +/** Return finite non-negative platform percentages, including overflow usage. */ +export function normalizeContextPercent(value: number | null | undefined): number | null { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? value + : null; +} + +/** Estimate session tokens from exact usage or percentage/context-window data. */ +export function estimateHandoffContextTokens(usage: HandoffContextUsage): number | null { + if (!usage) return null; + if (usage.tokens !== null && usage.tokens !== undefined) { + return isFiniteNonNegative(usage.tokens) ? usage.tokens : null; + } + const percent = normalizeContextPercent(usage.percent); + if (percent === null || !isFiniteNonNegative(usage.contextWindow) || usage.contextWindow === 0) { + return null; + } + const estimated = (usage.contextWindow * percent) / 100; + return Number.isFinite(estimated) ? estimated : null; +} + +/** Return whether usage meets the minimum handoff compaction threshold. */ +export function isHandoffEligible(usage: HandoffContextUsage): boolean { + const tokens = estimateHandoffContextTokens(usage); + return tokens !== null && tokens >= MIN_HANDOFF_TOKENS; +} + +/** Format exact or estimated usage for user-facing handoff errors. */ +export function formatHandoffContextUsage(usage: HandoffContextUsage): string { + if (isFiniteNonNegative(usage?.tokens)) return `${usage.tokens} tokens`; + const estimated = estimateHandoffContextTokens(usage); + return `~${estimated === null ? "unknown" : Math.round(estimated)} tokens estimated from context usage`; +} diff --git a/handoff/format.ts b/handoff/format.ts new file mode 100644 index 0000000..2071b3d --- /dev/null +++ b/handoff/format.ts @@ -0,0 +1,39 @@ +/** Build the task text used as the handoff compaction summary. */ + +import { + READONLY_BYPASS_CLEARED, + READONLY_NEXT_CONTEXT_RESUMES, + READONLY_NON_TEMP_MUTATION_SCOPE, +} from "../readonly-copy.js"; + +/** + * Build the enriched task that becomes the compaction summary. + * + * Shape: handoff primer + original task. + */ +export function buildEnrichedTask(task: string, options?: { resumeReadonlyAfterHandoff?: boolean }): string { + const parts: string[] = [ + "## Handoff — Continue Previous Work", + "", + "You are continuing a previous agent's work in a clean context. Use the available knowledge correctly:", + "- Notebook pages hold durable grounding knowledge; fetch them with `notebook_read`", + "- This handoff brief holds the distilled next task and immediate situational context", + "- Use `notebook_index` to scan available pages when needed", + "- Use `spawn` to delegate isolated subtasks to child agents", + "- Build on notebook grounding and this brief rather than reconstructing old context", + ]; + + if (options?.resumeReadonlyAfterHandoff) { + parts.push( + "", + "## Execution Constraints", + "", + `- ${READONLY_NEXT_CONTEXT_RESUMES}`, + `- ${READONLY_BYPASS_CLEARED}`, + `- ${READONLY_NON_TEMP_MUTATION_SCOPE} unless the user changes readonly mode.`, + ); + } + + parts.push("", "## Task", "", task); + return parts.join("\n"); +} diff --git a/handoff/tool.ts b/handoff/tool.ts index 790fa11..b7e3867 100644 --- a/handoff/tool.ts +++ b/handoff/tool.ts @@ -9,33 +9,128 @@ * remain durable grounding fetched on demand in the next context. */ -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; +import { clearActiveNotebookTopic } from "../notebook/topic.js"; +import { + HANDOFF_IN_PROGRESS_STATUS, + HANDOFF_REQUESTED_STATUS, + HANDOFF_REQUIRED_STATUS, +} from "./copy.js"; +import { buildEnrichedTask } from "./format.js"; +import { + MIN_HANDOFF_TOKENS, + estimateHandoffContextTokens, + formatHandoffContextUsage, + isHandoffEligible, + normalizeContextPercent, +} from "./eligibility.js"; import type { AgenticodingState } from "../state.js"; import { STATUS_KEY_HANDOFF } from "../tui.js"; -/** - * Build the enriched task that becomes the compaction summary. - * - * Shape: handoff primer + original task. - */ -function buildEnrichedTask(task: string): string { - const parts: string[] = [ - "## Handoff — Continue Previous Work", - "", - "You are continuing a previous agent's work in a clean context. Use the available knowledge correctly:", - "- Notebook pages hold durable grounding knowledge; fetch them with `notebook_read`", - "- This handoff brief holds the distilled next task and immediate situational context", - "- Use `notebook_index` to scan available pages when needed", - "- Use `spawn` to delegate isolated subtasks to child agents", - "- Build on notebook grounding and this brief rather than reconstructing old context", - "", - "## Task", - "", - task, - ]; - - return parts.join("\n"); +function validateHandoffTask(task: string, ctx: ExtensionContext): void { + const trimmed = task.trim(); + if (!trimmed) { + const pct = normalizeContextPercent(ctx.getContextUsage()?.percent); + throw new Error( + `Context at ${pct === null ? "?" : Math.round(pct) + "%"}. Empty handoff rejected. Save findings to notebook, then draft a substantive brief.`, + ); + } + + const usage = ctx.getContextUsage(); + const approximateTokens = estimateHandoffContextTokens(usage); + if (approximateTokens === null) { + throw new Error( + "Context usage unavailable; handoff rejected. Continue working and retry.", + ); + } + if (approximateTokens < MIN_HANDOFF_TOKENS) { + const tokenLabel = formatHandoffContextUsage(usage); + const percent = normalizeContextPercent(usage?.percent); + const pctLabel = percent === null ? "?" : `~${Math.round(percent)}%`; + throw new Error( + `Context at ${pctLabel} (${tokenLabel}); handoff unavailable yet. Continue working and retry.`, + ); + } +} + +function completeHandoff(pi: ExtensionAPI, state: AgenticodingState, ctx: ExtensionContext): void { + // Finalize the two-phase clear: pendingHandoff was already cleared by compact.ts; + // this is the sole path that clears pendingRequestedHandoff after successful compaction. + state.pendingHandoff = null; + clearActiveNotebookTopic(state); + state.pendingRequestedHandoff = null; + if (ctx.hasUI) { + ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); + ctx.ui.notify("Handoff complete. Fresh context will resume with the queued brief.", "info"); + } + pi.sendUserMessage("Proceed."); +} + +function notifyHandoffFailure(ctx: ExtensionContext, error: Error, pendingRequest: AgenticodingState["pendingRequestedHandoff"]): void { + if (!ctx.hasUI) return; + if (pendingRequest && ctx.ui.theme) { + const status = isHandoffEligible(ctx.getContextUsage()) + ? HANDOFF_REQUIRED_STATUS + : HANDOFF_REQUESTED_STATUS; + ctx.ui.setStatus(STATUS_KEY_HANDOFF, ctx.ui.theme.fg("accent", status)); + } else { + ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); + } + ctx.ui.notify(`Handoff compaction failed: ${error.message}. The handoff can be retried.`, "error"); +} + +function sendHandoffFailure(pi: ExtensionAPI, error: Error, pendingRequest: AgenticodingState["pendingRequestedHandoff"]): void { + const nextStep = pendingRequest + ? "The required handoff remains pending; retry when context usage is eligible. " + : "No required handoff remains pending; retry when ready. "; + pi.sendUserMessage(`Handoff failed — ${error.message}. ${nextStep.trim()}`); +} + +function failHandoff( + pi: ExtensionAPI, + state: AgenticodingState, + ctx: ExtensionContext, + rawError: unknown, +): void { + const error = rawError instanceof Error ? rawError : new Error(String(rawError)); + state.pendingHandoff = null; + const pendingRequest = state.pendingRequestedHandoff; + if (pendingRequest) pendingRequest.toolCalled = false; + notifyHandoffFailure(ctx, error, pendingRequest); + sendHandoffFailure(pi, error, pendingRequest); +} + +function createHandoffCallbacks( + pi: ExtensionAPI, + state: AgenticodingState, + ctx: ExtensionContext, + generation: number, +): { onComplete: () => void; onError: (error: unknown) => void } { + let settled = false; + const clearInFlight = () => { + // Pair generation with handoffCompactionGeneration: only clear this + // reservation if it is still the active one. A newer handoff will have + // bumped handoffGeneration and set its own reservation. + if (state.handoffCompactionGeneration !== generation) return; + state.handoffCompactionGeneration = null; + if (state.pendingHandoff?.generation === generation) state.pendingHandoff = null; + }; + const isCurrent = () => state.handoffGeneration === generation; + return { + onComplete: () => { + if (settled) return; + settled = true; + clearInFlight(); + if (isCurrent()) completeHandoff(pi, state, ctx); + }, + onError: (error) => { + if (settled) return; + settled = true; + clearInFlight(); + if (isCurrent()) failHandoff(pi, state, ctx, error); + }, + }; } export function registerHandoffTool( @@ -79,26 +174,39 @@ export function registerHandoffTool( }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const enrichedTask = buildEnrichedTask(params.task); - state.pendingHandoff = { task: enrichedTask, source: "tool" }; - if (state.pendingRequestedHandoff) { - state.pendingRequestedHandoff.toolCalled = true; + if (state.handoffCompactionGeneration !== null) { + throw new Error("Handoff compaction already in progress; retry after it completes."); + } + // validateHandoffTask throws with a user-facing reason. Before the throw + // reaches Pi (which will render a generic tool-error), send the richer + // sendHandoffFailure message so the LLM gets actionable guidance. The + // throw after this ensures Pi's tool-call lifecycle sees the rejection. + try { + validateHandoffTask(params.task, ctx); + } catch (error) { + sendHandoffFailure(pi, error instanceof Error ? error : new Error(String(error)), state.pendingRequestedHandoff); + throw error; + } + const requestedHandoff = state.pendingRequestedHandoff; + const generation = ++state.handoffGeneration; + state.pendingHandoff = { + task: params.task, + source: "tool", + generation, + }; + state.handoffCompactionGeneration = generation; + if (requestedHandoff) requestedHandoff.toolCalled = true; + if (ctx.hasUI && ctx.ui.theme) { + ctx.ui.setStatus(STATUS_KEY_HANDOFF, ctx.ui.theme.fg("accent", HANDOFF_IN_PROGRESS_STATUS)); + } + + const callbacks = createHandoffCallbacks(pi, state, ctx, generation); + try { + ctx.compact(callbacks); + } catch (error) { + callbacks.onError(error); + throw error; } - ctx.compact({ - onComplete: () => { - pi.sendUserMessage("Proceed."); - }, - onError: () => { - state.pendingHandoff = null; - // Safe: pendingRequestedHandoff may already be cleaned up by watchdog - if (state.pendingRequestedHandoff) { - state.pendingRequestedHandoff.toolCalled = false; - } - if (ctx.hasUI) { - ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); - } - }, - }); return { content: [{ type: "text", text: "Handoff started." }], diff --git a/index.ts b/index.ts index f6506f0..0b579c8 100644 --- a/index.ts +++ b/index.ts @@ -12,32 +12,179 @@ * - state reset on /new */ -import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; -import { DynamicBorder } from "@earendil-works/pi-coding-agent"; +import type { ExtensionAPI, ExtensionContext, Skill, SlashCommandInfo } from "@earendil-works/pi-coding-agent"; +import { DynamicBorder, isToolCallEventType } from "@earendil-works/pi-coding-agent"; import { Container, type SelectItem, SelectList, Text, } from "@earendil-works/pi-tui"; -import { createState, resetState, type AgenticodingState } from "./state.js"; +import { createState, invalidateHandoffState, resetState, type AgenticodingState } from "./state.js"; import { CONTEXT_PRIMER } from "./system-prompt.js"; import { buildNudge, registerWatchdog } from "./watchdog.js"; import { registerNotebookTools } from "./notebook/tools.js"; import { registerNotebookRehydration } from "./notebook/rehydration.js"; import { registerNotebookTopicTool } from "./notebook/topic-tool.js"; import { setActiveNotebookTopic } from "./notebook/topic.js"; +import { formatPagePreview } from "./notebook/store.js"; import { registerHandoffTool } from "./handoff/tool.js"; +import { + canPromoteBoundary, + discardNonHumanBoundary, + markBoundaryAdvisory, + promoteBoundary, +} from "./readonly-boundary.js"; +import { isHandoffEligible, normalizeContextPercent } from "./handoff/eligibility.js"; +import { getReadonlyFromBranch } from "./readonly-rehydration.js"; +import { HANDOFF_REQUIRED_STATUS } from "./handoff/copy.js"; import { registerHandoffCommand } from "./handoff/command.js"; import { registerHandoffCompaction } from "./handoff/compact.js"; +import { + READONLY_ACTIVE_SUMMARY, + READONLY_COMMAND_DESCRIPTION, + READONLY_DISABLED_NOTIFICATION, + READONLY_DISABLED_SUMMARY, + READONLY_ENABLED_STATUS, + READONLY_HANDOFF_BLOCK_REASON, + READONLY_HANDOFF_EXCEPTION_SUMMARY, + READONLY_PENDING_HANDOFF_READONLY_OFF_NOTIFICATION, + READONLY_PENDING_HANDOFF_READONLY_ON_NOTIFICATION, + READONLY_WRITE_EDIT_BLOCK_REASON, + buildReadonlyDisabledContextSuffix, + buildReadonlyFrontmatterNotification, + buildReadonlyTopicBoundaryNotification, +} from "./readonly-copy.js"; import { registerSpawnTool } from "./spawn/index.js"; +import { + cacheLookupCommand, + cacheLookupCommandIssue, + cacheLookupSkill, + cacheLookupSkillIssue, + formatReadonlyFrontmatterIssue, + populateFromSkills, + populatePromptCacheFromResolvedCommandsAndDirs, + type ReadonlyCacheIssue, +} from "./readonly-cache.js"; import { STATUS_KEY_HANDOFF, + STATUS_KEY_READONLY, STATUS_KEY_TOPIC, WIDGET_KEY_WARNING, updateIndicators, } from "./tui.js"; -import { formatPagePreview } from "./notebook/store.js"; +import { applyReadonlyBashGuard } from "./readonly-bash.js"; +// ── Helpers ──────────────────────────────────────────────────────────── + +/** + * Populate the readonly frontmatter cache from loaded skills and prompt + * commands/directories. Always called before toggle resolution so the cache + * is fresh for the current input. + */ +function populateReadonlyCache( + state: AgenticodingState, + event: { systemPromptOptions?: { skills?: Skill[] } }, + ctx: ExtensionContext, + pi: ExtensionAPI, +): void { + populateFromSkills(state, event.systemPromptOptions?.skills ?? []); + populatePromptCacheFromResolvedCommandsAndDirs(state, pi.getCommands(), ctx.cwd, ctx.isProjectTrusted()); +} + +function isPromptCommand(commands: SlashCommandInfo[], name: string): boolean { + return commands.some((command) => command.name === name && command.source === "prompt"); +} + +const READONLY_BYPASS_COMMANDS = new Set(["readonly", "notebook", "handoff"]); + +function isBuiltinReadonlyBypassCommand(name: string): boolean { + return READONLY_BYPASS_COMMANDS.has(name); +} + +function alignPendingReadonlyHandoff(state: AgenticodingState, readonly: boolean): void { + if (!state.pendingRequestedHandoff) return; + // pendingRequestedHandoff represents a required future handoff, not just a + // momentary bypass. Keep both fields aligned with the latest readonly intent: + // readonly ON => allow exactly one handoff path now and resume readonly after it; + // readonly OFF => remove the bypass flag because handoff is no longer blocked. + state.pendingRequestedHandoff.resumeReadonlyAfterHandoff = readonly; +} + +function formatReadonlyCommandRef(command: { type: "skill" | "command"; name: string }): string { + return command.type === "skill" ? `/skill:${command.name}` : `/${command.name}`; +} + +function recordReadonlyFrontmatterIssue( + ctx: ExtensionContext, + pi: ExtensionAPI, + command: { type: "skill" | "command"; name: string }, + issue: ReadonlyCacheIssue, +): void { + pi.appendEntry("agenticoding-readonly-frontmatter-issue", { name: command.name, type: command.type, issue }); + if (ctx.hasUI) { + ctx.ui.notify(formatReadonlyFrontmatterIssue(formatReadonlyCommandRef(command), issue), "warning"); + } +} + +/** + * Consume any deferred readonly toggle recorded by the `input` handler. + * Must be called after `populateReadonlyCache` so the cache is populated. + */ +function consumePendingReadonlyToggle( + state: AgenticodingState, + ctx: ExtensionContext, + pi: ExtensionAPI, +): void { + const commands = pi.getCommands(); + // Readonly is a TUI-only feature. Headless/RPC sessions must not inherit a + // queued slash-command toggle from some earlier interactive input source, so + // drop any deferred intents here instead of letting them mutate headless runs. + if (!ctx.hasUI) { + state.pendingReadonlyCommands.length = 0; + return; + } + + // Consume unknown/malformed queue entries until the first real readonly + // decision so a stale no-op slash command cannot delay the next valid toggle. + // Prompt commands may exist only on disk in Pi's standard prompt dirs, so + // command lookups trust the populated prompt cache instead of re-gating on + // the live registry here. Known non-prompt commands stay blocked because the + // cache builder marks their names as shadowed and never loads fallback files. + while (state.pendingReadonlyCommands.length > 0) { + const pendingCommand = state.pendingReadonlyCommands.shift(); + if (!pendingCommand) return; + + const readonly = pendingCommand.type === "skill" + ? cacheLookupSkill(state, pendingCommand.name) + : cacheLookupCommand(state, pendingCommand.name); + if (readonly === null) { + const issue = pendingCommand.type === "skill" + ? cacheLookupSkillIssue(state, pendingCommand.name) + : cacheLookupCommandIssue(state, pendingCommand.name); + if (issue) recordReadonlyFrontmatterIssue(ctx, pi, pendingCommand, issue); + continue; + } + + // Keep a queued required handoff aligned with the latest resolved readonly + // intent even when the frontmatter decision is a no-op for current mode. + // Otherwise the eventual handoff brief could resume with stale readonly + // semantics despite the slash command itself producing no visible toggle. + alignPendingReadonlyHandoff(state, readonly); + if (state.readonlyEnabled === readonly) { + return; + } + + state.readonlyEnabled = readonly; + state.readonlyNudgePending = true; + pi.appendEntry("agenticoding-readonly", { enabled: readonly }); + + if (ctx.hasUI) { + const commandRef = formatReadonlyCommandRef(pendingCommand); + ctx.ui.notify(buildReadonlyFrontmatterNotification(readonly, commandRef), "info"); + } + return; + } +} export default function (pi: ExtensionAPI): void { const state: AgenticodingState = createState(); @@ -56,6 +203,147 @@ export default function (pi: ExtensionAPI): void { // ── Register commands ─────────────────────────────────────────── registerHandoffCommand(pi, state); + // ── Readonly mode ─────────────────────────────────────────────── + + pi.registerFlag("readonly", { + description: "Start in readonly mode", + type: "boolean", + default: false, + }); + + function toggleReadonly(ctx: ExtensionContext): void { + if (!ctx.hasUI) return; // Toggle is a UI-only command, no-op in headless. + state.readonlyEnabled = !state.readonlyEnabled; + // A pendingRequestedHandoff is a promise to perform a real handoff later. + // If the user flips readonly before that happens, update the stored + // post-handoff readonly contract immediately so the eventual compacted task + // reflects the newest intent instead of the mode at /handoff time. + if (state.pendingRequestedHandoff) { + alignPendingReadonlyHandoff(state, state.readonlyEnabled); + if (state.readonlyEnabled) { + ctx.ui.notify(READONLY_PENDING_HANDOFF_READONLY_ON_NOTIFICATION, "info"); + } else { + ctx.ui.notify(READONLY_PENDING_HANDOFF_READONLY_OFF_NOTIFICATION, "info"); + } + } + state.readonlyNudgePending = true; + pi.appendEntry("agenticoding-readonly", { enabled: state.readonlyEnabled }); + updateIndicators(ctx, state); + ctx.ui.notify( + state.readonlyEnabled + ? READONLY_ENABLED_STATUS + : READONLY_DISABLED_NOTIFICATION, + "info", + ); + } + + pi.registerCommand("readonly", { + description: READONLY_COMMAND_DESCRIPTION, + handler: async (_args, ctx) => toggleReadonly(ctx), + }); + + pi.registerShortcut("ctrl+shift+r", { + description: "Toggle readonly mode", + handler: async (ctx) => { + if (ctx.isIdle()) toggleReadonly(ctx); + }, + }); + + function rehydrateReadonlyState(ctx: ExtensionContext): void { + const wasEnabled = state.readonlyEnabled; + const branch = ctx.sessionManager?.getBranch?.() ?? []; + state.readonlyEnabled = getReadonlyFromBranch(branch, pi); + // Nudge on any rehydrated readonly authority change. + if (state.readonlyEnabled !== wasEnabled) { + state.readonlyNudgePending = true; + } + } + + // ── Readonly: tool_call blocking ──────────────────────────────── + pi.on("tool_call", async (event, ctx) => { + // ── Readonly mode ─────────────────────────────────────────── + // Guardrail for a coding agent (not a security boundary): + // write/edit stay in the tool list but are blocked at call time. + // handoff is also blocked unless pendingRequestedHandoff has activated a + // narrow temporary bypass for this session's required pivot. That sticky + // state is created by explicit /handoff or by an eligible readonly human + // topic boundary. Keeping tools advertised + // avoids context-cache invalidation from tools disappearing mid-session. + // Children use the opposite approach (remove from tool list entirely) + // because they start with a fresh context — see spawn/index.ts. + if (!state.readonlyEnabled) return; + + if (event.toolName === "write" || event.toolName === "edit") { + return { + block: true as const, + reason: READONLY_WRITE_EDIT_BLOCK_REASON, + }; + } + + if (event.toolName === "handoff" && !state.pendingRequestedHandoff) { + return { + block: true as const, + reason: READONLY_HANDOFF_BLOCK_REASON, + }; + } + + if (isToolCallEventType("bash", event)) { + const result = applyReadonlyBashGuard(event.input.command, ctx.cwd); + if (result.action === "block") { + return { block: true as const, reason: result.reason }; + } + if (result.action === "sandbox") { + // Mutate input.command in-place — SDK has no transform return type. + // Other tool_call hooks will see the sandbox-wrapped command. + event.input.command = result.sandboxedCommand; + } + } + }); + + // ── Readonly: record slash-command intent for deferred toggle ─ + // Input interception runs earlier than the point where Pi has resolved the + // authoritative skill/prompt-command metadata for this turn. Record only the + // slash-command token here, then resolve readonly frontmatter later in + // before_agent_start once the cache and registry view are current. + pi.on("input", async (event, ctx) => { + // Only TUI sessions should enqueue a readonly toggle. Headless/RPC runs + // preserve the existing contract: readonly is a UI-only feature. + // Extension-sourced steer/followUp text must not mutate readonly state. + // Only interactive slash commands have authority to enqueue a toggle. + if (!ctx.hasUI || event.source === "extension") return { action: "continue" }; + + const text = event.text; + // Capture the full first slash-command token and defer authority to the + // resolved registry in before_agent_start. This avoids drifting from Pi's + // naming rules when prompt/skill names include dots or suffixed segments. + const skillName = text.match(/^\/skill:([^\s/]+)/)?.[1]; + if (skillName) { + state.pendingReadonlyCommands.push({ type: "skill", name: skillName }); + return { action: "continue" }; + } + + const commandName = text.match(/^\/([^\s/]+)/)?.[1]; + if (!commandName) return { action: "continue" }; + + // Prefer the live command registry when it's already available, but don't + // rely on it as the sole authority at input time: some runtimes surface + // prompt commands only later in before_agent_start. If the command is + // unknown here, defer it optimistically unless it's one of the builtin + // commands that must never create a stale no-op queue entry. + const commands = pi.getCommands(); + if (isBuiltinReadonlyBypassCommand(commandName)) return { action: "continue" }; + if (isPromptCommand(commands, commandName)) { + state.pendingReadonlyCommands.push({ type: "command", name: commandName }); + return { action: "continue" }; + } + if (commands.some((command) => command.name === commandName)) { + return { action: "continue" }; + } + + state.pendingReadonlyCommands.push({ type: "command", name: commandName }); + return { action: "continue" }; + }); + // ── /notebook command — interactive page selector ──────────────── pi.registerCommand("notebook", { description: "Select a notebook page to preview, or set the active notebook topic with /notebook ", @@ -65,7 +353,9 @@ export default function (pi: ExtensionAPI): void { const result = setActiveNotebookTopic(state, topicArg, "human"); if (ctx.hasUI) { const message = result.boundaryHint - ? `Active notebook topic changed: ${result.boundaryHint.from} → ${result.boundaryHint.to}. This is a likely task boundary; handoff is recommended before continuing.` + ? state.readonlyEnabled + ? buildReadonlyTopicBoundaryNotification(result.boundaryHint.from, result.boundaryHint.to) + : `Active notebook topic changed: ${result.boundaryHint.from} → ${result.boundaryHint.to}. This is a likely task boundary; handoff is recommended before continuing.` : `Active notebook topic: ${result.current}`; ctx.ui.notify(message, result.boundaryHint ? "warning" : "info"); } @@ -158,8 +448,15 @@ export default function (pi: ExtensionAPI): void { }, }); - // ── before_agent_start: inject context primer + notebook ─────── + // ── before_agent_start: populate readonly cache, consume the deferred + // queue until the first real readonly decision, then inject context + // primer + notebook ───────────────────────────────────────────── pi.on("before_agent_start", async (event, ctx: ExtensionContext) => { + if (state.pendingReadonlyCommands.length > 0) { + populateReadonlyCache(state, event, ctx, pi); + } + consumePendingReadonlyToggle(state, ctx, pi); + // Update TUI indicators before each user-prompt agent run updateIndicators(ctx, state); @@ -172,7 +469,7 @@ export default function (pi: ExtensionAPI): void { parts.push( `\n## Active Notebook Topic\n` + `Current topic: \`${state.activeNotebookTopic}\` (${state.activeNotebookTopicSource ?? "unknown"}-set).\n` + - `Treat this as the current semantic frame. If new work fits it, prefer spawn for isolated noisy subtasks. If it does not fit it, prefer handoff over dragging stale context forward.`, + `Treat this as the current semantic frame. If new work fits it, prefer spawn for isolated noisy subtasks. If it does not fit it, prefer handoff.`, ); } else { parts.push( @@ -201,22 +498,111 @@ export default function (pi: ExtensionAPI): void { return { systemPrompt: parts.join("\n\n") }; }); - // ── context: inject primacy-zone nudge before each LLM call ──── + // ── context: inject toggle-on/toggle-off readonly state + watchdog nudge ── + // Readonly visibility comes from two channels: + // 1. toggle-nudge: injected once on mode change (context hook) + // 2. tool-call blocking errors (tool_call handler) + // The watchdog nudge is suppressed while readonly is active unless a + // handoff is pending. pendingRequestedHandoff is created by explicit + // /handoff or by an eligible human topic boundary. Ineligible boundaries + // remain advisory, so the guardrail never mandates an impossible handoff. pi.on("context", async (event, ctx: ExtensionContext) => { const usage = ctx.getContextUsage(); - const percent = usage?.percent ?? null; - if (usage && usage.percent !== null) { - state.lastContextPercent = usage.percent; + const percent = normalizeContextPercent(usage?.percent); + state.lastContextPercent = percent; + + // Build the readonly toggle-nudge (one-shot on mode change) + let readonlyNudgeMsg: { role: string; customType: string; content: string; display: boolean; timestamp: number } | null = null; + if (state.readonlyNudgePending) { + state.readonlyNudgePending = false; + readonlyNudgeMsg = { + role: "custom" as const, + customType: "agenticoding-readonly-nudge", + content: state.readonlyEnabled + ? (state.pendingRequestedHandoff || + (state.pendingTopicBoundaryHint?.source === "human" && isHandoffEligible(usage))) + ? READONLY_HANDOFF_EXCEPTION_SUMMARY + : READONLY_ACTIVE_SUMMARY + : READONLY_DISABLED_SUMMARY + + (percent !== null && percent >= 30 + ? buildReadonlyDisabledContextSuffix(percent) + : ""), + display: false, + timestamp: Date.now(), + }; } - if (!state.pendingTopicBoundaryHint && (percent === null || percent < 30)) { - return; + const appendReadonlyNudge = () => readonlyNudgeMsg + ? { messages: [...event.messages, readonlyNudgeMsg as any] } + : undefined; + + // In readonly mode, an eligible human topic boundary is equivalent to /handoff: + // create the same sticky bypass contract only when compaction can proceed. + // Without an eligible boundary or pending handoff, suppress the watchdog entirely + // — readonly visibility comes from the toggle-nudge and tool-call blocking + // errors, not from repeated advisory watchdog text. + let retainIneligibleHumanBoundary = false; + if (state.readonlyEnabled && !state.pendingRequestedHandoff) { + if (discardNonHumanBoundary(state)) { + state.lastWatchdogBand = null; + return appendReadonlyNudge(); + } + if (state.pendingTopicBoundaryHint) { + if (canPromoteBoundary(state, usage)) { + promoteBoundary(state, ctx); + } else if (markBoundaryAdvisory(state)) { + retainIneligibleHumanBoundary = true; + } else { + // Already advised; boundary guidance stays advisory until eligible. + return appendReadonlyNudge(); + } + } else { + // Readonly active, no boundary hint, no pending handoff — suppress watchdog. + state.lastWatchdogBand = null; + return appendReadonlyNudge(); + } + } + + const mustEnforceRequestedHandoff = state.pendingRequestedHandoff !== null; + if ( + state.pendingRequestedHandoff && + !state.pendingRequestedHandoff.toolCalled && + isHandoffEligible(usage) && + ctx.hasUI && + ctx.ui.theme + ) { + ctx.ui.setStatus( + STATUS_KEY_HANDOFF, + ctx.ui.theme.fg("accent", HANDOFF_REQUIRED_STATUS), + ); + } + + // Below primacy-zone threshold (~30%), skip watchdog unless a boundary + // hint or a sticky user-requested handoff is pending — context is still + // fresh enough that ordinary nudges add noise. + // HACK: `as any` required because readonlyNudgeMsg has customType field not in AgentMessage. + // Proper fix: augment CustomAgentMessages via module augmentation on @earendil-works/pi-agent-core. + if (!mustEnforceRequestedHandoff && !state.pendingTopicBoundaryHint && (percent === null || percent < 30)) { + state.lastWatchdogBand = null; + return appendReadonlyNudge(); + } + + // Throttle: only nudge when crossing into a higher context-percentage band. + // Bands: null (<30), 0 (30-49), 1 (50-69), 2 (70+). This prevents nudging + // every turn once past 30%. + if (!mustEnforceRequestedHandoff && !state.pendingTopicBoundaryHint) { + const band = percent! < 50 ? 0 : percent! < 70 ? 1 : 2; + if (state.lastWatchdogBand !== null && band <= state.lastWatchdogBand) { + return appendReadonlyNudge(); + } + state.lastWatchdogBand = band; } - const nudge = buildNudge(state, percent); - state.pendingTopicBoundaryHint = null; + const nudge = buildNudge(state, percent, isHandoffEligible(usage)); + if (!retainIneligibleHumanBoundary) state.pendingTopicBoundaryHint = null; return { messages: [ ...event.messages, + ...(readonlyNudgeMsg ? [readonlyNudgeMsg as any] : []), { role: "custom", customType: "agenticoding-watchdog", @@ -228,7 +614,7 @@ export default function (pi: ExtensionAPI): void { }; }); - // ── session_start: reset state + update indicators ───────────── + // ── session_start: reset state + readonly rehydration + indicators ── pi.on("session_start", async (event, ctx: ExtensionContext) => { if (event.reason === "new") { resetState(state); @@ -236,22 +622,24 @@ export default function (pi: ExtensionAPI): void { if (ctx.hasUI) { ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); ctx.ui.setStatus(STATUS_KEY_TOPIC, undefined); + ctx.ui.setStatus(STATUS_KEY_READONLY, undefined); ctx.ui.setWidget(WIDGET_KEY_WARNING, undefined); } } + rehydrateReadonlyState(ctx); + updateIndicators(ctx, state); + }); + + // ── session_tree: invalidate branch-local handoff work, then rehydrate readonly ── + pi.on("session_tree", async (_event, ctx: ExtensionContext) => { + invalidateHandoffState(state); + if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); + rehydrateReadonlyState(ctx); updateIndicators(ctx, state); }); // ── update TUI indicators after each turn ─────────────────────── pi.on("turn_end", async (_event, ctx: ExtensionContext) => { - // Fallback: clear handoff indicator if the LLM completed a turn - // without calling the handoff tool (ignored the direction) - if (state.pendingRequestedHandoff && !state.pendingRequestedHandoff.toolCalled) { - state.pendingRequestedHandoff = null; - if (ctx.hasUI) { - ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); - } - } updateIndicators(ctx, state); }); } diff --git a/notebook/rehydration.ts b/notebook/rehydration.ts index 91e3cf7..1b2289e 100644 --- a/notebook/rehydration.ts +++ b/notebook/rehydration.ts @@ -42,6 +42,7 @@ export function registerNotebookRehydration( for (let i = branch.length - 1; i >= 0; i--) { const entry = branch[i]; + if (!entry || typeof entry !== "object") continue; if ( entry.type !== "custom" || diff --git a/notebook/topic-tool.ts b/notebook/topic-tool.ts index 2a23177..a407ba9 100644 --- a/notebook/topic-tool.ts +++ b/notebook/topic-tool.ts @@ -17,7 +17,7 @@ export function registerNotebookTopicTool( promptSnippet: "Set the active notebook topic for the current session", promptGuidelines: [ "Use this early in a fresh session when no active notebook topic exists yet.", - "Do not use this to override a human-set topic. If the work no longer fits the current topic, prefer handoff instead.", + "Do not use this to override a human-set topic. If the work no longer fits the current topic, prefer handoff.", ], parameters: Type.Object({ topic: Type.String({ diff --git a/notebook/topic.ts b/notebook/topic.ts index fa357b2..a1513d7 100644 --- a/notebook/topic.ts +++ b/notebook/topic.ts @@ -6,6 +6,8 @@ export interface NotebookTopicBoundaryHint { from: string | null; to: string; source: NotebookTopicSource; + /** Prevent repeated advisory nudges while waiting for handoff eligibility. */ + advisoryDelivered?: boolean; } export function normalizeNotebookTopic(input: string): string { @@ -33,7 +35,7 @@ export function setActiveNotebookTopic( state.activeNotebookTopicSource = source; const boundaryHint = changed && previous !== null - ? { from: previous, to: normalized, source } + ? { from: previous, to: normalized, source, advisoryDelivered: false } : null; state.pendingTopicBoundaryHint = boundaryHint; diff --git a/os-sandbox.ts b/os-sandbox.ts new file mode 100644 index 0000000..c1d3139 --- /dev/null +++ b/os-sandbox.ts @@ -0,0 +1,279 @@ +/** + * OS-level sandboxing for readonly-mode bash commands. + * + * Wraps bash commands to run inside an OS sandbox that denies filesystem + * writes outside the OS temp dir. Uses platform-native sandbox mechanisms: + * macOS → sandbox-exec with Seatbelt profile + * Linux → bubblewrap (bwrap) if available + * Windows → not supported (returns command unchanged, classifyBashCommand applies) + * + * This replaces the best-effort command-pattern matching in classifyBashCommand + * with actual kernel-enforced file-write blocking. + */ + +import { execSync } from "node:child_process"; +import crypto from "node:crypto"; +import os from "node:os"; +import path from "node:path"; + +import { + READONLY_SANDBOX_BLOCK_NOTICE, + buildReadonlySandboxPathError, +} from "./readonly-copy.js"; +import { TEMP_DIR } from "./temp-dir.js"; +import { resolveRealPath } from "./resolve-path.js"; + +// ── Temp dir canonicalization ──────────────────────────────────── + +let _canonicalTempDir: string | undefined; + +/** Get the canonical (symlink-resolved) temp dir path. */ +function getCanonicalTempDir(): string { + if (_canonicalTempDir === undefined) { + _canonicalTempDir = resolveRealPath(TEMP_DIR); + } + return _canonicalTempDir; +} + +// ── Platform detection ─────────────────────────────────────────── + +/** + * Check whether we can use OS-level sandboxing on the current platform. + * Returns true when sandbox-exec is available (macOS) or bwrap is installed (Linux). + */ +export function canUseOsSandbox(): boolean { + const platform = process.platform; + if (platform === "darwin") { + return _hasSandboxExec(); + } + if (platform === "linux") { + return _hasBwrap(); + } + + return false; +} + +let _bwrapResult: boolean | undefined; +let _sandboxExecResult: boolean | undefined; + +function hasCommand(command: string): boolean { + try { + execSync(`command -v ${command}`, { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function _hasBwrap(): boolean { + if (_bwrapResult === undefined) { + if (hasCommand("bwrap")) { + // Quick functional test: can bwrap actually create a namespace? + try { + execSync("bwrap --ro-bind / / true 2>/dev/null", { stdio: "ignore", timeout: 2000 }); + _bwrapResult = true; + } catch (e) { + _bwrapResult = false; + } + } else { + _bwrapResult = false; + } + } + return _bwrapResult; +} + +function _hasSandboxExec(): boolean { + if (_sandboxExecResult === undefined) { + if (hasCommand("sandbox-exec")) { + // Quick functional test: can sandbox-exec actually enforce a profile? + try { + execSync("echo true | sandbox-exec -p '(version 1)(allow default)' /bin/bash 2>/dev/null", + { stdio: "ignore", timeout: 2000 }); + _sandboxExecResult = true; + } catch (e) { + _sandboxExecResult = false; + } + } else { + _sandboxExecResult = false; + } + } + return _sandboxExecResult; +} + +// ── macOS: sandbox-exec ────────────────────────────────────────── + +/** + * Build a Seatbelt sandbox profile string for readonly mode. + * + * Pattern: allow everything by default, but deny all file writes except + * to the canonical temp dir and /dev/null. + * + * Using (allow default) + write denies (permissive pattern) because + * (deny default) + explicit read allows is fragile — system library + * reads, dyld, and process execution are complex to enumerate and + * vary across macOS versions. The permissive pattern keeps standard + * tooling (node, npm, git, python, etc.) working while correctly + * blocking all file writes outside the temp dir. + */ +export function buildMacProfile(tempDir: string): string { + const canon = resolveRealPath(tempDir); + const original = path.resolve(os.tmpdir()); // may have symlinks (e.g., /var -> /private/var) + + // Collect unique paths — both canonical and unresolved (symlink) forms. + // Seatbelt subpath does NOT resolve symlinks, so we must include both. + // Also include /tmp and /private/tmp because bash (on macOS) creates + // heredoc temp files in /tmp regardless of $TMPDIR. + const writePaths = new Set(); + writePaths.add(canon); + if (original !== canon) writePaths.add(original); + writePaths.add("/private/tmp"); + writePaths.add("/tmp"); + + // Two distinct injection risks in the profile string: + // - Single quotes (') break out of the outer shell wrapper: sandbox-exec -p '${profile}' + // - Double quotes (") break Seatbelt (subpath "...") literal syntax + for (const p of writePaths) { + if (p.includes("'") || p.includes('"')) { + throw new Error(buildReadonlySandboxPathError(p)); + } + } + + const parts = [ + "(version 1)", + "(allow default)", + "(deny file-write*)", + '(allow file-write* (literal "/dev/null"))', + ]; + for (const p of writePaths) { + parts.push(`(allow file-write* (subpath "${p}"))`); + } + return parts.join(""); +} + +/** + * Generate a unique heredoc delimiter for wrapping commands. + * Using a random suffix avoids accidental collision with command content. + */ +function generateDelimiter(): string { + const suffix = crypto.randomBytes(4).toString("hex"); + return `PI_SANDBOX_INNER_${suffix}`; +} + +/** Quote a dynamic argument for the outer bash that launches the sandbox. */ +export function quoteShellArgument(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +/** + * Wrap a bash command with sandbox-exec on macOS. + * + * Uses a heredoc to pipe the original command verbatim (with all newlines + * and special characters preserved) to an inner bash running under + * sandbox-exec: + * + * sandbox-exec -p '' /bin/bash << 'DELIM' + * + * DELIM + * + * The outer bash tool calls spawn(shell, ['-c', modifiedCommand]), so: + * /bin/bash -c "sandbox-exec -p '...' /bin/bash << 'DELIM'\n\nDELIM" + * + * The heredoc preserves all original characters (multiline, quotes, pipes, + * redirects) so the inner bash receives the exact original command. + * All descendants inherit the sandbox restrictions. + */ +export function wrapWithSandboxExec(command: string): string { + const profile = buildMacProfile(getCanonicalTempDir()); + const delim = generateDelimiter(); + return `sandbox-exec -p '${profile}' /bin/bash << '${delim}' +output=\$({ +: +${command} +} 2>&1) +rc=\$? +if [ \$rc -ne 0 ]; then + case "\$output" in + *"Operation not permitted"*|*"Permission denied"*|*"denying file-write"*) + echo "" + echo "${READONLY_SANDBOX_BLOCK_NOTICE}" + echo "" + ;; + esac +fi +[ -n "\$output" ] && echo "\$output" +exit \$rc +${delim}`; +} + +// ── Linux: bubblewrap ──────────────────────────────────────────── + +/** + * Wrap a bash command with bubblewrap on Linux. + * + * Uses the same heredoc approach as sandbox-exec for consistent behavior. + * + * --ro-bind / / makes entire root read-only + * --tmpfs /tmp then mounts writable tmpfs at /tmp (overrides ro-bind) + * --bind binds the real temp dir writable into /tmp + * --proc /proc, --dev /dev for proper /proc and /dev + * --unshare-all --share-net for isolation while allowing network + * --die-with-parent --new-session for clean termination + */ +export function wrapWithBwrap(command: string, tempDir: string = getCanonicalTempDir()): string { + const canon = resolveRealPath(tempDir); + const delim = generateDelimiter(); + const flags = [ + "--ro-bind / /", + "--tmpfs /tmp", + `--bind ${quoteShellArgument(canon)} ${quoteShellArgument(canon)}`, + "--proc /proc", + "--dev /dev", + "--unshare-all", + "--share-net", + "--die-with-parent", + "--new-session", + ]; + return `bwrap ${flags.join(" ")} /bin/bash << '${delim}' +output=\$({ +: +${command} +} 2>&1) +rc=\$? +if [ \$rc -ne 0 ]; then + case "\$output" in + *"Operation not permitted"*|*"Permission denied"*|*"denying file-write"*) + echo "" + echo "${READONLY_SANDBOX_BLOCK_NOTICE}" + echo "" + ;; + esac +fi +[ -n "\$output" ] && echo "\$output" +exit \$rc +${delim}`; +} + +// ── Unified dispatch ───────────────────────────────────────────── + +/** + * Wrap a bash command string to run inside an OS-level filesystem sandbox. + * + * On macOS: wraps with sandbox-exec (native, no deps). + * On Linux: wraps with bubblewrap if available. + * On other platforms / when unavailable: returns command unchanged. + * + * The returned command must be passed to /bin/bash -c (or equivalent) for + * execution — the shell tool handles this automatically. + */ +export function wrapCommandWithOsSandbox(command: string): string { + const platform = process.platform; + if (platform === "darwin") { + return wrapWithSandboxExec(command); + } + if (platform === "linux" && _hasBwrap()) { + return wrapWithBwrap(command); + } + // No OS sandbox available — command unchanged, classifyBashCommand + // fallback will handle it at the call site. + return command; +} diff --git a/package-lock.json b/package-lock.json index 944ec5f..62348ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,13 @@ "version": "0.3.0", "license": "MIT", "devDependencies": { - "@earendil-works/pi-ai": "^0.78.1", - "@earendil-works/pi-coding-agent": "^0.78.1", - "@earendil-works/pi-tui": "^0.78.1", + "@earendil-works/pi-ai": "^0.79.6", + "@earendil-works/pi-coding-agent": "^0.79.6", + "@earendil-works/pi-tui": "^0.79.6", + "@types/node": "^25.9.3", + "audit-ci": "^7.1.0", "fast-check": "^4.8.0", + "husky": "^9.1.7", "typebox": "^1.2.2", "typescript": "^6.0.3" }, @@ -20,10 +23,10 @@ "node": ">=22" }, "peerDependencies": { - "@earendil-works/pi-ai": "*", - "@earendil-works/pi-coding-agent": "*", - "@earendil-works/pi-tui": "*", - "typebox": "*" + "@earendil-works/pi-ai": "^0.79.6", + "@earendil-works/pi-coding-agent": "^0.79.6", + "@earendil-works/pi-tui": "^0.79.6", + "typebox": "^1.2.2" } }, "node_modules/@anthropic-ai/sdk": { @@ -535,9 +538,9 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.78.1.tgz", - "integrity": "sha512-CM2pkTs1iupG/maw381lC9Q/Y/aQaMGK7GILc28ttImD0ci3LDwKroDsGkWbly5JIy3iqxdRxB9JlG7vvzCzTg==", + "version": "0.79.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.6.tgz", + "integrity": "sha512-KGepEdgEeWDs7Imwlp96tBsO8TjSIpcDBvazsCDtHRa81+uwJI/YGetTegI52pMlKhVpJFLIGajRi4PCGC5MUg==", "dev": true, "license": "MIT", "dependencies": { @@ -567,16 +570,16 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.78.1.tgz", - "integrity": "sha512-Syjf6Ib8UoY5t9ZdKjp0BRrQZuFkFBc8j2KEU9zG/ZnmYPcAxYeioofdv2Q3MEXnHEX2U8sKQptkSnJIdMsd0g==", + "version": "0.79.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.79.6.tgz", + "integrity": "sha512-xPQDoA3+4Q8UsInST8OGFHPHyi7JQIbx51ku5kf2VuhXrrTv/npjVTXYD3A3D5xs6QRebV0B2mQqHfXaxQAplw==", "dev": true, "hasShrinkwrap": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.78.1", - "@earendil-works/pi-ai": "^0.78.1", - "@earendil-works/pi-tui": "^0.78.1", + "@earendil-works/pi-agent-core": "^0.79.6", + "@earendil-works/pi-ai": "^0.79.6", + "@earendil-works/pi-tui": "^0.79.6", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -588,6 +591,7 @@ "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", + "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.3.0", "yaml": "2.9.0" @@ -1065,12 +1069,12 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.78.1.tgz", + "version": "0.79.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.6.tgz", "dev": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.78.1", + "@earendil-works/pi-ai": "^0.79.6", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -1080,8 +1084,8 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.78.1.tgz", + "version": "0.79.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.6.tgz", "dev": true, "license": "MIT", "dependencies": { @@ -1097,20 +1101,20 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "dist/cli.js" + "pi-ai": "./dist/cli.js" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.78.1.tgz", + "version": "0.79.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.6.tgz", "dev": true, "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", - "marked": "15.0.12" + "marked": "18.0.5" }, "engines": { "node": ">=22.19.0" @@ -2066,16 +2070,16 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", "dev": true, "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { @@ -2322,6 +2326,19 @@ ], "license": "MIT" }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2504,14 +2521,14 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.78.1.tgz", - "integrity": "sha512-07GVQo/38a0yvIPlWDr3RJn1B8gk3ZuIX9h2oIQ+Biyu3JN0KppWmgWHfaWRydQgse5JtC++KDw5MWaIRnV0mw==", + "version": "0.79.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.6.tgz", + "integrity": "sha512-6JCq780X0UuqvJsUDSJmi4V54ObB8qSwFQiBOI1jhPpC+Ydusd8SEXn2HtyIqve/utMgwcZT9aOyZM72m26A0w==", "dev": true, "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", - "marked": "15.0.12" + "marked": "18.0.5" }, "engines": { "node": ">=22.19.0" @@ -2770,9 +2787,9 @@ } }, "node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "dev": true, "license": "MIT", "dependencies": { @@ -2796,6 +2813,56 @@ "node": ">= 14" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/audit-ci": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/audit-ci/-/audit-ci-7.1.0.tgz", + "integrity": "sha512-PjjEejlST57S/aDbeWLic0glJ8CNl/ekY3kfGFPMrPkmuaYaDKcMH0F9x9yS9Vp6URhuefSCubl/G0Y2r6oP0g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.3", + "escape-string-regexp": "^4.0.0", + "event-stream": "4.0.1", + "jju": "^1.4.0", + "jsonstream-next": "^3.0.0", + "readline-transform": "1.0.0", + "semver": "^7.0.0", + "tslib": "^2.0.0", + "yargs": "^17.0.0" + }, + "bin": { + "audit-ci": "dist/bin.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2841,6 +2908,56 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -2869,6 +2986,13 @@ } } }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true, + "license": "MIT" + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -2879,6 +3003,52 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/event-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-4.0.1.tgz", + "integrity": "sha512-qACXdu/9VHPBzcyhdOWR5/IahhGMf0roTeZJfzz077GwylcDd90yOHLouhmv7GJ5XzPi6ekaQWd8AvPP2nOvpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "^0.1.1", + "from": "^0.1.7", + "map-stream": "0.0.7", + "pause-stream": "^0.0.11", + "split": "^1.0.1", + "stream-combiner": "^0.2.2", + "through": "^2.3.8" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -2985,6 +3155,13 @@ "node": ">=12.20.0" } }, + "node_modules/from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", + "dev": true, + "license": "MIT" + }, "node_modules/gaxios": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", @@ -3015,6 +3192,16 @@ "node": ">=18" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -3084,6 +3271,53 @@ "node": ">= 14" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", + "dev": true, + "license": "MIT" + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -3108,6 +3342,33 @@ "node": ">=16" } }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/jsonstream-next": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/jsonstream-next/-/jsonstream-next-3.0.0.tgz", + "integrity": "sha512-aAi6oPhdt7BKyQn1SrIIGZBt0ukKuOUE1qV6kJ3GgioSOYzsRc8z9Hfr1BVmacA/jLe9nARfmgMGgn68BqIAgg==", + "dev": true, + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "jsonparse": "^1.2.0", + "through2": "^4.0.2" + }, + "bin": { + "jsonstream-next": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -3138,17 +3399,24 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/map-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.7.tgz", + "integrity": "sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==", + "dev": true, + "license": "MIT" + }, "node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", "dev": true, "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/ms": { @@ -3257,6 +3525,29 @@ "node": ">=14.0.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "dev": true, + "license": [ + "MIT", + "Apache2" + ], + "dependencies": { + "through": "~2.3" + } + }, "node_modules/protobufjs": { "version": "7.6.1", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", @@ -3299,6 +3590,41 @@ ], "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readline-transform": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/readline-transform/-/readline-transform-1.0.0.tgz", + "integrity": "sha512-7KA6+N9IGat52d83dvxnApAWN+MtVb1MiVuMR/cf1O4kYsJG+g/Aav0AHcHKsb6StinayfPLne0+fMX2sOzAKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -3330,6 +3656,104 @@ ], "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/split": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", + "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/stream-combiner": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz", + "integrity": "sha512-6yHMqgLYDzQDcAkL+tjJDC5nSNuNIx0vZtRZeiPh7Saef7VHX9H5Ijn9l2VIol2zaNYlYEX6KyuT/237A58qEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1", + "through": "~2.3.4" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strnum": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", @@ -3343,6 +3767,23 @@ ], "license": "MIT" }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, "node_modules/ts-algebra": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", @@ -3385,6 +3826,13 @@ "dev": true, "license": "MIT" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -3395,6 +3843,40 @@ "node": ">= 8" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -3433,6 +3915,45 @@ "node": ">=16.0.0" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index 86336d7..298dc53 100644 --- a/package.json +++ b/package.json @@ -15,23 +15,28 @@ "node": ">=22" }, "peerDependencies": { - "@earendil-works/pi-ai": "^0.78.1", - "@earendil-works/pi-coding-agent": "^0.78.1", - "@earendil-works/pi-tui": "^0.78.1", + "@earendil-works/pi-ai": "^0.79.6", + "@earendil-works/pi-coding-agent": "^0.79.6", + "@earendil-works/pi-tui": "^0.79.6", "typebox": "^1.2.2" }, "scripts": { + "prepare": "husky", "test": "node ./scripts/run-node-test.mjs tests/unit/**/*.test.ts", "test:e2e": "node ./scripts/run-node-test.mjs tests/e2e/**/*.test.ts", "test:all": "npm run test && npm run test:e2e", "test:snapshots:check": "node ./scripts/run-node-test.mjs tests/unit/render-snapshots.test.ts", - "test:snapshots:update": "node ./scripts/run-node-test.mjs --update-snapshots tests/unit/render-snapshots.test.ts" + "test:snapshots:update": "node ./scripts/run-node-test.mjs --update-snapshots tests/unit/render-snapshots.test.ts", + "typecheck": "tsc --noEmit" }, "devDependencies": { - "@earendil-works/pi-ai": "^0.78.1", - "@earendil-works/pi-coding-agent": "^0.78.1", - "@earendil-works/pi-tui": "^0.78.1", + "@earendil-works/pi-ai": "^0.79.6", + "@earendil-works/pi-coding-agent": "^0.79.6", + "@earendil-works/pi-tui": "^0.79.6", + "@types/node": "^25.9.3", + "audit-ci": "^7.1.0", "fast-check": "^4.8.0", + "husky": "^9.1.7", "typebox": "^1.2.2", "typescript": "^6.0.3" }, diff --git a/readonly-bash.ts b/readonly-bash.ts new file mode 100644 index 0000000..3d4f2d4 --- /dev/null +++ b/readonly-bash.ts @@ -0,0 +1,1115 @@ +import path from "node:path"; +import os from "node:os"; +import { globSync } from "node:fs"; +import { canUseOsSandbox, wrapCommandWithOsSandbox } from "./os-sandbox.js"; +import { + buildReadonlyBashBlockReason, + buildReadonlyPackageManagerBlockReason, + READONLY_INVALID_BASH_COMMAND_REASON, +} from "./readonly-copy.js"; +import { resolveRealPath } from "./resolve-path.js"; +import { TEMP_DIR } from "./temp-dir.js"; + +/** + * Readonly bash guard. + * + * Contract: block filesystem writes/deletions outside the OS temp dir. + * Non-mutating commands, unknown commands, and environment inheritance are + * allowed. Process-level commands (kill, reboot, shutdown, systemctl, su) + * are not filesystem mutations and are intentionally allowed. + * + * Package-manager mutations (npm install, pip install, etc.) are blocked + * unconditionally regardless of target path — they write outside any single + * directory (node_modules, site-packages, etc.) making temp-dir checking + * meaningless. + * + * This is a best-effort command inspection layer, not a security sandbox. + * + * ## Known L2 limitations (no OS sandbox available) + * + * These bypasses are mitigated by L1 (OS sandbox) on macOS and Linux but + * are effective on Windows or when sandbox tools are missing: + * + * - **Interpreters with programmatic code** — `node -e`, `python3 -c`, etc. + * running code like `require('fs').writeFileSync(...)` are not checked. + * The classifier only parses shell command tokens, not JS/Python/Perl code. + * - **xargs with stdin-fed package managers** — `printf install | xargs npm` + * bypasses because `xargs npm` alone has no verb args. The pipe feeds + * `install` at runtime via stdin; only the OS sandbox blocks the writes. + */ + +type Verdict = + | { ok: true } + | { ok: false; reason: string }; + +// TEMP_DIR is resolved in temp-dir.ts — imported above so both +// readonly-bash and os-sandbox use the same canonical temp dir. + +const GIT_IMMUTABLE = new Set([ + "diff", "log", "show", "status", "blame", "grep", + "ls-files", "ls-tree", "merge-tree", "format-patch", + "rev-parse", "rev-list", "cat-file", "for-each-ref", + "merge-base", "fsck", "range-diff", "shortlog", "name-rev", + "describe", "var", "version", +]); + +const GIT_MUTABLE = new Set([ + "add", "am", "apply", "checkout", "cherry-pick", "clean", + "clone", "commit", "fetch", "init", "merge", "mv", "pull", "push", + "rebase", "reset", "restore", "revert", "rm", "switch", +]); + +const GIT_MIXED: Record boolean> = { + reflog: (sub) => sub === "" || sub === "show" || sub.startsWith("show "), + branch: (sub) => + sub === "" || sub === "-l" || sub === "--show-current" || + /^--?[a-zA-Z-]*list(?:[=\s]|$)/.test(sub), + tag: (sub) => sub === "" || sub === "-l" || /^--?[a-zA-Z-]*list(?:[=\s]|$)/.test(sub), + remote: (sub) => sub === "" || sub === "-v" || sub === "show" || sub === "get-url", + config: (sub) => + sub === "" || sub === "-l" || sub === "--list" || + sub === "--get" || sub.startsWith("--get ") || sub.startsWith("--get="), + notes: (sub) => sub === "list" || sub === "show", + stash: (sub) => sub === "list" || sub === "show", + bisect: (sub) => sub === "log" || sub === "view" || sub === "", + worktree: (sub) => sub === "list", + submodule: (sub) => sub === "status", +}; + +// Interpreters whose inline-execution flag is recursively classified. +// node -c = syntax check only (non-executing); node -e executes code. +const INTERPRETER_EXEC_FLAGS: Record = { + node: ["-e"], + bash: ["-c"], sh: ["-c"], zsh: ["-c"], dash: ["-c"], ksh: ["-c"], + python3: ["-c"], python: ["-c"], + perl: ["-e"], + ruby: ["-e"], +}; + +const INTERPRETERS = new Set(Object.keys(INTERPRETER_EXEC_FLAGS)); + +// Package managers are blocked unconditionally — they mutate system state +// outside any single directory (npm install writes to node_modules, pip +// installs to site-packages, etc.). Temp-dir path checking is not meaningful. +const PACKAGE_MANAGERS = new Set(["npm", "yarn", "pnpm", "pip", "pip3", "pipx", "apt", "apt-get", "brew", "cargo", "gem", "yum", "dnf", "pacman", "choco"]); + + +/** + * Classify a bash command string for readonly mode. + * + * Splits the command into shell-operator-separated segments (&&, ||, ;, |, &, \n), + * checks each segment for command substitutions ($(...), backticks), write redirects (>), + * and filesystem mutations. Blocks if any target path resolves outside the OS temp dir. + * + * When OS-level sandboxing (canUseOsSandbox()) is available, this serves as a fallback — + * the kernel-enforced sandbox enforces the same write-restriction policy. + * + * @param cmd - Raw bash command string (may contain multiple segments via &&, ;, |, etc.) + * @param cwd - Working directory for relative path resolution (defaults to process.cwd()) + * @returns {ok: true} if allowed, or {ok: false, reason} with explanation + */ + +export function classifyBashCommand(cmd: string, cwd: string = process.cwd(), depth: number = 0, shellVars: ReadonlyMap = new Map()): Verdict { + if (depth > 10) return { ok: false, reason: "recursion depth exceeded in command classification" }; + const localVars = new Map(shellVars); + for (const rawSegment of splitUnquotedShellSegments(cmd)) { + const segment = rawSegment.trim(); + if (!segment) continue; + + for (const subcommand of extractCommandSubstitutions(segment)) { + const nested = classifyBashCommand(subcommand, cwd, depth + 1, localVars); + if (!nested.ok) { + return { ok: false, reason: `command substitution blocked: ${nested.reason}` }; + } + } + + const redirectTarget = getUnsafeWriteRedirectTarget(segment, cwd, localVars); + if (redirectTarget) { + return { ok: false, reason: `write redirect blocked outside temp dir: ${redirectTarget}` }; + } + + const mutationReason = getFilesystemMutationReason(segment, cwd, depth, localVars); + if (mutationReason) return { ok: false, reason: mutationReason }; + + for (const [name, value] of getStandaloneShellAssignments(segment, cwd, localVars)) { + localVars.set(name, value); + } + } + + return { ok: true }; +} + +/** + * Classify a shell segment's filesystem mutation risk. + * + * Extracts the command and its targets, then blocks if any target + * resolves outside the OS temp dir. Handles git, sudo, env, eval/exec, + * interpreter inline execution flags (-c/-e), dd of=, sed -i, + * find -exec/-delete, perl/ruby -pi, and package managers. + * Command names are compared case-insensitively (normalized via .toLowerCase()). + * Unknown commands return null (allowed). + */ +// ── Command-specific mutation classifiers (one per command, ≤15 lines each) ── + +/** Strip subshell parens: (rm file) → rm file, then classify recursively. */ +function classifySubshellSegment(segment: string, cwd: string, depth: number, shellVars: ReadonlyMap): string | null { + if (!segment.startsWith("(") || !segment.endsWith(")")) return null; + const inner = segment.slice(1, -1).trim(); + return inner ? getFilesystemMutationReason(inner, cwd, depth, shellVars) : null; +} + +/** eval/exec: recursively classify the remaining argument string. */ +function classifyEvalExec(command: string, tokens: string[], cwd: string, depth: number, shellVars: ReadonlyMap): string | null { + if (command !== "eval" && command !== "exec") return null; + const inner = tokens.slice(1).map(stripMatchingQuotes).join(" "); + const nested = classifyBashCommand(inner, cwd, depth + 1, shellVars); + return nested.ok ? null : nested.reason; +} + +/** sudo: classify the command after sudo flags. */ +function classifySudo(command: string, tokens: string[], cwd: string, depth: number, shellVars: ReadonlyMap): string | null { + if (command !== "sudo") return null; + const nested = classifyBashCommand(tokens.slice(findSudoCommandIndex(tokens)).join(" "), cwd, depth + 1, shellVars); + return nested.ok ? null : nested.reason; +} + +/** env: handle env prefix including -S/--split-string. */ +function classifyEnv(command: string, segment: string, tokens: string[], cwd: string, depth: number, shellVars: ReadonlyMap): string | null { + if (command !== "env") return null; + if (tokens.length > 1) { + const nested = classifyBashCommand(tokens.slice(1).join(" "), cwd, depth + 1, shellVars); + return nested.ok ? null : nested.reason; + } + // env with only flags (e.g., env -S "cmd") — extract -S value + const sMatch = segment.match(/\benv\b.*?(?:-S|--split-string)\s+/); + if (!sMatch) return null; + const afterS = segment.slice(sMatch.index! + sMatch[0].length).trim(); + const nested = classifyBashCommand(stripMatchingQuotes(afterS), cwd, depth + 1, shellVars); + return nested.ok ? null : nested.reason; +} + +/** git: classify subcommand via three-tier allowlist (immutable/mutable/mixed). */ +function classifyGit(command: string, tokens: string[]): string | null { + if (command !== "git") return null; + return isSafeGitCommand(tokens.slice(1).join(" ")) ? null : "mutable git command blocked outside temp dir"; +} + +/** Interpreters with inline-execution flags — classify inline code recursively. */ +function classifyInterpreter(command: string, tokens: string[], cwd: string, depth: number, shellVars: ReadonlyMap): string | null { + if (!INTERPRETERS.has(command)) return null; + const args = tokens.slice(1); + for (const flag of INTERPRETER_EXEC_FLAGS[command]) { + const idx = args.indexOf(flag); + if (idx === -1 || idx + 1 >= args.length) continue; + const nested = classifyBashCommand(stripMatchingQuotes(args[idx + 1]), cwd, depth + 1, shellVars); + if (!nested.ok) return `${command} ${flag} blocked: ${nested.reason}`; + } + return null; +} + +/** dd of= target check. */ +function classifyDdOutput(segment: string, cwd: string, shellVars: ReadonlyMap): string | null { + const ddMatch = segment.match(/\bof=([^\s]+)/); + if (!ddMatch || isTempPath(ddMatch[1], cwd, shellVars)) return null; + return `dd output blocked outside temp dir: ${stripMatchingQuotes(ddMatch[1])}`; +} + +/** wget -P/--download-dir outside temp — block. No-flag case falls through to classifyGenericMutation. */ +function classifyWget(command: string, tokens: string[], cwd: string, shellVars: ReadonlyMap): string | null { + if (command !== "wget") return null; + const wArgs = tokens.slice(1); + const hasOutputFlag = wArgs.some((a) => a === "-O" || a.startsWith("-O") || a === "--output-document" || a.startsWith("--output-document=")); + if (hasOutputFlag) return null; + const outputDir = getWgetOutputDir(tokens); + if (outputDir && !isTempPath(outputDir, cwd, shellVars)) return `wget download dir blocked outside temp dir: ${outputDir}`; + return null; +} + +/** curl -O/--remote-name writes to disk — block unless cwd is temp. */ +function classifyCurl(command: string, tokens: string[], cwd: string, shellVars: ReadonlyMap): string | null { + if (command !== "curl") return null; + const { hasRemoteName, outputDir } = getCurlWriteTargets(tokens); + if (hasRemoteName && !isTempPath(outputDir ?? ".", cwd, shellVars)) { + return "curl blocked outside temp dir: current directory (use -o /tmp/... to write to temp)"; + } + return null; +} + +/** xargs: classify the command xargs would run + block targetless mutation commands. */ +function classifyXargs(command: string, tokens: string[], cwd: string, depth: number, shellVars: ReadonlyMap): string | null { + if (command !== "xargs") return null; + const xArgs = tokens.slice(1); + const XARGS_FLAGS_WITH_VALUE = new Set(["-I", "-L", "-n", "-P", "-d", "-E", "-s"]); + let cmdStart = 0; + while (cmdStart < xArgs.length) { + if (XARGS_FLAGS_WITH_VALUE.has(xArgs[cmdStart])) { cmdStart += 2; continue; } + if (xArgs[cmdStart].startsWith("-")) { cmdStart++; continue; } + break; + } + if (cmdStart >= xArgs.length) return null; + const xTokens = xArgs.slice(cmdStart); + const nested = classifyBashCommand(xTokens.join(" "), cwd, depth + 1, shellVars); + if (!nested.ok) return nested.reason; + const xCmd = xTokens[0]?.toLowerCase(); + if (xCmd && getMutationTargets(xCmd, xTokens) !== null) return `xargs ${xCmd} blocked: mutation command via xargs`; + return null; +} + +/** Generic mutation: extract targets via getMutationTargets, block non-temp paths. */ +function classifyGenericMutation(command: string, tokens: string[], cwd: string, shellVars: ReadonlyMap): string | null { + const paths = getMutationTargets(command, tokens); + if (!paths) return null; + for (const target of paths) { + if (!isTempPath(target, cwd, shellVars)) return `${command} blocked outside temp dir: ${stripMatchingQuotes(target)}`; + } + return null; +} + +// ── Main dispatcher ───────────────────────────────────────────────── + +function getFilesystemMutationReason(segment: string, cwd: string, depth: number = 0, shellVars: ReadonlyMap = new Map()): string | null { + const tokens = getCommandTokens(segment); + const command = tokens[0]?.toLowerCase(); + if (!command) return null; + + return classifySubshellSegment(segment, cwd, depth, shellVars) + ?? classifyEvalExec(command, tokens, cwd, depth, shellVars) + ?? classifySudo(command, tokens, cwd, depth, shellVars) + ?? classifyEnv(command, segment, tokens, cwd, depth, shellVars) + ?? classifyGit(command, tokens) + ?? classifyInterpreter(command, tokens, cwd, depth, shellVars) + ?? classifyDdOutput(segment, cwd, shellVars) + ?? classifyPackageManager(command, tokens) + ?? classifyWget(command, tokens, cwd, shellVars) + ?? classifyCurl(command, tokens, cwd, shellVars) + ?? classifyXargs(command, tokens, cwd, depth, shellVars) + ?? classifyGenericMutation(command, tokens, cwd, shellVars); +} + +/** Package manager mutation: block unconditionally regardless of target path. */ +function classifyPackageManager(command: string, tokens: string[]): string | null { + if (!PACKAGE_MANAGERS.has(command)) return null; + if (!isPackageMutation(tokens.slice(1))) return null; + const args = tokens.slice(1).join(" "); + return buildReadonlyPackageManagerBlockReason(command, args); +} + +function skipFlagValues(args: string[], flagsWithValues: Set): string[] { + const result: string[] = []; + let i = 0; + while (i < args.length) { + if (flagsWithValues.has(args[i])) { + i += 2; // skip flag + value + } else { + result.push(args[i]); + i++; + } + } + return result; +} + +function getCurlWriteTargets(tokens: string[]): { hasRemoteName: boolean; outputs: string[]; outputDir: string | null } { + const cArgs = tokens.slice(1); + const outputs: string[] = []; + let hasRemoteName = false; + let outputDir: string | null = null; + for (let i = 0; i < cArgs.length; i++) { + if (cArgs[i] === "--") break; // end of options; remaining args are URLs + if ((cArgs[i] === "-o" || cArgs[i] === "--output") && cArgs[i + 1]) { + outputs.push(cArgs[i + 1]); + i++; + continue; + } + if (cArgs[i] === "--output-dir" && cArgs[i + 1]) { + outputDir = cArgs[i + 1]; + i++; + continue; + } + if (cArgs[i].startsWith("--output=")) { + outputs.push(cArgs[i].slice("--output=".length)); + continue; + } + if (cArgs[i].startsWith("--output-dir=")) { + outputDir = cArgs[i].slice("--output-dir=".length); + continue; + } + if (cArgs[i].startsWith("-o") && cArgs[i].length > 2 && !cArgs[i].startsWith("--")) { + outputs.push(cArgs[i].slice(2)); + continue; + } + if (cArgs[i] === "-O" || cArgs[i] === "--remote-name" || cArgs[i] === "--remote-name-all") { + hasRemoteName = true; + continue; + } + if (isCurlShortFlagBundleWithRemoteName(cArgs[i])) { + hasRemoteName = true; + continue; + } + } + return { hasRemoteName, outputs, outputDir }; +} + +const CURL_VALUE_SHORT_FLAGS = new Set([ + // All 27 value-consuming short flags per curl --help all. + // A, b, u, X pre-existed; C, K fixed -CO/-KO; remaining cover -dO, -oO, etc. + "A", "b", "u", "X", + "C", "K", "y", "Y", "z", + "c", "d", "D", "e", "E", "F", "h", "H", "m", "o", + "P", "Q", "r", "t", "T", "U", "w", "x", +]); + +function isCurlShortFlagBundleWithRemoteName(token: string): boolean { + if (!token.startsWith("-") || token.startsWith("--") || token.length <= 2) return false; + const flags = token.slice(1); + if (!/^[A-Za-z]+$/.test(flags)) return false; + for (const flag of flags) { + if (flag === "O") return true; + if (CURL_VALUE_SHORT_FLAGS.has(flag)) return false; + } + return false; +} + +/** + * Extract the download directory from wget flags (-P / --directory-prefix). + * Returns null when no directory flag is present. + */ +function getWgetOutputDir(tokens: string[]): string | null { + const wArgs = tokens.slice(1); + for (let i = 0; i < wArgs.length; i++) { + if (wArgs[i] === "--") break; + if (wArgs[i] === "-P" && wArgs[i + 1]) { + return wArgs[i + 1]; + } + if (wArgs[i].startsWith("-P") && wArgs[i].length > 2) { + return wArgs[i].slice(2); + } + if (wArgs[i] === "--directory-prefix" && wArgs[i + 1]) { + return wArgs[i + 1]; + } + if (wArgs[i].startsWith("--directory-prefix=")) { + return wArgs[i].slice("--directory-prefix=".length); + } + } + return null; +} + +// ── Mutation target extractors (one per command group, ≤20 lines each) ── + +function getRmTargets(tokens: string[]): string[] { + return nonOptionArgs(skipFlagValues(tokens.slice(1), new Set(["-s", "-o", "--io-size"]))); +} + +function getTruncateTargets(tokens: string[]): string[] { + return nonOptionArgs(skipFlagValues(tokens.slice(1), new Set(["-s", "-r", "--reference", "-o", "--io-size"]))); +} + +function getTouchTargets(tokens: string[]): string[] { + return nonOptionArgs(skipFlagValues(tokens.slice(1), new Set(["-t", "-d", "-r"]))); +} + +function getChmodTargets(tokens: string[]): string[] { + const args = nonOptionArgs(tokens.slice(1)); + return args.slice(1); +} + +function getCpTargets(tokens: string[]): string[] { + const args = nonOptionArgs(tokens.slice(1)); + return args.length > 0 ? [args[args.length - 1]] : []; +} + +function getTeeTargets(tokens: string[]): string[] { + return nonOptionArgs(tokens.slice(1)); +} + +function getPerlRubyTargets(tokens: string[]): string[] | null { + if (!tokens.slice(1).some((arg) => /^-p?i/.test(arg))) return null; + return nonOptionArgs(tokens.slice(1)); +} + +/** Strip -e/--expression flag-value pairs from sed tokens, tracking if any were found. */ +function filterSedExpressionTokens(sedTokens: string[]): { filtered: string[]; hasExpression: boolean } { + const filtered: string[] = []; + let hasExpression = false; + let ti = 0; + while (ti < sedTokens.length) { + if (sedTokens[ti] === "-e" || sedTokens[ti] === "--expression") { + ti += 2; hasExpression = true; + } else if (sedTokens[ti].startsWith("-e")) { + ti += 1; hasExpression = true; // -e'expr' concatenated form + } else if (sedTokens[ti].startsWith("--expression=")) { + ti += 1; hasExpression = true; + } else { + filtered.push(sedTokens[ti]); ti++; + } + } + return { filtered, hasExpression }; +} + +/** sed -i target extraction with -e/--expression and backup-extension handling. */ +function getSedTargets(tokens: string[]): string[] | null { + if (!tokens.slice(1).some((arg) => arg === "-i" || arg.startsWith("-i"))) return null; + const { filtered, hasExpression } = filterSedExpressionTokens(tokens.slice(1)); + const args = nonOptionArgs(filtered); + const extArg = args.length > 0 ? stripMatchingQuotes(args[0]) : ""; + const hasBackupExt = args.length > 0 && (extArg === "" || /^[a-zA-Z0-9._-]{1,10}$/.test(extArg)); + if (hasBackupExt) return hasExpression ? (extArg === "" ? args.slice(1) : args) : args.slice(2); + return hasExpression ? args : args.slice(1); +} + +/** wget target extraction from -O/--output-document flags. */ +function getWgetTargets(tokens: string[]): string[] { + const wArgs = tokens.slice(1); + let outputTarget: string | null = null; + for (let i = 0; i < wArgs.length; i++) { + if (wArgs[i] === "-O" && wArgs[i + 1]) { outputTarget = wArgs[i + 1]; i++; continue; } + if (wArgs[i].startsWith("-O") && wArgs[i].length > 2) { outputTarget = wArgs[i].slice(2); continue; } + if (wArgs[i] === "--output-document" && wArgs[i + 1]) { outputTarget = wArgs[i + 1]; i++; continue; } + if (wArgs[i].startsWith("--output-document=")) { outputTarget = wArgs[i].slice("--output-document=".length); } + } + if (outputTarget !== null) return stripMatchingQuotes(outputTarget) === "-" ? ["/dev/null"] : [outputTarget]; + const outputDir = getWgetOutputDir(tokens); + if (outputDir !== null) return [outputDir]; + return ["."]; // safety net — unreachable via getFilesystemMutationReason +} + +/** curl target extraction from -o/--output and -O/--remote-name flags. */ +function getCurlTargets(tokens: string[]): string[] | null { + const { hasRemoteName, outputs, outputDir } = getCurlWriteTargets(tokens); + const mapped = outputs.map((o) => stripMatchingQuotes(o) === "-" ? "/dev/null" : o); + const remoteNameTarget = outputDir ?? "."; + if (mapped.length > 0) return hasRemoteName ? [...mapped, remoteNameTarget] : mapped; + if (hasRemoteName) return [remoteNameTarget]; + return null; +} + +// ── Main dispatcher ───────────────────────────────────────────────── + +function getMutationTargets(command: string, tokens: string[]): string[] | null { + switch (command) { + case "rm": case "rmdir": case "unlink": case "mkdir": return getRmTargets(tokens); + case "truncate": return getTruncateTargets(tokens); + case "touch": return getTouchTargets(tokens); + case "chmod": case "chown": case "chgrp": return getChmodTargets(tokens); + case "cp": case "mv": case "install": case "ln": return getCpTargets(tokens); + case "tee": return getTeeTargets(tokens); + case "sed": return getSedTargets(tokens); + case "perl": case "ruby": return getPerlRubyTargets(tokens); + case "find": return getFindMutationTargets(tokens.slice(1)); + case "wget": return getWgetTargets(tokens); + case "curl": return getCurlTargets(tokens); + default: return null; + } +} + +function getFindMutationTargets(args: string[]): string[] | null { + // Skip glob-pattern args (e.g., -name '*.txt') — these cannot be filesystem roots. + const roots = args.filter((arg) => arg && !arg.startsWith("-") && !/[*?{}()\[\]~]/.test(arg)); + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "-delete") return roots.length > 0 ? roots : ["."]; + if (["-exec", "-execdir", "-ok", "-okdir"].includes(arg)) return roots.length > 0 ? roots : ["."]; + if (["-fprintf", "-fprint", "-fprint0", "-fls"].includes(arg)) { + const output = args[i + 1]; + return output ? [output] : ["."]; + } + } + return null; +} + +function isPackageMutation(args: string[]): boolean { + // Match individual tokens against known package-mutation verbs. + // Token-level matching (vs. substring-on-joined-string) avoids false + // positives when a path or argument contains a verb word (install-sh, etc.). + const VERBS = new Set(["install", "uninstall", "update", "upgrade", "ci", "link", "publish", "add", "remove", "reinstall", "tap", "untap", "download", "build-dep", "i", "un", "ad", "rm", "up", "in", "rb"]); + return args.some((a) => VERBS.has(a.toLowerCase())); +} + +function findSudoCommandIndex(tokens: string[]): number { + const FLAGS_WITH_VALUE = new Set(["-u", "-g", "-p", "-C", "-T", "-h"]); + let i = 1; + while (i < tokens.length) { + const token = tokens[i]; + if (token === "--") return i + 1; + if (!token.startsWith("-")) return i; + if (FLAGS_WITH_VALUE.has(token)) i += 2; + else i += 1; + } + return tokens.length; +} + +/** + * Extract the command tokens from a shell segment, stripping env-prefixes, + * env-var assignments, and the `command` builtin wrapper. + * + * The `env` prefix is handled specially: env flags with values (-u, --unset, + * -S, -g) consume the next token as their value, and env-var assignments + * (KEY=value) before the real command are stripped. + */ +function getCommandTokens(segment: string): string[] { + const tokens = segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? []; + let i = 0; + + if (tokens[i] === "env") { + i++; + // env -u VAR and -S "string" take a value — consume as flag-value pairs + const ENV_FLAGS_WITH_VALUE = new Set(["-u", "--unset", "-S", "--split-string", "-g", "--group"]); + while (i < tokens.length && tokens[i].startsWith("-")) { + if (ENV_FLAGS_WITH_VALUE.has(tokens[i])) { + i += 2; // skip flag + its value + } else { + i++; // valueless flag + } + } + while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++; + // Skip -- separator between env assignments and the command + if (i < tokens.length && tokens[i] === "--") i++; + if (i >= tokens.length) return ["env"]; + } + + while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++; + if (tokens[i] === "command") i++; + return tokens.slice(i); +} + +function nonOptionArgs(args: string[]): string[] { + const result: string[] = []; + let stopOptions = false; + for (const arg of args) { + if (!stopOptions && arg === "--") { + stopOptions = true; + continue; + } + if (!stopOptions && arg.startsWith("-") && arg !== "-") continue; + result.push(arg); + } + return result; +} + +function isSafeGitCommand(rest: string): boolean { + const trimmed = rest.trim(); + if (!trimmed) return false; + + const tokens = trimmed.split(/\s+/); + const FLAGS_WITH_VALUE = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace"]); + let subcommand = ""; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (FLAGS_WITH_VALUE.has(token)) { i++; continue; } + if (token.startsWith("-")) continue; + subcommand = token; + break; + } + + if (!subcommand) return false; + if (GIT_IMMUTABLE.has(subcommand)) return true; + if (GIT_MUTABLE.has(subcommand)) return false; + const mixed = GIT_MIXED[subcommand]; + if (!mixed) return false; + const afterSub = trimmed.slice(trimmed.indexOf(subcommand) + subcommand.length).trim(); + return mixed(afterSub); +} + +function stripMatchingQuotes(token: string): string { + if ( + (token.startsWith('"') && token.endsWith('"')) || + (token.startsWith("'") && token.endsWith("'")) + ) { + return token.slice(1, -1); + } + return token; +} + +/** + * Expand shell variable references ($VAR, ${VAR}) in a raw path string. + * Looks up the variable name in the provided map first, then falls back + * to process.env. Returns null if the path contains no variable reference + * or the variable is unknown. + */ +function expandShellVariable(rawPath: string, shellVars: ReadonlyMap, visited: Set = new Set()): string | null { + const braceMatch = rawPath.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:-|:=)([^}]*))?\}(.*)$/); + if (braceMatch) { + const [, varName, operator, fallback = "", suffix] = braceMatch; + if (visited.has(varName)) return null; + visited.add(varName); + const value = shellVars.get(varName) ?? process.env[varName]; + if (value !== undefined && value !== "") return value + suffix; + if (operator === ":-" || operator === ":=") return fallback + suffix; + return null; + } + const plainMatch = rawPath.match(/^\$([A-Za-z_][A-Za-z0-9_]*)(.*)$/); + if (plainMatch) { + const varName = plainMatch[1]; + if (visited.has(varName)) return null; + visited.add(varName); + const value = shellVars.get(varName) ?? process.env[varName]; + return value !== undefined && value !== "" ? value + plainMatch[2] : null; + } + return null; +} + +/** + * Extract standalone shell variable assignments from a segment. + * Handles: VAR=value, export VAR=value, declare -r VAR=value, etc. + * Declaration keywords (export/declare/typeset/local/readonly) and their + * flags are skipped before looking for assignments. + * Returns empty map if any non-keyword, non-flag token is not an assignment. + * Special-cases $(mktemp) to a synthetic temp-dir path. + */ +function getStandaloneShellAssignments(segment: string, cwd: string, shellVars: ReadonlyMap = new Map()): Map { + const assignments = new Map(); + let tokens: string[] = segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? []; + if (tokens.length === 0) return assignments; + + // Skip declaration keywords and their flags (export -x, declare -r, etc.) + const DECL_KEYWORDS = new Set(["export", "declare", "typeset", "local", "readonly"]); + if (tokens.length > 0 && DECL_KEYWORDS.has(tokens[0]!)) { + tokens = tokens.slice(1) as string[]; + // Skip flags after the keyword (e.g., declare -r -x VAR=val) + while (tokens.length > 0 && tokens[0]!.startsWith("-")) { + tokens = tokens.slice(1) as string[]; + } + } + for (let i = 0; i < tokens.length; i++) { + const match = tokens[i]?.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match) continue; + const [, name, initialRawValue] = match; + let rawValue = initialRawValue; + const quoteWrapped = rawValue.startsWith('"'); + const substitutionStart = quoteWrapped ? rawValue.slice(1) : rawValue; + + // Tokenization splits command substitutions on spaces. Rejoin a standalone + // assignment value until the substitution closes so mktemp templates are visible. + if (substitutionStart.startsWith("$(")) { + let depth = 1; + for (const ch of substitutionStart.slice(2)) { + if (ch === "(") depth++; + if (ch === ")") depth--; + } + while ((depth > 0 || (quoteWrapped && !rawValue.endsWith('"'))) && i + 1 < tokens.length) { + rawValue += ` ${tokens[++i]}`; + for (const ch of tokens[i]!) { + if (ch === "(") depth++; + if (ch === ")") depth--; + } + } + } else if (substitutionStart.startsWith("`")) { + while ((!rawValue.endsWith("`") || (quoteWrapped && !rawValue.endsWith('`"'))) && i + 1 < tokens.length) { + rawValue += ` ${tokens[++i]}`; + } + } + + const value = stripMatchingQuotes(rawValue); + const assignmentVars = new Map(shellVars); + for (const [assignedName, assignedValue] of assignments) assignmentVars.set(assignedName, assignedValue); + const syntheticMktempPath = getSafeMktempSyntheticPath(value, cwd, assignmentVars, name); + if (syntheticMktempPath) { + assignments.set(name, syntheticMktempPath); + continue; + } + assignments.set(name, value); + } + return assignments; +} + +function unwrapCommandSubstitution(rawValue: string): string | null { + const normalized = stripMatchingQuotes(rawValue); + if (normalized.startsWith("$(") && normalized.endsWith(")")) return normalized.slice(2, -1); + if (normalized.startsWith("`") && normalized.endsWith("`")) return normalized.slice(1, -1); + return null; +} + +function getSafeMktempSyntheticPath(rawValue: string, cwd: string, shellVars: ReadonlyMap, name: string): string | null { + const innerCmd = unwrapCommandSubstitution(rawValue); + if (!innerCmd) return null; + + const innerTokens = getCommandTokens(innerCmd); + if (innerTokens[0] !== "mktemp") return null; + + let template: string | null = null; + let tmpdirBase: string | null = null; + const args = innerTokens.slice(1); + + for (let i = 0; i < args.length; i++) { + const arg = stripMatchingQuotes(args[i]!); + if (!arg) continue; + if (arg === "--") { + template = stripMatchingQuotes(args[i + 1] ?? ""); + break; + } + if (arg === "-p") { + tmpdirBase = stripMatchingQuotes(args[i + 1] ?? ""); + if (!tmpdirBase) return null; + i++; + continue; + } + if (arg.startsWith("-p") && arg.length > 2) { + tmpdirBase = stripMatchingQuotes(arg.slice(2)); + continue; + } + if (arg === "-t") { + if (process.platform !== "darwin") return null; + template = stripMatchingQuotes(args[i + 1] ?? ""); + if (!template) return null; + tmpdirBase = TEMP_DIR; + i++; + continue; + } + if (arg.startsWith("-t") && arg.length > 2) { + if (process.platform !== "darwin") return null; + template = stripMatchingQuotes(arg.slice(2)); + if (!template) return null; + tmpdirBase = TEMP_DIR; + continue; + } + if (arg === "--tmpdir") { + // Bare --tmpdir defaults to TEMP_DIR, but with two following positionals + // the first is an explicit DIR even if relative, so validate it later. + const next = stripMatchingQuotes(args[i + 1] ?? ""); + const nextNext = stripMatchingQuotes(args[i + 2] ?? ""); + if (next && !next.startsWith("-") && nextNext && !nextNext.startsWith("-")) { + tmpdirBase = next; + i++; + continue; + } + if (next && !next.startsWith("-") && (next.startsWith("/") || next.startsWith("~") || next.startsWith("$") || next.includes("/") || next === "." || next === "..")) { + tmpdirBase = next; + i++; + continue; + } + tmpdirBase = TEMP_DIR; + continue; + } + if (arg.startsWith("--tmpdir=")) { + tmpdirBase = stripMatchingQuotes(arg.slice("--tmpdir=".length)) || TEMP_DIR; + continue; + } + if (arg === "--suffix") { + i++; + continue; + } + if (arg.startsWith("--suffix=")) continue; + if (arg.startsWith("-")) continue; + template = arg; + } + + if (tmpdirBase !== null) { + if (!isTempPath(tmpdirBase, cwd, shellVars)) return null; + if (!template) return path.join(TEMP_DIR, `.pi-mktemp-${name.toLowerCase()}`); + if (path.isAbsolute(template)) return null; + const joinedTemplate = path.join(tmpdirBase, template); + return isTempPath(joinedTemplate, cwd, shellVars) + ? path.join(TEMP_DIR, `.pi-mktemp-${name.toLowerCase()}`) + : null; + } + + if (template && !isTempPath(template, cwd, shellVars)) return null; + return path.join(TEMP_DIR, `.pi-mktemp-${name.toLowerCase()}`); +} + +/** + * Expand a glob pattern to include hidden-file (dotfile) variants. + * + * WORKAROUND: `node:fs.globSync` ignores the `dot: true` option on + * Node.js v24+ — it silently skips dotfiles. This function explicitly + * generates `.*` variants for each wildcard segment so hidden files + * are matched. + */ +function expandHiddenGlobPatterns(pattern: string): string[] { + const segments = pattern.split("/"); + const options = segments.map((segment) => { + if (segment === "**") return [segment, "**/.*", "**/.*/**"]; + // Literal or already-dotfile segments need no expansion. + // Brace patterns ({a,b}) get a redundant .{a,b} variant — harmless. + if (!/[*?{}\[\]]/.test(segment) || segment.startsWith(".")) return [segment]; + return [segment, `.${segment}`]; + }); + const patterns = new Set(); + const visit = (index: number, built: string[]) => { + if (index === options.length) return void patterns.add(built.join("/")); + for (const option of options[index]) visit(index + 1, [...built, option]); + }; + visit(0, []); + return [...patterns]; +} + +function isTempPath(rawPath: string, cwd: string, shellVars: ReadonlyMap = new Map(), visited: Set = new Set()): boolean { + const normalized = stripMatchingQuotes(rawPath); + if (!normalized || normalized === "/dev/null" || /^&\d+$/.test(normalized)) return true; + + // Expand ~ and ~/path to the home directory (os.homedir()). + // ~user/path is not resolvable without getpwuid — block conservatively. + if (normalized.startsWith("~")) { + if (normalized === "~" || normalized.startsWith("~/")) { + const expanded = normalized.replace(/^~/, os.homedir()); + return isTempPath(expanded, cwd); + } + return false; // ~user/path cannot be resolved safely + } + + const expandedVar = expandShellVariable(normalized, shellVars, visited); + if (expandedVar !== null && expandedVar !== normalized) { + return isTempPath(expandedVar, cwd, shellVars, visited); + } + + // Unresolved dynamic paths are unsafe: if a shell var expansion still leaves + // command/process substitution or an unknown $VAR, we cannot prove temp-dir safety. + if (/`|\$\(|<\(/.test(normalized) || /\$(?:\{[^}]*\}|[A-Za-z_][A-Za-z0-9_]*)/.test(normalized)) { + return false; + } + + if (/[*?{}\[\]]/.test(normalized)) { + // Glob pattern - resolve against cwd and check each target individually. + // Expand hidden variants explicitly because node:fs globSync skips dotfiles. + // Empty glob (no matches) is allowed — no files to mutate. + try { + const matches = globSync(expandHiddenGlobPatterns(normalized), { cwd }); + if (matches.length === 0) return true; + return matches.every((m) => isTempPath(m, cwd)); + } catch { + return false; + } + } + const absolute = path.resolve(cwd, normalized); + // Resolve symlinks so /tmp/link -> /etc/passwd is correctly classified as non-temp. + // Walking up to the nearest existing ancestor handles new files inside symlinked dirs. + const real = resolveRealPath(absolute); + const relative = path.relative(TEMP_DIR, real); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +/** + * Read the file redirect target starting at position `start`. + * + * Handles quoted targets (single/double quotes) and backslash escapes. + * Scope: > (write), >> (append), >| (noclobber override). Heredoc redirects + * (<= cmd.length) return { target: "", end: i }; + + const first = cmd[i]; + if (first === '"' || first === "'") { + const quote = first; + let target = quote; + i++; + while (i < cmd.length) { + const ch = cmd[i]; + target += ch; + if (ch === "\\" && quote === '"' && i + 1 < cmd.length) { + i++; + target += cmd[i]; + continue; + } + if (ch === quote) { + i++; + break; + } + i++; + } + return { target, end: i }; + } + + let target = ""; + while (i < cmd.length) { + const ch = cmd[i]; + if (/\s/.test(ch) || ch === ";" || ch === "|" || ch === "\n") break; + if (ch === "&" && target !== "") break; + target += ch; + i++; + } + return { target, end: i }; +} + +/** + * Detect write redirects (>) to unsafe targets outside the temp dir. + * + * Scope: > (write), >> (append), >| (noclobber override), 2> (stderr), &> (combined). + * Heredoc redirect targets (< = new Map()): string | null { + let quote: '"' | "'" | null = null; + let escaped = false; + + for (let i = 0; i < cmd.length; i++) { + const ch = cmd[i]; + if (escaped) { escaped = false; continue; } + if (ch === "\\") { escaped = true; continue; } + if (quote) { if (ch === quote) quote = null; continue; } + if (ch === '"' || ch === "'") { quote = ch; continue; } + if (ch !== ">") continue; + + const next = cmd[i + 1]; + // >&N = fd redirect (e.g., 2>&1) — not a file write, skip + if (next === "&" && /^[\d-]$/.test(cmd[i + 2] ?? "")) continue; + // >= is the comparison operator (e.g., in [[ or node -e), not a write redirect + if (next === "=") continue; + // >& = combined stdout+stderr redirect to a file, treat as 2-char operator + const opLen = next === ">" || next === "|" || next === "&" ? 2 : 1; + const { target, end } = readRedirectTarget(cmd, i + opLen); + if (!isTempPath(target, cwd, shellVars)) return stripMatchingQuotes(target) || "(unknown target)"; + i = Math.max(i, end - 1); + } + + return null; +} + +/** + * Split a shell command string into segments separated by shell operators. + * + * Handles quoted strings (single/double quotes) and backslash escapes. + * Shell operator handling: + * ; — sequential (segment boundary) + * | — pipe (segment boundary) + * & — background (segment boundary, but >& and <& are redirects not separators) + * && — AND (segment boundary) + * || — OR (segment boundary) + * \n — newline (segment boundary) + * The >| and >& operators are consumed as part of the preceding segment. + */ +function splitUnquotedShellSegments(cmd: string): string[] { + const segments: string[] = []; + let current = ""; + let quote: '"' | "'" | null = null; + let escaped = false; + + for (let i = 0; i < cmd.length; i++) { + const ch = cmd[i]; + const next = cmd[i + 1]; + + if (escaped) { + current += ch; + escaped = false; + continue; + } + if (ch === "\\") { + current += ch; + escaped = true; + continue; + } + if (quote) { + current += ch; + if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + current += ch; + continue; + } + if ((ch === "&" && next === "&") || (ch === "|" && next === "|")) { + segments.push(current); + current = ""; + i++; + continue; + } + const prev = current[current.length - 1]; + if (ch === "|" && prev === ">") { + current += ch; + continue; + } + if (ch === "&" && (prev === ">" || prev === "<" || next === ">")) { + current += ch; + continue; + } + if (ch === ";" || ch === "|" || ch === "&" || ch === "\n") { + segments.push(current); + current = ""; + continue; + } + current += ch; + } + segments.push(current); + return segments; +} + +/** + * Extract command substitution targets ($(...) and backticks) from a shell line. + * + * Uses simple depth-tracked matching. This is a best-effort guard — nested + * nesting, backslash escapes, and quote-aware tracking are intentionally + * skipped for simplicity since this is not a security boundary. + */ +function extractCommandSubstitutions(line: string): string[] { + const commands: string[] = []; + + // Backtick substitutions: `` `cmd` `` + const backtickRe = /`([^`]*)`/g; + let match: RegExpExecArray | null; + while ((match = backtickRe.exec(line)) !== null) { + if (match[1].trim()) commands.push(match[1].trim()); + } + + // $() substitutions: handles arbitrary nesting via depth counter + for (let i = 0; i < line.length; i++) { + if (line[i] !== "$" || line[i + 1] !== "(") continue; + let depth = 1; + let cmd = ""; + let j = i + 2; + for (; j < line.length && depth > 0; j++) { + if (line[j] === "(" && line[j - 1] === "$") depth++; + else if (line[j] === ")") depth--; + if (depth > 0) cmd += line[j]; + } + if (cmd.trim()) commands.push(cmd.trim()); + i = j; + } + + // <() process substitutions: extract inner command for recursive classification. + // Handles one level of nesting inside <(). + const procSubRe = /<\(([^()]*(?:\([^()]*\)[^()]*)*)\)/g; + let procMatch: RegExpExecArray | null; + while ((procMatch = procSubRe.exec(line)) !== null) { + if (procMatch[1].trim()) commands.push(procMatch[1].trim()); + } + + return commands; +} + +// ── Shared readonly bash guard (consumed by parent tool_call hook and child spawnHook) ── + +export type ReadonlyBashGuardResult = + | { action: "allow" } + | { action: "block"; reason: string } + | { action: "sandbox"; sandboxedCommand: string }; + +/** + * Apply the readonly bash guard to a command. + * + * L1: OS-level sandboxing — wraps command if available (sandbox-exec / bwrap). + * L2: Command-pattern inspection — blocks if OS sandbox unavailable. + * + * @param cmd - Raw bash command string + * @param cwd - Working directory for path resolution + * @returns Structured result: allow, block (with reason), or sandbox (with wrapped command) + */ +export function applyReadonlyBashGuard(cmd: unknown, cwd: string): ReadonlyBashGuardResult { + if (typeof cmd !== "string") { + return { + action: "block", + reason: buildReadonlyBashBlockReason(READONLY_INVALID_BASH_COMMAND_REASON, ""), + }; + } + + // L1: OS sandbox (primary enforcement when available) + if (canUseOsSandbox()) { + const verdict = classifyBashCommand(cmd, cwd); + if (verdict.ok === false) { + return { action: "block", reason: buildReadonlyBashBlockReason(verdict.reason, cmd) }; + } + return { action: "sandbox", sandboxedCommand: wrapCommandWithOsSandbox(cmd) }; + } + + // L2: Pattern inspection fallback (no sandbox available) + const verdict = classifyBashCommand(cmd, cwd); + if (verdict.ok === false) { + return { action: "block", reason: buildReadonlyBashBlockReason(verdict.reason, cmd) }; + } + return { action: "allow" }; +} diff --git a/readonly-boundary.ts b/readonly-boundary.ts new file mode 100644 index 0000000..e53ea28 --- /dev/null +++ b/readonly-boundary.ts @@ -0,0 +1,64 @@ +/** + * Readonly topic boundary promotion logic. + * + * Boundary predicates and state transitions extracted from index.ts so the + * context hook stays focused on message injection. A human-set topic boundary + * in readonly mode that meets the + * token eligibility threshold auto-activates the same handoff bypass that + * explicit /handoff creates. + */ + +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { isHandoffEligible } from "./handoff/eligibility.js"; +import { HANDOFF_REQUIRED_STATUS } from "./handoff/copy.js"; +import { buildReadonlyBoundaryPromotionNotification } from "./readonly-copy.js"; +import type { AgenticodingState } from "./state.js"; +import { STATUS_KEY_HANDOFF } from "./tui.js"; + +/** Can the queued human-set topic boundary be promoted to a handoff bypass right now? */ +export function canPromoteBoundary( + state: AgenticodingState, + usage: ReturnType, +): boolean { + const boundary = state.pendingTopicBoundaryHint; + return boundary !== null && boundary.source === "human" && isHandoffEligible(usage); +} + +/** Consume a boundary that cannot authorize a readonly handoff. */ +export function discardNonHumanBoundary(state: AgenticodingState): boolean { + const boundary = state.pendingTopicBoundaryHint; + if (!boundary || boundary.source === "human") return false; + state.pendingTopicBoundaryHint = null; + return true; +} + +/** Create pendingRequestedHandoff from the queued topic boundary and notify the user. */ +export function promoteBoundary(state: AgenticodingState, ctx: ExtensionContext): void { + state.pendingRequestedHandoff = { + toolCalled: false, + resumeReadonlyAfterHandoff: true, + enforcementAttempts: 0, + }; + if (ctx.hasUI) { + if (ctx.ui.theme) { + ctx.ui.setStatus( + STATUS_KEY_HANDOFF, + ctx.ui.theme.fg("accent", HANDOFF_REQUIRED_STATUS), + ); + } + ctx.ui.notify(buildReadonlyBoundaryPromotionNotification(), "warning"); + } +} + +/** + * Mark a human-set boundary as having delivered its advisory nudge. + * Returns true the first time (so the caller knows to retain the hint for later + * promotion); returns false on subsequent calls (already-advised, silent skip). + */ +export function markBoundaryAdvisory(state: AgenticodingState): boolean { + const boundary = state.pendingTopicBoundaryHint; + if (!boundary || boundary.source !== "human") return false; + if (boundary.advisoryDelivered) return false; + boundary.advisoryDelivered = true; + return true; +} diff --git a/readonly-cache.ts b/readonly-cache.ts new file mode 100644 index 0000000..50d0efd --- /dev/null +++ b/readonly-cache.ts @@ -0,0 +1,217 @@ +/** + * Readonly cache for skill/prompt-template frontmatter. + * + * Populated lazily in `before_agent_start` from: + * 1. Loaded skills (via `systemPromptOptions.skills`). + * 2. Resolved prompt commands from `pi.getCommands()`. + * 3. Standard prompt directories as a partial fallback: + * `~/.pi/agent/prompts/` and trusted `cwd/.pi/prompts/`. + * + * All production prompt-resolution happens through + * `populatePromptCacheFromResolvedCommandsAndDirs`. The narrower + * `populateFromPromptDirs`, `populateFromPromptCommands`, and + * `populateFromPromptTemplates` have been removed as dead code — + * they duplicated the logic of the production path. Any test that + * needs to exercise prompt caching should go through the production + * function with an empty command list. + */ + +import { readFileSync, statSync, readdirSync } from "node:fs"; +import { join, extname, basename } from "node:path"; +import { homedir } from "node:os"; +import { parseFrontmatter } from "@earendil-works/pi-coding-agent"; +import type { Skill, SlashCommandInfo } from "@earendil-works/pi-coding-agent"; + +export interface ReadonlyCacheEntry { + readonly: boolean | null; + mtimeMs: number; + filePath: string; +} + +export interface ReadonlyCacheIssue { + kind: "invalid-readonly-value" | "malformed-frontmatter" | "unreadable-file"; + filePath: string; +} + +export interface ReadonlyCacheStore { + readonlySkillCache: Map; + readonlyPromptCache: Map; + readonlySkillIssues: Map; + readonlyPromptIssues: Map; +} + +interface CacheReadResult { + entry: ReadonlyCacheEntry | null; + issue: ReadonlyCacheIssue | null; +} + +function readCacheEntry(filePath: string, previous?: ReadonlyCacheEntry): CacheReadResult { + let st; + try { + st = statSync(filePath); + } catch (error: any) { + return error?.code === "ENOENT" || error?.code === "ENOTDIR" + ? { entry: null, issue: null } + : { entry: null, issue: { kind: "unreadable-file", filePath } }; + } + + if (previous && previous.filePath === filePath && st.mtimeMs === previous.mtimeMs) { + return { entry: previous, issue: null }; + } + + let content: string; + try { + content = readFileSync(filePath, "utf-8"); + } catch { + return { entry: null, issue: { kind: "unreadable-file", filePath } }; + } + + try { + const { frontmatter } = parseFrontmatter>(content); + const readonly = frontmatter["readonly"]; + if (readonly === undefined || typeof readonly === "boolean") { + return { + entry: { readonly: readonly ?? null, mtimeMs: st.mtimeMs, filePath }, + issue: null, + }; + } + return { + entry: { readonly: null, mtimeMs: st.mtimeMs, filePath }, + issue: { kind: "invalid-readonly-value", filePath }, + }; + } catch { + return { + entry: null, + issue: { kind: "malformed-frontmatter", filePath }, + }; + } +} + +function replaceCache(target: Map, next: Map): void { + target.clear(); + for (const [name, entry] of next) target.set(name, entry); +} + +function replaceIssues(target: Map, next: Map): void { + target.clear(); + for (const [name, issue] of next) target.set(name, issue); +} + +function setEntry( + nextCache: Map, + nextIssues: Map, + name: string, + result: CacheReadResult, +): void { + if (result.entry) nextCache.set(name, result.entry); + if (result.issue) nextIssues.set(name, result.issue); +} + +export function cacheLookupSkill(store: ReadonlyCacheStore, name: string): boolean | null { + return store.readonlySkillCache.get(name)?.readonly ?? null; +} + +export function cacheLookupPrompt(store: ReadonlyCacheStore, name: string): boolean | null { + return store.readonlyPromptCache.get(name)?.readonly ?? null; +} + +export function cacheLookupCommand(store: ReadonlyCacheStore, name: string): boolean | null { + return cacheLookupPrompt(store, name); +} + +export function cacheLookupSkillIssue(store: ReadonlyCacheStore, name: string): ReadonlyCacheIssue | null { + return store.readonlySkillIssues.get(name) ?? null; +} + +export function cacheLookupCommandIssue(store: ReadonlyCacheStore, name: string): ReadonlyCacheIssue | null { + return store.readonlyPromptIssues.get(name) ?? null; +} + +/** + * Format a user-facing warning message for a readonly frontmatter issue. + * Used when a skill or prompt has invalid `readonly` frontmatter or the + * source file cannot be read. Missing `readonly` is a normal no-op. + */ +export function formatReadonlyFrontmatterIssue(commandRef: string, issue: ReadonlyCacheIssue): string { + const detail = issue.kind === "invalid-readonly-value" + ? "`readonly` frontmatter must be `true` or `false`" + : issue.kind === "malformed-frontmatter" + ? "frontmatter could not be parsed" + : "prompt/skill file could not be read"; + return `Readonly frontmatter ignored for \`${commandRef}\`: ${detail} at \`${issue.filePath}\`.`; +} + +export function populateFromSkills(store: ReadonlyCacheStore, skills: Skill[]): void { + const nextCache = new Map(); + const nextIssues = new Map(); + for (const skill of skills) { + const result = readCacheEntry(skill.filePath, store.readonlySkillCache.get(skill.name)); + setEntry(nextCache, nextIssues, skill.name, result); + } + replaceCache(store.readonlySkillCache, nextCache); + replaceIssues(store.readonlySkillIssues, nextIssues); +} + +function collectPromptFilesFromDir( + store: ReadonlyCacheStore, + dir: string, + nextCache: Map, + nextIssues: Map, + blockedNames: Set, +): void { + let files: string[]; + try { + files = readdirSync(dir); + } catch { + return; + } + for (const file of files) { + if (extname(file) !== ".md") continue; + const name = basename(file, ".md"); + if (!name || blockedNames.has(name) || nextCache.has(name) || nextIssues.has(name)) continue; + const result = readCacheEntry(join(dir, file), store.readonlyPromptCache.get(name)); + setEntry(nextCache, nextIssues, name, result); + } +} + +/** + * Populate the prompt cache from resolved prompt commands plus the standard + * prompt directories that Pi also uses on disk. + * + * Priority for duplicate names: + * 1. Resolved prompt commands from `pi.getCommands()`. + * 2. Trusted project prompts in `cwd/.pi/prompts/`. + * 3. Global prompts in `~/.pi/agent/prompts/`. + * + * This is intentionally not a full prompt-source resolver. Anything outside + * those standard dirs must already be surfaced by `pi.getCommands()`. + */ +export function populatePromptCacheFromResolvedCommandsAndDirs( + store: ReadonlyCacheStore, + commands: SlashCommandInfo[], + cwd: string, + projectTrusted: boolean, +): void { + const nextCache = new Map(); + const nextIssues = new Map(); + const blockedNames = new Set(); + + for (const command of commands) { + if (!command.name) continue; + if (command.source !== "prompt") { + blockedNames.add(command.name); + continue; + } + if (nextCache.has(command.name) || nextIssues.has(command.name)) continue; + const result = readCacheEntry(command.sourceInfo.path, store.readonlyPromptCache.get(command.name)); + setEntry(nextCache, nextIssues, command.name, result); + } + + if (projectTrusted) { + collectPromptFilesFromDir(store, join(cwd, ".pi", "prompts"), nextCache, nextIssues, blockedNames); + } + collectPromptFilesFromDir(store, join(homedir(), ".pi", "agent", "prompts"), nextCache, nextIssues, blockedNames); + + replaceCache(store.readonlyPromptCache, nextCache); + replaceIssues(store.readonlyPromptIssues, nextIssues); +} diff --git a/readonly-copy.ts b/readonly-copy.ts new file mode 100644 index 0000000..f1bea1c --- /dev/null +++ b/readonly-copy.ts @@ -0,0 +1,115 @@ +/** + * Shared readonly-mode copy. + * + * Keep reusable readonly wording here so tool blocks, nudges, TUI, and handoff + * prompts stay aligned. + */ + +/** Scope of bash filesystem mutations blocked by readonly mode. */ +export const READONLY_BASH_SCOPE = "bash writes/deletions outside temp blocked"; +/** Scope of all non-temporary mutations blocked after a readonly handoff. */ +export const READONLY_NON_TEMP_MUTATION_SCOPE = "write, edit, and non-temp bash filesystem mutations remain blocked"; +/** Message emitted when the OS sandbox rejects a readonly mutation. */ +export const READONLY_SANDBOX_BLOCK_NOTICE = "[readonly mode] The OS sandbox blocked a filesystem write outside the OS temp dir.\nUse /readonly to disable, or write within the OS temp dir."; +/** User-visible shorthand for an explicit handoff request. */ +export const READONLY_EXPLICIT_HANDOFF = "explicit /handoff"; +/** All supported ways to activate the temporary readonly handoff exception. */ +export const READONLY_HANDOFF_TRIGGER = "explicit /handoff or an eligible human topic boundary"; +/** Constraint carried into the fresh context after readonly handoff. */ +export const READONLY_NEXT_CONTEXT_RESUMES = "Fresh context resumes in readonly mode."; +/** Constraint stating that the temporary exception is cleared after handoff. */ +export const READONLY_BYPASS_CLEARED = "The temporary handoff-only exception used to reach this context is no longer active."; +/** Child-agent summary of the readonly mutation policy. */ +export const READONLY_WRITE_EDIT_BASH = `write/edit blocked; ${READONLY_BASH_SCOPE}`; + +/** Reason for malformed bash tool input at the readonly boundary. */ +export const READONLY_INVALID_BASH_COMMAND_REASON = "bash command input must be a string"; + +/** Build the detailed reason returned when a bash command is blocked. */ +export function buildReadonlyBashBlockReason(reason: string, command: string): string { + return `Readonly mode: command blocked.\nReason: ${reason}\nCommand: ${command}`; +} + +/** Build the package-manager mutation reason used by the bash classifier. */ +export function buildReadonlyPackageManagerBlockReason(command: string, args: string): string { + return `${command} ${args} is blocked in readonly mode`; +} + +/** Build the error for an unsafe OS sandbox profile path. */ +export function buildReadonlySandboxPathError(path: string): string { + return `[readonly] Sandbox profile path contains quote — cannot safely escape: ${path}`; +} + +/** Build the notification emitted after readonly frontmatter is applied. */ +export function buildReadonlyFrontmatterNotification(enabled: boolean, commandRef: string): string { + return `Readonly mode ${enabled ? "enabled" : "disabled"} via \`${commandRef}\` frontmatter`; +} + +/** Text shown when a readonly handoff is cancelled and must be requested again. */ +export const READONLY_HANDOFF_RETRY_ADVICE = "Use /handoff again to recreate the temporary readonly exception and retry."; + +/** Text shown in a readonly child session to establish inherited authority. */ +export const READONLY_CHILD_AUTHORITY_NOTE = "You inherit readonly authority in this session."; + +export const READONLY_WRITE_EDIT_BLOCK_REASON = + "Readonly mode: write/edit blocked until the user disables readonly. Do not attempt alternative write strategies."; + +export const READONLY_HANDOFF_BLOCK_REASON = + `Readonly mode: handoff blocked until an ${READONLY_HANDOFF_TRIGGER} enables the temporary exception. Use spawn for same-topic delegation.`; + +export const READONLY_WRITE_EDIT_SUMMARY = `[readonly] ${READONLY_WRITE_EDIT_BASH}`; + +export const READONLY_ACTIVE_SUMMARY = `[readonly] enabled — write/edit blocked; ${READONLY_BASH_SCOPE}; handoff needs ${READONLY_HANDOFF_TRIGGER}.`; + +export const READONLY_HANDOFF_EXCEPTION_SUMMARY = `[readonly] temporary handoff exception active; write/edit remain blocked; ${READONLY_BASH_SCOPE}.`; + +/** TUI notification shown when readonly mode is enabled. */ +export const READONLY_ENABLED_STATUS = `[readonly] enabled — write/edit blocked; handoff needs ${READONLY_HANDOFF_TRIGGER}; ${READONLY_BASH_SCOPE}`; + +export const READONLY_COMMAND_DESCRIPTION = + `Toggle readonly mode (${READONLY_WRITE_EDIT_BASH}; handoff needs ${READONLY_HANDOFF_TRIGGER})`; + +export const READONLY_DISABLED_SUMMARY = "[readonly] disabled — write, edit, handoff, and bash writes are now fully available."; + +/** Notification on readonly toggle-off for TUI user. */ +export const READONLY_DISABLED_NOTIFICATION = READONLY_DISABLED_SUMMARY; + +export const READONLY_PENDING_HANDOFF_READONLY_ON_NOTIFICATION = + "Pending handoff updated — the fresh context will resume in readonly mode."; + +export const READONLY_PENDING_HANDOFF_READONLY_OFF_NOTIFICATION = + "Pending handoff readonly continuation cleared — the fresh context will not resume in readonly mode."; + +/** Notification in /handoff command when readonly is active. */ +export const READONLY_HANDOFF_EXCEPTION_NOTIFICATION = + `Readonly is active. An ${READONLY_EXPLICIT_HANDOFF} exception is reserved for this request; the handoff will start a fresh context once feasible. Write/edit remain blocked, and ${READONLY_BASH_SCOPE} stays in effect. After a successful handoff, ${READONLY_NEXT_CONTEXT_RESUMES.toLowerCase()}`; + +/** Add context usage to the one-shot readonly-disabled message. */ +export function buildReadonlyDisabledContextSuffix(percent: number): string { + return ` Context at ${Math.round(percent)}% — if the work changed topics, you can handoff now.`; +} + +/** Explain that an eligible human topic boundary will activate the temporary bypass. */ +export function buildReadonlyTopicBoundaryNotification(from: string | null, to: string): string { + return `Active notebook topic changed: ${from ?? "(unset)"} → ${to}. This is a likely task boundary; use spawn only for same-topic delegation. In readonly mode, the handoff exception activates only once the context is ready; until then this boundary is advisory.`; +} + +/** Explain when a readonly topic boundary has activated the handoff path. */ +export function buildReadonlyBoundaryPromotionNotification(): string { + return "Readonly topic boundary detected. The handoff exception is now active because context usage is eligible; complete the handoff before continuing normal work."; +} + +/** Describe the readonly constraints carried into a required handoff. */ +export function buildReadonlyRequestedHandoffContinuation(): string { + return `${READONLY_HANDOFF_EXCEPTION_SUMMARY} This temporary bypass exists only so you can complete the required handoff now. After success, ${READONLY_NEXT_CONTEXT_RESUMES.toLowerCase()}`; +} + +/** Explain the readonly constraint while an ineligible handoff waits. */ +export function buildReadonlyHandoffWaitNotice(): string { + return ` Readonly remains active; ${READONLY_NON_TEMP_MUTATION_SCOPE}.`; +} + +/** Add readonly-specific instructions to the explicit /handoff command. */ +export function buildReadonlyHandoffCommandNotice(): string { + return `\n\n${READONLY_HANDOFF_EXCEPTION_SUMMARY} Draft the brief so the next context resumes readonly mode.`; +} diff --git a/readonly-rehydration.ts b/readonly-rehydration.ts new file mode 100644 index 0000000..0acc1a5 --- /dev/null +++ b/readonly-rehydration.ts @@ -0,0 +1,34 @@ +/** + * Readonly rehydration logic — extract branch-scanning into a pure function + * for testability. The rehydrateReadonlyState wrapper in index.ts calls this + * and handles the nudge side-effect. + */ + +/** + * Scan a session branch for the `agenticoding-readonly` custom entry and + * return whether readonly should be enabled. The most recent entry (found + * by scanning in reverse) wins. + * + * @param branch - Session branch entries from sessionManager.getBranch() + * @param pi - Used to check the `--readonly` CLI flag as fallback + * @returns `true` if readonly should be enabled after rehydration + */ +export function getReadonlyFromBranch( + branch: readonly unknown[], + pi: { getFlag: (name: string) => unknown }, +): boolean { + // Scan branch in reverse for the most recent readonly entry + for (let i = branch.length - 1; i >= 0; i--) { + const entry = branch[i]; + if (!entry || typeof entry !== "object") continue; + const e = entry as Record; + if (e.type !== "custom" || e.customType !== "agenticoding-readonly") continue; + const d = e.data as Record | undefined; + return d?.enabled === true; + } + // No branch entry found — fall back to CLI flag + if (pi.getFlag("readonly") === true) { + return true; + } + return false; +} diff --git a/resolve-path.ts b/resolve-path.ts new file mode 100644 index 0000000..f1c4ced --- /dev/null +++ b/resolve-path.ts @@ -0,0 +1,24 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * Resolve a path's real location, following symlinks. + * If the path doesn't exist, walk up to the nearest existing ancestor + * and resolve that, then append the remaining components. + * This handles the common case where a new file is created inside a + * symlinked temp dir (/tmp -> /private/tmp on macOS). + */ +export function resolveRealPath(p: string): string { + try { + return fs.realpathSync(p); + } catch { + const parent = path.dirname(p); + if (parent === p) return p; // hit root + try { + const realParent = fs.realpathSync(parent); + return path.join(realParent, path.basename(p)); + } catch { + return path.join(resolveRealPath(parent), path.basename(p)); + } + } +} diff --git a/spawn/index.ts b/spawn/index.ts index 2353c03..8d30ec9 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -17,9 +17,15 @@ import type { ToolInfo, } from "@earendil-works/pi-coding-agent"; import type { TextContent } from "@earendil-works/pi-ai"; +import { + READONLY_CHILD_AUTHORITY_NOTE, + READONLY_WRITE_EDIT_SUMMARY, +} from "../readonly-copy.js"; import { AuthStorage, createAgentSession, + createBashToolDefinition, + defineTool, ModelRegistry, SessionManager, } from "@earendil-works/pi-coding-agent"; @@ -28,6 +34,7 @@ import { Type } from "typebox"; import type { AgenticodingState } from "../state.js"; import { formatPageList } from "../notebook/store.js"; import { createNotebookToolDefinitions } from "../notebook/tools.js"; +import { applyReadonlyBashGuard } from "../readonly-bash.js"; import { renderSpawnCall, renderSpawnResult, @@ -74,13 +81,24 @@ function getLastAssistantOutcome(messages: AssistantMessageLike[]): SpawnOutcome * Line-count limit is applied first, then byte limit. * May end mid-line if the byte limit is the tighter constraint. */ -function truncateText(text: string, maxLines: number, maxBytes: number): string { +export function truncateText(text: string, maxLines: number, maxBytes: number): string { const lines = text.split("\n"); let truncated = lines.slice(0, maxLines).join("\n"); - if (new TextEncoder().encode(truncated).length > maxBytes) { - truncated = new TextDecoder().decode( - new TextEncoder().encode(truncated).slice(0, maxBytes), - ); + const encoded = new TextEncoder().encode(truncated); + if (encoded.length > maxBytes) { + // Shrink byte-by-byte at the boundary until we have valid UTF-8. + // This avoids splitting a multi-byte character mid-sequence. + // An empty slice (0 bytes) is always valid and decodes to empty string. + let slice = encoded.slice(0, maxBytes); + for (;;) { + try { + truncated = new TextDecoder("utf-8", { fatal: true }).decode(slice); + break; + } catch { + if (slice.length === 0) break; + slice = slice.slice(0, slice.length - 1); + } + } } return truncated; } @@ -138,6 +156,45 @@ export function buildChildToolNames( return [...new Set([...inheritedTools, ...childTools.map((tool) => tool.name)])]; } +/** + * Filter child tool names for readonly mode. + * Removes write/edit from the tool list entirely — children start with + * a fresh context, so there is no cache to preserve. + */ +export function filterReadonlyToolNames(toolNames: string[], readonlyEnabled: boolean): string[] { + return readonlyEnabled + ? toolNames.filter((name) => name !== "write" && name !== "edit") + : toolNames; +} + +/** + * Create a bash tool definition for readonly-mode child sessions. + * + * Applies OS-level sandboxing (sandbox-exec on macOS, bwrap on Linux) when available. + * Falls back to classifyBashCommand command-pattern inspection when no OS sandbox + * is available (Windows). The fallback blocks filesystem writes/deletions outside + * the OS temp dir using the same logic as the parent's tool_call hook. + */ +function createReadonlyChildBashTool( + cwd: string, +) { + const bashTool = createBashToolDefinition(cwd, { + spawnHook: (spawnContext) => { + const result = applyReadonlyBashGuard(spawnContext.command, cwd); + if (result.action === "block") { + throw new Error(result.reason); + } + if (result.action === "sandbox") { + spawnContext.command = result.sandboxedCommand; + } + return spawnContext; + }, + }); + return defineTool(bashTool); +} + + + // ── Spawn tool metadata ── const SPAWN_DESCRIPTION = @@ -167,7 +224,6 @@ const SPAWN_PARAMETERS = Type.Object({ }); - /** * Build the custom tool set for child agent sessions. * @@ -186,7 +242,6 @@ export function createChildTools( } - // ── Shared spawn execution logic ────────────────────────────────────── /** @@ -230,15 +285,21 @@ export async function executeSpawn( const notebookListing = listing ? "Available notebook pages:\n" + listing : "No notebook pages."; + const readonlyNotice = state.readonlyEnabled + ? `\n\n${READONLY_WRITE_EDIT_SUMMARY}.` + : ""; + const authorityNote = state.readonlyEnabled + ? READONLY_CHILD_AUTHORITY_NOTE + : "You have the same authority as the parent."; const fullPrompt = `You are a focused child agent spawned by a parent agent. ` + - `You have the same authority as the parent. ` + + `${authorityNote} ` + `Children cannot spawn further children. ` + `Your result will be read by the parent, so be concise and complete.\n\n` + `${notebookListing}\n\n` + `If you write notebook pages, store only durable grounding knowledge for future contexts. ` + `Keep transient task state in your final reply to the parent.\n\n` + - `## Task\n\n${params.prompt}\n\n` + + `## Task\n\n${params.prompt}${readonlyNotice}\n\n` + `When complete, provide a concise summary of findings. ` + `Keep the result under ${CHILD_MAX_LINES} lines / ${(CHILD_MAX_BYTES / 1024).toFixed(0)}KB.`; @@ -249,14 +310,31 @@ export async function executeSpawn( const childTools = createChildTools(pi, state, { isStale }); const parentToolNames = pi.getActiveTools(); const childToolNames = buildChildToolNames(parentToolNames, childTools, pi.getAllTools()); + // Children: readonly vs non-readonly tool strategy differs from the parent. + // Parent keeps write/edit in the tool list and blocks at call time to avoid + // context-cache misses (index.ts). Children start with a fresh context — no + // cache to preserve — so we remove write/edit from the tool list entirely + // (cleaner than advertising tools that always error). The readonly bash guard + // (sandbox-exec/bwrap or classifyBashCommand fallback) still propagates to + // children via createReadonlyChildBashTool below. + // + // This is a guardrail for a coding agent, not a security boundary. + const effectiveChildTools = [ + ...childTools, + ...(state.readonlyEnabled && childToolNames.includes("bash") + ? [createReadonlyChildBashTool(ctx.cwd)] + : []), + ]; + + const effectiveToolNames = filterReadonlyToolNames(childToolNames, state.readonlyEnabled); const { session } = await sessionFactory({ sessionManager: SessionManager.inMemory(), model: childModel, thinkingLevel: childThinking, cwd: ctx.cwd, - tools: childToolNames, - customTools: childTools, + tools: effectiveToolNames, + customTools: effectiveChildTools, authStorage, modelRegistry, }); @@ -265,7 +343,7 @@ export async function executeSpawn( let wasAborted = false; const abortChild = () => { wasAborted = true; - session.abort().catch(e => console.error("[spawn] abort failed:", toolCallId, e)); + session.abort().catch(() => {}); }; const clearChildSession = () => { if (state.childSessions.get(toolCallId) === session) { @@ -277,7 +355,7 @@ export async function executeSpawn( }; const abortAndInvalidate = async () => { clearChildSession(); - await session.abort().catch(e => console.error("[spawn] abort failed:", toolCallId, e)); + await session.abort().catch(() => {}); throw invalidatedError; }; @@ -361,7 +439,6 @@ export async function executeSpawn( } } catch (error: unknown) { statsUnavailable = true; - console.warn("[spawn] Failed to collect child session stats:", error, toolCallId); } if (isStale()) { @@ -424,7 +501,17 @@ export function registerSpawnTool( ctx: ExtensionContext, ) { const parentThinking: ThinkingValue = pi.getThinkingLevel(); - return executeSpawn(_toolCallId, pi, ctx, state, params, signal, onUpdate, parentThinking, sessionFactory); + return executeSpawn( + _toolCallId, + pi, + ctx, + state, + params, + signal, + onUpdate, + parentThinking, + sessionFactory, + ); }, renderCall: renderSpawnCall, diff --git a/spawn/renderer.ts b/spawn/renderer.ts index 5c837a2..0577893 100644 --- a/spawn/renderer.ts +++ b/spawn/renderer.ts @@ -537,7 +537,6 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget : undefined; } catch (error) { this.unsubscribe = undefined; - console.warn("[spawn] Failed to subscribe to child session events:", this.ownedToolCallId, error); } } @@ -607,7 +606,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget this.state = undefined; this.attachedChildSessionEpoch = undefined; if (session && ownedToolCallId && liveChildSessions?.get(ownedToolCallId) === session) { - session.abort().catch(e => console.error("[spawn] abort failed:", ownedToolCallId, e)); + session.abort().catch(() => {}); liveChildSessions.delete(ownedToolCallId); } } @@ -697,11 +696,6 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget if (isExpectedToolComponentFailure(error)) { return undefined; } - const failureKey = `${toolCallId}:${toolName}`; - if (!this.toolComponentFailures.has(failureKey)) { - this.toolComponentFailures.add(failureKey); - console.warn("[spawn] Failed to create tool component:", toolCallId, toolName, error); - } return undefined; } } @@ -929,7 +923,6 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget if (isExpectedToolComponentFailure(error)) { return; } - console.warn(`[spawn] streaming component error (${eventType}):`, this.ownedToolCallId, error); } // ── Event handlers ─────────────────────────────────────────────── @@ -1144,7 +1137,6 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget this.resetRenderBatching(); // Prevent a single bad event from killing the subscription. // The TUI degrades gracefully — stale content until next successful event. - console.warn("[spawn] Event handler error:", event.type, this.ownedToolCallId, error); } } } diff --git a/state.ts b/state.ts index 626c696..e99a8aa 100644 --- a/state.ts +++ b/state.ts @@ -6,6 +6,8 @@ */ import type { AgentSession } from "@earendil-works/pi-coding-agent"; +import type { ReadonlyCacheEntry, ReadonlyCacheIssue } from "./readonly-cache.js"; +import type { NotebookTopicBoundaryHint } from "./notebook/topic.js"; export interface AgenticodingState { /** Compact notebook pages keyed by kebab-case name */ @@ -20,24 +22,36 @@ export interface AgenticodingState { /** Whether the current topic came from the human or the agent. */ activeNotebookTopicSource: "human" | "agent" | null; - /** One-shot boundary cue consumed by the next LLM call after a topic change. */ - pendingTopicBoundaryHint: { - from: string | null; - to: string; - source: "human" | "agent"; - } | null; + /** + * Boundary cue consumed after a topic change. Human readonly boundaries remain + * queued while context usage is unavailable or below the handoff threshold so a + * later eligible context hook can promote them to the handoff bypass. + */ + pendingTopicBoundaryHint: NotebookTopicBoundaryHint | null; /** Last context usage percent from getContextUsage() */ lastContextPercent: number | null; - /** Handoff task queued by the tool until the compaction hook consumes it. */ - pendingHandoff: { task: string; source: "tool" } | null; + /** Handoff task queued by the tool until the matching compaction hook consumes it. */ + pendingHandoff: { task: string; source: "tool"; generation: number } | null; + + /** Monotonically increasing identity used to ignore stale compaction callbacks. */ + handoffGeneration: number; - /** User-requested handoff that must result in a real tool-driven compaction. */ + /** Generation of the compaction currently in flight, if any. */ + handoffCompactionGeneration: number | null; + + /** + * Required handoff request that stays alive until a real tool-driven compaction + * succeeds, is explicitly reset, or ages out via watchdog enforcement. + */ pendingRequestedHandoff: { - direction: string; - enforcementAttempts: number; + /** True only after the current session has actually called the handoff tool. */ toolCalled: boolean; + /** Fresh context after successful compaction resumes in readonly mode. */ + resumeReadonlyAfterHandoff: boolean; + /** Turn counter for repeated "you still owe the user a handoff" nudges. */ + enforcementAttempts: number; } | null; /** @@ -63,12 +77,54 @@ export interface AgenticodingState { * Increment on /new so stale child updates/results cannot touch fresh state. */ childSessionEpoch: number; + + /** Whether readonly mode is active — write/edit blocked; handoff needs explicit /handoff or a human topic boundary; bash writes limited to temp. */ + readonlyEnabled: boolean; + + /** One-shot flag: deliver a readonly ON or OFF nudge via context hook, then clear. */ + readonlyNudgePending: boolean; + + /** Session-owned readonly frontmatter cache for loaded skills. */ + readonlySkillCache: Map; + + /** Session-owned readonly frontmatter cache for resolved prompt commands. */ + readonlyPromptCache: Map; + + /** Frontmatter issues keyed by skill name. */ + readonlySkillIssues: Map; + + /** Frontmatter issues keyed by prompt command name. */ + readonlyPromptIssues: Map; + + /** + * FIFO slash-command intents extracted from queued user inputs, deferred to + * before_agent_start where the readonly frontmatter cache is populated. + * Empty = no pending toggle. + * + * `type` preserves `/skill:name` vs generic `/name` so lookup can target the + * correct readonly source without conflating skills and prompt templates. + * Enqueued in `input`, drained in `before_agent_start` until the first real + * readonly decision, cleared on session reset. + */ + pendingReadonlyCommands: Array<{ type: "skill" | "command"; name: string }>; + + /** + * Last context-percentage band at which the watchdog nudge was delivered. + * null = never delivered. Bands: null (<30), 0 (30-49), 1 (50-69), 2 (70+). + * Used to throttle nudges — only nudge when crossing into a higher band. + */ + lastWatchdogBand: number | null; + } /** Create a fresh state instance. Call reset() on /new. */ export function createState(): AgenticodingState { const childSessions = new Map(); const liveChildSessions = new Map(); + const readonlySkillCache = new Map(); + const readonlyPromptCache = new Map(); + const readonlySkillIssues = new Map(); + const readonlyPromptIssues = new Map(); const state: AgenticodingState = { notebookPages: new Map(), epoch: 0, @@ -77,10 +133,20 @@ export function createState(): AgenticodingState { pendingTopicBoundaryHint: null, lastContextPercent: null, pendingHandoff: null, + handoffGeneration: 0, + handoffCompactionGeneration: null, pendingRequestedHandoff: null, childSessions, liveChildSessions, childSessionEpoch: 0, + readonlyEnabled: false, + readonlyNudgePending: false, + readonlySkillCache, + readonlyPromptCache, + readonlySkillIssues, + readonlyPromptIssues, + pendingReadonlyCommands: [], + lastWatchdogBand: null, }; // Prevent replacement — spawn lifecycle code and renderer ownership checks // depend on stable map identity. Only .clear() and .delete() are valid — @@ -107,11 +173,32 @@ export function resetState(state: AgenticodingState): void { state.epoch = 0; // sentinel: 0 = not yet initialized; set to Date.now() on first write state.activeNotebookTopic = null; state.activeNotebookTopicSource = null; - state.pendingTopicBoundaryHint = null; state.lastContextPercent = null; + invalidateHandoffState(state); + // /new abandons the previous session completely; do not carry its in-flight + // reservation into the fresh session. + state.handoffCompactionGeneration = null; + state.readonlyEnabled = false; + state.readonlyNudgePending = false; + state.readonlySkillCache.clear(); + state.readonlyPromptCache.clear(); + state.readonlySkillIssues.clear(); + state.readonlyPromptIssues.clear(); + state.pendingReadonlyCommands.length = 0; + abortAndClearChildSessions(state); +} + +/** Invalidate handoff work that belongs to a previous session-tree branch. */ +export function invalidateHandoffState(state: AgenticodingState): void { + state.handoffGeneration++; state.pendingHandoff = null; + state.handoffCompactionGeneration = null; state.pendingRequestedHandoff = null; - abortAndClearChildSessions(state); + state.pendingTopicBoundaryHint = null; + state.lastWatchdogBand = null; + // A branch switch abandons the old compaction path completely. Release the + // overlap guard now so the new branch cannot get stuck waiting on callbacks + // from work that no longer belongs to the active session tree. } /** Abort all active child sessions and clear both registries. Called on /new (session reset). */ @@ -123,6 +210,6 @@ export function abortAndClearChildSessions(state: AgenticodingState): void { state.childSessions.clear(); state.liveChildSessions.clear(); for (const [session, id] of seen) { - session.abort().catch((e: unknown) => console.warn("[spawn] abort failed:", id, e)); + session.abort().catch(() => {}); } } diff --git a/system-prompt.ts b/system-prompt.ts index ae7b809..bb63a2b 100644 --- a/system-prompt.ts +++ b/system-prompt.ts @@ -11,18 +11,22 @@ export const CONTEXT_PRIMER = ` One context, one job. Research is one job. Planning is one job. Execution is one job. When the job changes, call the handoff tool. -### The primacy-zone heuristic +### Plan then execute +Before acting, deliberate internally. Does the work still fit the +current topic? If yes, break it into phases, size each sub-task, +and delegate >10k-token sub-tasks via spawn. If no, prefer handoff. +Consider spawn for verification. When planning, the plan must include full +delegation plan if relevant for the task at hand. +End by presenting the concise plan optimized for a human checkpoint. + +### The primacy-zone You use long context unevenly. Performance can degrade as context grows — -even far from the window limit. Treat the first ~30% as a practical heuristic -for keeping the current job near the front of attention. The system tells you -exact context usage after each turn, and watchdog reminders may be injected -before LLM calls when context is past the heuristic. Watchdog reminders are -advisory only. +even far from the window limit. Treat the first ~30% as the optimal working zone. ### Spawn — isolate noise Delegate isolated work to child agents. They are trusted extensions of you, with their own context and the same authority. You receive only condensed -results. Parent context stays at orchestration level. Siblings run in parallel. +results. Your context stays at orchestration level. Siblings run in parallel. ### Notebook — durable cross-context grounding Treat the notebook as durable grounding for future contexts. Each page covers @@ -42,13 +46,12 @@ bodies. The active notebook topic names the current high-level frame for this session. If the current work still fits that topic, prefer spawn for isolated noisy subtasks so the parent stays focused. If the work no longer fits that topic, -prefer handoff over dragging stale context forward. After handoff, assign a -fresh topic again in the next context. +prefer handoff over dragging stale context forward. After handoff, assign a fresh topic again in the next context. ### Handoff — distilled next task When the job changes, or when context is noisy past the ~30% heuristic, use -handoff to finish extracting what matters from the current context before the -cut. Save durable reusable knowledge to the notebook first, then draft a +handoff. Before the cut, save durable +reusable knowledge to the notebook first, then draft a handoff brief that carries only the situational context still missing: current state, blockers, unresolved questions, failed paths worth avoiding, and next steps. Handoff compacts the active session around that brief so the next turn diff --git a/temp-dir.ts b/temp-dir.ts new file mode 100644 index 0000000..b8ae0ad --- /dev/null +++ b/temp-dir.ts @@ -0,0 +1,17 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** + * Canonical (symlink-resolved) OS temp dir path. + * + * Resolved at module import time. Shared by readonly-bash.ts and os-sandbox.ts + * so both modules agree on the same temp directory. + * + * This lives in its own module to avoid a cyclic dependency between + * readonly-bash.ts (imports from os-sandbox.ts) and os-sandbox.ts. + */ +export const TEMP_DIR = (() => { + const resolved = path.resolve(os.tmpdir()); + try { return fs.realpathSync(resolved); } catch { return resolved; } +})(); diff --git a/tests/e2e/basic.test.ts b/tests/e2e/basic.test.ts index 30f78eb..c571910 100644 --- a/tests/e2e/basic.test.ts +++ b/tests/e2e/basic.test.ts @@ -9,9 +9,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { ProcessHarness } from "./pty-harness.js"; -/** - * Create a fresh host, wait for READY, and return the harness. - */ +/** Create a fresh host, wait for READY, and return the harness. */ async function start(): Promise { const h = new ProcessHarness(); await h.waitForText("READY"); @@ -117,9 +115,120 @@ describe("agenticoding E2E", () => { assert.ok(snap.includes("fresh-agent-topic")); })); - it("handoff tool queues handoff state", async () => withHarness(async (h) => { + it("handoff tool rejects when context usage is unavailable", async () => withHarness(async (h) => { h.write('tool handoff {"task":"test handoff task","direction":"next-phase"}'); - await h.waitForText("OK:Handoff started"); + await h.waitForText("ERR:Context usage unavailable"); + })); + + it("handoff succeeds with valid context usage and readonly execution constraints", async () => withHarness(async (h) => { + h.write("cmd readonly"); + await h.waitForText("OK"); + h.write("cmd handoff continue readonly work"); + await h.waitForText("OK"); + h.write('usage {"tokens":50000,"percent":25,"contextWindow":200000}'); + await h.waitForText("OK"); + h.write('tool handoff {"task":"continue readonly work"}'); + await h.waitForText("OK:Handoff started."); + h.write("compact-success"); + await h.waitForText("Fresh context resumes in readonly mode."); + const snap = h.snapshot(); + assert.ok(snap.includes("temporary handoff-only exception used to reach this context is no longer active")); + assert.ok(snap.includes("non-temp bash filesystem mutations remain blocked")); + })); + + it("failed handoff compaction preserves retryability", async () => withHarness(async (h) => { + h.write("cmd handoff retry after failure"); + await h.waitForText("OK"); + h.write('usage {"tokens":50000,"percent":25,"contextWindow":200000}'); + await h.waitForText("OK"); + h.write('tool handoff {"task":"retry after failure"}'); + await h.waitForText("OK:Handoff started."); + h.write("compact-fail simulated failure"); + await h.waitForText("OK:compaction failed"); + await h.waitForText("Handoff failed"); + h.clear(); + h.write("ui-events"); + await h.waitForText('"agenticoding-handoff":"🤝 Handoff required — ready to compact"'); + await h.waitForText("Handoff compaction failed"); + h.write('tool handoff {"task":"retry after failure"}'); + await h.waitForText("OK:Handoff started."); + })); + + it("stale handoff compaction is ignored after session-tree navigation", async () => withHarness(async (h) => { + h.write("cmd handoff stale branch"); + await h.waitForText("OK"); + h.write('usage {"tokens":50000,"percent":25,"contextWindow":200000}'); + await h.waitForText("OK"); + h.write('tool handoff {"task":"stale branch work"}'); + await h.waitForText("OK:Handoff started."); + h.write("session-tree"); + await h.waitForText("OK"); + h.write("compact-success"); + await h.waitForText("OK:null"); + h.clear(); + h.write("ui-events"); + await h.waitForText("OK:"); + assert.doesNotMatch(h.snapshot(), /agenticoding-handoff/); + })); + + it("readonly lifecycle: handoff bypass clears after compaction while readonly persists", async () => withHarness(async (h) => { + h.write("cmd readonly"); + await h.waitForText("OK"); + // Drain the readonly toggle nudge + h.write("context"); + await h.waitForText("agenticoding-readonly-nudge"); + // Issue /handoff command — creates the bypass + h.write("cmd handoff continue readonly work"); + await h.waitForText("OK"); + // Set eligible context usage and call the handoff tool + h.write('usage {"tokens":50000,"percent":25,"contextWindow":200000}'); + await h.waitForText("OK"); + h.write('tool handoff {"task":"continue readonly work"}'); + await h.waitForText("OK:Handoff started."); + // Simulate successful compaction + h.write("compact-success"); + await h.waitForText("Fresh context resumes in readonly mode."); + // After compaction: bypass cleared, readonly persists. + // handoff tool should now be blocked again + h.write('toolcall handoff {"task":"direct call"}'); + await h.waitForText('"block":true'); + // write tool should stay blocked + h.write('toolcall write {"path":"/tmp/x","content":"x"}'); + await h.waitForText('"block":true'); + })); + + it("readonly topic boundary enables the handoff bypass on the next context hook", async () => withHarness(async (h) => { + h.write("cmd readonly"); + await h.waitForText("OK"); + h.write("context"); + await h.waitForText("agenticoding-readonly-nudge"); + h.write("cmd notebook oauth"); + await h.waitForText("OK"); + h.write("cmd notebook billing"); + await h.waitForText("OK"); + h.write('usage {"tokens":50000,"percent":25,"contextWindow":200000}'); + await h.waitForText("OK"); + h.write("context"); + await h.waitForText("temporary handoff exception active"); + h.clear(); + h.write("ui-events"); + await h.waitForText('"agenticoding-handoff":"🤝 Handoff required — ready to compact"'); + await h.waitForText("Readonly topic boundary detected"); + h.write('toolcall handoff {"task":"continue billing work"}'); + await h.waitForText('OK:null'); + h.write('tool handoff {"task":"continue billing work"}'); + await h.waitForText('OK:Handoff started.'); + h.clear(); + h.write("ui-events"); + await h.waitForText('"agenticoding-handoff":"🤝 Handoff in progress"'); + h.write("compact-success"); + await h.waitForText("Fresh context resumes in readonly mode."); + h.clear(); + h.write("ui-events"); + await h.waitForText("OK:"); + assert.doesNotMatch(h.snapshot(), /agenticoding-handoff/); + h.write('toolcall handoff {"task":"direct call"}'); + await h.waitForText('"block":true'); })); it("commands are registered", async () => withHarness(async (h) => { @@ -140,6 +249,15 @@ describe("agenticoding E2E", () => { assert.ok(snap.includes("No model") || snap.includes("ERR"), "spawn errors gracefully"); })); + it("headless mode keeps readonly command a no-op", async () => withHarness(async (h) => { + h.write("headless"); + await h.waitForText("OK"); + h.write("cmd readonly"); + await h.waitForText("OK"); + h.write('toolcall write {"path":"/tmp/x","content":"x"}'); + await h.waitForText("OK:null"); + })); + it("handles errors gracefully", async () => withHarness(async (h) => { // Unknown tool h.write("tool nonexistent {}"); @@ -149,8 +267,12 @@ describe("agenticoding E2E", () => { h.write("tool notebook_write {bad json}"); await h.waitForText("ERR:invalid json"); + h.write("context {bad json}"); + await h.waitForText("ERR:invalid json"); + // Unknown command h.write("cmd nonexistent"); await h.waitForText("ERR:unknown command"); })); + }); diff --git a/tests/e2e/pty-harness.ts b/tests/e2e/pty-harness.ts index f31a71d..1b416fe 100644 --- a/tests/e2e/pty-harness.ts +++ b/tests/e2e/pty-harness.ts @@ -43,6 +43,12 @@ export class ProcessHarness { }, }); + // EPIPE is expected when the child exits — the Node.js project + // considers this by-design OS behavior, not a bug. Without a + // listener the error crashes the process (delivered async via + // IOCP on Windows, potentially after the test promise settles). + this.child.stdin.on("error", () => {}); + const append = (chunk: string | Buffer) => { this.output += chunk.toString(); for (const wake of this.waiters) wake(); diff --git a/tests/e2e/test-host.ts b/tests/e2e/test-host.ts index 8a3e0be..970eb5c 100644 --- a/tests/e2e/test-host.ts +++ b/tests/e2e/test-host.ts @@ -7,6 +7,14 @@ * Protocol: * → cmd [arg] — call a registered command * → tool — call a registered tool with JSON params + * → toolcall — run the first tool_call hook directly + * → context [json] — run the first context hook + * → usage — set getContextUsage() result for later calls + * → ui / headless — select UI mode for subsequent calls + * → ui-events — return current status values and emitted notifications + * → compact-success — run queued handoff compaction success path + * → compact-fail [message] — run queued handoff compaction failure path + * → session-tree — navigate the active session tree branch * → tools — list registered tool names * → cmds — list registered command names * → exit — graceful shutdown @@ -14,8 +22,6 @@ * ← READY\n — sent after extension registration * ← OK[:payload]\n — success * ← ERR:message\n — failure - * - * No TUI. All UI-dependent paths are skipped (hasUI=false). */ import { createInterface } from "node:readline"; @@ -37,15 +43,23 @@ registerAgenticoding(pi); // ── Mock ExtensionContext for tool/command execution ────────────── +type MockContextUsage = { tokens?: number | null; percent?: number | null; contextWindow?: number | null } | null; +type CompactRequest = { onComplete?: () => void; onError?: (error: Error) => void }; + +let currentUsage: MockContextUsage = null; +let lastCompactRequest: CompactRequest | null = null; +const statuses = new Map(); +const notifications: Array<{ message: string; level: string }> = []; + const mockCtx = { - hasUI: false, + hasUI: true, mode: "non-interactive", cwd: process.cwd(), ui: { - notify: () => {}, - setStatus: () => {}, + notify: (message: string, level: string) => { notifications.push({ message, level }); }, + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, setWidget: () => {}, - theme: { fg: () => "" }, + theme: { fg: (_name: string, text: string) => text }, select: () => Promise.resolve(undefined), confirm: () => Promise.resolve(false), input: () => Promise.resolve(""), @@ -67,9 +81,10 @@ const mockCtx = { getTheme: () => undefined, setTheme: () => ({ ok: true }), }, - getContextUsage: () => null, + getContextUsage: () => currentUsage, sessionManager: null, modelRegistry: null, + isProjectTrusted: () => true, // Required by spawn tool which checks ctx.model existence before using it model: undefined, isIdle: () => true, @@ -77,7 +92,9 @@ const mockCtx = { abort: () => {}, hasPendingMessages: () => false, shutdown: () => process.exit(0), - compact: () => {}, + compact: (request: { onComplete?: () => void; onError?: (error: Error) => void }) => { + lastCompactRequest = request; + }, getSystemPrompt: () => "", } as any; // Type assertion needed: mock intentionally omits some interface fields @@ -92,12 +109,107 @@ for await (const line of rl) { if (trimmed === "exit") { process.exit(0); + } else if (trimmed === "ui" || trimmed === "headless") { + mockCtx.hasUI = trimmed === "ui"; + process.stdout.write("OK\n"); + } else if (trimmed === "ui-events") { + process.stdout.write("OK:" + JSON.stringify({ statuses: Object.fromEntries(statuses), notifications }) + "\n"); + } else if (trimmed.startsWith("usage ")) { + const jsonArg = trimmed.slice(6).trim(); + try { + currentUsage = JSON.parse(jsonArg); + process.stdout.write("OK\n"); + } catch (e: unknown) { + process.stdout.write("ERR:invalid json: " + (e instanceof Error ? e.message : String(e)) + "\n"); + } + } else if (trimmed === "compact-success" || trimmed.startsWith("compact-fail")) { + const [beforeCompact] = pi.handlers.get("session_before_compact") ?? []; + const compactRequest = lastCompactRequest as any; + if (!beforeCompact || !compactRequest) { + process.stdout.write("ERR:no queued compaction\n"); + continue; + } + if (trimmed === "compact-success") { + if (typeof compactRequest.onComplete !== "function") { + process.stdout.write("ERR:no success callback\n"); + continue; + } + const result = await beforeCompact( + { preparation: { tokensBefore: 1 }, branchEntries: [{ id: "leaf-e2e" }] }, + mockCtx, + ); + lastCompactRequest = null; + if (!result?.compaction) { + process.stdout.write("OK:null\n"); + continue; + } + compactRequest.onComplete(); + process.stdout.write("OK:" + JSON.stringify(result.compaction) + "\n"); + } else { + if (typeof compactRequest.onError !== "function") { + process.stdout.write("ERR:no failure callback\n"); + continue; + } + compactRequest.onError(new Error(trimmed.slice("compact-fail".length).trim() || "simulated compaction failure")); + lastCompactRequest = null; + const lastMessage = pi.sentUserMessages.at(-1)?.content ?? ""; + process.stdout.write("OK:compaction failed:" + lastMessage + "\n"); + } + } else if (trimmed === "session-tree") { + const [sessionTree] = pi.handlers.get("session_tree") ?? []; + if (!sessionTree) { + process.stdout.write("ERR:no session_tree handler\n"); + continue; + } + await sessionTree({ newLeafId: "fresh-leaf", oldLeafId: "old-leaf" }, mockCtx); + process.stdout.write("OK\n"); } else if (trimmed === "tools") { const names = Array.from(tools.keys()).sort().join(","); process.stdout.write("OK:" + names + "\n"); } else if (trimmed === "cmds") { const names = Array.from(commands.keys()).sort().join(","); process.stdout.write("OK:" + names + "\n"); + } else if (trimmed.startsWith("toolcall ")) { + const rest = trimmed.slice(9).trim(); + const spaceIdx = rest.indexOf(" "); + if (spaceIdx === -1) { + process.stdout.write("ERR:usage toolcall \n"); + continue; + } + const toolName = rest.slice(0, spaceIdx); + const jsonArgs = rest.slice(spaceIdx + 1); + let input; + try { input = JSON.parse(jsonArgs); } + catch (e: unknown) { + process.stdout.write("ERR:invalid json: " + (e instanceof Error ? e.message : String(e)) + "\n"); + continue; + } + try { + const [handler] = pi.handlers.get("tool_call") ?? []; + const result = await handler({ toolName, input }, { cwd: mockCtx.cwd }); + process.stdout.write("OK:" + JSON.stringify(result ?? null) + "\n"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + process.stdout.write("ERR:" + msg + "\n"); + } + } else if (trimmed.startsWith("context")) { + let payload; + try { + payload = trimmed === "context" + ? { messages: [{ role: "user", content: "e2e", timestamp: Date.now() }] } + : JSON.parse(trimmed.slice(7).trim()); + } catch (e: unknown) { + process.stdout.write("ERR:invalid json: " + (e instanceof Error ? e.message : String(e)) + "\n"); + continue; + } + try { + const [handler] = pi.handlers.get("context") ?? []; + const result = await handler(payload, mockCtx); + process.stdout.write("OK:" + JSON.stringify(result ?? null) + "\n"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + process.stdout.write("ERR:" + msg + "\n"); + } } else if (trimmed.startsWith("tool ")) { const rest = trimmed.slice(5).trim(); const spaceIdx = rest.indexOf(" "); diff --git a/tests/unit/config-invariants.test.ts b/tests/unit/config-invariants.test.ts new file mode 100644 index 0000000..1eeac4d --- /dev/null +++ b/tests/unit/config-invariants.test.ts @@ -0,0 +1,145 @@ +/** + * Invariant tests for the audit-ci security audit configuration. + * + * Validates that allowlist entries have unexpired expiry dates, that the + * CI workflow ordering (audit → unit → e2e) is preserved, and that the + * allowlist matches the current lockfile's actual vulnerability state. + */ + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +type AuditRecord = { + active: boolean; + expiry: string; + notes: string; +}; + +type AuditConfig = { + $schema: string; + moderate: boolean; + allowlist: Array>; +}; + +type PackageJson = { + engines: { + node: string; + }; +}; + +const AUDIT_SCHEMA = "https://github.com/IBM/audit-ci/raw/main/docs/schema.json"; +const REPO_ROOT_URL = new URL("../../", import.meta.url); +const REPO_ROOT = fileURLToPath(REPO_ROOT_URL); +const AUDIT_CONFIG_PATH = new URL("audit-ci.jsonc", REPO_ROOT_URL); +const AUDIT_CLI_PATH = fileURLToPath( + new URL("node_modules/audit-ci/dist/bin.js", REPO_ROOT_URL), +); +const PACKAGE_JSON_PATH = new URL("package.json", REPO_ROOT_URL); +const WORKFLOW_PATH = new URL(".github/workflows/test.yml", REPO_ROOT_URL); +const EXPECTED_MATRIX = new Set([ + "ubuntu-latest@22", + "ubuntu-latest@24", + "macos-latest@24", + "windows-latest@24", +]); +const EXPECTED_ALLOWLIST_KEYS = new Set([ + "GHSA-96hv-2xvq-fx4p", + "GHSA-f38q-mgvj-vph7", + "GHSA-wcpc-wj8m-hjx6", + "GHSA-vmh5-mc38-953g|@earendil-works/pi-coding-agent>undici", + "GHSA-pr7r-676h-xcf6|@earendil-works/pi-coding-agent>undici", + "GHSA-38rv-x7px-6hhq|@earendil-works/pi-coding-agent>undici", + "GHSA-p88m-4jfj-68fv|@earendil-works/pi-coding-agent>undici", + "GHSA-vxpw-j846-p89q|@earendil-works/pi-coding-agent>undici", +]); + +function readText(url: URL): string { + return readFileSync(url, "utf8"); +} + +function parseAuditConfig(): AuditConfig { + const lines = readText(AUDIT_CONFIG_PATH) + .split("\n") + .filter((line) => !line.trimStart().startsWith("//")); + return JSON.parse(lines.join("\n")) as AuditConfig; +} + +function parsePackageJson(): PackageJson { + return JSON.parse(readText(PACKAGE_JSON_PATH)) as PackageJson; +} + +function parseMatrixEntries(workflow: string): Set { + const entries = workflow.matchAll(/- os: ([^\n]+)\n\s+node-version: "([^"]+)"/g); + return new Set(Array.from(entries, ([, os, node]) => `${os.trim()}@${node}`)); +} + +function stepIndex(workflow: string, step: string): number { + const index = workflow.indexOf(`- name: ${step}`); + assert.notEqual(index, -1, `missing workflow step: ${step}`); + return index; +} + +function allowlistEntries(config: AuditConfig): Array<[string, AuditRecord]> { + return config.allowlist.map((entry) => { + const [key, value] = Object.entries(entry)[0] ?? []; + assert.ok(key, "allowlist entry must define exactly one scoped advisory path"); + assert.ok(value, `missing metadata for allowlist entry: ${key}`); + return [key, value]; + }); +} + +function parseIsoDate(value: string): number { + const timestamp = Date.parse(`${value}T00:00:00Z`); + assert.notEqual(Number.isNaN(timestamp), true, `invalid ISO date: ${value}`); + return timestamp; +} + +function minimumNodeVersion(value: string): number { + const match = value.match(/^>=(?\d+)$/); + assert.ok(match?.groups?.major, `unsupported engines.node format: ${value}`); + return Number.parseInt(match.groups.major, 10); +} + +function runAuditCi(): void { + const result = spawnSync(process.execPath, [AUDIT_CLI_PATH, "--config", "audit-ci.jsonc"], { + cwd: REPO_ROOT, + encoding: "utf8", + }); + assert.equal(result.status, 0, [result.stdout, result.stderr].filter(Boolean).join("\n")); +} + +test("audit-ci config keeps an expiry-tracked, path-scoped allowlist", () => { + const config = parseAuditConfig(); + assert.equal(config.$schema, AUDIT_SCHEMA); + assert.equal(config.moderate, true); + + const entries = allowlistEntries(config); + assert.deepEqual(new Set(entries.map(([key]) => key)), EXPECTED_ALLOWLIST_KEYS); + const today = Date.parse(new Date().toISOString().slice(0, 10) + "T00:00:00Z"); + for (const [key, value] of entries) { + assert.match(key, /^GHSA-[a-z0-9-]+(\|.*)?$/); + assert.equal(value.active, true); + assert.match(value.expiry, /^\d{4}-\d{2}-\d{2}$/); + assert.ok(parseIsoDate(value.expiry) >= today, `expired allowlist entry: ${key}`); + assert.notEqual(value.notes.trim(), ""); + } +}); + +test("workflow keeps the expected matrix and audit/test order", () => { + const workflow = readText(WORKFLOW_PATH); + const packageJson = parsePackageJson(); + assert.match(workflow, /fail-fast:\s+false/); + assert.deepEqual(parseMatrixEntries(workflow), EXPECTED_MATRIX); + assert.ok(stepIndex(workflow, "Security audit") < stepIndex(workflow, "Unit tests")); + assert.ok(stepIndex(workflow, "Unit tests") < stepIndex(workflow, "E2E tests")); + assert.match(workflow, /run: npx audit-ci --config audit-ci\.jsonc/); + assert.ok(EXPECTED_MATRIX.has(`ubuntu-latest@${minimumNodeVersion(packageJson.engines.node)}`)); +}); + + +test("audit-ci config matches the current lockfile vulnerabilities", () => { + runAuditCi(); +}); diff --git a/tests/unit/handoff-eligibility.test.ts b/tests/unit/handoff-eligibility.test.ts new file mode 100644 index 0000000..49c4ef4 --- /dev/null +++ b/tests/unit/handoff-eligibility.test.ts @@ -0,0 +1,75 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fc from "fast-check"; +import { + estimateHandoffContextTokens, + formatHandoffContextUsage, + isHandoffEligible, + MIN_HANDOFF_TOKENS, + normalizeContextPercent, +} from "../../handoff/eligibility.js"; + +test("handoff eligibility prefers explicit tokens", () => { + assert.equal(estimateHandoffContextTokens({ tokens: MIN_HANDOFF_TOKENS, percent: 1, contextWindow: 1 }), MIN_HANDOFF_TOKENS); + assert.equal(isHandoffEligible({ tokens: MIN_HANDOFF_TOKENS }), true); + assert.equal(isHandoffEligible({ tokens: MIN_HANDOFF_TOKENS - 1 }), false); +}); + +test("handoff eligibility estimates tokens from percent and context window", () => { + assert.equal(estimateHandoffContextTokens({ tokens: null, percent: 15, contextWindow: 200_000 }), 30_000); + assert.equal(isHandoffEligible({ tokens: null, percent: 15.001, contextWindow: 200_000 }), true); + assert.equal(isHandoffEligible({ tokens: null, percent: 125, contextWindow: 200_000 }), true); + assert.equal(isHandoffEligible({ tokens: null, percent: 14.9, contextWindow: 200_000 }), false); + assert.equal(estimateHandoffContextTokens({ tokens: null, percent: 14.9999, contextWindow: 200_000 }), 29_999.8); + assert.equal(isHandoffEligible({ tokens: null, percent: 14.9999, contextWindow: 200_000 }), false); +}); + +test("context percentage normalization accepts finite non-negative usage, including overflow", () => { + assert.equal(normalizeContextPercent(0), 0); + assert.equal(normalizeContextPercent(100), 100); + assert.equal(normalizeContextPercent(125), 125); + for (const percent of [-1, Number.NaN, Number.POSITIVE_INFINITY, null, undefined]) { + assert.equal(normalizeContextPercent(percent), null); + } +}); + +test("handoff eligibility rejects malformed or unestimable usage", () => { + for (const usage of [ + null, + { tokens: Number.NaN }, + { tokens: -1 }, + { tokens: null, percent: Number.NaN, contextWindow: 200_000 }, + { tokens: null, percent: Number.POSITIVE_INFINITY, contextWindow: 200_000 }, + { tokens: null, percent: -1, contextWindow: 200_000 }, + { tokens: null, percent: 20, contextWindow: 0 }, + ]) { + assert.equal(estimateHandoffContextTokens(usage), null); + assert.equal(isHandoffEligible(usage), false); + } +}); + +test("handoff eligibility rejects arithmetic overflow", () => { + const usage = { tokens: null, percent: 100, contextWindow: Number.MAX_VALUE }; + assert.equal(estimateHandoffContextTokens(usage), null); + assert.equal(isHandoffEligible(usage), false); + assert.equal(formatHandoffContextUsage(usage), "~unknown tokens estimated from context usage"); +}); + +test("handoff eligibility is monotonic as explicit context tokens grow", () => { + fc.assert( + fc.property(fc.integer({ min: 0, max: 100_000 }), fc.integer({ min: 0, max: 100_000 }), (base, additional) => { + const initial = isHandoffEligible({ tokens: base }); + const larger = isHandoffEligible({ tokens: base + additional }); + assert.ok(!initial || larger, `${base} eligible but ${base + additional} was not`); + }), + ); +}); + +test("handoff usage formatting distinguishes explicit and estimated values", () => { + assert.equal(formatHandoffContextUsage({ tokens: 12_345 }), "12345 tokens"); + assert.equal( + formatHandoffContextUsage({ tokens: null, percent: 10, contextWindow: 200_000 }), + "~20000 tokens estimated from context usage", + ); + assert.equal(formatHandoffContextUsage(null), "~unknown tokens estimated from context usage"); +}); diff --git a/tests/unit/handoff.test.ts b/tests/unit/handoff.test.ts index 90b5084..3e3131a 100644 --- a/tests/unit/handoff.test.ts +++ b/tests/unit/handoff.test.ts @@ -1,11 +1,13 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { createState } from "../../state.js"; +import { createState, resetState } from "../../state.js"; import { registerHandoffCommand } from "../../handoff/command.js"; import { registerHandoffTool } from "../../handoff/tool.js"; +import { buildEnrichedTask } from "../../handoff/format.js"; import { registerHandoffCompaction } from "../../handoff/compact.js"; import registerAgenticoding from "../../index.js"; import { STATUS_KEY_HANDOFF, WIDGET_KEY_WARNING, updateIndicators } from "../../tui.js"; +import { registerWatchdog } from "../../watchdog.js"; import { createTestPI, makeTUICtx } from "./helpers.js"; test("/handoff sends the direction back through the LLM without opening the editor", async () => { @@ -16,21 +18,21 @@ test("/handoff sends the direction back through the LLM without opening the edit await pi.commands.get("handoff")!.handler("implement auth", { hasUI: true, isIdle: () => true, + getContextUsage: () => null, ui: { notify: (_message: string) => {} }, }); assert.deepEqual(state.pendingRequestedHandoff, { - direction: "implement auth", + resumeReadonlyAfterHandoff: false, enforcementAttempts: 0, toolCalled: false, }); - assert.deepEqual(pi.sentUserMessages, [ - { - content: - "Handoff direction: implement auth\n\nPrepare a handoff in the current session. First, save any durable reusable knowledge that aligns with the direction above to the notebook: findings worth keeping, constraints discovered, decisions made, or other grounding future contexts will need. Then draft a concise but sufficiently detailed handoff brief capturing only the remaining situational context: current state, blockers, unresolved questions, failed paths worth avoiding, and next steps. The next context will read the notebook on demand, so do not duplicate notebook content in the brief. Use any structure that makes the next work unambiguous. Reference notebook pages by name when relevant.", - options: undefined, - }, - ]); + assert.equal(pi.sentUserMessages.length, 1); + assert.match(pi.sentUserMessages[0].content, /Handoff direction: implement auth/); + assert.match(pi.sentUserMessages[0].content, /Prepare a handoff in the current session now/); + assert.match(pi.sentUserMessages[0].content, /A real handoff is required in the current session/); + assert.doesNotMatch(pi.sentUserMessages[0].content, /User explicitly requested|\/handoff/); + assert.equal(pi.sentUserMessages[0].options, undefined); }); test("/handoff requires a direction", async () => { @@ -53,7 +55,7 @@ test("handoff tool triggers compaction and resumes with the compacted task", asy const pi = createTestPI(); const state = createState(); state.notebookPages.set("auth-refresh", "sensitive notebook body"); - state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; + state.pendingRequestedHandoff = { toolCalled: false, resumeReadonlyAfterHandoff: true, enforcementAttempts: 0 }; registerHandoffTool(pi as any, state); let compactOptions: any; @@ -63,6 +65,7 @@ test("handoff tool triggers compaction and resumes with the compacted task", asy undefined, undefined, { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), compact: (options: any) => { compactOptions = options; }, @@ -70,14 +73,9 @@ test("handoff tool triggers compaction and resumes with the compacted task", asy ); assert.equal(state.pendingHandoff?.source, "tool"); - // Structural: verify the task contains the handoff template structure, - // not exact phrasing (template wording may evolve). - assert.match(state.pendingHandoff?.task ?? "", /## Handoff/); - assert.match(state.pendingHandoff?.task ?? "", /notebook/i); - assert.match(state.pendingHandoff?.task ?? "", /task|context|situational/i); - // The user's task content is the actual contract — keep exact match. - assert.match(state.pendingHandoff?.task ?? "", /Goal: continue auth-refresh/); - assert.doesNotMatch(state.pendingHandoff?.task ?? "", /sensitive notebook body/); + // Queue only the user brief. The compaction hook renders the primer at the + // cut so it can include readonly mode as it exists at that moment. + assert.equal(state.pendingHandoff?.task, "Goal: continue auth-refresh"); assert.equal(state.pendingRequestedHandoff?.toolCalled, true); assert.equal(typeof compactOptions?.onComplete, "function"); assert.equal(result.content[0].text, "Handoff started."); @@ -90,8 +88,8 @@ test("handoff tool triggers compaction and resumes with the compacted task", asy test("handoff compaction replaces old context with the queued task", async () => { const pi = createTestPI(); const state = createState(); - state.pendingHandoff = { task: "Goal: continue", source: "tool" }; - state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 1, toolCalled: true }; + state.pendingHandoff = { task: "Goal: continue", source: "tool", generation: state.handoffGeneration }; + state.pendingRequestedHandoff = { enforcementAttempts: 1, toolCalled: true, resumeReadonlyAfterHandoff: false }; state.activeNotebookTopic = "oauth"; state.activeNotebookTopicSource = "human"; registerHandoffCompaction(pi as any, state); @@ -106,13 +104,15 @@ test("handoff compaction replaces old context with the queued task", async () => ); assert.equal(state.pendingHandoff, null); - assert.equal(state.pendingRequestedHandoff, null); - assert.equal(state.activeNotebookTopic, null); - assert.equal(state.activeNotebookTopicSource, null); - assert.equal(result.compaction.summary, "Goal: continue"); + assert.notEqual(state.pendingRequestedHandoff, null, "pendingRequestedHandoff stays until onComplete in tool.ts"); + // Notebook topic is cleared in handoff tool's onComplete, not in compaction itself + assert.equal(state.activeNotebookTopic, "oauth"); + assert.equal(state.activeNotebookTopicSource, "human"); + const task = buildEnrichedTask("Goal: continue"); + assert.equal(result.compaction.summary, task); assert.equal(result.compaction.tokensBefore, 123); assert.equal(result.compaction.firstKeptEntryId, "leaf-1-handoff-cut"); - assert.deepEqual(result.compaction.details, { handoff: true, task: "Goal: continue" }); + assert.deepEqual(result.compaction.details, { handoff: true, task }); }); test("/handoff sets the handoff status indicator", async () => { @@ -129,15 +129,66 @@ test("/handoff sets the handoff status indicator", async () => { notify: () => {}, setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, }, + getContextUsage: () => null, }); - assert.equal(statuses.get(STATUS_KEY_HANDOFF), "🤝 Handoff in progress"); + assert.equal(statuses.get(STATUS_KEY_HANDOFF), "🤝 Handoff requested — waiting for eligible context"); +}); + +test("/handoff shows ready status when context is eligible", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffCommand(pi as any, state); + const statuses = new Map(); + + await pi.commands.get("handoff")!.handler("implement auth", { + hasUI: true, + isIdle: () => true, + getContextUsage: () => ({ tokens: 50_000, percent: 25, contextWindow: 200_000 }), + ui: { + theme: { fg: (_name: string, text: string) => text }, + notify: () => {}, + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, + }, + }); + + assert.equal(statuses.get(STATUS_KEY_HANDOFF), "🤝 Handoff required — ready to compact"); +}); + +test("handoff status becomes ready when later context becomes eligible", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const statuses = new Map(); + const commandContext = { + hasUI: true, + isIdle: () => true, + getContextUsage: () => null, + ui: { + theme: { fg: (_name: string, text: string) => text }, + notify: () => {}, + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, + }, + }; + await pi.commands.get("handoff")!.handler("implement auth", commandContext); + assert.equal(statuses.get(STATUS_KEY_HANDOFF), "🤝 Handoff requested — waiting for eligible context"); + + const [context] = pi.handlers.get("context")!; + await context( + { messages: [] }, + { + hasUI: true, + getContextUsage: () => ({ tokens: 50_000, percent: 25, contextWindow: 200_000 }), + ui: commandContext.ui, + }, + ); + + assert.equal(statuses.get(STATUS_KEY_HANDOFF), "🤝 Handoff required — ready to compact"); }); test("handoff compaction clears the handoff status indicator", async () => { const pi = createTestPI(); const state = createState(); - state.pendingHandoff = { task: "Goal: continue", source: "tool" }; + state.pendingHandoff = { task: "Goal: continue", source: "tool", generation: state.handoffGeneration }; registerHandoffCompaction(pi as any, state); const statuses = new Map(); const [handler] = pi.handlers.get("session_before_compact")!; @@ -150,12 +201,12 @@ test("handoff compaction clears the handoff status indicator", async () => { assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); }); -test("handoff compaction error clears pending state and status", async () => { +test("handoff success sends a completion notification", async () => { const pi = createTestPI(); const state = createState(); - state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; registerHandoffTool(pi as any, state); let compactOptions: any; + const notifications: Array<{ message: string; level: string }> = []; const statuses = new Map(); await pi.tools.get("handoff").execute( @@ -164,25 +215,278 @@ test("handoff compaction error clears pending state and status", async () => { undefined, undefined, { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), hasUI: true, - ui: { setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); } }, + ui: { + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, + notify: (message: string, level: string) => { notifications.push({ message, level }); }, + }, compact: (options: any) => { compactOptions = options; }, }, ); - compactOptions.onError({}); + compactOptions.onComplete(); + + assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); + assert.ok(notifications.some((n) => n.message.includes("Handoff complete") && n.level === "info")); + assert.equal(pi.sentUserMessages.at(-1)?.content, "Proceed."); +}); + +test("handoff compaction error restores a ready retry status when eligible", async () => { + const pi = createTestPI(); + const state = createState(); + state.pendingRequestedHandoff = { toolCalled: false, resumeReadonlyAfterHandoff: true, enforcementAttempts: 0 }; + registerHandoffTool(pi as any, state); + let compactOptions: any; + const statuses = new Map(); + const notifications: Array<{ message: string; level: string }> = []; + + await pi.tools.get("handoff").execute( + "1", + { task: "Goal: continue" }, + undefined, + undefined, + { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + hasUI: true, + ui: { + theme: { fg: (_name: string, text: string) => text }, + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, + notify: (message: string, level: string) => { notifications.push({ message, level }); }, + }, + compact: (options: any) => { compactOptions = options; }, + }, + ); + compactOptions.onError(new Error("Nothing to compact (session too small)")); assert.equal(state.pendingHandoff, null); assert.equal(state.pendingRequestedHandoff?.toolCalled, false); - assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); + assert.equal(statuses.get(STATUS_KEY_HANDOFF), "🤝 Handoff required — ready to compact"); + assert.deepEqual(notifications, [{ message: "Handoff compaction failed: Nothing to compact (session too small). The handoff can be retried.", level: "error" }]); + // onError re-engages the LLM via sendUserMessage + assert.ok(pi.sentUserMessages.length > 0); + assert.match(pi.sentUserMessages[pi.sentUserMessages.length - 1].content, /Handoff failed/); +}); + +test("synchronous compaction failure restores a retryable pending handoff", async () => { + const pi = createTestPI(); + const state = createState(); + state.pendingRequestedHandoff = { toolCalled: false, resumeReadonlyAfterHandoff: true, enforcementAttempts: 0 }; + registerHandoffTool(pi as any, state); + const statuses = new Map([[STATUS_KEY_HANDOFF, "🤝 Handoff in progress"]]); + + await assert.rejects( + () => pi.tools.get("handoff").execute( + "sync-failure", + { task: "continue work" }, + undefined, + undefined, + { + hasUI: true, + ui: { + theme: { fg: (_name: string, text: string) => text }, + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, + notify: () => {}, + }, + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + compact: () => { throw new Error("synchronous compact failure"); }, + }, + ), + /synchronous compact failure/, + ); + + assert.equal(state.pendingHandoff, null); + assert.equal(state.pendingRequestedHandoff?.toolCalled, false); + assert.equal(statuses.get(STATUS_KEY_HANDOFF), "🤝 Handoff required — ready to compact"); + assert.match(pi.sentUserMessages.at(-1)?.content ?? "", /Handoff failed/); +}); + +test("failed handoff shows waiting status when usage becomes unavailable", async () => { + const pi = createTestPI(); + const state = createState(); + state.pendingRequestedHandoff = { toolCalled: false, resumeReadonlyAfterHandoff: false, enforcementAttempts: 0 }; + registerHandoffTool(pi as any, state); + let compactOptions: any; + let usage: { tokens: number; percent: number; contextWindow: number } | null = { + tokens: 50_000, percent: 25, contextWindow: 200_000, + }; + const statuses = new Map(); + + await pi.tools.get("handoff").execute("1", { task: "Goal: continue" }, undefined, undefined, { + hasUI: true, + ui: { + theme: { fg: (_name: string, text: string) => text }, + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, + notify: () => {}, + }, + getContextUsage: () => usage, + compact: (options: any) => { compactOptions = options; }, + }); + usage = null; + compactOptions.onError(new Error("host failed")); + + assert.equal(statuses.get(STATUS_KEY_HANDOFF), "🤝 Handoff requested — waiting for eligible context"); +}); + +test("handoff rejects overlapping compaction and preserves the first task", async () => { + const pi = createTestPI(); + const state = createState(); + let firstCallbacks: any; + registerHandoffTool(pi as any, state); + + await pi.tools.get("handoff").execute("first", { task: "first" }, undefined, undefined, { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + compact: (options: any) => { firstCallbacks = options; }, + }); + await assert.rejects( + () => pi.tools.get("handoff").execute("second", { task: "second" }, undefined, undefined, { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + compact: () => {}, + }), + /handoff compaction already in progress/i, + ); + assert.equal(state.pendingHandoff?.task, "first"); + + firstCallbacks.onComplete(); + assert.equal(state.pendingHandoff, null); + assert.deepEqual(pi.sentUserMessages.map((message: any) => message.content), ["Proceed."]); +}); + +test("/handoff rejects a replacement while compaction is reserved", async () => { + const pi = createTestPI(); + const state = createState(); + let firstCallbacks: any; + registerHandoffCommand(pi as any, state); + registerHandoffTool(pi as any, state); + + await pi.commands.get("handoff")!.handler("first", { hasUI: false, isIdle: () => true } as any); + await pi.tools.get("handoff").execute("first", { task: "first" }, undefined, undefined, { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + compact: (options: any) => { firstCallbacks = options; }, + }); + + await assert.rejects( + () => pi.commands.get("handoff")!.handler("second", { hasUI: false, isIdle: () => true } as any), + /handoff compaction already in progress/i, + ); + assert.equal(state.pendingHandoff?.task, "first"); + assert.equal(state.pendingRequestedHandoff?.toolCalled, true); + + firstCallbacks.onComplete(); + assert.equal(state.pendingHandoff, null); + assert.equal(state.pendingRequestedHandoff, null); +}); + +test("failed compaction releases the overlap guard without mutating state twice", async () => { + const pi = createTestPI(); + const state = createState(); + let firstCallbacks: any; + let secondCallbacks: any; + const notifications: string[] = []; + registerHandoffTool(pi as any, state); + + await pi.tools.get("handoff").execute("first", { task: "first" }, undefined, undefined, { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + compact: (options: any) => { firstCallbacks = options; }, + }); + await assert.rejects( + () => pi.tools.get("handoff").execute("second", { task: "second" }, undefined, undefined, { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + compact: () => {}, + }), + /handoff compaction already in progress/i, + ); + + firstCallbacks.onError(new Error("first failure")); + assert.equal(state.pendingHandoff, null); + assert.deepEqual(notifications, []); + assert.match(pi.sentUserMessages.at(-1)?.content ?? "", /Handoff failed/); + + await pi.tools.get("handoff").execute("second", { task: "second" }, undefined, undefined, { + hasUI: true, + ui: { notify: () => {}, setStatus: () => {} } as any, + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + compact: (options: any) => { secondCallbacks = options; }, + }); + secondCallbacks.onComplete(); + assert.deepEqual((pi.sentUserMessages as any[]).map((message: any) => message.content), ["Handoff failed — first failure. No required handoff remains pending; retry when ready.", "Proceed."]); +}); + +test("reset invalidates late handoff callbacks", async () => { + const pi = createTestPI(); + const state = createState(); + let callbacks: any; + registerHandoffTool(pi as any, state); + + await pi.tools.get("handoff").execute("reset", { task: "reset" }, undefined, undefined, { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + compact: (options: any) => { callbacks = options; }, + }); + resetState(state); + state.pendingHandoff = { task: "new state", source: "tool", generation: state.handoffGeneration }; + state.pendingRequestedHandoff = { toolCalled: true, resumeReadonlyAfterHandoff: false, enforcementAttempts: 0 }; + callbacks.onComplete(); + + assert.equal(state.pendingHandoff?.task, "new state"); + assert.equal(state.pendingRequestedHandoff?.toolCalled, true); + assert.equal(pi.sentUserMessages.length, 0); +}); + +test("handoff terminal callbacks are idempotent", async () => { + const pi = createTestPI(); + const state = createState(); + let compactOptions: any; + registerHandoffTool(pi as any, state); + + await pi.tools.get("handoff").execute( + "callbacks", + { task: "continue work" }, + undefined, + undefined, + { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + compact: (options: any) => { compactOptions = options; }, + }, + ); + + compactOptions.onComplete({}); + compactOptions.onComplete({}); + compactOptions.onError(new Error("late failure")); + + assert.equal(pi.sentUserMessages.filter((message: any) => message.content === "Proceed.").length, 1); + assert.equal(state.pendingHandoff, null); +}); + +test("handoff rejects malformed numeric context usage", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffTool(pi as any, state); + + for (const usage of [ + { tokens: Number.NaN, percent: 20, contextWindow: 200000 }, + { tokens: Number.POSITIVE_INFINITY, percent: 20, contextWindow: 200000 }, + { tokens: null, percent: Number.NaN, contextWindow: 200000 }, + ]) { + await assert.rejects( + () => pi.tools.get("handoff").execute( + "invalid-usage", + { task: "continue work" }, + undefined, + undefined, + { getContextUsage: () => usage }, + ), + /Context usage unavailable/, + ); + } }); -test("turn_end fallback clears stale requested handoff status", async () => { +test("turn_end fallback keeps requested handoff status sticky until real handoff happens", async () => { const pi = createTestPI(); registerAgenticoding(pi as any); const statuses = new Map(); await pi.commands.get("handoff")!.handler("implement auth", { hasUI: true, isIdle: () => true, + getContextUsage: () => null, ui: { theme: { fg: (_name: string, text: string) => text }, notify: () => {}, @@ -201,7 +505,348 @@ test("turn_end fallback clears stale requested handoff status", async () => { getContextUsage: () => null, }); - assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); + assert.equal(statuses.get(STATUS_KEY_HANDOFF), "🤝 Handoff requested — waiting for eligible context"); +}); + +test("handoff tool metadata describes when to use and the call-handoff rule", () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffTool(pi as any, state); + + const tool = pi.tools.get("handoff"); + assert.match(tool.description, /past ~30%/i); + assert.match(tool.description, /call handoff/i); +}); + +test("buildEnrichedTask includes execution constraints when resumeReadonlyAfterHandoff is true", () => { + const task = buildEnrichedTask("continue billing work", { resumeReadonlyAfterHandoff: true }); + assert.match(task, /Execution Constraints/i); + assert.match(task, /readonly mode/i); + assert.match(task, /handoff-only exception.*no longer active/i); +}); + +test("buildEnrichedTask omits execution constraints when resumeReadonlyAfterHandoff is false", () => { + const task = buildEnrichedTask("continue billing work", { resumeReadonlyAfterHandoff: false }); + assert.doesNotMatch(task, /Execution Constraints/i); +}); + +test("buildEnrichedTask omits execution constraints by default", () => { + const task = buildEnrichedTask("continue billing work"); + assert.doesNotMatch(task, /Execution Constraints/i); +}); + +test("handoff tool rejects empty task with context usage", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffTool(pi as any, state); + + await assert.rejects( + () => pi.tools.get("handoff").execute( + "1", + { task: "" }, + undefined, + undefined, + { getContextUsage: () => ({ percent: 42 }) }, + ), + (error: unknown) => error instanceof Error && + error.message.includes("Empty handoff rejected") && + error.message.includes("42%"), + ); + + assert.equal(state.pendingHandoff, null, "empty handoff must not queue state"); +}); + +test("handoff tool rejects small session with clear error", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffTool(pi as any, state); + + await assert.rejects( + () => pi.tools.get("handoff").execute( + "1", + { task: "Goal: continue" }, + undefined, + undefined, + { getContextUsage: () => ({ tokens: 5000, percent: 2.5, contextWindow: 200000 }) }, + ), + (error: unknown) => + error instanceof Error && + error.message.includes("handoff unavailable yet") && + error.message.includes("~3% (5000 tokens)") && + error.message.includes("Continue working"), + ); + + assert.equal(state.pendingHandoff, null, "small-session rejection must not queue state"); +}); + +test("handoff tool preserves pending requested handoff and re-engages LLM after synchronous small-session rejection", async () => { + const pi = createTestPI(); + const state = createState(); + state.pendingRequestedHandoff = { toolCalled: false, resumeReadonlyAfterHandoff: true, enforcementAttempts: 0 }; + registerHandoffTool(pi as any, state); + const statuses = new Map([[STATUS_KEY_HANDOFF, "🤝 Handoff in progress"]]); + const notifications: Array<{ message: string; level: string }> = []; + + await assert.rejects( + () => pi.tools.get("handoff").execute( + "1", + { task: "Goal: continue" }, + undefined, + undefined, + { + hasUI: true, + ui: { + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, + notify: (message: string, level: string) => { notifications.push({ message, level }); }, + }, + getContextUsage: () => ({ tokens: 5000, percent: 2.5, contextWindow: 200000 }), + }, + ), + ); + + assert.deepEqual(state.pendingRequestedHandoff, { + toolCalled: false, + resumeReadonlyAfterHandoff: true, + enforcementAttempts: 0, + }); + assert.equal(statuses.get(STATUS_KEY_HANDOFF), "🤝 Handoff in progress"); + // sendHandoffFailure re-engages the LLM + assert.ok(pi.sentUserMessages.length > 0); + assert.match(pi.sentUserMessages[pi.sentUserMessages.length - 1].content, /Handoff failed/); + assert.match(pi.sentUserMessages[pi.sentUserMessages.length - 1].content, /required handoff remains pending/); +}); + +test("command handoff waits for eligibility and retries without watchdog cancellation", async () => { + const pi = createTestPI(); + const state = createState(); + let compactOptions: any; + registerHandoffCommand(pi as any, state); + registerHandoffTool(pi as any, state); + registerWatchdog(pi as any, state); + const [watchdogHandler] = pi.handlers.get("agent_end")!; + + await pi.commands.get("handoff")!.handler("continue work", { hasUI: false, isIdle: () => true } as any); + await assert.rejects( + () => pi.tools.get("handoff").execute("small", { task: "continue work" }, undefined, undefined, { + getContextUsage: () => ({ tokens: 5000, percent: 2.5, contextWindow: 200000 }), + }), + ); + await watchdogHandler({}, { hasUI: false, getContextUsage: () => ({ tokens: 5000, percent: 2.5, contextWindow: 200000 }) } as any); + assert.equal(state.pendingRequestedHandoff?.enforcementAttempts, 0); + + await pi.tools.get("handoff").execute("eligible", { task: "continue work" }, undefined, undefined, { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + compact: (options: any) => { compactOptions = options; }, + }); + compactOptions.onComplete(); + assert.equal(state.pendingRequestedHandoff, null); +}); + +test("handoff tool rejects small session with null percent without crashing", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffTool(pi as any, state); + + await assert.rejects( + () => pi.tools.get("handoff").execute( + "1", + { task: "Goal: continue" }, + undefined, + undefined, + { getContextUsage: () => ({ tokens: 5000, percent: null, contextWindow: 200000 }) }, + ), + (error: unknown) => + error instanceof Error && + error.message.includes("handoff unavailable yet") && + error.message.includes("(5000 tokens)") && + error.message.includes("Continue working"), + ); + + assert.equal(state.pendingHandoff, null, "null-percent rejection must not queue state"); +}); + +test("handoff tool rejects small session estimated from percent when tokens are unavailable", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffTool(pi as any, state); + + await assert.rejects( + () => pi.tools.get("handoff").execute( + "1", + { task: "Goal: continue" }, + undefined, + undefined, + { getContextUsage: () => ({ tokens: null, percent: 10, contextWindow: 200000 }) }, + ), + (error: unknown) => + error instanceof Error && + error.message.includes("handoff unavailable yet") && + error.message.includes("~20000 tokens estimated from context usage") && + error.message.includes("Continue working"), + ); + + assert.equal(state.pendingHandoff, null, "estimated small-session rejection must not queue state"); +}); + +test("handoff tool accepts large estimated session when tokens are unavailable", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffTool(pi as any, state); + + await assert.doesNotReject( + () => pi.tools.get("handoff").execute( + "1", + { task: "Goal: continue" }, + undefined, + undefined, + { + getContextUsage: () => ({ tokens: null, percent: 20, contextWindow: 200000 }), + compact: () => {}, + }, + ), + ); + assert.equal(state.pendingHandoff?.source, "tool"); +}); + +test("handoff tool accepts the exact 30000-token minimum", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffTool(pi as any, state); + + await assert.doesNotReject( + () => pi.tools.get("handoff").execute( + "1", + { task: "Goal: continue" }, + undefined, + undefined, + { + getContextUsage: () => ({ tokens: 30000, percent: 15, contextWindow: 200000 }), + compact: () => {}, + }, + ), + ); + assert.equal(state.pendingHandoff?.source, "tool"); +}); + +test("handoff tool rejects session just below the 30000-token minimum", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffTool(pi as any, state); + + await assert.rejects( + () => pi.tools.get("handoff").execute( + "1", + { task: "Goal: continue" }, + undefined, + undefined, + { getContextUsage: () => ({ tokens: 29999, percent: 15, contextWindow: 200000 }) }, + ), + (error: unknown) => + error instanceof Error && + error.message.includes("handoff unavailable yet") && + error.message.includes("29999 tokens") && + error.message.includes("Continue working"), + ); + + assert.equal(state.pendingHandoff, null, "just-below-boundary rejection must not queue state"); +}); + +test("handoff tool rejects whitespace-only task", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffTool(pi as any, state); + + await assert.rejects( + () => pi.tools.get("handoff").execute( + "1", + { task: " \t\n " }, + undefined, + undefined, + { getContextUsage: () => null }, + ), + (error: unknown) => error instanceof Error && + error.message.includes("Empty handoff rejected") && + error.message.includes("?"), + ); +}); + +test("handoff tool rejects when context usage is unavailable", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffTool(pi as any, state); + + await assert.rejects( + () => pi.tools.get("handoff").execute( + "1", + { task: "Goal: continue" }, + undefined, + undefined, + { getContextUsage: () => null }, + ), + (error: unknown) => error instanceof Error && + error.message.includes("Context usage unavailable") && + error.message.includes("rejected"), + ); + + assert.equal(state.pendingHandoff, null, "unavailable usage must not queue state"); +}); + +test("handoff tool preserves pending requested handoff and re-engages LLM after synchronous unavailable-usage rejection", async () => { + const pi = createTestPI(); + const state = createState(); + state.pendingRequestedHandoff = { toolCalled: false, resumeReadonlyAfterHandoff: true, enforcementAttempts: 0 }; + registerHandoffTool(pi as any, state); + const statuses = new Map([[STATUS_KEY_HANDOFF, "🤝 Handoff in progress"]]); + const notifications: Array<{ message: string; level: string }> = []; + + await assert.rejects( + () => pi.tools.get("handoff").execute( + "1", + { task: "Goal: continue" }, + undefined, + undefined, + { + hasUI: true, + ui: { + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, + notify: (message: string, level: string) => { notifications.push({ message, level }); }, + }, + getContextUsage: () => null, + }, + ), + ); + + assert.deepEqual(state.pendingRequestedHandoff, { + toolCalled: false, + resumeReadonlyAfterHandoff: true, + enforcementAttempts: 0, + }); + assert.equal(statuses.get(STATUS_KEY_HANDOFF), "🤝 Handoff in progress"); + // sendHandoffFailure re-engages the LLM + assert.ok(pi.sentUserMessages.length > 0); + assert.match(pi.sentUserMessages[pi.sentUserMessages.length - 1].content, /Handoff failed/); + assert.match(pi.sentUserMessages[pi.sentUserMessages.length - 1].content, /required handoff remains pending/); +}); + +test("handoff tool rejects when context usage cannot be estimated", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffTool(pi as any, state); + + await assert.rejects( + () => pi.tools.get("handoff").execute( + "1", + { task: "Goal: continue" }, + undefined, + undefined, + { getContextUsage: () => ({ tokens: null, percent: 20, contextWindow: null }) }, + ), + (error: unknown) => error instanceof Error && + error.message.includes("Context usage unavailable") && + error.message.includes("Continue working"), + ); + + assert.equal(state.pendingHandoff, null, "unestimable usage must not queue state"); }); test("session_start new clears stale handoff status and warning widget", async () => { @@ -227,3 +872,22 @@ test("session_start new clears stale handoff status and warning widget", async ( assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); assert.equal(widgets.get(WIDGET_KEY_WARNING), undefined); }); + +test("session_before_compact ignores a stale generation", async () => { + const pi = createTestPI(); + const state = createState(); + // Queue a handoff at generation N, then bump the generation counter + // as if a newer request superseded it. + state.pendingHandoff = { task: "old", source: "tool", generation: 1 }; + state.handoffGeneration = 2; + registerHandoffCompaction(pi as any, state); + + const [handler] = pi.handlers.get("session_before_compact")!; + const result = await handler( + { preparation: { tokensBefore: 100 }, branchEntries: [{ id: "leaf-1" }] }, + {} as any, + ); + + assert.equal(result, undefined, "generation mismatch must skip compaction"); + assert.equal(state.pendingHandoff?.task, "old", "pendingHandoff must not be cleared for stale generation"); +}); diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts index 59ae5f2..a831cbe 100644 --- a/tests/unit/helpers.ts +++ b/tests/unit/helpers.ts @@ -1,9 +1,13 @@ // ── Shared test helpers ────────────────────────────────────────── // Imported by other test files via `./helpers.js` -// Includes createTestPI(), test utilities, theme constants, etc. +// Includes createTestPI(), test utilities, theme constants, readonly helpers, etc. import type { Theme } from "@earendil-works/pi-coding-agent"; import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import os from "node:os"; +import registerAgenticoding from "../../index.js"; export const theme = { fg: (_name: string, text: string) => text, @@ -87,9 +91,12 @@ export function createTestPI() { const _handlers = new Map(); const _tools = new Map(); const _commands = new Map(); + const _shortcuts = new Map(); + const _flags = new Map(); const _activeTools: string[] = []; const _allToolNames: string[] = []; const _toolSources = new Map(); + const _slashCommands: any[] = []; const _sentUserMessages: Array<{ content: string; options: any }> = []; const _appendedEntries: Array<{ customType: string; data: any }> = []; @@ -143,18 +150,25 @@ export function createTestPI() { setSessionName: () => {}, getSessionName: () => undefined, exec: () => Promise.resolve({ exitCode: 0, stdout: "", stderr: "", code: 0, killed: false, signal: null } as any), - getCommands: () => [], + getCommands: () => [..._slashCommands], + setCommands: (commands: any[]) => { + _slashCommands.length = 0; + _slashCommands.push(...commands); + }, setModel: () => Promise.resolve(true), registerProvider: () => {}, - registerShortcut: () => {}, - registerFlag: () => {}, - getFlag: () => undefined, + registerShortcut: (key: string, def: any) => { _shortcuts.set(key, def); }, + registerFlag: (name: string, def: any) => { + if (!_flags.has(name)) _flags.set(name, def.default); + }, + getFlag: (name: string) => _flags.get(name), registerMessageRenderer: () => {}, setLabel: () => {}, unregisterProvider: () => {}, events: { on: () => () => {}, emit: () => {} } as import("@earendil-works/pi-coding-agent").EventBus, setEditorText: () => {}, get commands() { return _commands; }, + get shortcuts() { return _shortcuts; }, get tools() { return _tools; }, get handlers() { return _handlers; }, get activeTools() { return _activeTools; }, @@ -162,6 +176,7 @@ export function createTestPI() { _activeTools.length = 0; _activeTools.push(...tools); }, + get flags() { return _flags; }, get sentUserMessages() { return _sentUserMessages; }, get appendedEntries() { return _appendedEntries; }, get allToolNames() { return _allToolNames; }, @@ -177,6 +192,68 @@ type _TestPICoversExtensionAPI = typeof createTestPI extends () => import("@eare // eslint-disable-next-line @typescript-eslint/no-unused-vars const _testPIVerified: _TestPICoversExtensionAPI = true; +// ── Readonly test helpers ──────────────────────────────────────────── + +import type registerAgenticodingType from "../../index.js"; // type-only re-export for clients + +export type ToolCall = (event: { toolName: string; input?: Record }, ctx: { cwd?: string }) => Promise; + +/** + * Create a test PI instance with agenticoding registered and the tool_call handler extracted. + */ +export function registerReadonlyPI() { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [toolCall] = pi.handlers.get("tool_call") as ToolCall[]; + return { pi, toolCall }; +} + +/** + * Create a minimal UI context with the required shape for tool_call/auth tests. + */ +export function makeReadonlyUICtx(overrides: Record = {}) { + return { + hasUI: true, + ui: { + notify: () => {}, + theme: { fg: (_name: string, text: string) => text }, + setStatus: () => {}, + setWidget: () => {}, + }, + getContextUsage: () => null, + ...overrides, + }; +} + +// ── Temp directory helpers ────────────────────────────────────────── + +export async function tmpDir(): Promise { + return mkdtemp(join(os.tmpdir(), "pi-test-")); +} + +export async function withTempHome(run: (homeDir: string) => Promise): Promise { + const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; + const homeDir = await tmpDir(); + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + try { + return await run(homeDir); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previousUserProfile; + } + await rm(homeDir, { recursive: true, force: true }); + } +} + export const EMPTY_USAGE = { input: 0, output: 0, diff --git a/tests/unit/notebook.test.ts b/tests/unit/notebook.test.ts index 521a895..8d1d3f3 100644 --- a/tests/unit/notebook.test.ts +++ b/tests/unit/notebook.test.ts @@ -87,6 +87,31 @@ test("notebook rehydration clears stale in-memory notebook state when persisted assert.deepEqual(pi.activeTools, ["notebook_read", "notebook_index"]); }); +test("notebook rehydration ignores null and malformed branch entries", async () => { + const pi = createTestPI(); + const state = createState(); + registerNotebookRehydration(pi as any, state); + const [handler] = pi.handlers.get("session_start")!; + + await handler( + {}, + { + sessionManager: { + getBranch: () => [ + null, + undefined, + "bad-string", + { type: "custom", customType: "notebook-entry", data: { epoch: 1, name: "keep", content: "valid" } }, + null, + { customType: "notebook-entry" }, + ], + }, + }, + ); + + assert.equal(state.epoch, 1); + assert.deepEqual(Array.from(state.notebookPages.entries()), [["keep", "valid"]]); +}); test("session_start rehydrates the latest persisted notebook state through the full hook chain", async () => { const pi = createTestPI(); @@ -323,6 +348,31 @@ test("/notebook notifies with info on first set and warning on boundary assert.equal(widgets.get(WIDGET_KEY_WARNING), undefined); }); +test("readonly /notebook boundary notification explains deferred handoff eligibility", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const notifications: Array<{ message: string; level: string }> = []; + const ctx = { + hasUI: true, + getContextUsage: () => ({ percent: 20 }), + ui: { + theme: { fg: (_name: string, text: string) => text }, + notify: (message: string, level: string) => { notifications.push({ message, level }); }, + setStatus: () => {}, + setWidget: () => {}, + }, + }; + + await pi.commands.get("readonly")!.handler("", ctx as any); + await pi.commands.get("notebook")!.handler("oauth", ctx as any); + await pi.commands.get("notebook")!.handler("billing", ctx as any); + + assert.match(notifications.at(-1)?.message ?? "", /handoff exception activates.*once the context is ready/i); + assert.match(notifications.at(-1)?.message ?? "", /until then this boundary is advisory/i); + assert.doesNotMatch(notifications.at(-1)?.message ?? "", /ask the user for an explicit \/handoff/i); +}); + + test("/notebook empty overlay renders empty state and closes on input", async () => { const pi = createTestPI(); registerAgenticoding(pi as any); @@ -349,7 +399,6 @@ test("/notebook empty overlay renders empty state and closes on input", async () test("/notebook selection previews the chosen entry", async () => { const pi = createTestPI(); registerAgenticoding(pi as any); - const notifications: string[] = []; const notebookWrite = pi.tools.get("notebook_write"); await notebookWrite.execute("1", { name: "alpha", content: "body line\nsecond line" }, undefined, undefined, makeTUICtx()); let overlay: any; @@ -362,7 +411,6 @@ test("/notebook selection previews the chosen entry", async () => { custom: async (build: any) => { overlay = build({ requestRender: () => {} }, theme, {}, () => { doneCalls++; }); }, - notify: (message: string) => { notifications.push(message); }, }, }); @@ -372,7 +420,6 @@ test("/notebook selection previews the chosen entry", async () => { const bodyLines = stripAnsi(overlay.render(120).join("\n")); assert.match(bodyLines, /body line/); assert.match(bodyLines, /alpha/); - // Second keypress closes the overlay overlay.handleInput("\r"); assert.equal(doneCalls, 1); @@ -393,7 +440,6 @@ test("/notebook overlay sorts entries consistently", async () => { custom: async (build: any) => { overlay = build({ requestRender: () => {} }, theme, {}, () => {}); }, - notify: () => {}, }, }); diff --git a/tests/unit/os-sandbox.test.ts b/tests/unit/os-sandbox.test.ts new file mode 100644 index 0000000..ad0701b --- /dev/null +++ b/tests/unit/os-sandbox.test.ts @@ -0,0 +1,126 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs"; +import { execFileSync } from "node:child_process"; +import { + buildMacProfile, + canUseOsSandbox, + quoteShellArgument, + wrapCommandWithOsSandbox, + wrapWithBwrap, +} from "../../os-sandbox.js"; +import { resolveRealPath } from "../../resolve-path.js"; + +function hasPosixShell(): boolean { + if (process.platform === "win32") return false; + try { + execFileSync("/bin/sh", ["-c", "true"], { stdio: "ignore", timeout: 2000 }); + return true; + } catch { + return false; + } +} + +test("wrapped command blocks non-temp writes and allows temp writes", () => { + if (!canUseOsSandbox()) return; + + const outsidePath = path.join(process.cwd(), `.pi-readonly-outside-${Date.now()}`); + const insideDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-readonly-sandbox-")); + const insidePath = path.join(insideDir, "inside.txt"); + try { + assert.throws( + () => execFileSync("/bin/bash", ["-c", wrapCommandWithOsSandbox(`echo blocked > "${outsidePath}"`)], { encoding: "utf8", timeout: 5000 }), + /(Operation not permitted|Permission denied|readonly mode)/, + "sandbox should block writes outside temp", + ); + assert.equal(fs.existsSync(outsidePath), false, "outside temp file should not be created"); + + execFileSync("/bin/bash", ["-c", wrapCommandWithOsSandbox(`echo allowed > "${insidePath}"`)], { encoding: "utf8", timeout: 5000 }); + assert.equal(fs.readFileSync(insidePath, "utf8").trim(), "allowed", "sandbox should allow temp writes"); + } finally { + try { fs.rmSync(outsidePath, { force: true }); } catch { /* best-effort cleanup */ } + try { fs.rmSync(insideDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } + } +}); + +// ── buildMacProfile invariants ────────────────────────────────── + +test("buildMacProfile rejects paths containing quotes", () => { + assert.throws(() => buildMacProfile("/tmp/evil'"), /contain.*quote/); + assert.throws(() => buildMacProfile('/tmp/evil"'), /contain.*quote/); +}); + +test("quotes dynamic Linux sandbox paths for the outer shell", () => { + const tempPath = "/tmp/readonly' ; echo injected; #"; + const canonicalPath = resolveRealPath(tempPath); + const quotedPath = quoteShellArgument(canonicalPath); + const wrapped = wrapWithBwrap("true", tempPath); + + assert.equal(quoteShellArgument("a'b"), `'a'"'"'b'`); + assert.ok(wrapped.includes(`--bind ${quotedPath} ${quotedPath}`)); + assert.equal(wrapped.includes(`--bind "${canonicalPath}" "${canonicalPath}"`), false); +}); + +test("bwrap executes a quoted bind path without shell injection", () => { + if (process.platform !== "linux" || !canUseOsSandbox()) return; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-readonly-quote'-")); + const target = path.join(dir, "result.txt"); + try { + execFileSync( + "/bin/bash", + ["-c", wrapWithBwrap(`printf wrapped > ${quoteShellArgument(target)}`, dir)], + { encoding: "utf8", timeout: 5000 }, + ); + assert.equal(fs.readFileSync(target, "utf8"), "wrapped"); + } finally { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } + } +}); + +test("quoteShellArgument emits POSIX single-quoted arguments", () => { + // POSIX shell single-quote invariant: input is wrapped in single quotes. + // Embedded single quotes close, quote, and reopen the outer argument. + assert.equal(quoteShellArgument(""), "''"); + assert.equal(quoteShellArgument("normal"), "'normal'"); + assert.equal(quoteShellArgument("'"), `''"'"''`); +}); + +test("quoteShellArgument round-trips through a POSIX shell", () => { + if (!hasPosixShell()) return; + + for (const raw of ["normal", "spaces", "dollar$ign", "backtick`", "line1\nline2", "'mixed' quotes", "'"]) { + const quoted = quoteShellArgument(raw); + const result = execFileSync("/bin/sh", ["-c", `printf '%s' ${quoted}`], { encoding: "utf8", timeout: 2000 }); + assert.equal(result, raw, `quoteShellArgument(${JSON.stringify(raw)}) should round-trip`); + } +}); + +// ── Behavioral contract: observable sandbox effects ────────────── + +test("sandbox allows writes to /dev/null", () => { + if (!canUseOsSandbox()) return; + + // CONTRACT: /dev/null redirects are always allowed (not a real write) + execFileSync("/bin/bash", ["-c", wrapCommandWithOsSandbox('echo discard > /dev/null')], { encoding: "utf8", timeout: 5000 }); +}); + +test("sandbox allows writes through a symlinked temp path", () => { + if (!canUseOsSandbox()) return; + + const realDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-readonly-real-")); + const linkDir = path.join(os.tmpdir(), `pi-readonly-link-${Date.now()}`); + try { + // Create symlink to mimic /tmp → /private/tmp on macOS + fs.symlinkSync(realDir, linkDir); + const insidePath = path.join(linkDir, "via-symlink.txt"); + + execFileSync("/bin/bash", ["-c", wrapCommandWithOsSandbox(`echo symlink-works > "${insidePath}"`)], { encoding: "utf8", timeout: 5000 }); + assert.equal(fs.readFileSync(insidePath, "utf8").trim(), "symlink-works", "sandbox should allow writes through symlinks to temp"); + } finally { + try { fs.rmSync(linkDir, { force: true }); } catch { /* best-effort cleanup */ } + try { fs.rmSync(realDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } + } +}); + diff --git a/tests/unit/readonly-bash-classifier.test.ts b/tests/unit/readonly-bash-classifier.test.ts new file mode 100644 index 0000000..e668177 --- /dev/null +++ b/tests/unit/readonly-bash-classifier.test.ts @@ -0,0 +1,335 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { registerReadonlyPI, makeReadonlyUICtx } from "./helpers.js"; +import type { ToolCall } from "./helpers.js"; + +// ── Helpers ─────────────────────────────────────────────────────── + +async function enableReadonly(pi: ReturnType) { + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); +} + +async function assertBlocked(toolCall: ToolCall, command: string, cwd = "/workspace") { + const result = await toolCall({ toolName: "bash", input: { command } }, { cwd }); + assert.equal(result.block, true, `expected block: ${command}`); +} + +async function assertAllowed(toolCall: ToolCall, command: string, cwd = "/workspace") { + assert.equal(await toolCall({ toolName: "bash", input: { command } }, { cwd }), undefined, `expected allow: ${command}`); +} + +// ── Behavioral contract tests (via tool_call hook — real code path) ── + +test("blocks bash writes outside temp dir", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const outsideTemp = path.join(os.homedir(), "readonly-test-file"); + + // Representative mutation commands targeting a concrete non-temp path. + // Tests the CONTRACT: writes outside temp dir are blocked. + await assertBlocked(toolCall, `touch ${outsideTemp}`); + await assertBlocked(toolCall, `rm -f ${outsideTemp}`); + await assertBlocked(toolCall, `echo "test" > ${outsideTemp}`); +}); + +test("blocks malformed bash input without throwing", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + + for (const command of [undefined, null, 42, false, true, Symbol("test"), BigInt(42), () => {}]) { + const result = await toolCall( + { toolName: "bash", input: { command } } as any, + { cwd: "/workspace" }, + ); + assert.equal(result?.block, true, `expected malformed input to block: ${String(command)}`); + assert.match(result?.reason ?? "", /bash command input must be a string/); + } +}); + +test("allows bash reads and non-mutating commands", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + + // CONTRACT: reads are allowed (catches over-blocking). + await assertAllowed(toolCall, "ls -la"); + await assertAllowed(toolCall, "echo hello"); + await assertAllowed(toolCall, "git status"); + await assertAllowed(toolCall, "git log --oneline"); + await assertAllowed(toolCall, "git stash list"); +}); + +test("allows bash writes to temp dir", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const tmp = os.tmpdir(); + + // CONTRACT: temp dir writes are allowed. + await assertAllowed(toolCall, `echo ok > ${tmp}/readonly-test.txt`); + await assertAllowed(toolCall, `rm ${tmp}/readonly-test.txt`); +}); + +test("blocks command substitutions that write outside temp", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const outsideTemp = path.join(os.homedir(), "readonly-test-file"); + + // CONTRACT: evasion via command substitution is caught. + await assertBlocked(toolCall, `touch $(echo ${outsideTemp})`); +}); + +test("blocks write redirects outside temp dir", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const outsideTemp = path.join(os.homedir(), "readonly-test-file"); + + // CONTRACT: write redirects to non-temp paths are blocked. + await assertBlocked(toolCall, `echo hello > ${outsideTemp}`); +}); + +test("blocks package managers unconditionally", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + + // CONTRACT: package managers blocked regardless of target path. + await assertBlocked(toolCall, "npm install lodash"); + await assertBlocked(toolCall, "pip install requests"); + await assertBlocked(toolCall, "yarn add lodash"); +}); + +test("classifies git commands correctly", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + + // CONTRACT: immutable git commands allowed, mutable blocked. + await assertAllowed(toolCall, "git status"); + await assertAllowed(toolCall, "git log --oneline"); + await assertBlocked(toolCall, "git add ."); + await assertBlocked(toolCall, "git commit -m 'msg'"); +}); + +test("blocks cwd-relative downloads outside temp but allows inside temp", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const tmp = os.tmpdir(); + + // CONTRACT: cwd-relative downloads follow temp-dir rules. + await assertBlocked(toolCall, "curl -O https://example.com/file.txt", "/workspace"); + await assertAllowed(toolCall, "curl -O https://example.com/file.txt", tmp); + await assertBlocked(toolCall, "wget https://example.com/file.txt", "/workspace"); +}); + +test("allows readonly-safe git inspection subcommands", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + + await assertAllowed(toolCall, "git reflog"); + await assertAllowed(toolCall, "git branch -l"); + await assertAllowed(toolCall, "git config -l"); +}); + +test("allows piped read commands", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + + await assertAllowed(toolCall, "cat /etc/hosts | grep localhost"); + await assertAllowed(toolCall, "ls -la | head -5"); + await assertAllowed(toolCall, "git log | grep commit"); +}); + +test("blocks chained commands that write outside temp", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const outside = path.join(os.homedir(), "readonly-test-file"); + + await assertBlocked(toolCall, `echo a && touch ${outside}`); + await assertBlocked(toolCall, `ls; rm -f ${outside}`); + await assertBlocked(toolCall, `echo ok || touch ${outside}`); +}); + +test("allows chained commands that only read or write to temp", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const tmp = os.tmpdir(); + + await assertAllowed(toolCall, "echo a && ls"); + await assertAllowed(toolCall, `echo a > ${tmp}/x && cat ${tmp}/x`); +}); + +test("blocks heredoc redirects outside temp", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const outside = path.join(os.homedir(), "readonly-test-file"); + + await assertBlocked(toolCall, `cat <<'EOF' > ${outside}\nhello\nEOF`); +}); + +test("blocks dd of= outside temp and allows it in temp", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const tmp = os.tmpdir(); + const outside = path.join(os.homedir(), "readonly-test-file"); + + await assertBlocked(toolCall, `dd if=/dev/zero of=${outside} bs=1 count=1`); + await assertAllowed(toolCall, `dd if=/dev/zero of=${tmp}/dd-test bs=1 count=1`); +}); + +test("allows hidden-file globs inside temp dir", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const cwd = await mkdtemp(path.join(os.tmpdir(), "readonly-hidden-allow-")); + + try { + await writeFile(path.join(cwd, ".secret"), "ok"); + await assertAllowed(toolCall, "rm -f *", cwd); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("blocks hidden-file globs outside temp dir", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + // Home dir already has hidden files (.gitconfig, .ssh, etc.) and is + // outside the temp dir — no filesystem writes needed for the glob to match. + await assertBlocked(toolCall, "rm -f *", os.homedir()); +}); + +// ── Untested bash pattern coverage (from review findings) ────────── + +test("blocks sudo commands that write outside temp", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, "sudo rm /etc/passwd"); + await assertBlocked(toolCall, "sudo -u root touch /etc/test"); + await assertBlocked(toolCall, "sudo -h localhost rm /etc/test"); +}); + +test("allows sudo commands that only read", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertAllowed(toolCall, "sudo ls /etc"); + await assertAllowed(toolCall, "sudo cat /etc/hosts"); +}); + +test("blocks env -S with mutation command", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, 'env -S "rm -rf /"'); + await assertBlocked(toolCall, "env -S 'touch /etc/test'"); +}); + +test("allows env with read commands", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertAllowed(toolCall, "env -S 'ls /etc'"); + await assertAllowed(toolCall, "env VAR=value ls /tmp"); +}); + +test("blocks eval and exec wrappers with mutation", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, "eval 'rm /etc/passwd'"); + await assertBlocked(toolCall, "exec touch /etc/test"); +}); + +test("blocks interpreter inline execution that shells out to mutation commands", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, "node -e 'rm /etc/passwd'"); + await assertBlocked(toolCall, "python -c 'touch /etc/test'"); + await assertBlocked(toolCall, "perl -e 'rm /etc/passwd'"); + await assertBlocked(toolCall, "ruby -e 'touch /etc/test'"); +}); + +test("blocks process substitution with mutation", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, "cat <(rm /etc/passwd)"); +}); + +test("blocks xargs with mutation command", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, "echo /etc/test | xargs rm"); + // xargs npm is a known L2 bypass (documented at the top of readonly-bash.ts): + // npm alone has no verb args, so the classifier cannot detect the mutation + // at inspection time — only L1 (OS sandbox) catches this at runtime. + // await assertBlocked(toolCall, "printf 'install' | xargs npm"); +}); + +test("allows xargs with read commands", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertAllowed(toolCall, "echo /tmp/test | xargs ls"); +}); + +test("blocks xargs flag variants with mutation commands", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, "printf '/etc/passwd\n' | xargs -I {} rm {} "); + await assertBlocked(toolCall, "printf '/etc/passwd\0' | xargs -0 rm"); + await assertBlocked(toolCall, "printf '/etc/passwd\n' | xargs -n 1 rm"); +}); + +test("blocks git branch creation", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, "git branch new-branch"); + await assertBlocked(toolCall, "git checkout -b new-branch"); +}); + +test("blocks git tag creation", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, "git tag v1.0.0"); + await assertBlocked(toolCall, "git tag -a v1.0.0 -m 'release'"); +}); + +test("blocks command substitution with nested mutation", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const outside = path.join(os.homedir(), "readonly-test-file"); + await assertBlocked(toolCall, `echo $(touch ${outside})`); + await assertBlocked(toolCall, `echo $(rm /etc/passwd)`); +}); + +test("blocks wget download-dir writes outside temp and allows them in temp", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const tmp = os.tmpdir(); + await assertBlocked(toolCall, "wget -P /workspace https://example.com/file.txt"); + await assertBlocked(toolCall, "wget --directory-prefix=/workspace https://example.com/file.txt"); + await assertAllowed(toolCall, `wget -P ${tmp} https://example.com/file.txt`); +}); + +test("blocks mixed curl output modes outside temp and allows them in temp", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const tmp = os.tmpdir(); + const tmpOutput = path.join(tmp, "out.txt"); + await assertBlocked(toolCall, `curl -o ${tmpOutput} -O https://example.com/file.txt`, "/workspace"); + await assertAllowed(toolCall, `curl -o ${tmpOutput} -O https://example.com/file.txt`, tmp); +}); + +// ── Subshell and nested-wrapper edge cases ────────────────────── + +test("blocks double-parenthesized mutation", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, "((rm /etc/passwd))"); +}); + +test("allows double-parenthesized read command", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertAllowed(toolCall, "((echo hello))"); +}); + +test("blocks nested wrapper sudo+env+xargs", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, "sudo env xargs rm /etc/passwd"); +}); diff --git a/tests/unit/readonly-cache.test.ts b/tests/unit/readonly-cache.test.ts new file mode 100644 index 0000000..cf1d141 --- /dev/null +++ b/tests/unit/readonly-cache.test.ts @@ -0,0 +1,665 @@ +/** + * Readonly cache tests. + * + * Exercises populateFromSkills, populatePromptCacheFromResolvedCommandsAndDirs, + * and cache lookups using real temp files with frontmatter — no mocks, same + * pattern as readonly-bash-classifier.test.ts. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, rm, utimes, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpDir, withTempHome } from "./helpers.js"; +import { + cacheLookupCommand, + cacheLookupCommandIssue, + cacheLookupPrompt, + cacheLookupSkill, + cacheLookupSkillIssue, + populateFromSkills, + populatePromptCacheFromResolvedCommandsAndDirs, +} from "../../readonly-cache.js"; +import { createState } from "../../state.js"; +import type { Skill } from "@earendil-works/pi-coding-agent"; + +// ── Helpers ─────────────────────────────────────────────────────── + +async function writeMd(dir: string, name: string, frontmatter: Record): Promise { + const fm = Object.entries(frontmatter) + .map(([k, v]) => `${k}: ${JSON.stringify(v)}`) + .join("\n"); + const filePath = join(dir, `${name}.md`); + await writeFile(filePath, `---\n${fm}\n---\n\nBody content.\n`); + return filePath; +} + +function makeSkill(name: string, filePath: string): Skill { + return { + name, + description: `Test skill ${name}`, + filePath, + baseDir: "", + sourceInfo: { path: filePath, source: "test", scope: "temporary", origin: "top-level" }, + disableModelInvocation: false, + }; +} + +// ── Tests ───────────────────────────────────────────────────────── + +test("cache lookups return null for unknown names", () => { + const state = createState(); + assert.equal(cacheLookupSkill(state, "nonexistent-skill"), null); + assert.equal(cacheLookupCommand(state, "nonexistent-command"), null); +}); + +test("populateFromSkills caches a skill with readonly: true", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = await writeMd(dir, "my-skill", { readonly: true, description: "Test" }); + populateFromSkills(state, [makeSkill("my-skill", filePath)]); + + assert.equal(cacheLookupSkill(state, "my-skill"), true); + assert.equal(cacheLookupCommand(state, "my-skill"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populateFromSkills caches a skill with readonly: false", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = await writeMd(dir, "safe-skill", { readonly: false, description: "Test" }); + populateFromSkills(state, [makeSkill("safe-skill", filePath)]); + + assert.equal(cacheLookupSkill(state, "safe-skill"), false); + assert.equal(cacheLookupCommand(state, "safe-skill"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populateFromSkills returns null for skill without readonly frontmatter", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = await writeMd(dir, "no-readonly", { description: "Test" }); + populateFromSkills(state, [makeSkill("no-readonly", filePath)]); + + assert.equal(cacheLookupSkill(state, "no-readonly"), null); + assert.equal(cacheLookupCommand(state, "no-readonly"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populateFromSkills silently skips missing file", () => { + const state = createState(); + populateFromSkills(state, [makeSkill("missing", "/nonexistent/path/skill.md")]); + assert.equal(cacheLookupSkill(state, "missing"), null); + assert.equal(cacheLookupCommand(state, "missing"), null); +}); + +test("populateFromSkills caches multiple skills", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const fp1 = await writeMd(dir, "alpha", { readonly: true, description: "A" }); + const fp2 = await writeMd(dir, "beta", { readonly: false, description: "B" }); + const fp3 = await writeMd(dir, "gamma", { description: "C" }); + populateFromSkills(state, [ + makeSkill("alpha", fp1), + makeSkill("beta", fp2), + makeSkill("gamma", fp3), + ]); + + assert.equal(cacheLookupSkill(state, "alpha"), true); + assert.equal(cacheLookupSkill(state, "beta"), false); + assert.equal(cacheLookupSkill(state, "gamma"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populateFromSkills returns null for non-boolean readonly frontmatter", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const fp1 = await writeMd(dir, "string-ro", { readonly: "yes" }); + const fp2 = await writeMd(dir, "number-ro", { readonly: 1 }); + const fp3 = await writeMd(dir, "array-ro", { readonly: [true] }); + populateFromSkills(state, [ + makeSkill("string-ro", fp1), + makeSkill("number-ro", fp2), + makeSkill("array-ro", fp3), + ]); + + assert.equal(cacheLookupSkill(state, "string-ro"), null); + assert.equal(cacheLookupSkillIssue(state, "string-ro")?.kind, "invalid-readonly-value"); + assert.equal(cacheLookupSkill(state, "number-ro"), null); + assert.equal(cacheLookupSkillIssue(state, "number-ro")?.kind, "invalid-readonly-value"); + assert.equal(cacheLookupSkill(state, "array-ro"), null); + assert.equal(cacheLookupSkillIssue(state, "array-ro")?.kind, "invalid-readonly-value"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("cacheLookupCommand falls back to prompts when no skill is loaded", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const projectDir = join(dir, ".pi", "prompts"); + await mkdir(projectDir, { recursive: true }); + await writeMd(projectDir, "prompt-only", { readonly: true }); + + populateFromSkills(state, []); + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, true); + + assert.equal(cacheLookupSkill(state, "prompt-only"), null); + assert.equal(cacheLookupCommand(state, "prompt-only"), true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("/name prompt lookup stays distinct from /skill:name lookup for the same name", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const skillPath = await writeMd(dir, "shared-name", { readonly: false }); + const projectDir = join(dir, ".pi", "prompts"); + await mkdir(projectDir, { recursive: true }); + await writeMd(projectDir, "shared-name", { readonly: true }); + + populateFromSkills(state, [makeSkill("shared-name", skillPath)]); + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, true); + + assert.equal(cacheLookupSkill(state, "shared-name"), false); + assert.equal(cacheLookupPrompt(state, "shared-name"), true); + assert.equal(cacheLookupCommand(state, "shared-name"), true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs caches prompt commands", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = await writeMd(dir, "command-prompt", { readonly: false }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [{ + name: "command-prompt", + source: "prompt", + description: "Command prompt", + sourceInfo: { path: filePath, source: "test", scope: "temporary", origin: "top-level" }, + }], dir, false); + + assert.equal(cacheLookupCommand(state, "command-prompt"), false); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs caches resolved prompt template file paths", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = await writeMd(dir, "resolved-prompt", { readonly: true }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [{ + name: "resolved-prompt", + source: "prompt", + description: "Resolved prompt", + sourceInfo: { path: filePath, source: "test", scope: "temporary", origin: "top-level" }, + }], dir, false); + + assert.equal(cacheLookupPrompt(state, "resolved-prompt"), true); + assert.equal(cacheLookupCommand(state, "resolved-prompt"), true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs caches .md files from project dir", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const projectDir = join(dir, ".pi", "prompts"); + await mkdir(projectDir, { recursive: true }); + await writeMd(projectDir, "proj-prompt", { readonly: false }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, true); + + assert.equal(cacheLookupCommand(state, "proj-prompt"), false); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs skips non-.md files", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const projectDir = join(dir, ".pi", "prompts"); + await mkdir(projectDir, { recursive: true }); + await writeFile(join(projectDir, "readme.txt"), "not markdown"); + await writeMd(projectDir, "real-prompt", { readonly: true }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, true); + + assert.equal(cacheLookupCommand(state, "real-prompt"), true); + assert.equal(cacheLookupCommand(state, "readme"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs skips nonexistent dir silently", () => { + const state = createState(); + populatePromptCacheFromResolvedCommandsAndDirs(state, [], "/nonexistent/workspace", true); + assert.equal(cacheLookupCommand(state, "anything"), null); +}); + +test("project prompt overrides global prompt for the same name", async () => { + await withTempHome(async (homeDir) => { + const state = createState(); + const workspace = await tmpDir(); + try { + const globalDir = join(homeDir, ".pi", "agent", "prompts"); + const projectDir = join(workspace, ".pi", "prompts"); + await mkdir(globalDir, { recursive: true }); + await mkdir(projectDir, { recursive: true }); + await writeMd(globalDir, "shared", { readonly: false }); + await writeMd(projectDir, "shared", { readonly: true }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], workspace, true); + assert.equal(cacheLookupCommand(state, "shared"), true); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + }); +}); + + +test("resolved prompt command stays authoritative over directory fallback", async () => { + await withTempHome(async (homeDir) => { + const state = createState(); + const workspace = await tmpDir(); + try { + const globalDir = join(homeDir, ".pi", "agent", "prompts"); + const projectDir = join(workspace, ".pi", "prompts"); + await mkdir(globalDir, { recursive: true }); + await mkdir(projectDir, { recursive: true }); + const resolvedPath = await writeMd(workspace, "shared-resolved", { readonly: false }); + await writeMd(globalDir, "shared-resolved", { readonly: true }); + await writeMd(projectDir, "shared-resolved", { readonly: true }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [{ + name: "shared-resolved", + source: "prompt", + description: "Resolved command", + sourceInfo: { path: resolvedPath, source: "test", scope: "temporary", origin: "top-level" }, + }], workspace, true); + assert.equal(cacheLookupCommand(state, "shared-resolved"), false); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + }); +}); + +test("resolved non-prompt command blocks prompt-dir fallback for the same name", async () => { + const state = createState(); + const workspace = await tmpDir(); + try { + const projectDir = join(workspace, ".pi", "prompts"); + await mkdir(projectDir, { recursive: true }); + const promptPath = await writeMd(projectDir, "shared-owned", { readonly: true }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [{ + name: "shared-owned", + source: "builtin" as any, + description: "Builtin command", + sourceInfo: { path: promptPath, source: "test", scope: "temporary", origin: "top-level" }, + }], workspace, true); + assert.equal(cacheLookupCommand(state, "shared-owned"), null); + assert.equal(cacheLookupCommandIssue(state, "shared-owned"), null); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("resolved prompt command rebinding to a different file refreshes the cache", async () => { + const state = createState(); + const workspace = await tmpDir(); + try { + const pathA = await writeMd(workspace, "shared-a", { readonly: true }); + const pathB = await writeMd(workspace, "shared-b", { readonly: false }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [{ + name: "shared-rebound", + source: "prompt", + description: "Resolved command A", + sourceInfo: { path: pathA, source: "test", scope: "temporary", origin: "top-level" }, + }], workspace, false); + assert.equal(cacheLookupCommand(state, "shared-rebound"), true); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [{ + name: "shared-rebound", + source: "prompt", + description: "Resolved command B", + sourceInfo: { path: pathB, source: "test", scope: "temporary", origin: "top-level" }, + }], workspace, false); + assert.equal(cacheLookupCommand(state, "shared-rebound"), false); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs does not scan project dir when projectTrusted is false", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const projectDir = join(dir, ".pi", "prompts"); + await mkdir(projectDir, { recursive: true }); + await writeMd(projectDir, "proj-only", { readonly: true }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, false); + + assert.equal(cacheLookupCommand(state, "proj-only"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs evicts deleted prompt entries on rebuild", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const projectDir = join(dir, ".pi", "prompts"); + await mkdir(projectDir, { recursive: true }); + const filePath = await writeMd(projectDir, "deleted-prompt", { readonly: true }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, true); + assert.equal(cacheLookupCommand(state, "deleted-prompt"), true); + + await rm(filePath, { force: true }); + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, true); + assert.equal(cacheLookupCommand(state, "deleted-prompt"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs records invalid readonly value issues", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const projectDir = join(dir, ".pi", "prompts"); + await mkdir(projectDir, { recursive: true }); + await writeMd(projectDir, "broken-prompt", { readonly: "yes" }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, true); + assert.equal(cacheLookupCommand(state, "broken-prompt"), null); + assert.equal(cacheLookupCommandIssue(state, "broken-prompt")?.kind, "invalid-readonly-value"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs records unreadable prompt issues", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const projectDir = join(dir, ".pi", "prompts"); + await mkdir(projectDir, { recursive: true }); + await mkdir(join(projectDir, "dir-prompt.md"), { recursive: true }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, true); + assert.equal(cacheLookupCommand(state, "dir-prompt"), null); + assert.equal(cacheLookupCommandIssue(state, "dir-prompt")?.kind, "unreadable-file"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs evicts untrusted project prompt entries on rebuild", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const projectDir = join(dir, ".pi", "prompts"); + await mkdir(projectDir, { recursive: true }); + await writeMd(projectDir, "trusted-only", { readonly: true }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, true); + assert.equal(cacheLookupCommand(state, "trusted-only"), true); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, false); + assert.equal(cacheLookupCommand(state, "trusted-only"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("project/global precedence rebinding refreshes the cache for the same prompt name", async () => { + await withTempHome(async (homeDir) => { + const state = createState(); + const workspace = await tmpDir(); + try { + const globalDir = join(homeDir, ".pi", "agent", "prompts"); + const projectDir = join(workspace, ".pi", "prompts"); + await mkdir(globalDir, { recursive: true }); + await mkdir(projectDir, { recursive: true }); + await writeMd(globalDir, "shared-priority", { readonly: false }); + await writeMd(projectDir, "shared-priority", { readonly: true }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], workspace, true); + assert.equal(cacheLookupCommand(state, "shared-priority"), true); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], workspace, false); + assert.equal(cacheLookupCommand(state, "shared-priority"), false); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + }); +}); + +test("populateFromSkills reuses the cached entry while mtime is unchanged", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = await writeMd(dir, "stable-skill", { readonly: true }); + populateFromSkills(state, [makeSkill("stable-skill", filePath)]); + const firstEntry = state.readonlySkillCache.get("stable-skill"); + assert.equal(firstEntry?.readonly, true); + + populateFromSkills(state, [makeSkill("stable-skill", filePath)]); + const secondEntry = state.readonlySkillCache.get("stable-skill"); + assert.equal(secondEntry, firstEntry); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populateFromSkills records malformed frontmatter issues", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = join(dir, "broken-skill.md"); + await writeFile(filePath, `---\nreadonly: [\n---\n\nBody content.\n`); + populateFromSkills(state, [makeSkill("broken-skill", filePath)]); + + assert.equal(cacheLookupSkill(state, "broken-skill"), null); + assert.equal(cacheLookupSkillIssue(state, "broken-skill")?.kind, "malformed-frontmatter"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populateFromSkills refreshes changed frontmatter when mtime changes", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = await writeMd(dir, "mutable-skill", { readonly: true }); + populateFromSkills(state, [makeSkill("mutable-skill", filePath)]); + assert.equal(cacheLookupSkill(state, "mutable-skill"), true); + + await writeFile(filePath, `---\nreadonly: false\n---\n\nBody content.\n`); + const future = new Date(Date.now() + 2_000); + await utimes(filePath, future, future); + + populateFromSkills(state, [makeSkill("mutable-skill", filePath)]); + assert.equal(cacheLookupSkill(state, "mutable-skill"), false); + assert.equal(cacheLookupCommand(state, "mutable-skill"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populateFromSkills clears an invalid issue after the skill is fixed", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = await writeMd(dir, "recover-skill", { readonly: "yes" }); + populateFromSkills(state, [makeSkill("recover-skill", filePath)]); + assert.equal(cacheLookupSkillIssue(state, "recover-skill")?.kind, "invalid-readonly-value"); + + await writeFile(filePath, `---\nreadonly: true\n---\n\nBody content.\n`); + const future = new Date(Date.now() + 2_000); + await utimes(filePath, future, future); + + populateFromSkills(state, [makeSkill("recover-skill", filePath)]); + assert.equal(cacheLookupSkill(state, "recover-skill"), true); + assert.equal(cacheLookupSkillIssue(state, "recover-skill"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populateFromSkills clears an unreadable issue after the skill becomes readable", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = join(dir, "recover-readable-skill.md"); + await mkdir(filePath, { recursive: true }); + populateFromSkills(state, [makeSkill("recover-readable-skill", filePath)]); + assert.equal(cacheLookupSkillIssue(state, "recover-readable-skill")?.kind, "unreadable-file"); + + await rm(filePath, { recursive: true, force: true }); + await writeFile(filePath, `---\nreadonly: false\n---\n\nBody content.\n`); + const future = new Date(Date.now() + 2_000); + await utimes(filePath, future, future); + + populateFromSkills(state, [makeSkill("recover-readable-skill", filePath)]); + assert.equal(cacheLookupSkill(state, "recover-readable-skill"), false); + assert.equal(cacheLookupSkillIssue(state, "recover-readable-skill"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs refreshes a prompt when the same path changes", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = await writeMd(dir, "mutable-prompt", { readonly: true }); + const commands = [{ + name: "mutable-prompt", + source: "prompt" as const, + description: "Mutable prompt", + sourceInfo: { path: filePath, source: "test", scope: "temporary" as const, origin: "top-level" as const }, + }]; + + populatePromptCacheFromResolvedCommandsAndDirs(state, commands, dir, false); + assert.equal(cacheLookupCommand(state, "mutable-prompt"), true); + + await writeFile(filePath, `---\nreadonly: false\n---\n\nBody content.\n`); + const future = new Date(Date.now() + 2_000); + await utimes(filePath, future, future); + + populatePromptCacheFromResolvedCommandsAndDirs(state, commands, dir, false); + assert.equal(cacheLookupCommand(state, "mutable-prompt"), false); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs clears an invalid issue after the prompt is fixed", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = await writeMd(dir, "recover-prompt", { readonly: "yes" }); + const commands = [{ + name: "recover-prompt", + source: "prompt" as const, + description: "Recover prompt", + sourceInfo: { path: filePath, source: "test", scope: "temporary" as const, origin: "top-level" as const }, + }]; + + populatePromptCacheFromResolvedCommandsAndDirs(state, commands, dir, false); + assert.equal(cacheLookupCommandIssue(state, "recover-prompt")?.kind, "invalid-readonly-value"); + + await writeFile(filePath, `---\nreadonly: true\n---\n\nBody content.\n`); + const future = new Date(Date.now() + 2_000); + await utimes(filePath, future, future); + + populatePromptCacheFromResolvedCommandsAndDirs(state, commands, dir, false); + assert.equal(cacheLookupCommand(state, "recover-prompt"), true); + assert.equal(cacheLookupCommandIssue(state, "recover-prompt"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs records malformed frontmatter issues", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = join(dir, "broken-yaml.md"); + await writeFile(filePath, `---\nreadonly: [\n---\n\nBody content.\n`); + const commands = [{ + name: "broken-yaml", + source: "prompt" as const, + description: "Broken yaml prompt", + sourceInfo: { path: filePath, source: "test", scope: "temporary" as const, origin: "top-level" as const }, + }]; + + populatePromptCacheFromResolvedCommandsAndDirs(state, commands, dir, false); + assert.equal(cacheLookupCommand(state, "broken-yaml"), null); + assert.equal(cacheLookupCommandIssue(state, "broken-yaml")?.kind, "malformed-frontmatter"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs clears an unreadable issue after the prompt becomes readable", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = join(dir, "recover-readable.md"); + await mkdir(filePath, { recursive: true }); + const commands = [{ + name: "recover-readable", + source: "prompt" as const, + description: "Recover readable prompt", + sourceInfo: { path: filePath, source: "test", scope: "temporary" as const, origin: "top-level" as const }, + }]; + + populatePromptCacheFromResolvedCommandsAndDirs(state, commands, dir, false); + assert.equal(cacheLookupCommandIssue(state, "recover-readable")?.kind, "unreadable-file"); + + await rm(filePath, { recursive: true, force: true }); + await writeFile(filePath, `---\nreadonly: false\n---\n\nBody content.\n`); + const future = new Date(Date.now() + 2_000); + await utimes(filePath, future, future); + + populatePromptCacheFromResolvedCommandsAndDirs(state, commands, dir, false); + assert.equal(cacheLookupCommand(state, "recover-readable"), false); + assert.equal(cacheLookupCommandIssue(state, "recover-readable"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/readonly-copy.test.ts b/tests/unit/readonly-copy.test.ts new file mode 100644 index 0000000..deb8822 --- /dev/null +++ b/tests/unit/readonly-copy.test.ts @@ -0,0 +1,170 @@ +/** + * Smoke tests for readonly-copy.ts constants. + * + * Verifies the composition chain integrity — a typo in a base constant + * would cascade to downstream constants. These tests catch that cheaply. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import registerAgenticoding from "../../index.js"; +import { wrapWithBwrap, wrapWithSandboxExec } from "../../os-sandbox.js"; +import { applyReadonlyBashGuard, classifyBashCommand } from "../../readonly-bash.js"; +import { createTestPI } from "./helpers.js"; +import { + READONLY_BASH_SCOPE, + READONLY_NON_TEMP_MUTATION_SCOPE, + READONLY_SANDBOX_BLOCK_NOTICE, + READONLY_EXPLICIT_HANDOFF, + READONLY_HANDOFF_TRIGGER, + READONLY_NEXT_CONTEXT_RESUMES, + READONLY_BYPASS_CLEARED, + READONLY_WRITE_EDIT_BASH, + READONLY_WRITE_EDIT_BLOCK_REASON, + READONLY_HANDOFF_BLOCK_REASON, + READONLY_WRITE_EDIT_SUMMARY, + READONLY_ACTIVE_SUMMARY, + READONLY_HANDOFF_EXCEPTION_SUMMARY, + READONLY_ENABLED_STATUS, + READONLY_COMMAND_DESCRIPTION, + READONLY_DISABLED_SUMMARY, + READONLY_DISABLED_NOTIFICATION, + READONLY_HANDOFF_EXCEPTION_NOTIFICATION, + READONLY_PENDING_HANDOFF_READONLY_ON_NOTIFICATION, + READONLY_PENDING_HANDOFF_READONLY_OFF_NOTIFICATION, + READONLY_HANDOFF_RETRY_ADVICE, + READONLY_CHILD_AUTHORITY_NOTE, + buildReadonlyBashBlockReason, + buildReadonlyFrontmatterNotification, + buildReadonlyPackageManagerBlockReason, + buildReadonlySandboxPathError, + buildReadonlyDisabledContextSuffix, + buildReadonlyTopicBoundaryNotification, + buildReadonlyRequestedHandoffContinuation, + buildReadonlyHandoffWaitNotice, + buildReadonlyHandoffCommandNotice, +} from "../../readonly-copy.js"; + +const allConstants = { + READONLY_BASH_SCOPE, + READONLY_NON_TEMP_MUTATION_SCOPE, + READONLY_EXPLICIT_HANDOFF, + READONLY_HANDOFF_TRIGGER, + READONLY_NEXT_CONTEXT_RESUMES, + READONLY_BYPASS_CLEARED, + READONLY_WRITE_EDIT_BASH, + READONLY_WRITE_EDIT_BLOCK_REASON, + READONLY_HANDOFF_BLOCK_REASON, + READONLY_WRITE_EDIT_SUMMARY, + READONLY_ACTIVE_SUMMARY, + READONLY_HANDOFF_EXCEPTION_SUMMARY, + READONLY_ENABLED_STATUS, + READONLY_COMMAND_DESCRIPTION, + READONLY_DISABLED_SUMMARY, + READONLY_DISABLED_NOTIFICATION, + READONLY_HANDOFF_EXCEPTION_NOTIFICATION, + READONLY_PENDING_HANDOFF_READONLY_ON_NOTIFICATION, + READONLY_PENDING_HANDOFF_READONLY_OFF_NOTIFICATION, +}; + +test("all constants are non-empty strings", () => { + for (const [name, value] of Object.entries(allConstants)) { + assert.equal(typeof value, "string", `${name} must be a string`); + assert.ok(value.trim().length > 0, `${name} must be non-empty`); + } +}); + +test("composition chain: READONLY_WRITE_EDIT_BASH contains READONLY_BASH_SCOPE", () => { + assert.ok(READONLY_WRITE_EDIT_BASH.includes(READONLY_BASH_SCOPE)); +}); + +test("composition chain: READONLY_ENABLED_STATUS contains READONLY_BASH_SCOPE", () => { + assert.ok(READONLY_ENABLED_STATUS.includes(READONLY_BASH_SCOPE)); +}); + +test("summary constants use [readonly] prefix", () => { + assert.ok(READONLY_WRITE_EDIT_SUMMARY.startsWith("[readonly]")); + assert.ok(READONLY_ACTIVE_SUMMARY.startsWith("[readonly]")); + assert.ok(READONLY_HANDOFF_EXCEPTION_SUMMARY.startsWith("[readonly]")); + assert.ok(READONLY_ENABLED_STATUS.startsWith("[readonly]")); + assert.ok(READONLY_DISABLED_SUMMARY.startsWith("[readonly]")); +}); + +test("block reasons use 'Readonly mode:' prefix", () => { + assert.ok(READONLY_WRITE_EDIT_BLOCK_REASON.startsWith("Readonly mode:")); + assert.ok(READONLY_HANDOFF_BLOCK_REASON.startsWith("Readonly mode:")); + assert.ok(buildReadonlyBashBlockReason("test", "cmd").startsWith("Readonly mode:")); +}); + +test("bash and sandbox consumers use centralized readonly copy", () => { + const command = "touch /outside-temp-file"; + const result = applyReadonlyBashGuard(command, process.cwd()); + assert.equal(result.action, "block"); + if (result.action === "block") { + const verdict = classifyBashCommand(command, process.cwd()); + assert.equal(verdict.ok, false); + if (verdict.ok === false) { + assert.equal(result.reason, buildReadonlyBashBlockReason(verdict.reason, command)); + } + } + assert.ok(wrapWithSandboxExec("echo test").includes(READONLY_SANDBOX_BLOCK_NOTICE)); + assert.ok(wrapWithBwrap("echo test").includes(READONLY_SANDBOX_BLOCK_NOTICE)); +}); + +test("parent readonly tool-call consumer uses centralized block copy", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + await pi.commands.get("readonly")!.handler("", { + hasUI: true, + getContextUsage: () => null, + ui: { + theme: { fg: (_name: string, text: string) => text }, + notify: () => {}, + setStatus: () => {}, + setWidget: () => {}, + }, + } as any); + const [toolCall] = pi.handlers.get("tool_call")!; + const result = await toolCall({ toolName: "write", input: {} }, {}); + assert.deepEqual(result, { block: true, reason: READONLY_WRITE_EDIT_BLOCK_REASON }); + const handoffResult = await toolCall({ toolName: "handoff", input: {} }, {}); + assert.deepEqual(handoffResult, { block: true, reason: READONLY_HANDOFF_BLOCK_REASON }); +}); + +test("LLM-facing handoff copy reflects both readonly bypass triggers", () => { + assert.match(READONLY_HANDOFF_BLOCK_REASON, /explicit \/handoff/i); + assert.match(READONLY_HANDOFF_BLOCK_REASON, /human topic boundary/i); + assert.match(READONLY_HANDOFF_EXCEPTION_SUMMARY, /temporary handoff exception active/i); + assert.match(READONLY_HANDOFF_EXCEPTION_SUMMARY, /write\/edit remain blocked/i); + assert.doesNotMatch(READONLY_HANDOFF_EXCEPTION_SUMMARY, /for this turn|this request only/i); + assert.match(READONLY_HANDOFF_EXCEPTION_NOTIFICATION, /write\/edit remain blocked/i); + assert.doesNotMatch(READONLY_HANDOFF_EXCEPTION_NOTIFICATION, /for this turn/i); +}); + +test("shared readonly fragments keep copy aligned across contexts", () => { + assert.match(READONLY_HANDOFF_BLOCK_REASON, new RegExp(READONLY_HANDOFF_TRIGGER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i")); + assert.match(READONLY_HANDOFF_EXCEPTION_NOTIFICATION, /fresh context resumes in readonly mode/i); + assert.match(buildReadonlyRequestedHandoffContinuation(), /fresh context resumes in readonly mode/i); + assert.match(buildReadonlyHandoffWaitNotice(), /readonly remains active/i); + assert.match(buildReadonlyHandoffCommandNotice(), /next context resumes readonly mode/i); + assert.match(buildReadonlyTopicBoundaryNotification("oauth", "billing"), /handoff exception activates.*once the context is ready/i); + assert.match(buildReadonlyDisabledContextSuffix(42), /42%/); + assert.match(READONLY_NON_TEMP_MUTATION_SCOPE, /non-temp bash filesystem mutations/i); + assert.match(READONLY_HANDOFF_TRIGGER, /human topic boundary/i); + assert.match(READONLY_NEXT_CONTEXT_RESUMES, /readonly mode/i); + assert.match(READONLY_BYPASS_CLEARED, /no longer active/i); + assert.match(READONLY_PENDING_HANDOFF_READONLY_ON_NOTIFICATION, /resume in readonly mode/i); + assert.match(READONLY_PENDING_HANDOFF_READONLY_OFF_NOTIFICATION, /will not resume in readonly mode/i); + assert.match(READONLY_HANDOFF_RETRY_ADVICE, /temporary readonly exception/i); + assert.match(READONLY_CHILD_AUTHORITY_NOTE, /inherit readonly authority/i); + assert.equal(buildReadonlyFrontmatterNotification(true, "/review"), "Readonly mode enabled via `/review` frontmatter"); + assert.equal(buildReadonlyFrontmatterNotification(false, "/review"), "Readonly mode disabled via `/review` frontmatter"); + assert.match(buildReadonlyPackageManagerBlockReason("npm", "install"), /blocked in readonly mode/i); + assert.match(buildReadonlySandboxPathError("/tmp/'bad"), /cannot safely escape/i); +}); + +test("readonly command consumes the centralized description", () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + assert.equal(pi.commands.get("readonly")?.description, READONLY_COMMAND_DESCRIPTION); +}); diff --git a/tests/unit/readonly-frontmatter.test.ts b/tests/unit/readonly-frontmatter.test.ts new file mode 100644 index 0000000..a3b47fb --- /dev/null +++ b/tests/unit/readonly-frontmatter.test.ts @@ -0,0 +1,933 @@ +/** + * Readonly frontmatter integration tests. + * + * Exercises the full pipeline: + * input queue → before_agent_start → consumePendingReadonlyToggle + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { registerReadonlyPI, makeReadonlyUICtx, tmpDir, withTempHome } from "./helpers.js"; + +async function writePrompt(dir: string, name: string, readonly: boolean): Promise { + const filePath = join(dir, `${name}.md`); + await writeFile(filePath, `---\nreadonly: ${readonly}\ndescription: "Test"\n---\n\nBody content.\n`); + return filePath; +} + +function makeBeforeStartCtx(cwd = process.cwd()) { + return { + ...makeReadonlyUICtx(), + cwd, + isProjectTrusted: () => true, + }; +} + +function makeNotifyBeforeStartCtx() { + const notifications: Array<{ message: string; level: string }> = []; + return { + ctx: { + ...makeReadonlyUICtx({ + ui: { + notify: (message: string, level: string) => { notifications.push({ message, level }); }, + theme: { fg: (_name: string, text: string) => text }, + setStatus: () => {}, + setWidget: () => {}, + }, + }), + cwd: process.cwd(), + isProjectTrusted: () => true, + }, + notifications, + }; +} + +function makeResolvedCommand(name: string, filePath: string, source: "prompt" | "builtin" = "prompt") { + return { + name, + source, + description: "Test prompt", + sourceInfo: { path: filePath, source: "test", scope: "temporary" as const, origin: "top-level" as const }, + }; +} + +function makePromptCommand(name: string, filePath: string) { + return makeResolvedCommand(name, filePath, "prompt"); +} + +function makeSkill(name: string, filePath: string) { + return { + name, + description: "Test skill", + filePath, + baseDir: "", + sourceInfo: { path: filePath, source: "test", scope: "temporary" as const, origin: "top-level" as const }, + disableModelInvocation: false, + }; +} + +async function runPromptToggle( + text: string, + readonly: boolean, + name = text.slice(1), +): Promise<{ toolCall: ReturnType["toolCall"]; pi: ReturnType["pi"] }> { + const dir = await tmpDir(); + const filePath = await writePrompt(dir, name, readonly); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + pi.setCommands([makePromptCommand(name, filePath)]); + await inputHandler({ text, source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + await rm(dir, { recursive: true, force: true }); + return { pi, toolCall }; +} + +test("single /name input activates readonly frontmatter", async () => { + const { toolCall } = await runPromptToggle("/my-prompt", true); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); +}); + +test("readonly: false frontmatter keeps readonly disabled and stays silent when already disabled", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "safe-prompt", false); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const [contextHook] = pi.handlers.get("context")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + pi.setCommands([makePromptCommand("safe-prompt", filePath)]); + + await inputHandler({ text: "/safe-prompt", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly"), undefined); + assert.equal(notifications.length, 0); + const contextResult = await contextHook({ messages: [] }, { getContextUsage: () => ({ percent: 40 }) }); + assert.equal(contextResult.messages.find((message: any) => /readonly/i.test(message.content ?? "")), undefined); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("suffixed /name command activates readonly frontmatter", async () => { + const { toolCall } = await runPromptToggle("/review:1", true, "review:1"); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); +}); + +test("dotted /name command activates readonly frontmatter", async () => { + const { toolCall } = await runPromptToggle("/review.pr", true, "review.pr"); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); +}); + +test("unknown /command without frontmatter produces no toggle", async () => { + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + + await inputHandler({ text: "/nonexistent-cmd", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); +}); + +test("unknown /command does not delay the next valid prompt frontmatter toggle", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "review", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + pi.setCommands([makePromptCommand("review", filePath)]); + + await inputHandler({ text: "/nonexistent-cmd", source: "interactive" }, ctx); + await inputHandler({ text: "/review", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + const entries = pi.appendedEntries.filter((entry: any) => entry.customType === "agenticoding-readonly"); + assert.equal(entries.length, 1); + assert.equal(entries[0]?.data.enabled, true); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("before_agent_start skips readonly cache population while no slash-command toggle is pending", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "broken.md"); + await writeFile(filePath, `---\nreadonly: "yes"\n---\n\nBody content.\n`); + const { pi } = registerReadonlyPI(); + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + pi.setCommands([makePromptCommand("broken", filePath)]); + + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly-frontmatter-issue"), undefined); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + + +test("input handler queues a prompt command even when the registry is unavailable until before_agent_start", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "late-review", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + + await inputHandler({ text: "/late-review", source: "interactive" }, ctx); + pi.setCommands([makePromptCommand("late-review", filePath)]); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("late-resolved non-prompt /name does not inherit readonly from a same-named prompt file", async () => { + const workspace = await tmpDir(); + try { + const promptDir = join(workspace, ".pi", "prompts"); + await mkdir(promptDir, { recursive: true }); + const promptPath = await writePrompt(promptDir, "shared", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(workspace); + + await inputHandler({ text: "/shared", source: "interactive" }, ctx); + pi.setCommands([makeResolvedCommand("shared", promptPath, "builtin")]); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly"), undefined); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("known non-prompt /name already present in the registry does not enqueue a deferred toggle", async () => { + const workspace = await tmpDir(); + try { + const promptDir = join(workspace, ".pi", "prompts"); + await mkdir(promptDir, { recursive: true }); + const promptPath = await writePrompt(promptDir, "shared-known", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(workspace); + + pi.setCommands([makeResolvedCommand("shared-known", promptPath, "builtin")]); + await inputHandler({ text: "/shared-known", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly"), undefined); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("headless /name frontmatter stays a no-op through the deferred pipeline", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "headless-review", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + pi.setCommands([makePromptCommand("headless-review", filePath)]); + + await inputHandler({ text: "/headless-review", source: "interactive" }, { + hasUI: false, + getContextUsage: () => null, + } as any); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, { + hasUI: false, + cwd: process.cwd(), + isProjectTrusted: () => false, + getContextUsage: () => null, + } as any); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly"), undefined); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("extension input stays a no-op when hasUI is false", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "headless-extension", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + pi.setCommands([makePromptCommand("headless-extension", filePath)]); + + await inputHandler({ text: "/headless-extension", source: "extension" }, { + hasUI: false, + getContextUsage: () => null, + } as any); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, { + hasUI: false, + cwd: process.cwd(), + isProjectTrusted: () => false, + getContextUsage: () => null, + } as any); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly"), undefined); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("extension plain text without a slash stays a no-op", async () => { + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + + await inputHandler({ text: "Proceed.", source: "extension" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly"), undefined); +}); + +test("unresolved /name uses trusted cwd/.pi/prompts frontmatter via deferred fallback", async () => { + const workspace = await tmpDir(); + try { + const promptDir = join(workspace, ".pi", "prompts"); + await mkdir(promptDir, { recursive: true }); + await writePrompt(promptDir, "fallback-only", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(workspace); + + await inputHandler({ text: "/fallback-only", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly")?.data.enabled, true); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + + +test("unresolved /name uses ~/.pi/agent/prompts frontmatter via deferred fallback", async () => { + await withTempHome(async (homeDir) => { + const workspace = await tmpDir(); + try { + const promptDir = join(homeDir, ".pi", "agent", "prompts"); + await mkdir(promptDir, { recursive: true }); + await writePrompt(promptDir, "global-fallback", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(workspace); + + await inputHandler({ text: "/global-fallback", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly")?.data.enabled, true); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + }); +}); + +test("queued slash + extension message preserves the first pending command", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "my-prompt", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + pi.setCommands([makePromptCommand("my-prompt", filePath)]); + + await inputHandler({ text: "/my-prompt", source: "interactive" }, ctx); + await inputHandler({ text: "Proceed.", source: "extension" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("queued slash + plain text preserves the first pending command", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "my-prompt", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + pi.setCommands([makePromptCommand("my-prompt", filePath)]); + + await inputHandler({ text: "/my-prompt", source: "interactive", streamingBehavior: "steer" }, ctx); + await inputHandler({ text: "also fix this", source: "interactive", streamingBehavior: "steer" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("queued slash commands are consumed FIFO across before_agent_start calls", async () => { + const dir = await tmpDir(); + try { + const filePathA = await writePrompt(dir, "cmd-a", true); + const filePathB = await writePrompt(dir, "cmd-b", false); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + pi.setCommands([ + makePromptCommand("cmd-a", filePathA), + makePromptCommand("cmd-b", filePathB), + ]); + + await inputHandler({ text: "/cmd-a", source: "interactive" }, ctx); + await inputHandler({ text: "/cmd-b", source: "interactive" }, ctx); + + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + assert.equal(pi.appendedEntries.at(-1)?.data.enabled, true, "first before_agent_start should consume /cmd-a"); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + assert.equal(pi.appendedEntries.at(-1)?.data.enabled, false, "second before_agent_start should consume /cmd-b"); + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("non-prompt slash commands do not delay the next prompt frontmatter toggle", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "review", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + pi.setCommands([makePromptCommand("review", filePath)]); + + await inputHandler({ text: "/notebook", source: "interactive" }, ctx); + await inputHandler({ text: "/review", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + const entries = pi.appendedEntries.filter((entry: any) => entry.customType === "agenticoding-readonly"); + assert.equal(entries.length, 1); + assert.equal(entries[0]?.data.enabled, true); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("/skill:name activates readonly from skill frontmatter", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "my-skill", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + + await inputHandler({ text: "/skill:my-skill", source: "interactive" }, ctx); + await beforeStartHandler({ + systemPrompt: "", + systemPromptOptions: { skills: [makeSkill("my-skill", filePath)] }, + }, ctx); + + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("/skill:name preserves dotted skill names for readonly frontmatter", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "review.pr", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + + await inputHandler({ text: "/skill:review.pr", source: "interactive" }, ctx); + await beforeStartHandler({ + systemPrompt: "", + systemPromptOptions: { skills: [makeSkill("review.pr", filePath)] }, + }, ctx); + + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("readonly success notifications use the exact slash-command source", async () => { + const dir = await tmpDir(); + try { + const promptPath = await writePrompt(dir, "shared", true); + const skillPath = await writePrompt(dir, "shared-skill", false); + const { pi } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + pi.setCommands([makePromptCommand("shared", promptPath)]); + + await inputHandler({ text: "/shared", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [makeSkill("shared", skillPath)] } }, ctx); + assert.match(notifications.at(-1)?.message ?? "", /\/shared/); + assert.doesNotMatch(notifications.at(-1)?.message ?? "", /\/skill:shared/); + + await inputHandler({ text: "/skill:shared", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [makeSkill("shared", skillPath)] } }, ctx); + assert.match(notifications.at(-1)?.message ?? "", /\/skill:shared/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("invalid /prompt readonly value records a warning for the prompt source", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "broken-prompt.md"); + await writeFile(filePath, `---\nreadonly: "yes"\ndescription: "Broken"\n---\n\nBody content.\n`); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + pi.setCommands([makePromptCommand("broken-prompt", filePath)]); + + await inputHandler({ text: "/broken-prompt", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.at(-1)?.customType, "agenticoding-readonly-frontmatter-issue"); + assert.equal(pi.appendedEntries.at(-1)?.data.type, "command"); + assert.match(notifications.at(-1)?.message ?? "", /\/broken-prompt/); + assert.match(notifications.at(-1)?.message ?? "", /`readonly` frontmatter must be `true` or `false`/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("invalid /skill:name readonly value records a warning for the skill source", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "broken-skill.md"); + await writeFile(filePath, `---\nreadonly: "yes"\ndescription: "Broken"\n---\n\nBody content.\n`); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + + await inputHandler({ text: "/skill:broken-skill", source: "interactive" }, ctx); + await beforeStartHandler({ + systemPrompt: "", + systemPromptOptions: { skills: [makeSkill("broken-skill", filePath)] }, + }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.at(-1)?.customType, "agenticoding-readonly-frontmatter-issue"); + assert.equal(pi.appendedEntries.at(-1)?.data.type, "skill"); + assert.match(notifications.at(-1)?.message ?? "", /\/skill:broken-skill/); + assert.match(notifications.at(-1)?.message ?? "", /`readonly` frontmatter must be `true` or `false`/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("unreadable /prompt frontmatter records a warning for the prompt source", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "dir-prompt.md"); + await mkdir(filePath, { recursive: true }); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + pi.setCommands([makePromptCommand("dir-prompt", filePath)]); + + await inputHandler({ text: "/dir-prompt", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.at(-1)?.customType, "agenticoding-readonly-frontmatter-issue"); + assert.equal(pi.appendedEntries.at(-1)?.data.type, "command"); + assert.match(notifications.at(-1)?.message ?? "", /\/dir-prompt/); + assert.match(notifications.at(-1)?.message ?? "", /prompt\/skill file could not be read/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("unreadable /skill:name frontmatter records a warning for the skill source", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "dir-skill.md"); + await mkdir(filePath, { recursive: true }); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + + await inputHandler({ text: "/skill:dir-skill", source: "interactive" }, ctx); + await beforeStartHandler({ + systemPrompt: "", + systemPromptOptions: { skills: [makeSkill("dir-skill", filePath)] }, + }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.at(-1)?.customType, "agenticoding-readonly-frontmatter-issue"); + assert.equal(pi.appendedEntries.at(-1)?.data.type, "skill"); + assert.match(notifications.at(-1)?.message ?? "", /\/skill:dir-skill/); + assert.match(notifications.at(-1)?.message ?? "", /prompt\/skill file could not be read/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("invalid queued frontmatter warns and the next valid queued command still toggles readonly", async () => { + const dir = await tmpDir(); + try { + const brokenPath = join(dir, "broken-then-valid.md"); + await writeFile(brokenPath, `---\nreadonly: "yes"\n---\n\nBody content.\n`); + const validPath = await writePrompt(dir, "valid-after-broken", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + pi.setCommands([ + makePromptCommand("broken-then-valid", brokenPath), + makePromptCommand("valid-after-broken", validPath), + ]); + + await inputHandler({ text: "/broken-then-valid", source: "interactive" }, ctx); + await inputHandler({ text: "/valid-after-broken", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(pi.appendedEntries.at(-2)?.customType, "agenticoding-readonly-frontmatter-issue"); + assert.equal(pi.appendedEntries.at(-1)?.customType, "agenticoding-readonly"); + assert.equal(pi.appendedEntries.at(-1)?.data.enabled, true); + assert.match(notifications.at(-2)?.message ?? "", /`readonly` frontmatter must be `true` or `false`/); + assert.match(notifications.at(-1)?.message ?? "", /Readonly mode enabled/); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("prompt without readonly frontmatter stays a silent no-op through the deferred pipeline", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "no-readonly.md"); + await writeFile(filePath, `---\ndescription: "No readonly"\n---\n\nBody content.\n`); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + pi.setCommands([makePromptCommand("no-readonly", filePath)]); + + await inputHandler({ text: "/no-readonly", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly"), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly-frontmatter-issue"), undefined); + assert.equal(notifications.length, 0); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("skill without readonly frontmatter stays a silent no-op through the deferred pipeline", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "no-readonly-skill.md"); + await writeFile(filePath, `---\ndescription: "No readonly"\n---\n\nBody content.\n`); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + + await inputHandler({ text: "/skill:no-readonly-skill", source: "interactive" }, ctx); + await beforeStartHandler({ + systemPrompt: "", + systemPromptOptions: { skills: [makeSkill("no-readonly-skill", filePath)] }, + }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly"), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly-frontmatter-issue"), undefined); + assert.equal(notifications.length, 0); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("/readonly bypasses deferred frontmatter lookup", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "readonly.md"); + await writeFile(filePath, `---\nreadonly: "yes"\n---\n\nBody content.\n`); + const { pi } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + pi.setCommands([makePromptCommand("readonly", filePath)]); + + await inputHandler({ text: "/readonly", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly"), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly-frontmatter-issue"), undefined); + assert.equal(notifications.length, 0); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("/handoff bypasses deferred frontmatter lookup", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "handoff.md"); + await writeFile(filePath, `---\nreadonly: "yes"\n---\n\nBody content.\n`); + const { pi } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + pi.setCommands([makePromptCommand("handoff", filePath)]); + + await inputHandler({ text: "/handoff continue", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly"), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly-frontmatter-issue"), undefined); + assert.equal(notifications.length, 0); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("/notebook bypasses deferred frontmatter lookup", async () => { + const workspace = await tmpDir(); + try { + const promptDir = join(workspace, ".pi", "prompts"); + await mkdir(promptDir, { recursive: true }); + await writeFile(join(promptDir, "notebook.md"), `---\nreadonly: "yes"\n---\n\nBody content.\n`); + const { pi } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + + await inputHandler({ text: "/notebook", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, { + ...ctx, + cwd: workspace, + isProjectTrusted: () => true, + }); + + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly"), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly-frontmatter-issue"), undefined); + assert.equal(notifications.length, 0); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("malformed /prompt frontmatter records a parse warning for the prompt source", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "broken-yaml-prompt.md"); + await writeFile(filePath, `---\nreadonly: [\n---\n\nBody content.\n`); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + pi.setCommands([makePromptCommand("broken-yaml-prompt", filePath)]); + + await inputHandler({ text: "/broken-yaml-prompt", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.at(-1)?.customType, "agenticoding-readonly-frontmatter-issue"); + assert.match(notifications.at(-1)?.message ?? "", /\/broken-yaml-prompt/); + assert.match(notifications.at(-1)?.message ?? "", /frontmatter could not be parsed/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("malformed /skill:name frontmatter records a parse warning for the skill source", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "broken-yaml-skill.md"); + await writeFile(filePath, `---\nreadonly: [\n---\n\nBody content.\n`); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + + await inputHandler({ text: "/skill:broken-yaml-skill", source: "interactive" }, ctx); + await beforeStartHandler({ + systemPrompt: "", + systemPromptOptions: { skills: [makeSkill("broken-yaml-skill", filePath)] }, + }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.at(-1)?.customType, "agenticoding-readonly-frontmatter-issue"); + assert.equal(pi.appendedEntries.at(-1)?.data.type, "skill"); + assert.match(notifications.at(-1)?.message ?? "", /\/skill:broken-yaml-skill/); + assert.match(notifications.at(-1)?.message ?? "", /frontmatter could not be parsed/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("deferred readonly enable emits a one-shot context nudge", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "nudge-on", true); + const { pi } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const [contextHook] = pi.handlers.get("context")!; + const ctx = makeBeforeStartCtx(); + pi.setCommands([makePromptCommand("nudge-on", filePath)]); + + await inputHandler({ text: "/nudge-on", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + const firstResult = await contextHook({ messages: [] }, { getContextUsage: () => ({ percent: 20 }) }); + assert.equal(firstResult.messages.filter((message: any) => message.customType === "agenticoding-readonly-nudge").length, 1); + assert.match(firstResult.messages.find((message: any) => message.customType === "agenticoding-readonly-nudge")?.content ?? "", /\[readonly\]/); + + const secondResult = await contextHook({ messages: [] }, { getContextUsage: () => ({ percent: 20 }) }); + assert.equal(secondResult?.messages?.find((message: any) => message.customType === "agenticoding-readonly-nudge"), undefined); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("deferred readonly disable emits a one-shot context nudge", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "nudge-off", false); + const { pi } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const [contextHook] = pi.handlers.get("context")!; + const ctx = makeBeforeStartCtx(); + pi.setCommands([makePromptCommand("nudge-off", filePath)]); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + + await inputHandler({ text: "/nudge-off", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + const firstResult = await contextHook({ messages: [] }, { getContextUsage: () => ({ percent: 40 }) }); + assert.equal(firstResult.messages.filter((message: any) => message.customType === "agenticoding-readonly-nudge").length, 1); + assert.match(firstResult.messages.find((message: any) => message.customType === "agenticoding-readonly-nudge")?.content ?? "", /\[readonly\] disabled/); + + const secondResult = await contextHook({ messages: [] }, { getContextUsage: () => ({ percent: 40 }) }); + assert.equal(secondResult?.messages?.filter((message: any) => message.customType === "agenticoding-readonly-nudge").length ?? 0, 0); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("one readonly entry is appended per consumed queued toggle", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "prompt-a", true); + const { pi } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + pi.setCommands([makePromptCommand("prompt-a", filePath)]); + + await inputHandler({ text: "/prompt-a", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + const entries = pi.appendedEntries.filter((entry: any) => entry.customType === "agenticoding-readonly"); + assert.equal(entries.length, 1); + assert.equal(entries[0].data.enabled, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +async function assertHandoffAlignment(name: string, readonly: boolean, task: string): Promise { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, name, readonly); + const { pi } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const [beforeCompactHandler] = pi.handlers.get("session_before_compact")!; + const ctx = makeBeforeStartCtx(); + + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + await pi.commands.get("handoff").handler(task, { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + + pi.setCommands([makePromptCommand(name, filePath)]); + await inputHandler({ text: `/${name}`, source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(pi.appendedEntries.at(-1)?.customType, "agenticoding-readonly"); + assert.equal(pi.appendedEntries.at(-1)?.data.enabled, readonly); + + await pi.tools.get("handoff").execute( + "1", + { task }, + undefined, + undefined, + { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + compact: () => {}, + }, + ); + const compaction = await beforeCompactHandler( + { preparation: { tokensBefore: 1 }, branchEntries: [{ id: "leaf-1" }] }, + {}, + ); + const summary = compaction.compaction.summary; + assert.equal(summary.includes("Fresh context resumes in readonly mode."), readonly); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test("frontmatter toggle aligns pending handoff with readonly: true", async () => { + await assertHandoffAlignment("review-prompt", true, "continue review"); +}); + +test("frontmatter toggle aligns pending handoff with readonly: false", async () => { + await assertHandoffAlignment("safe-prompt", false, "continue work"); +}); diff --git a/tests/unit/readonly-handoff.test.ts b/tests/unit/readonly-handoff.test.ts new file mode 100644 index 0000000..c278426 --- /dev/null +++ b/tests/unit/readonly-handoff.test.ts @@ -0,0 +1,620 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { access, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import registerAgenticoding from "../../index.js"; +import { createState } from "../../state.js"; +import { canPromoteBoundary, discardNonHumanBoundary } from "../../readonly-boundary.js"; +import { setActiveNotebookTopic } from "../../notebook/topic.js"; +import { createTestPI, makeReadonlyUICtx } from "./helpers.js"; +import { STATUS_KEY_HANDOFF } from "../../tui.js"; +import { MAX_HANDOFF_ATTEMPTS } from "../../watchdog.js"; + +function createHandoffPI() { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [toolCall] = pi.handlers.get("tool_call")!; + const [agentEnd] = pi.handlers.get("agent_end")!; + const [beforeCompact] = pi.handlers.get("session_before_compact")!; + const [sessionTree] = pi.handlers.get("session_tree")!; + const sessionStartHandlers = pi.handlers.get("session_start") ?? []; + const sessionStart = async (event: unknown, ctx: unknown) => { + for (const handler of sessionStartHandlers) { + await handler(event, ctx); + } + }; + return { pi, toolCall, agentEnd, beforeCompact, sessionTree, sessionStart }; +} + +async function dispatchTool(pi: any, toolName: string, input: Record, ctx: any): Promise { + const [toolCall] = pi.handlers.get("tool_call")!; + const block = await toolCall({ toolName, input }, ctx); + if (block?.block) return block; + return pi.tools.get(toolName).execute("dispatch-test", input, undefined, undefined, ctx); +} + +function makeReadonlyResumeCtx(branch: unknown[]) { + return { + hasUI: false, + getContextUsage: () => null, + sessionManager: { + getBranch: () => branch, + }, + }; +} + +async function compactSummaryAfterPostToolToggle(initialReadonly: boolean): Promise { + const { pi, beforeCompact } = createHandoffPI(); + if (initialReadonly) await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + await pi.commands.get("handoff").handler("continue work", { ...makeReadonlyUICtx(), isIdle: () => true } as any); + await pi.tools.get("handoff").execute("handoff-1", { task: "Continue work" }, undefined, undefined, { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + compact: () => {}, + }); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + const result = await beforeCompact({ preparation: { tokensBefore: 1 }, branchEntries: [{ id: "leaf-1" }] }, {}); + return result.compaction.summary; +} + +async function assertNonTempBashBlocked(toolCall: (event: any, ctx: any) => Promise): Promise { + const target = path.join(os.homedir(), `readonly-handoff-test-${process.pid}-${Date.now()}`); + const command = `touch "${target}"`; + await rm(target, { force: true }); + try { + const event = { toolName: "bash", input: { command } }; + const result = await toolCall(event, { cwd: process.cwd() }); + if (!result?.block) { + try { + execFileSync("bash", ["-lc", event.input.command], { cwd: process.cwd(), stdio: "ignore" }); + } catch { + // A sandbox may reject the command with a non-zero exit status. + } + } + assert.equal(result?.block, true, "non-temp bash mutations must be blocked before execution"); + await assert.rejects(() => access(target), /ENOENT/); + } finally { + await rm(target, { force: true }); + } +} + +async function handoffAllowedAtUsage(usage: { tokens?: number | null; percent?: number | null; contextWindow?: number | null }): Promise { + const { pi, toolCall } = createHandoffPI(); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + const [contextHandler] = pi.handlers.get("context")!; + await contextHandler({ messages: [{ role: "user", content: "drain", timestamp: 1 }] }, { getContextUsage: () => null } as any); + await pi.commands.get("notebook").handler("oauth", { hasUI: false, getContextUsage: () => null } as any); + await pi.commands.get("notebook").handler("billing", { hasUI: false, getContextUsage: () => null } as any); + await contextHandler({ messages: [{ role: "user", content: "continue", timestamp: 2 }] }, { getContextUsage: () => usage } as any); + return (await toolCall({ toolName: "handoff", input: { task: "continue billing" } }, {})) === undefined; +} + +test("/handoff command creates temporary bypass for handoff tool only", async () => { + const { pi, toolCall } = createHandoffPI(); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + await pi.commands.get("handoff").handler("continue readonly work", { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + + assert.equal(await toolCall({ toolName: "handoff", input: { task: "continue readonly work" } }, {}), undefined, + "handoff should be unblocked after explicit /handoff"); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/test", content: "x" } }, {})).block, true, + "write should stay blocked"); + await assertNonTempBashBlocked(toolCall); +}); + +test("blocked readonly handoff never invokes compaction", async () => { + const { pi } = createHandoffPI(); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + let compactCalled = false; + const result = await dispatchTool( + pi, + "handoff", + { task: "must remain in current context" }, + { + ...makeReadonlyUICtx(), + cwd: process.cwd(), + getContextUsage: () => ({ tokens: 50_000, percent: 80, contextWindow: 64_000 }), + compact: () => { compactCalled = true; }, + }, + ); + assert.equal(result?.block, true); + assert.equal(compactCalled, false, "blocked handoff must not reach the tool executor"); +}); + +test("watchdog cancellation clears the readonly handoff bypass", async () => { + const { pi, toolCall, agentEnd } = createHandoffPI(); + const statuses = new Map(); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + await pi.commands.get("handoff").handler("continue readonly work", { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + + const ctx = { + hasUI: true, + ui: { + theme: { fg: (_name: string, text: string) => text }, + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, + notify: () => {}, + }, + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + }; + for (let i = 0; i < MAX_HANDOFF_ATTEMPTS; i++) await agentEnd({}, ctx as any); + + assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); + assert.equal((await toolCall({ toolName: "handoff", input: {} }, {})).block, true, + "readonly handoff should be blocked after watchdog cancellation"); +}); + +test("after handoff compaction, bypass is cleared and readonly persists", async () => { + const { pi, toolCall } = createHandoffPI(); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + await pi.commands.get("handoff").handler("continue readonly work", { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + + // Execute handoff tool and capture the compact callback + let compactOptions: any; + await pi.tools.get("handoff").execute( + "handoff-1", + { task: "Continue readonly work" }, + undefined, + undefined, + { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + hasUI: true, + ui: { setStatus: () => {}, notify: () => {} }, + compact: (options: any) => { compactOptions = options; }, + }, + ); + + // Trigger onComplete (simulates successful compaction) + compactOptions.onComplete({}); + + // Observable contract: bypass cleared, readonly still active + assert.equal((await toolCall({ toolName: "handoff", input: { task: "direct call" } }, {})).block, true, + "bypass cleared: direct handoff should be blocked"); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/test", content: "x" } }, {})).block, true, + "readonly persists: write should still be blocked after compaction"); +}); + +test("synchronous handoff rejection preserves the readonly bypass contract", async () => { + const { pi, toolCall } = createHandoffPI(); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + await pi.commands.get("handoff").handler("continue readonly work", { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + + await assert.rejects( + () => pi.tools.get("handoff").execute( + "handoff-1", + { task: "Continue readonly work" }, + undefined, + undefined, + { + hasUI: true, + ui: { setStatus: () => {} }, + getContextUsage: () => null, + }, + ), + ); + + assert.equal(await toolCall({ toolName: "handoff", input: { task: "retry readonly handoff" } }, {}), undefined, + "readonly bypass should remain active after synchronous rejection"); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/test", content: "x" } }, {})).block, true, + "write should stay blocked while the bypass remains active"); +}); + +test("retry succeeds after a failed compaction attempt", async () => { + const { pi, toolCall } = createHandoffPI(); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + await pi.commands.get("handoff").handler("continue readonly work", { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + + // Execute handoff tool and capture the compact callback + let compactOptions: any; + await pi.tools.get("handoff").execute( + "handoff-1", + { task: "Continue readonly work" }, + undefined, + undefined, + { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + hasUI: true, + ui: { setStatus: () => {}, notify: () => {} }, + compact: (options: any) => { compactOptions = options; }, + }, + ); + + // Trigger onError — simulates failed compaction + const userMessagesBefore = pi.sentUserMessages.length; + compactOptions.onError(new Error("Nothing to compact (session too small)")); + + // onError re-engages the LLM + assert.ok(pi.sentUserMessages.length > userMessagesBefore); + assert.match(pi.sentUserMessages[pi.sentUserMessages.length - 1].content, /Handoff failed/); + + // Observable contract: a retry handoff call succeeds after failed compaction + await assert.doesNotReject( + () => pi.tools.get("handoff").execute( + "handoff-retry", + { task: "retry handoff" }, + undefined, + undefined, + { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + hasUI: true, + ui: { setStatus: () => {}, notify: () => {} }, + compact: () => {}, + }, + ), + "retry handoff should succeed after failed compaction", + ); + + // Readonly still active + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/test", content: "x" } }, {})).block, true, + "readonly persists: write should still be blocked after failed compaction"); +}); + +test("/handoff re-enables bypass after compaction", async () => { + const { pi, toolCall } = createHandoffPI(); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + + // Create bypass, then complete the handoff to clear it + await pi.commands.get("handoff").handler("first handoff", { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + let compactOptions: any; + await pi.tools.get("handoff").execute( + "handoff-1", + { task: "first handoff" }, + undefined, + undefined, + { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + hasUI: true, + ui: { setStatus: () => {}, notify: () => {} }, + compact: (options: any) => { compactOptions = options; }, + }, + ); + compactOptions.onComplete({}); + + // Second /handoff re-enables the bypass + await pi.commands.get("handoff").handler("second readonly handoff", { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + assert.equal(await toolCall({ toolName: "handoff", input: { task: "second readonly handoff" } }, {}), undefined, + "second /handoff should re-enable the bypass"); +}); + +test("handoff summary follows readonly toggles after tool execution", async () => { + const readonlyDisabled = await compactSummaryAfterPostToolToggle(true); + assert.equal(readonlyDisabled.includes("Fresh context resumes in readonly mode."), false); + + const readonlyEnabled = await compactSummaryAfterPostToolToggle(false); + assert.equal(readonlyEnabled.includes("Fresh context resumes in readonly mode."), true); +}); + +test("readonly topic boundary derives eligibility from percentage when tokens are unavailable", async () => { + assert.equal(await handoffAllowedAtUsage({ tokens: null, percent: 15, contextWindow: 200000 }), true, + "15% of a 200K context meets the minimum token threshold"); + assert.equal(await handoffAllowedAtUsage({ tokens: null, percent: 14.999, contextWindow: 200000 }), false, + "just below the minimum token threshold must remain blocked"); +}); + +test("readonly topic boundary creates the same bypass contract as explicit /handoff", async () => { + const { pi, toolCall } = createHandoffPI(); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + + // Drain the initial readonly nudge + const [contextHandler] = pi.handlers.get("context")!; + await contextHandler( + { messages: [{ role: "user", content: "drain", timestamp: 1 }] }, + { getContextUsage: () => null } as any, + ); + + // Set initial topic, then change it to create a boundary hint + await pi.commands.get("notebook").handler( + "oauth", + { hasUI: false, getContextUsage: () => null } as any, + ); + await pi.commands.get("notebook").handler( + "billing", + { hasUI: false, getContextUsage: () => null } as any, + ); + + // Context hook should create the bypass from the boundary hint + await contextHandler( + { messages: [{ role: "user", content: "hi", timestamp: 2 }] }, + { getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }) }, + ); + + // Same observable contract as explicit /handoff: handoff tool is unblocked + assert.equal(await toolCall({ toolName: "handoff", input: { task: "continue billing work" } }, {}), undefined, + "handoff should be unblocked after topic boundary creates bypass"); + // write and non-temp bash mutations stay blocked + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/test", content: "x" } }, {})).block, true, + "write should stay blocked"); + await assertNonTempBashBlocked(toolCall); +}); + +test("promoted readonly boundary preserves bypass across execute-time eligibility failures", async () => { + const { pi, toolCall } = createHandoffPI(); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + const [contextHandler] = pi.handlers.get("context")!; + await contextHandler({ messages: [{ role: "user", content: "drain", timestamp: 1 }] }, { getContextUsage: () => null } as any); + await pi.commands.get("notebook").handler("oauth", { hasUI: false, getContextUsage: () => null } as any); + await pi.commands.get("notebook").handler("billing", { hasUI: false, getContextUsage: () => null } as any); + await contextHandler( + { messages: [{ role: "user", content: "promote", timestamp: 2 }] }, + { getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }) } as any, + ); + + for (const usage of [ + () => null, + () => ({ tokens: 5000, percent: 2.5, contextWindow: 200000 }), + ]) { + await assert.rejects( + () => pi.tools.get("handoff").execute("boundary-retry", { task: "continue billing" }, undefined, undefined, { + getContextUsage: usage, + }), + ); + assert.equal(await toolCall({ toolName: "handoff", input: {} }, {}), undefined, + "execute-time rejection must preserve the promoted readonly bypass"); + } + + let compactOptions: any; + await pi.tools.get("handoff").execute("boundary-success", { task: "continue billing" }, undefined, undefined, { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + compact: (options: any) => { compactOptions = options; }, + }); + compactOptions.onComplete(); + assert.equal((await toolCall({ toolName: "handoff", input: {} }, {})).block, true); +}); + +test("readonly topic boundary promotion exposes the handoff status", async () => { + const { pi } = createHandoffPI(); + const statuses = new Map(); + const notifications: string[] = []; + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + const [contextHandler] = pi.handlers.get("context")!; + await contextHandler({ messages: [{ role: "user", content: "drain", timestamp: 1 }] }, { getContextUsage: () => null } as any); + await pi.commands.get("notebook").handler("oauth", { hasUI: false, getContextUsage: () => null } as any); + await pi.commands.get("notebook").handler("billing", { hasUI: false, getContextUsage: () => null } as any); + + await contextHandler( + { messages: [{ role: "user", content: "continue", timestamp: 2 }] }, + { + hasUI: true, + ui: { + theme: { fg: (_name: string, text: string) => text }, + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, + notify: (message: string) => { notifications.push(message); }, + }, + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + } as any, + ); + + assert.equal(statuses.get(STATUS_KEY_HANDOFF), "🤝 Handoff required — ready to compact"); + assert.match(notifications[0] ?? "", /handoff exception is now active/i); +}); + +test("readonly topic boundary stays advisory until handoff is eligible", async () => { + const { pi, toolCall } = createHandoffPI(); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + const [contextHandler] = pi.handlers.get("context")!; + await contextHandler( + { messages: [{ role: "user", content: "drain", timestamp: 1 }] }, + { getContextUsage: () => null } as any, + ); + await pi.commands.get("notebook").handler("oauth", { hasUI: false, getContextUsage: () => null } as any); + await pi.commands.get("notebook").handler("billing", { hasUI: false, getContextUsage: () => null } as any); + + const result = await contextHandler( + { messages: [{ role: "user", content: "continue", timestamp: 2 }] }, + { getContextUsage: () => null } as any, + ); + + assert.match(result.messages.at(-1)?.content ?? "", /topic changed/i); + assert.doesNotMatch(result.messages.at(-1)?.content ?? "", /call handoff/i); + assert.equal((await toolCall({ toolName: "handoff", input: {} }, {})).block, true, + "ineligible boundary must not unblock handoff"); +}); + +test("readonly human topic boundary promotes exactly at the token threshold without repeated advisory nudges", async () => { + const { pi, toolCall } = createHandoffPI(); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + const [contextHandler] = pi.handlers.get("context")!; + await contextHandler( + { messages: [{ role: "user", content: "drain", timestamp: 1 }] }, + { getContextUsage: () => null } as any, + ); + await pi.commands.get("notebook").handler("oauth", { hasUI: false, getContextUsage: () => null } as any); + await pi.commands.get("notebook").handler("billing", { hasUI: false, getContextUsage: () => null } as any); + + const first = await contextHandler( + { messages: [{ role: "user", content: "still small", timestamp: 2 }] }, + { getContextUsage: () => ({ tokens: 29_999, percent: 15, contextWindow: 200000 }) } as any, + ); + assert.match(first.messages.at(-1)?.content ?? "", /topic changed/i); + assert.equal((await toolCall({ toolName: "handoff", input: {} }, {})).block, true); + + const second = await contextHandler( + { messages: [{ role: "user", content: "still ineligible", timestamp: 3 }] }, + { getContextUsage: () => ({ tokens: 29_999, percent: 15, contextWindow: 200000 }) } as any, + ); + assert.equal(second, undefined, "an ineligible boundary should not repeat its advisory nudge"); + + await contextHandler( + { messages: [{ role: "user", content: "now eligible", timestamp: 4 }] }, + { getContextUsage: () => ({ tokens: 30_000, percent: 15, contextWindow: 200000 }) } as any, + ); + assert.equal(await toolCall({ toolName: "handoff", input: {} }, {}), undefined); +}); + +test("readonly topic boundary handoff clears its bypass after successful compaction", async () => { + const { pi, toolCall, beforeCompact } = createHandoffPI(); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + const [contextHandler] = pi.handlers.get("context")!; + await contextHandler( + { messages: [{ role: "user", content: "drain", timestamp: 1 }] }, + { getContextUsage: () => null } as any, + ); + await pi.commands.get("notebook").handler("oauth", { hasUI: false, getContextUsage: () => null } as any); + await pi.commands.get("notebook").handler("billing", { hasUI: false, getContextUsage: () => null } as any); + await contextHandler( + { messages: [{ role: "user", content: "handoff", timestamp: 2 }] }, + { getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }) } as any, + ); + + let compactOptions: any; + await pi.tools.get("handoff").execute( + "boundary-handoff", + { task: "Continue billing work" }, + undefined, + undefined, + { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + compact: (options: any) => { compactOptions = options; }, + }, + ); + const result = await beforeCompact( + { preparation: { tokensBefore: 1 }, branchEntries: [{ id: "leaf-1" }] }, + {}, + ); + compactOptions.onComplete({}); + + assert.match(result.compaction.summary, /Fresh context resumes in readonly mode/); + assert.equal((await toolCall({ toolName: "handoff", input: {} }, {})).block, true); + assert.equal((await toolCall({ toolName: "write", input: {} }, {})).block, true); +}); + +test("readonly agent topic transitions cannot promote or remain queued", () => { + const state = createState(); + setActiveNotebookTopic(state, "oauth", "agent"); + setActiveNotebookTopic(state, "billing", "agent"); + + assert.equal(canPromoteBoundary(state, { tokens: 50_000, percent: 80, contextWindow: 64_000 }), false); + assert.equal(discardNonHumanBoundary(state), true); + assert.equal(state.pendingTopicBoundaryHint, null); +}); + +test("readonly agent topic boundary is not promoted to handoff bypass", async () => { + const { pi, toolCall } = createHandoffPI(); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + const [contextHandler] = pi.handlers.get("context")!; + // Drain the initial readonly toggle nudge + await contextHandler( + { messages: [{ role: "user", content: "drain", timestamp: 1 }] }, + { getContextUsage: () => null } as any, + ); + + // Set the initial topic via notebook_topic_set (agent source). The public + // tool intentionally rejects agent overrides, so transition behavior is + // covered by the pure state/helper test above. + const notebookTopicTool = pi.tools.get("notebook_topic_set"); + await notebookTopicTool.execute("1", { topic: "oauth" }, undefined, undefined, { + hasUI: false, + getContextUsage: () => null, + } as any); + + const result = await contextHandler( + { messages: [{ role: "user", content: "continue", timestamp: 2 }] }, + { getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }) } as any, + ); + + assert.equal(result, undefined); + assert.equal((await toolCall({ toolName: "handoff", input: {} }, {})).block, true, + "agent topic without boundary must not unblock handoff in readonly mode"); +}); + +test("advisoryDelivered flag prevents repeated advisory nudges for ineligible boundary", async () => { + const { pi, toolCall } = createHandoffPI(); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + const [contextHandler] = pi.handlers.get("context")!; + // Drain the initial readonly toggle nudge + await contextHandler( + { messages: [{ role: "user", content: "drain", timestamp: 1 }] }, + { getContextUsage: () => null } as any, + ); + // Set human topic boundary + await pi.commands.get("notebook").handler("oauth", { hasUI: false, getContextUsage: () => null } as any); + await pi.commands.get("notebook").handler("billing", { hasUI: false, getContextUsage: () => null } as any); + + // First context hook at ineligible usage — should deliver advisory + const first = await contextHandler( + { messages: [{ role: "user", content: "small", timestamp: 2 }] }, + { getContextUsage: () => ({ tokens: 5000, percent: 2.5, contextWindow: 200000 }) } as any, + ); + assert.ok(first, "first call should return messages"); + assert.match(first.messages.at(-1)?.content ?? "", /topic changed/i); + + // Second context hook at same ineligible usage — should NOT repeat advisory + const second = await contextHandler( + { messages: [{ role: "user", content: "still small", timestamp: 3 }] }, + { getContextUsage: () => ({ tokens: 5000, percent: 2.5, contextWindow: 200000 }) } as any, + ); + assert.equal(second, undefined, "ineligible boundary should not repeat its advisory nudge"); + + // Handoff should still be blocked + assert.equal((await toolCall({ toolName: "handoff", input: {} }, {})).block, true); +}); + +test("session tree invalidates pending handoff work, releases the overlap guard, and ignores stale callbacks", async () => { + const { pi, toolCall, sessionTree } = createHandoffPI(); + let staleCompactOptions: any; + let freshCompactOptions: any; + const statuses = new Map([[STATUS_KEY_HANDOFF, "stale"]]); + + await pi.tools.get("handoff").execute("branch-handoff", { task: "continue branch work" }, undefined, undefined, { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + compact: (options: any) => { staleCompactOptions = options; }, + }); + await sessionTree({}, { + hasUI: true, + ui: { + theme: { fg: (_name: string, text: string) => text }, + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, + setWidget: () => {}, + }, + getContextUsage: () => null, + } as any); + + assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); + assert.equal(await toolCall({ toolName: "handoff", input: {} }, {}), undefined, + "non-readonly branch should not retain a stale readonly handoff block"); + await assert.doesNotReject( + () => pi.tools.get("handoff").execute("fresh-branch-handoff", { task: "continue fresh branch work" }, undefined, undefined, { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + compact: (options: any) => { freshCompactOptions = options; }, + }), + "session_tree must release the overlap guard for a fresh branch handoff", + ); + + staleCompactOptions.onComplete(); + assert.deepEqual(pi.sentUserMessages, [], "stale callback must not touch the fresh branch state"); + freshCompactOptions.onComplete(); + assert.deepEqual(pi.sentUserMessages, [{ content: "Proceed.", options: undefined }]); +}); + +test("session resume restores readonly enforcement from persisted state", async () => { + const { toolCall, sessionStart } = createHandoffPI(); + const branch = [ + { type: "custom", customType: "agenticoding-readonly", data: { enabled: false } }, + { type: "custom", customType: "agenticoding-readonly", data: { enabled: true } }, + ]; + + await sessionStart({ reason: "resume" }, makeReadonlyResumeCtx(branch) as any); + + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + assert.equal(await toolCall({ toolName: "read", input: { path: "/tmp/x" } }, {}), undefined); +}); diff --git a/tests/unit/readonly-mode.test.ts b/tests/unit/readonly-mode.test.ts new file mode 100644 index 0000000..cf1687f --- /dev/null +++ b/tests/unit/readonly-mode.test.ts @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import { registerReadonlyPI, makeReadonlyUICtx } from "./helpers.js"; + +test("readonly toggle on blocks write, edit, handoff, and bash mutations", async () => { + const { pi, toolCall } = registerReadonlyPI(); + const ctx = makeReadonlyUICtx(); + + await pi.commands.get("readonly").handler("", ctx as any); + + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + assert.equal((await toolCall({ toolName: "edit", input: { path: "/tmp/x", edits: [] } }, {})).block, true); + assert.equal((await toolCall({ toolName: "handoff", input: { task: "pivot" } }, {})).block, true); + assert.equal((await toolCall({ toolName: "bash", input: { command: "rm -rf /" } }, { cwd: "/workspace" })).block, true); + assert.equal(await toolCall({ toolName: "bash", input: { command: `rm ${os.tmpdir()}/x` } }, { cwd: "/workspace" }), undefined); + assert.equal(await toolCall({ toolName: "read", input: { path: "/tmp/x" } }, {}), undefined); +}); + +test("readonly toggle off restores write, handoff, and bash access", async () => { + const { pi, toolCall } = registerReadonlyPI(); + const ctx = makeReadonlyUICtx(); + + await pi.commands.get("readonly").handler("", ctx as any); + await pi.commands.get("readonly").handler("", ctx as any); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(await toolCall({ toolName: "handoff", input: { task: "pivot" } }, {}), undefined); + assert.equal(await toolCall({ toolName: "bash", input: { command: "rm -rf /" } }, { cwd: "/workspace" }), undefined); +}); + +test("readonly toggle is a no-op in headless mode", async () => { + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + + // Toggle readonly via the command handler with hasUI: false. + // The handler guards on ctx.hasUI — in headless mode the toggle + // is a no-op, preserving the contract: readonly is a TUI-only feature. + await pi.commands.get("readonly").handler("", { + hasUI: false, + getContextUsage: () => null, + } as any); + + await inputHandler({ text: "/review", source: "interactive" }, { + hasUI: false, + getContextUsage: () => null, + } as any); + await beforeStartHandler({ + systemPrompt: "", + systemPromptOptions: { skills: [] }, + }, { + hasUI: false, + cwd: process.cwd(), + isProjectTrusted: () => false, + getContextUsage: () => null, + } as any); + + // Write should remain unblocked for both manual and deferred readonly paths. + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); +}); + +test("readonly shortcut only toggles while idle", async () => { + const { pi, toolCall } = registerReadonlyPI(); + const shortcut = pi.shortcuts.get("ctrl+shift+r"); + assert.ok(shortcut); + + await shortcut.handler({ ...makeReadonlyUICtx(), isIdle: () => false } as any); + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + + await shortcut.handler({ ...makeReadonlyUICtx(), isIdle: () => true } as any); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); +}); + +test("readonly toggle delivers an activation nudge via context hook", async () => { + const { pi } = registerReadonlyPI(); + const [contextHook] = pi.handlers.get("context")!; + + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + + const result = await contextHook( + { messages: [] }, + { getContextUsage: () => ({ percent: 40 }) }, + ); + const readonlyNudge = result.messages.find((message: any) => /readonly/i.test(message.content ?? "")); + assert.ok(readonlyNudge, "context hook should deliver a readonly activation nudge"); + assert.equal(readonlyNudge.role, "custom"); + assert.equal(readonlyNudge.display, false); +}); + +test("readonly toggle off delivers a deactivation nudge via context hook", async () => { + const { pi } = registerReadonlyPI(); + const [contextHook] = pi.handlers.get("context")!; + + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + + const result = await contextHook( + { messages: [] }, + { getContextUsage: () => ({ percent: 40 }) }, + ); + const readonlyNudge = result.messages.find((message: any) => /readonly|turned off|disabled/i.test(message.content ?? "")); + assert.ok(readonlyNudge, "context hook should deliver a readonly deactivation nudge"); + assert.match(readonlyNudge.content, /readonly/i); + assert.match(readonlyNudge.content, /off|disabled|turned off/i); +}); + + diff --git a/tests/unit/readonly-rehydration.test.ts b/tests/unit/readonly-rehydration.test.ts new file mode 100644 index 0000000..be11d91 --- /dev/null +++ b/tests/unit/readonly-rehydration.test.ts @@ -0,0 +1,65 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { getReadonlyFromBranch } from "../../readonly-rehydration.js"; + +function makePI(readonlyFlag = false) { + return { getFlag: () => readonlyFlag }; +} + +test("getReadonlyFromBranch returns false for empty branch with no CLI flag", () => { + assert.equal(getReadonlyFromBranch([], makePI(false)), false); +}); + +test("getReadonlyFromBranch returns true for empty branch with CLI flag", () => { + assert.equal(getReadonlyFromBranch([], makePI(true)), true); +}); + +test("getReadonlyFromBranch uses a persisted readonly entry when present", () => { + assert.equal( + getReadonlyFromBranch([ + { type: "custom", customType: "agenticoding-readonly", data: { enabled: true } }, + ], makePI(false)), + true, + ); + assert.equal( + getReadonlyFromBranch([ + { type: "custom", customType: "agenticoding-readonly", data: { enabled: false } }, + ], makePI(true)), + false, + ); +}); + +test("getReadonlyFromBranch picks the latest entry (true wins)", () => { + const branch = [ + { type: "custom", customType: "agenticoding-readonly", data: { enabled: true } }, + { type: "custom", customType: "agenticoding-readonly", data: { enabled: false } }, + { type: "custom", customType: "agenticoding-readonly", data: { enabled: true } }, + ]; + assert.equal(getReadonlyFromBranch(branch, makePI(false)), true); +}); + +test("getReadonlyFromBranch picks the latest entry (false wins)", () => { + const branch = [ + { type: "custom", customType: "agenticoding-readonly", data: { enabled: true } }, + { type: "custom", customType: "agenticoding-readonly", data: { enabled: true } }, + { type: "custom", customType: "agenticoding-readonly", data: { enabled: false } }, + ]; + assert.equal(getReadonlyFromBranch(branch, makePI(false)), false); +}); + +test("getReadonlyFromBranch skips entries with wrong customType", () => { + const branch = [ + { type: "custom", customType: "other-extension", data: { enabled: true } }, + ]; + assert.equal(getReadonlyFromBranch(branch, makePI(false)), false); +}); + +test("getReadonlyFromBranch falls back to the CLI flag when no valid readonly entry exists", () => { + assert.equal(getReadonlyFromBranch([null, "string", 42], makePI(true)), true); + assert.equal( + getReadonlyFromBranch([ + { type: "custom", customType: "agenticoding-readonly", data: null }, + ], makePI(false)), + false, + ); +}); diff --git a/tests/unit/readonly-spawn.test.ts b/tests/unit/readonly-spawn.test.ts new file mode 100644 index 0000000..fe767fa --- /dev/null +++ b/tests/unit/readonly-spawn.test.ts @@ -0,0 +1,103 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { access, rm } from "node:fs/promises"; +import { createState } from "../../state.js"; +import { registerSpawnTool } from "../../spawn/index.js"; +import { createTestPI } from "./helpers.js"; + +async function spawnWithCapture( + readonlyEnabled: boolean, + inspect: (config: any, prompt: string) => Promise | void, + activeTools?: string[], +) { + const pi = createTestPI(); + const tools = activeTools ?? ["read", "bash", "write", "edit", "spawn", "handoff"]; + pi.setActiveTools(tools); + pi.setAllTools(tools); + const state = createState(); + state.readonlyEnabled = readonlyEnabled; + + const sessionFactory = async (config: any) => { + const session = { + messages: [] as any[], + prompt: async (prompt: string) => { + await inspect(config, prompt); + session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; + }, + abort: async () => {}, + getSessionStats: () => undefined, + }; + return { session: session as any }; + }; + + registerSpawnTool(pi as any, state, sessionFactory as any); + await pi.tools.get("spawn").execute( + "spawn-readonly", + { prompt: "test" }, + undefined, + undefined, + { model: { id: "mock-model" }, cwd: process.cwd() }, + ); +} + +test("readonly spawn child prompt tells the child it inherits readonly authority", async () => { + let prompt = ""; + + await spawnWithCapture(true, (_config, childPrompt) => { + prompt = childPrompt; + }); + + assert.match(prompt, /inherit readonly authority/i); + assert.match(prompt, /\[readonly\] write\/edit blocked/i); + assert.match(prompt, /bash writes\/deletions outside temp blocked/i); +}); + +test("non-readonly spawn child prompt keeps normal authority", async () => { + let prompt = ""; + + await spawnWithCapture(false, (_config, childPrompt) => { + prompt = childPrompt; + }); + + assert.match(prompt, /same authority as the parent/i); + assert.doesNotMatch(prompt, /\[readonly\] write\/edit blocked; bash writes\/deletions outside temp blocked\./i); +}); + +test("readonly spawn child bash tool rejects malformed commands", async () => { + await spawnWithCapture(true, async (config) => { + const bashTool = config.customTools.find((tool: any) => tool.name === "bash"); + assert.ok(bashTool, "readonly child should receive a bash tool"); + for (const command of [undefined, null, 42, { command: "ls" }]) { + await assert.rejects( + () => bashTool.execute("malformed", { command }), + /bash command input must be a string/, + ); + } + }); +}); + +test("readonly spawn child bash tool blocks non-temp writes and allows temp writes", async () => { + const outsideTemp = path.join(os.homedir(), `readonly-child-test-${process.pid}-${Date.now()}`); + const insideTemp = path.join(os.tmpdir(), `readonly-child-test-${Date.now()}`); + await rm(outsideTemp, { force: true }); + + try { + await spawnWithCapture(true, async (config) => { + const bashTool = config.customTools.find((tool: any) => tool.name === "bash"); + + assert.ok(bashTool, "readonly child should receive a bash tool"); + await assert.rejects( + () => bashTool.execute("bash-1", { command: `touch ${outsideTemp}` }), + /Readonly mode:/, + ); + await assert.rejects(() => access(outsideTemp), /ENOENT/); + await assert.doesNotReject( + () => bashTool.execute("bash-2", { command: `touch ${insideTemp} && rm ${insideTemp}` }), + ); + }); + } finally { + await rm(outsideTemp, { force: true }); + } +}); diff --git a/tests/unit/resolve-path.test.ts b/tests/unit/resolve-path.test.ts new file mode 100644 index 0000000..6a31c2f --- /dev/null +++ b/tests/unit/resolve-path.test.ts @@ -0,0 +1,36 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; +import { resolveRealPath } from "../../resolve-path.js"; + +const DEEP_PATH_PARTS = ["__pi_test_deep", "a", "b", "c"]; + +test("resolveRealPath: non-existent path inside temp dir preserves full path", () => { + const tmp = os.tmpdir(); + const nonExistent = path.join(tmp, ...DEEP_PATH_PARTS); + const result = resolveRealPath(nonExistent); + // Use path.join for platform-native separators (\ vs /) + const expectedSuffix = path.join(...DEEP_PATH_PARTS); + assert.ok( + result.includes(expectedSuffix), + `should preserve all path components — expected "${expectedSuffix}" in "${result}"`, + ); +}); + +test("resolveRealPath follows symlinks", () => { + const dir = os.tmpdir(); + const target = path.join(dir, `pi-test-target-${Date.now()}`); + const link = path.join(dir, `pi-test-link-${Date.now()}`); + fs.mkdirSync(target); + try { + fs.symlinkSync(target, link); + const resolved = resolveRealPath(link); + // Use resolveRealPath on target too to handle macOS /var → /private/var + assert.equal(resolved, resolveRealPath(target)); + } finally { + fs.rmSync(link, { force: true }); + fs.rmSync(target, { force: true, recursive: true }); + } +}); diff --git a/tests/unit/spawn-event.test.ts b/tests/unit/spawn-event.test.ts index 45feaf9..81808eb 100644 --- a/tests/unit/spawn-event.test.ts +++ b/tests/unit/spawn-event.test.ts @@ -73,8 +73,6 @@ test("nested spawn handleEvent recovers from malformed events", () => { // Emit a malformed event that will throw inside handleEvent emit({ type: "message_start", message: null }); - assert.equal(h.warnings.length, 1); - assert.match(String(h.warnings[0].args[1]), /message_start/); // Subsequent valid events still process emit({ type: "message_start", message: { role: "assistant", content: [] } }); @@ -447,8 +445,6 @@ test("nested spawn recovers batching state after event handler error", async () const lines = component.render(120); assert.ok(lines.some((l: string) => l.includes("thinking")), "error recovery should allow subsequent events to render"); - assert.equal(h.warnings.length, 1); - assert.match(String(h.warnings[0].args[0]), /Event handler error/); }); test("handleEvent gracefully degrades with null message events", () => { diff --git a/tests/unit/spawn.test.ts b/tests/unit/spawn.test.ts index 817b3d4..8ad0475 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -10,6 +10,7 @@ import { createChildTools, executeSpawn, registerSpawnTool, + truncateText, } from "../../spawn/index.js"; import { renderSpawnResult } from "../../spawn/renderer.js"; import { createTestPI, createRenderContext, createSession, createSubscribableSession, messageText, makeTUICtx, theme, createTestAssistantMessage, createTestAssistantStream } from "./helpers.js"; @@ -222,6 +223,13 @@ test("spawn execute builds prompt with notebook pages and task", async () => { assert.match(seenPrompt, /entry-a: preview line/); }); +test("truncateText handles multi-byte boundaries correctly", () => { + assert.equal(truncateText("🙂", 10, 2), ""); + assert.equal(truncateText("🙂", 10, 4), "🙂"); + assert.equal(truncateText("", 10, 1024), ""); + assert.equal(truncateText("hello", 10, 1024), "hello"); +}); + test("spawn renderResult falls back to static text when no live session is stored", () => { const state = createState(); const pi = createTestPI(); @@ -353,9 +361,6 @@ test("spawn execute marks stats unavailable when stats collection throws", async assert.equal(result.details.stats, undefined); assert.equal(result.details.statsUnavailable, true); - assert.equal(h.warnings.length, 1); - assert.match(String(h.warnings[0].args[1]), /stats failed/); - assert.equal(h.warnings[0].args[2], "spawn-1"); }); test("spawn execute throws when child produces no output", async () => { @@ -674,6 +679,42 @@ test("child tool names exclude inactive registered and active phantom tools", () assert.equal(toolNames.includes("spawn"), false); }); +test("buildChildToolNames (2-arg fallback) removes spawn and handoff from inherited tools", () => { + const result = buildChildToolNames(["read", "bash", "spawn", "handoff"], []); + assert.ok(!result.includes("spawn"), "spawn must be filtered out"); + assert.ok(!result.includes("handoff"), "handoff must be filtered out"); + assert.ok(result.includes("read"), "read must be preserved"); + assert.ok(result.includes("bash"), "bash must be preserved"); +}); + +test("buildChildToolNames (2-arg fallback) preserves non-spawn/handoff tools", () => { + const result = buildChildToolNames(["read", "bash", "write", "edit"], []); + assert.deepEqual(result.sort(), ["bash", "edit", "read", "write"]); +}); + +test("buildChildToolNames (2-arg fallback) adds custom child tools to the list", () => { + const result = buildChildToolNames(["read"], [{ name: "custom-tool", description: "", parameters: {}, label: "", execute: async () => ({ content: [], details: undefined }) }]); + assert.ok(result.includes("custom-tool"), "custom child tool must be added"); + assert.ok(result.includes("read"), "inherited tool must be preserved"); +}); + +test("buildChildToolNames (2-arg fallback) deduplicates overlapping inherited and custom names", () => { + const result = buildChildToolNames(["read", "bash"], [{ name: "bash", description: "", parameters: {}, label: "", execute: async () => ({ content: [], details: undefined }) }]); + assert.deepEqual(result, ["read", "bash"]); +}); + +test("buildChildToolNames (2-arg fallback) handles empty parent tool names", () => { + assert.deepEqual(buildChildToolNames([], [{ name: "only-child", description: "", parameters: {}, label: "", execute: async () => ({ content: [], details: undefined }) }]), ["only-child"]); +}); + +test("buildChildToolNames (2-arg fallback) handles empty custom tools", () => { + assert.deepEqual(buildChildToolNames(["read", "bash"], []), ["read", "bash"]); +}); + +test("buildChildToolNames (2-arg fallback) handles both empty inputs", () => { + assert.deepEqual(buildChildToolNames([], []), []); +}); + test("spawn execute short-circuits when signal is already aborted", async () => { const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); @@ -1131,8 +1172,6 @@ test("nested spawn attachSession recovers from subscribe throwing", () => { // Should not crash, session attached, ownership transferred assert.equal(state.childSessions.has("tool-call-1"), false); - assert.equal(h.warnings.length, 1); - assert.match(String(h.warnings[0].args[0]), /Failed to subscribe/); // Should still render from session messages despite subscribe failure const lines = component.render(120); diff --git a/tests/unit/state-invariants.test.ts b/tests/unit/state-invariants.test.ts index c52778a..4ea08a8 100644 --- a/tests/unit/state-invariants.test.ts +++ b/tests/unit/state-invariants.test.ts @@ -9,7 +9,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import * as fc from "fast-check"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { createState, resetState, abortAndClearChildSessions } from "../../state.js"; +import { createState, resetState, abortAndClearChildSessions, invalidateHandoffState } from "../../state.js"; import type { AgenticodingState } from "../../state.js"; import { setActiveNotebookTopic, @@ -109,6 +109,14 @@ function assertResetClears(state: AgenticodingState): void { assert.equal(state.pendingHandoff, null, "pendingHandoff must be null after reset"); assert.equal(state.pendingRequestedHandoff, null, "pendingRequestedHandoff must be null after reset"); assert.equal(state.pendingTopicBoundaryHint, null, "pendingTopicBoundaryHint must be null after reset"); + assert.equal(state.readonlyEnabled, false, "readonlyEnabled must be false after reset"); + assert.equal(state.readonlyNudgePending, false, "readonlyNudgePending must be false after reset"); + assert.equal(state.readonlySkillCache.size, 0, "readonlySkillCache must be empty after reset"); + assert.equal(state.readonlyPromptCache.size, 0, "readonlyPromptCache must be empty after reset"); + assert.equal(state.readonlySkillIssues.size, 0, "readonlySkillIssues must be empty after reset"); + assert.equal(state.readonlyPromptIssues.size, 0, "readonlyPromptIssues must be empty after reset"); + assert.equal(state.pendingReadonlyCommands.length, 0, "pendingReadonlyCommands must be empty after reset"); + assert.equal(state.lastWatchdogBand, null, "lastWatchdogBand must be null after reset"); } // ── Properties ──────────────────────────────────────────────────────── @@ -257,6 +265,13 @@ test("Property 4: Reset clears all state fields", async () => { const s2 = createState(); setActiveNotebookTopic(s2, "test-topic", "agent"); await saveNotebookPage(mockPi, s2, "my-page", "some content"); + s2.readonlyEnabled = true; + s2.readonlyNudgePending = true; + s2.readonlySkillCache.set("skill-a", { readonly: true, mtimeMs: 1, filePath: "/tmp/skill-a.md" }); + s2.readonlyPromptCache.set("prompt-a", { readonly: false, mtimeMs: 1, filePath: "/tmp/prompt-a.md" }); + s2.readonlySkillIssues.set("skill-b", { kind: "invalid-readonly-value", filePath: "/tmp/skill-b.md" }); + s2.readonlyPromptIssues.set("prompt-b", { kind: "unreadable-file", filePath: "/tmp/prompt-b.md" }); + s2.pendingReadonlyCommands.push({ type: "skill", name: "skill-a" }); resetState(s2); assertResetClears(s2); }, @@ -324,6 +339,32 @@ test("Property 5: Epoch monotonicity — non-zero after savePage", async () => { } }); +test("reset increments handoffGeneration and clears compaction reservations", () => { + const state = createState(); + const initial = state.handoffGeneration; + state.handoffCompactionGeneration = 7; + setActiveNotebookTopic(state, "topic", "agent"); + assert.equal(state.handoffGeneration, initial); + resetState(state); + assert.equal(state.handoffGeneration, initial + 1); + assert.equal(state.handoffCompactionGeneration, null); + resetState(state); + assert.equal(state.handoffGeneration, initial + 2); +}); + +test("invalidateHandoffState clears branch-local compaction reservations", () => { + const state = createState(); + state.handoffCompactionGeneration = 7; + setActiveNotebookTopic(state, "topic", "agent"); + state.pendingRequestedHandoff = { toolCalled: false, resumeReadonlyAfterHandoff: true, enforcementAttempts: 1 }; + const initial = state.handoffGeneration; + invalidateHandoffState(state); + assert.equal(state.handoffGeneration, initial + 1); + assert.equal(state.handoffCompactionGeneration, null); + assert.equal(state.pendingRequestedHandoff, null); + assert.equal(state.pendingTopicBoundaryHint, null); +}); + test("Property 6: childSessionEpoch monotonicity (never decreases)", async () => { const h = createTestHarness(); try { diff --git a/tests/unit/system-prompt.test.ts b/tests/unit/system-prompt.test.ts index 45e6a89..0d1d023 100644 --- a/tests/unit/system-prompt.test.ts +++ b/tests/unit/system-prompt.test.ts @@ -30,10 +30,12 @@ test("CONTEXT_PRIMER states the notebook, topic, and handoff contracts", () => { assert.match(topicSection, /prefer handoff/i); assert.match(handoffSection, /handoff/i); assert.match(handoffSection, /notebook/i); + assert.match(rulesSection, /planning→execution/i); + assert.match(CONTEXT_PRIMER, /When the job changes, call the handoff tool\./i); + assert.match(CONTEXT_PRIMER, /Call handoff at job boundaries:/i); assert.match(rulesSection, /one subject, thread, or subsystem/i); }); - test("before_agent_start injects notebook contracts plus live topic and page data", async () => { const pi = createTestPI(); registerAgenticoding(pi as any); @@ -42,7 +44,8 @@ test("before_agent_start injects notebook contracts plus live topic and page dat await notebookWrite.execute("1", { name: "alpha", content: "first line\nsecond line" }, undefined, undefined, makeTUICtx()); const [handler] = pi.handlers.get("before_agent_start")!; - const result = await handler({ systemPrompt: "Base system prompt." }, makeTUICtx({ hasUI: false })); + const ctx = { ...makeTUICtx({ hasUI: false }), cwd: process.cwd(), isProjectTrusted: () => false }; + const result = await handler({ systemPrompt: "Base system prompt." }, ctx); assert.match(result.systemPrompt, /Base system prompt\./); assert.match(result.systemPrompt, /## Context management/); @@ -54,12 +57,12 @@ test("before_agent_start injects notebook contracts plus live topic and page dat assert.match(result.systemPrompt, /alpha: first line/); }); - test("before_agent_start injects no-topic guidance when the topic is unset", async () => { const pi = createTestPI(); registerAgenticoding(pi as any); const [handler] = pi.handlers.get("before_agent_start")!; - const result = await handler({ systemPrompt: "Base system prompt." }, makeTUICtx({ hasUI: false })); + const ctx = { ...makeTUICtx({ hasUI: false }), cwd: process.cwd(), isProjectTrusted: () => false }; + const result = await handler({ systemPrompt: "Base system prompt." }, ctx); assert.match(result.systemPrompt, /## Active Notebook Topic/); assert.match(result.systemPrompt, /No active notebook topic is set\./); diff --git a/tests/unit/tui-indicators.test.ts b/tests/unit/tui-indicators.test.ts index 83c2a18..413b195 100644 --- a/tests/unit/tui-indicators.test.ts +++ b/tests/unit/tui-indicators.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createState } from "../../state.js"; -import { updateIndicators, STATUS_KEY_TOPIC } from "../../tui.js"; +import { updateIndicators, STATUS_KEY_TOPIC, STATUS_KEY_READONLY } from "../../tui.js"; import { makeTUICtx } from "./helpers.js"; test("updateIndicators sets context usage status with correct color tone", () => { @@ -57,6 +57,24 @@ test("updateIndicators handles null context usage", () => { assert.ok(s?.includes("--%"), "null usage shows --%"); }); +test("updateIndicators treats malformed percentages as unavailable", () => { + for (const percent of [Number.NaN, Number.POSITIVE_INFINITY, -1]) { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + updateIndicators(makeTUICtx({ percent, record }), state); + assert.ok(record.statuses.get("agenticoding-ctx")?.includes("--%")); + assert.equal(record.widgets.get("agenticoding-warning"), undefined); + } +}); + +test("updateIndicators preserves overflow context percentages", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + updateIndicators(makeTUICtx({ percent: 125, record }), state); + assert.ok(record.statuses.get("agenticoding-ctx")?.includes("125%")); + assert.ok(record.widgets.get("agenticoding-warning")?.[0]?.includes("125%")); +}); + test("updateIndicators no-ops when ctx.hasUI is false", () => { const state = createState(); const record = { statuses: new Map(), widgets: new Map() }; @@ -89,6 +107,43 @@ test("updateIndicators shows active notebook topic when set", () => { assert.equal(record.statuses.get(STATUS_KEY_TOPIC), "🧭 oauth"); }); +test("updateIndicators shows readonly indicator when enabled", () => { + const state = createState(); + state.readonlyEnabled = true; + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: null, record }); + + updateIndicators(ctx, state); + const s = record.statuses.get(STATUS_KEY_READONLY); + assert.ok(s?.includes("\u{1F512}"), "readonly indicator should show lock emoji when enabled"); + assert.ok(s?.includes("readonly"), "readonly indicator should show 'readonly' text when enabled"); +}); + +test("updateIndicators hides readonly indicator when disabled", () => { + const state = createState(); + state.readonlyEnabled = false; + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: null, record }); + + updateIndicators(ctx, state); + assert.equal(record.statuses.get(STATUS_KEY_READONLY), undefined, "readonly indicator should be undefined when disabled"); +}); + +test("updateIndicators shows readonly-specific warning widget at 70%+ context", () => { + const state = createState(); + state.readonlyEnabled = true; + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: 85, record }); + + updateIndicators(ctx, state); + const w = record.widgets.get("agenticoding-warning"); + assert.ok(w, "warning widget should be present at 85%"); + assert.ok(w[0].includes("readonly"), "widget should mention readonly"); + assert.ok(w[0].includes("spawn"), "widget should mention spawn"); + assert.ok(w[0].includes("explicit /handoff"), "widget should mention explicit /handoff"); + assert.equal(w[0].includes("resumes readonly"), false, "widget should not promise auto-resume wording"); +}); + test("updateIndicators hides widget below 70% context", () => { const state = createState(); const record = { statuses: new Map(), widgets: new Map() }; diff --git a/tests/unit/watchdog.test.ts b/tests/unit/watchdog.test.ts index 2de756a..caec000 100644 --- a/tests/unit/watchdog.test.ts +++ b/tests/unit/watchdog.test.ts @@ -1,10 +1,14 @@ import test from "node:test"; import assert from "node:assert/strict"; +import fc from "fast-check"; import { createState } from "../../state.js"; -import { registerWatchdog } from "../../watchdog.js"; +import { registerWatchdog, MAX_HANDOFF_ATTEMPTS } from "../../watchdog.js"; import { buildNudge } from "../../watchdog.js"; import registerAgenticoding from "../../index.js"; -import { createTestPI } from "./helpers.js"; +import { registerHandoffCommand } from "../../handoff/command.js"; +import { registerHandoffTool } from "../../handoff/tool.js"; +import { createTestPI, makeReadonlyUICtx } from "./helpers.js"; +import { STATUS_KEY_HANDOFF } from "../../tui.js"; test("watchdog records context usage without user notifications", async () => { const pi = createTestPI(); @@ -22,8 +26,28 @@ test("watchdog records context usage without user notifications", async () => { }, ); - assert.equal(state.lastContextPercent, 70); assert.deepEqual(notifications, []); + assert.equal(state.lastContextPercent, 70); +}); + +test("watchdog ignores malformed percentages", async () => { + for (const percent of [Number.NaN, Number.POSITIVE_INFINITY, -1]) { + const pi = createTestPI(); + const state = createState(); + registerWatchdog(pi as any, state); + const [handler] = pi.handlers.get("agent_end")!; + await handler({}, { hasUI: false, getContextUsage: () => ({ percent }) }); + assert.equal(state.lastContextPercent, null); + } +}); + +test("watchdog records overflow percentages", async () => { + const pi = createTestPI(); + const state = createState(); + registerWatchdog(pi as any, state); + const [handler] = pi.handlers.get("agent_end")!; + await handler({}, { hasUI: false, getContextUsage: () => ({ percent: 125 }) }); + assert.equal(state.lastContextPercent, 125); }); test("context injects watchdog reminder before each LLM call", async () => { @@ -44,10 +68,11 @@ test("context injects watchdog reminder before each LLM call", async () => { assert.equal(result.messages[1].role, "custom"); assert.equal(result.messages[1].customType, "agenticoding-watchdog"); assert.equal(result.messages[1].display, false); - assert.match(result.messages[1].content, /Context at 70%/); - assert.match(result.messages[1].content, /Active notebook topic: oauth/); - assert.match(result.messages[1].content, /spawn it instead of polluting the parent context/i); - assert.doesNotMatch(result.messages[1].content, /If you're mid-job and still clear|consider a handoff and draft a clear brief for what comes next/i); + assert.match(result.messages[1].content, /70%/); + assert.match(result.messages[1].content, /oauth/); + assert.match(result.messages[1].content, /spawn/i); + assert.match(result.messages[1].content, /parent context/i); + assert.doesNotMatch(result.messages[1].content, /draft a clear brief|what comes next/i); }); @@ -64,10 +89,26 @@ test("context injects a boundary nudge below 30% after an explicit topic change" ); assert.equal(result.messages[1].display, false); - assert.match(result.messages[1].content, /Notebook topic changed from oauth to billing/); + assert.match(result.messages[1].content, /oauth/i); + assert.match(result.messages[1].content, /billing/i); + assert.match(result.messages[1].content, /topic changed/i); }); +test("context treats malformed percentages as unavailable", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [handler] = pi.handlers.get("context")!; + + for (const percent of [Number.NaN, Number.POSITIVE_INFINITY, -1]) { + const result = await handler( + { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + { getContextUsage: () => ({ percent }) }, + ); + assert.equal(result, undefined); + } +}); + test("context injects a no-topic nudge when context is high", async () => { const pi = createTestPI(); registerAgenticoding(pi as any); @@ -82,8 +123,27 @@ test("context injects a no-topic nudge when context is high", async () => { assert.equal(result.messages[1].role, "custom"); assert.equal(result.messages[1].customType, "agenticoding-watchdog"); assert.equal(result.messages[1].display, false); - assert.match(result.messages[1].content, /No active notebook topic is set/); - assert.match(result.messages[1].content, /Assign a fresh topic in the next clean context after handoff/i); + assert.match(result.messages[1].content, /no active notebook topic/i); + assert.match(result.messages[1].content, /fresh topic/i); + assert.match(result.messages[1].content, /handoff/i); +}); + + +test("context nudges at band crossings and after a below-30% reset", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [handler] = pi.handlers.get("context")!; + + for (const [percent, shouldNudge] of [ + [29, false], [30, true], [49, false], [50, true], + [69, false], [70, true], [29, false], [30, true], + ]) { + const result = await handler( + { messages: [{ role: "user", content: `at ${percent}`, timestamp: percent }] }, + { getContextUsage: () => ({ percent }) }, + ); + assert.equal(result !== undefined, shouldNudge, `watchdog nudge at ${percent}%`); + } }); @@ -98,7 +158,9 @@ test("context consumes a boundary hint after the first injected nudge", async () { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, { getContextUsage: () => ({ percent: 20 }) }, ); - assert.match(first.messages[1].content, /Notebook topic changed from oauth to billing/); + assert.match(first.messages[1].content, /oauth/i); + assert.match(first.messages[1].content, /billing/i); + assert.match(first.messages[1].content, /topic changed/i); const second = await handler( { messages: [{ role: "user", content: "hi", timestamp: 2 }] }, @@ -108,37 +170,67 @@ test("context consumes a boundary hint after the first injected nudge", async () }); -test("buildNudge no longer emits the old percent-only handoff text", () => { - const old = buildNudge({ activeNotebookTopic: "oauth", pendingTopicBoundaryHint: null }, 46); - assert.doesNotMatch(old, /One context, one job\.|If you're mid-job and still clear|consider a handoff and draft a clear brief/i); - assert.match(old, /Active notebook topic: oauth/); - assert.match(old, /prefer spawn/i); +test("buildNudge emits topic and spawn guidance", () => { + const nudge = buildNudge({ activeNotebookTopic: "oauth", pendingTopicBoundaryHint: null, readonlyEnabled: false, pendingRequestedHandoff: null }, 46, false); + assert.match(nudge, /Active notebook topic: oauth/); + assert.match(nudge, /prefer spawn/i); }); +test("default watchdog guidance respects token eligibility", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [handler] = pi.handlers.get("context")!; + const result = await handler( + { messages: [{ role: "user", content: "small window", timestamp: 1 }] }, + { getContextUsage: () => ({ percent: 30, contextWindow: 64_000 }) }, + ); + const nudge = result.messages.at(-1).content; + assert.match(nudge, /continue working until handoff is available/i); + assert.doesNotMatch(nudge, /prefer a deliberate handoff/i); +}); + +test("buildNudge does not require an ineligible pending handoff by default", () => { + const nudge = buildNudge({ + activeNotebookTopic: null, + pendingTopicBoundaryHint: null, + readonlyEnabled: false, + pendingRequestedHandoff: { toolCalled: false, resumeReadonlyAfterHandoff: false, enforcementAttempts: 0 }, + }, null, false); + assert.match(nudge, /not yet ready for compaction/i); + assert.doesNotMatch(nudge, /complete a real handoff in this session now/i); +}); test("buildNudge handles null percent and boundary hints before topic guidance", () => { const boundary = buildNudge( { activeNotebookTopic: "oauth", pendingTopicBoundaryHint: { from: "oauth", to: "billing", source: "human" }, + readonlyEnabled: false, + pendingRequestedHandoff: null, }, null, + false, ); assert.match(boundary, /Notebook topic changed from oauth to billing/); assert.doesNotMatch(boundary, /Active notebook topic: oauth/); - const noTopic = buildNudge({ activeNotebookTopic: null, pendingTopicBoundaryHint: null }, null); + const noTopic = buildNudge({ activeNotebookTopic: null, pendingTopicBoundaryHint: null, readonlyEnabled: false, pendingRequestedHandoff: null }, null, false); assert.match(noTopic, /Topic-aware context reminder/); assert.match(noTopic, /No active notebook topic is set/); }); -test("watchdog stays advisory when a requested handoff is not completed", async () => { +test("watchdog stays advisory for a fresh user-requested handoff", async () => { const pi = createTestPI(); const state = createState(); - state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; + registerHandoffCommand(pi as any, state); registerWatchdog(pi as any, state); const [handler] = pi.handlers.get("agent_end")!; + await pi.commands.get("handoff").handler("implement auth", { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + const notifications: string[] = []; await handler( {}, @@ -152,7 +244,252 @@ test("watchdog stays advisory when a requested handoff is not completed", async }, ); - assert.equal(state.pendingRequestedHandoff, null); + assert.equal(state.pendingRequestedHandoff?.toolCalled, false); + assert.ok(state.pendingRequestedHandoff, "handoff request should remain active after one turn"); assert.deepEqual(notifications, []); - assert.deepEqual(pi.sentUserMessages, []); +}); + +test("watchdog does not cancel an in-flight handoff", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffCommand(pi as any, state); + registerHandoffTool(pi as any, state); + registerWatchdog(pi as any, state); + await pi.commands.get("handoff").handler("continue work", { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + let compactOptions: any; + await pi.tools.get("handoff").execute("in-flight", { task: "continue work" }, undefined, undefined, { + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + compact: (options: any) => { compactOptions = options; }, + }); + + const [handler] = pi.handlers.get("agent_end")!; + for (let i = 0; i < MAX_HANDOFF_ATTEMPTS + 2; i++) { + await handler({}, { hasUI: false, getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }) }); + } + assert.equal(state.pendingRequestedHandoff?.toolCalled, true); + compactOptions.onComplete(); + assert.equal(state.pendingRequestedHandoff, null); +}); + +test("watchdog auto-cancels a required handoff after enough unanswered turns", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffCommand(pi as any, state); + registerWatchdog(pi as any, state); + const [handler] = pi.handlers.get("agent_end")!; + + await pi.commands.get("handoff").handler("implement auth", { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + + const notifications: unknown[] = []; + const statuses = new Map(); + const ctx = { + hasUI: true, + ui: { + notify: (message: unknown) => notifications.push(message), + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, + }, + getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }), + }; + + // Ineligible turns do not consume the cancellation budget. + for (let i = 0; i < 3; i++) { + await handler({}, { ...ctx, getContextUsage: () => ({ tokens: 5000, percent: 2.5, contextWindow: 200000 }) }); + } + assert.equal(state.pendingRequestedHandoff?.enforcementAttempts, 0); + + for (let i = 0; i < MAX_HANDOFF_ATTEMPTS - 1; i++) { + await handler({}, ctx); + } + assert.ok(state.pendingRequestedHandoff, "handoff must remain pending before the final attempt"); + assert.deepEqual(notifications, [], "cancellation must not notify early"); + + await handler({}, ctx); + assert.equal(state.pendingRequestedHandoff, null, "pending handoff should be auto-cancelled"); + assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined, "cancellation should clear the handoff status"); + assert.ok(notifications.length > 0, "user should receive a cancellation notification"); + assert.match(notifications[0] as string, /Required handoff cancelled/i, "notification should mention cancellation without source-specific wording"); + assert.doesNotMatch(notifications[0] as string, /user-requested|temporary bypass/i); +}); + +// ── Readonly-specific injection contracts ───────────────────────── + +async function drainReadonlyNudge(pi: ReturnType): Promise { + const [handler] = pi.handlers.get("context")!; + await handler( + { messages: [{ role: "user", content: "drain initial readonly nudge", timestamp: 1 }] }, + { getContextUsage: () => null } as any, + ); +} + +test("context hook suppresses watchdog after readonly toggle nudge is drained", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + const [handler] = pi.handlers.get("context")!; + + // First call: drain the one-shot toggle nudge + await handler( + { messages: [{ role: "user", content: "first", timestamp: 1 }] }, + { getContextUsage: () => null }, + ); + + // Repeated calls across watchdog bands remain silent while readonly is active. + for (const percent of [70, 30, 50, 80]) { + const result = await handler( + { messages: [{ role: "user", content: `second at ${percent}`, timestamp: percent }] }, + { getContextUsage: () => ({ percent }) }, + ); + assert.equal(result, undefined, `watchdog must be suppressed at ${percent}% in readonly mode`); + } +}); + +test("context injects a readonly-mode nudge after toggle", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + const [handler] = pi.handlers.get("context")!; + + const result = await handler( + { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + { getContextUsage: () => null }, + ); + + assert.equal(result.messages.length, 2); + assert.equal(result.messages[1].customType, "agenticoding-readonly-nudge"); + assert.match(result.messages[1].content, /readonly/i); + assert.match(result.messages[1].content, /write\/edit blocked/i); + assert.match(result.messages[1].content, /bash writes/i); + assert.match(result.messages[1].content, /handoff/i); +}); + +test("context injects readonly handoff guidance after explicit user /handoff", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [handler] = pi.handlers.get("context")!; + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + await drainReadonlyNudge(pi); + await pi.commands.get("handoff").handler("continue readonly work", { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + + const result = await handler( + { messages: [{ role: "user", content: "hi", timestamp: 2 }] }, + { getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }) }, + ); + const watchdogMessage = result.messages.find((message: any) => message.customType === "agenticoding-watchdog"); + + assert.ok(watchdogMessage, "requested handoff should inject watchdog guidance"); + assert.match(watchdogMessage.content, /handoff/i); + assert.match(watchdogMessage.content, /readonly/i); + assert.match(watchdogMessage.content, /temporary handoff exception active/i); + assert.match(watchdogMessage.content, /write\/edit remain blocked/i); + assert.match(watchdogMessage.content, /fresh context resumes in readonly mode|resumes readonly mode/i); + assert.doesNotMatch(watchdogMessage.content, /User explicitly requested|this request only/i); +}); + +test("readonly toggle nudge aligns with handoff exception in the same turn", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [handler] = pi.handlers.get("context")!; + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + await pi.commands.get("handoff").handler("continue readonly work", { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + + const result = await handler( + { messages: [{ role: "user", content: "hi", timestamp: 2 }] }, + { getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }) }, + ); + const readonlyMessage = result.messages.find((message: any) => message.customType === "agenticoding-readonly-nudge"); + const watchdogMessage = result.messages.find((message: any) => message.customType === "agenticoding-watchdog"); + + assert.ok(readonlyMessage, "readonly toggle should still emit its one-shot nudge"); + assert.ok(watchdogMessage, "handoff guidance should still be injected"); + assert.match(readonlyMessage.content, /temporary handoff exception active/i); + assert.match(readonlyMessage.content, /write\/edit remain blocked/i); + assert.doesNotMatch(readonlyMessage.content, /handoff blocked/i); + assert.match(watchdogMessage.content, /temporary handoff exception active/i); +}); + +test("eligible readonly human topic boundary auto-creates handoff bypass equivalent to /handoff", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [handler] = pi.handlers.get("context")!; + + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + await drainReadonlyNudge(pi); + await pi.commands.get("notebook").handler( + "oauth", + { hasUI: false, getContextUsage: () => null } as any, + ); + await pi.commands.get("notebook").handler( + "billing", + { hasUI: false, getContextUsage: () => null } as any, + ); + + const result = await handler( + { messages: [{ role: "user", content: "hi", timestamp: 2 }] }, + { getContextUsage: () => ({ tokens: 50000, percent: 25, contextWindow: 200000 }) }, + ); + + // An eligible topic boundary in readonly mode creates the handoff bypass and injects + // watchdog guidance — equivalent to explicit /handoff or a human topic boundary. + assert.ok(result, "topic boundary should inject watchdog guidance"); + const watchdogMessage = result.messages.find((m: any) => m.customType === "agenticoding-watchdog"); + assert.ok(watchdogMessage, "watchdog message should be present"); + assert.match(watchdogMessage.content, /handoff/i); + assert.match(watchdogMessage.content, /temporary handoff exception active/i); + + const [toolCallHandler] = pi.handlers.get("tool_call")!; + const blockResult = await toolCallHandler( + { toolName: "handoff", input: {} }, + { cwd: "/tmp" } as any, + ); + assert.equal(blockResult, undefined, "handoff tool should be unblocked after topic boundary creates bypass"); +}); + +test("watchdog band-crossing: nudge iff context enters a higher band", async () => { + // Band thresholds: null(<30%), 0(30–49%), 1(50–69%), 2(70%+). + // A nudge fires only the first time the band ascends. + await fc.assert( + fc.asyncProperty( + fc.array(fc.nat({ max: 150 }), { minLength: 1, maxLength: 20 }), + async (percentages) => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [handler] = pi.handlers.get("context")!; + let lastBand: number | null = null; + for (const raw of percentages) { + const pct = { percent: raw }; + const result = await handler( + { messages: [{ role: "user", content: String(raw), timestamp: Date.now() }] }, + { getContextUsage: () => pct }, + ); + const didNudge = result?.messages?.some( + (message: any) => message.customType === "agenticoding-watchdog", + ) ?? false; + if (raw < 30) { + // Below 30% resets lastWatchdogBand and never nudges. + assert.equal(didNudge, false, `${raw}% below 30% must not nudge`); + lastBand = null; + } else { + const currentBand: number = raw < 50 ? 0 : raw < 70 ? 1 : 2; + const shouldNudge: boolean = lastBand === null || currentBand > lastBand; + assert.equal(didNudge, shouldNudge, + `${raw}% band ${lastBand}→${currentBand}: nudge=${didNudge}`); + if (didNudge) lastBand = currentBand; + } + } + }, + ), + { numRuns: 100 }, + ); }); diff --git a/tui.ts b/tui.ts index 9205b2c..dcd83e1 100644 --- a/tui.ts +++ b/tui.ts @@ -6,6 +6,8 @@ */ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { normalizeContextPercent } from "./handoff/eligibility.js"; +import { READONLY_HANDOFF_TRIGGER } from "./readonly-copy.js"; import type { AgenticodingState } from "./state.js"; // ── TUI status / widget keys ───────────────────────────────────────── @@ -25,6 +27,9 @@ export const STATUS_KEY_NOTEBOOK = "agenticoding-notebook"; /** Status bar key for the active notebook topic. */ export const STATUS_KEY_TOPIC = "agenticoding-topic"; +/** Status bar key for the readonly mode indicator. */ +export const STATUS_KEY_READONLY = "agenticoding-readonly"; + /** Update TUI indicators: context usage, notebook count, topic, warning widget. */ export function updateIndicators(ctx: ExtensionContext, state: AgenticodingState): void { if (!ctx.hasUI) return; @@ -33,8 +38,9 @@ export function updateIndicators(ctx: ExtensionContext, state: AgenticodingState // Context usage const usage = ctx.getContextUsage(); - if (usage && usage.percent !== null) { - const pct = Math.round(usage.percent); + const percent = normalizeContextPercent(usage?.percent); + if (percent !== null) { + const pct = Math.round(percent); const tone = pct >= 70 ? "error" : pct >= 50 ? "warning" : pct >= 30 ? "accent" : "dim"; ctx.ui.setStatus(STATUS_KEY_CTX, theme.fg("dim", "ctx ") + theme.fg(tone, `${pct}%`)); } else { @@ -48,6 +54,12 @@ export function updateIndicators(ctx: ExtensionContext, state: AgenticodingState : theme.fg("dim", "\u{1F4D2} 0"), ); + // Readonly mode indicator + ctx.ui.setStatus( + STATUS_KEY_READONLY, + state.readonlyEnabled ? theme.fg("warning", "\u{1F512} readonly") : undefined, + ); + // Active notebook topic — show a dim placeholder when unset so the frame is discoverable ctx.ui.setStatus( STATUS_KEY_TOPIC, @@ -57,10 +69,13 @@ export function updateIndicators(ctx: ExtensionContext, state: AgenticodingState ); // High-context warning widget (above editor) - if (usage && usage.percent !== null && usage.percent >= 70) { - const warning = state.activeNotebookTopic - ? `Context at ${Math.round(usage.percent)}% — use topic fit: same topic → spawn, different topic → handoff` - : `Context at ${Math.round(usage.percent)}% — no active topic; handoff soon unless you can assign one cleanly`; + if (percent !== null && percent >= 70) { + const pct = Math.round(percent); + const warning = state.readonlyEnabled + ? `Context at ${pct}% — readonly: same topic → spawn; different topic → ${READONLY_HANDOFF_TRIGGER}` + : state.activeNotebookTopic + ? `Context at ${pct}% — use topic fit: same topic → spawn, different topic → handoff` + : `Context at ${pct}% — no active topic; handoff soon unless you can assign one cleanly`; ctx.ui.setWidget(WIDGET_KEY_WARNING, [ theme.fg("error", "\u26A0 ") + theme.fg("warning", warning), ]); diff --git a/watchdog.ts b/watchdog.ts index 2800817..d6482e0 100644 --- a/watchdog.ts +++ b/watchdog.ts @@ -1,30 +1,58 @@ /** - * Watchdog: advisory primacy-zone reminder. + * Watchdog: primacy-zone reminder plus sticky enforcement for required handoff. * * Exposes nudge text generation and records the latest context usage at * `agent_end` for UI/state purposes. Actual reminder injection happens in the * `context` hook so it can appear before every LLM call in the same agent run. - * - * Never force-disengages — the watchdog is advisory only. */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { AgenticodingState } from "./state.js"; +import { isHandoffEligible, normalizeContextPercent } from "./handoff/eligibility.js"; +import { + READONLY_HANDOFF_RETRY_ADVICE, + buildReadonlyHandoffWaitNotice, + buildReadonlyRequestedHandoffContinuation, +} from "./readonly-copy.js"; import { STATUS_KEY_HANDOFF } from "./tui.js"; -export function buildNudge(state: Pick, percent: number | null): string { - const pct = percent === null ? null : Math.round(percent); - const topic = state.activeNotebookTopic; - const boundary = state.pendingTopicBoundaryHint; +/** Max turns a required handoff stays sticky before auto-clear. + * 5 eligible turns gives the LLM ~2-3 response cycles to draft and execute a brief, + * including one async compaction retry path where handoff/tool.ts resets + * toolCalled=false after failure so enforcement can resume. */ +export const MAX_HANDOFF_ATTEMPTS = 5; + +type NudgeState = Pick; - if (boundary) { - return `Notebook topic changed from ${boundary.from ?? "(unset)"} to ${boundary.to}. -Treat this as a strong task-boundary signal. Prefer a deliberate handoff before -continuing under the new topic: save durable findings to the notebook, draft a -concise situational brief, and call handoff. Only continue inline if this was -merely a rename rather than a real pivot.`; +function buildRequestedHandoffNudge(state: NudgeState, eligible: boolean): string { + if (!eligible) { + const readonlyWait = state.pendingRequestedHandoff?.resumeReadonlyAfterHandoff + ? buildReadonlyHandoffWaitNotice() + : ""; + return "A handoff is requested, but context is not yet ready for compaction. Continue working and retry handoff later." + readonlyWait; } + const requestedHandoff = state.pendingRequestedHandoff!; + const readonlyContinuation = requestedHandoff.resumeReadonlyAfterHandoff + ? buildReadonlyRequestedHandoffContinuation() + : "Draft the brief so the next context can start cleanly."; + return `A real handoff is required in this session now. +You must complete it before continuing normal work. +Save durable findings to the notebook if needed, then call handoff. +${readonlyContinuation}`; +} + +function buildBoundaryNudge(state: NudgeState, eligible: boolean): string { + const boundary = state.pendingTopicBoundaryHint!; + const action = eligible + ? "Prefer a deliberate handoff before continuing under the new topic: save durable findings to the notebook, draft a concise situational brief, and call handoff." + : "Continue working until context is ready for handoff; this boundary remains advisory for now."; + return `Notebook topic changed from ${boundary.from ?? "(unset)"} to ${boundary.to}. +Treat this as a strong task-boundary signal. ${action} +Only continue inline if this was merely a rename rather than a real pivot.`; +} + +function buildDefaultNudge(pct: number | null, topic: string | null, eligible: boolean): string { const contextLead = pct === null ? "Topic-aware context reminder." : pct >= 70 @@ -32,11 +60,14 @@ merely a rename rather than a real pivot.`; : pct >= 50 ? `Context at ${pct}% — topic discipline matters now.` : `Context at ${pct}% — choose your next step by topic fit.`; + const pivotAdvice = eligible + ? "prefer a deliberate handoff" + : "continue working until handoff is available"; if (topic) { const urgency = pct !== null && pct >= 70 - ? "If the work no longer fits this topic, prefer a deliberate handoff now. If it still fits and only a focused noisy branch is needed, spawn it instead of polluting the parent context." - : "If the current work still fits this topic, prefer spawn for isolated noisy subtasks. If it no longer fits, prefer handoff instead of dragging stale context forward."; + ? `If the work no longer fits this topic, ${pivotAdvice}. If it still fits and only a focused noisy branch is needed, spawn it instead of polluting the parent context.` + : `If the current work still fits this topic, prefer spawn for isolated noisy subtasks. If it no longer fits, ${pivotAdvice} instead of dragging stale context forward.`; return `${contextLead} Active notebook topic: ${topic}. Use the topic as the current semantic frame. ${urgency} @@ -44,12 +75,22 @@ Save durable findings to the notebook before handoff.`; } const noTopicUrgency = pct !== null && pct >= 70 - ? "Assign a fresh topic in the next clean context after handoff." - : "Assign a short stable topic soon. If the work stays within that topic, prefer spawn for noisy subtasks. If the work shifts beyond it, prefer handoff."; + ? eligible + ? "Assign a fresh topic in the next clean context after handoff." + : "Assign a fresh topic now; continue working until handoff is available." + : `Assign a short stable topic soon. If the work stays within that topic, prefer spawn for noisy subtasks. If the work shifts beyond it, ${pivotAdvice}.`; return `${contextLead} No active notebook topic is set. ${noTopicUrgency}`; } +/** Build a watchdog message using the caller's current handoff eligibility. */ +export function buildNudge(state: NudgeState, percent: number | null, eligible: boolean): string { + const pct = percent === null ? null : Math.round(percent); + if (state.pendingRequestedHandoff) return buildRequestedHandoffNudge(state, eligible); + if (state.pendingTopicBoundaryHint) return buildBoundaryNudge(state, eligible); + return buildDefaultNudge(pct, state.activeNotebookTopic, eligible); +} + /** * Register the watchdog's `agent_end` handler. * @@ -57,27 +98,41 @@ No active notebook topic is set. ${noTopicUrgency}`; */ export function registerWatchdog(pi: ExtensionAPI, state: AgenticodingState): void { pi.on("agent_end", async (_event: unknown, ctx: ExtensionContext) => { + // ── Enforcement counter: prevent infinite handoff nudges ── + // pendingRequestedHandoff is the sticky "user still expects a real + // handoff" contract. It survives normal turns and even compaction-prep + // failure recovery; only a successful handoff completion, reset, or maxed + // retries should clear it. const requestedHandoff = state.pendingRequestedHandoff; - if (requestedHandoff) { + if (requestedHandoff && !requestedHandoff.toolCalled && isHandoffEligible(ctx.getContextUsage())) { requestedHandoff.enforcementAttempts += 1; - if (!requestedHandoff.toolCalled) { + if (requestedHandoff.enforcementAttempts >= MAX_HANDOFF_ATTEMPTS) { state.pendingRequestedHandoff = null; if (ctx.hasUI) { ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); + const retryAdvice = state.readonlyEnabled + ? READONLY_HANDOFF_RETRY_ADVICE + : "Use /handoff again to retry."; + ctx.ui.notify( + `Required handoff cancelled after ${MAX_HANDOFF_ATTEMPTS} turns without completion. ${retryAdvice}`, + "warning", + ); } } } // ── Primacy-zone nudge ────────────────────────────────────── const usage = ctx.getContextUsage(); + const percent = normalizeContextPercent(usage?.percent); - // Null usage / null percent — right after compaction, before next LLM response. - if (!usage || usage.percent === null) { + // Null or malformed usage — right after compaction or when the host cannot + // provide a real percentage. Do not persist or display invalid values. + if (percent === null) { state.lastContextPercent = null; return; } - state.lastContextPercent = usage.percent; + state.lastContextPercent = percent; }); }