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(