From 1d608ec53b3d51be6b58d15bbde48b77965d7728 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Mon, 7 Sep 2026 13:12:17 +0800 Subject: [PATCH] feat(web): integrate session provenance and trust diagnostics Integrate testikun's #398 and #392 contributions on the current React baseline. Preserve current-runtime provenance without claiming a global ownership lock, distinguish persisted trust from active Session authority, and align refreshRequired with Pi runtime initialization when resources change. Cover copied Session IDs, authentication, optional controllers, and unknown trust facts. No new UI or authority mutation is introduced. Refs #440, #398, #392 Co-authored-by: testikun <320479488+testikun@users.noreply.github.com> --- README.md | 2 + tests/web/app-render.spec.ts | 8 ++ tests/web/pi-adapter.test.ts | 46 +++++++++ tests/web/trust-status.test.ts | 184 +++++++++++++++++++++++++++++++++ tests/web/web-host.test.ts | 59 +++++++++++ tests/web/web-store.spec.ts | 10 +- web/adapter/pi-adapter.ts | 9 ++ web/host/web-host.ts | 9 ++ web/protocol/types.ts | 7 ++ web/runtime/pi-runtime.ts | 20 ++++ web/runtime/trust-status.ts | 74 +++++++++++++ web/runtime/types.ts | 2 + 12 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 tests/web/trust-status.test.ts create mode 100644 web/runtime/trust-status.ts 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(