diff --git a/README.md b/README.md index dac2a1ec..f1e66b18 100644 --- a/README.md +++ b/README.md @@ -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 请求层和精确轮次取消协议已覆盖这些目标;保留当前实现,并补充响应体停滞和超时清理的回归覆盖。 diff --git a/tests/web/app-render.spec.ts b/tests/web/app-render.spec.ts index 339db439..8629a0ee 100644 --- a/tests/web/app-render.spec.ts +++ b/tests/web/app-render.spec.ts @@ -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", }, @@ -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", }, diff --git a/tests/web/pi-adapter.test.ts b/tests/web/pi-adapter.test.ts index 513bea1f..32e770f3 100644 --- a/tests/web/pi-adapter.test.ts +++ b/tests/web/pi-adapter.test.ts @@ -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( ( @@ -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 }); + } +}); diff --git a/tests/web/trust-status.test.ts b/tests/web/trust-status.test.ts new file mode 100644 index 00000000..c9004a61 --- /dev/null +++ b/tests/web/trust-status.test.ts @@ -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" }), + ); +}); diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index 9c52ca85..31c4a263 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -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", @@ -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( @@ -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, diff --git a/tests/web/web-store.spec.ts b/tests/web/web-store.spec.ts index f61f1c81..52b14245 100644 --- a/tests/web/web-store.spec.ts +++ b/tests/web/web-store.spec.ts @@ -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 { @@ -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", }, @@ -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", }, diff --git a/web/adapter/pi-adapter.ts b/web/adapter/pi-adapter.ts index ba46852c..32c9e89a 100644 --- a/web/adapter/pi-adapter.ts +++ b/web/adapter/pi-adapter.ts @@ -321,6 +321,7 @@ export class PiWebAdapter { // only the Web projection below is retained and bounded. const sorted = allSessions; const currentId = this.runtime.sessionManager.getSessionId(); + const currentFile = this.runtime.sessionManager.getSessionFile(); const pinned = new Set( sorted .filter( @@ -341,6 +342,10 @@ export class PiWebAdapter { id: session.id, path: session.path, cwd: resolve(session.cwd), + source: "web-session" as const, + origin: "web" as const, + controller: session.id === currentId && currentFile !== undefined && resolve(session.path) === resolve(currentFile) ? ("web" as const) : ("none" as const), + readOnly: false as const, ...(session.name ? { name: boundedText(session.name, WEB_MAX_SESSION_PREVIEW) } : {}), @@ -378,6 +383,10 @@ export class PiWebAdapter { id: currentId, path: currentPath, cwd: resolve(this.runtime.cwd), + source: "web-session" as const, + origin: "web" as const, + controller: "web" as const, + readOnly: false as const, ...(this.runtime.sessionManager.getSessionName() ? { name: boundedText( diff --git a/web/host/web-host.ts b/web/host/web-host.ts index 9b1601ff..ab2be60b 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -683,6 +683,15 @@ export class WebHost { } if (url.pathname === "/api/models") return this.json(response, 200, { models: this.runtime.listModels() }); + if (url.pathname === "/api/trust") { + if (!this.runtime.getProjectTrustStatus) { + return this.json(response, 501, { + code: "PROJECT_TRUST_STATUS_UNAVAILABLE", + error: "project Trust status is unavailable", + }); + } + return this.json(response, 200, this.runtime.getProjectTrustStatus()); + } if (url.pathname === "/api/capabilities") return this.json(response, 200, { sessionId: this.runtime.sessionManager.getSessionId(), diff --git a/web/protocol/types.ts b/web/protocol/types.ts index 24fa803c..539a6b40 100644 --- a/web/protocol/types.ts +++ b/web/protocol/types.ts @@ -28,6 +28,13 @@ export interface WebSessionSummary { id: string; path: string; cwd: string; + /** Classification of this Web directory projection, not original creation history. */ + source: "web-session"; + origin: "web"; + /** Current runtime only; not a global ownership lock. */ + controller: "web" | "none"; + /** Existing operation admission rules still apply. */ + readOnly: false; name?: string; modified: string; created: string; diff --git a/web/runtime/pi-runtime.ts b/web/runtime/pi-runtime.ts index 55a8ebc4..c0b120ef 100644 --- a/web/runtime/pi-runtime.ts +++ b/web/runtime/pi-runtime.ts @@ -37,6 +37,9 @@ import { acquireWebHostLease, type WebHostLease, } from "./web-host-lease.ts"; +import { + projectWebTrustStatus, +} from "./trust-status.ts"; const STARTUP_TIMEOUT_MS = 15_000; const TURN_CANCELLATION_SETTLEMENT_TIMEOUT_MS = 10_000; @@ -191,6 +194,23 @@ export class PiWebRuntime implements WebRuntimeController { return this.runtime.session.sessionManager; } + getProjectTrustStatus() { + if (!this.hasSelectedWorkspace) return projectWebTrustStatus({}); + const workspace = this.cwd; + try { + const storedDecision = new ProjectTrustStore(getAgentDir()).get(workspace); + return projectWebTrustStatus({ + workspace, + storedDecision, + projectResources: hasTrustRequiringProjectResources(workspace), + sessionTrusted: + this.runtime.session.settingsManager.isProjectTrusted(), + }); + } catch { + return projectWebTrustStatus({ workspace }); + } + } + isIdle() { return !this.runtime.session.isStreaming; } diff --git a/web/runtime/trust-status.ts b/web/runtime/trust-status.ts new file mode 100644 index 00000000..b4570bc6 --- /dev/null +++ b/web/runtime/trust-status.ts @@ -0,0 +1,74 @@ +export type WebProjectTrustState = + | "trusted" + | "untrusted" + | "restricted" + | "unknown"; + +export type WebProjectTrustDecision = + | "trusted" + | "denied" + | "undecided" + | "unknown"; + +export interface WebProjectTrustStatus { + readonly source: "pi-project-trust"; + readonly workspace?: string; + readonly state: WebProjectTrustState; + readonly decision: WebProjectTrustDecision; + readonly projectResources: boolean | "unknown"; + readonly sessionTrusted: boolean | "unknown"; + readonly refreshRequired: boolean | "unknown"; +} + +export interface WebProjectTrustFacts { + readonly workspace?: string; + readonly storedDecision?: boolean | null; + readonly projectResources?: boolean; + readonly sessionTrusted?: boolean; +} + +export function projectWebTrustStatus( + facts: WebProjectTrustFacts, +): WebProjectTrustStatus { + if ( + facts.workspace === undefined || + facts.storedDecision === undefined || + facts.projectResources === undefined || + facts.sessionTrusted === undefined + ) { + return { + source: "pi-project-trust", + ...(facts.workspace ? { workspace: facts.workspace } : {}), + state: "unknown", + decision: "unknown", + projectResources: facts.projectResources ?? "unknown", + sessionTrusted: facts.sessionTrusted ?? "unknown", + refreshRequired: "unknown", + }; + } + + const decision = + facts.storedDecision === true + ? ("trusted" as const) + : facts.storedDecision === false + ? ("denied" as const) + : ("undecided" as const); + const state = facts.sessionTrusted + ? ("trusted" as const) + : facts.storedDecision === false + ? ("untrusted" as const) + : facts.projectResources + ? ("restricted" as const) + : ("unknown" as const); + const desiredTrust = !facts.projectResources || facts.storedDecision === true; + return { + source: "pi-project-trust", + workspace: facts.workspace, + state, + decision, + projectResources: facts.projectResources, + sessionTrusted: facts.sessionTrusted, + refreshRequired: + desiredTrust !== facts.sessionTrusted, + }; +} diff --git a/web/runtime/types.ts b/web/runtime/types.ts index 33272786..2fb6b379 100644 --- a/web/runtime/types.ts +++ b/web/runtime/types.ts @@ -1,5 +1,6 @@ import type { SessionManager } from "@earendil-works/pi-coding-agent"; import type { WebModelSummary } from "../protocol/types.ts"; +import type { WebProjectTrustStatus } from "./trust-status.ts"; export interface WebRuntimeEvent { type: string; @@ -77,6 +78,7 @@ export interface WebRuntimeController { readonly workspaceSelected: boolean; readonly sessionDirectory: string; readonly sessionManager: SessionManager; + getProjectTrustStatus?(): WebProjectTrustStatus; isIdle(): boolean; getActiveTurn(): WebActiveTurn | undefined; sendPrompt(