Archived
fix: harden machine federation boundaries
This commit is contained in:
@@ -0,0 +1,109 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
|
||||||
|
import { terminalsApi } from "./clients";
|
||||||
|
|
||||||
|
const workspace: Workspace = {
|
||||||
|
id: "w/1",
|
||||||
|
projectId: "p 1",
|
||||||
|
path: "/repo",
|
||||||
|
label: "repo",
|
||||||
|
isMain: true,
|
||||||
|
isGitRepo: true,
|
||||||
|
isGitWorktree: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const commandRun: TerminalCommandRun = {
|
||||||
|
id: "run1",
|
||||||
|
origin: "core",
|
||||||
|
projectId: workspace.projectId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
terminalId: "t1",
|
||||||
|
title: "Build",
|
||||||
|
command: "npm test",
|
||||||
|
status: "running",
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
metadata: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("machine-scoped terminal command-run API", () => {
|
||||||
|
it("creates command runs through the selected machine scope", async () => {
|
||||||
|
const fetchMock = stubJsonFetch(commandRun);
|
||||||
|
|
||||||
|
await terminalsApi.runTerminalCommand("core", { workspace, title: "Build", command: "npm test", open: true }, "remote a");
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
const [url, init] = fetchCall(fetchMock, 0);
|
||||||
|
expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminal-command-runs");
|
||||||
|
expect(init?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(requestBody(init))).toEqual({ origin: "core", title: "Build", command: "npm test", metadata: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists, reads, and cancels command runs through the selected machine scope", async () => {
|
||||||
|
const fetchMock = stubSequenceFetch([
|
||||||
|
jsonResponse([commandRun]),
|
||||||
|
jsonResponse(commandRun),
|
||||||
|
jsonResponse(commandRun),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await terminalsApi.listCommandRuns({ projectId: "p 1", workspaceId: "w/1", statuses: ["running"], metadata: { "pi.operation": "workspace.delete" } }, "remote a");
|
||||||
|
await terminalsApi.getCommandRun("run 1", "remote a");
|
||||||
|
await terminalsApi.cancelCommandRun("run 1", "remote a");
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||||
|
"/api/machines/remote%20a/terminal-command-runs?projectId=p+1&workspaceId=w%2F1&statuses=running&metadata=%7B%22pi.operation%22%3A%22workspace.delete%22%7D",
|
||||||
|
"/api/machines/remote%20a/terminal-command-runs/run%201",
|
||||||
|
"/api/machines/remote%20a/terminal-command-runs/run%201/cancel",
|
||||||
|
]);
|
||||||
|
expect(fetchCall(fetchMock, 2)[1]?.method).toBe("POST");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for missing command runs in the selected machine scope", async () => {
|
||||||
|
const fetchMock = stubResponseFetch(new Response("{}", { status: 404 }));
|
||||||
|
|
||||||
|
await expect(terminalsApi.getCommandRun("missing", "remote-a")).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote-a/terminal-command-runs/missing");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||||
|
type FetchMock = ReturnType<typeof vi.fn<FetchLike>>;
|
||||||
|
|
||||||
|
function stubJsonFetch(value: unknown): FetchMock {
|
||||||
|
return stubResponseFetch(jsonResponse(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubSequenceFetch(responses: Response[]): FetchMock {
|
||||||
|
const fetchMock = vi.fn<FetchLike>(() => {
|
||||||
|
const response = responses.shift();
|
||||||
|
if (response === undefined) throw new Error("No fetch response queued");
|
||||||
|
return Promise.resolve(response);
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
return fetchMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubResponseFetch(response: Response): FetchMock {
|
||||||
|
const fetchMock = vi.fn<FetchLike>(() => Promise.resolve(response));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
return fetchMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchCall(fetchMock: FetchMock, index: number): Parameters<FetchLike> {
|
||||||
|
const call = fetchMock.mock.calls[index];
|
||||||
|
if (call === undefined) throw new Error(`Missing fetch call ${String(index)}`);
|
||||||
|
return call;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBody(init: RequestInit | undefined): string {
|
||||||
|
if (typeof init?.body !== "string") throw new Error("Expected string request body");
|
||||||
|
return init.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(value: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
|
||||||
|
}
|
||||||
@@ -107,14 +107,14 @@ export const terminalsApi = {
|
|||||||
startTerminal: (projectId: string, workspaceId: string, options?: { name?: string; cols?: number; rows?: number }, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, parseTerminalInfo, { method: "POST", body: JSON.stringify(options ?? {}) }),
|
startTerminal: (projectId: string, workspaceId: string, options?: { name?: string; cols?: number; rows?: number }, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, parseTerminalInfo, { method: "POST", body: JSON.stringify(options ?? {}) }),
|
||||||
closeTerminal: (projectId: string, workspaceId: string, terminalId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}`, parseClosed, { method: "DELETE" }),
|
closeTerminal: (projectId: string, workspaceId: string, terminalId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}`, parseClosed, { method: "DELETE" }),
|
||||||
continueTerminal: (projectId: string, workspaceId: string, terminalId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/continue`, parseTerminalInfo, { method: "POST" }),
|
continueTerminal: (projectId: string, workspaceId: string, terminalId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/continue`, parseTerminalInfo, { method: "POST" }),
|
||||||
runTerminalCommand: (origin: string, input: RunTerminalCommandInput) => request(`/api/projects/${encodeURIComponent(input.workspace.projectId)}/workspaces/${encodeURIComponent(input.workspace.id)}/terminal-command-runs`, parseTerminalCommandRun, { method: "POST", body: JSON.stringify({ origin, title: input.title, command: input.command, metadata: input.metadata ?? {} }) }),
|
runTerminalCommand: (origin: string, input: RunTerminalCommandInput, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(input.workspace.projectId)}/workspaces/${encodeURIComponent(input.workspace.id)}/terminal-command-runs`, parseTerminalCommandRun, { method: "POST", body: JSON.stringify({ origin, title: input.title, command: input.command, metadata: input.metadata ?? {} }) }),
|
||||||
listCommandRuns: (filter?: TerminalCommandRunFilter) => request(`/api/terminal-command-runs${terminalCommandRunFilterQuery(filter)}`, arrayOf(parseTerminalCommandRun)),
|
listCommandRuns: (filter?: TerminalCommandRunFilter, machineId = "local") => request(`${machinePrefix(machineId)}/terminal-command-runs${terminalCommandRunFilterQuery(filter)}`, arrayOf(parseTerminalCommandRun)),
|
||||||
getCommandRun: (runId: string) => getOptionalTerminalCommandRun(runId),
|
getCommandRun: (runId: string, machineId = "local") => getOptionalTerminalCommandRun(runId, machineId),
|
||||||
cancelCommandRun: (runId: string) => request(`/api/terminal-command-runs/${encodeURIComponent(runId)}/cancel`, parseTerminalCommandRun, { method: "POST" }),
|
cancelCommandRun: (runId: string, machineId = "local") => request(`${machinePrefix(machineId)}/terminal-command-runs/${encodeURIComponent(runId)}/cancel`, parseTerminalCommandRun, { method: "POST" }),
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getOptionalTerminalCommandRun(runId: string): Promise<TerminalCommandRun | undefined> {
|
async function getOptionalTerminalCommandRun(runId: string, machineId: string): Promise<TerminalCommandRun | undefined> {
|
||||||
const response = await fetch(`/api/terminal-command-runs/${encodeURIComponent(runId)}`);
|
const response = await fetch(`${machinePrefix(machineId)}/terminal-command-runs/${encodeURIComponent(runId)}`);
|
||||||
if (response.status === 404) return undefined;
|
if (response.status === 404) return undefined;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body: unknown = await response.json().catch((): unknown => ({}));
|
const body: unknown = await response.json().catch((): unknown => ({}));
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Workspace } from "../../../shared/apiTypes";
|
||||||
|
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes";
|
||||||
|
import { activityApi, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||||
|
import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
|
||||||
|
import { workspaceImagePreviewUrl } from "./urls";
|
||||||
|
|
||||||
|
const machineId = "remote-a";
|
||||||
|
const workspace: Workspace = {
|
||||||
|
id: "w 1",
|
||||||
|
projectId: "p 1",
|
||||||
|
path: "/repo",
|
||||||
|
label: "repo",
|
||||||
|
isMain: true,
|
||||||
|
isGitRepo: true,
|
||||||
|
isGitWorktree: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("federated route contract", () => {
|
||||||
|
it("covers machine-scoped client HTTP calls with remote proxy routes", async () => {
|
||||||
|
const fetchMock = vi.fn<FetchLike>(() => Promise.resolve(jsonResponse({})));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
ignoreParseFailure(activityApi.workspaceActivity(machineId)),
|
||||||
|
ignoreParseFailure(projectsApi.projects(machineId)),
|
||||||
|
ignoreParseFailure(projectsApi.addProject("/repo", "Repo", false, machineId)),
|
||||||
|
ignoreParseFailure(projectsApi.closeProject("p 1", machineId)),
|
||||||
|
ignoreParseFailure(projectsApi.projectDirectories("/r", machineId)),
|
||||||
|
ignoreParseFailure(workspacesApi.workspaces("p 1", machineId)),
|
||||||
|
ignoreParseFailure(workspacesApi.workspaceTree("p 1", "w 1", "src", machineId)),
|
||||||
|
ignoreParseFailure(workspacesApi.workspaceFile("p 1", "w 1", "README.md", machineId)),
|
||||||
|
ignoreParseFailure(filesApi.files("/repo", "README", "tracked", "file", machineId)),
|
||||||
|
ignoreParseFailure(gitApi.gitStatus("p 1", "w 1", machineId)),
|
||||||
|
ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.sessions("/repo", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.startSession("/repo", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.messages("s 1", { limit: 20, before: 10 }, machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.status("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.models("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.setModel("s 1", "openai", "gpt", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.cycleModel("s 1", "forward", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.thinkingLevels("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.setThinkingLevel("s 1", "medium", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.cycleThinkingLevel("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.commands("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.prompt("s 1", "hello", "followUp", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.shell("s 1", "ls", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.runCommand("s 1", "/help", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.respondToCommand("s 1", "req 1", "yes", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.abort("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.stop("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.archive("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.archiveWithDescendants("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.restore("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.detachParent("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
|
||||||
|
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.logoutProvider("openai", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.startOAuthLogin("openai", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.oauthFlow("flow 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.respondOAuthFlow("flow 1", "req 1", "code", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.cancelOAuthFlow("flow 1", machineId)),
|
||||||
|
ignoreParseFailure(terminalsApi.terminals("p 1", "w 1", machineId)),
|
||||||
|
ignoreParseFailure(terminalsApi.startTerminal("p 1", "w 1", { cols: 120, rows: 40 }, machineId)),
|
||||||
|
ignoreParseFailure(terminalsApi.closeTerminal("p 1", "w 1", "t 1", machineId)),
|
||||||
|
ignoreParseFailure(terminalsApi.continueTerminal("p 1", "w 1", "t 1", machineId)),
|
||||||
|
ignoreParseFailure(terminalsApi.runTerminalCommand("core", { workspace, title: "Build", command: "npm test" }, machineId)),
|
||||||
|
ignoreParseFailure(terminalsApi.listCommandRuns({ projectId: "p 1", workspaceId: "w 1", statuses: ["running"], metadata: { "pi.operation": "test" } }, machineId)),
|
||||||
|
ignoreParseFailure(terminalsApi.getCommandRun("run 1", machineId)),
|
||||||
|
ignoreParseFailure(terminalsApi.cancelCommandRun("run 1", machineId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const observedRoutes = uniqueHttpRoutes([
|
||||||
|
...fetchMock.mock.calls.map((call) => fetchCallToRoute(call, machineId)),
|
||||||
|
routeFromMachineUrl("GET", workspaceImagePreviewUrl("p 1", "w 1", "diagram.svg", { machineId, modifiedAt: "2026-05-25T00:00:00.000Z" }), machineId),
|
||||||
|
]);
|
||||||
|
const unmatched = observedRoutes.filter((route) => !matchesHttpRoute(route, FEDERATED_HTTP_ROUTES));
|
||||||
|
|
||||||
|
expect(unmatched).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers machine-scoped client WebSocket calls with remote proxy routes", () => {
|
||||||
|
const webSocketUrls: string[] = [];
|
||||||
|
function FakeWebSocket(url: string): void {
|
||||||
|
webSocketUrls.push(url);
|
||||||
|
}
|
||||||
|
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||||
|
vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" });
|
||||||
|
|
||||||
|
sessionEvents("s 1", machineId);
|
||||||
|
globalSessionEvents(machineId);
|
||||||
|
realtimeEvents(machineId);
|
||||||
|
terminalSocket("p 1", "w 1", "t 1", { cols: 120, rows: 40 }, machineId);
|
||||||
|
|
||||||
|
const observedPaths = uniqueStrings(webSocketUrls.map((url) => routeFromMachineUrl("GET", url, machineId).path));
|
||||||
|
const unmatched = observedPaths.filter((path) => !FEDERATED_WEBSOCKET_ROUTES.some((route) => pathMatchesPattern(path, route)));
|
||||||
|
|
||||||
|
expect(unmatched).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
interface ObservedHttpRoute {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ignoreParseFailure(promise: Promise<unknown>): Promise<void> {
|
||||||
|
await promise.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchCallToRoute(call: Parameters<FetchLike>, scopedMachineId: string): ObservedHttpRoute {
|
||||||
|
const [url, init] = call;
|
||||||
|
return routeFromMachineUrl((init?.method ?? "GET").toUpperCase(), url, scopedMachineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeFromMachineUrl(method: string, input: string | URL | Request, scopedMachineId: string): ObservedHttpRoute {
|
||||||
|
const url = toUrl(input);
|
||||||
|
const prefix = `/api/machines/${encodeURIComponent(scopedMachineId)}`;
|
||||||
|
if (!url.pathname.startsWith(prefix)) throw new Error(`Expected machine-scoped URL, got ${url.pathname}`);
|
||||||
|
return { method, path: url.pathname.slice(prefix.length) || "/" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUrl(input: string | URL | Request): URL {
|
||||||
|
if (input instanceof URL) return input;
|
||||||
|
if (input instanceof Request) return new URL(input.url);
|
||||||
|
return new URL(input, "https://pi.example.test");
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesHttpRoute(route: ObservedHttpRoute, specs: readonly FederatedHttpRouteSpec[]): boolean {
|
||||||
|
return specs.some((spec) => spec.method === route.method && pathMatchesPattern(route.path, spec.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathMatchesPattern(path: string, pattern: string): boolean {
|
||||||
|
const pathSegments = path.split("/").filter((segment) => segment !== "");
|
||||||
|
const patternSegments = pattern.split("/").filter((segment) => segment !== "");
|
||||||
|
return pathSegments.length === patternSegments.length
|
||||||
|
&& patternSegments.every((segment, index) => segment.startsWith(":") || segment === pathSegments[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueHttpRoutes(routes: ObservedHttpRoute[]): ObservedHttpRoute[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return routes.filter((route) => {
|
||||||
|
const key = `${route.method} ${route.path}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueStrings(values: string[]): string[] {
|
||||||
|
return [...new Set(values)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(value: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
|
||||||
|
}
|
||||||
@@ -430,18 +430,36 @@ export class PiWebApp extends LitElement {
|
|||||||
this.openWorkspaceTool("core:workspace.terminal");
|
this.openWorkspaceTool("core:workspace.terminal");
|
||||||
}
|
}
|
||||||
|
|
||||||
private terminalCommandRunsForOrigin(origin: string): TerminalCommandRunsInternalRuntime {
|
private terminalCommandRunsForOrigin(origin: string, machineId = selectedMachineId(this.state)): TerminalCommandRunsInternalRuntime {
|
||||||
const existing = this.terminalCommandRunRuntimes.get(origin);
|
const key = machineScopedKey(machineId, origin);
|
||||||
|
const existing = this.terminalCommandRunRuntimes.get(key);
|
||||||
if (existing !== undefined) return existing;
|
if (existing !== undefined) return existing;
|
||||||
const runtime = createTerminalCommandRunsRuntime(origin, {
|
const runtime = createTerminalCommandRunsRuntime(origin, {
|
||||||
openTerminal: (workspace, options) => { void this.openRuntimeTerminal(workspace, options); },
|
api: {
|
||||||
|
runTerminalCommand: (runtimeOrigin, input) => terminalsApi.runTerminalCommand(runtimeOrigin, input, machineId),
|
||||||
|
listCommandRuns: (filter) => terminalsApi.listCommandRuns(filter, machineId),
|
||||||
|
getCommandRun: (runId) => terminalsApi.getCommandRun(runId, machineId),
|
||||||
|
},
|
||||||
|
openTerminal: (workspace, options) => { void this.openRuntimeTerminal(machineId, workspace, options); },
|
||||||
});
|
});
|
||||||
this.terminalCommandRunRuntimes.set(origin, runtime);
|
this.terminalCommandRunRuntimes.set(key, runtime);
|
||||||
return runtime;
|
return runtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async openRuntimeTerminal(workspace: Workspace | undefined, options?: { terminalId?: string | undefined }): Promise<void> {
|
private async openRuntimeTerminal(machineId: string, workspace: Workspace | undefined, options?: { terminalId?: string | undefined }): Promise<void> {
|
||||||
if (workspace !== undefined && this.state.selectedWorkspace?.id !== workspace.id) await this.workspaces.selectWorkspace(workspace);
|
if (selectedMachineId(this.state) !== machineId) {
|
||||||
|
const machine = this.state.machines.find((candidate) => candidate.id === machineId);
|
||||||
|
if (machine === undefined) {
|
||||||
|
this.setState({ error: "Machine not found for terminal command run" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.machines.selectMachine(machine);
|
||||||
|
}
|
||||||
|
if (workspace !== undefined && (this.state.selectedWorkspace?.id !== workspace.id || this.state.selectedProject?.id !== workspace.projectId)) {
|
||||||
|
const project = this.state.projects.find((candidate) => candidate.id === workspace.projectId);
|
||||||
|
if (project !== undefined && this.state.selectedProject?.id !== project.id) await this.workspaces.selectProject(project, { workspaceId: workspace.id });
|
||||||
|
else await this.workspaces.selectWorkspace(workspace);
|
||||||
|
}
|
||||||
this.openTerminal(options);
|
this.openTerminal(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,9 +543,10 @@ export class PiWebApp extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async refreshActiveTerminals(workspace: Workspace): Promise<void> {
|
private async refreshActiveTerminals(workspace: Workspace): Promise<void> {
|
||||||
|
const machineId = selectedMachineId(this.state);
|
||||||
try {
|
try {
|
||||||
const terminals = await terminalsApi.terminals(workspace.projectId, workspace.id, selectedMachineId(this.state));
|
const terminals = await terminalsApi.terminals(workspace.projectId, workspace.id, machineId);
|
||||||
if (this.state.selectedWorkspace?.id !== workspace.id) return;
|
if (selectedMachineId(this.state) !== machineId || this.state.selectedWorkspace?.id !== workspace.id) return;
|
||||||
this.activeTerminalIds.clear();
|
this.activeTerminalIds.clear();
|
||||||
for (const terminal of terminals) {
|
for (const terminal of terminals) {
|
||||||
if (!terminal.exited) this.activeTerminalIds.add(terminal.id);
|
if (!terminal.exited) this.activeTerminalIds.add(terminal.id);
|
||||||
@@ -824,25 +843,27 @@ export class PiWebApp extends LitElement {
|
|||||||
const confirmed = confirm(`Delete workspace ${label}?\n\nThis will run git worktree remove and delete:\n${workspace.path}\n\nThe Git branch will not be deleted.`);
|
const confirmed = confirm(`Delete workspace ${label}?\n\nThis will run git worktree remove and delete:\n${workspace.path}\n\nThe Git branch will not be deleted.`);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
const machineId = selectedMachineId(this.state);
|
||||||
try {
|
try {
|
||||||
const mainWorkspace = await this.mainWorkspaceForProject(workspace.projectId);
|
const mainWorkspace = await this.mainWorkspaceForProject(workspace.projectId);
|
||||||
if (mainWorkspace === undefined) {
|
if (mainWorkspace === undefined) {
|
||||||
this.setState({ error: "Project main workspace not found" });
|
this.setState({ error: "Project main workspace not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const handle = await this.terminalCommandRunsForOrigin("core").runCommand({
|
if (selectedMachineId(this.state) !== machineId) return;
|
||||||
|
const handle = await this.terminalCommandRunsForOrigin("core", machineId).runCommand({
|
||||||
workspace: mainWorkspace,
|
workspace: mainWorkspace,
|
||||||
title: `Delete workspace: ${label}`,
|
title: `Delete workspace: ${label}`,
|
||||||
command: `git worktree remove ${shellQuote(workspace.path)}`,
|
command: `git worktree remove ${shellQuote(workspace.path)}`,
|
||||||
open: true,
|
open: true,
|
||||||
metadata: workspaceDeletionMetadata(workspace),
|
metadata: workspaceDeletionMetadata(workspace),
|
||||||
});
|
});
|
||||||
this.recordWorkspaceDeletionRun(handle.run);
|
this.recordWorkspaceDeletionRun(handle.run, machineId);
|
||||||
void handle.completed.then((run) => this.handleCompletedWorkspaceDeletionRun(run)).catch((error: unknown) => {
|
void handle.completed.then((run) => this.handleCompletedWorkspaceDeletionRun(run, machineId)).catch((error: unknown) => {
|
||||||
this.setState({ error: `Workspace deletion failed. See terminal output. ${errorMessage(error)}` });
|
if (selectedMachineId(this.state) === machineId) this.setState({ error: `Workspace deletion failed. See terminal output. ${errorMessage(error)}` });
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: `Failed to start workspace deletion: ${errorMessage(error)}` });
|
if (selectedMachineId(this.state) === machineId) this.setState({ error: `Failed to start workspace deletion: ${errorMessage(error)}` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,7 +873,8 @@ export class PiWebApp extends LitElement {
|
|||||||
return workspaces.find((workspace) => workspace.isMain) ?? workspaces[0];
|
return workspaces.find((workspace) => workspace.isMain) ?? workspaces[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
private recordWorkspaceDeletionRun(run: TerminalCommandRun): void {
|
private recordWorkspaceDeletionRun(run: TerminalCommandRun, machineId: string): void {
|
||||||
|
if (selectedMachineId(this.state) !== machineId) return;
|
||||||
const workspaceId = targetWorkspaceIdForRun(run);
|
const workspaceId = targetWorkspaceIdForRun(run);
|
||||||
if (workspaceId === undefined) return;
|
if (workspaceId === undefined) return;
|
||||||
this.setState({ workspaceDeletionRuns: { ...this.state.workspaceDeletionRuns, [workspaceId]: run } });
|
this.setState({ workspaceDeletionRuns: { ...this.state.workspaceDeletionRuns, [workspaceId]: run } });
|
||||||
@@ -861,6 +883,7 @@ export class PiWebApp extends LitElement {
|
|||||||
|
|
||||||
private async refreshWorkspaceDeletionRuns(): Promise<void> {
|
private async refreshWorkspaceDeletionRuns(): Promise<void> {
|
||||||
if (this.refreshingWorkspaceDeletionRuns) return;
|
if (this.refreshingWorkspaceDeletionRuns) return;
|
||||||
|
const machineId = selectedMachineId(this.state);
|
||||||
const project = this.state.selectedProject;
|
const project = this.state.selectedProject;
|
||||||
if (project === undefined) {
|
if (project === undefined) {
|
||||||
this.setState({ workspaceDeletionRuns: {} });
|
this.setState({ workspaceDeletionRuns: {} });
|
||||||
@@ -870,11 +893,12 @@ export class PiWebApp extends LitElement {
|
|||||||
|
|
||||||
this.refreshingWorkspaceDeletionRuns = true;
|
this.refreshingWorkspaceDeletionRuns = true;
|
||||||
try {
|
try {
|
||||||
const runs = await this.terminalCommandRunsForOrigin("core").listCommandRuns(workspaceDeletionRunFilter(project.id));
|
const runs = await this.terminalCommandRunsForOrigin("core", machineId).listCommandRuns(workspaceDeletionRunFilter(project.id));
|
||||||
|
if (selectedMachineId(this.state) !== machineId) return;
|
||||||
const latestRuns = latestWorkspaceDeletionRuns(runs);
|
const latestRuns = latestWorkspaceDeletionRuns(runs);
|
||||||
this.setState({ workspaceDeletionRuns: latestRuns });
|
this.setState({ workspaceDeletionRuns: latestRuns });
|
||||||
for (const run of Object.values(latestRuns)) {
|
for (const run of Object.values(latestRuns)) {
|
||||||
if (!isWorkspaceDeletionRunPending(run)) await this.handleCompletedWorkspaceDeletionRun(run);
|
if (!isWorkspaceDeletionRunPending(run)) await this.handleCompletedWorkspaceDeletionRun(run, machineId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to refresh workspace deletion runs", error);
|
console.warn("Failed to refresh workspace deletion runs", error);
|
||||||
@@ -896,14 +920,17 @@ export class PiWebApp extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleCompletedWorkspaceDeletionRun(run: TerminalCommandRun): Promise<void> {
|
private async handleCompletedWorkspaceDeletionRun(run: TerminalCommandRun, machineId = selectedMachineId(this.state)): Promise<void> {
|
||||||
if (this.handledWorkspaceDeletionRunIds.has(run.id)) return;
|
if (selectedMachineId(this.state) !== machineId) return;
|
||||||
|
const runKey = machineScopedKey(machineId, run.id);
|
||||||
|
if (this.handledWorkspaceDeletionRunIds.has(runKey)) return;
|
||||||
const workspaceId = targetWorkspaceIdForRun(run);
|
const workspaceId = targetWorkspaceIdForRun(run);
|
||||||
if (workspaceId === undefined) return;
|
if (workspaceId === undefined) return;
|
||||||
this.handledWorkspaceDeletionRunIds.add(run.id);
|
this.handledWorkspaceDeletionRunIds.add(runKey);
|
||||||
|
|
||||||
if (run.status === "succeeded") {
|
if (run.status === "succeeded") {
|
||||||
await this.workspaces.refreshAfterWorkspaceDeleted(run.projectId, workspaceId);
|
await this.workspaces.refreshAfterWorkspaceDeleted(run.projectId, workspaceId);
|
||||||
|
if (selectedMachineId(this.state) !== machineId) return;
|
||||||
this.setState({ workspaceDeletionRuns: omitWorkspaceDeletionRun(this.state.workspaceDeletionRuns, workspaceId) });
|
this.setState({ workspaceDeletionRuns: omitWorkspaceDeletionRun(this.state.workspaceDeletionRuns, workspaceId) });
|
||||||
this.updateWorkspaceDeletionPolling();
|
this.updateWorkspaceDeletionPolling();
|
||||||
return;
|
return;
|
||||||
@@ -1383,6 +1410,10 @@ function isTerminalEvent(event: RealtimeEvent): event is TerminalUiEvent {
|
|||||||
return event.type === "terminal.created" || event.type === "terminal.exited" || event.type === "terminal.closed";
|
return event.type === "terminal.created" || event.type === "terminal.exited" || event.type === "terminal.closed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function machineScopedKey(machineId: string, value: string): string {
|
||||||
|
return JSON.stringify([machineId, value]);
|
||||||
|
}
|
||||||
|
|
||||||
function shellQuote(value: string): string {
|
function shellQuote(value: string): string {
|
||||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export class TerminalPanel extends LitElement {
|
|||||||
private intersectionObserver: IntersectionObserver | undefined;
|
private intersectionObserver: IntersectionObserver | undefined;
|
||||||
private themeObserver: MutationObserver | undefined;
|
private themeObserver: MutationObserver | undefined;
|
||||||
private suppressTerminalInput = false;
|
private suppressTerminalInput = false;
|
||||||
private observedCwd: string | undefined;
|
private observedWorkspaceScope: string | undefined;
|
||||||
private loadedCwd: string | undefined;
|
private loadedCwd: string | undefined;
|
||||||
private autoStartConsumedCwd: string | undefined;
|
private autoStartConsumedCwd: string | undefined;
|
||||||
private commandRunPollTimer: number | undefined;
|
private commandRunPollTimer: number | undefined;
|
||||||
@@ -69,9 +69,9 @@ export class TerminalPanel extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override willUpdate(changed: PropertyValues<this>): void {
|
override willUpdate(changed: PropertyValues<this>): void {
|
||||||
const cwd = this.workspace?.path;
|
const workspaceScope = this.workspace === undefined ? undefined : JSON.stringify([this.machineId, this.workspace.path]);
|
||||||
if (cwd !== this.observedCwd) {
|
if (workspaceScope !== this.observedWorkspaceScope) {
|
||||||
this.observedCwd = cwd;
|
this.observedWorkspaceScope = workspaceScope;
|
||||||
this.loadedCwd = undefined;
|
this.loadedCwd = undefined;
|
||||||
this.autoStartConsumedCwd = undefined;
|
this.autoStartConsumedCwd = undefined;
|
||||||
this.terminals = [];
|
this.terminals = [];
|
||||||
@@ -118,7 +118,7 @@ export class TerminalPanel extends LitElement {
|
|||||||
const shouldAutoStart = this.consumeAutoStart();
|
const shouldAutoStart = this.consumeAutoStart();
|
||||||
const [terminals, commandRuns] = await Promise.all([
|
const [terminals, commandRuns] = await Promise.all([
|
||||||
terminalsApi.terminals(workspace.projectId, workspace.id, this.machineId),
|
terminalsApi.terminals(workspace.projectId, workspace.id, this.machineId),
|
||||||
terminalsApi.listCommandRuns({ projectId: workspace.projectId, workspaceId: workspace.id }),
|
terminalsApi.listCommandRuns({ projectId: workspace.projectId, workspaceId: workspace.id }, this.machineId),
|
||||||
]);
|
]);
|
||||||
this.terminals = terminals;
|
this.terminals = terminals;
|
||||||
this.commandRuns = commandRuns;
|
this.commandRuns = commandRuns;
|
||||||
@@ -218,7 +218,7 @@ export class TerminalPanel extends LitElement {
|
|||||||
const workspace = this.workspace;
|
const workspace = this.workspace;
|
||||||
if (workspace === undefined) return;
|
if (workspace === undefined) return;
|
||||||
try {
|
try {
|
||||||
const commandRuns = await terminalsApi.listCommandRuns({ projectId: workspace.projectId, workspaceId: workspace.id });
|
const commandRuns = await terminalsApi.listCommandRuns({ projectId: workspace.projectId, workspaceId: workspace.id }, this.machineId);
|
||||||
this.commandRuns = commandRuns;
|
this.commandRuns = commandRuns;
|
||||||
this.cancellingRunIds = this.cancellingRunIds.filter((runId) => commandRuns.some((run) => run.id === runId && isCommandRunPending(run)));
|
this.cancellingRunIds = this.cancellingRunIds.filter((runId) => commandRuns.some((run) => run.id === runId && isCommandRunPending(run)));
|
||||||
this.updateCommandRunPolling(this.hasPendingCommandRuns(commandRuns));
|
this.updateCommandRunPolling(this.hasPendingCommandRuns(commandRuns));
|
||||||
@@ -247,7 +247,7 @@ export class TerminalPanel extends LitElement {
|
|||||||
this.error = undefined;
|
this.error = undefined;
|
||||||
this.cancellingRunIds = [...this.cancellingRunIds, run.id];
|
this.cancellingRunIds = [...this.cancellingRunIds, run.id];
|
||||||
try {
|
try {
|
||||||
await terminalsApi.cancelCommandRun(run.id);
|
await terminalsApi.cancelCommandRun(run.id, this.machineId);
|
||||||
await this.loadCommandRuns();
|
await this.loadCommandRuns();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.error = error instanceof Error ? error.message : String(error);
|
this.error = error instanceof Error ? error.message : String(error);
|
||||||
@@ -261,7 +261,7 @@ export class TerminalPanel extends LitElement {
|
|||||||
this.error = undefined;
|
this.error = undefined;
|
||||||
this.continuingTerminalIds = [...this.continuingTerminalIds, id];
|
this.continuingTerminalIds = [...this.continuingTerminalIds, id];
|
||||||
try {
|
try {
|
||||||
const terminal = await terminalsApi.continueTerminal(this.workspace.projectId, this.workspace.id, id);
|
const terminal = await terminalsApi.continueTerminal(this.workspace.projectId, this.workspace.id, id, this.machineId);
|
||||||
this.terminals = this.terminals.map((item) => item.id === id ? terminal : item);
|
this.terminals = this.terminals.map((item) => item.id === id ? terminal : item);
|
||||||
if (this.socket === undefined) this.disposeTerminalView();
|
if (this.socket === undefined) this.disposeTerminalView();
|
||||||
this.fitAndNotify();
|
this.fitAndNotify();
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export class MachineController {
|
|||||||
sessionActivities: {},
|
sessionActivities: {},
|
||||||
workspaceActivities: {},
|
workspaceActivities: {},
|
||||||
workspacesByProjectId: {},
|
workspacesByProjectId: {},
|
||||||
|
workspaceDeletionRuns: {},
|
||||||
|
activeTerminalCount: 0,
|
||||||
...resetWorkspaceScopedState(),
|
...resetWorkspaceScopedState(),
|
||||||
});
|
});
|
||||||
if (options.updateUrl !== false) this.updateUrl();
|
if (options.updateUrl !== false) this.updateUrl();
|
||||||
|
|||||||
+125
-4
@@ -11,6 +11,7 @@ import { RemoteMachineRequestError, type MachineClient } from "./machines/machin
|
|||||||
import { MachineService } from "./machines/machineService.js";
|
import { MachineService } from "./machines/machineService.js";
|
||||||
import { MachineStore } from "./machines/machineStore.js";
|
import { MachineStore } from "./machines/machineStore.js";
|
||||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||||
|
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
|
||||||
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
|
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
|
||||||
import type { Project, Workspace } from "./types.js";
|
import type { Project, Workspace } from "./types.js";
|
||||||
|
|
||||||
@@ -18,11 +19,13 @@ let app: FastifyInstance;
|
|||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
let projectDir: string;
|
let projectDir: string;
|
||||||
let remoteClient: MachineClient | undefined;
|
let remoteClient: MachineClient | undefined;
|
||||||
|
let sessionDaemonRequests: CapturedSessionDaemonRequest[];
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
tempDir = await realpath(await mkdtemp(join(tmpdir(), "pi-web-app-test-")));
|
tempDir = await realpath(await mkdtemp(join(tmpdir(), "pi-web-app-test-")));
|
||||||
projectDir = join(tempDir, "project");
|
projectDir = join(tempDir, "project");
|
||||||
remoteClient = undefined;
|
remoteClient = undefined;
|
||||||
|
sessionDaemonRequests = [];
|
||||||
app = await buildApp({
|
app = await buildApp({
|
||||||
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
|
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
|
||||||
workspaces: new WorkspaceService(),
|
workspaces: new WorkspaceService(),
|
||||||
@@ -44,6 +47,7 @@ beforeEach(async () => {
|
|||||||
messages: [],
|
messages: [],
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
sessionDaemon: fakeSessionDaemon(),
|
||||||
piWebPlugins: {
|
piWebPlugins: {
|
||||||
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] }),
|
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] }),
|
||||||
readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined),
|
readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined),
|
||||||
@@ -121,6 +125,57 @@ describe("buildApp", () => {
|
|||||||
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
|
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves remote file preview security headers while proxying safe response metadata", async () => {
|
||||||
|
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||||
|
const remote = addResponse.json<{ id: string }>();
|
||||||
|
const request = vi.fn(() => Promise.resolve({
|
||||||
|
statusCode: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "image/svg+xml",
|
||||||
|
"content-security-policy": "sandbox; default-src 'none'; img-src 'self' data: blob:; style-src 'unsafe-inline'",
|
||||||
|
"x-content-type-options": "nosniff",
|
||||||
|
"set-cookie": "session=secret",
|
||||||
|
},
|
||||||
|
body: Readable.from(["<svg xmlns=\"http://www.w3.org/2000/svg\" />"]),
|
||||||
|
}));
|
||||||
|
remoteClient = fakeRemoteClient({ request });
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/file/preview?path=${encodeURIComponent("diagram.svg")}` });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.headers["content-type"]).toContain("image/svg+xml");
|
||||||
|
expect(response.headers["content-security-policy"]).toContain("sandbox");
|
||||||
|
expect(response.headers["x-content-type-options"]).toBe("nosniff");
|
||||||
|
expect(response.headers["set-cookie"]).toBeUndefined();
|
||||||
|
expect(response.body).toBe("<svg xmlns=\"http://www.w3.org/2000/svg\" />");
|
||||||
|
expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces/w1/file/preview?path=diagram.svg", undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("proxies remote terminal command-run and continue routes", async () => {
|
||||||
|
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||||
|
const remote = addResponse.json<{ id: string }>();
|
||||||
|
const request = vi.fn((method: string, path: string) => Promise.resolve({
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: Readable.from([JSON.stringify({ method, path })]),
|
||||||
|
}));
|
||||||
|
remoteClient = fakeRemoteClient({ request });
|
||||||
|
|
||||||
|
const createBody = { origin: "core", title: "Build", command: "npm test", metadata: { "pi.operation": "test" } };
|
||||||
|
const createResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminal-command-runs`, payload: createBody });
|
||||||
|
const listResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/terminal-command-runs?projectId=p1&statuses=running` });
|
||||||
|
const getResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/terminal-command-runs/run1` });
|
||||||
|
const cancelResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/terminal-command-runs/run1/cancel` });
|
||||||
|
const continueResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminals/t1/continue` });
|
||||||
|
|
||||||
|
expect(createResponse.json()).toEqual({ method: "POST", path: "/api/projects/p1/workspaces/w1/terminal-command-runs" });
|
||||||
|
expect(listResponse.json()).toEqual({ method: "GET", path: "/api/terminal-command-runs?projectId=p1&statuses=running" });
|
||||||
|
expect(getResponse.json()).toEqual({ method: "GET", path: "/api/terminal-command-runs/run1" });
|
||||||
|
expect(cancelResponse.json()).toEqual({ method: "POST", path: "/api/terminal-command-runs/run1/cancel" });
|
||||||
|
expect(continueResponse.json()).toEqual({ method: "POST", path: "/api/projects/p1/workspaces/w1/terminals/t1/continue" });
|
||||||
|
expect(request).toHaveBeenCalledWith("POST", "/api/projects/p1/workspaces/w1/terminal-command-runs", createBody);
|
||||||
|
});
|
||||||
|
|
||||||
it("forwards remote JSON request bodies and normalizes remote timeouts", async () => {
|
it("forwards remote JSON request bodies and normalizes remote timeouts", async () => {
|
||||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||||
const remote = addResponse.json<{ id: string }>();
|
const remote = addResponse.json<{ id: string }>();
|
||||||
@@ -158,11 +213,56 @@ describe("buildApp", () => {
|
|||||||
expect(emptyListResponse.json<Project[]>()).toEqual([]);
|
expect(emptyListResponse.json<Project[]>()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serves local session proxy routes through machine-scoped aliases", async () => {
|
it("serves local session and terminal proxy routes through machine-scoped aliases", async () => {
|
||||||
const response = await app.inject({ method: "GET", url: `/api/machines/local/sessions?cwd=${encodeURIComponent(projectDir)}` });
|
const sessionsResponse = await app.inject({ method: "GET", url: `/api/machines/local/sessions?cwd=${encodeURIComponent(projectDir)}` });
|
||||||
|
|
||||||
expect(response.statusCode).toBe(502);
|
expect(sessionsResponse.statusCode).toBe(200);
|
||||||
expect(response.json()).toHaveProperty("error");
|
expect(sessionsResponse.json()).toEqual({ method: "GET", path: `/sessions?cwd=${encodeURIComponent(projectDir)}` });
|
||||||
|
expect(sessionDaemonRequests).toEqual([{ method: "GET", path: `/sessions?cwd=${encodeURIComponent(projectDir)}` }]);
|
||||||
|
|
||||||
|
const addResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/machines/local/projects",
|
||||||
|
payload: { name: "Machine Local", path: projectDir, create: true },
|
||||||
|
});
|
||||||
|
const project = addResponse.json<Project>();
|
||||||
|
const workspacesResponse = await app.inject({ method: "GET", url: `/api/machines/local/projects/${project.id}/workspaces` });
|
||||||
|
const workspace = workspacesResponse.json<Workspace[]>()[0];
|
||||||
|
if (workspace === undefined) throw new Error("Expected workspace");
|
||||||
|
|
||||||
|
const terminalResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/machines/local/projects/${project.id}/workspaces/${workspace.id}/terminal-command-runs`,
|
||||||
|
payload: { origin: "core", title: "Build", command: "npm test", metadata: { "pi.operation": "test" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(terminalResponse.statusCode).toBe(200);
|
||||||
|
expect(terminalResponse.json()).toEqual({
|
||||||
|
method: "POST",
|
||||||
|
path: "/terminal-command-runs",
|
||||||
|
body: {
|
||||||
|
origin: "core",
|
||||||
|
projectId: project.id,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
cwd: projectDir,
|
||||||
|
title: "Build",
|
||||||
|
command: "npm test",
|
||||||
|
metadata: { "pi.operation": "test" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(sessionDaemonRequests[1]).toEqual({
|
||||||
|
method: "POST",
|
||||||
|
path: "/terminal-command-runs",
|
||||||
|
body: {
|
||||||
|
origin: "core",
|
||||||
|
projectId: project.id,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
cwd: projectDir,
|
||||||
|
title: "Build",
|
||||||
|
command: "npm test",
|
||||||
|
metadata: { "pi.operation": "test" },
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serves local projects and workspaces through machine-scoped aliases", async () => {
|
it("serves local projects and workspaces through machine-scoped aliases", async () => {
|
||||||
@@ -271,6 +371,27 @@ describe("buildApp", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface CapturedSessionDaemonRequest {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
body?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeSessionDaemon(): SessionProxyDaemon {
|
||||||
|
return {
|
||||||
|
request: (method, path, body) => {
|
||||||
|
const captured = { method, path, ...(body === undefined ? {} : { body }) } satisfies CapturedSessionDaemonRequest;
|
||||||
|
sessionDaemonRequests.push(captured);
|
||||||
|
return Promise.resolve({
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(captured),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
connectWebSocket: () => { throw new Error("WebSocket not configured for test"); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function fakeRemoteClient(overrides: Partial<MachineClient>): MachineClient {
|
function fakeRemoteClient(overrides: Partial<MachineClient>): MachineClient {
|
||||||
return {
|
return {
|
||||||
request: () => Promise.resolve({ statusCode: 200, headers: {}, body: Readable.from([]) }),
|
request: () => Promise.resolve({ statusCode: 200, headers: {}, body: Readable.from([]) }),
|
||||||
|
|||||||
+8
-5
@@ -9,7 +9,8 @@ import { ProjectService } from "./projects/projectService.js";
|
|||||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||||
import { listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
import { listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
||||||
import { listDirectorySuggestions } from "./projects/directorySuggestions.js";
|
import { listDirectorySuggestions } from "./projects/directorySuggestions.js";
|
||||||
import { registerSessionProxyRoutes } from "./sessiond/sessionProxyRoutes.js";
|
import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
|
||||||
|
import { registerSessionProxyRoutes, type SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
|
||||||
import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
|
import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
|
||||||
import { registerGitRoutes } from "./gitRoutes.js";
|
import { registerGitRoutes } from "./gitRoutes.js";
|
||||||
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
|
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
|
||||||
@@ -23,6 +24,7 @@ export interface AppDependencies {
|
|||||||
projects?: ProjectService;
|
projects?: ProjectService;
|
||||||
workspaces?: WorkspaceService;
|
workspaces?: WorkspaceService;
|
||||||
machines?: MachineService;
|
machines?: MachineService;
|
||||||
|
sessionDaemon?: SessionProxyDaemon;
|
||||||
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "readAsset">;
|
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "readAsset">;
|
||||||
clientDist?: string | false;
|
clientDist?: string | false;
|
||||||
logger?: FastifyServerOptions["logger"];
|
logger?: FastifyServerOptions["logger"];
|
||||||
@@ -86,6 +88,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
|||||||
const workspaces = deps.workspaces ?? new WorkspaceService();
|
const workspaces = deps.workspaces ?? new WorkspaceService();
|
||||||
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
|
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
|
||||||
const machines = deps.machines ?? new MachineService();
|
const machines = deps.machines ?? new MachineService();
|
||||||
|
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
|
||||||
|
|
||||||
app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest());
|
app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest());
|
||||||
|
|
||||||
@@ -102,14 +105,14 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
|||||||
registerLocalProjectRoutes(app, projects, workspaces, "/api");
|
registerLocalProjectRoutes(app, projects, workspaces, "/api");
|
||||||
registerLocalProjectRoutes(app, projects, workspaces, "/api/machines/local");
|
registerLocalProjectRoutes(app, projects, workspaces, "/api/machines/local");
|
||||||
|
|
||||||
registerSessionProxyRoutes(app);
|
registerSessionProxyRoutes(app, sessionDaemon);
|
||||||
registerSessionProxyRoutes(app, undefined, "/api/machines/local");
|
registerSessionProxyRoutes(app, sessionDaemon, "/api/machines/local");
|
||||||
registerWorkspaceExplorerRoutes(app, projects, workspaces);
|
registerWorkspaceExplorerRoutes(app, projects, workspaces);
|
||||||
registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api/machines/local");
|
registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api/machines/local");
|
||||||
registerGitRoutes(app, projects, workspaces);
|
registerGitRoutes(app, projects, workspaces);
|
||||||
registerGitRoutes(app, projects, workspaces, "/api/machines/local");
|
registerGitRoutes(app, projects, workspaces, "/api/machines/local");
|
||||||
registerTerminalProxyRoutes(app, projects, workspaces);
|
registerTerminalProxyRoutes(app, projects, workspaces, sessionDaemon);
|
||||||
registerTerminalProxyRoutes(app, projects, workspaces, undefined, "/api/machines/local");
|
registerTerminalProxyRoutes(app, projects, workspaces, sessionDaemon, "/api/machines/local");
|
||||||
|
|
||||||
registerLocalFileSuggestionRoutes(app, "/api");
|
registerLocalFileSuggestionRoutes(app, "/api");
|
||||||
registerLocalFileSuggestionRoutes(app, "/api/machines/local");
|
registerLocalFileSuggestionRoutes(app, "/api/machines/local");
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { sanitizedGitEnv } from "./gitEnv.js";
|
||||||
|
|
||||||
|
describe("sanitizedGitEnv", () => {
|
||||||
|
it("removes repository-local Git variables inherited from hooks", () => {
|
||||||
|
const env = sanitizedGitEnv({
|
||||||
|
PATH: "/bin",
|
||||||
|
GIT_DIR: "/repo/.git",
|
||||||
|
GIT_WORK_TREE: "/repo",
|
||||||
|
GIT_INDEX_FILE: "/repo/.git/index.lock",
|
||||||
|
GIT_PREFIX: "src/",
|
||||||
|
GIT_COMMON_DIR: "/repo/.git",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(env).toEqual({ PATH: "/bin" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const GIT_LOCAL_ENV_VARS = [
|
||||||
|
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||||
|
"GIT_COMMON_DIR",
|
||||||
|
"GIT_DIR",
|
||||||
|
"GIT_INDEX_FILE",
|
||||||
|
"GIT_OBJECT_DIRECTORY",
|
||||||
|
"GIT_PREFIX",
|
||||||
|
"GIT_QUARANTINE_PATH",
|
||||||
|
"GIT_WORK_TREE",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function sanitizedGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||||
|
const blocked = new Set<string>(GIT_LOCAL_ENV_VARS);
|
||||||
|
return Object.fromEntries(Object.entries(env).filter(([key]) => !blocked.has(key)));
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import type { GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse } from "../../shared/apiTypes.js";
|
import type { GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse } from "../../shared/apiTypes.js";
|
||||||
import { normalizeRelativePath } from "../workspaces/pathSafety.js";
|
import { normalizeRelativePath } from "../workspaces/pathSafety.js";
|
||||||
|
import { sanitizedGitEnv } from "./gitEnv.js";
|
||||||
|
|
||||||
const MAX_OUTPUT = 2 * 1024 * 1024;
|
const MAX_OUTPUT = 2 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ function hash(value: string): string {
|
|||||||
|
|
||||||
async function runGit(cwd: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string; truncated: boolean }> {
|
async function runGit(cwd: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string; truncated: boolean }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
const child = spawn("git", args, { cwd, env: sanitizedGitEnv(), stdio: ["ignore", "pipe", "pipe"] });
|
||||||
const timer = setTimeout(() => { child.kill("SIGKILL"); }, 10000);
|
const timer = setTimeout(() => { child.kill("SIGKILL"); }, 10000);
|
||||||
let stdout = Buffer.alloc(0);
|
let stdout = Buffer.alloc(0);
|
||||||
let stderr = Buffer.alloc(0);
|
let stderr = Buffer.alloc(0);
|
||||||
|
|||||||
@@ -1,62 +1,12 @@
|
|||||||
import type { FastifyInstance, FastifyReply, HTTPMethods } from "fastify";
|
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||||
import type { WebSocket } from "ws";
|
import type { WebSocket } from "ws";
|
||||||
|
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES } from "../../shared/federatedRoutes.js";
|
||||||
import { bridgeSockets } from "../webSocketBridge.js";
|
import { bridgeSockets } from "../webSocketBridge.js";
|
||||||
import { RemoteMachineRequestError } from "./machineClient.js";
|
import { RemoteMachineRequestError } from "./machineClient.js";
|
||||||
import { MachineService } from "./machineService.js";
|
import { MachineService } from "./machineService.js";
|
||||||
|
|
||||||
interface HttpRouteSpec {
|
export const REMOTE_HTTP_ROUTES = FEDERATED_HTTP_ROUTES;
|
||||||
method: HTTPMethods;
|
export const REMOTE_WEBSOCKET_ROUTES = FEDERATED_WEBSOCKET_ROUTES;
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const REMOTE_HTTP_ROUTES: HttpRouteSpec[] = [
|
|
||||||
{ method: "GET", path: "/projects" },
|
|
||||||
{ method: "POST", path: "/projects" },
|
|
||||||
{ method: "DELETE", path: "/projects/:projectId" },
|
|
||||||
{ method: "GET", path: "/project-directories" },
|
|
||||||
{ method: "GET", path: "/projects/:projectId/workspaces" },
|
|
||||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/tree" },
|
|
||||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file" },
|
|
||||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file/preview" },
|
|
||||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/status" },
|
|
||||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/diff" },
|
|
||||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/terminals" },
|
|
||||||
{ method: "POST", path: "/projects/:projectId/workspaces/:workspaceId/terminals" },
|
|
||||||
{ method: "DELETE", path: "/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId" },
|
|
||||||
{ method: "GET", path: "/files" },
|
|
||||||
{ method: "GET", path: "/activity" },
|
|
||||||
{ method: "GET", path: "/sessions" },
|
|
||||||
{ method: "POST", path: "/sessions" },
|
|
||||||
{ method: "GET", path: "/sessions/:sessionId/messages" },
|
|
||||||
{ method: "GET", path: "/sessions/:sessionId/status" },
|
|
||||||
{ method: "GET", path: "/sessions/:sessionId/models" },
|
|
||||||
{ method: "POST", path: "/sessions/:sessionId/model" },
|
|
||||||
{ method: "POST", path: "/sessions/:sessionId/model/cycle" },
|
|
||||||
{ method: "GET", path: "/sessions/:sessionId/thinking-levels" },
|
|
||||||
{ method: "POST", path: "/sessions/:sessionId/thinking-level" },
|
|
||||||
{ method: "POST", path: "/sessions/:sessionId/thinking-level/cycle" },
|
|
||||||
{ method: "GET", path: "/sessions/:sessionId/commands" },
|
|
||||||
{ method: "POST", path: "/sessions/:sessionId/prompt" },
|
|
||||||
{ method: "POST", path: "/sessions/:sessionId/shell" },
|
|
||||||
{ method: "POST", path: "/sessions/:sessionId/commands/run" },
|
|
||||||
{ method: "POST", path: "/sessions/:sessionId/commands/respond" },
|
|
||||||
{ method: "POST", path: "/sessions/:sessionId/abort" },
|
|
||||||
{ method: "POST", path: "/sessions/:sessionId/stop" },
|
|
||||||
{ method: "POST", path: "/sessions/:sessionId/archive" },
|
|
||||||
{ method: "POST", path: "/sessions/:sessionId/archive-tree" },
|
|
||||||
{ method: "POST", path: "/sessions/:sessionId/restore" },
|
|
||||||
{ method: "POST", path: "/sessions/:sessionId/detach-parent" },
|
|
||||||
{ method: "GET", path: "/auth/providers" },
|
|
||||||
{ method: "POST", path: "/auth/api-key" },
|
|
||||||
{ method: "POST", path: "/auth/logout" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const REMOTE_WEBSOCKET_ROUTES = [
|
|
||||||
"/events",
|
|
||||||
"/sessions/events",
|
|
||||||
"/sessions/:sessionId/events",
|
|
||||||
"/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket",
|
|
||||||
];
|
|
||||||
|
|
||||||
const SAFE_RESPONSE_HEADERS = new Set([
|
const SAFE_RESPONSE_HEADERS = new Set([
|
||||||
"content-type",
|
"content-type",
|
||||||
@@ -64,6 +14,8 @@ const SAFE_RESPONSE_HEADERS = new Set([
|
|||||||
"cache-control",
|
"cache-control",
|
||||||
"last-modified",
|
"last-modified",
|
||||||
"etag",
|
"etag",
|
||||||
|
"content-security-policy",
|
||||||
|
"x-content-type-options",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function registerMachineProxyRoutes(app: FastifyInstance, machines = new MachineService()): void {
|
export function registerMachineProxyRoutes(app: FastifyInstance, machines = new MachineService()): void {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
@@ -35,6 +35,27 @@ describe("MachineService", () => {
|
|||||||
|
|
||||||
const raw: unknown = JSON.parse(await readFile(storePath, "utf8"));
|
const raw: unknown = JSON.parse(await readFile(storePath, "utf8"));
|
||||||
expect(raw).toMatchObject({ machines: [expect.objectContaining({ kind: "remote", token: "secret" })] });
|
expect(raw).toMatchObject({ machines: [expect.objectContaining({ kind: "remote", token: "secret" })] });
|
||||||
|
await expectOwnerOnlyMachineStore(storePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tightens permissions after reading an existing machine store", async () => {
|
||||||
|
if (process.platform === "win32") return;
|
||||||
|
await writeFile(storePath, `${JSON.stringify({
|
||||||
|
machines: [{
|
||||||
|
id: "remote-1",
|
||||||
|
name: "Remote",
|
||||||
|
kind: "remote",
|
||||||
|
baseUrl: "https://remote.example.test",
|
||||||
|
token: "secret",
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
}],
|
||||||
|
}, null, 2)}\n`, { encoding: "utf8", mode: 0o644 });
|
||||||
|
await chmod(storePath, 0o644);
|
||||||
|
|
||||||
|
await expect(service.list()).resolves.toEqual([expect.objectContaining({ id: "local" }), expect.objectContaining({ id: "remote-1" })]);
|
||||||
|
|
||||||
|
await expectOwnerOnlyMachineStore(storePath);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid remote base URLs", async () => {
|
it("rejects invalid remote base URLs", async () => {
|
||||||
@@ -58,3 +79,8 @@ describe("MachineService", () => {
|
|||||||
expect(machineStorePath(env, "/tmp/pi-web")).toBe(resolve("/tmp/pi-web", "data/machines.json"));
|
expect(machineStorePath(env, "/tmp/pi-web")).toBe(resolve("/tmp/pi-web", "data/machines.json"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function expectOwnerOnlyMachineStore(path: string): Promise<void> {
|
||||||
|
if (process.platform === "win32") return;
|
||||||
|
expect((await stat(path)).mode & 0o777).toBe(0o600);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import { dirname, join, resolve } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
import { piWebDataDir } from "../../config.js";
|
import { piWebDataDir } from "../../config.js";
|
||||||
|
|
||||||
@@ -18,6 +18,8 @@ interface MachineFile {
|
|||||||
machines: StoredMachine[];
|
machines: StoredMachine[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MACHINE_STORE_FILE_MODE = 0o600;
|
||||||
|
|
||||||
export function defaultMachineStorePath(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string {
|
export function defaultMachineStorePath(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string {
|
||||||
return join(piWebDataDir(env, cwd), "machines.json");
|
return join(piWebDataDir(env, cwd), "machines.json");
|
||||||
}
|
}
|
||||||
@@ -76,7 +78,9 @@ export class MachineStore {
|
|||||||
private async read(): Promise<MachineFile> {
|
private async read(): Promise<MachineFile> {
|
||||||
try {
|
try {
|
||||||
const value: unknown = JSON.parse(await readFile(this.filePath, "utf8"));
|
const value: unknown = JSON.parse(await readFile(this.filePath, "utf8"));
|
||||||
return parseMachineFile(value);
|
const parsed = parseMachineFile(value);
|
||||||
|
await restrictMachineStorePermissions(this.filePath);
|
||||||
|
return parsed;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isNodeErrorWithCode(error, "ENOENT")) return { machines: [] };
|
if (isNodeErrorWithCode(error, "ENOENT")) return { machines: [] };
|
||||||
throw error;
|
throw error;
|
||||||
@@ -85,7 +89,8 @@ export class MachineStore {
|
|||||||
|
|
||||||
private async write(data: MachineFile): Promise<void> {
|
private async write(data: MachineFile): Promise<void> {
|
||||||
await mkdir(dirname(this.filePath), { recursive: true });
|
await mkdir(dirname(this.filePath), { recursive: true });
|
||||||
await writeFile(this.filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
await writeFile(this.filePath, `${JSON.stringify(data, null, 2)}\n`, { encoding: "utf8", mode: MACHINE_STORE_FILE_MODE });
|
||||||
|
await restrictMachineStorePermissions(this.filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +128,11 @@ function optionalStringRecord(value: unknown, key: string): Record<string, strin
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function restrictMachineStorePermissions(path: string): Promise<void> {
|
||||||
|
if (process.platform === "win32") return;
|
||||||
|
await chmod(path, MACHINE_STORE_FILE_MODE);
|
||||||
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||||
import type { ProjectService } from "./projects/projectService.js";
|
import type { ProjectService } from "./projects/projectService.js";
|
||||||
import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
|
import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
|
||||||
|
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
|
||||||
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
||||||
import type { WorkspaceService } from "./workspaces/workspaceService.js";
|
import type { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||||
import { terminalSizeQuery } from "./terminals/terminalSize.js";
|
import { terminalSizeQuery } from "./terminals/terminalSize.js";
|
||||||
import { bridgeSockets } from "./webSocketBridge.js";
|
import { bridgeSockets } from "./webSocketBridge.js";
|
||||||
|
|
||||||
export function registerTerminalProxyRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, daemon = new SessionDaemonClient(), prefix = "/api"): void {
|
export function registerTerminalProxyRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, daemon: SessionProxyDaemon = new SessionDaemonClient(), prefix = "/api"): void {
|
||||||
app.get<{ Params: { projectId: string; workspaceId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals`, async (request, reply) => {
|
app.get<{ Params: { projectId: string; workspaceId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
@@ -130,7 +131,7 @@ function terminalCommandRunQuery(filter: TerminalCommandRunQuery): string {
|
|||||||
return query === "" ? "" : `?${query}`;
|
return query === "" ? "" : `?${query}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function proxyJson(daemon: SessionDaemonClient, method: string, path: string, body: unknown, reply: FastifyReply): Promise<unknown> {
|
async function proxyJson(daemon: SessionProxyDaemon, method: string, path: string, body: unknown, reply: FastifyReply): Promise<unknown> {
|
||||||
const upstream = await daemon.request(method, path, body);
|
const upstream = await daemon.request(method, path, body);
|
||||||
reply.code(upstream.statusCode);
|
reply.code(upstream.statusCode);
|
||||||
const contentType = upstream.headers["content-type"];
|
const contentType = upstream.headers["content-type"];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
|
|||||||
import { readdir, stat } from "node:fs/promises";
|
import { readdir, stat } from "node:fs/promises";
|
||||||
import { basename, dirname, join } from "node:path";
|
import { basename, dirname, join } from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
import { sanitizedGitEnv } from "../git/gitEnv.js";
|
||||||
import type { ClientFileSuggestion } from "../types.js";
|
import type { ClientFileSuggestion } from "../types.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
@@ -56,7 +57,7 @@ async function listPlainFiles(cwd: string): Promise<ClientFileSuggestion[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function git(cwd: string, args: string[]): Promise<string> {
|
async function git(cwd: string, args: string[]): Promise<string> {
|
||||||
const { stdout } = await execFileAsync("git", args, { cwd, maxBuffer: 1024 * 1024 * 8 });
|
const { stdout } = await execFileAsync("git", args, { cwd, env: sanitizedGitEnv(), maxBuffer: 1024 * 1024 * 8 });
|
||||||
return stdout;
|
return stdout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
import { sanitizedGitEnv } from "../git/gitEnv.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ export interface GitWorktreeInfo {
|
|||||||
|
|
||||||
export async function isGitRepository(path: string): Promise<boolean> {
|
export async function isGitRepository(path: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync("git", ["-C", path, "rev-parse", "--is-inside-work-tree"]);
|
const { stdout } = await execFileAsync("git", ["-C", path, "rev-parse", "--is-inside-work-tree"], { env: sanitizedGitEnv() });
|
||||||
return stdout.trim() === "true";
|
return stdout.trim() === "true";
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -20,7 +21,7 @@ export async function isGitRepository(path: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function discoverGitWorktrees(path: string): Promise<GitWorktreeInfo[]> {
|
export async function discoverGitWorktrees(path: string): Promise<GitWorktreeInfo[]> {
|
||||||
const { stdout } = await execFileAsync("git", ["-C", path, "worktree", "list", "--porcelain"]);
|
const { stdout } = await execFileAsync("git", ["-C", path, "worktree", "list", "--porcelain"], { env: sanitizedGitEnv() });
|
||||||
const chunks = stdout.trim().split(/\n\s*\n/).filter(Boolean);
|
const chunks = stdout.trim().split(/\n\s*\n/).filter(Boolean);
|
||||||
|
|
||||||
return chunks.map((chunk) => {
|
return chunks.map((chunk) => {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export type FederatedHttpMethod = "GET" | "POST" | "DELETE";
|
||||||
|
|
||||||
|
export interface FederatedHttpRouteSpec {
|
||||||
|
method: FederatedHttpMethod;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FEDERATED_HTTP_ROUTES = [
|
||||||
|
{ method: "GET", path: "/projects" },
|
||||||
|
{ method: "POST", path: "/projects" },
|
||||||
|
{ method: "DELETE", path: "/projects/:projectId" },
|
||||||
|
{ method: "GET", path: "/project-directories" },
|
||||||
|
{ method: "GET", path: "/projects/:projectId/workspaces" },
|
||||||
|
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/tree" },
|
||||||
|
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file" },
|
||||||
|
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file/preview" },
|
||||||
|
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/status" },
|
||||||
|
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/diff" },
|
||||||
|
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/terminals" },
|
||||||
|
{ method: "POST", path: "/projects/:projectId/workspaces/:workspaceId/terminals" },
|
||||||
|
{ method: "POST", path: "/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/continue" },
|
||||||
|
{ method: "DELETE", path: "/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId" },
|
||||||
|
{ method: "POST", path: "/projects/:projectId/workspaces/:workspaceId/terminal-command-runs" },
|
||||||
|
{ method: "GET", path: "/terminal-command-runs" },
|
||||||
|
{ method: "GET", path: "/terminal-command-runs/:runId" },
|
||||||
|
{ method: "POST", path: "/terminal-command-runs/:runId/cancel" },
|
||||||
|
{ method: "GET", path: "/files" },
|
||||||
|
{ method: "GET", path: "/activity" },
|
||||||
|
{ method: "GET", path: "/sessions" },
|
||||||
|
{ method: "POST", path: "/sessions" },
|
||||||
|
{ method: "GET", path: "/sessions/:sessionId/messages" },
|
||||||
|
{ method: "GET", path: "/sessions/:sessionId/status" },
|
||||||
|
{ method: "GET", path: "/sessions/:sessionId/models" },
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/model" },
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/model/cycle" },
|
||||||
|
{ method: "GET", path: "/sessions/:sessionId/thinking-levels" },
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/thinking-level" },
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/thinking-level/cycle" },
|
||||||
|
{ method: "GET", path: "/sessions/:sessionId/commands" },
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/prompt" },
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/shell" },
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/commands/run" },
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/commands/respond" },
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/abort" },
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/stop" },
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/archive" },
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/archive-tree" },
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/restore" },
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/detach-parent" },
|
||||||
|
{ method: "GET", path: "/auth/providers" },
|
||||||
|
{ method: "POST", path: "/auth/api-key" },
|
||||||
|
{ method: "POST", path: "/auth/logout" },
|
||||||
|
{ method: "POST", path: "/auth/oauth" },
|
||||||
|
{ method: "GET", path: "/auth/oauth/:flowId" },
|
||||||
|
{ method: "POST", path: "/auth/oauth/:flowId/respond" },
|
||||||
|
{ method: "POST", path: "/auth/oauth/:flowId/cancel" },
|
||||||
|
] as const satisfies readonly FederatedHttpRouteSpec[];
|
||||||
|
|
||||||
|
export const FEDERATED_WEBSOCKET_ROUTES = [
|
||||||
|
"/events",
|
||||||
|
"/sessions/events",
|
||||||
|
"/sessions/:sessionId/events",
|
||||||
|
"/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket",
|
||||||
|
] as const satisfies readonly string[];
|
||||||
Reference in New Issue
Block a user