From b1a3b08a13de1c93dd8227de41b2880fd95f6495 Mon Sep 17 00:00:00 2001 From: Adam Cheng <63501289+627150795@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:42:49 +0800 Subject: [PATCH 1/6] feat(workspace-cleanup-guard): add configurable modes --- README.md | 4 +- SETUP.md | 4 +- extensions/setup/index.ts | 22 +++- extensions/shared/setup-config.ts | 13 ++ extensions/workspace-cleanup-guard/index.ts | 20 ++- .../workspace-provenance.ts | 37 ++++-- scripts/check-config-contract.mjs | 7 ++ tests/extensions/shared/setup-config.test.ts | 19 +++ .../workspace-provenance.test.ts | 116 +++++++++++++++++- 9 files changed, 224 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 9a9f9317e..2edac49e0 100644 --- a/README.md +++ b/README.md @@ -456,6 +456,8 @@ macOS/Linux arm64 与 x64 缺少二进制时,OpenPI 会从官方 Release 下 Plan 模式下需先退出规划,再通过 `/openpi-setup` 修改配置。Web 设置页会禁用相关修改入口,并在会话空闲时提供退出按钮;退出仅切换状态,不调用模型或开始实施。Setup 在接收命令和投递排队请求时都检查当前 Session 的 Plan 状态。设置页依据实际工具回执反馈保存、设置未变化、失败或取消;回合结束或模型声称成功都不能替代保存回执。 +工作区清理保护默认使用 `enforce`;可用 `/openpi-setup 不透明清理命令走确认` 切换为 `ask`,或选择 `off` 关闭。 + Web 侧栏支持独立置顶会话分区。行内图钉可置顶或取消,置顶区菜单切换最近更新和手动排序;手动模式支持拖动以及会话菜单上移/下移。`ui.webPinnedSort` 默认为 `manual`,可选 `updated`,由 `/openpi-setup` 或置顶区菜单保存。置顶顺序保存在当前 Web 会话目录的工作区元数据中;归档暂时隐藏置顶,恢复后保留顺序。 会话行右侧只保留三种提示:执行中的转圈、已完成未读的小圆点、需要处理的提示。等待输入和失败共用需要处理标记,悬停或打开会话可查看具体原因;停止和未知结果恢复普通行。断线时收起执行动画,由页面连接状态统一提示,绝不当作完成。打开会话并在可见页面停留四秒后,完成圆点在当前浏览器标签页内记为已读;后台完成回执在当前 Host 生命周期内保留,不将已释放运行实例误报为空闲。 @@ -466,7 +468,7 @@ Web 侧栏宽度默认 280px(220–420px),工具面板宽度默认 520px Web 常规设置提供 Subagent、Bash 和 Write/Edit 的完整/紧凑显示控件,直接保存已有 `ui.subagentResultDisplay`、`ui.bashToolDisplay`、`ui.fileMutationDisplay`,并用于 Web 的结果与执行过程默认展开。Pi 终端页脚开关和样式保存已有 `ui.customFooter` / `ui.footerStyle`,仅影响 Pi 终端页脚。能力发现、工作流上限、下一步建议及编辑后命令的控件通过 `/openpi-setup` 修改,以实际写入回执为准。 - + Web 保存终端页脚偏好不会重新配置另一份已打开的 Pi 界面;该界面在下次 Session 启动或原生 setup 应用时更新页脚。 diff --git a/SETUP.md b/SETUP.md index 6546f25f9..5f2362b4c 100644 --- a/SETUP.md +++ b/SETUP.md @@ -40,6 +40,8 @@ Exit Plan mode before starting `/openpi-setup`, including from Web settings. The ## Configure this package +The workspace cleanup guard defaults to `enforce`: it blocks deletion commands whose targets OpenPI cannot verify. Set `workspace_cleanup_guard=ask` to confirm those opaque commands through Pi's existing confirmation UI, or `off` to disable this guard. Known pre-existing literal paths continue to use the existing deletion confirmation in both `enforce` and `ask` modes; change the mode with `/openpi-setup`. + Web appearance controls directly save only their presentation fields; their success status is based on the completed write. Other package-owned settings forms submit requests through `/openpi-setup`. Role/model choices, concurrency, skill and plugin configuration requests remain visible in that episode, including required confirmation and its final apply/closure receipt. A setup submission message is not proof of persistence. Pi-owned model definitions and credentials have separate direct forms backed by Pi's `models.json` and native login API; secrets never enter the setup prompt. Web shows the original setup command and its results, including failed or unapplied requests, rather than hiding the configuration episode. Appearance controls wait for the current run to settle before accepting another change. Chat-width and font-size reset buttons request the existing defaults through the same setup entry point. The model tree selects the editable Pi definition and retains unsaved drafts within the same Session; narrow screens use a labeled settings-section selector. @@ -103,7 +105,7 @@ Web pinned conversations have a separate collapsible section above Workspaces. U Web sidebar width defaults to 280px (220–420px); tool pane width defaults to 520px (360–720px). Dragging or keyboard resizing saves `ui.webSidebarWidth` / `ui.webAuxiliaryWidth` through the shared appearance writer. Reload restores the preferred widths; a small viewport clamps only their visible sizes. `/openpi-setup` exposes the same choices as `ui_web_sidebar_width` and `ui_web_auxiliary_width`. - + Web model configuration can query an explicitly supplied provider connection, search its model catalog and add up to 100 selected models in one revision-checked native `models.json` write. Existing model metadata and credentials are preserved. Discovery uses Pi credentials only for the matching saved endpoint, or an optional temporary key that is not persisted. Catalog queries are bounded to ten seconds, 1 MiB and 500 models, reject redirects, and never send prompts. New models inherit the form's context/output and reasoning defaults; verify these in advanced parameters. Manual configuration remains available when the catalog is unsupported. No additional OpenPI preference is introduced. diff --git a/extensions/setup/index.ts b/extensions/setup/index.ts index aa06e2fb4..b6d51dc18 100644 --- a/extensions/setup/index.ts +++ b/extensions/setup/index.ts @@ -43,6 +43,8 @@ import { POST_EDIT_COMMAND_MAX_CHARS, REASONING_LEVELS, SETUP_CONFIG_CHANGED_CHANNEL, + type WorkspaceCleanupGuardMode, + WORKSPACE_CLEANUP_GUARD_MODES, updateSetupConfig, WEB_THEMES, type WebTheme, @@ -154,12 +156,12 @@ export function buildInteractiveSetupPrompt(options: { }) { const configurationState = options.savedConfigExists ? [ - "This package has already been configured. Explain the current settings in the user's language, then ask whether they want to keep them or change Capability discovery, Next-action suggestions, Workflow limits, OpenPI Web appearance, UI/Footer, result detail display, Post-edit, Agent role models, or review everything.", + "This package has already been configured. Explain the current settings in the user's language, then ask whether they want to keep them or change Workspace cleanup guard, Capability discovery, Next-action suggestions, Workflow limits, OpenPI Web appearance, UI/Footer, result detail display, Post-edit, Agent role models, or review everything.", "If the user keeps the current settings, do not call configure_my_pi_setup. If they choose a category, ask only the follow-up needed for that category.", ] : [ "This is the first setup. Explain the available choices and their impact in the user's language, then collect the initial preferences.", - "Prefer one ask_user call with up to three independent questions covering Capability discovery plus Workflow limits, Next-action suggestions, and UI/Footer/result display. Explain that Post-edit defaults off; keep it off unless the user opts in, then ask only for the command. Explain that built-in Agent roles used by subagent_spawn and workflow agent_type inherit the parent model unless the user assigns an available model to a role.", + "Prefer one ask_user call with up to three independent questions covering Workspace cleanup guard plus Capability discovery, Workflow limits, Next-action suggestions, and UI/Footer/result display. Explain that Post-edit defaults off; keep it off unless the user opts in, then ask only for the command. Explain that built-in Agent roles used by subagent_spawn and workflow agent_type inherit the parent model unless the user assigns an available model to a role.", ]; return [ @@ -176,6 +178,7 @@ export function buildInteractiveSetupPrompt(options: { ...configurationState, "", "Before asking, briefly explain what can be configured and the practical impact:", + "- Workspace cleanup guard: enforce (default) blocks deletion commands whose targets OpenPI cannot verify; ask uses the existing confirmation for those opaque commands; off disables this guard. Known pre-existing literal paths still use the normal confirmation in enforce and ask modes.", "- Capability discovery: explicit is the safe default and keeps OpenPI model tools absent until the user asks for a capability. adaptive is opt-in and keeps only the small openpi_load_tools gateway visible, allowing the model to load Subagents, Workflows, background terminals, structured search, or Session tracking when it judges them useful. Loaded groups remain session-stable, and normal permission, concurrency, and workflow limits still apply.", "- Next-action suggestions: disabled, or model-generated after a fully settled main-agent run. A suggestion appears as dim inline text on the first row of an empty editor; reserved cells at the row end keep CJK IME preedit from overwriting it. Right accepts it without submitting, and any other editor input dismisses it. Enabling requires an available provider/model and reasoning level and adds one small model call per settled run.", "- Workflow fan-out: concurrency controls simultaneous agents and resource pressure; max agent calls controls the total capacity of one workflow. Valid ranges are 1-64 and 1-1024.", @@ -188,6 +191,7 @@ export function buildInteractiveSetupPrompt(options: { "Natural-language configuration examples the user might ask for:", '- "let the model discover OpenPI capabilities when useful" → capability_discovery=adaptive', '- "only use OpenPI capabilities when I ask" → capability_discovery=explicit', + '- "ask before allowing opaque workspace cleanup" → workspace_cleanup_guard=ask', '- "use dark theme in OpenPI Web" → ui_web_theme=dark', '- "set OpenPI Web chat width to 960px" → ui_web_chat_width=960', `- Web sidebar width defaults to ${DEFAULT_WEB_SIDEBAR_WIDTH}px (${MIN_WEB_SIDEBAR_WIDTH}-${MAX_WEB_SIDEBAR_WIDTH}); tool pane width defaults to ${DEFAULT_WEB_AUXILIARY_WIDTH}px (${MIN_WEB_AUXILIARY_WIDTH}-${MAX_WEB_AUXILIARY_WIDTH}). Dragging a pane saves the same canonical preferences; small viewports temporarily clamp the visible width.`, @@ -458,8 +462,14 @@ export default function openPiSetup(pi: ExtensionAPI) { name: "configure_my_pi_setup", label: "Configure OpenPI", description: - "Apply a user-requested configuration change for this Pi setup. Configures capability discovery (explicit or opt-in adaptive), next-action suggestions, workflow fan-out, canonical OpenPI Web appearance (theme, chat width, font size, thinking expansion), UI/Footer (presets, style, multi-line layout), result detail display, optional Post-edit, and built-in Agent-role model assignments shared by subagent_spawn and workflow agent_type. Role models must be available in the Pi registry; null clears a role back to parent-model inheritance. Footer examples: powerline preset, powerline-mono, compact, or custom ui_footer_lines with flex. Preserve current values for settings the user did not ask to change. Changes apply immediately to the capability gateway and active TUI footer; Web observes appearance changes through canonical snapshots.", + "Apply a user-requested configuration change for this Pi setup. Configures the workspace cleanup guard, capability discovery (explicit or opt-in adaptive), next-action suggestions, workflow fan-out, canonical OpenPI Web appearance (theme, chat width, font size, thinking expansion), UI/Footer (presets, style, multi-line layout), result detail display, optional Post-edit, and built-in Agent-role model assignments shared by subagent_spawn and workflow agent_type. Role models must be available in the Pi registry; null clears a role back to parent-model inheritance. Footer examples: powerline preset, powerline-mono, compact, or custom ui_footer_lines with flex. Preserve current values for settings the user did not ask to change. Changes apply immediately to the active session.", parameters: Type.Object({ + workspace_cleanup_guard: Type.Optional( + StringEnum(WORKSPACE_CLEANUP_GUARD_MODES, { + description: + "Workspace cleanup guard mode: enforce (default) blocks opaque deletion commands, ask opens confirmation for opaque commands, and off skips the guard. Known pre-existing literal paths still use the normal confirmation in enforce and ask. Omit to preserve the current value.", + }), + ), capability_discovery: Type.Optional( StringEnum(CAPABILITY_DISCOVERY_MODES, { description: @@ -694,6 +704,10 @@ export default function openPiSetup(pi: ExtensionAPI) { ); const config: MyPiSetupConfig = { + workspaceCleanupGuard: + (params.workspace_cleanup_guard as + | WorkspaceCleanupGuardMode + | undefined) ?? current.workspaceCleanupGuard, capabilities: { discovery: (params.capability_discovery as @@ -824,7 +838,7 @@ export default function openPiSetup(pi: ExtensionAPI) { EXPLICIT_VALUE_GUIDANCE, FOOTER_PRESET_GUIDANCE, "", - "Capability discovery is explicit by default; adaptive is an opt-in that keeps only openpi_load_tools visible so the model may load useful groups. Footer tips: presets are powerline, powerline-mono, compact; style is plain/powerline/powerline-mono; custom layouts use ui_footer_lines (2D enum arrays with optional flex). Do not use ui_footer_items together with ui_footer_lines. Built-in Agent role models (explorer, implementer, reviewer, advisor) are shared by subagent_spawn and workflow agent_type; they inherit the parent unless assigned an available registry model, and clearing an assignment restores inheritance. Custom agent-type files still override built-in role definitions. A Nerd Font renders Footer Codicons and powerline seams as designed; text stays readable without it. Changes apply immediately in the active TUI session.", + "Workspace cleanup guard defaults to enforce; ask confirms opaque cleanup commands, and off disables the guard. Capability discovery is explicit by default; adaptive is an opt-in that keeps only openpi_load_tools visible so the model may load useful groups. Footer tips: presets are powerline, powerline-mono, compact; style is plain/powerline/powerline-mono; custom layouts use ui_footer_lines (2D enum arrays with optional flex). Do not use ui_footer_items together with ui_footer_lines. Built-in Agent role models (explorer, implementer, reviewer, advisor) are shared by subagent_spawn and workflow agent_type; they inherit the parent unless assigned an available registry model, and clearing an assignment restores inheritance. Custom agent-type files still override built-in role definitions. A Nerd Font renders Footer Codicons and powerline seams as designed; text stays readable without it. Changes apply immediately in the active TUI session.", "", "configure_my_pi_setup is available only for this setup run. If the run settles without a successful apply, the writer is hidden and a later change requires /openpi-setup . Use configure_my_pi_setup to apply only the requested OpenPI-owned changes and preserve everything else. Interpret model names from the available Pi registry. Do not edit configuration files directly.", SETUP_REQUEST_VALIDATION, diff --git a/extensions/shared/setup-config.ts b/extensions/shared/setup-config.ts index af604484d..ed269b0df 100644 --- a/extensions/shared/setup-config.ts +++ b/extensions/shared/setup-config.ts @@ -93,6 +93,10 @@ export const CAPABILITY_DISCOVERY_MODES = ["explicit", "adaptive"] as const; export type CapabilityDiscoveryMode = (typeof CAPABILITY_DISCOVERY_MODES)[number]; +export const WORKSPACE_CLEANUP_GUARD_MODES = ["enforce", "ask", "off"] as const; +export type WorkspaceCleanupGuardMode = + (typeof WORKSPACE_CLEANUP_GUARD_MODES)[number]; + /** Canonical default layout: one-line plain footer with flex alignment. */ export const DEFAULT_FOOTER_LINES: FooterLines = [ ["model", "context", "flex", "git", "pr", "cwd"], @@ -165,6 +169,7 @@ export const POST_EDIT_COMMAND_MAX_CHARS = 500; export const SETUP_CONFIG_CHANGED_CHANNEL = "my-pi-setup:config-changed"; export interface MyPiSetupConfig { + readonly workspaceCleanupGuard: WorkspaceCleanupGuardMode; readonly capabilities: { readonly discovery: CapabilityDiscoveryMode; }; @@ -211,6 +216,7 @@ export interface MyPiSetupConfig { } export const DEFAULT_SETUP_CONFIG: MyPiSetupConfig = { + workspaceCleanupGuard: "enforce", capabilities: { discovery: "explicit" }, suggestions: { enabled: false }, workflows: { @@ -541,6 +547,11 @@ export function parseSetupConfig(value: unknown): MyPiSetupConfig { const subagents = isRecord(value.subagents) ? value.subagents : {}; const footer = parseUiFooter(ui); return { + workspaceCleanupGuard: WORKSPACE_CLEANUP_GUARD_MODES.includes( + value.workspaceCleanupGuard as WorkspaceCleanupGuardMode, + ) + ? (value.workspaceCleanupGuard as WorkspaceCleanupGuardMode) + : "enforce", capabilities: { discovery: isCapabilityDiscoveryMode(capabilities.discovery) ? capabilities.discovery @@ -665,6 +676,7 @@ const modelShape = { const suggestionShape = { enabled: booleanValue, model: modelShape }; const setupShape: ConfigShape = { configVersion: (value) => value === SETUP_CONFIG_VERSION, + workspaceCleanupGuard: oneOf(WORKSPACE_CLEANUP_GUARD_MODES), capabilities: { discovery: isCapabilityDiscoveryMode }, suggestions: suggestionShape, summaries: suggestionShape, @@ -1409,6 +1421,7 @@ export function formatSetupConfig(config = loadSetupConfig()) { ? `on · ${config.ui.footerStyle} · ${formatFooterLines(config.ui.footerLines)}` : "off"; return [ + `Workspace cleanup guard: ${config.workspaceCleanupGuard}`, `Capability discovery: ${config.capabilities.discovery}`, suggestions, `Workflows: ${config.workflows.concurrency} concurrent agents · ${config.workflows.maxAgentCalls} total calls`, diff --git a/extensions/workspace-cleanup-guard/index.ts b/extensions/workspace-cleanup-guard/index.ts index 6492cf354..2d5c5ade4 100644 --- a/extensions/workspace-cleanup-guard/index.ts +++ b/extensions/workspace-cleanup-guard/index.ts @@ -2,16 +2,30 @@ import { type ExtensionAPI, isToolCallEventType, } from "@earendil-works/pi-coding-agent"; +import { + loadSetupConfig, + SETUP_CONFIG_CHANGED_CHANNEL, +} from "../shared/setup-config.ts"; import { createWorkspaceCleanupGuard } from "./workspace-provenance.ts"; const DELETE_CONFIRMATION_TITLE = "Delete pre-existing workspace files?"; +const OPAQUE_DELETE_CONFIRMATION_TITLE = "Allow unverified workspace cleanup?"; function deleteConfirmationMessage(paths: readonly string[]) { + if (paths.length === 0) { + return "OpenPI cannot verify which workspace files this command may delete. Allow this command to run?"; + } return `The command would delete files that existed before this agent changed them:\n\n${paths.map((candidate) => `- ${candidate}`).join("\n")}\n\nAllow this exact deletion?`; } export default function workspaceCleanupGuard(pi: ExtensionAPI) { - const workspaceCleanup = createWorkspaceCleanupGuard(); + const workspaceCleanup = createWorkspaceCleanupGuard( + loadSetupConfig().workspaceCleanupGuard, + ); + + pi.events.on(SETUP_CONFIG_CHANGED_CHANNEL, () => { + workspaceCleanup.setMode(loadSetupConfig().workspaceCleanupGuard); + }); pi.on("tool_call", async (event, ctx) => { if (isToolCallEventType("write", event)) { @@ -30,7 +44,9 @@ export default function workspaceCleanupGuard(pi: ExtensionAPI) { cwd: ctx.cwd, confirmDelete: (paths) => ctx.ui.confirm( - DELETE_CONFIRMATION_TITLE, + paths.length === 0 + ? OPAQUE_DELETE_CONFIRMATION_TITLE + : DELETE_CONFIRMATION_TITLE, deleteConfirmationMessage(paths), { signal: ctx.signal }, ), diff --git a/extensions/workspace-cleanup-guard/workspace-provenance.ts b/extensions/workspace-cleanup-guard/workspace-provenance.ts index 8035f45a2..e82e84477 100644 --- a/extensions/workspace-cleanup-guard/workspace-provenance.ts +++ b/extensions/workspace-cleanup-guard/workspace-provenance.ts @@ -581,7 +581,10 @@ async function exists(candidate: string) { } } -export function createWorkspaceCleanupGuard() { +export function createWorkspaceCleanupGuard( + initialMode: "enforce" | "ask" | "off" = "enforce", +) { + let mode = initialMode; const origins = new Map(); const pending = new Map(); @@ -599,15 +602,21 @@ export function createWorkspaceCleanupGuard() { return { path: contained.absolute, existed, observeOnCommandError }; }; - const unverifiedCleanup = () => ({ - kind: "block" as const, - protectedPaths: [], - reason: - "Blocked cleanup: OpenPI recognized a source-visible deletion outside its supported direct rm command grammar. Use a direct rm command with literal workspace-relative paths so OpenPI can determine whether each target is session-created scratch or a pre-existing path requiring confirmation.", - }); + const unverifiedCleanup = async (attempt: BashAttempt) => { + if (mode === "ask" && (await attempt.confirmDelete([]))) { + return { kind: "allow" as const }; + } + return { + kind: "block" as const, + protectedPaths: [], + reason: + "Blocked cleanup: OpenPI could not verify which workspace files this command may delete. Use a direct rm command with literal workspace-relative paths, or explicitly confirm the unverified cleanup in ask mode.", + }; + }; return { async beforeWrite(attempt: WriteAttempt) { + if (mode === "off") return; const creation = await prepareCreation(attempt.cwd, attempt.path, false); pending.set(attempt.id, { creations: creation ? [creation] : [], @@ -616,14 +625,15 @@ export function createWorkspaceCleanupGuard() { }, async before(attempt: BashAttempt) { + if (mode === "off") return { kind: "allow" as const }; const inspected = inspectShell(attempt.command); - if (inspected.opaqueDestructiveCommand) return unverifiedCleanup(); + if (inspected.opaqueDestructiveCommand) return unverifiedCleanup(attempt); const containedRemovals = inspected.removals.map((candidate) => containedPath(attempt.cwd, candidate), ); if (containedRemovals.some((candidate) => !candidate)) { - return unverifiedCleanup(); + return unverifiedCleanup(attempt); } const creations: PendingEffects["creations"] = []; @@ -688,5 +698,14 @@ export function createWorkspaceCleanupGuard() { origins.clear(); pending.clear(); }, + + setMode(next: "enforce" | "ask" | "off") { + if (mode === next) return; + mode = next; + // ponytail: discard uncertain ownership on policy changes; + // re-confirm if needed. + origins.clear(); + pending.clear(); + }, }; } diff --git a/scripts/check-config-contract.mjs b/scripts/check-config-contract.mjs index af86684a0..b931896d3 100644 --- a/scripts/check-config-contract.mjs +++ b/scripts/check-config-contract.mjs @@ -7,6 +7,13 @@ import { parseExpressionAt } from "acorn"; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const CONFIG_FIELD_CONTRACT = [ + { + path: "workspaceCleanupGuard", + writerTokens: ["params.workspace_cleanup_guard"], + statusTokens: ["config.workspaceCleanupGuard"], + readmeTerms: ["Workspace cleanup guard"], + setupTerms: ["Workspace cleanup guard"], + }, { path: "capabilities.discovery", writerTokens: ["params.capability_discovery"], diff --git a/tests/extensions/shared/setup-config.test.ts b/tests/extensions/shared/setup-config.test.ts index 59dff28b2..364e1528a 100644 --- a/tests/extensions/shared/setup-config.test.ts +++ b/tests/extensions/shared/setup-config.test.ts @@ -155,6 +155,25 @@ test("FAIL: session child execution admission is opt-in and bounded", () => { ); }); +test("workspace cleanup guard modes are configurable and default to enforce", () => { + assert.equal(DEFAULT_SETUP_CONFIG.workspaceCleanupGuard, "enforce"); + for (const mode of ["enforce", "ask", "off"] as const) { + assert.equal( + parseSetupConfig({ workspaceCleanupGuard: mode }).workspaceCleanupGuard, + mode, + ); + } + assert.equal( + parseSetupConfig({ workspaceCleanupGuard: "invalid" }) + .workspaceCleanupGuard, + "enforce", + ); + assert.match( + formatSetupConfig(DEFAULT_SETUP_CONFIG), + /Workspace cleanup guard: enforce/, + ); +}); + test("process start-time queries are platform-specific and conservative", () => { const windowsQuery = processStartedAtQuery(123, "win32"); assert.equal(windowsQuery.command, "powershell.exe"); diff --git a/tests/extensions/workspace-cleanup-guard/workspace-provenance.test.ts b/tests/extensions/workspace-cleanup-guard/workspace-provenance.test.ts index a465d9889..aa3b5dd3e 100644 --- a/tests/extensions/workspace-cleanup-guard/workspace-provenance.test.ts +++ b/tests/extensions/workspace-cleanup-guard/workspace-provenance.test.ts @@ -7,8 +7,9 @@ import { createWorkspaceCleanupGuard } from "../../../extensions/workspace-clean function guardFor( confirmDelete: (paths: readonly string[]) => Promise, + mode: "enforce" | "ask" | "off" = "enforce", ) { - const guard = createWorkspaceCleanupGuard(); + const guard = createWorkspaceCleanupGuard(mode); return { ...guard, before(attempt: { id: string; command: string; cwd: string }) { @@ -17,6 +18,119 @@ function guardFor( }; } +test("cleanup guard modes keep enforce default, ask on opaque commands, and bypass in off", async () => { + await withWorkspace(async (workspace) => { + await writeFile(path.join(workspace, "baseline.txt"), "keep"); + const confirmations: string[][] = []; + const noConfirmations: string[][] = []; + const noPaths: string[] = []; + const baselinePath = ["baseline.txt"]; + const enforce = guardFor(async (paths) => { + confirmations.push([...paths]); + return true; + }); + assert.equal( + ( + await enforce.before({ + id: "enforce-opaque", + command: 'rm "$(cat target)"', + cwd: workspace, + }) + ).kind, + "block", + ); + assert.deepEqual(confirmations, noConfirmations); + + const ask = guardFor(async (paths) => { + confirmations.push([...paths]); + return true; + }, "ask"); + assert.equal( + ( + await ask.before({ + id: "ask-opaque", + command: 'rm "$(cat target)"', + cwd: workspace, + }) + ).kind, + "allow", + ); + assert.deepEqual(confirmations, [noPaths]); + + assert.equal( + ( + await ask.before({ + id: "ask-known-path", + command: "rm baseline.txt", + cwd: workspace, + }) + ).kind, + "allow", + ); + assert.deepEqual(confirmations, [noPaths, baselinePath]); + + const denyAsk = guardFor(async (paths) => { + confirmations.push([...paths]); + return false; + }, "ask"); + assert.equal( + ( + await denyAsk.before({ + id: "deny-ask-opaque", + command: 'rm "$(cat target)"', + cwd: workspace, + }) + ).kind, + "block", + ); + assert.deepEqual(confirmations, [noPaths, baselinePath, noPaths]); + + const off = guardFor(async (paths) => { + confirmations.push([...paths]); + return false; + }, "off"); + assert.equal( + ( + await off.before({ + id: "off-opaque", + command: 'rm "$(cat target)"', + cwd: workspace, + }) + ).kind, + "allow", + ); + assert.deepEqual(confirmations, [noPaths, baselinePath, noPaths]); + assert.equal( + ( + await off.before({ + id: "off-known-path", + command: "rm baseline.txt", + cwd: workspace, + }) + ).kind, + "allow", + ); + assert.deepEqual(confirmations, [noPaths, baselinePath, noPaths]); + + const changeable = guardFor(async (paths) => { + confirmations.push([...paths]); + return true; + }); + changeable.setMode("ask"); + assert.equal( + ( + await changeable.before({ + id: "changed-to-ask", + command: 'rm "$(cat target)"', + cwd: workspace, + }) + ).kind, + "allow", + ); + assert.deepEqual(confirmations, [noPaths, baselinePath, noPaths, noPaths]); + }); +}); + async function withWorkspace(run: (workspace: string) => Promise) { const workspace = await mkdtemp( path.join(tmpdir(), "openpi-workspace-provenance-"), From b71e354836d57ed693a46cd09bf3776fbf66cbd0 Mon Sep 17 00:00:00 2001 From: Adam Cheng <63501289+627150795@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:04:13 +0800 Subject: [PATCH 2/6] test(workspace-cleanup-guard): model extension event bus --- tests/extensions/workspace-cleanup-guard/index.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/extensions/workspace-cleanup-guard/index.test.ts b/tests/extensions/workspace-cleanup-guard/index.test.ts index 3c4c71c43..42566e545 100644 --- a/tests/extensions/workspace-cleanup-guard/index.test.ts +++ b/tests/extensions/workspace-cleanup-guard/index.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; +import { createEventBus } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI, ExtensionContext, @@ -27,7 +28,9 @@ interface HarnessOptions { function harness(options: HarnessOptions) { const handlers = new Map(); + const events = createEventBus(); const pi = { + events, on(event: string, handler: Handler) { handlers.set(event, [...(handlers.get(event) ?? []), handler]); }, From 408cade5530227cba3c0ce8f376a3c8bfea0b34e Mon Sep 17 00:00:00 2001 From: Adam Cheng <63501289+627150795@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:21:03 +0800 Subject: [PATCH 3/6] test(setup): include cleanup guard defaults --- tests/extensions/setup/index.test.ts | 4 ++-- tests/extensions/suggestions/config.test.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/extensions/setup/index.test.ts b/tests/extensions/setup/index.test.ts index d0f1ed313..3374fc3f5 100644 --- a/tests/extensions/setup/index.test.ts +++ b/tests/extensions/setup/index.test.ts @@ -1164,7 +1164,7 @@ test("partially assigns and clears validated subagent role models", () => { test("builds a focused review prompt when configuration already exists", () => { const message = buildInteractiveSetupPrompt({ currentConfiguration: - "Next-action suggestions: seal/deepseek-v4-flash · off · Right accepts\nWorkflows: 8 concurrent agents · 128 total calls", + "Workspace cleanup guard: enforce\nNext-action suggestions: seal/deepseek-v4-flash · off · Right accepts\nWorkflows: 8 concurrent agents · 128 total calls", currentModel: "seal/gpt-5.6-sol", currentThinking: "high", savedConfigExists: true, @@ -1174,7 +1174,7 @@ test("builds a focused review prompt when configuration already exists", () => { assert.match(message, /Explain the current settings/); assert.match( message, - /keep them or change Capability discovery, Next-action suggestions, Workflow limits, OpenPI Web appearance, UI\/Footer, result detail display, Post-edit, Agent role models/, + /keep them or change Workspace cleanup guard, Capability discovery, Next-action suggestions, Workflow limits, OpenPI Web appearance, UI\/Footer, result detail display, Post-edit, Agent role models/, ); assert.match(message, /keeps the current settings, do not call/); assert.match(message, /available only for this setup run/i); diff --git a/tests/extensions/suggestions/config.test.ts b/tests/extensions/suggestions/config.test.ts index 75ac656f4..5f51c4953 100644 --- a/tests/extensions/suggestions/config.test.ts +++ b/tests/extensions/suggestions/config.test.ts @@ -34,7 +34,7 @@ test("setup defaults to disabled next-action suggestions", () => { assert.deepEqual(parseSetupConfig(undefined), DEFAULT_SETUP_CONFIG); assert.equal( formatSetupConfig(parseSetupConfig(undefined)), - `Capability discovery: explicit\nNext-action suggestions: disabled\nWorkflows: 8 concurrent agents · 128 total calls\nSession child executions: unbounded (disabled)\nUI: Web theme system · chat 820px / 14px / thinking collapsed · panes 280px / 520px · pinned manual · large header off · custom footer on · plain · ${formatFooterLines(DEFAULT_FOOTER_LINES)}\nSubagent results: compact status summary (Ctrl+O expands full output)\nBash operations: one-line activity summary (Ctrl+O restores native evidence)\nWrite/Edit operations: one-line activity summary (Ctrl+O restores native evidence)\nPost-edit command: off\nAgent role models (Subagents + Workflows): explorer inherit · implementer inherit · reviewer inherit · advisor inherit`, + `Workspace cleanup guard: enforce\nCapability discovery: explicit\nNext-action suggestions: disabled\nWorkflows: 8 concurrent agents · 128 total calls\nSession child executions: unbounded (disabled)\nUI: Web theme system · chat 820px / 14px / thinking collapsed · panes 280px / 520px · pinned manual · large header off · custom footer on · plain · ${formatFooterLines(DEFAULT_FOOTER_LINES)}\nSubagent results: compact status summary (Ctrl+O expands full output)\nBash operations: one-line activity summary (Ctrl+O restores native evidence)\nWrite/Edit operations: one-line activity summary (Ctrl+O restores native evidence)\nPost-edit command: off\nAgent role models (Subagents + Workflows): explorer inherit · implementer inherit · reviewer inherit · advisor inherit`, ); }); @@ -50,6 +50,7 @@ test("setup config accepts suggestion models and migrates the recap key", () => }, }); assert.deepEqual(configured, { + workspaceCleanupGuard: "enforce", capabilities: { discovery: "explicit" }, suggestions: { enabled: true, @@ -67,7 +68,7 @@ test("setup config accepts suggestion models and migrates the recap key", () => }); assert.equal( formatSetupConfig(configured), - `Capability discovery: explicit\nNext-action suggestions: seal/deepseek-v4-flash · off · Right accepts\nWorkflows: 8 concurrent agents · 128 total calls\nSession child executions: unbounded (disabled)\nUI: Web theme system · chat 820px / 14px / thinking collapsed · panes 280px / 520px · pinned manual · large header off · custom footer on · plain · ${formatFooterLines(DEFAULT_FOOTER_LINES)}\nSubagent results: compact status summary (Ctrl+O expands full output)\nBash operations: one-line activity summary (Ctrl+O restores native evidence)\nWrite/Edit operations: one-line activity summary (Ctrl+O restores native evidence)\nPost-edit command: off\nAgent role models (Subagents + Workflows): explorer inherit · implementer inherit · reviewer inherit · advisor inherit`, + `Workspace cleanup guard: enforce\nCapability discovery: explicit\nNext-action suggestions: seal/deepseek-v4-flash · off · Right accepts\nWorkflows: 8 concurrent agents · 128 total calls\nSession child executions: unbounded (disabled)\nUI: Web theme system · chat 820px / 14px / thinking collapsed · panes 280px / 520px · pinned manual · large header off · custom footer on · plain · ${formatFooterLines(DEFAULT_FOOTER_LINES)}\nSubagent results: compact status summary (Ctrl+O expands full output)\nBash operations: one-line activity summary (Ctrl+O restores native evidence)\nWrite/Edit operations: one-line activity summary (Ctrl+O restores native evidence)\nPost-edit command: off\nAgent role models (Subagents + Workflows): explorer inherit · implementer inherit · reviewer inherit · advisor inherit`, ); assert.deepEqual( @@ -78,6 +79,7 @@ test("setup config accepts suggestion models and migrates the recap key", () => }, }), { + workspaceCleanupGuard: "enforce", capabilities: { discovery: "explicit" }, suggestions: { enabled: false }, workflows: { concurrency: 8, maxAgentCalls: 128 }, From 759aface0c6dfcfed0cb9ee200f394a127b73622 Mon Sep 17 00:00:00 2001 From: Adam Cheng <63501289+627150795@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:38:10 +0800 Subject: [PATCH 4/6] fix(workspace-cleanup-guard): ask only on opaque commands --- .../workspace-provenance.ts | 26 +++++++++---------- .../workspace-provenance.test.ts | 12 +++++++++ 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/extensions/workspace-cleanup-guard/workspace-provenance.ts b/extensions/workspace-cleanup-guard/workspace-provenance.ts index e82e84477..0f5d1efcb 100644 --- a/extensions/workspace-cleanup-guard/workspace-provenance.ts +++ b/extensions/workspace-cleanup-guard/workspace-provenance.ts @@ -602,17 +602,12 @@ export function createWorkspaceCleanupGuard( return { path: contained.absolute, existed, observeOnCommandError }; }; - const unverifiedCleanup = async (attempt: BashAttempt) => { - if (mode === "ask" && (await attempt.confirmDelete([]))) { - return { kind: "allow" as const }; - } - return { - kind: "block" as const, - protectedPaths: [], - reason: - "Blocked cleanup: OpenPI could not verify which workspace files this command may delete. Use a direct rm command with literal workspace-relative paths, or explicitly confirm the unverified cleanup in ask mode.", - }; - }; + const unverifiedCleanup = () => ({ + kind: "block" as const, + protectedPaths: [], + reason: + "Blocked cleanup: OpenPI recognized a source-visible deletion outside its supported direct rm command grammar. Use a direct rm command with literal workspace-relative paths so OpenPI can determine whether each target is session-created scratch or a pre-existing path requiring confirmation.", + }); return { async beforeWrite(attempt: WriteAttempt) { @@ -627,13 +622,18 @@ export function createWorkspaceCleanupGuard( async before(attempt: BashAttempt) { if (mode === "off") return { kind: "allow" as const }; const inspected = inspectShell(attempt.command); - if (inspected.opaqueDestructiveCommand) return unverifiedCleanup(attempt); + if (inspected.opaqueDestructiveCommand) { + if (mode === "ask" && (await attempt.confirmDelete([]))) { + return { kind: "allow" as const }; + } + return unverifiedCleanup(); + } const containedRemovals = inspected.removals.map((candidate) => containedPath(attempt.cwd, candidate), ); if (containedRemovals.some((candidate) => !candidate)) { - return unverifiedCleanup(attempt); + return unverifiedCleanup(); } const creations: PendingEffects["creations"] = []; diff --git a/tests/extensions/workspace-cleanup-guard/workspace-provenance.test.ts b/tests/extensions/workspace-cleanup-guard/workspace-provenance.test.ts index aa3b5dd3e..f6568c4d3 100644 --- a/tests/extensions/workspace-cleanup-guard/workspace-provenance.test.ts +++ b/tests/extensions/workspace-cleanup-guard/workspace-provenance.test.ts @@ -69,6 +69,18 @@ test("cleanup guard modes keep enforce default, ask on opaque commands, and bypa ); assert.deepEqual(confirmations, [noPaths, baselinePath]); + assert.equal( + ( + await ask.before({ + id: "ask-outside-path", + command: "rm ../outside.txt", + cwd: workspace, + }) + ).kind, + "block", + ); + assert.deepEqual(confirmations, [noPaths, baselinePath]); + const denyAsk = guardFor(async (paths) => { confirmations.push([...paths]); return false; From efba4f1e21d99fa0dc9cd2902f4b8c16b0b8f954 Mon Sep 17 00:00:00 2001 From: Adam Cheng <63501289+627150795@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:51:08 +0800 Subject: [PATCH 5/6] fix(workspace-cleanup-guard): invalidate stale confirmations --- README.md | 1 + .../workspace-provenance.ts | 38 +++++++++++++------ .../workspace-provenance.test.ts | 37 ++++++++++++++++++ 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2edac49e0..a23324d40 100644 --- a/README.md +++ b/README.md @@ -500,6 +500,7 @@ Footer 布局以 `footerLines` 作为唯一持久化格式。旧版 `footerItems | 配置 | 默认值 | | ---------------------------- | ---------------------------------------------- | +| Workspace cleanup guard | `enforce`;另有 `ask` / `off` | | Capability discovery | `explicit`;`adaptive` 必须显式开启 | | Next-action Suggestion | 关闭;启用时显式选择 Registry 模型与 reasoning | | Workflow 并发 / 总调用 | 8 / 128;硬上限 64 / 1024 | diff --git a/extensions/workspace-cleanup-guard/workspace-provenance.ts b/extensions/workspace-cleanup-guard/workspace-provenance.ts index 0f5d1efcb..8b7775b43 100644 --- a/extensions/workspace-cleanup-guard/workspace-provenance.ts +++ b/extensions/workspace-cleanup-guard/workspace-provenance.ts @@ -585,6 +585,7 @@ export function createWorkspaceCleanupGuard( initialMode: "enforce" | "ask" | "off" = "enforce", ) { let mode = initialMode; + let modeGeneration = 0; const origins = new Map(); const pending = new Map(); @@ -609,10 +610,19 @@ export function createWorkspaceCleanupGuard( "Blocked cleanup: OpenPI recognized a source-visible deletion outside its supported direct rm command grammar. Use a direct rm command with literal workspace-relative paths so OpenPI can determine whether each target is session-created scratch or a pre-existing path requiring confirmation.", }); + const changedModeCleanup = () => ({ + kind: "block" as const, + protectedPaths: [], + reason: + "Blocked cleanup: the workspace cleanup guard mode changed while this command was being checked. Retry the command so the current mode can be applied.", + }); + return { async beforeWrite(attempt: WriteAttempt) { + const generation = modeGeneration; if (mode === "off") return; const creation = await prepareCreation(attempt.cwd, attempt.path, false); + if (generation !== modeGeneration) return; pending.set(attempt.id, { creations: creation ? [creation] : [], removals: [], @@ -620,11 +630,14 @@ export function createWorkspaceCleanupGuard( }, async before(attempt: BashAttempt) { + const generation = modeGeneration; if (mode === "off") return { kind: "allow" as const }; const inspected = inspectShell(attempt.command); if (inspected.opaqueDestructiveCommand) { - if (mode === "ask" && (await attempt.confirmDelete([]))) { - return { kind: "allow" as const }; + if (mode === "ask") { + const confirmed = await attempt.confirmDelete([]); + if (generation !== modeGeneration) return changedModeCleanup(); + if (confirmed) return { kind: "allow" as const }; } return unverifiedCleanup(); } @@ -648,6 +661,7 @@ export function createWorkspaceCleanupGuard( if (!contained) continue; const origin = origins.get(contained.absolute); const present = await exists(contained.absolute); + if (generation !== modeGeneration) return changedModeCleanup(); if (present && !origin) origins.set(contained.absolute, "baseline"); if (present && origins.get(contained.absolute) !== "session_created") { protectedPaths.push(contained.relative); @@ -655,15 +669,16 @@ export function createWorkspaceCleanupGuard( removals.push(contained.absolute); } - if ( - protectedPaths.length > 0 && - !(await attempt.confirmDelete(protectedPaths)) - ) { - return { - kind: "block" as const, - protectedPaths, - reason: `Blocked cleanup: ${protectedPaths.join(", ")} existed before this agent changed it and is not proven session-created scratch. Retry the cleanup without that path, or obtain explicit user confirmation to delete it.`, - }; + if (protectedPaths.length > 0) { + const confirmed = await attempt.confirmDelete(protectedPaths); + if (generation !== modeGeneration) return changedModeCleanup(); + if (!confirmed) { + return { + kind: "block" as const, + protectedPaths, + reason: `Blocked cleanup: ${protectedPaths.join(", ")} existed before this agent changed it and is not proven session-created scratch. Retry the cleanup without that path, or obtain explicit user confirmation to delete it.`, + }; + } } pending.set(attempt.id, { creations, removals }); @@ -702,6 +717,7 @@ export function createWorkspaceCleanupGuard( setMode(next: "enforce" | "ask" | "off") { if (mode === next) return; mode = next; + modeGeneration += 1; // ponytail: discard uncertain ownership on policy changes; // re-confirm if needed. origins.clear(); diff --git a/tests/extensions/workspace-cleanup-guard/workspace-provenance.test.ts b/tests/extensions/workspace-cleanup-guard/workspace-provenance.test.ts index f6568c4d3..8761ece86 100644 --- a/tests/extensions/workspace-cleanup-guard/workspace-provenance.test.ts +++ b/tests/extensions/workspace-cleanup-guard/workspace-provenance.test.ts @@ -143,6 +143,43 @@ test("cleanup guard modes keep enforce default, ask on opaque commands, and bypa }); }); +test("mode changes invalidate pending cleanup confirmations", async () => { + await withWorkspace(async (workspace) => { + const confirmAfterModeChange = async ( + command: string, + expectedPaths: string[], + ) => { + let resolveConfirmation!: (confirmed: boolean) => void; + let signalConfirmationStarted!: () => void; + const confirmation = new Promise((resolve) => { + resolveConfirmation = resolve; + }); + const confirmationStarted = new Promise((resolve) => { + signalConfirmationStarted = resolve; + }); + const guard = guardFor(async (paths) => { + assert.deepEqual(paths, expectedPaths); + signalConfirmationStarted(); + return confirmation; + }, "ask"); + + const pending = guard.before({ + id: command, + command, + cwd: workspace, + }); + await confirmationStarted; + guard.setMode("enforce"); + resolveConfirmation(true); + assert.equal((await pending).kind, "block"); + }; + + await confirmAfterModeChange('rm "$(cat target)"', []); + await writeFile(path.join(workspace, "baseline.txt"), "keep"); + await confirmAfterModeChange("rm baseline.txt", ["baseline.txt"]); + }); +}); + async function withWorkspace(run: (workspace: string) => Promise) { const workspace = await mkdtemp( path.join(tmpdir(), "openpi-workspace-provenance-"), From 461c5577d6bef28052807b06535ab5e2f7599ea6 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Tue, 13 Oct 2026 12:30:35 +0800 Subject: [PATCH 6/6] fix(cleanup): invalidate stale asynchronous provenance commits --- .../workspace-provenance.ts | 50 ++- .../workspace-provenance.test.ts | 369 +++++++++++++++++- 2 files changed, 404 insertions(+), 15 deletions(-) diff --git a/extensions/workspace-cleanup-guard/workspace-provenance.ts b/extensions/workspace-cleanup-guard/workspace-provenance.ts index 8b7775b43..1443620ed 100644 --- a/extensions/workspace-cleanup-guard/workspace-provenance.ts +++ b/extensions/workspace-cleanup-guard/workspace-provenance.ts @@ -4,6 +4,7 @@ import path from "node:path"; type Origin = "baseline" | "session_created"; interface PendingEffects { + generation: number; creations: Array<{ path: string; existed: boolean; @@ -593,10 +594,12 @@ export function createWorkspaceCleanupGuard( cwd: string, candidate: string, observeOnCommandError: boolean, + generation: number, ) => { const contained = containedPath(cwd, candidate); if (!contained) return undefined; const existed = await exists(contained.absolute); + if (generation !== modeGeneration) return undefined; if (existed && !origins.has(contained.absolute)) { origins.set(contained.absolute, "baseline"); } @@ -621,9 +624,15 @@ export function createWorkspaceCleanupGuard( async beforeWrite(attempt: WriteAttempt) { const generation = modeGeneration; if (mode === "off") return; - const creation = await prepareCreation(attempt.cwd, attempt.path, false); + const creation = await prepareCreation( + attempt.cwd, + attempt.path, + false, + generation, + ); if (generation !== modeGeneration) return; pending.set(attempt.id, { + generation, creations: creation ? [creation] : [], removals: [], }); @@ -651,7 +660,13 @@ export function createWorkspaceCleanupGuard( const creations: PendingEffects["creations"] = []; for (const candidate of inspected.creations) { - const creation = await prepareCreation(attempt.cwd, candidate, true); + const creation = await prepareCreation( + attempt.cwd, + candidate, + true, + generation, + ); + if (generation !== modeGeneration) return changedModeCleanup(); if (creation) creations.push(creation); } @@ -659,10 +674,10 @@ export function createWorkspaceCleanupGuard( const protectedPaths: string[] = []; for (const contained of containedRemovals) { if (!contained) continue; - const origin = origins.get(contained.absolute); const present = await exists(contained.absolute); if (generation !== modeGeneration) return changedModeCleanup(); - if (present && !origin) origins.set(contained.absolute, "baseline"); + if (present && !origins.has(contained.absolute)) + origins.set(contained.absolute, "baseline"); if (present && origins.get(contained.absolute) !== "session_created") { protectedPaths.push(contained.relative); } @@ -681,35 +696,42 @@ export function createWorkspaceCleanupGuard( } } - pending.set(attempt.id, { creations, removals }); + if (generation !== modeGeneration) return changedModeCleanup(); + pending.set(attempt.id, { generation, creations, removals }); return { kind: "allow" as const }; }, async after(result: { id: string; isError: boolean }) { + const generation = modeGeneration; const effects = pending.get(result.id); + if (!effects || effects.generation !== generation) return; pending.delete(result.id); - if (!effects) return; for (const creation of effects.creations) { if ( - !creation.existed && - (!result.isError || creation.observeOnCommandError) && - !origins.has(creation.path) && - (await exists(creation.path)) + creation.existed || + (result.isError && !creation.observeOnCommandError) || + origins.has(creation.path) ) { + continue; + } + const present = await exists(creation.path); + if (generation !== modeGeneration) return; + if (present && !origins.has(creation.path)) { origins.set(creation.path, "session_created"); } } for (const removed of effects.removals) { - if ( - origins.get(removed) === "session_created" && - !(await exists(removed)) - ) { + if (origins.get(removed) !== "session_created") continue; + const present = await exists(removed); + if (generation !== modeGeneration) return; + if (!present && origins.get(removed) === "session_created") { origins.delete(removed); } } }, reset() { + modeGeneration += 1; origins.clear(); pending.clear(); }, diff --git a/tests/extensions/workspace-cleanup-guard/workspace-provenance.test.ts b/tests/extensions/workspace-cleanup-guard/workspace-provenance.test.ts index 8761ece86..d2ebf744b 100644 --- a/tests/extensions/workspace-cleanup-guard/workspace-provenance.test.ts +++ b/tests/extensions/workspace-cleanup-guard/workspace-provenance.test.ts @@ -1,8 +1,10 @@ import assert from "node:assert/strict"; +import fsPromises from "node:fs/promises"; +import { syncBuiltinESMExports } from "node:module"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import test from "node:test"; +import test, { mock } from "node:test"; import { createWorkspaceCleanupGuard } from "../../../extensions/workspace-cleanup-guard/workspace-provenance.ts"; function guardFor( @@ -754,3 +756,368 @@ test("proven creation survives a later command failure", async () => { ); }); }); + +async function pauseNextLstat( + run: (started: Promise, resume: () => void) => Promise, + absentObservations: readonly number[] = [], +) { + const original = fsPromises.lstat; + let signalStarted!: () => void; + let resume!: () => void; + const started = new Promise((resolve) => { + signalStarted = resolve; + }); + const resumed = new Promise((resolve) => { + resume = resolve; + }); + let observationCount = 0; + const observe = (...args: Parameters) => { + observationCount += 1; + if (absentObservations.includes(observationCount)) { + return Promise.reject( + Object.assign(new Error("simulated absent observation"), { + code: "ENOENT", + }), + ); + } + return original(...args); + }; + let first = true; + const mocked = mock.method( + fsPromises, + "lstat", + async (...args: Parameters) => { + if (!first) return observe(...args); + first = false; + // Capture the filesystem observation before allowing the policy/reset interleaving. + const observation = observe(...args); + void observation.catch(() => undefined); + signalStarted(); + await resumed; + return observation; + }, + ); + syncBuiltinESMExports(); + try { + await run(started, resume); + } finally { + resume(); + mocked.mock.restore(); + syncBuiltinESMExports(); + } +} + +for (const invalidate of ["policy", "reset"] as const) { + const invalidateGuard = (guard: ReturnType) => { + if (invalidate === "reset") guard.reset(); + else { + guard.setMode("off"); + guard.setMode("enforce"); + } + }; + + test(`${invalidate} prevents stale preparation from restoring baseline over fresh ownership`, async () => { + await withWorkspace(async (workspace) => { + let confirmations = 0; + const guard = guardFor(async () => { + confirmations += 1; + return false; + }); + await writeFile(path.join(workspace, "scratch.txt"), "old observation"); + await pauseNextLstat( + async (started, resume) => { + const preparation = guard.beforeWrite({ + id: "old", + cwd: workspace, + path: "scratch.txt", + }); + await started; + invalidateGuard(guard); + // Model a fresh absent observation without deleting a filesystem path. + await guard.beforeWrite({ + id: "new", + cwd: workspace, + path: "scratch.txt", + }); + await writeFile(path.join(workspace, "scratch.txt"), "new scratch"); + resume(); + await preparation; + }, + [2], + ); + await guard.after({ id: "new", isError: false }); + assert.equal( + ( + await guard.before({ + id: "delete", + cwd: workspace, + command: "rm scratch.txt", + }) + ).kind, + "allow", + ); + assert.equal(confirmations, 0); + }); + }); + + test(`${invalidate} invalidates Bash creation preparation`, async () => { + await withWorkspace(async (workspace) => { + let confirmations = 0; + const guard = guardFor(async () => { + confirmations += 1; + return false; + }); + await pauseNextLstat(async (started, resume) => { + const preparation = guard.before({ + id: "old", + cwd: workspace, + command: "cat > scratch.txt", + }); + await started; + invalidateGuard(guard); + resume(); + assert.equal((await preparation).kind, "block"); + }); + await writeFile(path.join(workspace, "scratch.txt"), "scratch"); + await guard.after({ id: "old", isError: false }); + assert.equal( + ( + await guard.before({ + id: "delete", + cwd: workspace, + command: "rm scratch.txt", + }) + ).kind, + "block", + ); + assert.equal(confirmations, 1); + }); + }); + + test(`${invalidate} invalidates write preparation and cannot consume new same-id pending`, async () => { + await withWorkspace(async (workspace) => { + let confirmations = 0; + const guard = guardFor(async () => { + confirmations += 1; + return false; + }); + await pauseNextLstat(async (started, resume) => { + const preparation = guard.beforeWrite({ + id: "same", + cwd: workspace, + path: "old.txt", + }); + await started; + invalidateGuard(guard); + await guard.beforeWrite({ + id: "same", + cwd: workspace, + path: "new.txt", + }); + resume(); + await preparation; + }); + await writeFile(path.join(workspace, "old.txt"), "old"); + await writeFile(path.join(workspace, "new.txt"), "new"); + await guard.after({ id: "same", isError: false }); + assert.equal( + ( + await guard.before({ + id: "new-delete", + cwd: workspace, + command: "rm new.txt", + }) + ).kind, + "allow", + ); + assert.equal( + ( + await guard.before({ + id: "old-delete", + cwd: workspace, + command: "rm old.txt", + }) + ).kind, + "block", + ); + assert.equal(confirmations, 1); + }); + }); + + test(`${invalidate} invalidates after creation observation and preserves new pending`, async () => { + await withWorkspace(async (workspace) => { + let confirmations = 0; + const guard = guardFor(async () => { + confirmations += 1; + return false; + }); + await guard.beforeWrite({ id: "same", cwd: workspace, path: "old.txt" }); + await writeFile(path.join(workspace, "old.txt"), "old"); + await pauseNextLstat(async (started, resume) => { + const completion = guard.after({ id: "same", isError: false }); + await started; + invalidateGuard(guard); + await guard.beforeWrite({ + id: "same", + cwd: workspace, + path: "new.txt", + }); + await writeFile(path.join(workspace, "new.txt"), "new"); + resume(); + await completion; + }); + await guard.after({ id: "same", isError: false }); + assert.equal( + ( + await guard.before({ + id: "new-delete", + cwd: workspace, + command: "rm new.txt", + }) + ).kind, + "allow", + ); + assert.equal( + ( + await guard.before({ + id: "old-delete", + cwd: workspace, + command: "rm old.txt", + }) + ).kind, + "block", + ); + assert.equal(confirmations, 1); + }); + }); + + test(`${invalidate} invalidates after removal observation before deleting new ownership`, async () => { + await withWorkspace(async (workspace) => { + let confirmations = 0; + const guard = guardFor(async () => { + confirmations += 1; + return false; + }); + await guard.beforeWrite({ + id: "create", + cwd: workspace, + path: "scratch.txt", + }); + await writeFile(path.join(workspace, "scratch.txt"), "scratch"); + await guard.after({ id: "create", isError: false }); + assert.equal( + ( + await guard.before({ + id: "remove", + cwd: workspace, + command: "rm scratch.txt", + }) + ).kind, + "allow", + ); + // Simulate absent lstat observations for the completed removal and fresh + // preparation; no deletion command or filesystem removal is executed. + await pauseNextLstat( + async (started, resume) => { + const completion = guard.after({ id: "remove", isError: false }); + await started; + invalidateGuard(guard); + await guard.beforeWrite({ + id: "new", + cwd: workspace, + path: "scratch.txt", + }); + await writeFile(path.join(workspace, "scratch.txt"), "new scratch"); + await guard.after({ id: "new", isError: false }); + resume(); + await completion; + }, + [1, 2], + ); + assert.equal( + ( + await guard.before({ + id: "current-delete", + cwd: workspace, + command: "rm scratch.txt", + }) + ).kind, + "allow", + ); + assert.equal(confirmations, 0); + }); + }); +} + +test("unchanged mode preserves current pending and session ownership", async () => { + await withWorkspace(async (workspace) => { + let confirmations = 0; + const guard = guardFor(async () => { + confirmations += 1; + return false; + }); + await guard.beforeWrite({ + id: "create", + cwd: workspace, + path: "scratch.txt", + }); + guard.setMode("enforce"); + await writeFile(path.join(workspace, "scratch.txt"), "scratch"); + await guard.after({ id: "create", isError: false }); + guard.setMode("enforce"); + assert.equal( + ( + await guard.before({ + id: "delete", + cwd: workspace, + command: "rm scratch.txt", + }) + ).kind, + "allow", + ); + assert.equal(confirmations, 0); + }); +}); + +test("after rechecks current ownership after its asynchronous creation observation", async () => { + await withWorkspace(async (workspace) => { + let confirmations = 0; + const guard = guardFor(async () => { + confirmations += 1; + return false; + }); + await guard.beforeWrite({ + id: "create", + cwd: workspace, + path: "scratch.txt", + }); + await writeFile(path.join(workspace, "scratch.txt"), "scratch"); + await pauseNextLstat(async (started, resume) => { + const completion = guard.after({ id: "create", isError: false }); + await started; + assert.equal( + ( + await guard.before({ + id: "protected", + cwd: workspace, + command: "rm scratch.txt", + }) + ).kind, + "block", + ); + resume(); + await completion; + }); + assert.equal( + ( + await guard.before({ + id: "still-protected", + cwd: workspace, + command: "rm scratch.txt", + }) + ).kind, + "block", + ); + assert.equal(confirmations, 2); + }); +});