diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts new file mode 100644 index 0000000..ef05d09 --- /dev/null +++ b/src/client/src/api/clients.test.ts @@ -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; +type FetchMock = ReturnType>; + +function stubJsonFetch(value: unknown): FetchMock { + return stubResponseFetch(jsonResponse(value)); +} + +function stubSequenceFetch(responses: Response[]): FetchMock { + const fetchMock = vi.fn(() => { + 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(() => Promise.resolve(response)); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +function fetchCall(fetchMock: FetchMock, index: number): Parameters { + 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" } }); +} diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index ab66a11f..8e2621f 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -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 ?? {}) }), 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" }), - 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 ?? {} }) }), - listCommandRuns: (filter?: TerminalCommandRunFilter) => request(`/api/terminal-command-runs${terminalCommandRunFilterQuery(filter)}`, arrayOf(parseTerminalCommandRun)), - getCommandRun: (runId: string) => getOptionalTerminalCommandRun(runId), - cancelCommandRun: (runId: string) => request(`/api/terminal-command-runs/${encodeURIComponent(runId)}/cancel`, parseTerminalCommandRun, { method: "POST" }), + 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, machineId = "local") => request(`${machinePrefix(machineId)}/terminal-command-runs${terminalCommandRunFilterQuery(filter)}`, arrayOf(parseTerminalCommandRun)), + getCommandRun: (runId: string, machineId = "local") => getOptionalTerminalCommandRun(runId, machineId), + cancelCommandRun: (runId: string, machineId = "local") => request(`${machinePrefix(machineId)}/terminal-command-runs/${encodeURIComponent(runId)}/cancel`, parseTerminalCommandRun, { method: "POST" }), }; -async function getOptionalTerminalCommandRun(runId: string): Promise { - const response = await fetch(`/api/terminal-command-runs/${encodeURIComponent(runId)}`); +async function getOptionalTerminalCommandRun(runId: string, machineId: string): Promise { + const response = await fetch(`${machinePrefix(machineId)}/terminal-command-runs/${encodeURIComponent(runId)}`); if (response.status === 404) return undefined; if (!response.ok) { const body: unknown = await response.json().catch((): unknown => ({})); diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts new file mode 100644 index 0000000..4005365 --- /dev/null +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -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(() => 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; + +interface ObservedHttpRoute { + method: string; + path: string; +} + +async function ignoreParseFailure(promise: Promise): Promise { + await promise.catch(() => undefined); +} + +function fetchCallToRoute(call: Parameters, 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(); + 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" } }); +} diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index a01b719..a0ef0f2 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -430,18 +430,36 @@ export class PiWebApp extends LitElement { this.openWorkspaceTool("core:workspace.terminal"); } - private terminalCommandRunsForOrigin(origin: string): TerminalCommandRunsInternalRuntime { - const existing = this.terminalCommandRunRuntimes.get(origin); + private terminalCommandRunsForOrigin(origin: string, machineId = selectedMachineId(this.state)): TerminalCommandRunsInternalRuntime { + const key = machineScopedKey(machineId, origin); + const existing = this.terminalCommandRunRuntimes.get(key); if (existing !== undefined) return existing; 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; } - private async openRuntimeTerminal(workspace: Workspace | undefined, options?: { terminalId?: string | undefined }): Promise { - if (workspace !== undefined && this.state.selectedWorkspace?.id !== workspace.id) await this.workspaces.selectWorkspace(workspace); + private async openRuntimeTerminal(machineId: string, workspace: Workspace | undefined, options?: { terminalId?: string | undefined }): Promise { + 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); } @@ -525,9 +543,10 @@ export class PiWebApp extends LitElement { } private async refreshActiveTerminals(workspace: Workspace): Promise { + const machineId = selectedMachineId(this.state); try { - const terminals = await terminalsApi.terminals(workspace.projectId, workspace.id, selectedMachineId(this.state)); - if (this.state.selectedWorkspace?.id !== workspace.id) return; + const terminals = await terminalsApi.terminals(workspace.projectId, workspace.id, machineId); + if (selectedMachineId(this.state) !== machineId || this.state.selectedWorkspace?.id !== workspace.id) return; this.activeTerminalIds.clear(); for (const terminal of terminals) { 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.`); if (!confirmed) return; + const machineId = selectedMachineId(this.state); try { const mainWorkspace = await this.mainWorkspaceForProject(workspace.projectId); if (mainWorkspace === undefined) { this.setState({ error: "Project main workspace not found" }); return; } - const handle = await this.terminalCommandRunsForOrigin("core").runCommand({ + if (selectedMachineId(this.state) !== machineId) return; + const handle = await this.terminalCommandRunsForOrigin("core", machineId).runCommand({ workspace: mainWorkspace, title: `Delete workspace: ${label}`, command: `git worktree remove ${shellQuote(workspace.path)}`, open: true, metadata: workspaceDeletionMetadata(workspace), }); - this.recordWorkspaceDeletionRun(handle.run); - void handle.completed.then((run) => this.handleCompletedWorkspaceDeletionRun(run)).catch((error: unknown) => { - this.setState({ error: `Workspace deletion failed. See terminal output. ${errorMessage(error)}` }); + this.recordWorkspaceDeletionRun(handle.run, machineId); + void handle.completed.then((run) => this.handleCompletedWorkspaceDeletionRun(run, machineId)).catch((error: unknown) => { + if (selectedMachineId(this.state) === machineId) this.setState({ error: `Workspace deletion failed. See terminal output. ${errorMessage(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]; } - private recordWorkspaceDeletionRun(run: TerminalCommandRun): void { + private recordWorkspaceDeletionRun(run: TerminalCommandRun, machineId: string): void { + if (selectedMachineId(this.state) !== machineId) return; const workspaceId = targetWorkspaceIdForRun(run); if (workspaceId === undefined) return; this.setState({ workspaceDeletionRuns: { ...this.state.workspaceDeletionRuns, [workspaceId]: run } }); @@ -861,6 +883,7 @@ export class PiWebApp extends LitElement { private async refreshWorkspaceDeletionRuns(): Promise { if (this.refreshingWorkspaceDeletionRuns) return; + const machineId = selectedMachineId(this.state); const project = this.state.selectedProject; if (project === undefined) { this.setState({ workspaceDeletionRuns: {} }); @@ -870,11 +893,12 @@ export class PiWebApp extends LitElement { this.refreshingWorkspaceDeletionRuns = true; 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); this.setState({ workspaceDeletionRuns: 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) { console.warn("Failed to refresh workspace deletion runs", error); @@ -896,14 +920,17 @@ export class PiWebApp extends LitElement { } } - private async handleCompletedWorkspaceDeletionRun(run: TerminalCommandRun): Promise { - if (this.handledWorkspaceDeletionRunIds.has(run.id)) return; + private async handleCompletedWorkspaceDeletionRun(run: TerminalCommandRun, machineId = selectedMachineId(this.state)): Promise { + if (selectedMachineId(this.state) !== machineId) return; + const runKey = machineScopedKey(machineId, run.id); + if (this.handledWorkspaceDeletionRunIds.has(runKey)) return; const workspaceId = targetWorkspaceIdForRun(run); if (workspaceId === undefined) return; - this.handledWorkspaceDeletionRunIds.add(run.id); + this.handledWorkspaceDeletionRunIds.add(runKey); if (run.status === "succeeded") { await this.workspaces.refreshAfterWorkspaceDeleted(run.projectId, workspaceId); + if (selectedMachineId(this.state) !== machineId) return; this.setState({ workspaceDeletionRuns: omitWorkspaceDeletionRun(this.state.workspaceDeletionRuns, workspaceId) }); this.updateWorkspaceDeletionPolling(); 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"; } +function machineScopedKey(machineId: string, value: string): string { + return JSON.stringify([machineId, value]); +} + function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } diff --git a/src/client/src/components/TerminalPanel.ts b/src/client/src/components/TerminalPanel.ts index 8c243c5..d5cb430 100644 --- a/src/client/src/components/TerminalPanel.ts +++ b/src/client/src/components/TerminalPanel.ts @@ -40,7 +40,7 @@ export class TerminalPanel extends LitElement { private intersectionObserver: IntersectionObserver | undefined; private themeObserver: MutationObserver | undefined; private suppressTerminalInput = false; - private observedCwd: string | undefined; + private observedWorkspaceScope: string | undefined; private loadedCwd: string | undefined; private autoStartConsumedCwd: string | undefined; private commandRunPollTimer: number | undefined; @@ -69,9 +69,9 @@ export class TerminalPanel extends LitElement { } override willUpdate(changed: PropertyValues): void { - const cwd = this.workspace?.path; - if (cwd !== this.observedCwd) { - this.observedCwd = cwd; + const workspaceScope = this.workspace === undefined ? undefined : JSON.stringify([this.machineId, this.workspace.path]); + if (workspaceScope !== this.observedWorkspaceScope) { + this.observedWorkspaceScope = workspaceScope; this.loadedCwd = undefined; this.autoStartConsumedCwd = undefined; this.terminals = []; @@ -118,7 +118,7 @@ export class TerminalPanel extends LitElement { const shouldAutoStart = this.consumeAutoStart(); const [terminals, commandRuns] = await Promise.all([ 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.commandRuns = commandRuns; @@ -218,7 +218,7 @@ export class TerminalPanel extends LitElement { const workspace = this.workspace; if (workspace === undefined) return; 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.cancellingRunIds = this.cancellingRunIds.filter((runId) => commandRuns.some((run) => run.id === runId && isCommandRunPending(run))); this.updateCommandRunPolling(this.hasPendingCommandRuns(commandRuns)); @@ -247,7 +247,7 @@ export class TerminalPanel extends LitElement { this.error = undefined; this.cancellingRunIds = [...this.cancellingRunIds, run.id]; try { - await terminalsApi.cancelCommandRun(run.id); + await terminalsApi.cancelCommandRun(run.id, this.machineId); await this.loadCommandRuns(); } catch (error) { this.error = error instanceof Error ? error.message : String(error); @@ -261,7 +261,7 @@ export class TerminalPanel extends LitElement { this.error = undefined; this.continuingTerminalIds = [...this.continuingTerminalIds, id]; 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); if (this.socket === undefined) this.disposeTerminalView(); this.fitAndNotify(); diff --git a/src/client/src/controllers/machineController.ts b/src/client/src/controllers/machineController.ts index ddd33aa..7903cfa 100644 --- a/src/client/src/controllers/machineController.ts +++ b/src/client/src/controllers/machineController.ts @@ -38,6 +38,8 @@ export class MachineController { sessionActivities: {}, workspaceActivities: {}, workspacesByProjectId: {}, + workspaceDeletionRuns: {}, + activeTerminalCount: 0, ...resetWorkspaceScopedState(), }); if (options.updateUrl !== false) this.updateUrl(); diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 3698bf1..e4379bf 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -11,6 +11,7 @@ import { RemoteMachineRequestError, type MachineClient } from "./machines/machin import { MachineService } from "./machines/machineService.js"; import { MachineStore } from "./machines/machineStore.js"; import { WorkspaceService } from "./workspaces/workspaceService.js"; +import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js"; import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js"; import type { Project, Workspace } from "./types.js"; @@ -18,11 +19,13 @@ let app: FastifyInstance; let tempDir: string; let projectDir: string; let remoteClient: MachineClient | undefined; +let sessionDaemonRequests: CapturedSessionDaemonRequest[]; beforeEach(async () => { tempDir = await realpath(await mkdtemp(join(tmpdir(), "pi-web-app-test-"))); projectDir = join(tempDir, "project"); remoteClient = undefined; + sessionDaemonRequests = []; app = await buildApp({ projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))), workspaces: new WorkspaceService(), @@ -44,6 +47,7 @@ beforeEach(async () => { messages: [], }), }), + sessionDaemon: fakeSessionDaemon(), piWebPlugins: { 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), @@ -121,6 +125,57 @@ describe("buildApp", () => { 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([""]), + })); + 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(""); + 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 () => { const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); const remote = addResponse.json<{ id: string }>(); @@ -158,11 +213,56 @@ describe("buildApp", () => { expect(emptyListResponse.json()).toEqual([]); }); - it("serves local session proxy routes through machine-scoped aliases", async () => { - const response = await app.inject({ method: "GET", url: `/api/machines/local/sessions?cwd=${encodeURIComponent(projectDir)}` }); + it("serves local session and terminal proxy routes through machine-scoped aliases", async () => { + const sessionsResponse = await app.inject({ method: "GET", url: `/api/machines/local/sessions?cwd=${encodeURIComponent(projectDir)}` }); - expect(response.statusCode).toBe(502); - expect(response.json()).toHaveProperty("error"); + expect(sessionsResponse.statusCode).toBe(200); + 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(); + const workspacesResponse = await app.inject({ method: "GET", url: `/api/machines/local/projects/${project.id}/workspaces` }); + const workspace = workspacesResponse.json()[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 () => { @@ -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 { return { request: () => Promise.resolve({ statusCode: 200, headers: {}, body: Readable.from([]) }), diff --git a/src/server/app.ts b/src/server/app.ts index 5fc876f..6432acd 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -9,7 +9,8 @@ import { ProjectService } from "./projects/projectService.js"; import { WorkspaceService } from "./workspaces/workspaceService.js"; import { listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.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 { registerGitRoutes } from "./gitRoutes.js"; import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js"; @@ -23,6 +24,7 @@ export interface AppDependencies { projects?: ProjectService; workspaces?: WorkspaceService; machines?: MachineService; + sessionDaemon?: SessionProxyDaemon; piWebPlugins?: Pick; clientDist?: string | false; logger?: FastifyServerOptions["logger"]; @@ -86,6 +88,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise piWebPlugins.manifest()); @@ -102,14 +105,14 @@ export async function buildApp(deps: AppDependencies = {}): Promise { + 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" }); + }); +}); diff --git a/src/server/git/gitEnv.ts b/src/server/git/gitEnv.ts new file mode 100644 index 0000000..cac7200 --- /dev/null +++ b/src/server/git/gitEnv.ts @@ -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(GIT_LOCAL_ENV_VARS); + return Object.fromEntries(Object.entries(env).filter(([key]) => !blocked.has(key))); +} diff --git a/src/server/git/gitService.ts b/src/server/git/gitService.ts index 906a3b3..cfa3c4c 100644 --- a/src/server/git/gitService.ts +++ b/src/server/git/gitService.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; import type { GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse } from "../../shared/apiTypes.js"; import { normalizeRelativePath } from "../workspaces/pathSafety.js"; +import { sanitizedGitEnv } from "./gitEnv.js"; 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 }> { 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); let stdout = Buffer.alloc(0); let stderr = Buffer.alloc(0); diff --git a/src/server/machines/machineProxyRoutes.ts b/src/server/machines/machineProxyRoutes.ts index b1fd9dd..bdd710f 100644 --- a/src/server/machines/machineProxyRoutes.ts +++ b/src/server/machines/machineProxyRoutes.ts @@ -1,62 +1,12 @@ -import type { FastifyInstance, FastifyReply, HTTPMethods } from "fastify"; +import type { FastifyInstance, FastifyReply } from "fastify"; import type { WebSocket } from "ws"; +import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES } from "../../shared/federatedRoutes.js"; import { bridgeSockets } from "../webSocketBridge.js"; import { RemoteMachineRequestError } from "./machineClient.js"; import { MachineService } from "./machineService.js"; -interface HttpRouteSpec { - method: HTTPMethods; - 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", -]; +export const REMOTE_HTTP_ROUTES = FEDERATED_HTTP_ROUTES; +export const REMOTE_WEBSOCKET_ROUTES = FEDERATED_WEBSOCKET_ROUTES; const SAFE_RESPONSE_HEADERS = new Set([ "content-type", @@ -64,6 +14,8 @@ const SAFE_RESPONSE_HEADERS = new Set([ "cache-control", "last-modified", "etag", + "content-security-policy", + "x-content-type-options", ]); export function registerMachineProxyRoutes(app: FastifyInstance, machines = new MachineService()): void { diff --git a/src/server/machines/machineService.test.ts b/src/server/machines/machineService.test.ts index dc2b544..337deb7 100644 --- a/src/server/machines/machineService.test.ts +++ b/src/server/machines/machineService.test.ts @@ -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 { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -35,6 +35,27 @@ describe("MachineService", () => { const raw: unknown = JSON.parse(await readFile(storePath, "utf8")); 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 () => { @@ -58,3 +79,8 @@ describe("MachineService", () => { expect(machineStorePath(env, "/tmp/pi-web")).toBe(resolve("/tmp/pi-web", "data/machines.json")); }); }); + +async function expectOwnerOnlyMachineStore(path: string): Promise { + if (process.platform === "win32") return; + expect((await stat(path)).mode & 0o777).toBe(0o600); +} diff --git a/src/server/machines/machineStore.ts b/src/server/machines/machineStore.ts index 2da52be..6ffa0af 100644 --- a/src/server/machines/machineStore.ts +++ b/src/server/machines/machineStore.ts @@ -1,5 +1,5 @@ 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 { piWebDataDir } from "../../config.js"; @@ -18,6 +18,8 @@ interface MachineFile { machines: StoredMachine[]; } +const MACHINE_STORE_FILE_MODE = 0o600; + export function defaultMachineStorePath(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string { return join(piWebDataDir(env, cwd), "machines.json"); } @@ -76,7 +78,9 @@ export class MachineStore { private async read(): Promise { try { 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) { if (isNodeErrorWithCode(error, "ENOENT")) return { machines: [] }; throw error; @@ -85,7 +89,8 @@ export class MachineStore { private async write(data: MachineFile): Promise { 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 { + if (process.platform === "win32") return; + await chmod(path, MACHINE_STORE_FILE_MODE); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/server/terminalProxyRoutes.ts b/src/server/terminalProxyRoutes.ts index 58c21bd..a0bac1c 100644 --- a/src/server/terminalProxyRoutes.ts +++ b/src/server/terminalProxyRoutes.ts @@ -1,12 +1,13 @@ import type { FastifyInstance, FastifyReply } from "fastify"; import type { ProjectService } from "./projects/projectService.js"; import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js"; +import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js"; import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js"; import type { WorkspaceService } from "./workspaces/workspaceService.js"; import { terminalSizeQuery } from "./terminals/terminalSize.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) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); @@ -130,7 +131,7 @@ function terminalCommandRunQuery(filter: TerminalCommandRunQuery): string { return query === "" ? "" : `?${query}`; } -async function proxyJson(daemon: SessionDaemonClient, method: string, path: string, body: unknown, reply: FastifyReply): Promise { +async function proxyJson(daemon: SessionProxyDaemon, method: string, path: string, body: unknown, reply: FastifyReply): Promise { const upstream = await daemon.request(method, path, body); reply.code(upstream.statusCode); const contentType = upstream.headers["content-type"]; diff --git a/src/server/workspaces/fileSuggestions.ts b/src/server/workspaces/fileSuggestions.ts index 6147fa8..09263da 100644 --- a/src/server/workspaces/fileSuggestions.ts +++ b/src/server/workspaces/fileSuggestions.ts @@ -2,6 +2,7 @@ import { execFile } from "node:child_process"; import { readdir, stat } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { promisify } from "node:util"; +import { sanitizedGitEnv } from "../git/gitEnv.js"; import type { ClientFileSuggestion } from "../types.js"; const execFileAsync = promisify(execFile); @@ -56,7 +57,7 @@ async function listPlainFiles(cwd: string): Promise { } async function git(cwd: string, args: string[]): Promise { - 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; } diff --git a/src/server/workspaces/gitWorktreeDiscovery.ts b/src/server/workspaces/gitWorktreeDiscovery.ts index 60361f3..f4127bf 100644 --- a/src/server/workspaces/gitWorktreeDiscovery.ts +++ b/src/server/workspaces/gitWorktreeDiscovery.ts @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; +import { sanitizedGitEnv } from "../git/gitEnv.js"; const execFileAsync = promisify(execFile); @@ -12,7 +13,7 @@ export interface GitWorktreeInfo { export async function isGitRepository(path: string): Promise { 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"; } catch { return false; @@ -20,7 +21,7 @@ export async function isGitRepository(path: string): Promise { } export async function discoverGitWorktrees(path: string): Promise { - 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); return chunks.map((chunk) => { diff --git a/src/shared/federatedRoutes.ts b/src/shared/federatedRoutes.ts new file mode 100644 index 0000000..27cb7a5 --- /dev/null +++ b/src/shared/federatedRoutes.ts @@ -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[];