From 7f267fe6e4cafcb27c3ef8d5ea1e3d8dccd2940a Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:17:19 +0800 Subject: [PATCH 1/4] feat(web): expose project trust diagnostics --- tests/web/trust-status.test.ts | 92 ++++++++++++++++++++++++++++++++++ tests/web/web-host.test.ts | 22 ++++++++ web/host/web-host.ts | 9 ++++ web/runtime/pi-runtime.ts | 21 ++++++++ web/runtime/trust-status.ts | 74 +++++++++++++++++++++++++++ web/runtime/types.ts | 2 + 6 files changed, 220 insertions(+) create mode 100644 tests/web/trust-status.test.ts create mode 100644 web/runtime/trust-status.ts diff --git a/tests/web/trust-status.test.ts b/tests/web/trust-status.test.ts new file mode 100644 index 00000000..d630aef4 --- /dev/null +++ b/tests/web/trust-status.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +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); +}); diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index d80df4fa..5f8d90f9 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -65,6 +65,15 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn }, switchSession: async () => ({ cancelled: false }), listModels: () => [], + 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", @@ -266,6 +275,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/web/host/web-host.ts b/web/host/web-host.ts index 6203e647..5292c491 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -557,6 +557,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/snapshot") { const cursor = this.sequence; const projection = await this.adapter.getSnapshot( diff --git a/web/runtime/pi-runtime.ts b/web/runtime/pi-runtime.ts index cf792d39..e56339d4 100644 --- a/web/runtime/pi-runtime.ts +++ b/web/runtime/pi-runtime.ts @@ -33,6 +33,10 @@ import { acquireWebHostLease, type WebHostLease, } from "./web-host-lease.ts"; +import { + projectWebTrustStatus, + type WebProjectTrustStatus, +} from "./trust-status.ts"; const STARTUP_TIMEOUT_MS = 15_000; const BOOTSTRAP_WORKSPACE_DIRECTORY = ".bootstrap-workspace"; @@ -170,6 +174,23 @@ export class PiWebRuntime implements WebRuntimeController { return this.runtime.session.sessionManager; } + getProjectTrustStatus(): WebProjectTrustStatus { + 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..799d5749 --- /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 storedTrusted = facts.storedDecision === true; + return { + source: "pi-project-trust", + workspace: facts.workspace, + state, + decision, + projectResources: facts.projectResources, + sessionTrusted: facts.sessionTrusted, + refreshRequired: + facts.projectResources && storedTrusted !== facts.sessionTrusted, + }; +} diff --git a/web/runtime/types.ts b/web/runtime/types.ts index 2b66c3f0..bdb8390a 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; @@ -53,6 +54,7 @@ export interface WebRuntimeController { readonly workspaceSelected: boolean; readonly sessionDirectory: string; readonly sessionManager: SessionManager; + getProjectTrustStatus?(): WebProjectTrustStatus; isIdle(): boolean; sendPrompt(content: string, options?: WebPromptOptions): Promise; newSession( From 1185e45b66c8ec561933af2ba7dee1aec40ea415 Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:04:20 +0800 Subject: [PATCH 2/4] test: cover runtime project trust store integration --- tests/web/trust-status.test.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/web/trust-status.test.ts b/tests/web/trust-status.test.ts index d630aef4..be1e291c 100644 --- a/tests/web/trust-status.test.ts +++ b/tests/web/trust-status.test.ts @@ -1,5 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +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", () => { @@ -90,3 +95,29 @@ test("TrustStore changes do not pretend to mutate active Session authority", () 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 PiWebRuntime & { + hasSelectedWorkspace: boolean; + runtime: { cwd: string; session: { settingsManager: { isProjectTrusted(): boolean } } }; + }; + 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; + } +}); From b5dda00bf17a22fa6c5235569784a55686e0c46e Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:00:27 +0800 Subject: [PATCH 3/4] test(web): type trust store integration fixture --- tests/web/trust-status.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/web/trust-status.test.ts b/tests/web/trust-status.test.ts index be1e291c..8ecfa5f2 100644 --- a/tests/web/trust-status.test.ts +++ b/tests/web/trust-status.test.ts @@ -106,9 +106,10 @@ test("PiWebRuntime reads the real ProjectTrustStore decision", async () => { const previous = process.env.PI_CODING_AGENT_DIR; process.env.PI_CODING_AGENT_DIR = agentDir; try { - const runtime = Object.create(PiWebRuntime.prototype) as PiWebRuntime & { + 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 } } }; From 5502b05fa3384f70dd537d2166b988f7f55c32ac Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:01:54 +0800 Subject: [PATCH 4/4] style(web): format trust status integration test --- tests/web/trust-status.test.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/web/trust-status.test.ts b/tests/web/trust-status.test.ts index 8ecfa5f2..3a77100f 100644 --- a/tests/web/trust-status.test.ts +++ b/tests/web/trust-status.test.ts @@ -96,7 +96,6 @@ test("TrustStore changes do not pretend to mutate active Session authority", () 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-")); @@ -108,14 +107,25 @@ test("PiWebRuntime reads the real ProjectTrustStore decision", async () => { try { const runtime = Object.create(PiWebRuntime.prototype) as { hasSelectedWorkspace: boolean; - runtime: { cwd: string; session: { settingsManager: { isProjectTrusted(): boolean } } }; + runtime: { + cwd: string; + session: { settingsManager: { isProjectTrusted(): boolean } }; + }; getProjectTrustStatus: PiWebRuntime["getProjectTrustStatus"]; }; runtime.hasSelectedWorkspace = true; - runtime.runtime = { cwd: workspace, session: { settingsManager: { isProjectTrusted: () => 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, + 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;