Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,8 @@ npm 仍用于发布包的 `pack` / clean-install 验证,因为用户通过 npm

Web workbench 的贡献包括:

- [@testikun](https://github.com/testikun):[#398](https://github.com/openpi-dev/openpi/pull/398) 的 Web 会话来源信息和 [#392](https://github.com/openpi-dev/openpi/pull/392) 的 Pi 项目信任诊断。来源字段只描述当前 Web 运行时,信任诊断区分已保存的决定和当前会话权限;这些是只读 API,目前没有新增界面入口。

- [QuinnWan (@somewan820)](https://github.com/somewan820):[#352](https://github.com/openpi-dev/openpi/pull/352) 提供界面恢复基线及复制回退;界面主体由 [#384](https://github.com/openpi-dev/openpi/pull/384) 迁移至 React,复制回退继续沿用并补充失败反馈。
- [@seekskyworld](https://github.com/seekskyworld):[#377](https://github.com/openpi-dev/openpi/pull/377) 提出浏览器请求超时保护,[#358](https://github.com/openpi-dev/openpi/pull/358) 提出 Pi 原生取消能力。当前 React 请求层和精确轮次取消协议已覆盖这些目标;保留当前实现,并补充响应体停滞和超时清理的回归覆盖。

Expand Down
8 changes: 8 additions & 0 deletions tests/web/app-render.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ describe("OpenPI React transcript", () => {
name: "foo bar",
modified: "2026-09-01T10:00:00Z",
created: "2026-09-01T10:00:00Z",
source: "web-session",
origin: "web",
controller: "web",
readOnly: false,
messageCount: 1,
firstMessage: "hello",
},
Expand Down Expand Up @@ -190,6 +194,10 @@ describe("OpenPI React transcript", () => {
cwd: "/tmp/ws",
modified: "2026-09-01T10:00:03Z",
created: "2026-09-01T10:00:00Z",
source: "web-session",
origin: "web",
controller: "web",
readOnly: false,
messageCount: entries.length,
firstMessage: "inspect it",
},
Expand Down
46 changes: 46 additions & 0 deletions tests/web/pi-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,33 @@ test("snapshot pins current and selected sessions while bounding the projection"
(session) => session.id === current.getSessionId(),
),
);
const currentSummary = snapshot.sessions.find(
(session) => session.id === current.getSessionId(),
);
assert.deepEqual(
{
source: currentSummary?.source,
origin: currentSummary?.origin,
controller: currentSummary?.controller,
readOnly: currentSummary?.readOnly,
},
{
source: "web-session",
origin: "web",
controller: "web",
readOnly: false,
},
);
assert.ok(
snapshot.sessions.some((session) => session.path === selectedPath),
);
const selectedSummary = snapshot.sessions.find(
(session) => session.path === selectedPath,
);
assert.equal(selectedSummary?.source, "web-session");
assert.equal(selectedSummary?.origin, "web");
assert.equal(selectedSummary?.controller, "none");
assert.equal(selectedSummary?.readOnly, false);
assert.equal(snapshot.selectedSession?.path, selectedPath);
assert.equal(
(
Expand Down Expand Up @@ -636,3 +660,25 @@ test("initialize fails closed without exposing or retrying uncommitted state", a
await rm(root, { recursive: true, force: true });
}
});

test("Session provenance targets the current file even when a copied file retains its id", async () => {
const root = await mkdtemp(join(tmpdir(), "openpi-provenance-"));
try {
const directory = join(root, "sessions");
const manager = SessionManager.create(root, directory);
persistSession(manager, "original", 1);
const original = manager.getSessionFile();
assert.ok(original);
const copy = join(directory, "copied.jsonl");
await writeFile(copy, await readFile(original));
const adapter = new PiWebAdapter(runtimeFor(root, directory, manager));
const { sessions } = await adapter.listSessionProjection();
assert.equal(sessions.find((s) => s.path === copy)?.controller, "none");
assert.deepEqual(
sessions.filter((s) => s.controller === "web").map((s) => s.path),
[original],
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
184 changes: 184 additions & 0 deletions tests/web/trust-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { ProjectTrustStore } from "@earendil-works/pi-coding-agent";
import { PiWebRuntime } from "../../web/runtime/pi-runtime.ts";
import { projectWebTrustStatus } from "../../web/runtime/trust-status.ts";

test("unbound or incomplete Trust facts fail closed to unknown", () => {
assert.deepEqual(projectWebTrustStatus({}), {
source: "pi-project-trust",
state: "unknown",
decision: "unknown",
projectResources: "unknown",
sessionTrusted: "unknown",
refreshRequired: "unknown",
});
assert.deepEqual(projectWebTrustStatus({ workspace: "/workspace" }), {
source: "pi-project-trust",
workspace: "/workspace",
state: "unknown",
decision: "unknown",
projectResources: "unknown",
sessionTrusted: "unknown",
refreshRequired: "unknown",
});
});

test("projects trusted, denied, and restricted Pi Trust states", () => {
assert.deepEqual(
projectWebTrustStatus({
workspace: "/trusted",
storedDecision: true,
projectResources: true,
sessionTrusted: true,
}),
{
source: "pi-project-trust",
workspace: "/trusted",
state: "trusted",
decision: "trusted",
projectResources: true,
sessionTrusted: true,
refreshRequired: false,
},
);
assert.equal(
projectWebTrustStatus({
workspace: "/denied",
storedDecision: false,
projectResources: true,
sessionTrusted: false,
}).state,
"untrusted",
);
assert.equal(
projectWebTrustStatus({
workspace: "/undecided",
storedDecision: null,
projectResources: true,
sessionTrusted: false,
}).state,
"restricted",
);
assert.equal(
projectWebTrustStatus({
workspace: "/no-project-resources",
storedDecision: null,
projectResources: false,
sessionTrusted: true,
}).state,
"trusted",
);
});

test("TrustStore changes do not pretend to mutate active Session authority", () => {
const newlyTrusted = projectWebTrustStatus({
workspace: "/workspace",
storedDecision: true,
projectResources: true,
sessionTrusted: false,
});
assert.equal(newlyTrusted.state, "restricted");
assert.equal(newlyTrusted.decision, "trusted");
assert.equal(newlyTrusted.refreshRequired, true);

const newlyDenied = projectWebTrustStatus({
workspace: "/workspace",
storedDecision: false,
projectResources: true,
sessionTrusted: true,
});
assert.equal(newlyDenied.state, "trusted");
assert.equal(newlyDenied.decision, "denied");
assert.equal(newlyDenied.refreshRequired, true);
});

test("PiWebRuntime reads the real ProjectTrustStore decision", async () => {
const workspace = await mkdtemp(join(tmpdir(), "openpi-trust-runtime-"));
const agentDir = await mkdtemp(join(tmpdir(), "openpi-agent-dir-"));
await mkdir(join(workspace, ".pi"));
await writeFile(join(workspace, ".pi", "settings.json"), "{}\n");
new ProjectTrustStore(agentDir).set(workspace, true);
const previous = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
try {
const runtime = Object.create(PiWebRuntime.prototype) as {
hasSelectedWorkspace: boolean;
runtime: {
cwd: string;
session: { settingsManager: { isProjectTrusted(): boolean } };
};
getProjectTrustStatus: PiWebRuntime["getProjectTrustStatus"];
};
runtime.hasSelectedWorkspace = true;
runtime.runtime = {
cwd: workspace,
session: { settingsManager: { isProjectTrusted: () => true } },
};
assert.deepEqual(runtime.getProjectTrustStatus(), {
source: "pi-project-trust",
workspace,
state: "trusted",
decision: "trusted",
projectResources: true,
sessionTrusted: true,
refreshRequired: false,
});
} finally {
if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previous;
await rm(workspace, { recursive: true, force: true });
await rm(agentDir, { recursive: true, force: true });
}
});

test("refresh matches Pi initialization when project resources change", () => {
for (const storedDecision of [true, false, null]) {
for (const projectResources of [true, false]) {
for (const sessionTrusted of [true, false]) {
const status = projectWebTrustStatus({
workspace: "/workspace",
storedDecision,
projectResources,
sessionTrusted,
});
assert.equal(
status.refreshRequired,
(!projectResources || storedDecision === true) !== sessionTrusted,
);
assert.equal(status.sessionTrusted, sessionTrusted);
}
}
}
});

test("runtime diagnostics do not read an unselected workspace and fail closed on unreadable facts", () => {
const runtime = Object.create(PiWebRuntime.prototype) as {
hasSelectedWorkspace: boolean;
runtime: {
cwd: string;
session: { settingsManager: { isProjectTrusted(): boolean } };
};
getProjectTrustStatus: PiWebRuntime["getProjectTrustStatus"];
};
runtime.hasSelectedWorkspace = false;
assert.deepEqual(runtime.getProjectTrustStatus(), projectWebTrustStatus({}));
runtime.hasSelectedWorkspace = true;
runtime.runtime = {
cwd: "/unreadable-workspace",
session: {
settingsManager: {
isProjectTrusted() {
throw new Error("private diagnostic failure");
},
},
},
};
assert.deepEqual(
runtime.getProjectTrustStatus(),
projectWebTrustStatus({ workspace: "/unreadable-workspace" }),
);
});
59 changes: 59 additions & 0 deletions tests/web/web-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn
current: false,
},
],
getProjectTrustStatus: () => ({
source: "pi-project-trust",
workspace: runtimeCwd,
state: "restricted",
decision: "undecided",
projectResources: true,
sessionTrusted: false,
refreshRequired: false,
}),
setModel: async () => {
throw new WebRuntimeRequestError(
"Model is not available",
Expand Down Expand Up @@ -159,6 +168,43 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn
const removedLegacyAsset = await fetch(`${launched.origin}/marked.js`);
assert.equal(removedLegacyAsset.status, 401);

const trustGetter = runtime.getProjectTrustStatus;
assert.ok(trustGetter);
let trustReads = 0;
runtime.getProjectTrustStatus = () => {
trustReads++;
return trustGetter();
};
assert.equal((await fetch(`${launched.origin}/api/trust`)).status, 401);
assert.equal(
(
await fetch(`${launched.origin}/api/trust`, {
headers: { ...authorized, Origin: "https://untrusted.example" },
})
).status,
403,
);
assert.equal(
(
await fetch(`${launched.origin}/api/trust`, {
method: "POST",
headers: authorized,
})
).status,
405,
);
assert.equal(trustReads, 0);
delete runtime.getProjectTrustStatus;
const unavailableTrust = await fetch(`${launched.origin}/api/trust`, {
headers: authorized,
});
assert.equal(unavailableTrust.status, 501);
assert.equal(
(await unavailableTrust.json()).code,
"PROJECT_TRUST_STATUS_UNAVAILABLE",
);
runtime.getProjectTrustStatus = trustGetter;

const unauthorized = await fetch(`${launched.origin}/api/snapshot`);
assert.equal(unauthorized.status, 401);
const unauthorizedCapabilities = await fetch(
Expand Down Expand Up @@ -194,6 +240,19 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn
});
assert.equal(modelsResponse.status, 200);
assert.deepEqual((await modelsResponse.json()).models, snapshot.models);
const trustResponse = await fetch(`${launched.origin}/api/trust`, {
headers: authorized,
});
assert.equal(trustResponse.status, 200);
assert.deepEqual(await trustResponse.json(), {
source: "pi-project-trust",
workspace: cwd,
state: "restricted",
decision: "undecided",
projectResources: true,
sessionTrusted: false,
refreshRequired: false,
});
const unavailableModel = await fetch(`${launched.origin}/api/model`, {
method: "POST",
headers: authorized,
Expand Down
10 changes: 9 additions & 1 deletion tests/web/web-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import type { WebEvent, WebSnapshot } from "../../web/protocol/types.ts";
import {
type CommandReceipt,
type SessionMutationResult,
WebClient,
WebApiError,
WebClient,
type WorkspaceSelectionResult,
} from "../../web/ui/src/protocol/client.ts";
import {
Expand Down Expand Up @@ -49,6 +49,10 @@ function snapshot(name = "Current"): WebSnapshot {
name,
modified: "2026-09-03T00:00:00Z",
created: "2026-09-03T00:00:00Z",
source: "web-session",
origin: "web",
controller: "web",
readOnly: false,
messageCount: 1,
firstMessage: "Hello",
},
Expand Down Expand Up @@ -93,6 +97,10 @@ function activeSnapshot(
name: options.name ?? id,
modified: "2026-09-03T00:00:00Z",
created: "2026-09-03T00:00:00Z",
source: "web-session",
origin: "web",
controller: "web",
readOnly: false,
messageCount: 1,
firstMessage: "Hello",
},
Expand Down
Loading
Loading