diff --git a/tests/web/trust-status.test.ts b/tests/web/trust-status.test.ts new file mode 100644 index 00000000..3a77100f --- /dev/null +++ b/tests/web/trust-status.test.ts @@ -0,0 +1,134 @@ +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", () => { + 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; + } +}); diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index febbd3f9..23244aec 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", @@ -297,6 +306,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 805d03c6..d27abd1f 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -686,6 +686,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/runtime/pi-runtime.ts b/web/runtime/pi-runtime.ts index 55a8ebc4..5571e125 100644 --- a/web/runtime/pi-runtime.ts +++ b/web/runtime/pi-runtime.ts @@ -37,6 +37,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 TURN_CANCELLATION_SETTLEMENT_TIMEOUT_MS = 10_000; @@ -191,6 +195,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 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(