Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions tests/web/trust-status.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
22 changes: 22 additions & 0 deletions tests/web/web-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions web/host/web-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
21 changes: 21 additions & 0 deletions web/runtime/pi-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down
74 changes: 74 additions & 0 deletions web/runtime/trust-status.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
2 changes: 2 additions & 0 deletions web/runtime/types.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<void>;
newSession(
Expand Down
Loading