Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,8 @@ Web 可以在选择工作区之前预选可用模型。选择仅保留在当前

Pi 当前只原生分派 `install`、`remove`、`update`、`list`、`config` 和 `auth` 等固定子命令,package 不能注册新的顶层子命令。因此 Web 入口是独立 CLI 的 `openpi web`,不是会被 Pi 当成初始 Prompt 的 `pi open`。Web 进程仍沿用 Pi 的 Provider、模型、凭据、Settings、Trust、Session 格式和 extension 资源加载,不引入第二套 Provider 或 Session 存储。

Web 的检查面板只读展示当前工作区的 Pi Trust 状态,不在浏览器内变更 Trust。对于 cleanup guard 能准确识别的预存文件删除,发起命令的浏览器标签会收到一次有时限的准确路径确认;批准或拒绝只作用于该 Session、运行轮次及命令请求。其他标签不能答复,刷新同一标签可恢复尚未过期的请求;断线、过期、取消、切换会话或关闭 Host 时不默认批准。普通聊天里说“同意”不是原生确认;若确认界面不可用,guard 会阻止删除并指明使用交互式 Pi 或重新连接后重试。此切片仅覆盖 cleanup guard 的删除确认,不代表任意扩展的 `select`、`input` 或自定义 TUI 界面已支持 Web。

### 命令速查

| 命令 | 作用 |
Expand Down
41 changes: 41 additions & 0 deletions extensions/shared/web-cleanup-confirmation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export type CleanupConfirmation = "approved" | "denied" | "unavailable";

type Confirm = (
paths: readonly string[],
signal?: AbortSignal,
) => Promise<CleanupConfirmation>;

const key = Symbol.for("@tt-a1i/openpi/web-cleanup-confirmation/v1");

function providers(): Map<object, Confirm> {
const existing: unknown = Reflect.get(globalThis, key);
if (existing !== undefined) {
if (!(existing instanceof Map))
throw new Error("Incompatible Web confirmation registry");
return existing as Map<object, Confirm>;
}
const registry = new Map<object, Confirm>();
Object.defineProperty(globalThis, key, { value: registry });
return registry;
}

export function registerWebCleanupConfirmation(
scope: object,
confirm: Confirm,
) {
const registry = providers();
registry.set(scope, confirm);
return () => {
if (registry.get(scope) === confirm) registry.delete(scope);
};
}

export function requestWebCleanupConfirmation(
scope: object,
paths: readonly string[],
signal?: AbortSignal,
): Promise<CleanupConfirmation> {
return (
providers().get(scope)?.(paths, signal) ?? Promise.resolve("unavailable")
);
}
13 changes: 8 additions & 5 deletions extensions/workspace-cleanup-guard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
isToolCallEventType,
} from "@earendil-works/pi-coding-agent";
import { createWorkspaceCleanupGuard } from "./workspace-provenance.ts";
import { requestWebCleanupConfirmation } from "../shared/web-cleanup-confirmation.ts";

const DELETE_CONFIRMATION_TITLE = "Delete pre-existing workspace files?";

