Archived
fix: harden machine federation boundaries
This commit is contained in:
+125
-4
@@ -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(["<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 () => {
|
||||
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<Project[]>()).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<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 () => {
|
||||
@@ -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 {
|
||||
return {
|
||||
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 { 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<PiWebPluginService, "manifest" | "readAsset">;
|
||||
clientDist?: string | false;
|
||||
logger?: FastifyServerOptions["logger"];
|
||||
@@ -86,6 +88,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
const workspaces = deps.workspaces ?? new WorkspaceService();
|
||||
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
|
||||
const machines = deps.machines ?? new MachineService();
|
||||
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
|
||||
|
||||
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/machines/local");
|
||||
|
||||
registerSessionProxyRoutes(app);
|
||||
registerSessionProxyRoutes(app, undefined, "/api/machines/local");
|
||||
registerSessionProxyRoutes(app, sessionDaemon);
|
||||
registerSessionProxyRoutes(app, sessionDaemon, "/api/machines/local");
|
||||
registerWorkspaceExplorerRoutes(app, projects, workspaces);
|
||||
registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api/machines/local");
|
||||
registerGitRoutes(app, projects, workspaces);
|
||||
registerGitRoutes(app, projects, workspaces, "/api/machines/local");
|
||||
registerTerminalProxyRoutes(app, projects, workspaces);
|
||||
registerTerminalProxyRoutes(app, projects, workspaces, undefined, "/api/machines/local");
|
||||
registerTerminalProxyRoutes(app, projects, workspaces, sessionDaemon);
|
||||
registerTerminalProxyRoutes(app, projects, workspaces, sessionDaemon, "/api/machines/local");
|
||||
|
||||
registerLocalFileSuggestionRoutes(app, "/api");
|
||||
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 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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<void> {
|
||||
if (process.platform === "win32") return;
|
||||
expect((await stat(path)).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
|
||||
@@ -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<MachineFile> {
|
||||
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<void> {
|
||||
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> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -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<unknown> {
|
||||
async function proxyJson(daemon: SessionProxyDaemon, method: string, path: string, body: unknown, reply: FastifyReply): Promise<unknown> {
|
||||
const upstream = await daemon.request(method, path, body);
|
||||
reply.code(upstream.statusCode);
|
||||
const contentType = upstream.headers["content-type"];
|
||||
|
||||
@@ -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<ClientFileSuggestion[]> {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return chunks.map((chunk) => {
|
||||
|
||||
Reference in New Issue
Block a user