Expand All @@ -29,11 +30,13 @@ export default function workspaceCleanupGuard(pi: ExtensionAPI) {
command: event.input.command,
cwd: ctx.cwd,
confirmDelete: (paths) =>
ctx.ui.confirm(
DELETE_CONFIRMATION_TITLE,
deleteConfirmationMessage(paths),
{ signal: ctx.signal },
),
ctx.mode === "print" && !ctx.hasUI
? requestWebCleanupConfirmation(ctx.sessionManager, paths, ctx.signal)
: ctx.ui.confirm(
DELETE_CONFIRMATION_TITLE,
deleteConfirmationMessage(paths),
{ signal: ctx.signal },
),
});
if (cleanupDecision.kind === "block") {
return { block: true, reason: cleanupDecision.reason };
Expand Down
18 changes: 12 additions & 6 deletions extensions/workspace-cleanup-guard/workspace-provenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ interface BashAttempt {
id: string;
command: string;
cwd: string;
confirmDelete: (paths: readonly string[]) => Promise<boolean>;
confirmDelete: (
paths: readonly string[],
) => Promise<boolean | "approved" | "denied" | "unavailable">;
}

interface WriteAttempt {
Expand Down Expand Up @@ -516,14 +518,18 @@ export function createWorkspaceCleanupGuard() {
removals.push(contained.absolute);
}

if (
protectedPaths.length > 0 &&
!(await attempt.confirmDelete(protectedPaths))
) {
const confirmation =
protectedPaths.length > 0
? await attempt.confirmDelete(protectedPaths)
: true;
if (confirmation !== true && confirmation !== "approved") {
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.`,
reason:
confirmation === "unavailable"
? `Blocked cleanup: native confirmation is unavailable for ${protectedPaths.join(", ")}. The deletion was not approved; retry in an interactive Pi session or reconnect the controlling Web tab and retry the command.`
: `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.`,
};
}

Expand Down
38 changes: 38 additions & 0 deletions tests/extensions/workspace-cleanup-guard/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import workspaceCleanupGuard from "../../../extensions/workspace-cleanup-guard/index.ts";
import { registerWebCleanupConfirmation } from "../../../extensions/shared/web-cleanup-confirmation.ts";

type Handler = (event: unknown, ctx: ExtensionContext) => unknown;

Expand All @@ -17,6 +18,8 @@ interface ConfirmOptions {

interface HarnessOptions {
cwd: string;
mode?: "print" | "tui";
scope?: object;
signal?: AbortSignal;
confirm?: (
title: string,
Expand All @@ -34,6 +37,8 @@ function harness(options: HarnessOptions) {
} as unknown as ExtensionAPI;
const ctx = {
cwd: options.cwd,
mode: options.mode ?? "tui",
sessionManager: options.scope ?? {},
signal: options.signal,
ui: {
confirm: options.confirm ?? (async () => false),
Expand Down Expand Up @@ -140,6 +145,39 @@ test("refused deletion of a pre-existing file is blocked", async () => {
});
});

test("Web approval travels through the exact cleanup request; missing transport is unavailable", async () => {
await withWorkspace(async (workspace) => {
const target = path.join(workspace, "keep.txt");
await writeFile(target, "keep");
const scope = {};
const h = harness({ cwd: workspace, mode: "print", scope });
const unavailable = (await h.emit(
"tool_call",
bashCall("first", "rm keep.txt"),
)) as {
block: boolean;
reason: string;
};
assert.equal(unavailable.block, true);
assert.match(unavailable.reason, /native confirmation is unavailable/u);
let received: unknown;
const unregister = registerWebCleanupConfirmation(scope, async (paths) => {
received = paths;
return "approved";
});
try {
assert.equal(
await h.emit("tool_call", bashCall("second", "rm keep.txt")),
undefined,
);
assert.deepEqual(received, ["keep.txt"]);
assert.equal(await readFile(target, "utf8"), "keep");
} finally {
unregister();
}
});
});

test("an unverified rm target is blocked without opening confirmation", async () => {
await withWorkspace(async (workspace) => {
await writeFile(path.join(workspace, "keep.txt"), "keep");
Expand Down
85 changes: 85 additions & 0 deletions tests/web/confirmation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import test from "node:test";
import { WebCleanupConfirmations } from "../../web/runtime/confirmation.ts";

const turn = { sessionId: "session-1", commandId: "command-1", epoch: 1 };
const target = { workspace: "/workspace", turn };

test("approval is bound to the exact request, workspace and turn and settles once", async () => {
let changes = 0;
const confirmations = new WebCleanupConfirmations(() => {
changes++;
});
const decision = confirmations.request(target, ["old.txt"]);
const [pending] = confirmations.list();
assert.ok(pending);
assert.deepEqual(pending.paths, ["old.txt"]);
assert.equal(
confirmations.respond({ ...pending, workspace: "/other" }, true),
"stale",
);
assert.equal(confirmations.respond({ ...pending, epoch: 2 }, true), "stale");
assert.equal(
confirmations.respond({ ...pending, requestId: "unknown" }, true),
"stale",
);
assert.equal(confirmations.list().length, 1);
assert.equal(confirmations.respond(pending, true), "approved");
assert.equal(await decision, "approved");
assert.equal(confirmations.respond(pending, true), "already-settled");
assert.deepEqual(confirmations.list(), []);
assert.equal(changes, 2);
});

test("denial, abort, expiry and shutdown never grant deletion", async () => {
const confirmations = new WebCleanupConfirmations(() => {}, 15);
const denied = confirmations.request(target, ["keep.txt"]);
assert.equal(
confirmations.respond(confirmations.list()[0]!, false),
"denied",
);
assert.equal(await denied, "denied");

const controller = new AbortController();
const cancelled = confirmations.request(
target,
["keep.txt"],
controller.signal,
);
controller.abort();
assert.equal(await cancelled, "unavailable");
assert.deepEqual(confirmations.list(), []);

const expired = confirmations.request(target, ["keep.txt"]);
const request = confirmations.list()[0]!;
assert.equal(await expired, "unavailable");
assert.equal(confirmations.respond(request, true), "expired");

const shutdown = confirmations.request(target, ["keep.txt"]);
confirmations.invalidate();
assert.equal(await shutdown, "unavailable");
assert.deepEqual(confirmations.list(), []);
});

test("oversized paths and exhausted capacity fail closed without exposing a partial request", async () => {
const confirmations = new WebCleanupConfirmations(() => {});
assert.equal(
await confirmations.request(target, ["x".repeat(513)]),
"unavailable",
);
assert.equal(
await confirmations.request(target, Array(33).fill("old.txt")),
"unavailable",
);
assert.deepEqual(confirmations.list(), []);
const waits = Array.from({ length: 4 }, () =>
confirmations.request(target, ["old.txt"]),
);
assert.equal(
await confirmations.request(target, ["other.txt"]),
"unavailable",
);
assert.equal(confirmations.list().length, 4);
confirmations.invalidate();
assert.deepEqual(await Promise.all(waits), Array(4).fill("unavailable"));
});
80 changes: 80 additions & 0 deletions tests/web/openpi-web.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,86 @@ test("restores a running turn and canonical dark theme without losing cancellati
expect(accessibility.violations).toEqual([]);
});

test("restores a controller-bound cleanup confirmation after refresh", async ({
page,
}, testInfo) => {
const turn = {
sessionId: "cleanup-browser-session",
commandId: "cleanup-browser-turn",
epoch: 8,
};
const workspace = "/cleanup-browser";
const requests = ["obsolete.txt", "another-old.txt"].map((path, index) => ({
...turn,
workspace,
paths: [path],
requestId: `f150d4da-958d-4d5a-8f4e-d94b8cb9ca0${index}`,
expiresAt: Date.now() + 60_000,
}));
const answers: unknown[] = [];
const controllers: string[] = [];
await page.route("**/api/snapshot**", async (route) => {
const response = await route.fetch();
const snapshot = await response.json();
snapshot.currentSessionId = turn.sessionId;
snapshot.selectedSession = {
id: turn.sessionId,
path: `${workspace}/session.jsonl`,
cwd: workspace,
entries: [],
bytes: 0,
truncation: {
truncated: false,
maxBytes: 2097152,
entriesOmitted: 0,
messagesTruncated: 0,
messagePartsOmitted: 0,
},
};
snapshot.runtime = {
status: "running",
activeTurn: turn,
capabilities: {},
};
await route.fulfill({ response, json: snapshot });
});
await page.route("**/api/confirmations/pending", (route) => {
controllers.push(
route.request().headers()["x-openpi-web-controller"] ?? "",
);
return route.fulfill({
json: { pending: requests.slice(answers.length, answers.length + 1) },
});
});
await page.route("**/api/confirmations/answer", (route) => {
answers.push(route.request().postDataJSON());
return route.fulfill({
json: { state: answers.length === 1 ? "denied" : "approved" },
});
});
await openWorkbench(page);
const dialog = page.getByRole("dialog", { name: "删除预存文件?" });
await expect(dialog).toContainText("obsolete.txt");
await page.waitForTimeout(350);
await page.screenshot({
path: testInfo.outputPath("cleanup-confirmation.png"),
});
await dialog.getByRole("button", { name: "拒绝" }).click();
await expect.poll(() => answers.length).toBe(1);
await page.reload();
await expect(dialog).toContainText("another-old.txt");
await dialog.getByRole("button", { name: "批准删除" }).click();
await expect.poll(() => answers.length).toBe(2);
expect(answers).toEqual([
{ ...turn, workspace, requestId: requests[0]!.requestId, approved: false },
{ ...turn, workspace, requestId: requests[1]!.requestId, approved: true },
]);
expect(controllers[0]).toMatch(/^[0-9a-f-]{36}$/u);
expect(controllers.every((id) => id === controllers[0])).toBe(true);
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
});

test("recovers an unknown prompt admission only after an explicit user decision", async ({
page,
}, testInfo) => {
Expand Down
Loading
Loading