diff --git a/apps/desktop/src/main/daemon-diagnostics.test.ts b/apps/desktop/src/main/daemon-diagnostics.test.ts new file mode 100644 index 00000000000..96c642423c3 --- /dev/null +++ b/apps/desktop/src/main/daemon-diagnostics.test.ts @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { chmod, mkdir, rm, symlink, writeFile } from "fs/promises"; +import { dirname, join } from "path"; +import { + fetchDaemonDiagnostics, + profileOperatorCredentialPath, +} from "./daemon-diagnostics"; + +const credential = Buffer.alloc(32, 7).toString("base64url"); +let originalHome: string | undefined; +let home: string; + +beforeEach(async () => { + originalHome = process.env.HOME; + home = join(process.cwd(), `.daemon-diagnostics-test-${process.pid}`); + process.env.HOME = home; + await rm(home, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +afterEach(async () => { + process.env.HOME = originalHome; + await rm(home, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +async function writeCredential(profile: string, value = credential) { + const path = profileOperatorCredentialPath(profile); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${value}\n`, { mode: 0o600 }); + if (process.platform !== "win32") await chmod(path, 0o600); + return path; +} + +function validDiagnostics(overrides: Record = {}) { + return { + status: "running", + os: "darwin", + pid: 1234, + uptime: "1m2s", + daemon_id: "daemon-1", + device_name: "test-device", + server_url: "https://example.test", + cli_version: "v9.9.9", + active_task_count: 2, + agents: ["codex"], + workspaces: [{ id: "workspace-1", runtimes: ["codex"] }], + ...overrides, + }; +} + +describe("daemon diagnostics", () => { + it("uses the exact default and named profile credential paths", () => { + expect(profileOperatorCredentialPath("")).toBe( + join(home, ".multica", "daemon.shutdown-token"), + ); + expect(profileOperatorCredentialPath("desktop-localhost-8082")).toBe( + join( + home, + ".multica", + "profiles", + "desktop-localhost-8082", + "daemon.shutdown-token", + ), + ); + }); + + it("authenticates diagnostics without exposing the credential in the URL", async () => { + await writeCredential("test-profile"); + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify(validDiagnostics()), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await fetchDaemonDiagnostics("test-profile", 19514); + expect(result?.cli_version).toBe("v9.9.9"); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toBe("http://127.0.0.1:19514/diagnostics"); + expect(url).not.toContain(credential); + expect(options.redirect).toBe("error"); + expect(options.headers).toEqual({ + "X-Multica-Shutdown-Credential": credential, + }); + }); + + it("fails closed for invalid credential files", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await writeCredential("malformed", "not-base64url!"); + expect(await fetchDaemonDiagnostics("malformed", 19514)).toBeNull(); + + if (process.platform !== "win32") { + const broad = await writeCredential("broad"); + await chmod(broad, 0o644); + expect(await fetchDaemonDiagnostics("broad", 19514)).toBeNull(); + + const target = await writeCredential("target"); + const link = profileOperatorCredentialPath("link"); + await mkdir(dirname(link), { recursive: true }); + await symlink(target, link); + expect(await fetchDaemonDiagnostics("link", 19514)).toBeNull(); + } + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("fails closed for authentication, redirects, and malformed JSON", async () => { + await writeCredential("test-profile"); + for (const response of [ + new Response("unauthorized", { status: 401 }), + new Response("redirect", { status: 307 }), + new Response("not json", { status: 200 }), + ]) { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response)); + expect(await fetchDaemonDiagnostics("test-profile", 19514)).toBeNull(); + } + }); + + it("rejects oversized diagnostics responses", async () => { + await writeCredential("test-profile"); + for (const response of [ + new Response("{}", { + status: 200, + headers: { "Content-Length": String((1 << 20) + 1) }, + }), + new Response(`{"status":"running","padding":"${"x".repeat(1 << 20)}"}`, { + status: 200, + }), + ]) { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response)); + expect(await fetchDaemonDiagnostics("test-profile", 19514)).toBeNull(); + } + }); + + it("rejects duplicate keys, trailing content, and unknown fields", async () => { + await writeCredential("test-profile"); + for (const source of [ + '{"status":"running","status":"starting"}', + '{"status":"running","workspaces":[{"id":1,"id":2}]}', + '{"status":"running"} true', + '{"status":"running","unexpected":true}', + '{"status":"running","pid":1e400}', + `${"[".repeat(65)}0${"]".repeat(65)}`, + ]) { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(source, { status: 200 })), + ); + expect(await fetchDaemonDiagnostics("test-profile", 19514)).toBeNull(); + } + }); + + it("fails closed for malformed diagnostics fields", async () => { + await writeCredential("test-profile"); + for (const payload of [ + {}, + { status: "stopped" }, + { status: "running", active_task_count: -1 }, + { status: "running", active_task_count: 0.5 }, + { status: "running", pid: -1 }, + { status: "running", pid: 0.5 }, + { status: "running", agents: ["codex", 7] }, + { status: "running", workspaces: "workspace-id" }, + { status: "running", workspaces: [{ id: 7, runtimes: [] }] }, + { status: "running", workspaces: [{ id: "id", runtimes: [7] }] }, + { status: "running", workspaces: [{ id: "id", runtimes: [], extra: true }] }, + validDiagnostics({ os: undefined }), + validDiagnostics({ active_task_count: Number.MAX_SAFE_INTEGER + 1 }), + ]) { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + expect(await fetchDaemonDiagnostics("test-profile", 19514)).toBeNull(); + } + }); +}); diff --git a/apps/desktop/src/main/daemon-diagnostics.ts b/apps/desktop/src/main/daemon-diagnostics.ts new file mode 100644 index 00000000000..ad17185ea8d --- /dev/null +++ b/apps/desktop/src/main/daemon-diagnostics.ts @@ -0,0 +1,147 @@ +import { constants } from "fs"; +import { open, lstat } from "fs/promises"; +import { join } from "path"; +import { homedir } from "os"; +import { readBoundedDaemonJSON } from "./daemon-json"; + +const OPERATOR_CREDENTIAL_HEADER = "X-Multica-Shutdown-Credential"; +const MAX_CREDENTIAL_BYTES = 256; +const DIAGNOSTICS_FIELDS = new Set([ + "status", + "os", + "pid", + "uptime", + "daemon_id", + "device_name", + "server_url", + "cli_version", + "active_task_count", + "agents", + "workspaces", +]); + +export interface DiagnosticsPayload { + status: "running" | "starting"; + os: string; + pid: number; + uptime: string; + daemon_id: string; + device_name: string; + server_url: string; + cli_version: string; + active_task_count: number; + agents: string[]; + workspaces: Array<{ id: string; runtimes: string[] }>; +} + +function isWorkspace(value: unknown): value is { id: string; runtimes: string[] } { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const workspace = value as Record; + const keys = Object.keys(workspace); + return ( + keys.length === 2 && + keys.includes("id") && + keys.includes("runtimes") && + typeof workspace.id === "string" && + Array.isArray(workspace.runtimes) && + workspace.runtimes.every((runtime) => typeof runtime === "string") + ); +} + +function isDiagnosticsPayload(value: unknown): value is DiagnosticsPayload { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const payload = value as Record; + return ( + Object.keys(payload).length === DIAGNOSTICS_FIELDS.size && + Object.keys(payload).every((key) => DIAGNOSTICS_FIELDS.has(key)) && + (payload.status === "running" || payload.status === "starting") && + typeof payload.os === "string" && + typeof payload.pid === "number" && + Number.isSafeInteger(payload.pid) && + payload.pid > 0 && + typeof payload.uptime === "string" && + typeof payload.daemon_id === "string" && + typeof payload.device_name === "string" && + typeof payload.server_url === "string" && + typeof payload.cli_version === "string" && + typeof payload.active_task_count === "number" && + Number.isSafeInteger(payload.active_task_count) && + payload.active_task_count >= 0 && + Array.isArray(payload.agents) && + payload.agents.every((agent) => typeof agent === "string") && + Array.isArray(payload.workspaces) && + payload.workspaces.every(isWorkspace) + ); +} + +export function profileOperatorCredentialPath(profile: string): string { + const dir = profile + ? join(homedir(), ".multica", "profiles", profile) + : join(homedir(), ".multica"); + return join(dir, "daemon.shutdown-token"); +} + +async function readOperatorCredential(profile: string): Promise { + const path = profileOperatorCredentialPath(profile); + let before; + try { + before = await lstat(path); + } catch { + return null; + } + if (!before.isFile() || before.isSymbolicLink()) return null; + if (process.platform !== "win32" && (before.mode & 0o077) !== 0) return null; + if (before.size <= 0 || before.size > MAX_CREDENTIAL_BYTES) return null; + + let file; + try { + const flags = + process.platform === "win32" + ? constants.O_RDONLY + : constants.O_RDONLY | constants.O_NOFOLLOW; + file = await open(path, flags); + const after = await file.stat(); + if (!after.isFile()) return null; + if (before.dev !== after.dev || before.ino !== after.ino) return null; + if (process.platform !== "win32" && (after.mode & 0o077) !== 0) return null; + if (after.size <= 0 || after.size > MAX_CREDENTIAL_BYTES) return null; + const raw = await file.readFile({ encoding: "utf-8" }); + if (Buffer.byteLength(raw) > MAX_CREDENTIAL_BYTES) return null; + const credential = raw.trim(); + if (!/^[A-Za-z0-9_-]+$/.test(credential)) return null; + const decoded = Buffer.from(credential, "base64url"); + if (decoded.length !== 32 || decoded.toString("base64url") !== credential) { + return null; + } + return credential; + } catch { + return null; + } finally { + await file?.close().catch(() => {}); + } +} + +export async function fetchDaemonDiagnostics( + profile: string, + port: number, +): Promise { + const credential = await readOperatorCredential(profile); + if (!credential) return null; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 2_000); + try { + const res = await fetch(`http://127.0.0.1:${port}/diagnostics`, { + headers: { [OPERATOR_CREDENTIAL_HEADER]: credential }, + redirect: "error", + signal: controller.signal, + }); + if (!res.ok) return null; + const payload = await readBoundedDaemonJSON(res, controller.signal); + return isDiagnosticsPayload(payload) ? payload : null; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} diff --git a/apps/desktop/src/main/daemon-health.test.ts b/apps/desktop/src/main/daemon-health.test.ts new file mode 100644 index 00000000000..ed371312c5b --- /dev/null +++ b/apps/desktop/src/main/daemon-health.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchDaemonHealth } from "./daemon-health"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("daemon health", () => { + it("accepts only the public contract and rejects redirects", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('{"status":"running","os":"darwin"}', { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock); + + expect(await fetchDaemonHealth(19514)).toEqual({ status: "running", os: "darwin" }); + expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:19514/health", { + redirect: "error", + signal: expect.any(AbortSignal), + }); + }); + + it("rejects oversized, ambiguous, trailing, and unknown responses", async () => { + for (const response of [ + new Response("{}", { + status: 200, + headers: { "Content-Length": String((1 << 20) + 1) }, + }), + new Response(`{"status":"running","os":"${"x".repeat(1 << 20)}"}`, { + status: 200, + }), + new Response('{"status":"running","status":"starting"}', { status: 200 }), + new Response('{"status":"running"} true', { status: 200 }), + new Response('{"status":"running","pid":123}', { status: 200 }), + new Response('{"status":"running"}', { status: 200 }), + new Response(`${"[".repeat(65)}0${"]".repeat(65)}`, { status: 200 }), + ]) { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response)); + expect(await fetchDaemonHealth(19514)).toBeNull(); + } + }); + + it("keeps the timeout active while reading the response body", async () => { + vi.useFakeTimers(); + try { + const body = new ReadableStream({ start() {} }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(body, { status: 200 })), + ); + + const result = fetchDaemonHealth(19514); + await vi.advanceTimersByTimeAsync(2_001); + await expect(result).resolves.toBeNull(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/desktop/src/main/daemon-health.ts b/apps/desktop/src/main/daemon-health.ts new file mode 100644 index 00000000000..54b138c2bb2 --- /dev/null +++ b/apps/desktop/src/main/daemon-health.ts @@ -0,0 +1,39 @@ +import { readBoundedDaemonJSON } from "./daemon-json"; + +export interface PublicHealthPayload { + status: "running" | "starting"; + os: string; +} + +function isPublicHealthPayload(value: unknown): value is PublicHealthPayload { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const payload = value as Record; + const keys = Object.keys(payload); + return ( + keys.length === 2 && + keys.includes("status") && + keys.includes("os") && + (payload.status === "running" || payload.status === "starting") && + typeof payload.os === "string" + ); +} + +export async function fetchDaemonHealth( + port: number, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 2_000); + try { + const response = await fetch(`http://127.0.0.1:${port}/health`, { + redirect: "error", + signal: controller.signal, + }); + if (!response.ok) return null; + const payload = await readBoundedDaemonJSON(response, controller.signal); + return isPublicHealthPayload(payload) ? payload : null; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} diff --git a/apps/desktop/src/main/daemon-json.ts b/apps/desktop/src/main/daemon-json.ts new file mode 100644 index 00000000000..c3bee39c142 --- /dev/null +++ b/apps/desktop/src/main/daemon-json.ts @@ -0,0 +1,135 @@ +const MAX_DAEMON_RESPONSE_BYTES = 1 << 20; +const MAX_DAEMON_JSON_DEPTH = 64; + +function rejectDuplicateJSONKeys(source: string): void { + let offset = 0; + + function skipWhitespace(): void { + while (/\s/.test(source[offset] ?? "")) offset += 1; + } + + function parseString(): string { + const start = offset; + if (source[offset++] !== '"') throw new SyntaxError("expected string"); + while (offset < source.length) { + const char = source[offset++]; + if (char === '"') return JSON.parse(source.slice(start, offset)) as string; + if (char === "\\") offset += 1; + else if (char < " ") throw new SyntaxError("invalid control character"); + } + throw new SyntaxError("unterminated string"); + } + + function parseValue(depth: number): void { + if (depth > MAX_DAEMON_JSON_DEPTH) { + throw new SyntaxError("daemon JSON nesting limit exceeded"); + } + skipWhitespace(); + const char = source[offset]; + if (char === "{") return parseObject(depth); + if (char === "[") return parseArray(depth); + if (char === '"') { + parseString(); + return; + } + const match = source + .slice(offset) + .match(/^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/); + if (!match) throw new SyntaxError("invalid JSON value"); + offset += match[0].length; + } + + function parseObject(depth: number): void { + offset += 1; + const keys = new Set(); + skipWhitespace(); + if (source[offset] === "}") { + offset += 1; + return; + } + while (true) { + skipWhitespace(); + const key = parseString(); + if (keys.has(key)) throw new SyntaxError("duplicate JSON key"); + keys.add(key); + skipWhitespace(); + if (source[offset++] !== ":") throw new SyntaxError("expected colon"); + parseValue(depth + 1); + skipWhitespace(); + const separator = source[offset++]; + if (separator === "}") return; + if (separator !== ",") throw new SyntaxError("expected object separator"); + } + } + + function parseArray(depth: number): void { + offset += 1; + skipWhitespace(); + if (source[offset] === "]") { + offset += 1; + return; + } + while (true) { + parseValue(depth + 1); + skipWhitespace(); + const separator = source[offset++]; + if (separator === "]") return; + if (separator !== ",") throw new SyntaxError("expected array separator"); + } + } + + parseValue(0); + skipWhitespace(); + if (offset !== source.length) throw new SyntaxError("trailing JSON content"); +} + +export async function readBoundedDaemonJSON( + response: Response, + signal?: AbortSignal, +): Promise { + const declaredLength = response.headers.get("content-length"); + if (declaredLength !== null) { + const parsedLength = Number(declaredLength); + if ( + !Number.isSafeInteger(parsedLength) || + parsedLength < 0 || + parsedLength > MAX_DAEMON_RESPONSE_BYTES + ) { + throw new Error("invalid daemon response content length"); + } + } + if (!response.body) throw new Error("missing daemon response body"); + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + const abort = () => void reader.cancel().catch(() => {}); + signal?.addEventListener("abort", abort, { once: true }); + try { + while (true) { + if (signal?.aborted) throw signal.reason; + const { done, value } = await reader.read(); + if (signal?.aborted) throw signal.reason; + if (done) break; + total += value.byteLength; + if (total > MAX_DAEMON_RESPONSE_BYTES) { + await reader.cancel(); + throw new Error("daemon response exceeds 1 MiB"); + } + chunks.push(value); + } + } finally { + signal?.removeEventListener("abort", abort); + reader.releaseLock(); + } + + const bytes = new Uint8Array(total); + let position = 0; + for (const chunk of chunks) { + bytes.set(chunk, position); + position += chunk.byteLength; + } + const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + rejectDuplicateJSONKeys(source); + return JSON.parse(source) as unknown; +} diff --git a/apps/desktop/src/main/daemon-lifecycle-coordinator.test.ts b/apps/desktop/src/main/daemon-lifecycle-coordinator.test.ts new file mode 100644 index 00000000000..855b4a95841 --- /dev/null +++ b/apps/desktop/src/main/daemon-lifecycle-coordinator.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import { + DaemonLifecycleCoordinator, + daemonStopArgs, +} from "./daemon-lifecycle-coordinator"; + +describe("daemonStopArgs", () => { + it("requires an atomic idle shutdown for automatic restarts", () => { + expect(daemonStopArgs("desktop-host", true)).toEqual([ + "daemon", + "stop", + "--require-idle", + "--profile", + "desktop-host", + ]); + }); + + it("keeps explicit operator stops unconditional", () => { + expect(daemonStopArgs("", false)).toEqual(["daemon", "stop"]); + }); +}); + +describe("DaemonLifecycleCoordinator", () => { + it("serializes automatic and manual lifecycle operations", async () => { + const coordinator = new DaemonLifecycleCoordinator(); + let releaseFirst: (() => void) | undefined; + const first = coordinator.run( + () => + new Promise<{ success: true }>((resolve) => { + releaseFirst = () => resolve({ success: true }); + }), + ); + const blockedOperations = ["polling", "version", "manual"].map(() => { + const operation = vi.fn(async () => ({ success: true as const })); + return { operation, result: coordinator.run(operation) }; + }); + + for (const blocked of blockedOperations) { + await expect(blocked.result).resolves.toEqual({ + success: false, + error: "Another daemon operation is in progress", + }); + expect(blocked.operation).not.toHaveBeenCalled(); + } + + releaseFirst?.(); + await expect(first).resolves.toEqual({ success: true }); + await expect( + coordinator.run(async () => ({ success: true as const })), + ).resolves.toEqual({ success: true }); + }); + + it("releases the lifecycle slot after an operation throws", async () => { + const coordinator = new DaemonLifecycleCoordinator(); + + await expect( + coordinator.run(async () => { + throw new Error("failed"); + }), + ).rejects.toThrow("failed"); + await expect( + coordinator.run(async () => ({ success: true as const })), + ).resolves.toEqual({ success: true }); + }); +}); diff --git a/apps/desktop/src/main/daemon-lifecycle-coordinator.ts b/apps/desktop/src/main/daemon-lifecycle-coordinator.ts new file mode 100644 index 00000000000..666e1737a03 --- /dev/null +++ b/apps/desktop/src/main/daemon-lifecycle-coordinator.ts @@ -0,0 +1,29 @@ +export interface LifecycleResult { + success: boolean; + error?: string; +} + +export function daemonStopArgs(profile: string, requireIdle: boolean): string[] { + return [ + "daemon", + "stop", + ...(requireIdle ? ["--require-idle"] : []), + ...(profile ? ["--profile", profile] : []), + ]; +} + +export class DaemonLifecycleCoordinator { + private inProgress = false; + + async run(operation: () => Promise): Promise { + if (this.inProgress) { + return { success: false, error: "Another daemon operation is in progress" }; + } + this.inProgress = true; + try { + return await operation(); + } finally { + this.inProgress = false; + } + } +} diff --git a/apps/desktop/src/main/daemon-manager.ts b/apps/desktop/src/main/daemon-manager.ts index 195db7aef64..244e596fde1 100644 --- a/apps/desktop/src/main/daemon-manager.ts +++ b/apps/desktop/src/main/daemon-manager.ts @@ -20,6 +20,16 @@ import type { DaemonStatus, DaemonPrefs } from "../shared/daemon-types"; import { daemonStatusAlive } from "../shared/daemon-types"; import { ensureManagedCli, managedCliPath } from "./cli-bootstrap"; import { decideVersionAction } from "./version-decision"; +import { + fetchDaemonDiagnostics, + type DiagnosticsPayload, +} from "./daemon-diagnostics"; +import { fetchDaemonHealth } from "./daemon-health"; +import { decideAutomaticRestart } from "./restart-decision"; +import { + DaemonLifecycleCoordinator, + daemonStopArgs, +} from "./daemon-lifecycle-coordinator"; import { daemonLifecycleUnreachable, isDaemonExternallyManaged, @@ -60,7 +70,7 @@ let statusPollTimer: ReturnType | null = null; let logTailWatcher: { path: string; listener: StatsListener } | null = null; let currentState: DaemonStatus["state"] = "installing_cli"; let getMainWindow: () => BrowserWindow | null = () => null; -let operationInProgress = false; +const lifecycleCoordinator = new DaemonLifecycleCoordinator(); let cachedCliBinary: string | null | undefined = undefined; let cliResolvePromise: Promise | null = null; let cachedCliBinaryVersion: string | null | undefined = undefined; @@ -68,6 +78,8 @@ let cachedCliBinaryVersion: string | null | undefined = undefined; // busy executing tasks. The poll loop retries the check on each tick and // fires the restart once active_task_count drops to 0. let pendingVersionRestart = false; +let pendingCredentialRestart = false; +let automaticRestartInProgress = false; let targetApiBaseUrl: string | null = null; let activeProfile: ActiveProfile | null = null; @@ -163,37 +175,7 @@ function sendStatus(status: DaemonStatus): void { win?.webContents.send("daemon:status", status); } -interface HealthPayload { - status?: string; - pid?: number; - /** Daemon's runtime.GOOS. Absent on daemons older than the #3916 fix. */ - os?: string; - uptime?: string; - daemon_id?: string; - device_name?: string; - server_url?: string; - cli_version?: string; - active_task_count?: number; - agents?: string[]; - workspaces?: unknown[]; -} - -async function fetchHealthAtPort( - port: number, -): Promise { - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 2_000); - const res = await fetch(`http://127.0.0.1:${port}/health`, { - signal: controller.signal, - }); - clearTimeout(timeout); - if (!res.ok) return null; - return (await res.json()) as HealthPayload; - } catch { - return null; - } -} +const fetchHealthAtPort = fetchDaemonHealth; /** * Validates the daemon profile's token against the backend to find out whether @@ -364,12 +346,14 @@ async function fetchHealth(): Promise { normalizeHostOS(process.platform), ); + const diagnostics = await fetchDaemonDiagnostics(active.name, active.port); + // Safety: if we have a target URL and the daemon on our port reports a // different server_url, it's not "our" daemon — drop it and re-resolve. if ( targetApiBaseUrl && - data.server_url && - !urlsMatch(data.server_url, targetApiBaseUrl) + diagnostics?.server_url && + !urlsMatch(diagnostics.server_url, targetApiBaseUrl) ) { invalidateActiveProfile(); return { state: "stopped" }; @@ -377,16 +361,16 @@ async function fetchHealth(): Promise { return { state: "running", - pid: data.pid, - uptime: data.uptime, - daemonId: data.daemon_id, - deviceName: data.device_name, - agents: data.agents ?? [], - workspaceCount: Array.isArray(data.workspaces) - ? data.workspaces.length + pid: diagnostics?.pid, + uptime: diagnostics?.uptime, + daemonId: diagnostics?.daemon_id, + deviceName: diagnostics?.device_name, + agents: diagnostics?.agents ?? [], + workspaceCount: Array.isArray(diagnostics?.workspaces) + ? diagnostics.workspaces.length : 0, profile: active.name, - serverUrl: data.server_url, + serverUrl: diagnostics?.server_url, externallyManaged, }; } @@ -583,8 +567,16 @@ async function ensureRunningDaemonVersionMatches(): Promise< return "ok"; } + if (running?.status !== "running") { + pendingVersionRestart = false; + return "not_running"; + } + const diagnostics: DiagnosticsPayload | null = await fetchDaemonDiagnostics( + active.name, + active.port, + ); const bundled = await getCliBinaryVersion(); - const action = decideVersionAction(bundled, running); + const action = decideVersionAction(bundled, diagnostics); switch (action) { case "not_running": @@ -595,21 +587,26 @@ async function ensureRunningDaemonVersionMatches(): Promise< return "ok"; case "defer": { if (!pendingVersionRestart) { - const activeTasks = running?.active_task_count ?? 0; + const activeTasks = diagnostics?.active_task_count ?? 0; console.log( - `[daemon] CLI version mismatch (bundled=${bundled} running=${running?.cli_version}); deferring restart until ${activeTasks} active task(s) finish`, + `[daemon] CLI version mismatch (bundled=${bundled} running=${diagnostics?.cli_version}); deferring restart until ${activeTasks} active task(s) finish`, ); } pendingVersionRestart = true; return "deferred"; } - case "restart": + case "restart": { console.log( - `[daemon] CLI version mismatch (bundled=${bundled} running=${running?.cli_version}) — restarting daemon`, + `[daemon] CLI version mismatch (bundled=${bundled} running=${diagnostics?.cli_version}) — restarting daemon`, ); pendingVersionRestart = false; - await restartDaemon(); + const restart = await restartDaemon({ requireIdle: true }); + if (!restart.success) { + pendingVersionRestart = true; + return "deferred"; + } return "restarted"; + } } } @@ -712,7 +709,13 @@ async function syncToken( console.log( "[daemon] user switched — restarting daemon with new credentials", ); - void restartDaemon(); + pendingCredentialRestart = true; + void restartDaemon({ requireIdle: true }).then((result) => { + pendingCredentialRestart = !result.success; + if (!result.success) { + console.log(`[daemon] credential restart deferred: ${result.error}`); + } + }); } } catch (err) { console.warn("[daemon] restart-on-user-switch failed:", err); @@ -783,8 +786,9 @@ async function reauthenticate( if (isAuthStatusError(err)) return { ok: false, reason: "session_invalid" }; return { ok: false, reason: "transient", message: errorMessage(err) }; } - const restart = await restartDaemon(); + const restart = await restartDaemon({ requireIdle: true }); if (!restart.success) { + pendingCredentialRestart = true; return { ok: false, reason: "transient", @@ -794,18 +798,6 @@ async function reauthenticate( return { ok: true }; } -async function withGuard(fn: () => Promise): Promise { - if (operationInProgress) { - return { success: false, error: "Another daemon operation is in progress" }; - } - operationInProgress = true; - try { - return await fn(); - } finally { - operationInProgress = false; - } -} - function profileArgs(active: ActiveProfile): string[] { return active.name ? ["--profile", active.name] : []; } @@ -819,7 +811,7 @@ function desktopSpawnEnv(): NodeJS.ProcessEnv { return { ...process.env, MULTICA_LAUNCHED_BY: "desktop" }; } -async function startDaemon(): Promise<{ success: boolean; error?: string }> { +async function startDaemonUnlocked(): Promise<{ success: boolean; error?: string }> { const bin = await resolveCliBinary(); if (!bin) return { success: false, error: "multica CLI is not installed" }; @@ -880,7 +872,9 @@ async function lifecycleBlockedByForeignDaemon(): Promise { ); } -async function stopDaemon(): Promise<{ success: boolean; error?: string }> { +async function stopDaemonUnlocked( + options: { requireIdle?: boolean } = {}, +): Promise<{ success: boolean; error?: string }> { // Central lifecycle guard: a daemon running in an environment we can't drive // (e.g. Linux in WSL2 behind a Windows desktop) can't be stopped by the // native CLI — it would act on the host process namespace and no-op, while @@ -894,36 +888,88 @@ async function stopDaemon(): Promise<{ success: boolean; error?: string }> { if (!bin) return { success: false, error: "multica CLI is not installed" }; const active = await ensureActiveProfile(); + const previousState = currentState; currentState = "stopping"; // An explicit stop is a clean reset — drop any pending auth-failure verdict. authExpired = false; startingSince = null; sendStatus({ state: "stopping" }); - const args = ["daemon", "stop", ...profileArgs(active)]; + const args = daemonStopArgs(active.name, options.requireIdle === true); return new Promise((resolve) => { execFile(bin, args, { timeout: 15_000 }, (err) => { if (err) { + currentState = previousState; + sendStatus({ state: previousState }); resolve({ success: false, error: err.message }); } else { + currentState = "stopped"; + sendStatus({ state: "stopped" }); resolve({ success: true }); } - currentState = "stopped"; - sendStatus({ state: "stopped" }); }); }); } -async function restartDaemon(): Promise<{ success: boolean; error?: string }> { +async function restartDaemonUnlocked( + options: { requireIdle?: boolean } = {}, +): Promise<{ success: boolean; error?: string }> { // Same central, live-preflighted guard as stopDaemon: we can neither stop nor // start a daemon we don't manage, so don't try (user-switch, reauth, // first-workspace, and any future restart caller all route through here). // #3916. if (await lifecycleBlockedByForeignDaemon()) return { success: true }; - const stopResult = await stopDaemon(); + if (options.requireIdle) { + const active = await ensureActiveProfile(); + const health = await fetchHealthAtPort(active.port); + const diagnostics = health + ? await fetchDaemonDiagnostics(active.name, active.port) + : null; + if (decideAutomaticRestart(health, diagnostics) === "defer") { + return { + success: false, + error: "daemon restart deferred until authenticated diagnostics report zero active tasks", + }; + } + } + const stopResult = await stopDaemonUnlocked(options); if (!stopResult.success) return stopResult; - return startDaemon(); + return startDaemonUnlocked(); +} + +function startDaemon(): Promise<{ success: boolean; error?: string }> { + return lifecycleCoordinator.run(startDaemonUnlocked); +} + +function stopDaemon( + options: { requireIdle?: boolean } = {}, +): Promise<{ success: boolean; error?: string }> { + return lifecycleCoordinator.run(() => stopDaemonUnlocked(options)); +} + +function restartDaemon( + options: { requireIdle?: boolean } = {}, +): Promise<{ success: boolean; error?: string }> { + return lifecycleCoordinator.run(() => restartDaemonUnlocked(options)); +} + +async function retryPendingAutomaticRestarts(): Promise { + if (automaticRestartInProgress) return; + automaticRestartInProgress = true; + try { + if (pendingCredentialRestart) { + const result = await restartDaemon({ requireIdle: true }); + pendingCredentialRestart = !result.success; + if (result.success) pendingVersionRestart = false; + return; + } + if (pendingVersionRestart) { + await ensureRunningDaemonVersionMatches(); + } + } finally { + automaticRestartInProgress = false; + } } async function pollOnce(): Promise { @@ -931,8 +977,11 @@ async function pollOnce(): Promise { currentState = status.state; sendStatus(status); // Retry a deferred version-mismatch restart once the daemon drains. - if (pendingVersionRestart && status.state === "running") { - void ensureRunningDaemonVersionMatches(); + if ( + status.state === "running" && + (pendingCredentialRestart || pendingVersionRestart) + ) { + void retryPendingAutomaticRestarts(); } } @@ -1074,9 +1123,9 @@ export function setupDaemonManager( await pollOnce(); } }); - ipcMain.handle("daemon:start", () => withGuard(() => startDaemon())); - ipcMain.handle("daemon:stop", () => withGuard(() => stopDaemon())); - ipcMain.handle("daemon:restart", () => withGuard(() => restartDaemon())); + ipcMain.handle("daemon:start", () => startDaemon()); + ipcMain.handle("daemon:stop", () => stopDaemon()); + ipcMain.handle("daemon:restart", () => restartDaemon()); ipcMain.handle("daemon:get-status", () => fetchHealth()); // The host's OS name, available regardless of daemon state. The Runtimes // page uses it as a fallback identity for "this machine" when no diff --git a/apps/desktop/src/main/restart-decision.test.ts b/apps/desktop/src/main/restart-decision.test.ts new file mode 100644 index 00000000000..12633cd9740 --- /dev/null +++ b/apps/desktop/src/main/restart-decision.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import type { DiagnosticsPayload } from "./daemon-diagnostics"; +import { decideAutomaticRestart } from "./restart-decision"; + +const diagnostics: DiagnosticsPayload = { + status: "running", + os: "darwin", + pid: 123, + uptime: "1m", + daemon_id: "daemon-1", + device_name: "test", + server_url: "https://example.test", + cli_version: "v1.0.0", + active_task_count: 0, + agents: ["codex"], + workspaces: [{ id: "workspace-1", runtimes: ["codex"] }], +}; + +describe("decideAutomaticRestart", () => { + it("fails closed when daemon liveness cannot be proven", () => { + expect(decideAutomaticRestart(null, null)).toBe("defer"); + }); + + it("requires a running daemon and authenticated idle evidence", () => { + expect( + decideAutomaticRestart({ status: "starting", os: "darwin" }, diagnostics), + ).toBe("defer"); + expect( + decideAutomaticRestart({ status: "running", os: "darwin" }, null), + ).toBe("defer"); + expect( + decideAutomaticRestart( + { status: "running", os: "darwin" }, + { ...diagnostics, active_task_count: 1 }, + ), + ).toBe("defer"); + }); + + it("allows an automatic restart only with explicit zero active tasks", () => { + expect( + decideAutomaticRestart( + { status: "running", os: "darwin" }, + diagnostics, + ), + ).toBe("restart"); + }); +}); diff --git a/apps/desktop/src/main/restart-decision.ts b/apps/desktop/src/main/restart-decision.ts new file mode 100644 index 00000000000..977abc56130 --- /dev/null +++ b/apps/desktop/src/main/restart-decision.ts @@ -0,0 +1,14 @@ +import type { DiagnosticsPayload } from "./daemon-diagnostics"; +import type { PublicHealthPayload } from "./daemon-health"; + +export type AutomaticRestartDecision = "restart" | "defer"; + +export function decideAutomaticRestart( + health: PublicHealthPayload | null, + diagnostics: DiagnosticsPayload | null, +): AutomaticRestartDecision { + if (!health) return "defer"; + if (health.status !== "running") return "defer"; + if (!diagnostics || diagnostics.status !== "running") return "defer"; + return diagnostics.active_task_count === 0 ? "restart" : "defer"; +} diff --git a/apps/desktop/src/main/version-decision.test.ts b/apps/desktop/src/main/version-decision.test.ts index cfc109ff370..1ce6f4111b3 100644 --- a/apps/desktop/src/main/version-decision.test.ts +++ b/apps/desktop/src/main/version-decision.test.ts @@ -51,13 +51,13 @@ describe("decideVersionAction", () => { ).toBe("restart"); }); - it("treats missing active_task_count as 0 (old daemon that still reports cli_version)", () => { + it("does not restart when authenticated active_task_count is unavailable", () => { expect( decideVersionAction("v1.2.3", { status: "running", cli_version: "v1.2.2", }), - ).toBe("restart"); + ).toBe("ok"); }); it("returns defer when versions differ but daemon is busy", () => { diff --git a/apps/desktop/src/main/version-decision.ts b/apps/desktop/src/main/version-decision.ts index 9a00294019f..893ca367be9 100644 --- a/apps/desktop/src/main/version-decision.ts +++ b/apps/desktop/src/main/version-decision.ts @@ -31,7 +31,8 @@ export function decideVersionAction( if (!bundled || !runningVersion) return "ok"; if (runningVersion === bundled) return "ok"; - const activeTasks = running.active_task_count ?? 0; + if (typeof running.active_task_count !== "number") return "ok"; + const activeTasks = running.active_task_count; if (activeTasks > 0) return "defer"; return "restart"; } diff --git a/server/cmd/multica/cmd_agent.go b/server/cmd/multica/cmd_agent.go index 6052dd7b2d9..5ae89250b99 100644 --- a/server/cmd/multica/cmd_agent.go +++ b/server/cmd/multica/cmd_agent.go @@ -16,6 +16,7 @@ import ( "github.com/multica-ai/multica/server/internal/cli" "github.com/multica-ai/multica/server/internal/daemon" "github.com/multica-ai/multica/server/internal/daemon/execenv" + "github.com/multica-ai/multica/server/internal/taskauth" ) var agentCmd = &cobra.Command{ @@ -249,6 +250,16 @@ func resolveProfile(cmd *cobra.Command) string { } func newAPIClient(cmd *cobra.Command) (*cli.APIClient, error) { + if authority, ok := taskAuthorityFromContext(cmd); ok { + client := cli.NewAPIClient(authority.ServerURL, authority.WorkspaceID, authority.Token) + client.AgentID = authority.AgentID + client.TaskID = authority.TaskID + return client, nil + } + if managedTaskSignalPresent() { + return nil, fmt.Errorf("managed task authority is required at %s", taskauth.FixedPath) + } + serverURL := resolveServerURL(cmd) workspaceID := resolveWorkspaceID(cmd) token := resolveToken(cmd) @@ -256,20 +267,6 @@ func newAPIClient(cmd *cobra.Command) (*cli.APIClient, error) { if serverURL == "" { return nil, fmt.Errorf("server URL not set: use --server-url flag, MULTICA_SERVER_URL env, or 'multica config set server_url '") } - if inDaemonManagedExecutionContext() && !strings.HasPrefix(token, "mat_") { - // When the ONLY daemon signal is a workdir marker (no MULTICA_AGENT_ID / - // MULTICA_TASK_ID / MULTICA_DAEMON_PORT), the likeliest cause outside a - // real task is a leftover marker from a crashed daemon task in a - // local_directory. Name the exact file so a normal user can recover - // instead of hitting an opaque "requires mat_ token" error. - if !inAgentExecutionContext() && os.Getenv("MULTICA_DAEMON_PORT") == "" { - if markerPath := daemonTaskContextMarkerPath(); markerPath != "" { - return nil, fmt.Errorf("agent execution context requires MULTICA_TOKEN to be a task-scoped mat_ token; detected a daemon task marker at %s — if you are not running inside an agent task this is likely a leftover, remove it and retry", markerPath) - } - } - return nil, fmt.Errorf("agent execution context requires MULTICA_TOKEN to be a task-scoped mat_ token") - } - client := cli.NewAPIClient(serverURL, workspaceID, token) // When running inside a daemon task, attribute actions to the agent. if agentID := os.Getenv("MULTICA_AGENT_ID"); agentID != "" { @@ -379,6 +376,9 @@ func daemonTaskContextMarkerPath() string { } func resolveWorkspaceID(cmd *cobra.Command) string { + if authority, ok := taskAuthorityFromContext(cmd); ok { + return authority.WorkspaceID + } val := cli.FlagOrEnv(cmd, "workspace-id", "MULTICA_WORKSPACE_ID", "") if val != "" { return val diff --git a/server/cmd/multica/cmd_agent_test.go b/server/cmd/multica/cmd_agent_test.go index bfb4eec523f..ef6b9d8ad9d 100644 --- a/server/cmd/multica/cmd_agent_test.go +++ b/server/cmd/multica/cmd_agent_test.go @@ -123,16 +123,15 @@ func TestNewAPIClient_WorkdirParentEscapeFailsClosed(t *testing.T) { } if _, err := newAPIClient(testCmd()); err == nil { t.Fatal("newAPIClient(): expected fail-closed error from workdir-parent escape, got nil") - } else if !strings.Contains(err.Error(), "mat_") { - t.Fatalf("error should demand a task-scoped mat_ token; got %q", err.Error()) + } else if !strings.Contains(err.Error(), "managed task authority") { + t.Fatalf("error should demand managed task authority; got %q", err.Error()) } } -// TestNewAPIClient_LeftoverMarkerActionableError verifies that a stale -// daemon-task marker with no daemon env (the local_directory crash-leftover -// case) fails closed with an actionable message that names the marker file, -// rather than an opaque "requires mat_ token" error. -func TestNewAPIClient_LeftoverMarkerActionableError(t *testing.T) { +// TestNewAPIClient_LeftoverMarkerRequiresAuthority verifies that a stale +// daemon-task marker with no daemon env still fails closed. A marker is only a +// managed-context signal; it cannot replace the daemon-published authority. +func TestNewAPIClient_LeftoverMarkerRequiresAuthority(t *testing.T) { chdirWithDaemonTaskMarker(t) t.Setenv("MULTICA_AGENT_ID", "") t.Setenv("MULTICA_TASK_ID", "") @@ -142,10 +141,8 @@ func TestNewAPIClient_LeftoverMarkerActionableError(t *testing.T) { if _, err := newAPIClient(testCmd()); err == nil { t.Fatal("newAPIClient(): expected error for leftover daemon-task marker, got nil") - } else if !strings.Contains(err.Error(), execenv.TaskContextMarkerRelPath) { - t.Fatalf("error should name the marker path; got %q", err.Error()) - } else if !strings.Contains(err.Error(), "leftover") { - t.Fatalf("error should hint it may be a leftover; got %q", err.Error()) + } else if !strings.Contains(err.Error(), "managed task authority") { + t.Fatalf("error should demand managed task authority; got %q", err.Error()) } } @@ -436,8 +433,8 @@ func TestNewAPIClient_AgentContextRequiresTaskToken(t *testing.T) { if err == nil { t.Fatal("newAPIClient(): expected error without task token") } - if !strings.Contains(err.Error(), "mat_ token") { - t.Fatalf("newAPIClient() error = %q, want mat_ token guidance", err.Error()) + if !strings.Contains(err.Error(), "managed task authority") { + t.Fatalf("newAPIClient() error = %q, want authority guidance", err.Error()) } }) @@ -448,15 +445,17 @@ func TestNewAPIClient_AgentContextRequiresTaskToken(t *testing.T) { if err == nil { t.Fatal("newAPIClient(): expected error with member token") } - if !strings.Contains(err.Error(), "mat_ token") { - t.Fatalf("newAPIClient() error = %q, want mat_ token guidance", err.Error()) + if !strings.Contains(err.Error(), "managed task authority") { + t.Fatalf("newAPIClient() error = %q, want authority guidance", err.Error()) } }) - t.Run("task token succeeds", func(t *testing.T) { + t.Run("task authority succeeds", func(t *testing.T) { t.Setenv("MULTICA_TOKEN", "mat_task_token") - client, err := newAPIClient(testCmd()) + cmd := testCmd() + bindTaskScopeTestAuthority(cmd) + client, err := newAPIClient(cmd) if err != nil { t.Fatalf("newAPIClient(): %v", err) } @@ -483,8 +482,8 @@ func TestNewAPIClient_DaemonPortRequiresTaskToken(t *testing.T) { if err == nil { t.Fatal("newAPIClient(): expected error without task token") } - if !strings.Contains(err.Error(), "mat_ token") { - t.Fatalf("newAPIClient() error = %q, want mat_ token guidance", err.Error()) + if !strings.Contains(err.Error(), "managed task authority") { + t.Fatalf("newAPIClient() error = %q, want authority guidance", err.Error()) } } @@ -505,8 +504,8 @@ func TestNewAPIClient_WorkdirMarkerRequiresTaskToken(t *testing.T) { if err == nil { t.Fatal("newAPIClient(): expected error without task token") } - if !strings.Contains(err.Error(), "mat_ token") { - t.Fatalf("newAPIClient() error = %q, want mat_ token guidance", err.Error()) + if !strings.Contains(err.Error(), "managed task authority") { + t.Fatalf("newAPIClient() error = %q, want authority guidance", err.Error()) } } diff --git a/server/cmd/multica/cmd_attachment_test.go b/server/cmd/multica/cmd_attachment_test.go index 7448f0f677c..3d42e0d0ac4 100644 --- a/server/cmd/multica/cmd_attachment_test.go +++ b/server/cmd/multica/cmd_attachment_test.go @@ -107,9 +107,6 @@ func TestRunAttachmentUploadSendsTaskIDAndPrintsContract(t *testing.T) { })) defer srv.Close() setCLITestServerEnv(t, srv.URL) - // An agent upload always carries a task-scoped mat_ token; set one so the - // daemon-managed-context gate in newAPIClient admits the request. - t.Setenv("MULTICA_TOKEN", "mat_test-token") dir := t.TempDir() imgPath := filepath.Join(dir, "chart.png") @@ -161,7 +158,6 @@ func TestRunAttachmentUploadNonImageUsesFileCardMarkdown(t *testing.T) { })) defer srv.Close() setCLITestServerEnv(t, srv.URL) - t.Setenv("MULTICA_TOKEN", "mat_test-token") dir := t.TempDir() docPath := filepath.Join(dir, "report.pdf") @@ -206,7 +202,6 @@ func TestRunAttachmentUploadEscapesFilename(t *testing.T) { })) defer srv.Close() setCLITestServerEnv(t, srv.URL) - t.Setenv("MULTICA_TOKEN", "mat_test-token") dir := t.TempDir() docPath := filepath.Join(dir, "a]b.pdf") @@ -234,7 +229,6 @@ func TestRunAttachmentUploadRequiresTask(t *testing.T) { })) defer srv.Close() setCLITestServerEnv(t, srv.URL) - t.Setenv("MULTICA_TOKEN", "mat_test-token") dir := t.TempDir() imgPath := filepath.Join(dir, "chart.png") diff --git a/server/cmd/multica/cmd_auth.go b/server/cmd/multica/cmd_auth.go index e930089d066..355d29ad362 100644 --- a/server/cmd/multica/cmd_auth.go +++ b/server/cmd/multica/cmd_auth.go @@ -71,6 +71,9 @@ func init() { } func resolveToken(cmd *cobra.Command) string { + if authority, ok := taskAuthorityFromContext(cmd); ok { + return authority.Token + } if v := strings.TrimSpace(os.Getenv("MULTICA_TOKEN")); v != "" { return v } diff --git a/server/cmd/multica/cmd_daemon.go b/server/cmd/multica/cmd_daemon.go index abeee1fb69f..a067653baf9 100644 --- a/server/cmd/multica/cmd_daemon.go +++ b/server/cmd/multica/cmd_daemon.go @@ -1,7 +1,9 @@ package main import ( + "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -11,10 +13,12 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "sort" "strconv" "strings" "time" + "unicode/utf8" "github.com/spf13/cobra" lumberjack "gopkg.in/natefinch/lumberjack.v2" @@ -97,6 +101,7 @@ func init() { daemonLogsCmd.Flags().IntP("lines", "n", 50, "Number of lines to show") daemonStatusCmd.Flags().String("output", "table", "Output format: table or json") + daemonStopCmd.Flags().Bool("require-idle", false, "Stop only if no task or claim is in flight") // restart shares all the same flags as start rf := daemonRestartCmd.Flags() @@ -143,6 +148,10 @@ func daemonPIDPathForProfile(profile string) string { return filepath.Join(daemonDirForProfile(profile), "daemon.pid") } +func daemonShutdownCredentialPathForProfile(profile string) string { + return filepath.Join(daemonDirForProfile(profile), daemon.ShutdownCredentialFileName) +} + func daemonLogPathForProfile(profile string) string { return filepath.Join(daemonDirForProfile(profile), "daemon.log") } @@ -257,8 +266,7 @@ func runDaemonBackground(cmd *cobra.Command) error { if profile != "" { label = fmt.Sprintf("daemon [%s]", profile) } - pid, _ := health["pid"].(float64) - return fmt.Errorf("%s is already running (pid %v). Use 'daemon restart' to restart it", label, int(pid)) + return fmt.Errorf("%s is already running. Use 'daemon restart' to restart it", label) } // Resolve current executable so the foreground child reuses this binary. @@ -599,24 +607,28 @@ func runDaemonRestart(cmd *cobra.Command, args []string) error { defer cancel() health := checkDaemonHealthOnPort(ctx, healthPort) if daemonAlive(health) { - pid, _ := health["pid"].(float64) - if pid > 0 { + credential, err := readDaemonShutdownCredential(profile) + if err != nil { + return fmt.Errorf("load daemon shutdown credential: %w", err) + } + diagnostics, _ := requestDaemonDiagnostics(ctx, healthPort, credential) + if pid, ok := diagnostics["pid"].(float64); ok && pid > 0 { fmt.Fprintf(os.Stderr, "Stopping daemon (pid %d)...\n", int(pid)) - if err := requestDaemonShutdown(healthPort); err != nil { - if p, perr := os.FindProcess(int(pid)); perr == nil { - _ = p.Kill() - } - } - // Wait until the port is fully released (not merely past "running"), - // otherwise the fresh start below races the old daemon's listener. - for i := 0; i < 10; i++ { - time.Sleep(500 * time.Millisecond) - sctx, scancel := context.WithTimeout(context.Background(), 1*time.Second) - h := checkDaemonHealthOnPort(sctx, healthPort) - scancel() - if !daemonAlive(h) { - break - } + } else { + fmt.Fprintln(os.Stderr, "Stopping daemon...") + } + if err := requestDaemonShutdown(healthPort, credential, false); err != nil { + return fmt.Errorf("request daemon shutdown: %w", err) + } + // Wait until the port is fully released (not merely past "running"), + // otherwise the fresh start below races the old daemon's listener. + for i := 0; i < 10; i++ { + time.Sleep(500 * time.Millisecond) + sctx, scancel := context.WithTimeout(context.Background(), 1*time.Second) + h := checkDaemonHealthOnPort(sctx, healthPort) + scancel() + if !daemonAlive(h) { + break } } } @@ -644,30 +656,30 @@ func runDaemonStop(cmd *cobra.Command, _ []string) error { return nil } - pid, ok := health["pid"].(float64) - if !ok || pid == 0 { - return fmt.Errorf("could not determine daemon PID from health endpoint") - } - - process, err := os.FindProcess(int(pid)) - if err != nil { - return fmt.Errorf("find process %d: %w", int(pid), err) - } - // Request graceful shutdown via the daemon's HTTP /shutdown endpoint // rather than an OS signal. On Windows the daemon is spawned with // DETACHED_PROCESS so it shares no console with us, which means // GenerateConsoleCtrlEvent can't reach it; HTTP works on both // platforms and triggers the same context-cancel path the daemon // already uses for self-restart. - if err := requestDaemonShutdown(healthPort); err != nil { - fmt.Fprintf(os.Stderr, "Graceful shutdown request failed: %v — falling back to forced kill.\n", err) - if kerr := process.Kill(); kerr != nil { - return fmt.Errorf("kill daemon (pid %d): %w", int(pid), kerr) - } + credential, err := readDaemonShutdownCredential(profile) + if err != nil { + return fmt.Errorf("load daemon shutdown credential: %w", err) + } + diagnostics, _ := requestDaemonDiagnostics(ctx, healthPort, credential) + requireIdle, err := cmd.Flags().GetBool("require-idle") + if err != nil { + return fmt.Errorf("read require-idle flag: %w", err) + } + if err := requestDaemonShutdown(healthPort, credential, requireIdle); err != nil { + return fmt.Errorf("request daemon shutdown: %w", err) } - fmt.Fprintf(os.Stderr, "Stopping daemon (pid %d)...\n", int(pid)) + if pid, ok := diagnostics["pid"].(float64); ok && pid > 0 { + fmt.Fprintf(os.Stderr, "Stopping daemon (pid %d)...\n", int(pid)) + } else { + fmt.Fprintln(os.Stderr, "Stopping daemon...") + } // Poll health endpoint until daemon is gone. for i := 0; i < 10; i++ { @@ -689,13 +701,25 @@ func runDaemonStop(cmd *cobra.Command, _ []string) error { // requestDaemonShutdown POSTs to the daemon's /shutdown endpoint to ask it // to exit gracefully. Returns an error if the request could not be delivered // (network error, non-2xx status, or the endpoint predates this change). -func requestDaemonShutdown(healthPort int) error { +func requestDaemonShutdown(healthPort int, credential string, requireIdle bool) error { + if strings.TrimSpace(credential) == "" { + return errors.New("shutdown credential is required") + } url := fmt.Sprintf("http://127.0.0.1:%d/shutdown", healthPort) req, err := http.NewRequest(http.MethodPost, url, nil) if err != nil { return err } - client := &http.Client{Timeout: 2 * time.Second} + req.Header.Set(daemon.ShutdownCredentialHeader, credential) + if requireIdle { + req.Header.Set(daemon.ShutdownRequireIdleHeader, "true") + } + client := &http.Client{ + Timeout: 2 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } resp, err := client.Do(req) if err != nil { return err @@ -707,6 +731,274 @@ func requestDaemonShutdown(healthPort int) error { return nil } +func requestDaemonDiagnostics(ctx context.Context, healthPort int, credential string) (map[string]any, error) { + if strings.TrimSpace(credential) == "" { + return nil, errors.New("operator credential is required") + } + url := fmt.Sprintf("http://127.0.0.1:%d/diagnostics", healthPort) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set(daemon.ShutdownCredentialHeader, credential) + client := &http.Client{ + Timeout: 2 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("unexpected status %d", resp.StatusCode) + } + var payload daemonDiagnosticsPayload + if err := decodeStrictDaemonJSON(resp.Body, &payload); err != nil { + return nil, fmt.Errorf("decode daemon diagnostics: %w", err) + } + if err := payload.validate(); err != nil { + return nil, fmt.Errorf("decode daemon diagnostics: %w", err) + } + return payload.asMap(), nil +} + +const ( + maxDaemonResponseBytes = 1 << 20 + maxDaemonJSONDepth = 64 + maxJSONSafeInteger = int64(1<<53 - 1) +) + +type daemonHealthPayload struct { + Status *string `json:"status"` + OS *string `json:"os"` +} + +type daemonDiagnosticsWorkspace struct { + ID *string `json:"id"` + Runtimes *[]string `json:"runtimes"` +} + +type daemonDiagnosticsPayload struct { + Status *string `json:"status"` + PID *int64 `json:"pid"` + OS *string `json:"os"` + Uptime *string `json:"uptime"` + DaemonID *string `json:"daemon_id"` + DeviceName *string `json:"device_name"` + ServerURL *string `json:"server_url"` + CLIVersion *string `json:"cli_version"` + ActiveTaskCount *int64 `json:"active_task_count"` + Agents *[]string `json:"agents"` + Workspaces *[]daemonDiagnosticsWorkspace `json:"workspaces"` +} + +func decodeStrictDaemonJSON(body io.Reader, target any) error { + data, err := io.ReadAll(io.LimitReader(body, maxDaemonResponseBytes+1)) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + if len(data) > maxDaemonResponseBytes { + return errors.New("response exceeds 1 MiB") + } + if !utf8.Valid(data) { + return errors.New("response is not valid UTF-8") + } + if err := validateDaemonJSONStructure(data); err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := requireJSONEOF(decoder); err != nil { + return err + } + return nil +} + +func validateDaemonJSONStructure(data []byte) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := validateDaemonJSONValue(decoder, 0); err != nil { + return err + } + return requireJSONEOF(decoder) +} + +func validateDaemonJSONValue(decoder *json.Decoder, depth int) error { + if depth > maxDaemonJSONDepth { + return errors.New("JSON nesting limit exceeded") + } + token, err := decoder.Token() + if err != nil { + return err + } + delim, ok := token.(json.Delim) + if !ok { + return nil + } + switch delim { + case '{': + keys := make(map[string]struct{}) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return errors.New("expected object key") + } + if _, exists := keys[key]; exists { + return fmt.Errorf("duplicate JSON key %q", key) + } + keys[key] = struct{}{} + if err := validateDaemonJSONValue(decoder, depth+1); err != nil { + return err + } + } + end, err := decoder.Token() + if err != nil { + return err + } + if end != json.Delim('}') { + return errors.New("expected object terminator") + } + case '[': + for decoder.More() { + if err := validateDaemonJSONValue(decoder, depth+1); err != nil { + return err + } + } + end, err := decoder.Token() + if err != nil { + return err + } + if end != json.Delim(']') { + return errors.New("expected array terminator") + } + default: + return fmt.Errorf("unexpected JSON delimiter %q", delim) + } + return nil +} + +func requireJSONEOF(decoder *json.Decoder) error { + if _, err := decoder.Token(); err != io.EOF { + if err == nil { + return errors.New("trailing JSON content") + } + return err + } + return nil +} + +func (payload daemonHealthPayload) validate() error { + if payload.Status == nil || payload.OS == nil { + return errors.New("health response is missing required fields") + } + if *payload.Status != "running" && *payload.Status != "starting" { + return errors.New("health response has invalid status") + } + return nil +} + +func (payload daemonDiagnosticsPayload) validate() error { + if payload.Status == nil || payload.PID == nil || payload.OS == nil || + payload.Uptime == nil || payload.DaemonID == nil || payload.DeviceName == nil || + payload.ServerURL == nil || payload.CLIVersion == nil || + payload.ActiveTaskCount == nil || payload.Agents == nil || payload.Workspaces == nil { + return errors.New("diagnostics response is missing required fields") + } + if *payload.Status != "running" && *payload.Status != "starting" { + return errors.New("diagnostics response has invalid status") + } + if *payload.PID <= 0 || *payload.PID > maxJSONSafeInteger || + *payload.ActiveTaskCount < 0 || *payload.ActiveTaskCount > maxJSONSafeInteger { + return errors.New("diagnostics response has invalid numeric fields") + } + for _, workspace := range *payload.Workspaces { + if workspace.ID == nil || workspace.Runtimes == nil { + return errors.New("diagnostics workspace is missing required fields") + } + } + return nil +} + +func (payload daemonDiagnosticsPayload) asMap() map[string]any { + agents := make([]any, len(*payload.Agents)) + for i, agent := range *payload.Agents { + agents[i] = agent + } + workspaces := make([]any, len(*payload.Workspaces)) + for i, workspace := range *payload.Workspaces { + runtimes := make([]any, len(*workspace.Runtimes)) + for j, runtimeName := range *workspace.Runtimes { + runtimes[j] = runtimeName + } + workspaces[i] = map[string]any{"id": *workspace.ID, "runtimes": runtimes} + } + return map[string]any{ + "status": *payload.Status, "pid": float64(*payload.PID), "os": *payload.OS, + "uptime": *payload.Uptime, "daemon_id": *payload.DaemonID, + "device_name": *payload.DeviceName, "server_url": *payload.ServerURL, + "cli_version": *payload.CLIVersion, "active_task_count": float64(*payload.ActiveTaskCount), + "agents": agents, "workspaces": workspaces, + } +} + +func readDaemonShutdownCredential(profile string) (string, error) { + path := daemonShutdownCredentialPathForProfile(profile) + pathInfo, err := os.Lstat(path) + if err != nil { + return "", fmt.Errorf("inspect %s: %w", path, err) + } + if !pathInfo.Mode().IsRegular() || pathInfo.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("%s is not a regular credential file", path) + } + if runtime.GOOS != "windows" && pathInfo.Mode().Perm()&0o077 != 0 { + return "", fmt.Errorf("%s permissions are not operator-only", path) + } + if pathInfo.Size() <= 0 || pathInfo.Size() > 256 { + return "", fmt.Errorf("%s has invalid size", path) + } + file, err := openDaemonCredentialFile(path) + if err != nil { + return "", fmt.Errorf("open %s: %w", path, err) + } + defer file.Close() + openedInfo, err := file.Stat() + if err != nil { + return "", fmt.Errorf("inspect opened %s: %w", path, err) + } + if !openedInfo.Mode().IsRegular() || !os.SameFile(pathInfo, openedInfo) { + return "", fmt.Errorf("%s identity changed while opening", path) + } + if runtime.GOOS != "windows" && openedInfo.Mode().Perm()&0o077 != 0 { + return "", fmt.Errorf("%s permissions are not operator-only", path) + } + if openedInfo.Size() <= 0 || openedInfo.Size() > 256 { + return "", fmt.Errorf("%s has invalid size", path) + } + data, err := io.ReadAll(io.LimitReader(file, 257)) + if err != nil { + return "", fmt.Errorf("read %s: %w", path, err) + } + if len(data) > 256 { + return "", fmt.Errorf("%s has invalid size", path) + } + credential := strings.TrimSpace(string(data)) + raw, err := base64.RawURLEncoding.DecodeString(credential) + if err != nil || len(raw) != 32 { + return "", fmt.Errorf("%s contains an invalid credential", path) + } + return credential, nil +} + // --- daemon status --- func runDaemonStatus(cmd *cobra.Command, _ []string) error { @@ -717,10 +1009,21 @@ func runDaemonStatus(cmd *cobra.Command, _ []string) error { defer cancel() health := checkDaemonHealthOnPort(ctx, healthPort) + diagnostics := health + if daemonAlive(health) { + credential, err := readDaemonShutdownCredential(profile) + if err != nil { + return fmt.Errorf("load daemon diagnostics credential: %w", err) + } + diagnostics, err = requestDaemonDiagnostics(ctx, healthPort, credential) + if err != nil { + return fmt.Errorf("request daemon diagnostics: %w", err) + } + } output, _ := cmd.Flags().GetString("output") if output == "json" { - return cli.PrintJSON(os.Stdout, health) + return cli.PrintJSON(os.Stdout, diagnostics) } label := "Daemon" @@ -730,9 +1033,13 @@ func runDaemonStatus(cmd *cobra.Command, _ []string) error { switch health["status"] { case "running": - printDaemonStatusReport(os.Stdout, label, health) + printDaemonStatusReport(os.Stdout, label, diagnostics) case "starting": - fmt.Fprintf(os.Stdout, "%s: starting (pid %v)\n", label, health["pid"]) + if pid, ok := diagnostics["pid"].(float64); ok && pid > 0 { + fmt.Fprintf(os.Stdout, "%s: starting (pid %v)\n", label, pid) + } else { + fmt.Fprintf(os.Stdout, "%s: starting\n", label) + } default: fmt.Fprintf(os.Stdout, "%s: stopped\n", label) } @@ -809,18 +1116,29 @@ func checkDaemonHealthOnPort(ctx context.Context, port int) map[string]any { return map[string]any{"status": "stopped"} } - httpClient := &http.Client{Timeout: 2 * time.Second} + httpClient := &http.Client{ + Timeout: 2 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } resp, err := httpClient.Do(req) if err != nil { return map[string]any{"status": "stopped"} } defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return map[string]any{"status": "stopped"} + } - var result map[string]any - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + var payload daemonHealthPayload + if err := decodeStrictDaemonJSON(resp.Body, &payload); err != nil { + return map[string]any{"status": "stopped"} + } + if err := payload.validate(); err != nil { return map[string]any{"status": "stopped"} } - return result + return map[string]any{"status": *payload.Status, "os": *payload.OS} } // flagString returns a string flag value or empty string. diff --git a/server/cmd/multica/cmd_daemon_credential_unix.go b/server/cmd/multica/cmd_daemon_credential_unix.go new file mode 100644 index 00000000000..3b154abd153 --- /dev/null +++ b/server/cmd/multica/cmd_daemon_credential_unix.go @@ -0,0 +1,23 @@ +//go:build !windows + +package main + +import ( + "fmt" + "os" + + "golang.org/x/sys/unix" +) + +func openDaemonCredentialFile(path string) (*os.File, error) { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(fd), path) + if file == nil { + _ = unix.Close(fd) + return nil, fmt.Errorf("adopt credential descriptor") + } + return file, nil +} diff --git a/server/cmd/multica/cmd_daemon_credential_windows.go b/server/cmd/multica/cmd_daemon_credential_windows.go new file mode 100644 index 00000000000..3805bfe8a68 --- /dev/null +++ b/server/cmd/multica/cmd_daemon_credential_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package main + +import "os" + +func openDaemonCredentialFile(path string) (*os.File, error) { + return os.Open(path) +} diff --git a/server/cmd/multica/cmd_daemon_test.go b/server/cmd/multica/cmd_daemon_test.go index 53efbfa15a5..3c6753fae75 100644 --- a/server/cmd/multica/cmd_daemon_test.go +++ b/server/cmd/multica/cmd_daemon_test.go @@ -2,8 +2,14 @@ package main import ( "bytes" + "context" + "net" + "net/http" + "net/http/httptest" "os" "path/filepath" + "runtime" + "strconv" "strings" "testing" "time" @@ -12,6 +18,371 @@ import ( "github.com/spf13/cobra" ) +func TestRequestDaemonShutdownSendsOperatorCredential(t *testing.T) { + t.Parallel() + + const credential = "operator-shutdown-credential" + requests := make(chan *http.Request, 1) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- r.Clone(r.Context()) + w.WriteHeader(http.StatusOK) + })) + server.Listener = listener + server.Start() + defer server.Close() + + port, err := strconv.Atoi(strings.TrimPrefix(server.URL, "http://127.0.0.1:")) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } + if err := requestDaemonShutdown(port, credential, false); err != nil { + t.Fatalf("request shutdown: %v", err) + } + + request := <-requests + if request.Method != http.MethodPost { + t.Fatalf("method = %q, want POST", request.Method) + } + if got := request.Header.Get(daemon.ShutdownCredentialHeader); got != credential { + t.Fatalf("shutdown credential header = %q, want %q", got, credential) + } +} + +func TestRequestDaemonShutdownDoesNotForwardCredentialAcrossRedirect(t *testing.T) { + t.Parallel() + + const credential = "operator-shutdown-credential" + redirected := make(chan string, 1) + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + redirected <- r.Header.Get(daemon.ShutdownCredentialHeader) + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect) + })) + server.Listener = listener + server.Start() + defer server.Close() + + port, err := strconv.Atoi(strings.TrimPrefix(server.URL, "http://127.0.0.1:")) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } + if err := requestDaemonShutdown(port, credential, false); err == nil { + t.Fatal("redirected shutdown request unexpectedly succeeded") + } + select { + case got := <-redirected: + t.Fatalf("shutdown redirect reached target with credential %q", got) + default: + } +} + +func TestRequestDaemonShutdownRejectsMissingCredential(t *testing.T) { + t.Parallel() + + if err := requestDaemonShutdown(1, " \n\t", false); err == nil { + t.Fatal("shutdown request without a credential unexpectedly succeeded") + } +} + +func TestRequestDaemonShutdownCanRequireIdle(t *testing.T) { + t.Parallel() + + const credential = "operator-shutdown-credential" + requestHeaders := make(chan http.Header, 1) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestHeaders <- r.Header.Clone() + w.WriteHeader(http.StatusOK) + })) + server.Listener = listener + server.Start() + defer server.Close() + + port, err := strconv.Atoi(strings.TrimPrefix(server.URL, "http://127.0.0.1:")) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } + if err := requestDaemonShutdown(port, credential, true); err != nil { + t.Fatalf("request shutdown: %v", err) + } + if got := (<-requestHeaders).Get(daemon.ShutdownRequireIdleHeader); got != "true" { + t.Fatalf("require-idle header = %q, want true", got) + } +} + +func TestRequestDaemonDiagnosticsSendsOperatorCredential(t *testing.T) { + t.Parallel() + + const credential = "operator-diagnostics-credential" + requests := make(chan *http.Request, 1) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- r.Clone(r.Context()) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(validDaemonDiagnosticsJSON())) + })) + server.Listener = listener + server.Start() + defer server.Close() + + port, err := strconv.Atoi(strings.TrimPrefix(server.URL, "http://127.0.0.1:")) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } + got, err := requestDaemonDiagnostics(context.Background(), port, credential) + if err != nil { + t.Fatalf("request diagnostics: %v", err) + } + if got["pid"] != float64(1234) || got["cli_version"] != "v9.9.9" { + t.Fatalf("diagnostics = %#v", got) + } + request := <-requests + if request.Method != http.MethodGet || request.URL.Path != "/diagnostics" { + t.Fatalf("request = %s %s, want GET /diagnostics", request.Method, request.URL.Path) + } + if got := request.Header.Get(daemon.ShutdownCredentialHeader); got != credential { + t.Fatalf("operator credential header = %q, want %q", got, credential) + } +} + +func TestRequestDaemonDiagnosticsRejectsMissingCredentialAndRedirects(t *testing.T) { + t.Parallel() + + if _, err := requestDaemonDiagnostics(context.Background(), 1, " \n\t"); err == nil { + t.Fatal("diagnostics request without a credential unexpectedly succeeded") + } + + const credential = "operator-diagnostics-credential" + redirected := make(chan string, 1) + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + redirected <- r.Header.Get(daemon.ShutdownCredentialHeader) + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect) + })) + server.Listener = listener + server.Start() + defer server.Close() + port, err := strconv.Atoi(strings.TrimPrefix(server.URL, "http://127.0.0.1:")) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } + if _, err := requestDaemonDiagnostics(context.Background(), port, credential); err == nil { + t.Fatal("redirected diagnostics request unexpectedly succeeded") + } + select { + case got := <-redirected: + t.Fatalf("diagnostics redirect reached target with credential %q", got) + default: + } +} + +func TestRequestDaemonDiagnosticsRejectsInvalidAndOversizedBodies(t *testing.T) { + t.Parallel() + + for name, body := range map[string]string{ + "malformed": `{`, + "trailing": validDaemonDiagnosticsJSON() + ` {"extra":true}`, + "null": `null`, + "oversized": `{"padding":"` + strings.Repeat("x", (1<<20)+1) + `"}`, + "duplicate top level": strings.Replace(validDaemonDiagnosticsJSON(), `"status":"running"`, `"status":"running","status":"starting"`, 1), + "duplicate nested": strings.Replace(validDaemonDiagnosticsJSON(), `"id":"ws-1"`, `"id":"ws-1","id":"ws-2"`, 1), + "unknown field": strings.Replace(validDaemonDiagnosticsJSON(), `"status":"running"`, `"status":"running","extra":true`, 1), + "missing field": strings.Replace(validDaemonDiagnosticsJSON(), `"os":"darwin",`, ``, 1), + "wrong type": strings.Replace(validDaemonDiagnosticsJSON(), `"pid":1234`, `"pid":"1234"`, 1), + "negative tasks": strings.Replace(validDaemonDiagnosticsJSON(), `"active_task_count":0`, `"active_task_count":-1`, 1), + "extreme pid": strings.Replace(validDaemonDiagnosticsJSON(), `"pid":1234`, `"pid":9007199254740992`, 1), + "deep nesting": `[` + strings.Repeat(`[`, maxDaemonJSONDepth) + `0` + strings.Repeat(`]`, maxDaemonJSONDepth) + `]`, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) + server.Listener = listener + server.Start() + defer server.Close() + + port, err := strconv.Atoi(strings.TrimPrefix(server.URL, "http://127.0.0.1:")) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } + if _, err := requestDaemonDiagnostics(context.Background(), port, "operator-credential"); err == nil { + t.Fatalf("diagnostics body %q unexpectedly succeeded", name) + } + }) + } +} + +func TestCheckDaemonHealthOnPortRequiresExactBoundedContract(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + status int + body string + }{ + "valid": {http.StatusOK, `{"status":"running","os":"darwin"}`}, + "non-2xx": {http.StatusInternalServerError, `{"status":"running","os":"darwin"}`}, + "duplicate": {http.StatusOK, `{"status":"running","status":"starting","os":"darwin"}`}, + "unknown": {http.StatusOK, `{"status":"running","os":"darwin","pid":1}`}, + "missing": {http.StatusOK, `{"status":"running"}`}, + "trailing": {http.StatusOK, `{"status":"running","os":"darwin"} true`}, + "invalid status": {http.StatusOK, `{"status":"stopped","os":"darwin"}`}, + "oversized": {http.StatusOK, strings.Repeat(" ", maxDaemonResponseBytes+1)}, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(test.status) + _, _ = w.Write([]byte(test.body)) + })) + server.Listener = listener + server.Start() + defer server.Close() + port, err := strconv.Atoi(strings.TrimPrefix(server.URL, "http://127.0.0.1:")) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } + got := checkDaemonHealthOnPort(context.Background(), port) + if name == "valid" { + if got["status"] != "running" || got["os"] != "darwin" { + t.Fatalf("health = %#v, want exact running payload", got) + } + } else if got["status"] != "stopped" { + t.Fatalf("health = %#v, want stopped", got) + } + }) + } +} + +func TestCheckDaemonHealthOnPortRejectsRedirect(t *testing.T) { + t.Parallel() + targetReached := make(chan struct{}, 1) + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + targetReached <- struct{}{} + _, _ = w.Write([]byte(`{"status":"running","os":"darwin"}`)) + })) + defer target.Close() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect) + })) + server.Listener = listener + server.Start() + defer server.Close() + port, err := strconv.Atoi(strings.TrimPrefix(server.URL, "http://127.0.0.1:")) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } + if got := checkDaemonHealthOnPort(context.Background(), port); got["status"] != "stopped" { + t.Fatalf("redirect health = %#v, want stopped", got) + } + select { + case <-targetReached: + t.Fatal("health redirect unexpectedly reached target") + default: + } +} + +func validDaemonDiagnosticsJSON() string { + return `{"status":"running","pid":1234,"os":"darwin","uptime":"1m","daemon_id":"daemon-1","device_name":"test","server_url":"https://example.test","cli_version":"v9.9.9","active_task_count":0,"agents":["codex"],"workspaces":[{"id":"ws-1","runtimes":["codex"]}]}` +} + +func TestReadDaemonShutdownCredentialValidatesOperatorFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + const profile = "test-profile" + const credential = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY" + path := daemonShutdownCredentialPathForProfile(profile) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("create profile directory: %v", err) + } + if err := os.WriteFile(path, []byte(credential+"\n"), 0o600); err != nil { + t.Fatalf("write shutdown credential: %v", err) + } + + got, err := readDaemonShutdownCredential(profile) + if err != nil { + t.Fatalf("read shutdown credential: %v", err) + } + if got != credential { + t.Fatalf("credential = %q, want %q", got, credential) + } + + if err := os.WriteFile(path, []byte("not-base64\n"), 0o600); err != nil { + t.Fatalf("replace shutdown credential: %v", err) + } + if _, err := readDaemonShutdownCredential(profile); err == nil { + t.Fatal("malformed shutdown credential unexpectedly accepted") + } + + if runtime.GOOS != "windows" { + if err := os.WriteFile(path, []byte(credential+"\n"), 0o644); err != nil { + t.Fatalf("replace shutdown credential with broad permissions: %v", err) + } + if err := os.Chmod(path, 0o644); err != nil { + t.Fatalf("broaden shutdown credential permissions: %v", err) + } + if _, err := readDaemonShutdownCredential(profile); err == nil { + t.Fatal("non-operator-only shutdown credential unexpectedly accepted") + } + + if err := os.Chmod(path, 0o600); err != nil { + t.Fatalf("restore shutdown credential permissions: %v", err) + } + link := filepath.Join(filepath.Dir(path), "credential-link") + if err := os.Symlink(path, link); err != nil { + t.Fatalf("create shutdown credential symlink: %v", err) + } + if file, err := openDaemonCredentialFile(link); err == nil { + _ = file.Close() + t.Fatal("no-follow credential open unexpectedly accepted a symlink") + } + } +} + // TestDaemonAlive locks in the liveness predicate the lifecycle commands rely // on: both a ready ("running") and a still-booting ("starting") daemon count as // alive, so `daemon start` won't double-spawn over a starting daemon and diff --git a/server/cmd/multica/cmd_issue.go b/server/cmd/multica/cmd_issue.go index 33369c32d16..9b70244d318 100644 --- a/server/cmd/multica/cmd_issue.go +++ b/server/cmd/multica/cmd_issue.go @@ -503,6 +503,7 @@ func init() { // issue assign issueAssignCmd.Flags().String("to", "", "Assignee name (member, agent, or squad; fuzzy match)") issueAssignCmd.Flags().String("to-id", "", "Assignee UUID — member, agent, or squad (mutually exclusive with --to)") + issueAssignCmd.Flags().String("to-type", "", "Assignee type for --to-id: member, agent, or squad (required in task-scoped execution)") issueAssignCmd.Flags().Bool("unassign", false, "Remove current assignee") issueAssignCmd.Flags().String("output", "json", "Output format: table or json") @@ -809,8 +810,7 @@ func runIssueGet(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") if output == "table" { - actors := loadActorDisplayLookup(ctx, client) - assignee := formatAssignee(issue, actors) + assignee := taskSafeAssigneeDisplay(cmd, ctx, client, issue) startDate := strVal(issue, "start_date") if startDate != "" && len(startDate) >= 10 { startDate = startDate[:10] @@ -1350,15 +1350,34 @@ func runIssueUpdate(cmd *cobra.Command, args []string) error { func runIssueAssign(cmd *cobra.Command, args []string) error { toName, _ := cmd.Flags().GetString("to") + toID, _ := cmd.Flags().GetString("to-id") + toType, _ := cmd.Flags().GetString("to-type") unassign, _ := cmd.Flags().GetBool("unassign") toNameSet := cmd.Flags().Changed("to") toIDSet := cmd.Flags().Changed("to-id") + toTypeSet := cmd.Flags().Changed("to-type") if !toNameSet && !toIDSet && !unassign { return fmt.Errorf("provide --to , --to-id , or --unassign") } - if (toNameSet || toIDSet) && unassign { - return fmt.Errorf("--to/--to-id and --unassign are mutually exclusive") + if (toNameSet || toIDSet || toTypeSet) && unassign { + return fmt.Errorf("--to/--to-id/--to-type and --unassign are mutually exclusive") + } + if toNameSet && toTypeSet { + return fmt.Errorf("--to and --to-type are mutually exclusive") + } + if inTaskScopedCLIContext(cmd) && !unassign { + if toNameSet || !toIDSet || !toTypeSet { + return fmt.Errorf("task-scoped issue assign requires --to-id and --to-type member|agent|squad") + } + toID = strings.ToLower(strings.TrimSpace(toID)) + toType = strings.ToLower(strings.TrimSpace(toType)) + if !uuidRegexp.MatchString(toID) { + return fmt.Errorf("task-scoped --to-id must be a canonical UUID") + } + if !isTaskScopedAssigneeType(toType) { + return fmt.Errorf("task-scoped --to-type must be member, agent, or squad") + } } client, err := newAPIClient(cmd) @@ -1379,6 +1398,25 @@ func runIssueAssign(cmd *cobra.Command, args []string) error { if unassign { body["assignee_type"] = nil body["assignee_id"] = nil + } else if inTaskScopedCLIContext(cmd) { + body["assignee_type"] = toType + body["assignee_id"] = toID + displayTarget = toType + ":" + toID + } else if toTypeSet { + if !toIDSet { + return fmt.Errorf("--to-type requires --to-id") + } + toID = strings.ToLower(strings.TrimSpace(toID)) + toType = strings.ToLower(strings.TrimSpace(toType)) + if !uuidRegexp.MatchString(toID) { + return fmt.Errorf("--to-id must be a canonical UUID") + } + if !isTaskScopedAssigneeType(toType) { + return fmt.Errorf("--to-type must be member, agent, or squad") + } + body["assignee_type"] = toType + body["assignee_id"] = toID + displayTarget = toType + ":" + toID } else { aType, aID, _, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "to", "to-id", issueAssigneeKinds) if resolveErr != nil { @@ -1876,7 +1914,10 @@ func runIssueCommentList(cmd *cobra.Command, args []string) error { return cli.PrintJSON(os.Stdout, comments) } - actors := loadActorDisplayLookup(ctx, client) + var actors actorDisplayLookup + if !inTaskScopedCLIContext(cmd) { + actors = loadActorDisplayLookup(ctx, client) + } headers := []string{"ID", "PARENT", "AUTHOR", "TYPE", "CONTENT", "CREATED"} rows := make([][]string, 0, len(comments)) for _, c := range comments { @@ -1893,10 +1934,16 @@ func runIssueCommentList(cmd *cobra.Command, args []string) error { if parentID == "" { parentID = "—" } + authorType := strVal(c, "author_type") + authorID := strVal(c, "author_id") + author := authorType + ":" + authorID + if !inTaskScopedCLIContext(cmd) { + author = actors.actor(authorType, authorID) + } rows = append(rows, []string{ strVal(c, "id"), parentID, - actors.actor(strVal(c, "author_type"), strVal(c, "author_id")), + author, strVal(c, "type"), content, created, @@ -1907,6 +1954,12 @@ func runIssueCommentList(cmd *cobra.Command, args []string) error { } func runIssueCommentAdd(cmd *cobra.Command, args []string) error { + attachments, _ := cmd.Flags().GetStringSlice("attachment") + allowExternal, _ := cmd.Flags().GetBool("allow-external-file") + if inTaskScopedCLIContext(cmd) && (len(attachments) > 0 || allowExternal) { + return fmt.Errorf("task-scoped issue comment add prohibits attachment upload and --allow-external-file") + } + content, hasContent, err := resolveTextFlag(cmd, "content") if err != nil { return err @@ -1922,7 +1975,6 @@ func runIssueCommentAdd(cmd *cobra.Command, args []string) error { // Use a longer timeout when attachments are present (file uploads can be slow). timeout := cli.APITimeout() - attachments, _ := cmd.Flags().GetStringSlice("attachment") if len(attachments) > 0 { timeout = cli.AtLeastAPITimeout(60 * time.Second) } @@ -2059,7 +2111,10 @@ func runIssueRuns(cmd *cobra.Command, args []string) error { return cli.PrintJSON(os.Stdout, runs) } - actors := loadActorDisplayLookup(ctx, client) + var actors actorDisplayLookup + if !inTaskScopedCLIContext(cmd) { + actors = loadActorDisplayLookup(ctx, client) + } fullID, _ := cmd.Flags().GetBool("full-id") headers := []string{"ID", "AGENT", "STATUS", "STARTED", "COMPLETED", "ERROR"} rows := make([][]string, 0, len(runs)) @@ -2077,9 +2132,14 @@ func runIssueRuns(cmd *cobra.Command, args []string) error { runes := []rune(errMsg) errMsg = string(runes[:47]) + "..." } + agentID := strVal(r, "agent_id") + agent := agentID + if !inTaskScopedCLIContext(cmd) { + agent = actors.agent(agentID) + } rows = append(rows, []string{ displayID(strVal(r, "id"), fullID), - actors.agent(strVal(r, "agent_id")), + agent, strVal(r, "status"), started, completed, @@ -2218,7 +2278,10 @@ func runIssueRerun(cmd *cobra.Command, args []string) error { if output == "json" { return cli.PrintJSON(os.Stdout, task) } - agent := loadActorDisplayLookup(ctx, client).agent(strVal(task, "agent_id")) + agent := strVal(task, "agent_id") + if !inTaskScopedCLIContext(cmd) { + agent = loadActorDisplayLookup(ctx, client).agent(agent) + } fmt.Fprintf(os.Stdout, "Re-enqueued task %s on agent %s\n", strVal(task, "id"), agent) return nil } @@ -2760,6 +2823,27 @@ func formatAssignee(issue map[string]any, actors actorDisplayLookup) string { return actors.actor(aType, aID) } +func taskSafeAssigneeDisplay(cmd *cobra.Command, ctx context.Context, client *cli.APIClient, issue map[string]any) string { + aType := strVal(issue, "assignee_type") + aID := strVal(issue, "assignee_id") + if aType == "" || aID == "" { + return "" + } + if inTaskScopedCLIContext(cmd) { + return aType + ":" + aID + } + return formatAssignee(issue, loadActorDisplayLookup(ctx, client)) +} + +func isTaskScopedAssigneeType(value string) bool { + switch value { + case "member", "agent", "squad": + return true + default: + return false + } +} + func truncateID(id string) string { if utf8.RuneCountInString(id) > 8 { runes := []rune(id) diff --git a/server/cmd/multica/cmd_issue_test.go b/server/cmd/multica/cmd_issue_test.go index f39889cfc7b..37ae9cc5afd 100644 --- a/server/cmd/multica/cmd_issue_test.go +++ b/server/cmd/multica/cmd_issue_test.go @@ -388,9 +388,6 @@ func TestRunIssueCommentAddRejectsExternalAttachmentWithZeroUploads(t *testing.T })) defer srv.Close() setCLITestServerEnv(t, srv.URL) - // mat_ prefix clears the daemon-managed execution-context guard both in CI - // and when the suite runs inside an agent task (leftover daemon marker). - t.Setenv("MULTICA_TOKEN", "mat_test-token") // A valid attachment inside the workdir, FOLLOWED BY an external one. t.Chdir(t.TempDir()) diff --git a/server/cmd/multica/cmd_repo.go b/server/cmd/multica/cmd_repo.go index a396be74a2e..5df35b51c9c 100644 --- a/server/cmd/multica/cmd_repo.go +++ b/server/cmd/multica/cmd_repo.go @@ -56,6 +56,8 @@ var repoCheckoutCmd = &cobra.Command{ var repoCheckoutRef string +const repoCheckoutCapabilityHeader = "X-Multica-Repo-Checkout-Capability" + func init() { repoListCmd.Flags().String("output", "table", "Output format: table or json") @@ -338,24 +340,14 @@ func runRepoCheckout(cmd *cobra.Command, args []string) error { if daemonPort == "" { return fmt.Errorf("MULTICA_DAEMON_PORT not set (this command is intended to be run by an agent inside a daemon task)") } - - workspaceID := os.Getenv("MULTICA_WORKSPACE_ID") - agentName := os.Getenv("MULTICA_AGENT_NAME") - taskID := os.Getenv("MULTICA_TASK_ID") - - // Use current working directory as the checkout target. - workDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("get working directory: %w", err) + capability := strings.TrimSpace(os.Getenv("MULTICA_REPO_CHECKOUT_TOKEN")) + if capability == "" { + return fmt.Errorf("MULTICA_REPO_CHECKOUT_TOKEN not set (checkout is limited to repos assigned to this daemon task)") } reqBody := map[string]string{ - "url": repoURL, - "workspace_id": workspaceID, - "workdir": workDir, - "ref": repoCheckoutRef, - "agent_name": agentName, - "task_id": taskID, + "url": repoURL, + "ref": repoCheckoutRef, } data, err := json.Marshal(reqBody) @@ -364,11 +356,13 @@ func runRepoCheckout(cmd *cobra.Command, args []string) error { } client := &http.Client{Timeout: 5 * time.Minute} - resp, err := client.Post( - fmt.Sprintf("http://127.0.0.1:%s/repo/checkout", daemonPort), - "application/json", - bytes.NewReader(data), - ) + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://127.0.0.1:%s/repo/checkout", daemonPort), bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("create checkout request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set(repoCheckoutCapabilityHeader, capability) + resp, err := client.Do(req) if err != nil { return fmt.Errorf("connect to daemon: %w", err) } diff --git a/server/cmd/multica/cmd_repo_test.go b/server/cmd/multica/cmd_repo_test.go index f4fcc726b2e..009a94b24e8 100644 --- a/server/cmd/multica/cmd_repo_test.go +++ b/server/cmd/multica/cmd_repo_test.go @@ -2,14 +2,77 @@ package main import ( "encoding/json" + "io" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "github.com/spf13/cobra" ) +func TestRunRepoCheckoutUsesOnlyTaskCapabilityAndRepoBinding(t *testing.T) { + const capability = "task-bound-checkout-capability" + const repoURL = "https://github.com/org/repo.git" + + var gotHeader http.Header + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Clone() + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode checkout body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"path":"/tmp/work/repo","branch_name":"agent/test"}`) + })) + defer srv.Close() + + parsed, err := url.Parse(srv.URL) + if err != nil { + t.Fatalf("parse test server URL: %v", err) + } + _, port, ok := strings.Cut(parsed.Host, ":") + if !ok { + t.Fatalf("test server address has no port: %s", parsed.Host) + } + t.Setenv("MULTICA_DAEMON_PORT", port) + t.Setenv("MULTICA_REPO_CHECKOUT_TOKEN", capability) + + previousRef := repoCheckoutRef + repoCheckoutRef = "release/v2" + t.Cleanup(func() { repoCheckoutRef = previousRef }) + + if err := runRepoCheckout(nil, []string{repoURL}); err != nil { + t.Fatalf("runRepoCheckout: %v", err) + } + if got := gotHeader.Get(repoCheckoutCapabilityHeader); got != capability { + t.Fatalf("checkout capability header = %q, want %q", got, capability) + } + if got := gotHeader.Get("Content-Type"); got != "application/json" { + t.Fatalf("Content-Type = %q, want application/json", got) + } + wantBody := map[string]any{"url": repoURL, "ref": "release/v2"} + if len(gotBody) != len(wantBody) || gotBody["url"] != wantBody["url"] || gotBody["ref"] != wantBody["ref"] { + t.Fatalf("checkout body = %#v, want only %#v", gotBody, wantBody) + } + for _, forbidden := range []string{"workspace_id", "task_id", "agent_id", "workdir"} { + if _, ok := gotBody[forbidden]; ok { + t.Fatalf("checkout body contains caller-controlled %q: %#v", forbidden, gotBody) + } + } +} + +func TestRunRepoCheckoutRequiresTaskCapabilityBeforeNetwork(t *testing.T) { + t.Setenv("MULTICA_DAEMON_PORT", "1") + t.Setenv("MULTICA_REPO_CHECKOUT_TOKEN", "") + + err := runRepoCheckout(nil, []string{"https://github.com/org/repo.git"}) + if err == nil || !strings.Contains(err.Error(), "MULTICA_REPO_CHECKOUT_TOKEN not set") { + t.Fatalf("runRepoCheckout error = %v, want missing capability failure", err) + } +} + func newRepoRegistryTestCmd(serverURL string) *cobra.Command { cmd := &cobra.Command{Use: "repo-test"} cmd.Flags().String("server-url", "", "") diff --git a/server/cmd/multica/main.go b/server/cmd/multica/main.go index 7491fd18da7..e0a474561ae 100644 --- a/server/cmd/multica/main.go +++ b/server/cmd/multica/main.go @@ -1,13 +1,17 @@ package main import ( + "context" + "errors" "fmt" "os" "runtime" + "strings" "github.com/spf13/cobra" "github.com/multica-ai/multica/server/internal/cli" + "github.com/multica-ai/multica/server/internal/taskauth" ) var ( @@ -22,11 +26,102 @@ var ( var debugFlag bool var rootCmd = &cobra.Command{ - Use: "multica", - Short: "Multica CLI — local agent runtime and management tool", - Long: "Work seamlessly with Multica from the command line.", - SilenceUsage: true, - SilenceErrors: true, + Use: "multica", + Short: "Multica CLI — local agent runtime and management tool", + Long: "Work seamlessly with Multica from the command line.", + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRunE: enforceTaskScopedCLI, +} + +var taskScopedCLICommands = map[string]struct{}{ + "multica issue get": {}, + "multica issue comment list": {}, + "multica issue comment add": {}, + "multica issue status": {}, + "multica issue run-messages": {}, + "multica repo checkout": {}, +} + +func enforceTaskScopedCLI(cmd *cobra.Command, _ []string) error { + authority, present := taskAuthorityFromContext(cmd) + if !present { + loaded, err := taskauth.Load(taskauth.FixedPath) + if err != nil { + if managedTaskSignalPresent() || !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("managed task authority unavailable: %w", err) + } + return nil + } + authority = loaded + cmd.SetContext(context.WithValue(cmd.Context(), taskAuthorityContextKey{}, authority)) + } + if err := validateTaskAuthorityCompatibility(authority); err != nil { + return err + } + + if !inTaskScopedCLIContext(cmd) { + return nil + } + + for _, flagName := range []string{"profile", "server-url", "workspace-id"} { + flag := cmd.Root().PersistentFlags().Lookup(flagName) + if flag != nil && flag.Changed { + return fmt.Errorf("task-scoped CLI context prohibits --%s overrides", flagName) + } + } + + if _, allowed := taskScopedCLICommands[cmd.CommandPath()]; !allowed { + return fmt.Errorf("task-scoped CLI context permits only bounded issue commands; %q is prohibited", cmd.CommandPath()) + } + return nil +} + +type taskAuthorityContextKey struct{} + +func taskAuthorityFromContext(cmd *cobra.Command) (taskauth.Authority, bool) { + if cmd == nil || cmd.Context() == nil { + return taskauth.Authority{}, false + } + authority, ok := cmd.Context().Value(taskAuthorityContextKey{}).(taskauth.Authority) + return authority, ok +} + +func withTaskAuthority(cmd *cobra.Command, authority taskauth.Authority) { + parent := cmd.Context() + if parent == nil { + parent = context.Background() + } + cmd.SetContext(context.WithValue(parent, taskAuthorityContextKey{}, authority)) +} + +func inTaskScopedCLIContext(cmd *cobra.Command) bool { + _, ok := taskAuthorityFromContext(cmd) + return ok +} + +func managedTaskSignalPresent() bool { + return strings.HasPrefix(strings.TrimSpace(os.Getenv("MULTICA_TOKEN")), "mat_") || inDaemonManagedExecutionContext() +} + +func validateTaskAuthorityCompatibility(authority taskauth.Authority) error { + values := []struct { + name string + got string + want string + }{ + {name: "MULTICA_SERVER_URL", got: strings.TrimRight(strings.TrimSpace(os.Getenv("MULTICA_SERVER_URL")), "/"), want: authority.ServerURL}, + {name: "MULTICA_WORKSPACE_ID", got: strings.TrimSpace(os.Getenv("MULTICA_WORKSPACE_ID")), want: authority.WorkspaceID}, + {name: "MULTICA_TOKEN", got: strings.TrimSpace(os.Getenv("MULTICA_TOKEN")), want: authority.Token}, + {name: "MULTICA_TASK_ID", got: strings.TrimSpace(os.Getenv("MULTICA_TASK_ID")), want: authority.TaskID}, + {name: "MULTICA_AGENT_ID", got: strings.TrimSpace(os.Getenv("MULTICA_AGENT_ID")), want: authority.AgentID}, + } + for _, value := range values { + if value.got != "" && value.got != value.want { + return fmt.Errorf("managed task authority mismatch for %s", value.name) + } + } + return nil } func init() { diff --git a/server/cmd/multica/task_scope_test.go b/server/cmd/multica/task_scope_test.go new file mode 100644 index 00000000000..f588bd5ad10 --- /dev/null +++ b/server/cmd/multica/task_scope_test.go @@ -0,0 +1,615 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/spf13/cobra" + + "github.com/multica-ai/multica/server/internal/daemon/execenv" + "github.com/multica-ai/multica/server/internal/taskauth" +) + +func bindTaskScopeTestAuthority(cmd *cobra.Command) { + valueOr := func(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + return fallback + } + withTaskAuthority(cmd, taskauth.Authority{ + ManagedBy: taskauth.ManagedBy, + Version: taskauth.Version, + ServerURL: strings.TrimRight(valueOr("MULTICA_SERVER_URL", "https://api.example.test"), "/"), + WorkspaceID: valueOr("MULTICA_WORKSPACE_ID", taskScopedCLIWorkspaceID), + Token: valueOr("MULTICA_TOKEN", "mat_contract_test"), + TaskID: valueOr("MULTICA_TASK_ID", taskScopedCLITaskID), + AgentID: valueOr("MULTICA_AGENT_ID", taskScopedCLIAgentID), + }) +} + +func newTaskScopeTestCommand(path []string, ran *bool) (*cobra.Command, error) { + root := &cobra.Command{ + Use: "multica", + SilenceErrors: true, + SilenceUsage: true, + PersistentPreRunE: enforceTaskScopedCLI, + } + root.PersistentFlags().String("server-url", "", "") + root.PersistentFlags().String("workspace-id", "", "") + root.PersistentFlags().String("profile", "", "") + + parent := root + for i, name := range path { + command := &cobra.Command{Use: name} + if i == len(path)-1 { + command.RunE = func(*cobra.Command, []string) error { + *ran = true + return nil + } + } + parent.AddCommand(command) + parent = command + } + if len(path) == 0 { + root.RunE = func(*cobra.Command, []string) error { + *ran = true + return nil + } + } + return root, nil +} + +func executeTaskScopeTestCommand(t *testing.T, path []string, extraArgs ...string) (bool, error) { + t.Helper() + ran := false + root, err := newTaskScopeTestCommand(path, &ran) + if err != nil { + t.Fatalf("new command: %v", err) + } + root.SetArgs(append(extraArgs, path...)) + if managedTaskSignalPresent() { + bindTaskScopeTestAuthority(root) + } + err = root.Execute() + return ran, err +} + +func clearTaskScopeContext(t *testing.T) { + t.Helper() + for _, key := range []string{ + "MULTICA_AGENT_ID", + "MULTICA_TASK_ID", + "MULTICA_TOKEN", + "MULTICA_DAEMON_PORT", + } { + t.Setenv(key, "") + } +} + +func TestTaskScopedCLIAllowsOnlyBoundedIssueCommands(t *testing.T) { + clearTaskScopeContext(t) + t.Setenv("MULTICA_TOKEN", "mat_task_token") + + allowed := [][]string{ + {"issue", "get"}, + {"issue", "comment", "list"}, + {"issue", "comment", "add"}, + {"issue", "status"}, + {"issue", "run-messages"}, + {"repo", "checkout"}, + } + for _, path := range allowed { + path := path + t.Run(strings.Join(path, "_"), func(t *testing.T) { + ran, err := executeTaskScopeTestCommand(t, path) + if err != nil { + t.Fatalf("allowed command %q returned error: %v", strings.Join(path, " "), err) + } + if !ran { + t.Fatalf("allowed command %q did not execute", strings.Join(path, " ")) + } + }) + } + + blocked := [][]string{ + {"issue", "list"}, + {"issue", "create"}, + {"issue", "update"}, + {"issue", "assign"}, + {"issue", "rerun"}, + {"issue", "runs"}, + {"issue", "comment", "delete"}, + {"workspace", "update"}, + {"project", "update"}, + {"repo", "list"}, + {"repo", "add"}, + {"repo", "remove"}, + {"config", "set"}, + {"auth", "status"}, + {"login"}, + {"setup"}, + {"runtime", "list"}, + {"daemon", "start"}, + {"autopilot", "restore"}, + {"agent", "update"}, + {"skill", "create"}, + {"squad", "create"}, + } + for _, path := range blocked { + path := path + t.Run("blocked_"+strings.Join(path, "_"), func(t *testing.T) { + ran, err := executeTaskScopeTestCommand(t, path) + if err == nil { + t.Fatalf("blocked command %q returned no error", strings.Join(path, " ")) + } + if ran { + t.Fatalf("blocked command %q reached RunE", strings.Join(path, " ")) + } + if !strings.Contains(err.Error(), "task-scoped CLI context") { + t.Fatalf("error = %q, want task-scope explanation", err) + } + }) + } +} + +func TestTaskScopedCLIRejectsRootConfigurationOverrides(t *testing.T) { + for _, flag := range []string{"--profile", "--server-url", "--workspace-id"} { + flag := flag + t.Run(strings.TrimPrefix(flag, "--"), func(t *testing.T) { + clearTaskScopeContext(t) + t.Setenv("MULTICA_TASK_ID", "task-123") + + ran, err := executeTaskScopeTestCommand(t, []string{"issue", "get"}, flag, "override") + if err == nil { + t.Fatalf("%s override returned no error", flag) + } + if ran { + t.Fatalf("%s override reached RunE", flag) + } + if !strings.Contains(err.Error(), flag) { + t.Fatalf("error = %q, want rejected flag %q", err, flag) + } + }) + } +} + +func TestTaskScopedCLIRecognizesEveryManagedContextSignal(t *testing.T) { + contexts := []struct { + name string + setup func(*testing.T) + }{ + {name: "task token", setup: func(t *testing.T) { t.Setenv("MULTICA_TOKEN", "mat_token") }}, + {name: "agent id", setup: func(t *testing.T) { t.Setenv("MULTICA_AGENT_ID", "agent-123") }}, + {name: "task id", setup: func(t *testing.T) { t.Setenv("MULTICA_TASK_ID", "task-123") }}, + {name: "daemon port", setup: func(t *testing.T) { t.Setenv("MULTICA_DAEMON_PORT", "27182") }}, + {name: "task marker", setup: func(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, execenv.TaskContextMarkerRelPath) + if err := os.MkdirAll(filepath.Dir(marker), 0o755); err != nil { + t.Fatalf("mkdir marker parent: %v", err) + } + body := fmt.Sprintf(`{"managed_by":%q}`, execenv.TaskContextMarkerManagedBy) + if err := os.WriteFile(marker, []byte(body), 0o644); err != nil { + t.Fatalf("write marker: %v", err) + } + t.Chdir(dir) + }}, + } + + for _, tc := range contexts { + t.Run(tc.name, func(t *testing.T) { + clearTaskScopeContext(t) + tc.setup(t) + ran, err := executeTaskScopeTestCommand(t, []string{"workspace", "update"}) + if err == nil { + t.Fatal("workspace update returned no error") + } + if ran { + t.Fatal("workspace update reached RunE") + } + }) + } +} + +func TestTaskScopedCLILeavesHumanContextUnchanged(t *testing.T) { + clearTaskScopeContext(t) + + ran, err := executeTaskScopeTestCommand(t, []string{"workspace", "update"}, "--profile", "human") + if err != nil { + t.Fatalf("human command returned error: %v", err) + } + if !ran { + t.Fatal("human command did not execute") + } +} + +const ( + taskScopedCLIWorkspaceID = "10000000-0000-0000-0000-000000000001" + taskScopedCLIIssueID = "10000000-0000-0000-0000-000000000002" + taskScopedCLITaskID = "10000000-0000-0000-0000-000000000003" + taskScopedCLIAgentID = "10000000-0000-0000-0000-000000000004" + taskScopedCLICommentID = "10000000-0000-0000-0000-000000000005" +) + +type taskScopedCLIRequest struct { + Method string + Path string + Body map[string]any +} + +type taskScopedCLIRecorder struct { + mu sync.Mutex + requests []taskScopedCLIRequest +} + +func (r *taskScopedCLIRecorder) append(req taskScopedCLIRequest) { + r.mu.Lock() + defer r.mu.Unlock() + r.requests = append(r.requests, req) +} + +func (r *taskScopedCLIRecorder) snapshot() []taskScopedCLIRequest { + r.mu.Lock() + defer r.mu.Unlock() + return append([]taskScopedCLIRequest(nil), r.requests...) +} + +func newTaskScopedCLIContractServer(t *testing.T) (*httptest.Server, *taskScopedCLIRecorder) { + t.Helper() + recorder := &taskScopedCLIRecorder{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + var body map[string]any + if req.Body != nil && req.Method != http.MethodGet { + decoder := json.NewDecoder(req.Body) + if err := decoder.Decode(&body); err != nil { + t.Errorf("decode %s %s body: %v", req.Method, req.URL.RequestURI(), err) + w.WriteHeader(http.StatusBadRequest) + return + } + } + recorder.append(taskScopedCLIRequest{Method: req.Method, Path: req.URL.RequestURI(), Body: body}) + w.Header().Set("Content-Type", "application/json") + + switch { + case req.Method == http.MethodGet && req.URL.Path == "/api/issues/"+taskScopedCLIIssueID: + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": taskScopedCLIIssueID, "identifier": "ATH-75", "title": "Bound issue", + "status": "in_progress", "priority": "high", "assignee_type": "agent", + "assignee_id": taskScopedCLIAgentID, + }) + case req.Method == http.MethodGet && req.URL.Path == "/api/issues/"+taskScopedCLIIssueID+"/comments": + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "id": taskScopedCLICommentID, "author_type": "agent", "author_id": taskScopedCLIAgentID, + "type": "comment", "content": "bounded", "created_at": "2026-07-15T01:02:03Z", + }}) + case req.Method == http.MethodPost && req.URL.Path == "/api/issues/"+taskScopedCLIIssueID+"/comments": + _ = json.NewEncoder(w).Encode(map[string]any{"id": taskScopedCLICommentID, "content": body["content"]}) + case req.Method == http.MethodPut && req.URL.Path == "/api/issues/"+taskScopedCLIIssueID: + result := map[string]any{"id": taskScopedCLIIssueID, "identifier": "ATH-75"} + for key, value := range body { + result[key] = value + } + _ = json.NewEncoder(w).Encode(result) + case req.Method == http.MethodGet && req.URL.Path == "/api/issues/"+taskScopedCLIIssueID+"/task-runs": + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "id": taskScopedCLITaskID, "agent_id": taskScopedCLIAgentID, "status": "completed", + }}) + case req.Method == http.MethodPost && req.URL.Path == "/api/issues/"+taskScopedCLIIssueID+"/rerun": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": taskScopedCLITaskID, "agent_id": taskScopedCLIAgentID, "status": "queued", + }) + case req.Method == http.MethodGet && req.URL.Path == "/api/tasks/"+taskScopedCLITaskID+"/messages": + _ = json.NewEncoder(w).Encode([]map[string]any{{"seq": 1, "type": "assistant", "content": "done"}}) + case req.Method == http.MethodGet && req.URL.Path == "/api/agents": + _ = json.NewEncoder(w).Encode([]map[string]any{{"id": taskScopedCLIAgentID, "name": "Human-visible agent"}}) + case req.Method == http.MethodGet && req.URL.Path == "/api/workspaces/"+taskScopedCLIWorkspaceID+"/members": + _ = json.NewEncoder(w).Encode([]map[string]any{}) + case req.Method == http.MethodGet && req.URL.Path == "/api/squads": + _ = json.NewEncoder(w).Encode([]map[string]any{}) + default: + t.Errorf("unexpected request: %s %s", req.Method, req.URL.RequestURI()) + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"unexpected_request"}`)) + } + })) + return server, recorder +} + +func setTaskScopedCLIContractEnv(t *testing.T, serverURL string) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("MULTICA_SERVER_URL", serverURL) + t.Setenv("MULTICA_WORKSPACE_ID", taskScopedCLIWorkspaceID) + t.Setenv("MULTICA_TOKEN", "mat_contract_test") + t.Setenv("MULTICA_TASK_ID", taskScopedCLITaskID) + t.Setenv("MULTICA_AGENT_ID", taskScopedCLIAgentID) + t.Setenv("MULTICA_DAEMON_PORT", "27182") +} + +func assertTaskScopedCLIRequests(t *testing.T, recorder *taskScopedCLIRecorder, expected ...string) { + t.Helper() + requests := recorder.snapshot() + actual := make([]string, 0, len(requests)) + for _, request := range requests { + actual = append(actual, request.Method+" "+request.Path) + if strings.HasPrefix(request.Path, "/api/workspaces") || + strings.HasPrefix(request.Path, "/api/projects") || + strings.HasPrefix(request.Path, "/api/repos") || + strings.HasPrefix(request.Path, "/api/agents") || + strings.HasPrefix(request.Path, "/api/runtimes") || + strings.HasPrefix(request.Path, "/api/autopilots") || + strings.HasPrefix(request.Path, "/api/skills") || + strings.HasPrefix(request.Path, "/api/squads") { + t.Fatalf("task-scoped CLI performed management lookup: %s %s", request.Method, request.Path) + } + } + if strings.Join(actual, "\n") != strings.Join(expected, "\n") { + t.Fatalf("requests:\n%s\nwant:\n%s", strings.Join(actual, "\n"), strings.Join(expected, "\n")) + } +} + +func newTaskScopedIssueGetCmd() *cobra.Command { + cmd := &cobra.Command{Use: "get"} + cmd.Flags().String("output", "table", "") + bindTaskScopeTestAuthority(cmd) + return cmd +} + +func newTaskScopedIssueCommentListCmd() *cobra.Command { + cmd := &cobra.Command{Use: "list"} + cmd.Flags().String("output", "table", "") + cmd.Flags().String("since", "", "") + cmd.Flags().String("thread", "", "") + cmd.Flags().Int("tail", 0, "") + cmd.Flags().Int("recent", 0, "") + cmd.Flags().Bool("roots-only", false, "") + cmd.Flags().Bool("summary", false, "") + cmd.Flags().Bool("full", false, "") + cmd.Flags().String("before", "", "") + cmd.Flags().String("before-id", "", "") + bindTaskScopeTestAuthority(cmd) + return cmd +} + +func newTaskScopedIssueAssignCmd() *cobra.Command { + cmd := &cobra.Command{Use: "assign"} + cmd.Flags().String("to", "", "") + cmd.Flags().String("to-id", "", "") + cmd.Flags().String("to-type", "", "") + cmd.Flags().Bool("unassign", false, "") + cmd.Flags().String("output", "json", "") + bindTaskScopeTestAuthority(cmd) + return cmd +} + +func newTaskScopedIssueStatusCmd() *cobra.Command { + cmd := &cobra.Command{Use: "status"} + cmd.Flags().String("output", "json", "") + bindTaskScopeTestAuthority(cmd) + return cmd +} + +func newTaskScopedIssueRunsCmd() *cobra.Command { + cmd := &cobra.Command{Use: "runs"} + cmd.Flags().String("output", "table", "") + cmd.Flags().Bool("full-id", false, "") + bindTaskScopeTestAuthority(cmd) + return cmd +} + +func newTaskScopedIssueRerunCmd() *cobra.Command { + cmd := &cobra.Command{Use: "rerun"} + cmd.Flags().String("output", "table", "") + bindTaskScopeTestAuthority(cmd) + return cmd +} + +func newTaskScopedIssueRunMessagesCmd() *cobra.Command { + cmd := &cobra.Command{Use: "run-messages"} + cmd.Flags().String("output", "table", "") + cmd.Flags().Int("since", 0, "") + cmd.Flags().String("issue", "", "") + bindTaskScopeTestAuthority(cmd) + return cmd +} + +func TestTaskScopedIssueCLIUsesOnlyBoundIssueAPIs(t *testing.T) { + tests := []struct { + name string + run func(*testing.T) error + expected []string + }{ + { + name: "get table", + run: func(t *testing.T) error { + _, err := captureStdout(t, func() error { return runIssueGet(newTaskScopedIssueGetCmd(), []string{taskScopedCLIIssueID}) }) + return err + }, + expected: []string{"GET /api/issues/" + taskScopedCLIIssueID, "GET /api/issues/" + taskScopedCLIIssueID}, + }, + { + name: "comment list table", + run: func(t *testing.T) error { + _, err := captureStdout(t, func() error { + return runIssueCommentList(newTaskScopedIssueCommentListCmd(), []string{taskScopedCLIIssueID}) + }) + return err + }, + expected: []string{"GET /api/issues/" + taskScopedCLIIssueID, "GET /api/issues/" + taskScopedCLIIssueID + "/comments?fold=true"}, + }, + { + name: "comment add", + run: func(t *testing.T) error { + cmd := newIssueCommentAddTestCmd() + bindTaskScopeTestAuthority(cmd) + _ = cmd.Flags().Set("content", "bounded comment") + _, err := captureStdout(t, func() error { return runIssueCommentAdd(cmd, []string{taskScopedCLIIssueID}) }) + return err + }, + expected: []string{"GET /api/issues/" + taskScopedCLIIssueID, "POST /api/issues/" + taskScopedCLIIssueID + "/comments"}, + }, + { + name: "assign explicit identity", + run: func(t *testing.T) error { + cmd := newTaskScopedIssueAssignCmd() + _ = cmd.Flags().Set("to-id", taskScopedCLIAgentID) + _ = cmd.Flags().Set("to-type", "agent") + _, err := captureStdout(t, func() error { return runIssueAssign(cmd, []string{taskScopedCLIIssueID}) }) + return err + }, + expected: []string{"GET /api/issues/" + taskScopedCLIIssueID, "PUT /api/issues/" + taskScopedCLIIssueID}, + }, + { + name: "status", + run: func(t *testing.T) error { + _, err := captureStdout(t, func() error { + return runIssueStatus(newTaskScopedIssueStatusCmd(), []string{taskScopedCLIIssueID, "in_review"}) + }) + return err + }, + expected: []string{"GET /api/issues/" + taskScopedCLIIssueID, "PUT /api/issues/" + taskScopedCLIIssueID}, + }, + { + name: "run messages full uuid", + run: func(t *testing.T) error { + _, err := captureStdout(t, func() error { + return runIssueRunMessages(newTaskScopedIssueRunMessagesCmd(), []string{taskScopedCLITaskID}) + }) + return err + }, + expected: []string{"GET /api/tasks/" + taskScopedCLITaskID + "/messages"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server, recorder := newTaskScopedCLIContractServer(t) + defer server.Close() + setTaskScopedCLIContractEnv(t, server.URL) + if err := tc.run(t); err != nil { + t.Fatalf("command failed: %v", err) + } + assertTaskScopedCLIRequests(t, recorder, tc.expected...) + }) + } +} + +func TestTaskScopedCLIRejectsRunEnumerationAndRerunBeforeHTTP(t *testing.T) { + server, recorder := newTaskScopedCLIContractServer(t) + defer server.Close() + setTaskScopedCLIContractEnv(t, server.URL) + + for _, path := range [][]string{{"issue", "runs"}, {"issue", "rerun"}} { + path := path + t.Run(strings.Join(path, "_"), func(t *testing.T) { + ran, err := executeTaskScopeTestCommand(t, path) + if err == nil || !strings.Contains(err.Error(), "task-scoped CLI context") { + t.Fatalf("task-scoped command %q error = %v", strings.Join(path, " "), err) + } + if ran { + t.Fatalf("task-scoped command %q reached RunE", strings.Join(path, " ")) + } + }) + } + + if requests := recorder.snapshot(); len(requests) != 0 { + t.Fatalf("task-scoped run commands performed HTTP requests: %#v", requests) + } +} + +func TestTaskScopedIssueCLILocallyRejectsManagementLookups(t *testing.T) { + server, recorder := newTaskScopedCLIContractServer(t) + defer server.Close() + setTaskScopedCLIContractEnv(t, server.URL) + + assign := newTaskScopedIssueAssignCmd() + _ = assign.Flags().Set("to", "agent-name") + if err := runIssueAssign(assign, []string{taskScopedCLIIssueID}); err == nil || !strings.Contains(err.Error(), "requires --to-id") { + t.Fatalf("task assign by name error = %v", err) + } + + comment := newIssueCommentAddTestCmd() + bindTaskScopeTestAuthority(comment) + _ = comment.Flags().Set("content", "bounded") + _ = comment.Flags().Set("attachment", "artifact.txt") + if err := runIssueCommentAdd(comment, []string{taskScopedCLIIssueID}); err == nil || !strings.Contains(err.Error(), "prohibits attachment") { + t.Fatalf("task attachment error = %v", err) + } + + if requests := recorder.snapshot(); len(requests) != 0 { + t.Fatalf("local task-scope rejections performed HTTP requests: %#v", requests) + } +} + +func TestHumanIssueGetTableKeepsActorDisplayLookup(t *testing.T) { + server, recorder := newTaskScopedCLIContractServer(t) + defer server.Close() + clearTaskScopeContext(t) + t.Setenv("HOME", t.TempDir()) + t.Setenv("MULTICA_SERVER_URL", server.URL) + t.Setenv("MULTICA_WORKSPACE_ID", taskScopedCLIWorkspaceID) + t.Setenv("MULTICA_TOKEN", "mul_human_test") + + cmd := &cobra.Command{Use: "get"} + cmd.Flags().String("output", "table", "") + out, err := captureStdout(t, func() error { + return runIssueGet(cmd, []string{taskScopedCLIIssueID}) + }) + if err != nil { + t.Fatalf("human issue get: %v", err) + } + if !strings.Contains(out, "agent:Human-visible agent") { + t.Fatalf("human table output did not resolve actor name:\n%s", out) + } + requests := recorder.snapshot() + foundAgentLookup := false + for _, request := range requests { + if request.Method == http.MethodGet && strings.HasPrefix(request.Path, "/api/agents?") { + foundAgentLookup = true + } + } + if !foundAgentLookup { + t.Fatalf("human context did not preserve agent display lookup: %#v", requests) + } +} + +func TestTaskAuthorityLocatorEnvironmentCannotRedirectFixedPath(t *testing.T) { + authorityServer, _ := newTaskScopedCLIContractServer(t) + defer authorityServer.Close() + + var attackerRequests int + attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + attackerRequests++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"` + taskScopedCLIIssueID + `","identifier":"ATH-75","title":"stolen"}`)) + })) + defer attacker.Close() + + clearTaskScopeContext(t) + t.Setenv("HOME", t.TempDir()) + t.Setenv("MULTICA_SERVER_URL", attacker.URL) + t.Setenv("MULTICA_WORKSPACE_ID", taskScopedCLIWorkspaceID) + t.Setenv("MULTICA_TOKEN", "mat_contract_test") + t.Setenv("MULTICA_TASK_ID", taskScopedCLITaskID) + t.Setenv("MULTICA_AGENT_ID", taskScopedCLIAgentID) + + t.Setenv("MULTICA_TASK_AUTHORITY_PATH", filepath.Join(t.TempDir(), "attacker-authority.json")) + + cmd := &cobra.Command{Use: "get"} + cmd.Flags().String("output", "table", "") + err := runIssueGet(cmd, []string{taskScopedCLIIssueID}) + if err == nil || !strings.Contains(err.Error(), "authority") { + t.Fatalf("environment redirect error = %v, want authority mismatch rejection", err) + } + if attackerRequests != 0 { + t.Fatalf("task credential was redirected to attacker endpoint (%d requests)", attackerRequests) + } +} diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 9b1eb3e7d50..2e9156c291f 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -811,6 +811,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus // Protected API routes r.Group(func(r chi.Router) { r.Use(middleware.Auth(queries, patCache, cloudPATVerifier)) + r.Use(middleware.TaskTokenScopeGuard(queries)) r.Use(middleware.RefreshCloudFrontCookies(cfSigner)) // --- User-scoped routes (no workspace context required) --- diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index bea576437e9..d9335c39560 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -24,7 +24,9 @@ import ( "github.com/multica-ai/multica/server/internal/daemon/execenv" "github.com/multica-ai/multica/server/internal/daemon/repocache" "github.com/multica-ai/multica/server/internal/selfexec" + "github.com/multica-ai/multica/server/internal/taskauth" "github.com/multica-ai/multica/server/pkg/agent" + "github.com/multica-ai/multica/server/pkg/protocol" "github.com/multica-ai/multica/server/pkg/skillbundle" "github.com/multica-ai/multica/server/pkg/taskfailure" ) @@ -119,21 +121,15 @@ var ( // workspaceState tracks registered runtimes for a single workspace. // // allowedRepoURLs covers the workspace-level repo bindings; it gets rebuilt on -// every refresh from the server. taskRepoURLs covers repos that the server -// surfaced through a per-task claim (project github_repo resources today, -// possibly other typed sources later) — those don't show up in -// GetWorkspaceRepos, so they would be wiped on refresh if we shared one map. -// taskRepoRefs tracks optional checkout refs for the specific task that -// surfaced each project repo so two projects using the same URL don't leak refs -// into each other. +// every refresh from the server. Task-assigned repos are deliberately excluded: +// their authority is frozen into a daemon-issued checkout capability instead +// of widening mutable workspace configuration. type workspaceState struct { workspaceID string runtimeIDs []string reposVersion string // stored for future use: skip refresh when version unchanged allowedRepoURLs map[string]struct{} - taskRepoURLs map[string]struct{} - taskRepoRefs map[string]map[string]string // taskID -> repo URL -> checkout ref - settings json.RawMessage // workspace settings (JSONB) + settings json.RawMessage // workspace settings (JSONB) lastRepoSyncErr string repoRefreshMu sync.Mutex // profileSetSig is a content hash of the workspace's custom runtime @@ -147,10 +143,29 @@ type workspaceState struct { } type repoCacheBackend interface { - Lookup(workspaceID, url string) string - Sync(workspaceID string, repos []repocache.RepoInfo) error + LookupContext(ctx context.Context, workspaceID, url string) string + ResolveContext(ctx context.Context, workspaceID, url string) (repocache.ResolvedRepo, error) + SyncContext(ctx context.Context, workspaceID string, repos []repocache.RepoInfo) error WithRepoLock(barePath string, fn func() error) error - CreateWorktree(params repocache.WorktreeParams) (*repocache.WorktreeResult, error) + CreateWorktreeContext(ctx context.Context, params repocache.WorktreeParams) (*repocache.WorktreeResult, error) +} + +type resolvedTaskRepo struct { + URL string + Ref string + BarePath string +} + +type taskIsolationParams struct { + Environment *execenv.Environment + TaskTempDir string + TaskAuthority string + Repos []resolvedTaskRepo + Executable string + OwnerHome string + HermesSourceHome string + SelfExecutable string + LookupPath func(string) (string, error) } // Daemon is the local agent runtime that polls for and executes tasks. @@ -160,6 +175,10 @@ type Daemon struct { repoCache repoCacheBackend skillCache *SkillBundleCache logger *slog.Logger + // taskLauncher is initialized by New. Tests that construct Daemon directly + // must opt into an explicit launcher; a missing factory fails closed. + taskLauncher func() agent.CommandBuilder + taskAuthorityDir func() (string, error) mu sync.Mutex workspaces map[string]*workspaceState @@ -234,8 +253,12 @@ type Daemon struct { rootCtx context.Context // set by Run(); used by long-running recoveries that must survive per-runtime ctx cancellation restartBinary string // non-empty after a successful update; path to the new binary updating atomic.Bool // prevents concurrent update attempts - activeTasks atomic.Int64 // number of tasks currently in handleTask; exposed via /health + activeTasks atomic.Int64 // number of tasks currently in handleTask; exposed via authenticated /diagnostics ready atomic.Bool // false until preflight completes; gates /health status (starting -> running) + // taskExecutionCapable is set once at startup after the local Linux + // bubblewrap FD-bound smoke probe succeeds. It gates registration, polling, + // and final execution independently of server behavior. + taskExecutionCapable bool // claimMu guards pauseClaims and claimsInFlight. It is held only for the // microseconds it takes to make a decision; ClaimTask itself runs without @@ -268,12 +291,11 @@ type Daemon struct { // waits. See MUL-2663. localPathLocks *LocalPathLocker - // bgSyncs tracks background goroutines started by registerTaskRepos so - // callers (notably tests using t.TempDir-backed cache roots) can wait for - // them to drain before tearing the daemon down. Without this the bg - // goroutine can race against t.TempDir cleanup, leaving a partially - // deleted bare clone and an unrelated `not empty` cleanup failure. - bgSyncs sync.WaitGroup + // repoCheckoutCapabilities are daemon-issued, task-bound permissions for + // the local checkout endpoint. The child receives only the opaque token; + // workspace, task, workdir, agent, repo, and ref stay daemon-controlled. + repoCheckoutCapabilityMu sync.RWMutex + repoCheckoutCapabilities map[string]*repoCheckoutCapability runner taskRunner // executes agent tasks; set to d.runTask by New(), overridable in tests cancelPollInterval time.Duration // how often handleTask polls for server-side cancellation; overridable in tests @@ -317,10 +339,15 @@ func New(cfg Config, logger *slog.Logger) *Daemon { runtimeGoneInflight: make(map[string]struct{}), reregisterNextAttempt: make(map[string]time.Time), reregisterLastCompletedAt: make(map[string]time.Time), + repoCheckoutCapabilities: make(map[string]*repoCheckoutCapability), cancelPollInterval: 5 * time.Second, reconcile: newReconcileBroadcaster(), workspaceChanges: newWorkspaceChangeSignal(), wsRPC: newWSRPCClient(wsRPCResponseGrace), + taskLauncher: func() agent.CommandBuilder { + return agent.NewCommandLauncher() + }, + taskAuthorityDir: ensureTaskAuthorityDir, } d.activeCodexStoresCond = sync.NewCond(&d.activeCodexStoresMu) d.runner = taskRunnerFunc(d.runTask) @@ -937,6 +964,13 @@ func (d *Daemon) Run(ctx context.Context) error { logFields = append(logFields, "profile", d.cfg.Profile) } d.logger.Info("starting daemon", logFields...) + if err := agent.ProbeTaskExecutionCapability(ctx); err != nil { + d.taskExecutionCapable = false + d.logger.Warn("task execution disabled: secure Linux executor probe failed", "error", err) + } else { + d.taskExecutionCapable = true + d.logger.Info("secure Linux task executor enabled", "capability", protocol.RuntimeCapabilityTaskExecution) + } d.logger.Debug("daemon config resolved", "daemon_id", d.cfg.DaemonID, "device_name", d.cfg.DeviceName, @@ -1123,7 +1157,7 @@ func (d *Daemon) customProfileLaunchForRuntime(runtimeID string) (profileLaunchS func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID string) (*RegisterResponse, string, error) { d.logger.Debug("registering runtimes for workspace", "workspace_id", workspaceID, "agent_count", len(d.cfg.Agents)) - var runtimes []map[string]string + var runtimes []map[string]any var failedProfiles []map[string]string for name, entry := range d.cfg.Agents { // Self-heal a pinned executable path an in-place upgrade deleted @@ -1147,12 +1181,16 @@ func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID s if d.cfg.DeviceName != "" { displayName = fmt.Sprintf("%s (%s)", displayName, d.cfg.DeviceName) } - runtimes = append(runtimes, map[string]string{ + runtimeRegistration := map[string]any{ "name": displayName, "type": name, "version": version, "status": "online", - }) + } + if d.taskExecutionCapable { + runtimeRegistration["capabilities"] = []string{protocol.RuntimeCapabilityTaskExecution} + } + runtimes = append(runtimes, runtimeRegistration) } // Append any workspace custom runtime profiles whose command resolves on @@ -1223,7 +1261,7 @@ func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID s // that as "unknown, do not overwrite a previously-stored signature" (otherwise // a transient 5xx would silently flip the daemon into thinking the workspace // has zero profiles). -func (d *Daemon) appendProfileRuntimes(ctx context.Context, workspaceID string, runtimes *[]map[string]string, failedProfiles *[]map[string]string) string { +func (d *Daemon) appendProfileRuntimes(ctx context.Context, workspaceID string, runtimes *[]map[string]any, failedProfiles *[]map[string]string) string { resp, err := d.client.GetRuntimeProfiles(ctx, workspaceID) if err != nil { // Best-effort: never fail registration because profiles couldn't be @@ -1316,13 +1354,17 @@ func (d *Daemon) appendProfileRuntimes(ctx context.Context, workspaceID string, d.logger.Info("registering custom runtime profile", "workspace_id", workspaceID, "profile_id", profile.ID, "protocol_family", profile.ProtocolFamily, "command_path", resolved) - *runtimes = append(*runtimes, map[string]string{ + runtimeRegistration := map[string]any{ "name": displayName, "type": profile.ProtocolFamily, "version": version, "status": "online", "profile_id": profile.ID, - }) + } + if d.taskExecutionCapable { + runtimeRegistration["capabilities"] = []string{protocol.RuntimeCapabilityTaskExecution} + } + *runtimes = append(*runtimes, runtimeRegistration) } return profileSetSignature(resp.RuntimeProfiles) } @@ -1404,9 +1446,6 @@ func (d *Daemon) workspaceRepoAllowed(workspaceID, repoURL string) bool { if _, allowed := ws.allowedRepoURLs[repoURL]; allowed { return true } - if _, allowed := ws.taskRepoURLs[repoURL]; allowed { - return true - } return false } @@ -1451,124 +1490,57 @@ func (d *Daemon) workspaceCoAuthoredByEnabled(workspaceID string) bool { return *s.CoAuthoredByEnabled } -// registerTaskRepos merges task-scoped repos (e.g. project github_repo -// resources lifted into resp.Repos by the claim handler) into the workspace's -// allowlist and kicks off a cache sync for any URLs that aren't yet cached. -// -// It's safe to call with the workspace's own repos — duplicates are -// idempotent. Called from runTask before the agent spawns so -// `multica repo checkout` accepts project-only URLs without an extra round -// trip back to GetWorkspaceRepos (which doesn't carry project resources). -func (d *Daemon) registerTaskRepos(workspaceID, taskID string, repos []RepoData) { +// prepareTaskRepoCache synchronously materializes the immutable repo snapshot +// assigned to a task. It never changes workspace authorization and never reads +// live workspace/project/repo management state. +func (d *Daemon) prepareTaskRepoCache(ctx context.Context, workspaceID string, repos []RepoData) ([]resolvedTaskRepo, error) { + workspaceID = strings.TrimSpace(workspaceID) + if workspaceID == "" { + return nil, fmt.Errorf("prepare task repo cache: workspace id is required") + } if len(repos) == 0 { - return + return nil, nil } - - type repoCandidate struct { - url string - tracked bool + if d.repoCache == nil { + return nil, fmt.Errorf("prepare task repo cache: repo cache not initialized") } - d.mu.Lock() - ws, ok := d.workspaces[workspaceID] - if !ok { - d.mu.Unlock() - return - } - if ws.taskRepoURLs == nil { - ws.taskRepoURLs = make(map[string]struct{}, len(repos)) - } - if taskID != "" && ws.taskRepoRefs == nil { - ws.taskRepoRefs = make(map[string]map[string]string) - } - candidates := make([]repoCandidate, 0, len(repos)) + seen := make(map[string]string, len(repos)) + normalized := make([]RepoData, 0, len(repos)) for _, repo := range repos { url := strings.TrimSpace(repo.URL) + ref := strings.TrimSpace(repo.Ref) if url == "" { - continue + return nil, fmt.Errorf("prepare task repo cache: empty repo URL") } - // Don't re-sync if the URL is already tracked (workspace or task-scoped) - // AND the cache already has it. - _, inWorkspace := ws.allowedRepoURLs[url] - _, inTask := ws.taskRepoURLs[url] - ws.taskRepoURLs[url] = struct{}{} - if taskID != "" { - if ws.taskRepoRefs[taskID] == nil { - ws.taskRepoRefs[taskID] = make(map[string]string, len(repos)) - } - if _, exists := ws.taskRepoRefs[taskID][url]; !exists { - ws.taskRepoRefs[taskID][url] = strings.TrimSpace(repo.Ref) + if existing, ok := seen[url]; ok { + if existing != ref { + return nil, fmt.Errorf("prepare task repo cache: conflicting refs for %s", url) } - } - candidates = append(candidates, repoCandidate{ - url: url, - tracked: inWorkspace || inTask, - }) - } - d.mu.Unlock() - - toSync := make([]RepoData, 0, len(candidates)) - for _, candidate := range candidates { - if candidate.tracked && d.repoCache != nil && d.repoCache.Lookup(workspaceID, candidate.url) != "" { continue } - toSync = append(toSync, RepoData{URL: candidate.url}) - } - - if d.repoCache != nil && len(toSync) > 0 { - // Sync in the background — same shape used at workspace registration. - // `ensureRepoReady` reports a meaningful error if the cache isn't ready - // yet, so the agent's first checkout will surface a sync failure - // without silently treating it as a config bug. - d.bgSyncs.Add(1) - go func() { - defer d.bgSyncs.Done() - d.syncWorkspaceRepos(workspaceID, toSync) - }() - } -} - -func (d *Daemon) taskRepoDefaultRef(workspaceID, taskID, repoURL string) string { - taskID = strings.TrimSpace(taskID) - repoURL = strings.TrimSpace(repoURL) - if taskID == "" || repoURL == "" { - return "" + seen[url] = ref + normalized = append(normalized, RepoData{URL: url, Ref: ref}) } - d.mu.Lock() - defer d.mu.Unlock() - ws, ok := d.workspaces[workspaceID] - if !ok || ws.taskRepoRefs == nil { - return "" + if err := d.repoCache.SyncContext(ctx, workspaceID, repoDataToInfo(normalized)); err != nil { + return nil, fmt.Errorf("prepare task repo cache: %w", err) } - return strings.TrimSpace(ws.taskRepoRefs[taskID][repoURL]) -} - -func (d *Daemon) clearTaskRepoRefs(workspaceID, taskID string) { - taskID = strings.TrimSpace(taskID) - if taskID == "" { - return - } - d.mu.Lock() - defer d.mu.Unlock() - if ws, ok := d.workspaces[workspaceID]; ok && ws.taskRepoRefs != nil { - delete(ws.taskRepoRefs, taskID) + resolved := make([]resolvedTaskRepo, 0, len(normalized)) + for _, repo := range normalized { + identity, err := d.repoCache.ResolveContext(ctx, workspaceID, repo.URL) + if err != nil { + return nil, fmt.Errorf("prepare task repo cache: resolve %s: %w", repo.URL, err) + } + resolved = append(resolved, resolvedTaskRepo{URL: identity.URL, Ref: repo.Ref, BarePath: identity.BarePath}) } + return resolved, nil } -// waitBackgroundSyncs blocks until every background sync started by -// registerTaskRepos has finished. Intended for test teardown: tests that -// hand the daemon a t.TempDir-backed repo cache must call this before -// returning, otherwise an in-flight clone/fetch can race against TempDir -// cleanup and surface as an unrelated "directory not empty" failure. -func (d *Daemon) waitBackgroundSyncs() { - d.bgSyncs.Wait() -} - -func (d *Daemon) syncWorkspaceRepos(workspaceID string, repos []RepoData) { +func (d *Daemon) syncWorkspaceRepos(ctx context.Context, workspaceID string, repos []RepoData) { if d.repoCache == nil { return } - if err := d.repoCache.Sync(workspaceID, repoDataToInfo(repos)); err != nil { + if err := d.repoCache.SyncContext(ctx, workspaceID, repoDataToInfo(repos)); err != nil { d.setWorkspaceRepoSyncError(workspaceID, err.Error()) d.logger.Warn("repo cache sync failed", "workspace_id", workspaceID, "error", err) return @@ -1782,12 +1754,12 @@ func (d *Daemon) ensureRepoReady(ctx context.Context, workspaceID, repoURL strin // a sibling goroutine on a concurrent cold-miss already refreshed // and populated the cache. We can skip the duplicate refresh — the // sibling's refresh is fresh enough for our gate read. - cacheHitOnEntry := d.workspaceRepoAllowed(workspaceID, repoURL) && d.repoCache.Lookup(workspaceID, repoURL) != "" + cacheHitOnEntry := d.workspaceRepoAllowed(workspaceID, repoURL) && d.repoCache.LookupContext(ctx, workspaceID, repoURL) != "" ws.repoRefreshMu.Lock() defer ws.repoRefreshMu.Unlock() - if !cacheHitOnEntry && d.workspaceRepoAllowed(workspaceID, repoURL) && d.repoCache.Lookup(workspaceID, repoURL) != "" { + if !cacheHitOnEntry && d.workspaceRepoAllowed(workspaceID, repoURL) && d.repoCache.LookupContext(ctx, workspaceID, repoURL) != "" { return nil } @@ -1800,13 +1772,13 @@ func (d *Daemon) ensureRepoReady(ctx context.Context, workspaceID, repoURL strin return ErrRepoNotConfigured } - if d.repoCache.Lookup(workspaceID, repoURL) != "" { + if d.repoCache.LookupContext(ctx, workspaceID, repoURL) != "" { return nil } - d.syncWorkspaceRepos(workspaceID, resp.Repos) + d.syncWorkspaceRepos(ctx, workspaceID, resp.Repos) - if d.repoCache.Lookup(workspaceID, repoURL) != "" { + if d.repoCache.LookupContext(ctx, workspaceID, repoURL) != "" { return nil } @@ -2069,7 +2041,7 @@ func (d *Daemon) syncWorkspacesFromAPI(ctx context.Context, reconcileProfiles bo d.mu.Unlock() if d.repoCache != nil && len(resp.Repos) > 0 { - go d.syncWorkspaceRepos(id, resp.Repos) + go d.syncWorkspaceRepos(ctx, id, resp.Repos) } // Tell the server about any tasks the previous daemon process was @@ -2808,6 +2780,12 @@ func (d *Daemon) runBatchPoller(pollerCtx, parentCtx context.Context, sem chan i if pollerCtx.Err() != nil { return } + if !d.taskExecutionCapable { + if err := sleepWithContextOrWakeup(pollerCtx, d.cfg.PollInterval, wakeup); err != nil { + return + } + continue + } runtimeIDs := d.allRuntimeIDs() if len(runtimeIDs) == 0 { @@ -3122,12 +3100,6 @@ func (d *Daemon) handleTask(ctx context.Context, task Task, slot int) { d.markActiveEnvRoot(predictedEnvRoot) defer d.unmarkActiveEnvRoot(predictedEnvRoot) } - if task.PriorWorkDir != "" { - if priorRoot := filepath.Dir(task.PriorWorkDir); priorRoot != "" && priorRoot != predictedEnvRoot { - d.markActiveEnvRoot(priorRoot) - defer d.unmarkActiveEnvRoot(priorRoot) - } - } // Create a cancellable context so we can interrupt the running agent // when the server signals the task should stop — either the task reached @@ -3356,6 +3328,21 @@ func (d *Daemon) acquireLocalDirectoryLockIfNeeded(ctx context.Context, task Tas } return nil, true } + // Revalidate only after this task owns the path mutex. A tree may have + // changed while the task was waiting, and granting the entire writable + // root would otherwise expose daemon-owner files through nested hardlinks. + validationErr := validateLocalPath(assignment.AbsPath) + if validationErr == nil { + validationErr = validateLocalDirectoryTree(assignment.AbsPath) + } + if validationErr != nil { + release() + taskLog.Error("local_directory: locked path validation failed", "error", validationErr) + if failErr := d.client.FailTask(ctx, task.ID, validationErr.Error(), "", "", "local_directory_error"); failErr != nil { + taskLog.Error("fail task after locked local_directory validation error", "error", failErr) + } + return nil, true + } taskLog.Info("local_directory: lock acquired") return release, false } @@ -3545,6 +3532,23 @@ func gateCodexResumeToRolloutPresence(task *Task, taskCtx *execenv.TaskContextFo taskCtx.PriorSessionResumeUnavailable = true } +// gatePiResumeToTaskState rejects legacy path-valued Pi session pointers and +// missing or replaced task-private files. Pi sessions are persisted beside the +// reused workdir; no resume value may select a filesystem location. +func gatePiResumeToTaskState(task *Task, taskCtx *execenv.TaskContextForEnv, provider, taskStateDir string, taskLog *slog.Logger) { + if provider != "pi" || task.PriorSessionID == "" { + return + } + if agent.PiSessionPresent(taskStateDir, task.PriorSessionID) { + return + } + taskLog.Warn("dropping prior pi session: task-private session is unavailable; starting fresh", + "session_id", task.PriorSessionID, "task_state_dir", taskStateDir) + task.PriorSessionID = "" + taskCtx.PriorSessionResumed = false + taskCtx.PriorSessionResumeUnavailable = true +} + func (d *Daemon) ensureTaskSkillBundles(ctx context.Context, task *Task) error { if task == nil || task.Agent == nil || len(task.Agent.SkillRefs) == 0 { return nil @@ -3725,6 +3729,9 @@ func skillRefFromBundle(bundle SkillData) SkillRefData { } func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot int, taskLog *slog.Logger) (TaskResult, error) { + if !d.taskExecutionCapable { + return TaskResult{}, fmt.Errorf("refusing to spawn agent: secure Linux task executor is unavailable") + } // Refuse to spawn an agent without a workspace. An empty workspace_id // here would make MULTICA_WORKSPACE_ID empty in the agent env, and the // CLI would otherwise silently fall back to the user-global config — a @@ -3734,15 +3741,6 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i return TaskResult{}, fmt.Errorf("refusing to spawn agent: task has no workspace_id (task_id=%s)", task.ID) } - // task.Repos is the authoritative repo list for this task — when the - // claimed task belongs to a project with github_repo resources the server - // has already narrowed it to project repos only. Make sure those URLs are - // in the per-workspace allowlist and the local cache, otherwise - // `multica repo checkout` would reject project-only URLs that aren't also - // bound at the workspace level. - d.registerTaskRepos(task.WorkspaceID, task.ID, task.Repos) - defer d.clearTaskRepoRefs(task.WorkspaceID, task.ID) - entry, ok := d.cfg.Agents[provider] // A custom runtime profile (MUL-3284) overrides the executable path: the // runtime's protocol_family is the provider (so agent.New still selects @@ -3841,13 +3839,6 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i predictedRoot := execenv.PredictRootDir(d.cfg.WorkspacesRoot, task.WorkspaceID, task.ID) d.markActiveEnvRoot(predictedRoot) defer d.unmarkActiveEnvRoot(predictedRoot) - if task.PriorWorkDir != "" { - priorRoot := filepath.Dir(task.PriorWorkDir) - if priorRoot != predictedRoot { - d.markActiveEnvRoot(priorRoot) - defer d.unmarkActiveEnvRoot(priorRoot) - } - } // Try to reuse the workdir from a previous task on the same (agent, issue) pair. var env *execenv.Environment @@ -3949,6 +3940,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i if task.PriorWorkDir != "" && localAssignment == nil && !task.IsLeaderTask { env = execenv.Reuse(execenv.ReuseParams{ WorkspacesRoot: d.cfg.WorkspacesRoot, + RootDir: predictedRoot, Profile: d.cfg.Profile, WorkDir: task.PriorWorkDir, Provider: provider, @@ -4033,6 +4025,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i // conversation Codex will silently restart from scratch. if reused { gateCodexResumeToRolloutPresence(&task, &taskCtx, provider, env.CodexHome, taskLog) + gatePiResumeToTaskState(&task, &taskCtx, provider, env.RootDir, taskLog) } // Inject runtime-specific config (meta skill) so the agent discovers .agent_context/. @@ -4086,6 +4079,54 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i taskLog.Error("task auth token invalid; refusing to start agent", "error", err) return TaskResult{}, err } + authorityDirFactory := d.taskAuthorityDir + if authorityDirFactory == nil { + authorityDirFactory = ensureTaskAuthorityDir + } + authorityDir, err := authorityDirFactory() + if err != nil { + return TaskResult{}, fmt.Errorf("prepare task authority dir: %w", err) + } + defer func() { + if cerr := os.RemoveAll(authorityDir); cerr != nil { + taskLog.Warn("task authority dir cleanup failed", "path", authorityDir, "error", cerr) + } + }() + authorityPath, err := taskauth.Write(authorityDir, taskauth.Authority{ + ManagedBy: taskauth.ManagedBy, + Version: taskauth.Version, + ServerURL: d.cfg.ServerBaseURL, + WorkspaceID: task.WorkspaceID, + Token: agentToken, + TaskID: task.ID, + AgentID: task.AgentID, + }) + if err != nil { + return TaskResult{}, fmt.Errorf("publish task authority: %w", err) + } + var resolvedTaskRepos []resolvedTaskRepo + repoCheckoutToken := "" + if len(task.Repos) > 0 { + if localAssignment != nil { + return TaskResult{}, fmt.Errorf("local_directory tasks cannot request additional repo checkout") + } + resolvedTaskRepos, err = d.prepareTaskRepoCache(ctx, task.WorkspaceID, task.Repos) + if err != nil { + return TaskResult{}, err + } + repoCheckoutToken, err = d.registerRepoCheckoutCapability(repoCheckoutCapabilityParams{ + WorkspaceID: task.WorkspaceID, + TaskID: task.ID, + WorkDir: env.WorkDir, + AgentName: agentName, + Repos: task.Repos, + CoAuthoredByEnabled: d.workspaceCoAuthoredByEnabled(task.WorkspaceID), + }) + if err != nil { + return TaskResult{}, fmt.Errorf("register repo checkout capability: %w", err) + } + defer d.revokeRepoCheckoutCapability(repoCheckoutToken) + } agentEnv := map[string]string{ "MULTICA_TOKEN": agentToken, "MULTICA_SERVER_URL": d.cfg.ServerBaseURL, @@ -4098,6 +4139,53 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i "TMPDIR": taskTempDir, "TMP": taskTempDir, "TEMP": taskTempDir, + // Isolate user-level CLI configuration from the daemon owner's home. + // In particular, clearing MULTICA_* must not make the child discover + // the owner's ~/.multica/config.json and regain human PAT/JWT authority. + "HOME": env.HomeDir, + "XDG_CONFIG_HOME": env.ConfigDir, + "USERPROFILE": env.HomeDir, + "APPDATA": filepath.Join(env.HomeDir, "AppData", "Roaming"), + "LOCALAPPDATA": filepath.Join(env.HomeDir, "AppData", "Local"), + } + ownerHome, err := os.UserHomeDir() + if err != nil { + return TaskResult{}, fmt.Errorf("resolve daemon owner home: %w", err) + } + selfExecutable, err := os.Executable() + if err != nil { + return TaskResult{}, fmt.Errorf("resolve multica executable: %w", err) + } + isolationPolicy, taskPath, err := buildTaskIsolationPolicy(taskIsolationParams{ + Environment: env, + TaskTempDir: taskTempDir, + TaskAuthority: authorityPath, + Repos: resolvedTaskRepos, + Executable: entry.Path, + OwnerHome: ownerHome, + HermesSourceHome: hermesSourceHome, + SelfExecutable: selfExecutable, + LookupPath: exec.LookPath, + }) + if err != nil { + return TaskResult{}, fmt.Errorf("build task isolation policy: %w", err) + } + taskExecutable, err := existingTaskExecutable("provider executable", entry.Path) + if err != nil { + return TaskResult{}, err + } + agentEnv["PATH"] = taskPath + if repoCheckoutToken != "" { + agentEnv["MULTICA_REPO_CHECKOUT_TOKEN"] = repoCheckoutToken + } + if volume := filepath.VolumeName(env.HomeDir); volume != "" { + agentEnv["HOMEDRIVE"] = volume + agentEnv["HOMEPATH"] = strings.TrimPrefix(env.HomeDir, volume) + } else { + // Override any inherited Windows locators even on compatibility layers + // where filepath.VolumeName cannot derive a native drive. + agentEnv["HOMEDRIVE"] = "" + agentEnv["HOMEPATH"] = env.HomeDir } if task.AutopilotRunID != "" { agentEnv["MULTICA_AUTOPILOT_RUN_ID"] = task.AutopilotRunID @@ -4119,14 +4207,6 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i } } } - // Ensure the multica CLI is on PATH inside the agent's environment. - // Some runtimes (e.g. Codex) run in an isolated sandbox that may not - // inherit the daemon's PATH. Prepend the directory of the running - // multica binary so that `multica` commands in the agent always resolve. - if selfBin, err := resolveSelfExecutable(); err == nil { - binDir := filepath.Dir(selfBin) - agentEnv["PATH"] = binDir + string(os.PathListSeparator) + os.Getenv("PATH") - } // Point Codex to the per-task CODEX_HOME so it discovers skills natively // without polluting the system ~/.codex/skills/. if env.CodexHome != "" { @@ -4162,14 +4242,6 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i if env.OpenclawConfigPath != "" { agentEnv["OPENCLAW_CONFIG_PATH"] = env.OpenclawConfigPath } - // Grant the wrapper config permission to $include the user's active - // config across directories. OpenClaw's $include defaults to confining - // resolution to the wrapper's own directory; without this, the - // wrapper-out-of-envRoot $include into ~/.openclaw/openclaw.json is - // rejected and the run boots with no user-registered agents. - if rootsValue, ok := composeOpenclawIncludeRoots(env.OpenclawIncludeRoot, os.Getenv("OPENCLAW_INCLUDE_ROOTS")); ok { - agentEnv["OPENCLAW_INCLUDE_ROOTS"] = rootsValue - } // Inject user-configured custom environment variables (e.g. ANTHROPIC_API_KEY, // ANTHROPIC_BASE_URL for router/proxy mode, or CLAUDE_CODE_USE_BEDROCK for // Bedrock). These are set per-agent via the agent settings UI. @@ -4180,8 +4252,12 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i agentCustomEnv = task.Agent.CustomEnv } layerCustomEnvAndHermesHome(agentEnv, agentCustomEnv, env.HermesHome, d.logger) + launcher, err := d.newTaskCommandBuilder() + if err != nil { + return TaskResult{}, err + } backend, err := agent.New(provider, agent.Config{ - ExecutablePath: entry.Path, + ExecutablePath: taskExecutable, CLIVersion: resolvedVersion, Env: agentEnv, Logger: d.logger, @@ -4189,6 +4265,10 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i RuntimeID: task.RuntimeID, DaemonVersion: d.cfg.CLIVersion, CodexVersion: codexVersion, + Launcher: launcher, + Isolation: &isolationPolicy, + TaskTempDir: taskTempDir, + TaskStateDir: env.RootDir, }) if err != nil { return TaskResult{}, fmt.Errorf("create agent backend: %w", err) @@ -4239,26 +4319,32 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i if task.Agent != nil { thinkingLevel = task.Agent.ThinkingLevel } - // Per-model guard: the server validates the literal token against the - // provider's enum, but per-model gaps (Claude's `xhigh` on a non-Opus - // model, Codex's per-model `supported_reasoning_levels`) only resolve - // here, against the daemon's local CLI catalog. Invalid combinations - // log a warning and drop the level rather than failing the task, so a - // stale persisted value never blocks execution. An empty model is - // resolved by ValidateThinkingLevel to the provider's default model so - // default-model tasks aren't misjudged — except for codex, whose empty - // model follows config.toml (any model) and so fails closed, dropping the - // level here. Discovery errors fail open for resolved models: if we can't - // list models, we keep the persisted level and let the CLI object. + // Per-model gaps are resolved through the same executable, launcher, + // task environment, cwd, and isolation policy used for execution. Any + // discovery failure drops the persisted level: task code must never gain + // daemon process authority through model discovery. if thinkingLevel != "" { - ok, err := agent.ValidateThinkingLevel(ctx, provider, entry.Path, model, thinkingLevel) + ok, err := agent.ValidateThinkingLevelForTask( + ctx, + provider, + taskExecutable, + model, + thinkingLevel, + agent.TaskModelDiscovery{Execution: &agent.ModelDiscoveryExecution{ + Launcher: launcher, + Cwd: env.WorkDir, + Env: agentEnv, + Isolation: &isolationPolicy, + }}, + ) if err != nil { - taskLog.Warn("thinking_level: catalog lookup failed; passing through", + taskLog.Warn("thinking_level: task-scoped catalog lookup failed; skipping injection", "provider", provider, "model", model, "thinking_level", thinkingLevel, "error", err, ) + thinkingLevel = "" } else if !ok { taskLog.Warn("thinking_level: not valid for this (provider, model); skipping injection", "provider", provider, @@ -4537,6 +4623,17 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i } } +func (d *Daemon) newTaskCommandBuilder() (agent.CommandBuilder, error) { + if d.taskLauncher == nil { + return nil, fmt.Errorf("task command launcher factory is unavailable") + } + launcher := d.taskLauncher() + if launcher == nil { + return nil, fmt.Errorf("task command launcher factory returned nil") + } + return launcher, nil +} + // executeAndDrain runs a backend, drains its message stream (forwarding to the // server), and waits for the final result. msgSeq numbers the reported task // messages and is owned by the caller so a same-task retry continues the @@ -5127,40 +5224,6 @@ func convertSkillsForEnv(skills []SkillData) []execenv.SkillContextForEnv { return result } -// composeOpenclawIncludeRoots returns the value the daemon should set for -// OPENCLAW_INCLUDE_ROOTS on the child openclaw process so its `$include` -// loader will follow the wrapper's reference out of envRoot into the -// user's active config directory. -// -// addRoot is the directory we must grant (typically dirname of the user's -// active openclaw.json). userValue is whatever the daemon's own -// environment already has under OPENCLAW_INCLUDE_ROOTS — the user's own -// cross-directory layout. We prepend addRoot, dedupe by string equality, -// drop empty path segments, and return ok=false when there's nothing to -// grant (addRoot is empty — fresh install case), so callers can leave the -// env var alone in that case. -// -// Path separator is the OS-native list separator (`:` on Unix, `;` on -// Windows) to match how OpenClaw splits the env var. -func composeOpenclawIncludeRoots(addRoot, userValue string) (string, bool) { - if addRoot == "" { - return "", false - } - parts := []string{addRoot} - seen := map[string]struct{}{addRoot: {}} - for _, p := range strings.Split(userValue, string(os.PathListSeparator)) { - if p == "" { - continue - } - if _, dup := seen[p]; dup { - continue - } - seen[p] = struct{}{} - parts = append(parts, p) - } - return strings.Join(parts, string(os.PathListSeparator)), true -} - func ensureTaskTempDir(envRoot string, workspaceID string, taskID string) (string, error) { envRoot = strings.TrimSpace(envRoot) if envRoot == "" { @@ -5174,11 +5237,20 @@ func ensureTaskTempDir(envRoot string, workspaceID string, taskID string) (strin if taskID == "" { return "", errors.New("task id is empty") } - dir, err := os.MkdirTemp(socketSafeTempBaseDir(), "multica-task-") + return ensurePrivateTaskDir("multica-task-") +} + +func ensureTaskAuthorityDir() (string, error) { + return ensurePrivateTaskDir("multica-task-authority-") +} + +func ensurePrivateTaskDir(prefix string) (string, error) { + dir, err := os.MkdirTemp(socketSafeTempBaseDir(), prefix) if err != nil { return "", err } if err := os.Chmod(dir, 0o700); err != nil { + _ = os.RemoveAll(dir) return "", err } return dir, nil @@ -5202,7 +5274,7 @@ func isBlockedEnvKey(key string) bool { return true } switch upper { - case "HOME", "PATH", "USER", "SHELL", "TERM", "TMPDIR", "TMP", "TEMP", "CODEX_HOME", "CURSOR_DATA_DIR", execenv.CursorMcpAuthSourceEnv, "OPENCLAW_CONFIG_PATH", "OPENCLAW_INCLUDE_ROOTS": + case "HOME", "XDG_CONFIG_HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "HOMEDRIVE", "HOMEPATH", "PATH", "USER", "SHELL", "TERM", "TMPDIR", "TMP", "TEMP", "CODEX_HOME", "CURSOR_DATA_DIR", execenv.CursorMcpAuthSourceEnv, "OPENCLAW_CONFIG_PATH", "OPENCLAW_INCLUDE_ROOTS": return true } return false diff --git a/server/internal/daemon/daemon_test.go b/server/internal/daemon/daemon_test.go index 6cd0e81a066..b99bd7e0ef2 100644 --- a/server/internal/daemon/daemon_test.go +++ b/server/internal/daemon/daemon_test.go @@ -22,6 +22,7 @@ import ( "github.com/multica-ai/multica/server/internal/daemon/execenv" "github.com/multica-ai/multica/server/internal/daemon/repocache" + "github.com/multica-ai/multica/server/internal/taskauth" "github.com/multica-ai/multica/server/pkg/agent" ) @@ -149,6 +150,12 @@ func TestIsBlockedEnvKey(t *testing.T) { {key: "MULTICA_TOKEN", want: true}, {key: "multica_runtime_id", want: true}, {key: "HOME", want: true}, + {key: "XDG_CONFIG_HOME", want: true}, + {key: "USERPROFILE", want: true}, + {key: "APPDATA", want: true}, + {key: "LOCALAPPDATA", want: true}, + {key: "HOMEDRIVE", want: true}, + {key: "HOMEPATH", want: true}, {key: "PATH", want: true}, {key: "TMPDIR", want: true}, {key: "tmp", want: true}, @@ -240,6 +247,563 @@ func TestLayerCustomEnvAndHermesHome(t *testing.T) { } } +func TestRunTaskInjectsPrivateHomeAndBlocksCustomOverrides(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-script agent fixture is POSIX-only") + } + + realHome := t.TempDir() + realConfig := filepath.Join(realHome, ".multica", "config.json") + if err := os.MkdirAll(filepath.Dir(realConfig), 0o700); err != nil { + t.Fatalf("mkdir real config dir: %v", err) + } + const humanPAT = "human-pat-must-not-leak" + if err := os.WriteFile(realConfig, []byte(`{"token":"`+humanPAT+`"}`), 0o600); err != nil { + t.Fatalf("write real config: %v", err) + } + t.Setenv("HOME", realHome) + + captureFile := filepath.Join(t.TempDir(), "agent-home.txt") + fakeBin := filepath.Join(t.TempDir(), "claude") + script := `#!/bin/sh +unset MULTICA_TOKEN MULTICA_SERVER_URL MULTICA_DAEMON_PORT MULTICA_WORKSPACE_ID MULTICA_AGENT_NAME MULTICA_AGENT_ID MULTICA_TASK_ID MULTICA_TASK_SLOT +default_config="$HOME/.multica/config.json" +if [ -f "$default_config" ]; then + config_exists=yes + config_content=$(cat "$default_config") +else + config_exists=no + config_content= +fi +printf 'HOME=%s\nXDG_CONFIG_HOME=%s\nUSERPROFILE=%s\nAPPDATA=%s\nLOCALAPPDATA=%s\nHOMEDRIVE=%s\nHOMEPATH=%s\nDEFAULT_CONFIG=%s\nDEFAULT_CONFIG_EXISTS=%s\nDEFAULT_CONFIG_CONTENT=%s\n' \ + "$HOME" "$XDG_CONFIG_HOME" "$USERPROFILE" "$APPDATA" "$LOCALAPPDATA" "$HOMEDRIVE" "$HOMEPATH" \ + "$default_config" "$config_exists" "$config_content" > "$CAPTURE_FILE" +IFS= read -r _ +printf '%s\n' '{"type":"system","session_id":"sess-private-home"}' +printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"session_id":"sess-private-home","result":"done"}' +` + if err := os.WriteFile(fakeBin, []byte(script), 0o755); err != nil { + t.Fatalf("write fake agent: %v", err) + } + + client := NewClient("http://multica.test") + client.client.Transport = taskHomeRoundTripper(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("{}")), + }, nil + }) + + workspacesRoot := t.TempDir() + d := &Daemon{ + taskExecutionCapable: true, + client: client, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + workspaces: make(map[string]*workspaceState), + runtimeIndex: map[string]Runtime{"rt-private-home": {ID: "rt-private-home", Provider: "claude"}}, + activeEnvRoots: make(map[string]int), + taskLauncher: directTaskLauncherFactory, + cfg: Config{ + WorkspacesRoot: workspacesRoot, + AgentTimeout: 15 * time.Second, + ServerBaseURL: "http://multica.test", + Agents: map[string]AgentEntry{ + "claude": {Path: fakeBin}, + }, + }, + } + + overrideRoot := filepath.Join(t.TempDir(), "custom-env-home") + task := Task{ + ID: "20000000-0000-0000-0000-000000000001", + WorkspaceID: "20000000-0000-0000-0000-000000000002", + AgentID: "20000000-0000-0000-0000-000000000003", + RuntimeID: "rt-private-home", + IssueID: "issue-private-home", + AuthToken: "mat_private_home", + Agent: &AgentData{ + ID: "20000000-0000-0000-0000-000000000003", + Name: "private-home-agent", + CustomEnv: map[string]string{ + "CAPTURE_FILE": captureFile, + "HOME": overrideRoot, + "XDG_CONFIG_HOME": filepath.Join(overrideRoot, "xdg"), + "USERPROFILE": overrideRoot, + "APPDATA": filepath.Join(overrideRoot, "appdata"), + "LOCALAPPDATA": filepath.Join(overrideRoot, "localappdata"), + "HOMEDRIVE": "Z:", + "HOMEPATH": `\override`, + }, + }, + } + + result, err := d.runTask(context.Background(), task, "claude", 0, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("runTask(): %v", err) + } + if result.Status != "completed" { + t.Fatalf("runTask status = %q, want completed (comment=%q)", result.Status, result.Comment) + } + + raw, err := os.ReadFile(captureFile) + if err != nil { + t.Fatalf("read captured env: %v", err) + } + got := make(map[string]string) + for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { + key, value, ok := strings.Cut(line, "=") + if !ok { + t.Fatalf("malformed captured env line %q", line) + } + got[key] = value + } + + wantRoot := execenv.PredictRootDir(workspacesRoot, task.WorkspaceID, task.ID) + wantHome := filepath.Join(wantRoot, "home") + wantConfig := filepath.Join(wantHome, ".config") + if got["HOME"] != wantHome { + t.Fatalf("child HOME = %q, want private home %q", got["HOME"], wantHome) + } + if got["XDG_CONFIG_HOME"] != wantConfig { + t.Fatalf("child XDG_CONFIG_HOME = %q, want %q", got["XDG_CONFIG_HOME"], wantConfig) + } + for _, key := range []string{"USERPROFILE", "APPDATA", "LOCALAPPDATA"} { + if got[key] == "" || !strings.HasPrefix(got[key], wantHome) { + t.Fatalf("child %s = %q, want path under %q", key, got[key], wantHome) + } + } + if got["HOMEDRIVE"] == "Z:" || got["HOMEPATH"] == `\override` { + t.Fatalf("custom_env overrode Windows home locators: HOMEDRIVE=%q HOMEPATH=%q", got["HOMEDRIVE"], got["HOMEPATH"]) + } + if got["DEFAULT_CONFIG"] == realConfig || strings.Contains(got["DEFAULT_CONFIG_CONTENT"], humanPAT) { + t.Fatalf("child default config leaked real human PAT: path=%q content=%q", got["DEFAULT_CONFIG"], got["DEFAULT_CONFIG_CONTENT"]) + } + if got["DEFAULT_CONFIG_EXISTS"] != "no" { + t.Fatalf("child default config exists at %q; private HOME must start without .multica/config.json", got["DEFAULT_CONFIG"]) + } + for _, path := range []string{wantHome, wantConfig} { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat private path %q: %v", path, err) + } + if mode := info.Mode().Perm(); mode != 0o700 { + t.Fatalf("mode(%q) = %#o, want 0700", path, mode) + } + } +} + +type taskHomeRoundTripper func(*http.Request) (*http.Response, error) + +func (f taskHomeRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +type directTaskCommandLauncher struct{} + +type directTaskCommand struct { + cmd *exec.Cmd +} + +func (c *directTaskCommand) Start() error { return c.cmd.Start() } +func (c *directTaskCommand) Wait() error { return c.cmd.Wait() } +func (c *directTaskCommand) Run() error { return c.cmd.Run() } +func (c *directTaskCommand) Output() ([]byte, error) { return c.cmd.Output() } +func (c *directTaskCommand) CombinedOutput() ([]byte, error) { return c.cmd.CombinedOutput() } +func (c *directTaskCommand) Close() error { return nil } +func (c *directTaskCommand) StdoutPipe() (io.ReadCloser, error) { return c.cmd.StdoutPipe() } +func (c *directTaskCommand) StdinPipe() (io.WriteCloser, error) { return c.cmd.StdinPipe() } +func (c *directTaskCommand) StderrPipe() (io.ReadCloser, error) { return c.cmd.StderrPipe() } +func (c *directTaskCommand) SetStderr(w io.Writer) error { c.cmd.Stderr = w; return nil } +func (c *directTaskCommand) SetCancel(cancel func() error) error { c.cmd.Cancel = cancel; return nil } +func (c *directTaskCommand) Process() *os.Process { return c.cmd.Process } +func (c *directTaskCommand) ProcessState() *os.ProcessState { return c.cmd.ProcessState } +func (c *directTaskCommand) Environment() []string { return append([]string(nil), c.cmd.Env...) } + +func (directTaskCommandLauncher) Command(ctx context.Context, req agent.CommandRequest) (agent.TaskCommand, error) { + cmd := exec.CommandContext(ctx, req.Executable, req.Args...) + cmd.Dir = req.Cwd + keys := make([]string, 0, len(req.Env)) + for key := range req.Env { + keys = append(keys, key) + } + slices.Sort(keys) + for _, key := range keys { + cmd.Env = append(cmd.Env, key+"="+req.Env[key]) + } + cmd.WaitDelay = req.WaitDelay + if len(req.LeadingExtraFiles) > 0 { + cmd.ExtraFiles = append([]*os.File(nil), req.LeadingExtraFiles...) + } + return &directTaskCommand{cmd: cmd}, nil +} + +func directTaskLauncherFactory() agent.CommandBuilder { + return directTaskCommandLauncher{} +} + +func recordTaskAuthorityDir(t *testing.T) (func() (string, error), *string) { + t.Helper() + root := t.TempDir() + var created string + return func() (string, error) { + if created != "" { + return "", errors.New("task authority directory created more than once") + } + created = filepath.Join(root, "authority") + if err := os.Mkdir(created, 0o700); err != nil { + return "", err + } + return created, nil + }, &created +} + +func assertTaskAuthorityDirRemoved(t *testing.T, created *string) { + t.Helper() + if *created == "" { + t.Fatal("task authority directory was not created") + } + if _, err := os.Stat(*created); !os.IsNotExist(err) { + t.Fatalf("task authority directory %q was not removed: %v", *created, err) + } +} + +type runTaskRepoCache struct { + syncErr error + path string + syncStarted chan struct{} +} + +func (c *runTaskRepoCache) LookupContext(context.Context, string, string) string { return c.path } +func (c *runTaskRepoCache) ResolveContext(_ context.Context, _ string, url string) (repocache.ResolvedRepo, error) { + if c.path == "" { + return repocache.ResolvedRepo{}, errors.New("repo unavailable") + } + return repocache.ResolvedRepo{URL: url, BarePath: c.path}, nil +} +func (c *runTaskRepoCache) SyncContext(ctx context.Context, _ string, _ []repocache.RepoInfo) error { + if c.syncStarted != nil { + close(c.syncStarted) + <-ctx.Done() + return ctx.Err() + } + return c.syncErr +} +func (c *runTaskRepoCache) WithRepoLock(_ string, fn func() error) error { return fn() } +func (c *runTaskRepoCache) CreateWorktreeContext(context.Context, repocache.WorktreeParams) (*repocache.WorktreeResult, error) { + return nil, errors.New("unexpected CreateWorktree call") +} + +func TestRunTaskInjectsAndRevokesRepoCheckoutCapability(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-script agent fixture is POSIX-only") + } + + captureFile := filepath.Join(t.TempDir(), "checkout-token.txt") + fakeBin := filepath.Join(t.TempDir(), "claude") + script := `#!/bin/sh +printf '%s' "$MULTICA_REPO_CHECKOUT_TOKEN" > "$CAPTURE_FILE" +IFS= read -r _ +printf '%s\n' '{"type":"system","session_id":"sess-capability"}' +printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"session_id":"sess-capability","result":"done"}' +` + if err := os.WriteFile(fakeBin, []byte(script), 0o755); err != nil { + t.Fatalf("write fake agent: %v", err) + } + + client := NewClient("http://multica.test") + client.client.Transport = taskHomeRoundTripper(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("{}")), + }, nil + }) + repoPath := filepath.Join(t.TempDir(), "repo.git") + if err := os.MkdirAll(repoPath, 0o700); err != nil { + t.Fatalf("create repo cache fixture: %v", err) + } + authorityDir, createdAuthorityDir := recordTaskAuthorityDir(t) + d := &Daemon{ + taskExecutionCapable: true, + client: client, + repoCache: &runTaskRepoCache{path: repoPath}, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + workspaces: make(map[string]*workspaceState), + runtimeIndex: map[string]Runtime{"rt-capability": {ID: "rt-capability", Provider: "claude"}}, + activeEnvRoots: make(map[string]int), + repoCheckoutCapabilities: make(map[string]*repoCheckoutCapability), + taskLauncher: directTaskLauncherFactory, + taskAuthorityDir: authorityDir, + cfg: Config{ + WorkspacesRoot: t.TempDir(), + AgentTimeout: 15 * time.Second, + ServerBaseURL: "http://multica.test", + Agents: map[string]AgentEntry{ + "claude": {Path: fakeBin}, + }, + }, + } + task := Task{ + ID: "30000000-0000-0000-0000-000000000001", + WorkspaceID: "30000000-0000-0000-0000-000000000002", + AgentID: "30000000-0000-0000-0000-000000000003", + RuntimeID: "rt-capability", + IssueID: "issue-capability", + AuthToken: "mat_capability", + Repos: []RepoData{{URL: "https://example.com/repo.git", Ref: "refs/heads/main"}}, + Agent: &AgentData{ + ID: "30000000-0000-0000-0000-000000000003", + Name: "capability-agent", + CustomEnv: map[string]string{ + "CAPTURE_FILE": captureFile, + "MULTICA_REPO_CHECKOUT_TOKEN": "attacker-controlled-token", + }, + }, + } + + result, err := d.runTask(context.Background(), task, "claude", 0, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("runTask(): %v", err) + } + if result.Status != "completed" { + t.Fatalf("runTask status = %q, want completed", result.Status) + } + raw, err := os.ReadFile(captureFile) + if err != nil { + t.Fatalf("read captured checkout token: %v", err) + } + token := strings.TrimSpace(string(raw)) + if token == "" || token == "attacker-controlled-token" { + t.Fatalf("child checkout token = %q, want non-empty daemon-issued token", token) + } + if _, release, ok := d.acquireRepoCheckoutCapability(token); ok { + release() + t.Fatal("checkout capability remained valid after runTask returned") + } + if len(d.repoCheckoutCapabilities) != 0 { + t.Fatalf("capability registry contains %d entries after task completion", len(d.repoCheckoutCapabilities)) + } + assertTaskAuthorityDirRemoved(t, createdAuthorityDir) +} + +func TestRunTaskRepoCacheFailureDoesNotStartBackend(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-script agent fixture is POSIX-only") + } + + startedFile := filepath.Join(t.TempDir(), "backend-started") + fakeBin := filepath.Join(t.TempDir(), "claude") + script := `#!/bin/sh +: > "$STARTED_FILE" +exit 1 +` + if err := os.WriteFile(fakeBin, []byte(script), 0o755); err != nil { + t.Fatalf("write fake agent: %v", err) + } + client := NewClient("http://multica.test") + client.client.Transport = taskHomeRoundTripper(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("{}")), + }, nil + }) + authorityDir, createdAuthorityDir := recordTaskAuthorityDir(t) + d := &Daemon{ + taskExecutionCapable: true, + client: client, + repoCache: &runTaskRepoCache{syncErr: errors.New("cache unavailable")}, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + workspaces: make(map[string]*workspaceState), + runtimeIndex: map[string]Runtime{"rt-cache-fail": {ID: "rt-cache-fail", Provider: "claude"}}, + activeEnvRoots: make(map[string]int), + repoCheckoutCapabilities: make(map[string]*repoCheckoutCapability), + taskLauncher: directTaskLauncherFactory, + taskAuthorityDir: authorityDir, + cfg: Config{ + WorkspacesRoot: t.TempDir(), + AgentTimeout: 5 * time.Second, + ServerBaseURL: "http://multica.test", + Agents: map[string]AgentEntry{ + "claude": {Path: fakeBin}, + }, + }, + } + task := Task{ + ID: "40000000-0000-0000-0000-000000000001", + WorkspaceID: "40000000-0000-0000-0000-000000000002", + AgentID: "40000000-0000-0000-0000-000000000003", + RuntimeID: "rt-cache-fail", + IssueID: "issue-cache-fail", + AuthToken: "mat_cache_fail", + Repos: []RepoData{{URL: "https://example.com/repo.git"}}, + Agent: &AgentData{ + ID: "40000000-0000-0000-0000-000000000003", + Name: "cache-fail-agent", + CustomEnv: map[string]string{ + "STARTED_FILE": startedFile, + }, + }, + } + + _, err := d.runTask(context.Background(), task, "claude", 0, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err == nil || !strings.Contains(err.Error(), "cache unavailable") { + t.Fatalf("runTask error = %v, want cache failure", err) + } + if _, statErr := os.Stat(startedFile); !os.IsNotExist(statErr) { + t.Fatalf("backend started despite cache failure: stat error = %v", statErr) + } + if len(d.repoCheckoutCapabilities) != 0 { + t.Fatalf("cache failure registered %d checkout capabilities", len(d.repoCheckoutCapabilities)) + } + assertTaskAuthorityDirRemoved(t, createdAuthorityDir) +} + +func TestRunTaskCancellationCleansTaskAuthority(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-script agent fixture is POSIX-only") + } + + fakeBin := filepath.Join(t.TempDir(), "claude") + if err := os.WriteFile(fakeBin, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { + t.Fatal(err) + } + client := NewClient("http://multica.test") + client.client.Transport = taskHomeRoundTripper(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("{}"))}, nil + }) + cache := &runTaskRepoCache{syncStarted: make(chan struct{})} + authorityDir, createdAuthorityDir := recordTaskAuthorityDir(t) + d := &Daemon{ + taskExecutionCapable: true, + client: client, + repoCache: cache, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + workspaces: make(map[string]*workspaceState), + runtimeIndex: map[string]Runtime{"rt-cancel": {ID: "rt-cancel", Provider: "claude"}}, + activeEnvRoots: make(map[string]int), + taskLauncher: directTaskLauncherFactory, + taskAuthorityDir: authorityDir, + cfg: Config{ + WorkspacesRoot: t.TempDir(), ServerBaseURL: "http://multica.test", + Agents: map[string]AgentEntry{"claude": {Path: fakeBin}}, + }, + } + task := Task{ + ID: "41000000-0000-0000-0000-000000000001", WorkspaceID: "41000000-0000-0000-0000-000000000002", + AgentID: "41000000-0000-0000-0000-000000000003", RuntimeID: "rt-cancel", IssueID: "issue-cancel", + AuthToken: "mat_cancel", Repos: []RepoData{{URL: "https://example.com/repo.git"}}, + Agent: &AgentData{ID: "41000000-0000-0000-0000-000000000003", Name: "cancel-agent"}, + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + _, err := d.runTask(ctx, task, "claude", 0, slog.New(slog.NewTextHandler(io.Discard, nil))) + done <- err + }() + select { + case <-cache.syncStarted: + case <-time.After(time.Second): + t.Fatal("repo cache sync did not start") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("runTask error = %v, want context.Canceled", err) + } + case <-time.After(time.Second): + t.Fatal("runTask ignored cancellation") + } + assertTaskAuthorityDirRemoved(t, createdAuthorityDir) +} + +func TestRunTaskLauncherFailureCleansTaskAuthority(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("executable fixture is POSIX-only") + } + fakeBin := filepath.Join(t.TempDir(), "claude") + if err := os.WriteFile(fakeBin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + client := NewClient("http://multica.test") + client.client.Transport = taskHomeRoundTripper(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("{}"))}, nil + }) + authorityDir, createdAuthorityDir := recordTaskAuthorityDir(t) + d := &Daemon{ + taskExecutionCapable: true, client: client, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + workspaces: make(map[string]*workspaceState), runtimeIndex: map[string]Runtime{"rt-launcher": {ID: "rt-launcher", Provider: "claude"}}, + activeEnvRoots: make(map[string]int), taskAuthorityDir: authorityDir, + cfg: Config{WorkspacesRoot: t.TempDir(), ServerBaseURL: "http://multica.test", Agents: map[string]AgentEntry{"claude": {Path: fakeBin}}}, + } + task := Task{ + ID: "42000000-0000-0000-0000-000000000001", WorkspaceID: "42000000-0000-0000-0000-000000000002", + AgentID: "42000000-0000-0000-0000-000000000003", RuntimeID: "rt-launcher", IssueID: "issue-launcher", + AuthToken: "mat_launcher", Agent: &AgentData{ID: "42000000-0000-0000-0000-000000000003", Name: "launcher-agent"}, + } + _, err := d.runTask(context.Background(), task, "claude", 0, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err == nil || !strings.Contains(err.Error(), "task command launcher factory is unavailable") { + t.Fatalf("runTask error = %v, want launcher failure", err) + } + assertTaskAuthorityDirRemoved(t, createdAuthorityDir) +} + +func TestRunTaskLocalDirectoryKeepsAuthorityOutsideWorkDir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-script agent fixture is POSIX-only") + } + workDir := t.TempDir() + fakeBin := filepath.Join(t.TempDir(), "claude") + script := `#!/bin/sh +IFS= read -r _ +printf '%s\n' '{"type":"system","session_id":"sess-local-authority"}' +printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"session_id":"sess-local-authority","result":"done"}' +` + if err := os.WriteFile(fakeBin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + client := NewClient("http://multica.test") + client.client.Transport = taskHomeRoundTripper(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("{}"))}, nil + }) + authorityDir, createdAuthorityDir := recordTaskAuthorityDir(t) + d := &Daemon{ + taskExecutionCapable: true, client: client, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + workspaces: make(map[string]*workspaceState), runtimeIndex: map[string]Runtime{"rt-local": {ID: "rt-local", Provider: "claude"}}, + activeEnvRoots: make(map[string]int), taskLauncher: directTaskLauncherFactory, taskAuthorityDir: authorityDir, + cfg: Config{DaemonID: "daemon-local", WorkspacesRoot: t.TempDir(), AgentTimeout: 5 * time.Second, ServerBaseURL: "http://multica.test", Agents: map[string]AgentEntry{"claude": {Path: fakeBin}}}, + } + resourceRef, err := json.Marshal(localDirectoryRef{LocalPath: workDir, DaemonID: "daemon-local"}) + if err != nil { + t.Fatal(err) + } + task := Task{ + ID: "43000000-0000-0000-0000-000000000001", WorkspaceID: "43000000-0000-0000-0000-000000000002", + AgentID: "43000000-0000-0000-0000-000000000003", RuntimeID: "rt-local", IssueID: "issue-local", + AuthToken: "mat_local", Agent: &AgentData{ID: "43000000-0000-0000-0000-000000000003", Name: "local-agent"}, + ProjectResources: []ProjectResourceData{{ResourceType: localDirectoryResourceType, ResourceRef: resourceRef}}, + } + result, err := d.runTask(context.Background(), task, "claude", 0, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil || result.Status != "completed" { + t.Fatalf("runTask = (%#v, %v), want completed", result, err) + } + err = filepath.WalkDir(workDir, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.Name() == taskauth.FileName { + t.Fatalf("task authority was written into local workdir at %q", path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + assertTaskAuthorityDirRemoved(t, createdAuthorityDir) +} + func TestTaskScopedAuthToken(t *testing.T) { t.Parallel() @@ -289,6 +853,14 @@ func TestTaskScopedAuthToken(t *testing.T) { } } +func TestRunTaskRefusesWithoutTaskExecutionCapability(t *testing.T) { + d := &Daemon{} + _, err := d.runTask(context.Background(), Task{}, "codex", 0, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err == nil || !strings.Contains(err.Error(), "secure Linux task executor is unavailable") { + t.Fatalf("runTask error = %v, want secure executor refusal", err) + } +} + // When `brew --prefix` is unavailable but the executable path is under a // known Cellar root, triggerRestart must recover the prefix from the // known-prefix list and target /bin/multica. @@ -424,80 +996,6 @@ func TestProviderNeedsInlineSystemPrompt(t *testing.T) { } } -// TestComposeOpenclawIncludeRoots — the Elon must-fix regression: the -// daemon must grant OpenClaw permission to follow the wrapper's $include -// link from envRoot into the user's active config dir, while preserving -// any roots the user already configured in their shell env so their own -// cross-directory layouts keep working. -func TestComposeOpenclawIncludeRoots(t *testing.T) { - t.Parallel() - - sep := string(os.PathListSeparator) - cases := []struct { - name string - add string - user string - want string - wantSet bool - }{ - { - // Fresh install — preparer emits no $include, so daemon - // shouldn't touch OPENCLAW_INCLUDE_ROOTS at all. - name: "fresh_install_no_root_to_grant", - add: "", - user: "/some/user/dir", - wantSet: false, - }, - { - // User has no existing value — output is just the granted dir. - name: "no_user_value", - add: "/home/alice/.openclaw", - user: "", - want: "/home/alice/.openclaw", - wantSet: true, - }, - { - // User has their own include roots — daemon must prepend - // granted dir AND preserve user's entries verbatim. - name: "preserves_user_value", - add: "/home/alice/.openclaw", - user: "/etc/openclaw" + sep + "/opt/openclaw/shared", - want: "/home/alice/.openclaw" + sep + "/etc/openclaw" + sep + "/opt/openclaw/shared", - wantSet: true, - }, - { - // User's value already contains the granted dir — daemon - // must dedupe rather than emit a redundant entry that would - // trip OpenClaw confused-deputy heuristics. - name: "dedupes_when_user_already_grants_same_dir", - add: "/home/alice/.openclaw", - user: "/home/alice/.openclaw" + sep + "/etc/openclaw", - want: "/home/alice/.openclaw" + sep + "/etc/openclaw", - wantSet: true, - }, - { - // Stray empty segments from a malformed user env are skipped. - name: "skips_empty_segments_in_user_value", - add: "/home/alice/.openclaw", - user: "" + sep + "/etc/openclaw" + sep + "", - want: "/home/alice/.openclaw" + sep + "/etc/openclaw", - wantSet: true, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got, ok := composeOpenclawIncludeRoots(tc.add, tc.user) - if ok != tc.wantSet { - t.Fatalf("ok = %v, want %v (got = %q)", ok, tc.wantSet, got) - } - if got != tc.want { - t.Errorf("got = %q, want %q", got, tc.want) - } - }) - } -} - func TestBuildPromptContainsIssueID(t *testing.T) { t.Parallel() @@ -1107,11 +1605,6 @@ func newRepoReadyTestDaemon(t *testing.T, handler http.HandlerFunc) *Daemon { workspaces: make(map[string]*workspaceState), runtimeIndex: make(map[string]Runtime), } - // Drain background syncs (started by registerTaskRepos) before the - // t.TempDir cache root is cleaned up, otherwise an in-flight clone/fetch - // races against the deletion and the test fails with a misleading - // "directory not empty" cleanup error. - t.Cleanup(d.waitBackgroundSyncs) return d } @@ -1165,6 +1658,58 @@ func TestGateCodexResumeToRolloutPresence(t *testing.T) { } } +func TestGatePiResumeToTaskState(t *testing.T) { + t.Parallel() + + stateDir := t.TempDir() + sessionID := "0123456789abcdef0123456789abcdef" + sessionDir := filepath.Join(stateDir, "pi-sessions") + if err := os.MkdirAll(sessionDir, 0o700); err != nil { + t.Fatalf("mkdir sessions: %v", err) + } + if err := os.WriteFile(filepath.Join(sessionDir, sessionID+".jsonl"), []byte("{}"), 0o600); err != nil { + t.Fatalf("write session: %v", err) + } + + symlinkID := "abcdef0123456789abcdef0123456789" + if err := os.Symlink(filepath.Join(sessionDir, sessionID+".jsonl"), filepath.Join(sessionDir, symlinkID+".jsonl")); err != nil { + t.Fatalf("create session symlink: %v", err) + } + + tests := []struct { + name string + provider string + sessionID string + wantSession string + }{ + {name: "task-private session present keeps resume", provider: "pi", sessionID: sessionID, wantSession: sessionID}, + {name: "legacy absolute path drops resume", provider: "pi", sessionID: filepath.Join(stateDir, "legacy.jsonl"), wantSession: ""}, + {name: "missing session drops resume", provider: "pi", sessionID: "11111111111111111111111111111111", wantSession: ""}, + {name: "symlink session drops resume", provider: "pi", sessionID: symlinkID, wantSession: ""}, + {name: "non-pi provider is a no-op", provider: "codex", sessionID: "legacy-session", wantSession: "legacy-session"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + task := Task{PriorSessionID: tt.sessionID} + taskCtx := execenv.TaskContextForEnv{PriorSessionResumed: tt.sessionID != ""} + + gatePiResumeToTaskState(&task, &taskCtx, tt.provider, stateDir, slog.Default()) + + if task.PriorSessionID != tt.wantSession { + t.Fatalf("PriorSessionID = %q, want %q", task.PriorSessionID, tt.wantSession) + } + if taskCtx.PriorSessionResumed != (tt.wantSession != "") { + t.Fatalf("PriorSessionResumed = %v, want %v", taskCtx.PriorSessionResumed, tt.wantSession != "") + } + wantUnavailable := tt.sessionID != "" && tt.wantSession == "" + if taskCtx.PriorSessionResumeUnavailable != wantUnavailable { + t.Fatalf("PriorSessionResumeUnavailable = %v, want %v", taskCtx.PriorSessionResumeUnavailable, wantUnavailable) + } + }) + } +} + func TestGateResumeToReusedWorkdir(t *testing.T) { t.Parallel() @@ -1601,7 +2146,7 @@ func TestExecuteAndDrain_CodexInactivityReportsToolResultTranscript(t *testing.T `echo '{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"thr-drain","turn":{"id":"turn-drain"}}}'` + "\n" + `echo '{"jsonrpc":"2.0","method":"item/started","params":{"threadId":"thr-drain","item":{"type":"commandExecution","id":"cmd-1","command":"git status"}}}'` + "\n" + `echo '{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thr-drain","item":{"type":"commandExecution","id":"cmd-1","aggregatedOutput":"clean"}}}'` + "\n" + - `sleep 5` + "\n" + `sleep 15` + "\n" if err := os.WriteFile(fakePath, []byte(script), 0o755); err != nil { t.Fatalf("write fake codex: %v", err) } @@ -1631,13 +2176,21 @@ func TestExecuteAndDrain_CodexInactivityReportsToolResultTranscript(t *testing.T })) t.Cleanup(srv.Close) - backend, err := agent.New("codex", agent.Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := agent.New("codex", agent.Config{ + ExecutablePath: fakePath, + Logger: slog.Default(), + Launcher: directTaskCommandLauncher{}, + Isolation: &agent.TaskIsolationPolicy{ + WritableRoots: []string{filepath.Dir(fakePath)}, + Network: agent.NetworkAccessPublicAndLoopback, + }, + }) if err != nil { t.Fatalf("new codex backend: %v", err) } d := &Daemon{client: NewClient(srv.URL), logger: slog.Default()} result, tools, err := d.executeAndDrain(context.Background(), backend, "prompt", agent.ExecOptions{ - Timeout: 5 * time.Second, + Timeout: 15 * time.Second, SemanticInactivityTimeout: 100 * time.Millisecond, }, slog.Default(), "task-stale", new(atomic.Int32)) if err != nil { @@ -2104,7 +2657,7 @@ func TestEnsureRepoReadyCachedRepoStillRefreshesSettings(t *testing.T) { Settings: json.RawMessage(`{"github_enabled":false,"co_authored_by_enabled":true}`), }) }) - if err := d.repoCache.Sync("ws-1", []repocache.RepoInfo{{URL: sourceRepo}}); err != nil { + if err := d.repoCache.SyncContext(context.Background(), "ws-1", []repocache.RepoInfo{{URL: sourceRepo}}); err != nil { t.Fatalf("seed repo cache: %v", err) } // Workspace starts with the master switch ON. The server above will return @@ -2149,7 +2702,7 @@ func TestEnsureRepoReadyTrimsURL(t *testing.T) { ReposVersion: "v2", }) }) - if err := d.repoCache.Sync("ws-1", []repocache.RepoInfo{{URL: sourceRepo}}); err != nil { + if err := d.repoCache.SyncContext(context.Background(), "ws-1", []repocache.RepoInfo{{URL: sourceRepo}}); err != nil { t.Fatalf("seed repo cache: %v", err) } d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "v1", []RepoData{{URL: sourceRepo}}, nil) @@ -2189,128 +2742,428 @@ func TestEnsureRepoReadyRefreshesOnMiss(t *testing.T) { if got := refreshCalls.Load(); got != 1 { t.Fatalf("expected 1 refresh call, got %d", got) } - if d.repoCache.Lookup("ws-1", sourceRepo) == "" { + if d.repoCache.LookupContext(context.Background(), "ws-1", sourceRepo) == "" { t.Fatal("expected repo to be cached after refresh") } } -// A project github_repo URL that the workspace itself does not bind must still -// be allowed for `multica repo checkout` after registerTaskRepos runs. Without -// this, the new project-repos-override-workspace-repos behavior would surface -// repos in the meta-skill that the agent then can't actually clone. -func TestRegisterTaskReposAllowsProjectOnlyURL(t *testing.T) { +// A project github_repo URL that the workspace itself does not bind is cached +// before task launch without becoming workspace-level authorization. +func TestPrepareTaskRepoCacheCachesProjectOnlyURLWithoutWideningWorkspace(t *testing.T) { t.Parallel() sourceRepo := createDaemonTestRepo(t) - var refreshCalls atomic.Int32 d := newRepoReadyTestDaemon(t, func(w http.ResponseWriter, r *http.Request) { - refreshCalls.Add(1) - // If the workspace endpoint is hit it returns an empty list — the - // project-only URL must NOT depend on this for allowlist membership. - json.NewEncoder(w).Encode(WorkspaceReposResponse{ - WorkspaceID: "ws-1", - Repos: []RepoData{}, - ReposVersion: "v1", - }) + t.Fatal("prepareTaskRepoCache must not read workspace management state") }) - // Workspace has zero workspace-bound repos; the project resource gives us - // the only repo URL the agent should be able to check out. d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil, nil) - d.registerTaskRepos("ws-1", "task-project-only", []RepoData{{URL: sourceRepo}}) + resolved, err := d.prepareTaskRepoCache(context.Background(), "ws-1", []RepoData{{URL: sourceRepo, Ref: "main"}}) + if err != nil { + t.Fatalf("prepareTaskRepoCache: %v", err) + } + if len(resolved) != 1 { + t.Fatalf("resolved repos = %#v, want one", resolved) + } + if resolved[0].URL != sourceRepo || resolved[0].Ref != "main" { + t.Fatalf("resolved repo identity = %#v", resolved[0]) + } + if resolved[0].BarePath != d.repoCache.LookupContext(context.Background(), "ws-1", sourceRepo) { + t.Fatalf("resolved bare path = %q, want exact cache path", resolved[0].BarePath) + } + if d.repoCache.LookupContext(context.Background(), "ws-1", sourceRepo) == "" { + t.Fatal("expected project repo to be cached before task launch") + } + if d.workspaceRepoAllowed("ws-1", sourceRepo) { + t.Fatal("task repo widened workspace authorization") + } +} + +func TestPrepareTaskRepoCacheRejectsConflictingRefs(t *testing.T) { + t.Parallel() + + const repoURL = "https://github.com/example/shared.git" + d := &Daemon{repoCache: &recordingRepoCache{lookupPath: "/cache/shared.git"}} + _, err := d.prepareTaskRepoCache(context.Background(), "ws-1", []RepoData{ + {URL: repoURL, Ref: "release/a"}, + {URL: repoURL, Ref: "release/b"}, + }) + if err == nil || !strings.Contains(err.Error(), "conflicting refs") { + t.Fatalf("prepareTaskRepoCache error = %v, want conflicting refs", err) + } +} + +func TestPrepareTaskRepoCachePropagatesTaskCancellation(t *testing.T) { + t.Parallel() + + cache := &runTaskRepoCache{syncStarted: make(chan struct{})} + d := &Daemon{repoCache: cache} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + _, err := d.prepareTaskRepoCache(ctx, "ws-1", []RepoData{{URL: "https://github.com/example/repo.git"}}) + done <- err + }() - // The async clone goroutine in registerTaskRepos may not have finished; - // poll briefly until the cache is populated so the test isn't racy. - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - if d.repoCache.Lookup("ws-1", sourceRepo) != "" { - break + select { + case <-cache.syncStarted: + case <-time.After(time.Second): + t.Fatal("repo cache sync did not start") + } + cancel() + + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("prepareTaskRepoCache error = %v, want context.Canceled", err) } - time.Sleep(20 * time.Millisecond) + case <-time.After(time.Second): + t.Fatal("prepareTaskRepoCache ignored task cancellation") + } +} + +func TestBuildTaskIsolationPolicyUsesExactTaskAuthority(t *testing.T) { + t.Parallel() + + root := t.TempDir() + envRoot := filepath.Join(root, "env") + workDir := filepath.Join(root, "external-worktree") + taskTempDir := filepath.Join(envRoot, "tmp") + authorityDir := filepath.Join(root, "daemon-private-authority") + bareRepo := filepath.Join(root, "cache", "repo.git") + providerRoot := filepath.Join(root, "runtime", "node_modules", "@vendor", "agent") + providerBin := filepath.Join(providerRoot, "bin", "agent.js") + nodeBin := filepath.Join(root, "runtime", "bin", "node") + selfExecutable := filepath.Join(root, "bin", "multica") + for _, dir := range []string{envRoot, workDir, taskTempDir, authorityDir, bareRepo, filepath.Dir(providerBin), filepath.Dir(nodeBin), filepath.Dir(selfExecutable)} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %q: %v", dir, err) + } + } + if err := os.WriteFile(providerBin, []byte("#!/usr/bin/env node\n"), 0o755); err != nil { + t.Fatalf("write provider: %v", err) + } + if err := os.WriteFile(nodeBin, []byte("runtime"), 0o755); err != nil { + t.Fatalf("write node: %v", err) } - if d.repoCache.Lookup("ws-1", sourceRepo) == "" { - t.Fatalf("expected repo to be cached after registerTaskRepos, but Lookup returned empty") + if err := os.WriteFile(selfExecutable, []byte("multica"), 0o755); err != nil { + t.Fatalf("write self executable: %v", err) } + authorityPath := writeTaskAuthorityFixture(t, authorityDir) - if !d.workspaceRepoAllowed("ws-1", sourceRepo) { - t.Fatal("expected project repo to pass workspaceRepoAllowed") + ownerHome := filepath.Join(root, "owner") + for _, rel := range []string{".multica", ".codex", ".config/opencode"} { + if err := os.MkdirAll(filepath.Join(ownerHome, rel), 0o700); err != nil { + t.Fatalf("mkdir owner config: %v", err) + } } - if err := d.ensureRepoReady(context.Background(), "ws-1", sourceRepo); err != nil { - t.Fatalf("ensureRepoReady: %v", err) + policy, taskPath, err := buildTaskIsolationPolicy(taskIsolationParams{ + Environment: &execenv.Environment{ + RootDir: envRoot, + WorkDir: workDir, + LocalDirectory: true, + }, + TaskTempDir: taskTempDir, + TaskAuthority: authorityPath, + Repos: []resolvedTaskRepo{{URL: "https://example.com/repo.git", BarePath: bareRepo}}, + Executable: providerBin, + OwnerHome: ownerHome, + SelfExecutable: selfExecutable, + LookupPath: func(name string) (string, error) { + if name != "node" { + return "", exec.ErrNotFound + } + return nodeBin, nil + }, + }) + if err != nil { + t.Fatalf("buildTaskIsolationPolicy: %v", err) + } + envRoot = resolvedTestPath(t, envRoot) + workDir = resolvedTestPath(t, workDir) + taskTempDir = resolvedTestPath(t, taskTempDir) + bareRepo = resolvedTestPath(t, bareRepo) + providerRoot = resolvedTestPath(t, providerRoot) + providerBin = resolvedTestPath(t, providerBin) + nodeBin = resolvedTestPath(t, nodeBin) + ownerHome = resolvedTestPath(t, ownerHome) + for _, want := range []string{envRoot, workDir, taskTempDir} { + if !slices.Contains(policy.WritableRoots, want) { + t.Errorf("writable roots %v missing %q", policy.WritableRoots, want) + } } - // ensureRepoReady refreshes settings on every call (RFC MUL-2414 §4.8; PR - // #2847 review by Emacs) so a freshly-flipped GitHub toggle takes effect - // without waiting for the 30s sync tick. We expect exactly one refresh — - // the project-only URL still skips re-cloning because the cache is warm. - if got := refreshCalls.Load(); got != 1 { - t.Fatalf("expected 1 workspace-repos refresh (settings live-refresh on checkout), got %d", got) + if !slices.Contains(policy.ReadOnlyRoots, bareRepo) { + t.Errorf("read-only roots %v missing exact repo %q", policy.ReadOnlyRoots, bareRepo) + } + if len(policy.ReadOnlyFiles) != 1 || policy.ReadOnlyFiles[0].Source != resolvedTestPath(t, authorityPath) || policy.ReadOnlyFiles[0].Target != taskauth.FixedPath { + t.Fatalf("read-only authority mounts = %#v", policy.ReadOnlyFiles) } + if slices.Contains(policy.WritableRoots, resolvedTestPath(t, authorityDir)) { + t.Fatalf("authority source directory %q must not be writable", authorityDir) + } + if slices.Contains(policy.WritableRoots, filepath.Dir(bareRepo)) || slices.Contains(policy.ReadOnlyRoots, filepath.Dir(bareRepo)) { + t.Fatalf("policy exposed repo cache parent %q", filepath.Dir(bareRepo)) + } + if !slices.Contains(policy.ReadOnlyRoots, providerRoot) { + t.Errorf("read-only roots %v missing provider package %q", policy.ReadOnlyRoots, providerRoot) + } + if !slices.Contains(policy.ReadOnlyRoots, filepath.Dir(nodeBin)) { + t.Errorf("read-only roots %v missing interpreter directory %q", policy.ReadOnlyRoots, filepath.Dir(nodeBin)) + } + for _, rel := range []string{".multica", ".codex", ".config/opencode"} { + want := filepath.Join(ownerHome, rel) + if !slices.Contains(policy.ForbiddenRoots, want) { + t.Errorf("forbidden roots %v missing %q", policy.ForbiddenRoots, want) + } + } + if policy.Network != agent.NetworkAccessPublicAndLoopback { + t.Fatalf("network = %v, want public and loopback", policy.Network) + } + pathParts := filepath.SplitList(taskPath) + for _, want := range []string{filepath.Dir(nodeBin), filepath.Dir(providerBin)} { + if !slices.Contains(pathParts, want) { + t.Errorf("task PATH %v missing %q", pathParts, want) + } + } + if strings.Contains(taskPath, os.Getenv("PATH")) { + t.Fatalf("task PATH inherited daemon PATH verbatim: %q", taskPath) + } +} + +func writeTaskAuthorityFixture(t *testing.T, root string) string { + t.Helper() + path, err := taskauth.Write(root, taskauth.Authority{ + ManagedBy: taskauth.ManagedBy, + Version: taskauth.Version, + ServerURL: "https://api.example.test", + WorkspaceID: "10000000-0000-0000-0000-000000000001", + Token: "mat_test_authority", + TaskID: "10000000-0000-0000-0000-000000000002", + AgentID: "10000000-0000-0000-0000-000000000003", + }) + if err != nil { + t.Fatalf("write task authority fixture: %v", err) + } + return path +} + +func resolvedTestPath(t *testing.T, path string) string { + t.Helper() + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + t.Fatalf("resolve test path %q: %v", path, err) + } + return resolved } -// Confirms that a workspace refresh wiping allowedRepoURLs does not also wipe -// task-scoped URLs (project repos). Without the separate taskRepoURLs map a -// concurrent refresh would silently revoke project-only URLs and the next -// checkout would fail. -func TestRegisterTaskReposSurvivesWorkspaceRefresh(t *testing.T) { +func TestBuildTaskIsolationPolicyRejectsOwnerConfigOverlap(t *testing.T) { t.Parallel() - sourceRepo := createDaemonTestRepo(t) - d := newRepoReadyTestDaemon(t, func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(WorkspaceReposResponse{ - WorkspaceID: "ws-1", - Repos: []RepoData{}, - ReposVersion: "v2", - }) + ownerHome := t.TempDir() + envRoot := filepath.Join(ownerHome, ".codex", "task") + if err := os.MkdirAll(envRoot, 0o700); err != nil { + t.Fatal(err) + } + executable := filepath.Join(t.TempDir(), "agent") + if err := os.WriteFile(executable, []byte("binary"), 0o755); err != nil { + t.Fatal(err) + } + _, _, err := buildTaskIsolationPolicy(taskIsolationParams{ + Environment: &execenv.Environment{RootDir: envRoot, WorkDir: envRoot}, + TaskTempDir: envRoot, + TaskAuthority: writeTaskAuthorityFixture(t, t.TempDir()), + Executable: executable, + OwnerHome: ownerHome, + SelfExecutable: executable, }) - d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil, nil) - d.registerTaskRepos("ws-1", "task-refresh", []RepoData{{URL: sourceRepo}}) + if err == nil || !strings.Contains(err.Error(), "forbidden") { + t.Fatalf("buildTaskIsolationPolicy error = %v, want forbidden overlap", err) + } +} - // Wait for the registration to populate the cache. - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) && d.repoCache.Lookup("ws-1", sourceRepo) == "" { - time.Sleep(20 * time.Millisecond) +func TestBuildTaskIsolationPolicyForbidsCustomHermesSourceHome(t *testing.T) { + t.Parallel() + + root := t.TempDir() + envRoot := filepath.Join(root, "env") + hermesSource := filepath.Join(root, "custom-hermes") + executable := filepath.Join(root, "bin", "agent") + for _, dir := range []string{envRoot, hermesSource, filepath.Dir(executable)} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } } + if err := os.WriteFile(executable, []byte("binary"), 0o755); err != nil { + t.Fatal(err) + } + + policy, _, err := buildTaskIsolationPolicy(taskIsolationParams{ + Environment: &execenv.Environment{RootDir: envRoot, WorkDir: envRoot}, + TaskTempDir: envRoot, + TaskAuthority: writeTaskAuthorityFixture(t, t.TempDir()), + Executable: executable, + OwnerHome: filepath.Join(root, "owner"), + HermesSourceHome: hermesSource, + SelfExecutable: executable, + }) + if err != nil { + t.Fatalf("buildTaskIsolationPolicy: %v", err) + } + want := resolvedTestPath(t, hermesSource) + if !slices.Contains(policy.ForbiddenRoots, want) { + t.Fatalf("forbidden roots %v missing custom Hermes source %q", policy.ForbiddenRoots, want) + } +} - if _, err := d.refreshWorkspaceRepos(context.Background(), "ws-1"); err != nil { - t.Fatalf("refreshWorkspaceRepos: %v", err) +func TestBuildTaskIsolationPolicyRejectsHermesSourceOverlap(t *testing.T) { + t.Parallel() + + root := t.TempDir() + envRoot := filepath.Join(root, "env") + executable := filepath.Join(root, "agent") + if err := os.MkdirAll(envRoot, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(executable, []byte("binary"), 0o755); err != nil { + t.Fatal(err) } - if !d.workspaceRepoAllowed("ws-1", sourceRepo) { - t.Fatal("project repo URL was wiped by workspace refresh") + _, _, err := buildTaskIsolationPolicy(taskIsolationParams{ + Environment: &execenv.Environment{RootDir: envRoot, WorkDir: envRoot}, + TaskTempDir: envRoot, + TaskAuthority: writeTaskAuthorityFixture(t, t.TempDir()), + Executable: executable, + OwnerHome: filepath.Join(root, "owner"), + HermesSourceHome: envRoot, + SelfExecutable: executable, + }) + if err == nil || !strings.Contains(err.Error(), "forbidden") { + t.Fatalf("buildTaskIsolationPolicy error = %v, want forbidden overlap", err) } } -func TestTaskRepoDefaultRefScopedByTask(t *testing.T) { +func TestBuildTaskIsolationPolicySupportsAbsoluteShebang(t *testing.T) { t.Parallel() - const repoURL = "https://github.com/example/shared" - d := &Daemon{ - workspaces: map[string]*workspaceState{ - "ws-1": newWorkspaceState("ws-1", nil, "", nil, nil), - }, + root := t.TempDir() + interpreter := filepath.Join(root, "runtime", "shell") + provider := filepath.Join(root, "provider", "agent") + self := filepath.Join(root, "bin", "multica") + envRoot := filepath.Join(root, "env") + for _, dir := range []string{filepath.Dir(interpreter), filepath.Dir(provider), filepath.Dir(self), envRoot} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + for path, body := range map[string]string{ + interpreter: "runtime", + provider: "#!" + interpreter + "\n", + self: "multica", + } { + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { + t.Fatal(err) + } } - d.registerTaskRepos("ws-1", "task-a", []RepoData{ - {URL: repoURL, Ref: "release/a"}, - {URL: repoURL, Ref: "late-duplicate"}, + policy, taskPath, err := buildTaskIsolationPolicy(taskIsolationParams{ + Environment: &execenv.Environment{RootDir: envRoot, WorkDir: envRoot}, + TaskTempDir: envRoot, + TaskAuthority: writeTaskAuthorityFixture(t, t.TempDir()), + Executable: provider, + OwnerHome: filepath.Join(root, "owner"), + SelfExecutable: self, }) - d.registerTaskRepos("ws-1", "task-b", []RepoData{{URL: repoURL, Ref: "release/b"}}) + if err != nil { + t.Fatalf("buildTaskIsolationPolicy: %v", err) + } + interpreterDir := filepath.Dir(resolvedTestPath(t, interpreter)) + if !slices.Contains(policy.ReadOnlyRoots, interpreterDir) { + t.Fatalf("read-only roots %v missing direct interpreter %q", policy.ReadOnlyRoots, interpreterDir) + } + if !slices.Contains(filepath.SplitList(taskPath), interpreterDir) { + t.Fatalf("task PATH %q missing direct interpreter directory %q", taskPath, interpreterDir) + } +} - if got := d.taskRepoDefaultRef("ws-1", "task-a", repoURL); got != "release/a" { - t.Fatalf("task-a default ref = %q, want release/a", got) +func TestBuildTaskIsolationPolicyRejectsMissingShebangRuntime(t *testing.T) { + t.Parallel() + + root := t.TempDir() + provider := filepath.Join(root, "provider") + self := filepath.Join(root, "multica") + envRoot := filepath.Join(root, "env") + if err := os.MkdirAll(envRoot, 0o755); err != nil { + t.Fatal(err) } - if got := d.taskRepoDefaultRef("ws-1", "task-b", repoURL); got != "release/b" { - t.Fatalf("task-b default ref = %q, want release/b", got) + for path, body := range map[string]string{ + provider: "#!/usr/bin/env missing-runtime\n", + self: "multica", + } { + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { + t.Fatal(err) + } } - d.clearTaskRepoRefs("ws-1", "task-a") + _, _, err := buildTaskIsolationPolicy(taskIsolationParams{ + Environment: &execenv.Environment{RootDir: envRoot, WorkDir: envRoot}, + TaskTempDir: envRoot, + TaskAuthority: writeTaskAuthorityFixture(t, t.TempDir()), + Executable: provider, + OwnerHome: filepath.Join(root, "owner"), + SelfExecutable: self, + LookupPath: func(string) (string, error) { + return "", exec.ErrNotFound + }, + }) + if err == nil || !strings.Contains(err.Error(), "resolve shebang runtime") { + t.Fatalf("buildTaskIsolationPolicy error = %v, want missing runtime failure", err) + } +} - if got := d.taskRepoDefaultRef("ws-1", "task-a", repoURL); got != "" { - t.Fatalf("task-a default ref after cleanup = %q, want empty", got) +func TestExistingTaskExecutableResolvesSymlinkIdentity(t *testing.T) { + t.Parallel() + + root := t.TempDir() + target := filepath.Join(root, "runtime", "agent") + link := filepath.Join(root, "bin", "agent") + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte("agent"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) } - if got := d.taskRepoDefaultRef("ws-1", "task-b", repoURL); got != "release/b" { - t.Fatalf("task-b default ref after task-a cleanup = %q, want release/b", got) + + got, err := existingTaskExecutable("provider executable", link) + if err != nil { + t.Fatalf("existingTaskExecutable: %v", err) + } + want := resolvedTestPath(t, target) + if got != want { + t.Fatalf("resolved executable = %q, want %q", got, want) + } +} + +func TestNewTaskCommandBuilderFailsClosed(t *testing.T) { + t.Parallel() + + for name, daemon := range map[string]*Daemon{ + "missing factory": {}, + "nil launcher": {taskLauncher: func() agent.CommandBuilder { + return nil + }}, + } { + t.Run(name, func(t *testing.T) { + if launcher, err := daemon.newTaskCommandBuilder(); err == nil || launcher != nil { + t.Fatalf("newTaskCommandBuilder = (%T, %v), want nil error result", launcher, err) + } + }) } } diff --git a/server/internal/daemon/execenv/codex_home.go b/server/internal/daemon/execenv/codex_home.go index 00edd875b75..2ccb6fc6c1b 100644 --- a/server/internal/daemon/execenv/codex_home.go +++ b/server/internal/daemon/execenv/codex_home.go @@ -3,8 +3,10 @@ package execenv import ( "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" + "io/fs" "log/slog" "os" "path/filepath" @@ -14,11 +16,16 @@ import ( "github.com/pelletier/go-toml/v2" ) -// Files to symlink from the shared ~/.codex/ into the per-task CODEX_HOME. -// Symlinks share state (e.g. auth tokens) so changes propagate automatically. -var codexSymlinkedFiles = []string{ - "auth.json", -} +const ( + taskCodexPluginCacheMaxFiles = 25_000 + taskCodexPluginCacheMaxFileBytes = 16 << 20 + taskCodexPluginCacheMaxBytes = 512 << 20 +) + +var ( + errUnsafeTaskCodexPluginCacheEntry = errors.New("unsafe Codex plugin cache entry") + errTaskCodexPluginCacheLimit = errors.New("Codex plugin cache snapshot limit exceeded") +) // Files to copy from the shared ~/.codex/ into the per-task CODEX_HOME. // Copies are isolated — task-local config and cache refreshes don't mutate @@ -94,8 +101,9 @@ func prepareCodexHome(codexHome string, logger *slog.Logger) error { } // prepareCodexHomeWithOpts creates a per-task CODEX_HOME directory and seeds -// it with config from the shared ~/.codex/ home. Auth is symlinked (shared), -// config files are copied (isolated). The per-task config.toml gets a +// it with config from the shared ~/.codex/ home. Auth is captured as a private +// snapshot, session history stays task-local, and config files are copied. The +// per-task config.toml gets a // daemon-managed sandbox block picked by codexSandboxPolicyFor. func prepareCodexHomeWithOpts(codexHome string, opts CodexHomeOptions, logger *slog.Logger) error { sharedHome := resolveSharedCodexHome() @@ -104,7 +112,7 @@ func prepareCodexHomeWithOpts(codexHome string, opts CodexHomeOptions, logger *s freshHome = true } - if err := os.MkdirAll(codexHome, 0o755); err != nil { + if err := ensurePrivateCodexDir(codexHome); err != nil { return fmt.Errorf("create codex-home dir: %w", err) } @@ -115,13 +123,10 @@ func prepareCodexHomeWithOpts(codexHome string, opts CodexHomeOptions, logger *s logger.Warn("execenv: codex-home sessions dir prepare failed", "error", err) } - // Symlink shared files (auth). - for _, name := range codexSymlinkedFiles { - src := filepath.Join(sharedHome, name) - dst := filepath.Join(codexHome, name) - if err := ensureSymlink(src, dst); err != nil { - logger.Warn("execenv: codex-home symlink failed", "file", name, "error", err) - } + authSource := filepath.Join(sharedHome, "auth.json") + authTarget := filepath.Join(codexHome, "auth.json") + if err := syncCodexAuthSnapshot(authSource, authTarget); err != nil { + return fmt.Errorf("prepare codex auth snapshot: %w", err) } // Surface the resulting auth.json state (file kind only, never contents) @@ -164,8 +169,8 @@ func prepareCodexHomeWithOpts(codexHome string, opts CodexHomeOptions, logger *s } } - if err := exposeSharedCodexPluginCache(codexHome, sharedHome); err != nil { - logger.Warn("execenv: codex-home plugin cache exposure failed", "error", err) + if err := snapshotSharedCodexPluginCache(codexHome, sharedHome); err != nil { + logger.Warn("execenv: codex-home plugin cache snapshot failed", "error", err) } // Write a daemon-managed sandbox block into config.toml. On macOS we may @@ -196,6 +201,34 @@ func prepareCodexHomeWithOpts(codexHome string, opts CodexHomeOptions, logger *s return nil } +func ensurePrivateCodexDir(path string) error { + if info, err := os.Lstat(path); err == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("remove unsafe path: %w", err) + } + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect path: %w", err) + } + if err := os.MkdirAll(path, 0o700); err != nil { + return err + } + return os.Chmod(path, 0o700) +} + +func syncCodexAuthSnapshot(source, target string) error { + if _, err := os.Lstat(source); os.IsNotExist(err) { + if removeErr := os.Remove(target); removeErr != nil && !os.IsNotExist(removeErr) { + return fmt.Errorf("remove stale auth snapshot: %w", removeErr) + } + return nil + } else if err != nil { + return fmt.Errorf("inspect shared auth: %w", err) + } + return snapshotRegularFile(source, target, codexAuthSnapshotMaxBytes) +} + // resolveSharedCodexHome returns the path to the user's shared Codex home. // Checks $CODEX_HOME first, falls back to ~/.codex. func resolveSharedCodexHome() string { @@ -454,7 +487,7 @@ func prepareCodexSessionsDir(codexHome, sharedHome string, opts CodexHomeOptions if storeDir == "" { // No stable per-issue key (e.g. a non-issue task). Fall back to an // empty local dir rather than re-exposing the whole shared history. - return os.MkdirAll(dst, 0o755) + return ensurePrivateCodexDir(dst) } return linkCodexSessionsToStore(dst, storeDir, sharedSessions, opts.ResumeSessionID, logger) } @@ -462,15 +495,15 @@ func prepareCodexSessionsDir(codexHome, sharedHome string, opts CodexHomeOptions fi, err := os.Lstat(dst) switch { case os.IsNotExist(err): - return os.MkdirAll(dst, 0o755) // fresh managed task — empty local dir + return ensurePrivateCodexDir(dst) // fresh managed task — empty local dir case err != nil: return fmt.Errorf("stat sessions dir %s: %w", dst, err) } if fi.Mode()&os.ModeSymlink == 0 { // Already a real directory (task-local, authoritative). Ensure it - // exists (no-op) and leave its contents alone. - return os.MkdirAll(dst, 0o755) + // remains private and leave its contents alone. + return ensurePrivateCodexDir(dst) } // A symlink/junction. If it already points at this issue's store (a home @@ -501,7 +534,7 @@ func prepareCodexSessionsDir(codexHome, sharedHome string, opts CodexHomeOptions } logger.Info("execenv: migrated codex-home sessions from shared symlink to task-local dir", "codex_home", codexHome, "resume_session", false) - return os.MkdirAll(dst, 0o755) + return ensurePrivateCodexDir(dst) } // linkCodexSessionsToStore points codex-home/sessions (dst) at the per-issue @@ -933,35 +966,135 @@ func resolveCodexConfigPath(configPath, sharedHome string) (string, error) { return filepath.Join(sharedHome, filepath.Clean(configPath)), nil } -func exposeSharedCodexPluginCache(codexHome, sharedHome string) error { +func snapshotSharedCodexPluginCache(codexHome, sharedHome string) error { + return snapshotSharedCodexPluginCacheWithAfterOpen(codexHome, sharedHome, nil) +} + +func snapshotSharedCodexPluginCacheWithAfterOpen(codexHome, sharedHome string, afterOpen func(string)) error { src := filepath.Join(sharedHome, "plugins", "cache") dst := filepath.Join(codexHome, "plugins", "cache") - if err := os.MkdirAll(src, 0o755); err != nil { - return fmt.Errorf("create shared plugin cache dir: %w", err) - } - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + if err := ensurePrivateCodexDir(filepath.Dir(dst)); err != nil { return fmt.Errorf("create codex plugin dir: %w", err) } + if info, err := os.Lstat(dst); err == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + if err := os.RemoveAll(dst); err != nil { + return fmt.Errorf("remove unsafe Codex plugin cache path: %w", err) + } + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect Codex plugin cache destination: %w", err) + } - if fi, err := os.Lstat(dst); err == nil { - isLink := fi.Mode()&os.ModeSymlink != 0 - if isLink { - if target, readlinkErr := os.Readlink(dst); readlinkErr == nil && target == src { + sourceExists := true + if info, err := os.Lstat(src); os.IsNotExist(err) { + sourceExists = false + } else if err != nil { + return fmt.Errorf("inspect shared plugin cache: %w", err) + } else if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("%w: source is not a regular directory", errUnsafeTaskCodexPluginCacheEntry) + } + + staging, err := os.MkdirTemp(filepath.Dir(dst), ".codex-plugin-cache-snapshot-") + if err != nil { + return fmt.Errorf("create Codex plugin cache staging directory: %w", err) + } + published := false + defer func() { + if !published { + _ = os.RemoveAll(staging) + } + }() + + if sourceExists { + limits := taskCodexPluginCacheSnapshotLimits{} + if err := copyTaskCodexPluginCacheTree(src, staging, &limits, afterOpen); err != nil { + return err + } + } + + if err := os.RemoveAll(dst); err != nil { + return fmt.Errorf("remove stale Codex plugin cache snapshot: %w", err) + } + if err := os.Rename(staging, dst); err != nil { + return fmt.Errorf("publish Codex plugin cache snapshot: %w", err) + } + published = true + return nil +} + +type taskCodexPluginCacheSnapshotLimits struct { + files int + bytes int64 +} + +func copyTaskCodexPluginCacheTree(src, dst string, limits *taskCodexPluginCacheSnapshotLimits, afterOpen func(string)) error { + return filepath.WalkDir(src, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + relative, err := filepath.Rel(src, path) + if err != nil { + return fmt.Errorf("resolve Codex plugin cache entry: %w", err) + } + target := filepath.Join(dst, relative) + if entry.IsDir() { + if relative == "." { return nil } - if err := os.Remove(dst); err != nil { - return fmt.Errorf("remove stale plugin cache link: %w", err) - } - } else { - if err := os.RemoveAll(dst); err != nil { - return fmt.Errorf("remove stale plugin cache path: %w", err) + if err := os.Mkdir(target, 0o755); err != nil { + return fmt.Errorf("create Codex plugin cache snapshot directory: %w", err) } + return nil } + if entry.Type()&os.ModeSymlink != 0 || !entry.Type().IsRegular() { + return fmt.Errorf("%w: %s", errUnsafeTaskCodexPluginCacheEntry, relative) + } + + limits.files++ + if limits.files > taskCodexPluginCacheMaxFiles { + return fmt.Errorf("%w: %s", errTaskCodexPluginCacheLimit, relative) + } + + var fileAfterOpen func() + if afterOpen != nil { + fileAfterOpen = func() { afterOpen(path) } + } + data, err := readStableRegularFile(path, taskCodexPluginCacheMaxFileBytes, fileAfterOpen) + if err != nil { + return fmt.Errorf("%w: %s: %v", errUnsafeTaskCodexPluginCacheEntry, relative, err) + } + if limits.bytes > taskCodexPluginCacheMaxBytes-int64(len(data)) { + return fmt.Errorf("%w: %s", errTaskCodexPluginCacheLimit, relative) + } + limits.bytes += int64(len(data)) + if err := writeTaskCodexPluginCacheFile(target, data); err != nil { + return err + } + return nil + }) +} + +func writeTaskCodexPluginCacheFile(dst string, data []byte) error { + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return fmt.Errorf("create Codex plugin cache snapshot file: %w", err) } + ok := false + defer func() { + _ = out.Close() + if !ok { + _ = os.Remove(dst) + } + }() - if err := createDirLink(src, dst); err != nil { - return fmt.Errorf("expose shared plugin cache: %w", err) + if _, err := out.Write(data); err != nil { + return fmt.Errorf("write Codex plugin cache snapshot file: %w", err) + } + if err := out.Close(); err != nil { + return fmt.Errorf("close Codex plugin cache snapshot file: %w", err) } + ok = true return nil } diff --git a/server/internal/daemon/execenv/codex_home_link_windows.go b/server/internal/daemon/execenv/codex_home_link_windows.go index 819efbc25e1..19413f36934 100644 --- a/server/internal/daemon/execenv/codex_home_link_windows.go +++ b/server/internal/daemon/execenv/codex_home_link_windows.go @@ -3,21 +3,157 @@ package execenv import ( + "errors" "fmt" + "io" + "io/fs" "os" - "os/exec" + "path/filepath" + + "golang.org/x/sys/windows" +) + +const ( + codexPluginCacheMaxFiles = 25_000 + codexPluginCacheMaxFileBytes = 16 << 20 + codexPluginCacheMaxBytes = 512 << 20 +) + +var ( + errUnsafeCodexPluginCacheEntry = errors.New("unsafe Codex plugin cache entry") + errCodexPluginCacheLimit = errors.New("Codex plugin cache snapshot limit exceeded") ) -// createDirLink tries os.Symlink first (requires Developer Mode or admin on -// Windows). If that fails, it falls back to a directory junction (mklink /J) -// which works without elevated privileges. +// createDirLink creates a bounded task-local snapshot on Windows. A directory +// link or junction would expose owner state through the writable task tree. func createDirLink(src, dst string) error { - if err := os.Symlink(src, dst); err == nil { + if err := rejectWindowsReparsePoint(src); err != nil { + return err + } + info, err := os.Lstat(src) + if err != nil { + return fmt.Errorf("inspect Codex plugin cache source: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("%w: source is not a directory", errUnsafeCodexPluginCacheEntry) + } + + parent := filepath.Dir(dst) + staging, err := os.MkdirTemp(parent, ".codex-plugin-cache-snapshot-") + if err != nil { + return fmt.Errorf("create Codex plugin cache staging directory: %w", err) + } + published := false + defer func() { + if !published { + _ = os.RemoveAll(staging) + } + }() + + limits := codexPluginCacheSnapshotLimits{} + if err := copyCodexPluginCacheTree(src, staging, &limits); err != nil { + return err + } + if err := os.Rename(staging, dst); err != nil { + return fmt.Errorf("publish Codex plugin cache snapshot: %w", err) + } + published = true + return nil +} + +type codexPluginCacheSnapshotLimits struct { + files int + bytes int64 +} + +func copyCodexPluginCacheTree(src, dst string, limits *codexPluginCacheSnapshotLimits) error { + return filepath.WalkDir(src, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := rejectWindowsReparsePoint(path); err != nil { + return err + } + + relative, err := filepath.Rel(src, path) + if err != nil { + return fmt.Errorf("resolve Codex plugin cache entry: %w", err) + } + target := filepath.Join(dst, relative) + if entry.IsDir() { + if relative == "." { + return nil + } + if err := os.Mkdir(target, 0o755); err != nil { + return fmt.Errorf("create Codex plugin cache snapshot directory: %w", err) + } + return nil + } + if entry.Type()&os.ModeSymlink != 0 || !entry.Type().IsRegular() { + return fmt.Errorf("%w: %s", errUnsafeCodexPluginCacheEntry, relative) + } + + info, err := entry.Info() + if err != nil { + return fmt.Errorf("inspect Codex plugin cache file: %w", err) + } + limits.files++ + if limits.files > codexPluginCacheMaxFiles || info.Size() > codexPluginCacheMaxFileBytes || + limits.bytes > codexPluginCacheMaxBytes-info.Size() { + return fmt.Errorf("%w: %s", errCodexPluginCacheLimit, relative) + } + limits.bytes += info.Size() + if err := copyCodexPluginCacheFile(path, target, info.Size()); err != nil { + return err + } return nil + }) +} + +func copyCodexPluginCacheFile(src, dst string, expectedSize int64) error { + in, err := os.Open(src) + if err != nil { + return fmt.Errorf("open Codex plugin cache file: %w", err) } - out, err := exec.Command("cmd", "/c", "mklink", "/J", dst, src).CombinedOutput() + defer in.Close() + + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) if err != nil { - return fmt.Errorf("mklink /J %s %s: %s: %w", dst, src, out, err) + return fmt.Errorf("create Codex plugin cache snapshot file: %w", err) + } + ok := false + defer func() { + _ = out.Close() + if !ok { + _ = os.Remove(dst) + } + }() + + written, err := io.CopyN(out, in, expectedSize+1) + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("copy Codex plugin cache file: %w", err) + } + if written != expectedSize { + return fmt.Errorf("%w: source changed while copying", errUnsafeCodexPluginCacheEntry) + } + if err := out.Close(); err != nil { + return fmt.Errorf("close Codex plugin cache snapshot file: %w", err) + } + ok = true + return nil +} + +func rejectWindowsReparsePoint(path string) error { + pathPtr, err := windows.UTF16PtrFromString(path) + if err != nil { + return fmt.Errorf("encode Codex plugin cache path: %w", err) + } + attributes, err := windows.GetFileAttributes(pathPtr) + if err != nil { + return fmt.Errorf("inspect Codex plugin cache attributes: %w", err) + } + if attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return fmt.Errorf("%w: reparse point %s", errUnsafeCodexPluginCacheEntry, path) } return nil } diff --git a/server/internal/daemon/execenv/codex_home_link_windows_source_test.go b/server/internal/daemon/execenv/codex_home_link_windows_source_test.go new file mode 100644 index 00000000000..f89140b2c71 --- /dev/null +++ b/server/internal/daemon/execenv/codex_home_link_windows_source_test.go @@ -0,0 +1,73 @@ +package execenv + +import ( + "go/ast" + "go/parser" + "go/token" + "strconv" + "testing" +) + +func TestWindowsCodexHomeLinkDoesNotLaunchShell(t *testing.T) { + t.Parallel() + + const path = "codex_home_link_windows.go" + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + + for _, spec := range file.Imports { + name, err := strconv.Unquote(spec.Path.Value) + if err != nil { + t.Fatalf("decode import %s: %v", spec.Path.Value, err) + } + if name == "os/exec" { + t.Fatalf("%s must not import os/exec", path) + } + } + + var createDirLink *ast.FuncDecl + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if ok && function.Name.Name == "createDirLink" { + createDirLink = function + break + } + } + if createDirLink == nil { + t.Fatalf("%s does not declare createDirLink", path) + } + + ast.Inspect(createDirLink.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + identifier, ok := selector.X.(*ast.Ident) + if ok && identifier.Name == "os" && selector.Sel.Name == "Symlink" { + t.Errorf("createDirLink must not expose owner directories through os.Symlink") + } + return true + }) + + ast.Inspect(file, func(node ast.Node) bool { + literal, ok := node.(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + return true + } + value, err := strconv.Unquote(literal.Value) + if err != nil { + return true + } + if value == "cmd" || value == "cmd.exe" || value == "mklink" { + t.Errorf("%s contains forbidden shell token %q", path, value) + } + return true + }) +} diff --git a/server/internal/daemon/execenv/codex_home_link_windows_test.go b/server/internal/daemon/execenv/codex_home_link_windows_test.go new file mode 100644 index 00000000000..e63a63d0a83 --- /dev/null +++ b/server/internal/daemon/execenv/codex_home_link_windows_test.go @@ -0,0 +1,127 @@ +//go:build windows + +package execenv + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestCreateDirLinkCreatesIndependentSnapshot(t *testing.T) { + t.Parallel() + + root := t.TempDir() + src := filepath.Join(root, "owner-cache") + dst := filepath.Join(root, "task-cache") + if err := os.MkdirAll(filepath.Join(src, "plugin"), 0o755); err != nil { + t.Fatalf("create source: %v", err) + } + sourceFile := filepath.Join(src, "plugin", "manifest.json") + if err := os.WriteFile(sourceFile, []byte("owner-v1"), 0o644); err != nil { + t.Fatalf("write source: %v", err) + } + + if err := createDirLink(src, dst); err != nil { + t.Fatalf("createDirLink: %v", err) + } + + info, err := os.Lstat(dst) + if err != nil { + t.Fatalf("lstat snapshot: %v", err) + } + if info.Mode()&os.ModeSymlink != 0 { + t.Fatal("task cache must be a snapshot, not a link to owner state") + } + + taskFile := filepath.Join(dst, "plugin", "manifest.json") + data, err := os.ReadFile(taskFile) + if err != nil { + t.Fatalf("read snapshot: %v", err) + } + if !bytes.Equal(data, []byte("owner-v1")) { + t.Fatalf("snapshot content = %q", data) + } + + if err := os.WriteFile(sourceFile, []byte("owner-v2"), 0o644); err != nil { + t.Fatalf("update source: %v", err) + } + data, err = os.ReadFile(taskFile) + if err != nil { + t.Fatalf("read snapshot after owner update: %v", err) + } + if !bytes.Equal(data, []byte("owner-v1")) { + t.Fatalf("task snapshot tracked owner mutation: %q", data) + } + + if err := os.WriteFile(taskFile, []byte("task-write"), 0o644); err != nil { + t.Fatalf("write task-local snapshot: %v", err) + } + ownerData, err := os.ReadFile(sourceFile) + if err != nil { + t.Fatalf("read owner source: %v", err) + } + if !bytes.Equal(ownerData, []byte("owner-v2")) { + t.Fatalf("task write affected owner cache: %q", ownerData) + } +} + +func TestCreateDirLinkRejectsNestedDirectoryLink(t *testing.T) { + t.Parallel() + + root := t.TempDir() + src := filepath.Join(root, "owner-cache") + dst := filepath.Join(root, "task-cache") + outside := filepath.Join(root, "owner-private") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatalf("create source: %v", err) + } + if err := os.MkdirAll(outside, 0o755); err != nil { + t.Fatalf("create outside dir: %v", err) + } + if err := os.WriteFile(filepath.Join(outside, "secret"), []byte("secret"), 0o644); err != nil { + t.Fatalf("write outside file: %v", err) + } + if err := os.Symlink(outside, filepath.Join(src, "escape")); err != nil { + t.Skipf("directory symlink unavailable on this Windows session: %v", err) + } + + err := createDirLink(src, dst) + if err == nil { + t.Fatal("expected linked source entry to be rejected") + } + if !errors.Is(err, errUnsafeCodexPluginCacheEntry) { + t.Fatalf("error = %v, want unsafe-entry classification", err) + } + if _, statErr := os.Lstat(dst); !os.IsNotExist(statErr) { + t.Fatalf("failed snapshot was published, lstat error: %v", statErr) + } +} + +func TestCreateDirLinkRejectsOversizedSnapshotWithoutPublishing(t *testing.T) { + t.Parallel() + + root := t.TempDir() + src := filepath.Join(root, "owner-cache") + dst := filepath.Join(root, "task-cache") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatalf("create source: %v", err) + } + oversized := make([]byte, codexPluginCacheMaxFileBytes+1) + if err := os.WriteFile(filepath.Join(src, "oversized.bin"), oversized, 0o644); err != nil { + t.Fatalf("write oversized source: %v", err) + } + + err := createDirLink(src, dst) + if err == nil { + t.Fatal("expected oversized snapshot to be rejected") + } + if !errors.Is(err, errCodexPluginCacheLimit) { + t.Fatalf("error = %v, want limit classification", err) + } + if _, statErr := os.Lstat(dst); !os.IsNotExist(statErr) { + t.Fatalf("failed snapshot was published, lstat error: %v", statErr) + } +} diff --git a/server/internal/daemon/execenv/context_marker_test.go b/server/internal/daemon/execenv/context_marker_test.go index 50ffdb9b842..f40ac5d40f1 100644 --- a/server/internal/daemon/execenv/context_marker_test.go +++ b/server/internal/daemon/execenv/context_marker_test.go @@ -159,6 +159,7 @@ func TestReuse_SelfHealsWorkspacesRootMarker(t *testing.T) { reused := Reuse(ReuseParams{ WorkspacesRoot: root, + RootDir: env.RootDir, WorkDir: env.WorkDir, Task: TaskContextForEnv{IssueID: "b2c3d4e5-f6a7-8901-bcde-f23456789012"}, }, testLogger()) diff --git a/server/internal/daemon/execenv/cursor_mcp.go b/server/internal/daemon/execenv/cursor_mcp.go index f8e6c2de7e6..fdc0a6c4282 100644 --- a/server/internal/daemon/execenv/cursor_mcp.go +++ b/server/internal/daemon/execenv/cursor_mcp.go @@ -7,7 +7,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "io/fs" "os" "path/filepath" @@ -108,10 +107,7 @@ func seedCursorMcpAuthFile(projectDataDir, source string) error { return err } target := filepath.Join(projectDataDir, cursorMcpAuthFile) - if err := os.Symlink(sourcePath, target); err == nil { - return nil - } - if err := copyCursorMcpAuthFile(target, sourcePath); err != nil { + if err := snapshotRegularFile(sourcePath, target, cursorAuthSnapshotMaxBytes); err != nil { return fmt.Errorf("seed cursor mcp auth file: %w", err) } return nil @@ -145,19 +141,19 @@ func resolveCursorMcpAuthSource(source string) (string, error) { return "", fmt.Errorf("%s must be an absolute path to %s or its containing Cursor project directory", CursorMcpAuthSourceEnv, cursorMcpAuthFile) } source = filepath.Clean(source) - info, err := os.Stat(source) + info, err := os.Lstat(source) if err != nil { return "", fmt.Errorf("stat %s: %w", CursorMcpAuthSourceEnv, err) } if info.IsDir() { source = filepath.Join(source, cursorMcpAuthFile) - info, err = os.Stat(source) + info, err = os.Lstat(source) if err != nil { return "", fmt.Errorf("stat %s %s: %w", CursorMcpAuthSourceEnv, cursorMcpAuthFile, err) } } - if info.IsDir() { - return "", fmt.Errorf("%s must resolve to a file, got directory %s", CursorMcpAuthSourceEnv, source) + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return "", fmt.Errorf("%s must resolve to a regular, non-symlink file", CursorMcpAuthSourceEnv) } if filepath.Base(source) != cursorMcpAuthFile { return "", fmt.Errorf("%s must point at %s, got %s", CursorMcpAuthSourceEnv, cursorMcpAuthFile, filepath.Base(source)) @@ -165,30 +161,6 @@ func resolveCursorMcpAuthSource(source string) (string, error) { return source, nil } -func copyCursorMcpAuthFile(target, source string) error { - in, err := os.Open(source) - if err != nil { - return err - } - defer in.Close() - - out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - return err - } - _, copyErr := io.Copy(out, in) - closeErr := out.Close() - if copyErr != nil { - _ = os.Remove(target) - return copyErr - } - if closeErr != nil { - _ = os.Remove(target) - return closeErr - } - return nil -} - func hasManagedCursorMcpConfig(raw json.RawMessage) bool { trimmed := bytes.TrimSpace(raw) return len(trimmed) > 0 && !bytes.Equal(trimmed, []byte("null")) diff --git a/server/internal/daemon/execenv/cursor_mcp_test.go b/server/internal/daemon/execenv/cursor_mcp_test.go index 337969dd294..0dc595c0c84 100644 --- a/server/internal/daemon/execenv/cursor_mcp_test.go +++ b/server/internal/daemon/execenv/cursor_mcp_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "testing" ) @@ -149,6 +150,53 @@ func TestPrepareCursorMcpConfigSeedsExplicitAuthSource(t *testing.T) { if string(data) != `{"tokens":{"fetch":"secret"}}` { t.Fatalf("seeded auth = %s", data) } + info, err := os.Lstat(targetAuth) + if err != nil { + t.Fatalf("lstat seeded auth: %v", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + t.Fatalf("seeded auth mode = %v, want task-private regular file", info.Mode()) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("seeded auth mode = %#o, want 0600", info.Mode().Perm()) + } + if err := os.WriteFile(sourceAuth, []byte(`{"tokens":{"fetch":"rotated"}}`), 0o600); err != nil { + t.Fatalf("rotate source auth: %v", err) + } + data, err = os.ReadFile(targetAuth) + if err != nil { + t.Fatalf("read seeded auth after source mutation: %v", err) + } + if string(data) != `{"tokens":{"fetch":"secret"}}` { + t.Fatalf("seeded auth changed with source: %s", data) + } +} + +func TestPrepareCursorMcpConfigRejectsSymlinkAuthSource(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink fixture requires elevated privileges on some Windows hosts") + } + envRoot := t.TempDir() + workDir := filepath.Join(envRoot, "workdir") + if err := os.MkdirAll(workDir, 0o755); err != nil { + t.Fatalf("mkdir workDir: %v", err) + } + realAuth := filepath.Join(envRoot, "owner-auth.json") + if err := os.WriteFile(realAuth, []byte(`{"token":"owner"}`), 0o600); err != nil { + t.Fatalf("write real auth: %v", err) + } + sourceDir := filepath.Join(envRoot, "source-project") + if err := os.MkdirAll(sourceDir, 0o700); err != nil { + t.Fatalf("mkdir source project: %v", err) + } + if err := os.Symlink(realAuth, filepath.Join(sourceDir, cursorMcpAuthFile)); err != nil { + t.Fatalf("symlink auth: %v", err) + } + + _, err := prepareCursorMcpConfig(envRoot, workDir, json.RawMessage(`{"mcpServers":{}}`), sourceDir, &sidecarManifest{}) + if err == nil { + t.Fatal("expected symlink mcp-auth source to fail closed") + } } func TestPrepareCursorMcpConfigRemovesPriorAuthOnOptOut(t *testing.T) { diff --git a/server/internal/daemon/execenv/execenv.go b/server/internal/daemon/execenv/execenv.go index 6f1b3f66660..d2f06b136e2 100644 --- a/server/internal/daemon/execenv/execenv.go +++ b/server/internal/daemon/execenv/execenv.go @@ -45,7 +45,7 @@ type PrepareParams struct { Profile string Provider string // agent provider (determines runtime config and skill injection paths) CodexVersion string // detected Codex CLI version (only used when Provider == "codex") - OpenclawBin string // resolved openclaw CLI path (only used when Provider == "openclaw"); empty = look up on PATH + OpenclawBin string // runtime OpenClaw path; never executed during task preparation // McpConfig is the agent's saved `mcp_config` JSON, forwarded to the // provider-specific config preparer when that provider materialises MCP // via a per-task config file. Cursor and OpenClaw consume it here; other @@ -175,6 +175,12 @@ type SkillFileContextForEnv struct { type Environment struct { // RootDir is the top-level env directory ({workspacesRoot}/{task_id_short}/). RootDir string + // HomeDir is the task-private user home exposed to the agent process. It + // deliberately starts without the daemon owner's ~/.multica credentials so + // clearing MULTICA_* cannot fall back to a human PAT/JWT. + HomeDir string + // ConfigDir is the task-private XDG configuration root under HomeDir. + ConfigDir string // WorkDir is the directory to pass as Cwd to the agent. Normally // ({RootDir}/workdir/); when the task is bound to a local_directory // project_resource, it is the user's path instead. See LocalDirectory. @@ -199,28 +205,17 @@ type Environment struct { // OPENCLAW_CONFIG_PATH on the openclaw subprocess so its native skill // scanner pins workspaceDir to WorkDir. OpenclawConfigPath string - // OpenclawIncludeRoot is the directory of the user's active OpenClaw - // config (set only for openclaw provider with an on-disk user config). - // The daemon must prepend it to OPENCLAW_INCLUDE_ROOTS so OpenClaw is - // allowed to follow the wrapper's `$include` link out of envRoot into - // the user's config — by default OpenClaw confines `$include` to the - // directory holding the wrapper file. Empty when no $include is - // emitted (fresh install). - OpenclawIncludeRoot string // CursorDataDir is the per-task Cursor data directory (set only for // cursor provider when the agent has managed mcp_config). The daemon // exports this as CURSOR_DATA_DIR so project-level MCP approvals are // isolated from the user's persistent ~/.cursor/projects state. CursorDataDir string - // HermesHome is the path to the per-task HERMES_HOME overlay (set only for + // HermesHome is the path to the per-task HERMES_HOME projection (set only for // the hermes provider, and only when the agent has skills bound — empty - // otherwise, leaving the user's real home in place). It mirrors ~/.hermes/ - // via symlink, derives a config.yaml that references the user's real skills - // as an external root, and holds the bound skills in its skills/ subdir. The - // daemon exports it as HERMES_HOME so the hermes CLI discovers those skills - // natively — Hermes has no workspace-relative discovery, so the previous - // .agent_context/skills/ fallback was never read (issue #5242). See - // hermes_home.go. + // otherwise). It contains bounded private snapshots of allowlisted runtime + // state, a minimal derived config with no owner paths, and only the bound + // skills. The daemon exports it as HERMES_HOME because Hermes has no + // workspace-relative skill discovery (issue #5242). See hermes_home.go. HermesHome string logger *slog.Logger // for cleanup logging @@ -285,9 +280,15 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) { return nil, fmt.Errorf("execenv: create directory %s: %w", dir, err) } } + homeDir, configDir, err := preparePrivateTaskHome(envRoot) + if err != nil { + return nil, fmt.Errorf("execenv: prepare private task home: %w", err) + } env := &Environment{ RootDir: envRoot, + HomeDir: homeDir, + ConfigDir: configDir, WorkDir: workDir, LocalDirectory: params.LocalWorkDir != "", logger: logger, @@ -325,13 +326,10 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) { env.CodexHome = codexHome } - // For Hermes, redirect HERMES_HOME to a per-task compatibility overlay ONLY - // when the agent has skills bound. A skill-less Hermes task keeps the user's - // real home and its original behavior untouched. The overlay makes the bound - // skills visible — Hermes discovers skills only from its home, so the old - // .agent_context/skills/ fallback was never read (issue #5242). See - // hermes_home.go. - if params.Provider == "hermes" && len(params.Task.AgentSkills) > 0 { + // Hermes always runs from a task-private HERMES_HOME. This prevents an Issue + // task without bound skills from inheriting owner credentials, plugins, + // memories, profiles, or future owner-home state. + if params.Provider == "hermes" { hermesHome := filepath.Join(envRoot, "hermes-home") if err := prepareHermesHome(hermesHome, params.HermesSourceHome, params.HermesSourceMustExist, params.Task.AgentSkills, params.HermesEnv, logger); err != nil { return nil, fmt.Errorf("execenv: prepare hermes-home: %w", err) @@ -359,9 +357,8 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) { // For OpenClaw, synthesize a per-task config that pins workspace to // workDir. The skill scanner then reads {workDir}/skills/ (written by // writeContextFiles above). Fail closed on errors: a malformed user - // config that the openclaw CLI can't read is a real problem and - // silently degrading to a minimal config would mask it by booting - // OpenClaw without the agents / providers / API keys it expects. + // config that cannot be projected safely is a real problem. Preparation + // never executes the task-selected OpenClaw binary. if params.Provider == "openclaw" { result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{ OpenclawBin: params.OpenclawBin, @@ -372,7 +369,6 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) { return nil, fmt.Errorf("execenv: prepare openclaw config: %w", err) } env.OpenclawConfigPath = result.ConfigPath - env.OpenclawIncludeRoot = result.IncludeRoot } logger.Info("execenv: prepared env", "root", envRoot, "repos_available", len(params.Task.Repos)) @@ -388,9 +384,12 @@ type ReuseParams struct { // too — a marker removed while the daemon runs is restored before a reused // task spawns, not only on the fresh-Prepare path. WorkspacesRoot string - WorkDir string - Provider string - CodexVersion string // only used when Provider == "codex" + // RootDir is the daemon-derived, task-specific environment root. Reuse never + // derives authority from the persisted WorkDir path. + RootDir string + WorkDir string + Provider string + CodexVersion string // only used when Provider == "codex" // ResumeSessionID is the prior Codex thread/session ID this reused task // intends to resume, when any. Only consulted when Provider == "codex" and // only used while migrating a legacy per-task home whose sessions/ still @@ -398,7 +397,7 @@ type ReuseParams struct { // exposed into the new task-local sessions dir so thread/resume still finds // it. Empty means a fresh thread. See prepareCodexSessionsDir (MUL-4424). ResumeSessionID string - OpenclawBin string // only used when Provider == "openclaw"; empty = PATH lookup + OpenclawBin string // runtime OpenClaw path; ignored by task preparation // McpConfig is the agent's saved `mcp_config` JSON. Reused on reuse so a // freshly-saved managed set re-materialises into the wrapper before the // task starts — without this a stale wrapper from a prior run would keep @@ -420,8 +419,7 @@ type ReuseParams struct { // reuse paths. LocalDirectory bool // HermesSourceHome and HermesEnv mirror PrepareParams on reuse so the Hermes - // overlay re-derives against the agent's current source home / profile and - // external_dirs vars. + // projection re-derives against the agent's current source home/profile. HermesSourceHome string HermesSourceMustExist bool HermesEnv map[string]string @@ -431,7 +429,11 @@ type ReuseParams struct { // Reuse wraps an existing workdir into an Environment and refreshes context files. // Returns nil if the workdir does not exist (caller should fall back to Prepare). func Reuse(params ReuseParams, logger *slog.Logger) *Environment { - if _, err := os.Stat(params.WorkDir); err != nil { + rootDir, workDir, err := validateReusePaths(params.RootDir, params.WorkDir) + if err != nil { + if logger != nil { + logger.Warn("execenv: reject unsafe reused environment", "error", err) + } return nil } @@ -446,7 +448,6 @@ func Reuse(params ReuseParams, logger *slog.Logger) *Environment { } } - rootDir := filepath.Dir(params.WorkDir) if params.LocalDirectory { // For local_directory tasks the user's WorkDir is unrelated to // envRoot (envRoot still lives under workspacesRoot/{wsID}/...), @@ -461,10 +462,17 @@ func Reuse(params ReuseParams, logger *slog.Logger) *Environment { } env := &Environment{ RootDir: rootDir, - WorkDir: params.WorkDir, + WorkDir: workDir, LocalDirectory: params.LocalDirectory, logger: logger, } + homeDir, configDir, err := preparePrivateTaskHome(env.RootDir) + if err != nil { + logger.Warn("execenv: reset private task home failed", "error", err) + return nil + } + env.HomeDir = homeDir + env.ConfigDir = configDir // Roll back the previous dispatch's sidecar writes before refreshing. // On reuse the workdir still holds the prior run's issue_context.md and @@ -538,30 +546,15 @@ func Reuse(params ReuseParams, logger *slog.Logger) *Environment { } } - // Refresh (or tear down) the per-task HERMES_HOME on reuse. With skills - // bound, rebuild the overlay so an added/removed/edited skill and the - // mirrored home/config track the user's current ~/.hermes/ before the next - // hermes process starts. With no skills bound, drop the redirect entirely so - // the task reverts to the user's real home — matching a fresh Prepare for a - // skill-less agent. + // Refresh the per-task HERMES_HOME on reuse so runtime snapshots and bound + // skills track current task authority. if params.Provider == "hermes" && env.RootDir != "" { hermesHome := filepath.Join(env.RootDir, "hermes-home") - if len(params.Task.AgentSkills) > 0 { - if err := prepareHermesHome(hermesHome, params.HermesSourceHome, params.HermesSourceMustExist, params.Task.AgentSkills, params.HermesEnv, logger); err != nil { - // Fail closed: a half-built overlay must not run. Returning nil - // makes the daemon fall back to a fresh Prepare, whose error - // then blocks dispatch rather than silently dropping the bound - // skill. - logger.Warn("execenv: refresh hermes-home failed; forcing fresh prepare", "error", err) - return nil - } - env.HermesHome = hermesHome - } else { - env.HermesHome = "" - if err := os.RemoveAll(hermesHome); err != nil { - logger.Warn("execenv: remove stale hermes-home failed", "error", err) - } + if err := prepareHermesHome(hermesHome, params.HermesSourceHome, params.HermesSourceMustExist, params.Task.AgentSkills, params.HermesEnv, logger); err != nil { + logger.Warn("execenv: refresh hermes-home failed; forcing fresh prepare", "error", err) + return nil } + env.HermesHome = hermesHome } // Refresh Cursor's managed MCP sidecars on reuse. A newly saved agent @@ -583,11 +576,8 @@ func Reuse(params ReuseParams, logger *slog.Logger) *Environment { } // Refresh the per-task OpenClaw config on reuse — the user may have - // added/removed agents or rotated providers since the prior task ran, + // changed allowlisted model/provider settings since the prior task ran, // and the workspace override always re-targets the current workDir. - // Fail closed: a user config that can no longer be parsed should block - // reuse rather than degrade to a minimal config that boots OpenClaw - // without the registered agents. if params.Provider == "openclaw" { result, err := prepareOpenclawConfig(env.RootDir, params.WorkDir, OpenclawConfigPrep{ OpenclawBin: params.OpenclawBin, @@ -599,13 +589,84 @@ func Reuse(params ReuseParams, logger *slog.Logger) *Environment { return nil } env.OpenclawConfigPath = result.ConfigPath - env.OpenclawIncludeRoot = result.IncludeRoot } logger.Info("execenv: reusing env", "workdir", params.WorkDir) return env } +func validateReusePaths(rootDir, workDir string) (string, string, error) { + if rootDir == "" || workDir == "" || !filepath.IsAbs(rootDir) || !filepath.IsAbs(workDir) || + filepath.Clean(rootDir) != rootDir || filepath.Clean(workDir) != workDir { + return "", "", fmt.Errorf("reuse root and workdir must be absolute canonical paths") + } + if workDir != filepath.Join(rootDir, "workdir") { + return "", "", fmt.Errorf("reuse workdir %q is not the expected child of root %q", workDir, rootDir) + } + resolvedRoot, err := filepath.EvalSymlinks(rootDir) + if err != nil { + return "", "", fmt.Errorf("resolve reuse root: %w", err) + } + resolvedWorkDir, err := filepath.EvalSymlinks(workDir) + if err != nil { + return "", "", fmt.Errorf("resolve reuse workdir: %w", err) + } + if resolvedWorkDir != filepath.Join(resolvedRoot, "workdir") { + return "", "", fmt.Errorf("resolved reuse workdir is not the expected child of the resolved root") + } + rootInfo, err := os.Lstat(rootDir) + if err != nil || !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 { + return "", "", fmt.Errorf("reuse root is not a real directory") + } + workInfo, err := os.Lstat(workDir) + if err != nil || !workInfo.IsDir() || workInfo.Mode()&os.ModeSymlink != 0 { + return "", "", fmt.Errorf("reuse workdir is not a real directory") + } + // Preserve the daemon-issued lexical identity after validating its resolved + // filesystem relationship. On macOS, /var commonly resolves to /private/var; + // replacing the issued paths would split active-root and persisted identity. + return rootDir, workDir, nil +} + +// preparePrivateTaskHome creates the home/config roots inherited by an agent +// subprocess and removes any Multica user configuration left by a prior run. +// The explicit chmod calls are intentional: MkdirAll honors an existing +// directory's mode, so reuse must tighten directories an earlier process made +// more permissive. +func preparePrivateTaskHome(rootDir string) (string, string, error) { + if rootDir == "" { + return "", "", fmt.Errorf("environment root is required") + } + + homeDir := filepath.Join(rootDir, "home") + configDir := filepath.Join(homeDir, ".config") + for _, dir := range []string{homeDir, configDir} { + info, err := os.Lstat(dir) + switch { + case err == nil && (!info.IsDir() || info.Mode()&os.ModeSymlink != 0): + if err := os.RemoveAll(dir); err != nil { + return "", "", fmt.Errorf("replace non-directory %s: %w", dir, err) + } + case err != nil && !os.IsNotExist(err): + return "", "", fmt.Errorf("inspect directory %s: %w", dir, err) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", "", fmt.Errorf("create directory %s: %w", dir, err) + } + if err := os.Chmod(dir, 0o700); err != nil { + return "", "", fmt.Errorf("secure directory %s: %w", dir, err) + } + } + + // A task may invoke the Multica CLI and write a config into its private + // home. Never carry that credential into a later dispatch/reuse. + if err := os.RemoveAll(filepath.Join(homeDir, ".multica")); err != nil { + return "", "", fmt.Errorf("clear private Multica config: %w", err) + } + + return homeDir, configDir, nil +} + // hydrateCodexSkills populates the per-task CODEX_HOME/skills directory with // both user-installed skills (from the shared ~/.codex/skills/) and // workspace-assigned skills. Workspace skills win on name conflict — they are diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go index abf5a555ab8..7f66794228c 100644 --- a/server/internal/daemon/execenv/execenv_test.go +++ b/server/internal/daemon/execenv/execenv_test.go @@ -2,6 +2,7 @@ package execenv import ( "encoding/json" + "errors" "io" "log/slog" "os" @@ -637,6 +638,7 @@ func TestReuseRefreshesSkillsWithoutDuplicating(t *testing.T) { // Re-dispatch twice on the same persistent workdir. for i := 0; i < 2; i++ { if reused := Reuse(ReuseParams{ + RootDir: env.RootDir, WorkDir: env.WorkDir, Provider: "claude", Task: task, @@ -707,6 +709,7 @@ func TestReuseReclaimsManagedSkillDirWithStrayAgentFile(t *testing.T) { } if reused := Reuse(ReuseParams{ + RootDir: env.RootDir, WorkDir: env.WorkDir, Provider: "claude", Task: task, @@ -736,6 +739,122 @@ func TestReuseReclaimsManagedSkillDirWithStrayAgentFile(t *testing.T) { } } +func TestReuseRejectsUntrustedPathsBeforeWriting(t *testing.T) { + t.Parallel() + + makeRoot := func(t *testing.T) (string, string) { + t.Helper() + root := filepath.Join(t.TempDir(), "task-root") + workdir := filepath.Join(root, "workdir") + if err := os.MkdirAll(workdir, 0o700); err != nil { + t.Fatal(err) + } + return root, workdir + } + assertUntouched := func(t *testing.T, root, sentinel string) { + t.Helper() + data, err := os.ReadFile(sentinel) + if err != nil || string(data) != "owner-data" { + t.Fatalf("sentinel changed: data=%q err=%v", data, err) + } + for _, name := range []string{"home", "codex-home", "hermes-home", ".agent_context"} { + if _, err := os.Lstat(filepath.Join(root, name)); !os.IsNotExist(err) { + t.Fatalf("rejected reuse wrote %s: %v", name, err) + } + } + } + + tests := []struct { + name string + params func(*testing.T) (ReuseParams, string, string) + }{ + { + name: "missing trusted root", + params: func(t *testing.T) (ReuseParams, string, string) { + root, workdir := makeRoot(t) + return ReuseParams{WorkDir: workdir, Provider: "claude"}, root, workdir + }, + }, + { + name: "foreign workdir", + params: func(t *testing.T) (ReuseParams, string, string) { + trustedRoot, _ := makeRoot(t) + foreignRoot, foreignWorkdir := makeRoot(t) + return ReuseParams{RootDir: trustedRoot, WorkDir: foreignWorkdir, Provider: "claude"}, foreignRoot, foreignWorkdir + }, + }, + { + name: "wrong child name", + params: func(t *testing.T) (ReuseParams, string, string) { + root, _ := makeRoot(t) + foreign := filepath.Join(root, "foreign") + if err := os.Mkdir(foreign, 0o700); err != nil { + t.Fatal(err) + } + return ReuseParams{RootDir: root, WorkDir: foreign, Provider: "claude"}, root, foreign + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + params, observedRoot, sentinelDir := tt.params(t) + sentinel := filepath.Join(sentinelDir, "sentinel") + if err := os.WriteFile(sentinel, []byte("owner-data"), 0o600); err != nil { + t.Fatal(err) + } + if reused := Reuse(params, testLogger()); reused != nil { + t.Fatal("Reuse accepted an untrusted path") + } + assertUntouched(t, observedRoot, sentinel) + }) + } + + t.Run("symlinked root", func(t *testing.T) { + realRoot, realWorkdir := makeRoot(t) + linkRoot := filepath.Join(t.TempDir(), "task-root-link") + if err := os.Symlink(realRoot, linkRoot); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + sentinel := filepath.Join(realWorkdir, "sentinel") + if err := os.WriteFile(sentinel, []byte("owner-data"), 0o600); err != nil { + t.Fatal(err) + } + if reused := Reuse(ReuseParams{RootDir: linkRoot, WorkDir: filepath.Join(linkRoot, "workdir"), Provider: "claude"}, testLogger()); reused != nil { + t.Fatal("Reuse accepted a symlinked root") + } + assertUntouched(t, realRoot, sentinel) + }) + + t.Run("symlinked workdir", func(t *testing.T) { + root := filepath.Join(t.TempDir(), "task-root") + if err := os.Mkdir(root, 0o700); err != nil { + t.Fatal(err) + } + foreignRoot, foreignWorkdir := makeRoot(t) + if err := os.Symlink(foreignWorkdir, filepath.Join(root, "workdir")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + sentinel := filepath.Join(foreignWorkdir, "sentinel") + if err := os.WriteFile(sentinel, []byte("owner-data"), 0o600); err != nil { + t.Fatal(err) + } + if reused := Reuse(ReuseParams{RootDir: root, WorkDir: filepath.Join(root, "workdir"), Provider: "claude"}, testLogger()); reused != nil { + t.Fatal("Reuse accepted a symlinked workdir") + } + assertUntouched(t, foreignRoot, sentinel) + }) + + t.Run("valid expected paths", func(t *testing.T) { + root, workdir := makeRoot(t) + reused := Reuse(ReuseParams{RootDir: root, WorkDir: workdir, Provider: "claude"}, testLogger()) + if reused == nil || reused.RootDir != root || reused.WorkDir != workdir { + t.Fatalf("Reuse valid identity = %#v", reused) + } + }) +} + // TestReuseSkillRefreshIsCanonicalAcrossProviders exercises the reuse skill // rollback (removeReusedManagedSkillDirs + CleanupSidecars + writeContextFiles // — the exact sequence Reuse runs) directly across the file-based providers, @@ -2011,40 +2130,47 @@ func TestPrepareCodexHomeSeedsFromShared(t *testing.T) { } // sessions should be a real, task-local directory — NOT a symlink into the - // shared home (MUL-4424). A fresh home gets an empty local dir. + // shared home (MUL-4424). A fresh home gets an empty private local dir. sessionsPath := filepath.Join(codexHome, "sessions") fi, err := os.Lstat(sessionsPath) if err != nil { t.Fatalf("sessions not found: %v", err) } - if fi.Mode()&os.ModeSymlink != 0 { - t.Error("sessions should be a task-local directory, not a symlink into the shared home") + if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() { + t.Fatalf("sessions mode = %v, want task-private directory", fi.Mode()) } - if !fi.IsDir() { - t.Error("sessions should be a directory") + if fi.Mode().Perm() != 0o700 { + t.Errorf("sessions mode = %#o, want 0700", fi.Mode().Perm()) } - // auth.json should be a symlink. + // auth.json must be a private, permission-restricted snapshot. authPath := filepath.Join(codexHome, "auth.json") fi, err = os.Lstat(authPath) if err != nil { t.Fatalf("auth.json not found: %v", err) } - authIsLink := fi.Mode()&os.ModeSymlink != 0 - if !authIsLink && runtime.GOOS != "windows" { - t.Error("auth.json should be a symlink") + if fi.Mode()&os.ModeSymlink != 0 || !fi.Mode().IsRegular() { + t.Fatalf("auth.json mode = %v, want task-private regular file", fi.Mode()) } - if authIsLink { - target, _ := os.Readlink(authPath) - if target != filepath.Join(sharedHome, "auth.json") { - t.Errorf("auth.json symlink target = %q, want %q", target, filepath.Join(sharedHome, "auth.json")) - } + if fi.Mode().Perm() != 0o600 { + t.Errorf("auth.json mode = %#o, want 0600", fi.Mode().Perm()) } - // Verify content is accessible through symlink. + // Verify content was captured, then prove later owner mutation cannot alter + // the credentials visible to the running task. data, _ := os.ReadFile(authPath) if string(data) != `{"token":"secret"}` { t.Errorf("auth.json content = %q", data) } + if err := os.WriteFile(filepath.Join(sharedHome, "auth.json"), []byte(`{"token":"rotated"}`), 0o600); err != nil { + t.Fatalf("rotate shared auth: %v", err) + } + data, err = os.ReadFile(authPath) + if err != nil { + t.Fatalf("read auth snapshot after owner mutation: %v", err) + } + if string(data) != `{"token":"secret"}` { + t.Errorf("auth snapshot changed with owner file: %q", data) + } // config.json should be a copy (not symlink). configPath := filepath.Join(codexHome, "config.json") @@ -2109,6 +2235,166 @@ func TestPrepareCodexHomeSeedsFromShared(t *testing.T) { } } +func TestPrepareCodexHomeIsolatesPluginCacheFromSharedHome(t *testing.T) { + // Cannot use t.Parallel() with t.Setenv. + + sharedHome := t.TempDir() + sharedPluginFile := filepath.Join(sharedHome, "plugins", "cache", "superpowers", "SKILL.md") + if err := os.MkdirAll(filepath.Dir(sharedPluginFile), 0o755); err != nil { + t.Fatalf("create shared plugin cache: %v", err) + } + if err := os.WriteFile(sharedPluginFile, []byte("owner-v1"), 0o644); err != nil { + t.Fatalf("write shared plugin cache: %v", err) + } + t.Setenv("CODEX_HOME", sharedHome) + + codexHome := filepath.Join(t.TempDir(), "codex-home") + if runtime.GOOS != "windows" { + legacyTaskPluginCache := filepath.Join(codexHome, "plugins", "cache") + if err := os.MkdirAll(filepath.Dir(legacyTaskPluginCache), 0o755); err != nil { + t.Fatalf("create legacy task plugin directory: %v", err) + } + if err := os.Symlink(filepath.Join(sharedHome, "plugins", "cache"), legacyTaskPluginCache); err != nil { + t.Fatalf("create legacy shared plugin cache link: %v", err) + } + } + if err := prepareCodexHome(codexHome, testLogger()); err != nil { + t.Fatalf("prepareCodexHome failed: %v", err) + } + + taskPluginCache := filepath.Join(codexHome, "plugins", "cache") + info, err := os.Lstat(taskPluginCache) + if err != nil { + t.Fatalf("inspect task plugin cache: %v", err) + } + if info.Mode()&os.ModeSymlink != 0 { + t.Errorf("task plugin cache is a symlink to mutable owner state") + } + + taskPluginFile := filepath.Join(taskPluginCache, "superpowers", "SKILL.md") + if err := os.WriteFile(taskPluginFile, []byte("task-write"), 0o644); err != nil { + t.Fatalf("write task plugin cache: %v", err) + } + ownerData, err := os.ReadFile(sharedPluginFile) + if err != nil { + t.Fatalf("read shared plugin cache after task write: %v", err) + } + if string(ownerData) != "owner-v1" { + t.Errorf("task plugin cache write mutated owner cache: got %q, want %q", ownerData, "owner-v1") + } + + if err := os.WriteFile(sharedPluginFile, []byte("owner-v2"), 0o644); err != nil { + t.Fatalf("update shared plugin cache: %v", err) + } + taskData, err := os.ReadFile(taskPluginFile) + if err != nil { + t.Fatalf("read task plugin cache after owner update: %v", err) + } + if string(taskData) != "task-write" { + t.Errorf("owner plugin cache mutation changed task cache: got %q, want %q", taskData, "task-write") + } +} + +func TestSnapshotSharedCodexPluginCacheRejectsHardlinkedSourceFile(t *testing.T) { + sharedHome := t.TempDir() + sharedPluginFile := filepath.Join(sharedHome, "plugins", "cache", "plugin", "manifest.json") + if err := os.MkdirAll(filepath.Dir(sharedPluginFile), 0o755); err != nil { + t.Fatalf("create shared plugin cache: %v", err) + } + if err := os.WriteFile(sharedPluginFile, []byte("owner-plugin"), 0o644); err != nil { + t.Fatalf("write shared plugin file: %v", err) + } + ownerAlias := filepath.Join(sharedHome, "owner-alias.json") + if err := os.Link(sharedPluginFile, ownerAlias); err != nil { + t.Skipf("hard links unavailable: %v", err) + } + + codexHome := filepath.Join(t.TempDir(), "codex-home") + taskMarker := filepath.Join(codexHome, "plugins", "cache", "existing.txt") + if err := os.MkdirAll(filepath.Dir(taskMarker), 0o700); err != nil { + t.Fatalf("create existing task plugin cache: %v", err) + } + if err := os.WriteFile(taskMarker, []byte("existing-snapshot"), 0o600); err != nil { + t.Fatalf("write existing task plugin cache: %v", err) + } + + err := snapshotSharedCodexPluginCache(codexHome, sharedHome) + if err == nil { + t.Fatal("expected hardlinked plugin cache source to be rejected") + } + if !errors.Is(err, errUnsafeTaskCodexPluginCacheEntry) || !strings.Contains(err.Error(), "hard link") { + t.Fatalf("snapshot error = %v, want unsafe hard-link rejection", err) + } + data, readErr := os.ReadFile(taskMarker) + if readErr != nil { + t.Fatalf("existing task plugin cache was not preserved: %v", readErr) + } + if string(data) != "existing-snapshot" { + t.Fatalf("existing task plugin cache changed: %q", data) + } +} + +func TestSnapshotSharedCodexPluginCacheRejectsSymlinkSwapDuringCopy(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink fixture requires elevated privileges on some Windows hosts") + } + + sharedHome := t.TempDir() + sharedPluginFile := filepath.Join(sharedHome, "plugins", "cache", "plugin", "manifest.json") + if err := os.MkdirAll(filepath.Dir(sharedPluginFile), 0o755); err != nil { + t.Fatalf("create shared plugin cache: %v", err) + } + if err := os.WriteFile(sharedPluginFile, []byte("owner-plugin"), 0o644); err != nil { + t.Fatalf("write shared plugin file: %v", err) + } + outsideFile := filepath.Join(t.TempDir(), "outside.json") + if err := os.WriteFile(outsideFile, []byte("outside-owner-state"), 0o600); err != nil { + t.Fatalf("write outside owner file: %v", err) + } + + codexHome := filepath.Join(t.TempDir(), "codex-home") + taskMarker := filepath.Join(codexHome, "plugins", "cache", "existing.txt") + if err := os.MkdirAll(filepath.Dir(taskMarker), 0o700); err != nil { + t.Fatalf("create existing task plugin cache: %v", err) + } + if err := os.WriteFile(taskMarker, []byte("existing-snapshot"), 0o600); err != nil { + t.Fatalf("write existing task plugin cache: %v", err) + } + + swapped := false + err := snapshotSharedCodexPluginCacheWithAfterOpen(codexHome, sharedHome, func(path string) { + if swapped || path != sharedPluginFile { + return + } + swapped = true + if removeErr := os.Remove(path); removeErr != nil { + t.Fatalf("remove plugin file during swap: %v", removeErr) + } + if symlinkErr := os.Symlink(outsideFile, path); symlinkErr != nil { + t.Fatalf("replace plugin file with symlink: %v", symlinkErr) + } + }) + if !swapped { + t.Fatal("test hook did not swap the plugin cache source") + } + if err == nil { + t.Fatal("expected plugin cache source identity change to be rejected") + } + if !errors.Is(err, errUnsafeTaskCodexPluginCacheEntry) || !strings.Contains(err.Error(), "identity changed") { + t.Fatalf("snapshot error = %v, want unsafe identity-change rejection", err) + } + data, readErr := os.ReadFile(taskMarker) + if readErr != nil { + t.Fatalf("existing task plugin cache was not preserved: %v", readErr) + } + if string(data) != "existing-snapshot" { + t.Fatalf("existing task plugin cache changed: %q", data) + } + if _, statErr := os.Stat(filepath.Join(codexHome, "plugins", "cache", "plugin", "manifest.json")); !os.IsNotExist(statErr) { + t.Fatalf("unsafe staging snapshot was published: stat error = %v", statErr) + } +} + func TestPrepareCodexHomeCopiesRelativeModelCatalog(t *testing.T) { // Cannot use t.Parallel() with t.Setenv. @@ -2219,8 +2505,8 @@ func TestPrepareCodexHomeSkipsMissingFiles(t *testing.T) { t.Fatalf("prepareCodexHome failed: %v", err) } - // Directory should contain task-local sessions, the model-cache config - // binding, and auto-generated config.toml. + // Directory should contain private task-local sessions, the model-cache + // config binding, and auto-generated config.toml. entries, err := os.ReadDir(codexHome) if err != nil { t.Fatalf("failed to read codex-home: %v", err) @@ -2230,7 +2516,7 @@ func TestPrepareCodexHomeSkipsMissingFiles(t *testing.T) { entryNames[e.Name()] = true } if !entryNames["sessions"] { - t.Error("expected sessions directory") + t.Error("expected private sessions directory") } if !entryNames["config.toml"] { t.Error("expected config.toml (auto-generated for network access)") @@ -2247,23 +2533,43 @@ func TestPrepareCodexHomeSkipsMissingFiles(t *testing.T) { } } // sessions should be a real, task-local directory — not a symlink into the - // shared home (MUL-4424). + // shared home, even when the shared home is empty (MUL-4424). sessionsPath := filepath.Join(codexHome, "sessions") fi, err := os.Lstat(sessionsPath) if err != nil { t.Fatalf("sessions not found: %v", err) } - if fi.Mode()&os.ModeSymlink != 0 { - t.Error("sessions should be a task-local directory, not a symlink") + if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() { + t.Fatalf("sessions mode = %v, want private directory", fi.Mode()) } - if !fi.IsDir() { - t.Error("sessions should be a directory") + if fi.Mode().Perm() != 0o700 { + t.Errorf("sessions mode = %#o, want 0700", fi.Mode().Perm()) } if _, err := os.Stat(filepath.Join(codexHome, "plugins", "cache")); err != nil { t.Fatalf("missing shared plugin cache exposure should still be tolerated and created: %v", err) } } +func TestPrepareCodexHomeRejectsUnsafeAuthSource(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink fixture requires elevated privileges on some Windows hosts") + } + sharedHome := t.TempDir() + ownerAuth := filepath.Join(t.TempDir(), "owner-auth.json") + if err := os.WriteFile(ownerAuth, []byte(`{"token":"owner"}`), 0o600); err != nil { + t.Fatalf("write owner auth: %v", err) + } + if err := os.Symlink(ownerAuth, filepath.Join(sharedHome, "auth.json")); err != nil { + t.Fatalf("symlink shared auth: %v", err) + } + t.Setenv("CODEX_HOME", sharedHome) + + err := prepareCodexHome(filepath.Join(t.TempDir(), "codex-home"), testLogger()) + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("prepareCodexHome error = %v, want fail-closed symlink rejection", err) + } +} + // Regression for issue #2081: when the per-task auth.json is a stale regular // file (e.g. left behind from an earlier Windows copy fallback), a subsequent // Reuse() / prepareCodexHome must refresh it from the shared source rather @@ -2828,7 +3134,7 @@ func TestReuseRestoresCodexHome(t *testing.T) { } // Reuse should restore CodexHome. - reused := Reuse(ReuseParams{WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{IssueID: "reuse-test"}}, testLogger()) + reused := Reuse(ReuseParams{RootDir: env.RootDir, WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{IssueID: "reuse-test"}}, testLogger()) if reused == nil { t.Fatal("Reuse returned nil") } @@ -2878,7 +3184,7 @@ func TestReuseRestoresCodexPluginCache(t *testing.T) { t.Fatalf("remove codex plugins dir: %v", err) } - reused := Reuse(ReuseParams{WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{IssueID: "reuse-plugin-test"}}, testLogger()) + reused := Reuse(ReuseParams{RootDir: env.RootDir, WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{IssueID: "reuse-plugin-test"}}, testLogger()) if reused == nil { t.Fatal("Reuse returned nil") } @@ -2917,6 +3223,7 @@ func TestReusePreservesTaskLocalModelsCacheWhenSharedMissing(t *testing.T) { } reused := Reuse(ReuseParams{ + RootDir: env.RootDir, WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{IssueID: "reuse-model-cache-missing"}, @@ -2962,6 +3269,7 @@ func TestReusePreservesTaskLocalModelsCacheOverStaleSharedSnapshot(t *testing.T) } reused := Reuse(ReuseParams{ + RootDir: env.RootDir, WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{IssueID: "reuse-model-cache-stale"}, @@ -3020,6 +3328,7 @@ func TestReuseInvalidatesTaskLocalModelsCacheWhenProviderConfigChanges(t *testin } reused := Reuse(ReuseParams{ + RootDir: env.RootDir, WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{IssueID: "reuse-model-cache-provider-change"}, @@ -3044,6 +3353,7 @@ func TestReuseInvalidatesTaskLocalModelsCacheWhenProviderConfigChanges(t *testin t.Fatalf("write provider B task cache: %v", err) } if Reuse(ReuseParams{ + RootDir: env.RootDir, WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{IssueID: "reuse-model-cache-provider-change"}, @@ -3097,6 +3407,7 @@ func TestReuseInvalidatesTaskLocalModelsCacheWhenModelCatalogChanges(t *testing. } reused := Reuse(ReuseParams{ + RootDir: env.RootDir, WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{IssueID: "reuse-model-catalog-change"}, @@ -3147,6 +3458,7 @@ func TestReuseInvalidatesUnboundLegacyModelsCache(t *testing.T) { } if Reuse(ReuseParams{ + RootDir: env.RootDir, WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{IssueID: "reuse-model-cache-legacy"}, @@ -3166,6 +3478,7 @@ func TestReuseInvalidatesUnboundLegacyModelsCache(t *testing.T) { t.Fatalf("write shared cache: %v", err) } if Reuse(ReuseParams{ + RootDir: env.RootDir, WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{IssueID: "reuse-model-cache-legacy"}, @@ -3201,7 +3514,7 @@ func TestReuseWritesMissingCodexWorkspaceSkills(t *testing.T) { t.Fatalf("remove codex skills dir: %v", err) } - reused := Reuse(ReuseParams{WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{ + reused := Reuse(ReuseParams{RootDir: env.RootDir, WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{ IssueID: "reuse-skill-test", AgentSkills: []SkillContextForEnv{ { @@ -3260,7 +3573,7 @@ func TestReuseUpdatesCodexWorkspaceSkills(t *testing.T) { } defer env.Cleanup(true) - reused := Reuse(ReuseParams{WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{ + reused := Reuse(ReuseParams{RootDir: env.RootDir, WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{ IssueID: "reuse-skill-update-test", AgentSkills: []SkillContextForEnv{ { @@ -3527,7 +3840,7 @@ func TestReuseSeedsUserSkillUpdates(t *testing.T) { t.Fatalf("update user SKILL.md: %v", err) } - reused := Reuse(ReuseParams{WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{ + reused := Reuse(ReuseParams{RootDir: env.RootDir, WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{ IssueID: "user-skill-reuse-test", }}, testLogger()) if reused == nil { @@ -3582,7 +3895,7 @@ func TestReuseClearsUserSkillResidueOnWorkspaceConflict(t *testing.T) { t.Fatalf("user support file should be seeded in round 1: %v", err) } - reused := Reuse(ReuseParams{WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{ + reused := Reuse(ReuseParams{RootDir: env.RootDir, WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{ IssueID: "reuse-conflict-test", AgentSkills: []SkillContextForEnv{ {Name: "Writing", Content: "workspace writing"}, @@ -3644,7 +3957,7 @@ func TestReuseClearsRemovedUserSkill(t *testing.T) { t.Fatalf("remove user skill: %v", err) } - reused := Reuse(ReuseParams{WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{ + reused := Reuse(ReuseParams{RootDir: env.RootDir, WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{ IssueID: "reuse-remove-test", }}, testLogger()) if reused == nil { diff --git a/server/internal/daemon/execenv/hermes_home.go b/server/internal/daemon/execenv/hermes_home.go index 8ad5af02ec9..47546c3f4d3 100644 --- a/server/internal/daemon/execenv/hermes_home.go +++ b/server/internal/daemon/execenv/hermes_home.go @@ -1,7 +1,9 @@ package execenv import ( + "errors" "fmt" + "io/fs" "log/slog" "os" "path/filepath" @@ -12,81 +14,116 @@ import ( "gopkg.in/yaml.v3" ) -// Hermes discovers skills from exactly two places (verified against the bundled -// Hermes agent source, agent/skill_utils.py get_all_skills_dirs): its home -// skills dir `/skills/` first, then the directories listed under -// `skills.external_dirs` in `/config.yaml`, in config order. It has -// NO workspace-relative discovery, so the generic `.agent_context/skills/` -// fallback the daemon used before was never scanned and workspace-assigned -// skills silently never took effect (issue #5242). +// Hermes only discovers managed skills under HERMES_HOME. Issue tasks therefore +// use a task-private home containing the bound Multica skills and a narrow, +// immutable projection of runtime state needed to launch Hermes. The projection +// never links back to daemon-owner paths, never exposes owner skills or +// external_dirs, and drops unknown current or future owner-home entries. // -// Rather than replace HERMES_HOME with a home rebuilt from a fixed allowlist — -// which would silently change behavior for tasks that don't even use skills and -// would drop any home state not on the list (plugins, OAuth state, hooks, SOUL, -// scripts, and whatever Hermes adds next) — this builds a minimal compatibility -// overlay, and only when the agent actually has skills bound (gated at the call -// site in Prepare/Reuse; a skill-less Hermes task keeps HERMES_HOME untouched). -// -// The overlay: -// - mirrors every top-level entry of the shared ~/.hermes/ into the per-task -// home via symlink, EXCEPT the entries it overrides — so the denylist stays -// tiny and future home state is inherited automatically instead of being -// missed by an allowlist; -// - derives a task-local config.yaml whose `skills.external_dirs` points at -// the shared skills dir plus the user's existing external_dirs, expanded -// against the agent's effective env and normalized to absolute paths (Hermes -// resolves relative external_dirs against HERMES_HOME, so leaving them -// relative after the redirect would silently repoint them) — this exposes -// the user's global/builtin skills read-only without copying them; -// - populates the task-local `skills/` dir with ONLY the Multica-bound skills, -// which take precedence because Hermes lists the home skills dir first; -// - keeps `memories/` overlay-owned (a fresh per-task dir), NOT symlinked to -// the shared home: Hermes loads and writes back MEMORY.md/USER.md there, and -// per-agent memory is a Multica product concern — the host's local Hermes -// memory must not bleed into a task, nor task memory back out to the host; -// - disables the external `memory.provider` in the derived config so a -// host-configured Supermemory/Hindsight/etc. backend isn't shared across -// tasks. This is the on-disk + external-backend memory isolation; a managed, -// agent-scoped memory backend is a separate future product decision. -// -// The shared ~/.hermes/ is never modified by this setup. Note however that -// mirrored entries are writable symlinks, so if Hermes writes through one at -// runtime (e.g. refreshing token state under a mirrored auth/OAuth path) that -// write does reach the shared home — that propagation is intentional. - -// hermesOverriddenEntries are the top-level entries of the shared ~/.hermes/ -// that the overlay supplies its own task-local version of, so they are NOT -// mirrored from the shared home and are preserved across reuse reconciliation: -// - skills/ task-local, only the bound skills -// - config.yaml derived config with absolutized external_dirs -// - memories/ fresh per-task dir, isolated from the host's memory -// -// Everything else in the shared home is mirrored generically. -// -// active_profile and profiles are also overlay-owned (never mirrored): Hermes -// reads /active_profile at startup and, if it names a non-default -// profile, redirects HERMES_HOME to that real profile — which would bypass the -// overlay's skills and memory isolation. Keeping active_profile out of the -// overlay means Hermes finds none and stays put. The overlay is already seeded -// from the correct (possibly profile) source home. -// -// .env is overlay-owned too, but unlike the others it is DERIVED, not just -// omitted (see writeDerivedHermesEnv): Hermes runs _apply_profile_override() -// and then load_hermes_dotenv(), which loads /.env with -// override=True. A source .env carrying an out-of-band HERMES_HOME= would -// overwrite the overlay's HERMES_HOME after the argv/sticky-profile protections -// ran, repointing skill discovery and memory back at the source home. The -// derived overlay .env preserves the source's credentials/settings but pins -// HERMES_HOME to the overlay, and is written even when the source has none so -// Hermes' project-.env fallback (loaded with override=True only when no user -// .env loaded) can't relocate the home either. -var hermesOverriddenEntries = map[string]struct{}{ - "skills": {}, - "config.yaml": {}, - "memories": {}, - "active_profile": {}, - "profiles": {}, - ".env": {}, +// auth.json, oauth_state.json, .env, and config.yaml are stable bounded file +// snapshots or derived documents. plugins/ is a bounded fail-closed directory +// snapshot. memories/ and skills/ are task-owned. Profile selectors are absent +// so Hermes cannot redirect itself back to an owner home after launch. +var hermesTaskHomeEntries = map[string]struct{}{ + "auth.json": {}, + "oauth_state.json": {}, + "plugins": {}, + "skills": {}, + "config.yaml": {}, + "memories": {}, + ".env": {}, +} + +const ( + hermesRuntimeSnapshotMaxBytes = 16 << 20 + hermesPluginSnapshotMaxFiles = 10_000 + hermesPluginSnapshotMaxFileBytes = 16 << 20 + hermesPluginSnapshotMaxBytes = 256 << 20 +) + +var ( + errUnsafeHermesPluginEntry = errors.New("unsafe Hermes plugin entry") + errHermesPluginLimit = errors.New("Hermes plugin snapshot limit exceeded") +) + +// hermesOwnerEnvAllowlist is the complete set of source-home dotenv variables +// that may be projected into an issue task. Keep this explicit: the owner .env +// can contain unrelated process settings and credentials that the task must not +// inherit merely because it needs a Hermes skills overlay. +var hermesOwnerEnvAllowlist = map[string]struct{}{ + "ALIBABA_CODING_PLAN_API_KEY": {}, + "ALIBABA_CODING_PLAN_BASE_URL": {}, + "ANTHROPIC_API_KEY": {}, + "ANTHROPIC_BASE_URL": {}, + "ANTHROPIC_TOKEN": {}, + "ARCEEAI_API_KEY": {}, + "ARCEE_BASE_URL": {}, + "AZURE_FOUNDRY_API_KEY": {}, + "AZURE_FOUNDRY_BASE_URL": {}, + "BEDROCK_BASE_URL": {}, + "CLAUDE_CODE_OAUTH_TOKEN": {}, + "COPILOT_ACP_BASE_URL": {}, + "COPILOT_API_BASE_URL": {}, + "DASHSCOPE_API_KEY": {}, + "DASHSCOPE_BASE_URL": {}, + "DEEPSEEK_API_KEY": {}, + "DEEPSEEK_BASE_URL": {}, + "GEMINI_API_KEY": {}, + "GEMINI_BASE_URL": {}, + "GLM_API_KEY": {}, + "GLM_BASE_URL": {}, + "GMI_API_KEY": {}, + "GMI_BASE_URL": {}, + "GOOGLE_API_KEY": {}, + "HF_BASE_URL": {}, + "HF_TOKEN": {}, + "KILOCODE_API_KEY": {}, + "KILOCODE_BASE_URL": {}, + "KIMI_API_KEY": {}, + "KIMI_BASE_URL": {}, + "KIMI_CN_API_KEY": {}, + "KIMI_CODING_API_KEY": {}, + "LM_API_KEY": {}, + "LM_BASE_URL": {}, + "MINIMAX_API_KEY": {}, + "MINIMAX_BASE_URL": {}, + "MINIMAX_CN_API_KEY": {}, + "MINIMAX_CN_BASE_URL": {}, + "NVIDIA_API_KEY": {}, + "NVIDIA_BASE_URL": {}, + "OLLAMA_API_KEY": {}, + "OLLAMA_BASE_URL": {}, + "OPENCODE_GO_API_KEY": {}, + "OPENCODE_GO_BASE_URL": {}, + "OPENCODE_ZEN_API_KEY": {}, + "OPENCODE_ZEN_BASE_URL": {}, + "OPENAI_API_KEY": {}, + "OPENAI_BASE_URL": {}, + "OPENROUTER_API_KEY": {}, + "OPENROUTER_BASE_URL": {}, + "STEPFUN_API_KEY": {}, + "STEPFUN_BASE_URL": {}, + "TOKENHUB_API_KEY": {}, + "TOKENHUB_BASE_URL": {}, + "XAI_API_KEY": {}, + "XAI_BASE_URL": {}, + "XIAOMI_API_KEY": {}, + "XIAOMI_BASE_URL": {}, + "ZAI_API_KEY": {}, + "Z_AI_API_KEY": {}, +} + +// hermesOwnerModelConfigAllowlist contains the public runtime fields accepted +// from Hermes' modern model mapping. Credential-bearing and unknown model +// fields are intentionally omitted. +var hermesOwnerModelConfigAllowlist = []string{ + "default", + "model", + "provider", + "base_url", + "api_mode", + "openai_runtime", + "auth_mode", } // platformDefaultHermesHome returns Hermes' platform-native default home: @@ -324,24 +361,23 @@ func hermesProfileDir(root, name string) (home string, mustExist bool, err error return filepath.Join(root, "profiles", canon), true, nil } -// prepareHermesHome builds the per-task HERMES_HOME compatibility overlay -// described above. The daemon exports the given path as HERMES_HOME on the -// hermes subprocess so the CLI discovers the bound skills natively. +// prepareHermesHome builds the task-private HERMES_HOME described above. The +// daemon exports the given path as HERMES_HOME so Hermes discovers only the +// task-bound skills and projected runtime state. // // Callers gate this on the agent having skills bound; it is a full rebuild each -// time (mirror reconciled, config re-derived, bound skills rewritten) so a Reuse -// after a skill/config change lands cleanly. It fails CLOSED: if the mirror, the -// derived config, or the bound skills can't be established the whole overlay is -// unusable, so the error propagates and the caller must not start Hermes against -// a half-built home. +// time (state re-snapshotted, config re-derived, bound skills rewritten) so a +// Reuse after a skill/config change lands cleanly. It fails CLOSED: if any +// projection cannot be established, the caller must not start Hermes against a +// partial home. // sourceHome is the shared home to seed from (resolved by the daemon via // ResolveHermesProfile, honoring the agent's HERMES_HOME/profile); empty // falls back to the platform default. sourceMustExist fails closed when the // source home is absent — set for an explicitly named profile so a typo doesn't -// silently seed from an empty dir and drop the user's auth/config. env is the -// sanitized effective env used to expand ${VAR} in external_dirs so it matches -// what the Hermes child sees. -func prepareHermesHome(hermesHome, sourceHome string, sourceMustExist bool, workspaceSkills []SkillContextForEnv, env map[string]string, logger *slog.Logger) error { +// silently seed from an empty dir and drop the user's auth/config. env is kept +// in the signature for compatibility with existing callers; owner external_dirs +// are intentionally not projected. +func prepareHermesHome(hermesHome, sourceHome string, sourceMustExist bool, workspaceSkills []SkillContextForEnv, _ map[string]string, logger *slog.Logger) error { sharedHome := strings.TrimSpace(sourceHome) if sharedHome == "" { sharedHome = platformDefaultHermesHome() @@ -356,7 +392,7 @@ func prepareHermesHome(hermesHome, sourceHome string, sourceMustExist bool, work return fmt.Errorf("create hermes-home dir: %w", err) } // Tighten perms on reuse too — MkdirAll leaves an existing dir's mode alone, - // and the derived config below can hold inline api_key secrets. + // and the derived files below contain projected owner runtime settings. if err := os.Chmod(hermesHome, 0o700); err != nil { return fmt.Errorf("chmod hermes-home dir: %w", err) } @@ -366,10 +402,13 @@ func prepareHermesHome(hermesHome, sourceHome string, sourceMustExist bool, work return fmt.Errorf("create task memories dir: %w", err) } - if err := mirrorSharedHermesHome(sharedHome, hermesHome, logger); err != nil { - return fmt.Errorf("mirror shared hermes home: %w", err) + if err := reconcileHermesTaskHome(hermesHome); err != nil { + return fmt.Errorf("reconcile hermes task home: %w", err) + } + if err := snapshotHermesRuntimeState(sharedHome, hermesHome); err != nil { + return fmt.Errorf("snapshot hermes runtime state: %w", err) } - if err := writeDerivedHermesConfig(sharedHome, hermesHome, env, logger); err != nil { + if err := writeDerivedHermesConfig(sharedHome, hermesHome, logger); err != nil { return fmt.Errorf("derive hermes config: %w", err) } if err := writeDerivedHermesEnv(sharedHome, hermesHome); err != nil { @@ -378,27 +417,25 @@ func prepareHermesHome(hermesHome, sourceHome string, sourceMustExist bool, work return writeHermesBoundSkills(hermesHome, workspaceSkills, logger) } -// writeDerivedHermesEnv writes the task-local .env: the source home's .env -// contents (credentials/settings preserved) with any HERMES_HOME assignment -// removed, then a pinned HERMES_HOME pointing at the overlay appended last so it -// wins. Hermes loads /.env with override=True right after profile -// resolution, so without this an out-of-band HERMES_HOME= in the source .env -// would relocate the home past the overlay (dropping bound skills and memory -// isolation). We always write the file — even when the source has none — so the -// overlay .env "loads" and Hermes' project-.env fallback (override=True only when -// no user .env loaded) can't relocate the home either. Written 0600 via atomic -// replace since it can hold API-key secrets; reuse also repairs prior perms. +// writeDerivedHermesEnv writes the task-local .env: only explicitly allowlisted +// Hermes credentials/public settings from the source home's .env, followed by a +// pinned HERMES_HOME pointing at the overlay so it wins. Hermes loads +// /.env with override=True right after profile resolution, so +// without the pin an out-of-band HERMES_HOME= could relocate the home past the +// overlay. We always write the file — even when the source has none — so Hermes' +// project-.env fallback cannot supply owner/project state instead. Written 0600 +// via atomic replace since it can hold API-key secrets. func writeDerivedHermesEnv(sharedHome, hermesHome string) error { dst := filepath.Join(hermesHome, ".env") var body []byte - src, err := os.ReadFile(filepath.Join(sharedHome, ".env")) + src, err := readStableRegularFile(filepath.Join(sharedHome, ".env"), hermesRuntimeSnapshotMaxBytes, nil) if err != nil { - if !os.IsNotExist(err) { + if !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("read shared .env: %w", err) } } else { - body = stripDotenvAssignment(src, "HERMES_HOME") + body = projectHermesDotenv(src) } var buf strings.Builder @@ -416,14 +453,16 @@ func writeDerivedHermesEnv(sharedHome, hermesHome string) error { return writeFileAtomic(dst, []byte(buf.String()), 0o600) } -// stripDotenvAssignment drops every line of a .env file that assigns key, -// tolerating a leading `export ` and surrounding whitespace the way python-dotenv -// does. Other lines (including comments and blanks) are preserved verbatim. -func stripDotenvAssignment(content []byte, key string) []byte { +// projectHermesDotenv keeps only assignments whose key is explicitly allowed +// for Hermes task execution. Comments, blanks, malformed lines, HERMES_HOME, +// and all unrelated owner variables are dropped rather than copied into task +// scratch. Assignment text is otherwise preserved so python-dotenv retains its +// native quoting and expansion behavior for allowed values. +func projectHermesDotenv(content []byte) []byte { lines := strings.Split(string(content), "\n") - out := lines[:0] + out := make([]string, 0, len(lines)) for _, line := range lines { - if dotenvLineKey(line) == key { + if _, allowed := hermesOwnerEnvAllowlist[dotenvLineKey(line)]; !allowed { continue } out = append(out, line) @@ -449,133 +488,223 @@ func dotenvLineKey(line string) string { return strings.TrimSpace(s[:eq]) } -// mirrorSharedHermesHome symlinks every top-level entry of the shared ~/.hermes/ -// into the per-task home except the overlay-owned entries, then reconciles the -// destination so entries removed from the shared home (or left over from a prior -// reuse) don't linger as readable stale state. Symlinks share state with the -// user's real home (auth/OAuth refreshes propagate, no credential copy lingers -// in task scratch). The shared home itself is never written — we only read it -// and create links pointing into it. -func mirrorSharedHermesHome(sharedHome, hermesHome string, logger *slog.Logger) error { - entries, err := os.ReadDir(sharedHome) +// reconcileHermesTaskHome removes every top-level entry not owned by the task +// projection. This prevents unknown owner state or stale data from an older +// implementation surviving a Reuse. +func reconcileHermesTaskHome(hermesHome string) error { + entries, err := os.ReadDir(hermesHome) if err != nil { - if os.IsNotExist(err) { - // No shared home to mirror. The derived config + bound skills still - // give Hermes a working home, so this is not fatal on its own. - return reconcileMirroredEntries(hermesHome, nil) - } - return fmt.Errorf("read shared home: %w", err) + return fmt.Errorf("read task home: %w", err) } - mirrored := make(map[string]struct{}, len(entries)) for _, entry := range entries { - name := entry.Name() - if _, overridden := hermesOverriddenEntries[name]; overridden { + if _, keep := hermesTaskHomeEntries[entry.Name()]; keep { continue } - src := filepath.Join(sharedHome, name) - dst := filepath.Join(hermesHome, name) - if err := linkSharedHermesEntry(src, dst); err != nil { - return fmt.Errorf("mirror %s: %w", name, err) + if err := os.RemoveAll(filepath.Join(hermesHome, entry.Name())); err != nil { + return fmt.Errorf("remove unknown task-home entry %s: %w", entry.Name(), err) } - mirrored[name] = struct{}{} } - return reconcileMirroredEntries(hermesHome, mirrored) + return nil } -// reconcileMirroredEntries removes overlay entries that are neither overlay-owned -// nor currently mirrored from the shared home, so a shared entry deleted between -// runs (or a Windows copy-fallback left behind) doesn't survive as stale state. -func reconcileMirroredEntries(hermesHome string, mirrored map[string]struct{}) error { - entries, err := os.ReadDir(hermesHome) - if err != nil { - return fmt.Errorf("read overlay home: %w", err) - } - for _, entry := range entries { - name := entry.Name() - if _, owned := hermesOverriddenEntries[name]; owned { - continue +func snapshotHermesRuntimeState(sharedHome, hermesHome string) error { + for _, name := range []string{"auth.json", "oauth_state.json"} { + if err := snapshotOptionalHermesFile(filepath.Join(sharedHome, name), filepath.Join(hermesHome, name)); err != nil { + return fmt.Errorf("snapshot %s: %w", name, err) } - if _, keep := mirrored[name]; keep { - continue + } + return snapshotHermesPlugins(sharedHome, hermesHome, nil) +} + +func snapshotOptionalHermesFile(source, target string) error { + if _, err := os.Lstat(source); os.IsNotExist(err) { + return os.RemoveAll(target) + } else if err != nil { + return fmt.Errorf("inspect source: %w", err) + } + if info, err := os.Lstat(target); err == nil && (!info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0) { + if err := os.RemoveAll(target); err != nil { + return fmt.Errorf("remove unsafe target: %w", err) } - if err := os.RemoveAll(filepath.Join(hermesHome, name)); err != nil { - return fmt.Errorf("reconcile stale %s: %w", name, err) + } else if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("inspect target: %w", err) + } + return snapshotRegularFile(source, target, hermesRuntimeSnapshotMaxBytes) +} + +type hermesPluginSnapshotLimits struct { + files int + bytes int64 +} + +func snapshotHermesPlugins(sharedHome, hermesHome string, afterOpen func(string)) error { + source := filepath.Join(sharedHome, "plugins") + target := filepath.Join(hermesHome, "plugins") + info, err := os.Lstat(source) + if os.IsNotExist(err) { + return os.RemoveAll(target) + } + if err != nil { + return fmt.Errorf("inspect source: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("%w: source is not a regular directory", errUnsafeHermesPluginEntry) + } + + staging, err := os.MkdirTemp(hermesHome, ".hermes-plugins-snapshot-") + if err != nil { + return fmt.Errorf("create staging directory: %w", err) + } + published := false + defer func() { + if !published { + _ = os.RemoveAll(staging) } + }() + + limits := hermesPluginSnapshotLimits{} + if err := copyHermesPluginTree(source, staging, &limits, afterOpen); err != nil { + return err + } + if err := os.RemoveAll(target); err != nil { + return fmt.Errorf("remove stale snapshot: %w", err) + } + if err := os.Rename(staging, target); err != nil { + return fmt.Errorf("publish snapshot: %w", err) } + published = true return nil } -// linkSharedHermesEntry symlinks dst → src, idempotent across Reuse: an existing -// link already pointing at src is left alone; anything else is removed and -// recreated so the overlay never drifts from the shared home. A dangling source -// (a broken symlink in the user's home) is skipped, not failed. Directories use -// createDirLink and files createFileLink so the Windows copy fallbacks match the -// entry kind. -func linkSharedHermesEntry(src, dst string) error { - if fi, err := os.Lstat(dst); err == nil { - if fi.Mode()&os.ModeSymlink != 0 { - if target, err := os.Readlink(dst); err == nil && target == src { +func copyHermesPluginTree(source, target string, limits *hermesPluginSnapshotLimits, afterOpen func(string)) error { + return filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + relative, err := filepath.Rel(source, path) + if err != nil { + return fmt.Errorf("resolve plugin entry: %w", err) + } + destination := filepath.Join(target, relative) + if entry.IsDir() { + if relative == "." { return nil } + if err := os.Mkdir(destination, 0o700); err != nil { + return fmt.Errorf("create plugin snapshot directory: %w", err) + } + return nil } - if err := os.RemoveAll(dst); err != nil { - return fmt.Errorf("remove stale %s: %w", dst, err) + if entry.Type()&os.ModeSymlink != 0 || !entry.Type().IsRegular() { + return fmt.Errorf("%w: %s", errUnsafeHermesPluginEntry, relative) } - } - - info, err := os.Stat(src) // follow the link to decide dir vs file - if err != nil { - if os.IsNotExist(err) { - return nil // dangling source in the user's home — nothing to link + limits.files++ + if limits.files > hermesPluginSnapshotMaxFiles { + return fmt.Errorf("%w: %s", errHermesPluginLimit, relative) } - return fmt.Errorf("stat %s: %w", src, err) - } - if info.IsDir() { - return createDirLink(src, dst) - } - return createFileLink(src, dst) + var callback func() + if afterOpen != nil { + callback = func() { afterOpen(path) } + } + data, err := readStableRegularFile(path, hermesPluginSnapshotMaxFileBytes, callback) + if err != nil { + return fmt.Errorf("%w: %s: %v", errUnsafeHermesPluginEntry, relative, err) + } + if limits.bytes > hermesPluginSnapshotMaxBytes-int64(len(data)) { + return fmt.Errorf("%w: %s", errHermesPluginLimit, relative) + } + limits.bytes += int64(len(data)) + if err := writePrivateSnapshot(destination, data, hermesPluginSnapshotMaxFileBytes); err != nil { + return fmt.Errorf("write plugin snapshot %s: %w", relative, err) + } + return nil + }) } -// writeDerivedHermesConfig writes the task-local config.yaml: the user's config -// with `skills.external_dirs` set to their existing external dirs plus the shared -// ~/.hermes/skills, all as absolute paths. When the user has no config we still -// write a minimal one so their global skills stay reachable via the external -// root. If the config can't be parsed we copy it verbatim so auth/model settings -// survive — the bound skills still load from the task-local skills/ dir, which is -// the point of the fix; only the user's global skills would be missing. The file -// is written 0600 (it can hold inline api_key secrets) via atomic replace, so -// reuse also repairs a prior file's permissions. -func writeDerivedHermesConfig(sharedHome, hermesHome string, env map[string]string, logger *slog.Logger) error { +// writeDerivedHermesConfig writes a minimal task-local config.yaml. It projects +// only explicitly allowlisted public model-selection fields, sets an empty +// skills.external_dirs list, and disables external memory. Credentials, owner +// paths, unknown fields, and malformed +// source bytes never enter task scratch. The file is written 0600 via atomic +// replace so reuse also repairs prior permissions. +func writeDerivedHermesConfig(sharedHome, hermesHome string, logger *slog.Logger) error { srcConfig := filepath.Join(sharedHome, "config.yaml") dstConfig := filepath.Join(hermesHome, "config.yaml") + derived := newHermesConfigDocument() + var source *yaml.Node - data, err := os.ReadFile(srcConfig) + data, err := readStableRegularFile(srcConfig, hermesRuntimeSnapshotMaxBytes, nil) if err != nil { - if !os.IsNotExist(err) { + if !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("read shared config: %w", err) } - doc := &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}}} - if err := setHermesExternalDirs(doc, computeHermesExternalDirs(sharedHome, nil, env)); err != nil { - return err + } else { + parsed := &yaml.Node{} + if err := yaml.Unmarshal(data, parsed); err != nil { + logger.Warn("execenv: hermes-home config parse failed; using safe minimal config", "error", err) + } else { + source = parsed + projectHermesConfigFields(derived, source) } - return marshalYAMLToFile(doc, dstConfig) - } - - var doc yaml.Node - if err := yaml.Unmarshal(data, &doc); err != nil { - logger.Warn("execenv: hermes-home config parse failed; copying verbatim", "error", err) - return writeFileAtomic(dstConfig, data, 0o600) } - dirs := computeHermesExternalDirs(sharedHome, existingHermesExternalDirs(&doc), env) - if err := setHermesExternalDirs(&doc, dirs); err != nil { - logger.Warn("execenv: hermes-home set external_dirs failed; copying verbatim", "error", err) - return writeFileAtomic(dstConfig, data, 0o600) + if err := setHermesExternalDirs(derived, nil); err != nil { + return err } // Disable any host-configured external memory backend (memory.provider) so a // Supermemory/Hindsight/etc. bank isn't shared across managed tasks; the // built-in per-task memories/ dir is already isolated above. - disableHermesMemoryProvider(&doc) - return marshalYAMLToFile(&doc, dstConfig) + disableHermesMemoryProvider(derived) + return marshalYAMLToFile(derived, dstConfig) +} + +func newHermesConfigDocument() *yaml.Node { + return &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}}} +} + +// projectHermesConfigFields preserves legacy scalar model/provider selection or +// reconstructs the modern model mapping from public allowlisted fields. Nodes +// are copied by value so the derived document does not retain pointers into the +// full owner config tree. +func projectHermesConfigFields(dst, source *yaml.Node) { + dstTop := yamlDocumentRoot(dst) + sourceTop := yamlDocumentRoot(source) + if dstTop == nil || sourceTop == nil { + return + } + + model := yamlMapValue(sourceTop, "model") + switch { + case model == nil: + case model.Kind == yaml.ScalarNode: + yamlSetMapValue(dstTop, "model", cloneYAMLScalar(model)) + case model.Kind == yaml.MappingNode: + projected := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + for _, key := range hermesOwnerModelConfigAllowlist { + if value := yamlMapValue(model, key); value != nil && value.Kind == yaml.ScalarNode { + yamlSetMapValue(projected, key, cloneYAMLScalar(value)) + } + } + if entra := yamlMapValue(model, "entra"); entra != nil && entra.Kind == yaml.MappingNode { + if scope := yamlMapValue(entra, "scope"); scope != nil && scope.Kind == yaml.ScalarNode { + projectedEntra := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + yamlSetMapValue(projectedEntra, "scope", cloneYAMLScalar(scope)) + yamlSetMapValue(projected, "entra", projectedEntra) + } + } + if len(projected.Content) > 0 { + yamlSetMapValue(dstTop, "model", projected) + } + } + + if provider := yamlMapValue(sourceTop, "provider"); provider != nil && provider.Kind == yaml.ScalarNode { + yamlSetMapValue(dstTop, "provider", cloneYAMLScalar(provider)) + } +} + +func cloneYAMLScalar(source *yaml.Node) *yaml.Node { + cloned := *source + cloned.Content = nil + return &cloned } // disableHermesMemoryProvider forces skills-adjacent `memory.provider` to empty @@ -596,77 +725,6 @@ func disableHermesMemoryProvider(doc *yaml.Node) { yamlSetMapValue(memory, "provider", &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ""}) } -// computeHermesExternalDirs normalizes the user's existing external_dirs to -// absolute paths and appends the shared skills dir as a read-only external root. -// Variable/`~` expansion uses the sanitized effective env (the same map layered -// onto the Hermes child), falling back to the daemon process env — so a `${VAR}` -// configured on the agent resolves to what Hermes will actually see, and a var -// the daemon blocklists (e.g. HOME) resolves to the process value rather than -// the dropped custom one. Unknown variables are PRESERVED as `${VAR}` (matching -// Hermes/Python expandvars) rather than collapsed to empty, so a path meant to -// be resolved at runtime isn't silently rewritten. An entry still containing an -// unresolved `${` is left as-is (Hermes expands it later); otherwise relative -// paths resolve against the shared home, matching pre-redirect behavior. Order -// preserved; duplicates dropped. -func computeHermesExternalDirs(sharedHome string, existing []string, env map[string]string) []string { - expand := func(s string) string { - return os.Expand(s, func(k string) string { - if v, ok := env[k]; ok { - return v - } - if v, ok := os.LookupEnv(k); ok { - return v - } - return "${" + k + "}" // preserve unknown vars for runtime expansion - }) - } - - out := make([]string, 0, len(existing)+1) - seen := make(map[string]struct{}, len(existing)+1) - add := func(p string) { - if p == "" { - return - } - if _, ok := seen[p]; ok { - return - } - seen[p] = struct{}{} - out = append(out, p) - } - for _, raw := range existing { - entry := strings.TrimSpace(raw) - if entry == "" { - continue - } - entry = strings.TrimSpace(expand(entry)) - if entry == "" { - continue - } - // An unresolved ${VAR} remains (unknown var preserved above) — leave the - // entry untouched so Hermes expands and resolves it at runtime; we can't - // safely decide abs-vs-relative here. - if strings.Contains(entry, "${") { - add(entry) - continue - } - if entry == "~" || strings.HasPrefix(entry, "~/") { - if home, err := os.UserHomeDir(); err == nil { - entry = filepath.Join(home, strings.TrimPrefix(entry, "~")) - } - } - if !filepath.IsAbs(entry) { - entry = filepath.Join(sharedHome, entry) - } - add(filepath.Clean(entry)) - } - // The shared skills dir, referenced (not copied) so the user's global and - // builtin skills stay visible. It differs from the task-local skills dir, - // so Hermes won't fold it into the local root, and being last it yields to - // the bound skills on a name collision. - add(filepath.Join(sharedHome, "skills")) - return out -} - // writeHermesBoundSkills rebuilds the task-local skills/ dir from scratch so a // skill removed since the last run can't linger, then writes only the // Multica-bound skills. They keep their natural slug (no user skills share this @@ -677,9 +735,7 @@ func writeHermesBoundSkills(hermesHome string, workspaceSkills []SkillContextFor return fmt.Errorf("clear hermes skills dir: %w", err) } if len(workspaceSkills) == 0 { - // Defensive: callers gate on a non-empty set, but stay correct if that - // ever changes — an empty local dir just means the external root is the - // only source, matching un-redirected behavior. + // Keep an explicit empty task-owned skills directory. return os.MkdirAll(skillsDir, 0o700) } // Skills live under env.RootDir/hermes-home, which the GC loop (cloud) or @@ -687,34 +743,6 @@ func writeHermesBoundSkills(hermesHome string, workspaceSkills []SkillContextFor return writeSkillFiles(skillsDir, workspaceSkills, nil) } -// existingHermesExternalDirs reads the raw skills.external_dirs entries from a -// parsed config document, accepting either a single string or a list (both of -// which Hermes accepts). Returns nil when absent. -func existingHermesExternalDirs(doc *yaml.Node) []string { - top := yamlDocumentRoot(doc) - if top == nil { - return nil - } - skills := yamlMapValue(top, "skills") - ed := yamlMapValue(skills, "external_dirs") - if ed == nil { - return nil - } - if ed.Kind == yaml.ScalarNode { - return []string{ed.Value} - } - if ed.Kind != yaml.SequenceNode { - return nil - } - out := make([]string, 0, len(ed.Content)) - for _, c := range ed.Content { - if c.Kind == yaml.ScalarNode { - out = append(out, c.Value) - } - } - return out -} - // setHermesExternalDirs sets skills.external_dirs on the config document, // creating the skills mapping if needed and preserving every other setting. func setHermesExternalDirs(doc *yaml.Node, dirs []string) error { @@ -787,8 +815,8 @@ func yamlStringSeq(vals []string) *yaml.Node { return seq } -// marshalYAMLToFile renders a YAML node to dst as a 0600 file (it can hold -// inline secrets) via atomic replace. +// marshalYAMLToFile renders a YAML node to dst as a 0600 file via atomic +// replace. func marshalYAMLToFile(doc *yaml.Node, dst string) error { out, err := yaml.Marshal(doc) if err != nil { diff --git a/server/internal/daemon/execenv/hermes_home_test.go b/server/internal/daemon/execenv/hermes_home_test.go index 53c36145b2d..dcc85a47c03 100644 --- a/server/internal/daemon/execenv/hermes_home_test.go +++ b/server/internal/daemon/execenv/hermes_home_test.go @@ -1,9 +1,12 @@ package execenv import ( + "errors" "os" "path/filepath" + "runtime" "strings" + "syscall" "testing" "gopkg.in/yaml.v3" @@ -49,19 +52,19 @@ func hermesMemoryProvider(t *testing.T, configPath string) (string, bool) { return *parsed.Memory.Provider, true } -// TestPrepareHermesHomeOverlay verifies the compatibility overlay: shared state -// is mirrored via symlink, the derived config references the user's real skills -// as an external root, and only the bound skill lands in the task-local skills/ -// dir (the user's global skills are referenced, not copied). +// TestPrepareHermesHomeOverlay verifies the issue-task projection: only the +// explicitly supported runtime snapshots enter the task home, no task entry +// points back at owner state, and only bound skills are discoverable. func TestPrepareHermesHomeOverlay(t *testing.T) { t.Parallel() sharedHome := t.TempDir() mustWrite(t, filepath.Join(sharedHome, "auth.json"), `{"token":"secret"}`) - mustWrite(t, filepath.Join(sharedHome, ".env"), "API_KEY=abc") + mustWrite(t, filepath.Join(sharedHome, ".env"), "ANTHROPIC_API_KEY=abc\nOWNER_NOTE=private") mustWrite(t, filepath.Join(sharedHome, "config.yaml"), "model: hermes-4\n") mustWrite(t, filepath.Join(sharedHome, "plugins", "custom-provider", "plugin.py"), "# provider plugin") mustWrite(t, filepath.Join(sharedHome, "oauth_state.json"), `{"nous":"tok"}`) mustWrite(t, filepath.Join(sharedHome, "skills", "personal-notes", "SKILL.md"), "My personal notes skill.") + mustWrite(t, filepath.Join(sharedHome, "owner-private.txt"), "must not enter task home") hermesHome := filepath.Join(t.TempDir(), "hermes-home") skills := []SkillContextForEnv{{Name: "Review Helper", Content: "Help review code."}} @@ -72,24 +75,44 @@ func TestPrepareHermesHomeOverlay(t *testing.T) { for _, name := range []string{"auth.json", "plugins", "oauth_state.json"} { fi, err := os.Lstat(filepath.Join(hermesHome, name)) if err != nil { - t.Fatalf("%s not mirrored into overlay: %v", name, err) + t.Fatalf("%s not projected into overlay: %v", name, err) } - if fi.Mode()&os.ModeSymlink == 0 { - t.Errorf("%s should be a symlink into the shared home", name) + if fi.Mode()&os.ModeSymlink != 0 { + t.Errorf("%s must be task-private, not a symlink into owner state", name) } } + if _, err := os.Lstat(filepath.Join(hermesHome, "owner-private.txt")); !os.IsNotExist(err) { + t.Fatalf("unknown owner entry entered task home: %v", err) + } + + // Owner mutation after preparation must not change the task snapshot, and + // task mutation must not propagate back to the owner home. + mustWrite(t, filepath.Join(sharedHome, "auth.json"), `{"token":"rotated"}`) + if data, err := os.ReadFile(filepath.Join(hermesHome, "auth.json")); err != nil || string(data) != `{"token":"secret"}` { + t.Fatalf("task auth snapshot tracked owner mutation: bytes=%q err=%v", data, err) + } + mustWrite(t, filepath.Join(hermesHome, "oauth_state.json"), `{"nous":"task-local"}`) + if data, err := os.ReadFile(filepath.Join(sharedHome, "oauth_state.json")); err != nil || string(data) != `{"nous":"tok"}` { + t.Fatalf("task OAuth mutation reached owner state: bytes=%q err=%v", data, err) + } + mustWrite(t, filepath.Join(sharedHome, "plugins", "custom-provider", "plugin.py"), "# owner update") + if data, err := os.ReadFile(filepath.Join(hermesHome, "plugins", "custom-provider", "plugin.py")); err != nil || string(data) != "# provider plugin" { + t.Fatalf("task plugin snapshot tracked owner mutation: bytes=%q err=%v", data, err) + } - // .env is overlay-owned/derived (not symlinked): it preserves the source's - // credentials but pins HERMES_HOME to the overlay so Hermes' override=True - // dotenv load can't relocate the home past it. + // .env is overlay-owned/derived (not symlinked): it keeps only explicitly + // allowed Hermes runtime settings and pins HERMES_HOME to the overlay so + // Hermes' override=True dotenv load can't relocate the home past it. envPath := filepath.Join(hermesHome, ".env") if fi, err := os.Lstat(envPath); err != nil { t.Fatalf(".env missing from overlay: %v", err) } else if fi.Mode()&os.ModeSymlink != 0 { t.Error(".env should be a derived copy, not a symlink into the shared home") } - if data, _ := os.ReadFile(envPath); !strings.Contains(string(data), "API_KEY=abc") { - t.Error("derived .env dropped the source credentials") + if data, _ := os.ReadFile(envPath); !strings.Contains(string(data), "ANTHROPIC_API_KEY=abc") { + t.Error("derived .env dropped an allowed source credential") + } else if strings.Contains(string(data), "OWNER_NOTE") { + t.Error("derived .env projected an unrelated owner variable") } else if !strings.Contains(string(data), "HERMES_HOME='"+hermesHome+"'") { t.Errorf("derived .env must pin HERMES_HOME to the overlay, got:\n%s", data) } @@ -103,9 +126,8 @@ func TestPrepareHermesHomeOverlay(t *testing.T) { if data, _ := os.ReadFile(cfgPath); !strings.Contains(string(data), "hermes-4") { t.Error("derived config dropped the user's model setting") } - wantExternal := filepath.Join(sharedHome, "skills") - if got := hermesExternalDirs(t, cfgPath); len(got) != 1 || got[0] != wantExternal { - t.Errorf("external_dirs = %v, want [%s]", got, wantExternal) + if got := hermesExternalDirs(t, cfgPath); len(got) != 0 { + t.Errorf("external_dirs = %v, want no owner paths", got) } if body, err := os.ReadFile(filepath.Join(hermesHome, "skills", "review-helper", "SKILL.md")); err != nil { @@ -114,7 +136,209 @@ func TestPrepareHermesHomeOverlay(t *testing.T) { t.Error("bound SKILL.md missing content") } if _, err := os.Stat(filepath.Join(hermesHome, "skills", "personal-notes")); !os.IsNotExist(err) { - t.Error("user global skill should be referenced via external_dirs, not copied into the task-local skills/") + t.Error("owner global skill must not enter the task-local skills directory") + } +} + +func TestHermesOverlayRejectsUnsafeOwnerSnapshotEntries(t *testing.T) { + t.Parallel() + for _, name := range []string{"auth.json", "oauth_state.json", "plugins"} { + t.Run(name, func(t *testing.T) { + sharedHome := t.TempDir() + target := filepath.Join(t.TempDir(), "owner-secret") + mustWrite(t, target, "secret") + if err := os.Symlink(target, filepath.Join(sharedHome, name)); err != nil { + t.Fatalf("symlink %s: %v", name, err) + } + hermesHome := filepath.Join(t.TempDir(), "hermes-home") + err := prepareHermesHome(hermesHome, sharedHome, false, + []SkillContextForEnv{{Name: "Review Helper", Content: "x"}}, nil, testLogger()) + if err == nil { + t.Fatalf("unsafe owner %s symlink was accepted", name) + } + }) + } +} + +// TestHermesOverlayProjectsOnlyAllowlistedOwnerCredentials is the P1 regression: +// an issue task must not inherit arbitrary owner dotenv state. Only explicit +// Hermes provider authentication/public settings may enter the task overlay. +func TestHermesOverlayProjectsOnlyAllowlistedOwnerCredentials(t *testing.T) { + t.Parallel() + sharedHome := t.TempDir() + mustWrite(t, filepath.Join(sharedHome, ".env"), strings.Join([]string{ + "ANTHROPIC_API_KEY=sk-anthropic", + "ANTHROPIC_BASE_URL=https://anthropic.example.test", + "ANTHROPIC_TOKEN=anthropic-token", + "DEEPSEEK_API_KEY=sk-deepseek", + "GEMINI_BASE_URL=https://gemini.example.test", + "OPENAI_API_KEY=sk-openai", + "OPENAI_BASE_URL=https://openai.example.test/v1", + "OPENROUTER_API_KEY=sk-openrouter", + "GOOGLE_API_KEY=google-key", + "HERMES_YOLO_MODE=0", + "OWNER_NOTE=do-not-project", + "DATABASE_URL=postgres://owner-secret", + "AWS_SECRET_ACCESS_KEY=owner-aws-secret", + "UNLISTED_API_KEY=owner-unlisted-secret", + "HERMES_HOME=/owner/home", + }, "\n")) + + hermesHome := filepath.Join(t.TempDir(), "hermes-home") + skills := []SkillContextForEnv{{Name: "Review Helper", Content: "x"}} + if err := prepareHermesHome(hermesHome, sharedHome, false, skills, nil, testLogger()); err != nil { + t.Fatalf("prepareHermesHome failed: %v", err) + } + + got := applyDotenvOverride(t, filepath.Join(hermesHome, ".env")) + want := map[string]string{ + "ANTHROPIC_API_KEY": "sk-anthropic", + "ANTHROPIC_BASE_URL": "https://anthropic.example.test", + "ANTHROPIC_TOKEN": "anthropic-token", + "DEEPSEEK_API_KEY": "sk-deepseek", + "GEMINI_BASE_URL": "https://gemini.example.test", + "OPENAI_API_KEY": "sk-openai", + "OPENAI_BASE_URL": "https://openai.example.test/v1", + "OPENROUTER_API_KEY": "sk-openrouter", + "GOOGLE_API_KEY": "google-key", + "HERMES_HOME": hermesHome, + } + if len(got) != len(want) { + t.Fatalf("overlay dotenv keys = %v, want only %v", got, want) + } + for key, value := range want { + if got[key] != value { + t.Errorf("%s = %q, want %q", key, got[key], value) + } + } +} + +// TestHermesOverlayConfigProjectsOnlyAllowlistedFields is the config half of +// the P1 regression: owner credentials and unknown config must not cross into an +// issue task, while the model/provider and external skill roots Hermes needs do. +func TestHermesOverlayConfigProjectsOnlyAllowlistedFields(t *testing.T) { + t.Parallel() + sharedHome := t.TempDir() + mustWrite(t, filepath.Join(sharedHome, "config.yaml"), strings.Join([]string{ + "model:", + " default: anthropic/claude-sonnet", + " model: claude-sonnet", + " provider: anthropic", + " base_url: https://anthropic.example.test", + " api_mode: messages", + " openai_runtime: false", + " auth_mode: api-key", + " api_key: sk-model-owner-secret", + " owner_private: do-not-project", + " entra:", + " scope: https://cognitiveservices.azure.com/.default", + " client_secret: entra-owner-secret", + "provider: legacy-anthropic", + "api_key: sk-inline-owner-secret", + "base_url: https://inline-owner.example.test", + "unknown_top_level: owner-private", + "skills:", + " external_dirs:", + " - team-skills", + " owner_private: do-not-project", + "memory:", + " provider: supermemory", + " api_key: memory-owner-secret", + " memory_enabled: true", + }, "\n")) + + hermesHome := filepath.Join(t.TempDir(), "hermes-home") + skills := []SkillContextForEnv{{Name: "Review Helper", Content: "x"}} + if err := prepareHermesHome(hermesHome, sharedHome, false, skills, nil, testLogger()); err != nil { + t.Fatalf("prepareHermesHome failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(hermesHome, "config.yaml")) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := yaml.Unmarshal(data, &got); err != nil { + t.Fatalf("parse derived config: %v\n%s", err, data) + } + if len(got) != 4 { + t.Fatalf("derived config keys = %v, want model/provider/skills/memory only", got) + } + if got["provider"] != "legacy-anthropic" { + t.Errorf("allowed legacy provider was not preserved: %v", got) + } + modelConfig, ok := got["model"].(map[string]any) + if !ok { + t.Fatalf("model config = %#v, want projected mapping", got["model"]) + } + wantModel := map[string]any{ + "default": "anthropic/claude-sonnet", + "model": "claude-sonnet", + "provider": "anthropic", + "base_url": "https://anthropic.example.test", + "api_mode": "messages", + "openai_runtime": false, + "auth_mode": "api-key", + } + for key, value := range wantModel { + if modelConfig[key] != value { + t.Errorf("model.%s = %#v, want %#v", key, modelConfig[key], value) + } + } + for _, forbidden := range []string{"api_key", "owner_private"} { + if _, ok := modelConfig[forbidden]; ok { + t.Errorf("forbidden model field %q entered overlay: %v", forbidden, modelConfig) + } + } + entraConfig, ok := modelConfig["entra"].(map[string]any) + if !ok || len(entraConfig) != 1 || entraConfig["scope"] != "https://cognitiveservices.azure.com/.default" { + t.Errorf("model.entra = %#v, want only public scope", modelConfig["entra"]) + } + for _, forbidden := range []string{"api_key", "base_url", "unknown_top_level"} { + if _, ok := got[forbidden]; ok { + t.Errorf("forbidden config field %q entered overlay: %v", forbidden, got) + } + } + skillsConfig, ok := got["skills"].(map[string]any) + if !ok || len(skillsConfig) != 1 { + t.Fatalf("skills config = %#v, want only external_dirs", got["skills"]) + } + memoryConfig, ok := got["memory"].(map[string]any) + if !ok || len(memoryConfig) != 1 || memoryConfig["provider"] != "" { + t.Fatalf("memory config = %#v, want only disabled provider", got["memory"]) + } + if dirs := hermesExternalDirs(t, filepath.Join(hermesHome, "config.yaml")); len(dirs) != 0 { + t.Errorf("external_dirs = %v, want no owner paths", dirs) + } +} + +// TestHermesOverlayConfigParseFailureDoesNotCopyOwnerBytes ensures malformed +// owner config never falls back to a verbatim task-local copy containing secrets. +func TestHermesOverlayConfigParseFailureDoesNotCopyOwnerBytes(t *testing.T) { + t.Parallel() + sharedHome := t.TempDir() + mustWrite(t, filepath.Join(sharedHome, "config.yaml"), + "api_key: sk-owner-secret\nunknown: [unterminated\n") + + hermesHome := filepath.Join(t.TempDir(), "hermes-home") + skills := []SkillContextForEnv{{Name: "Review Helper", Content: "x"}} + if err := prepareHermesHome(hermesHome, sharedHome, false, skills, nil, testLogger()); err != nil { + t.Fatalf("prepareHermesHome failed: %v", err) + } + + configPath := filepath.Join(hermesHome, "config.yaml") + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "sk-owner-secret") || strings.Contains(string(data), "unterminated") { + t.Fatalf("malformed owner config bytes entered overlay:\n%s", data) + } + if provider, ok := hermesMemoryProvider(t, configPath); !ok || provider != "" { + t.Fatalf("memory.provider = %q, present=%v; want explicit disabled provider", provider, ok) + } + if dirs := hermesExternalDirs(t, configPath); len(dirs) != 0 { + t.Errorf("external_dirs = %v, want no owner paths", dirs) } } @@ -140,16 +364,25 @@ func TestHermesDisablesExternalMemoryProvider(t *testing.T) { if got != "" { t.Errorf("memory.provider = %q, want \"\" (external backend disabled)", got) } - if data, _ := os.ReadFile(filepath.Join(hermesHome, "config.yaml")); !strings.Contains(string(data), "memory_enabled: true") { - t.Error("built-in memory settings should be preserved") + data, err := os.ReadFile(filepath.Join(hermesHome, "config.yaml")) + if err != nil { + t.Fatal(err) + } + var config map[string]any + if err := yaml.Unmarshal(data, &config); err != nil { + t.Fatalf("parse derived config: %v", err) + } + memoryConfig, ok := config["memory"].(map[string]any) + if !ok || len(memoryConfig) != 1 { + t.Errorf("memory config = %#v, want only disabled provider", config["memory"]) + } else if _, exists := memoryConfig["memory_enabled"]; exists { + t.Error("unknown memory_enabled field should not enter the task overlay") } } -// TestHermesDerivedConfigRebasesRelativeExternalDirs is the regression for the -// silent-repoint bug: relative external_dirs must be rewritten to absolute paths -// anchored at the shared home, absolute entries left intact, and the real skills -// dir appended. -func TestHermesDerivedConfigRebasesRelativeExternalDirs(t *testing.T) { +// TestHermesDerivedConfigDropsOwnerExternalDirs ensures owner-configured paths +// cannot expose arbitrary daemon-owner files to an issue-derived task. +func TestHermesDerivedConfigDropsOwnerExternalDirs(t *testing.T) { t.Parallel() sharedHome := t.TempDir() mustWrite(t, filepath.Join(sharedHome, "config.yaml"), @@ -161,22 +394,14 @@ func TestHermesDerivedConfigRebasesRelativeExternalDirs(t *testing.T) { t.Fatalf("prepareHermesHome failed: %v", err) } - got := hermesExternalDirs(t, filepath.Join(hermesHome, "config.yaml")) - want := []string{ - filepath.Join(sharedHome, "team-skills"), - "/opt/shared/skills", - filepath.Join(sharedHome, "skills"), - } - if strings.Join(got, "\n") != strings.Join(want, "\n") { - t.Errorf("external_dirs =\n%v\nwant\n%v", got, want) + if got := hermesExternalDirs(t, filepath.Join(hermesHome, "config.yaml")); len(got) != 0 { + t.Errorf("owner external_dirs entered task config: %v", got) } } -// TestHermesExternalDirsExpandsSanitizedEnv verifies a ${VAR} present in the -// sanitized effective env expands, while an UNKNOWN var is preserved verbatim -// (Hermes/Python expandvars semantics) instead of collapsing to empty and being -// silently rewritten to an absolute path. -func TestHermesExternalDirsExpandsSanitizedEnv(t *testing.T) { +// TestHermesExternalDirsDoNotExpandIntoOwnerPaths verifies custom environment +// expansion cannot smuggle owner-controlled directories into task config. +func TestHermesExternalDirsDoNotExpandIntoOwnerPaths(t *testing.T) { t.Parallel() sharedHome := t.TempDir() mustWrite(t, filepath.Join(sharedHome, "config.yaml"), @@ -189,14 +414,8 @@ func TestHermesExternalDirsExpandsSanitizedEnv(t *testing.T) { t.Fatalf("prepareHermesHome failed: %v", err) } - got := hermesExternalDirs(t, filepath.Join(hermesHome, "config.yaml")) - want := []string{ - "/srv/team/reviews", // known var expanded - "${MYSTERY_VAR}/x", // unknown var preserved verbatim, NOT absolutized - filepath.Join(sharedHome, "skills"), - } - if strings.Join(got, "\n") != strings.Join(want, "\n") { - t.Errorf("external_dirs =\n%v\nwant\n%v", got, want) + if got := hermesExternalDirs(t, filepath.Join(hermesHome, "config.yaml")); len(got) != 0 { + t.Errorf("expanded owner external_dirs entered task config: %v", got) } } @@ -265,7 +484,7 @@ func TestHermesOverlayIsolatesMemories(t *testing.T) { } // TestHermesOverlayPermissions asserts the task home is 0700 and the derived -// config (which can hold inline api_key secrets) is 0600. +// config containing projected owner runtime settings is 0600. func TestHermesOverlayPermissions(t *testing.T) { t.Parallel() sharedHome := t.TempDir() @@ -289,9 +508,9 @@ func TestHermesOverlayPermissions(t *testing.T) { } } -// TestHermesOverlayReconcilesDeletedSharedEntry asserts a top-level entry -// removed from the shared home is dropped from the overlay on rebuild. -func TestHermesOverlayReconcilesDeletedSharedEntry(t *testing.T) { +// TestHermesPluginSnapshotRemovedWithSource asserts an allowlisted snapshot is +// removed on rebuild when its source disappears. +func TestHermesPluginSnapshotRemovedWithSource(t *testing.T) { t.Parallel() sharedHome := t.TempDir() mustWrite(t, filepath.Join(sharedHome, "config.yaml"), "model: hermes-4\n") @@ -303,7 +522,7 @@ func TestHermesOverlayReconcilesDeletedSharedEntry(t *testing.T) { t.Fatalf("prepareHermesHome failed: %v", err) } if _, err := os.Lstat(filepath.Join(hermesHome, "plugins")); err != nil { - t.Fatalf("plugins not mirrored: %v", err) + t.Fatalf("plugins not snapshotted: %v", err) } if err := os.RemoveAll(filepath.Join(sharedHome, "plugins")); err != nil { t.Fatalf("remove shared plugins: %v", err) @@ -312,7 +531,129 @@ func TestHermesOverlayReconcilesDeletedSharedEntry(t *testing.T) { t.Fatalf("prepareHermesHome (rebuild) failed: %v", err) } if _, err := os.Lstat(filepath.Join(hermesHome, "plugins")); !os.IsNotExist(err) { - t.Error("stale mirrored plugins should be reconciled away after deletion in the shared home") + t.Error("stale plugin snapshot should be removed after source deletion") + } +} + +func TestHermesOptionalSnapshotRemovedWithSource(t *testing.T) { + t.Parallel() + sharedHome := t.TempDir() + hermesHome := filepath.Join(t.TempDir(), "hermes-home") + mustWrite(t, filepath.Join(sharedHome, "auth.json"), `{"token":"owner"}`) + mustWrite(t, filepath.Join(sharedHome, "oauth_state.json"), `{"state":"owner"}`) + + if err := prepareHermesHome(hermesHome, sharedHome, false, nil, nil, testLogger()); err != nil { + t.Fatalf("prepareHermesHome failed: %v", err) + } + for _, name := range []string{"auth.json", "oauth_state.json"} { + if err := os.Remove(filepath.Join(sharedHome, name)); err != nil { + t.Fatalf("remove source %s: %v", name, err) + } + } + if err := prepareHermesHome(hermesHome, sharedHome, false, nil, nil, testLogger()); err != nil { + t.Fatalf("prepareHermesHome (rebuild) failed: %v", err) + } + for _, name := range []string{"auth.json", "oauth_state.json"} { + if _, err := os.Lstat(filepath.Join(hermesHome, name)); !os.IsNotExist(err) { + t.Errorf("stale %s snapshot survived source deletion: %v", name, err) + } + } +} + +func TestHermesOptionalSnapshotsRejectHardLinks(t *testing.T) { + t.Parallel() + for _, name := range []string{"auth.json", "oauth_state.json"} { + name := name + t.Run(name, func(t *testing.T) { + t.Parallel() + sharedHome := t.TempDir() + source := filepath.Join(sharedHome, name) + mustWrite(t, source, "owner credential") + if err := os.Link(source, filepath.Join(sharedHome, name+".link")); err != nil { + t.Skipf("hard links unavailable: %v", err) + } + + err := prepareHermesHome(filepath.Join(t.TempDir(), "hermes-home"), sharedHome, false, nil, nil, testLogger()) + if err == nil || !strings.Contains(err.Error(), "hard link") { + t.Fatalf("hard-linked %s error = %v, want fail-closed rejection", name, err) + } + }) + } +} + +func TestHermesPluginSnapshotRejectsHardLinks(t *testing.T) { + t.Parallel() + sharedHome := t.TempDir() + source := filepath.Join(sharedHome, "plugins", "provider", "plugin.py") + mustWrite(t, source, "original plugin") + if err := os.Link(source, filepath.Join(sharedHome, "plugins", "provider", "plugin-copy.py")); err != nil { + t.Skipf("hard links unavailable: %v", err) + } + + err := snapshotHermesPlugins(sharedHome, t.TempDir(), nil) + if err == nil || !errors.Is(err, errUnsafeHermesPluginEntry) || !strings.Contains(err.Error(), "hard link") { + t.Fatalf("plugin hard-link error = %v, want unsafe hard-link rejection", err) + } +} + +func TestHermesPluginSnapshotRejectsSpecialEntries(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("FIFO fixture is POSIX-only") + } + sharedHome := t.TempDir() + pluginDir := filepath.Join(sharedHome, "plugins", "provider") + if err := os.MkdirAll(pluginDir, 0o700); err != nil { + t.Fatal(err) + } + fifo := filepath.Join(pluginDir, "control.pipe") + if err := syscall.Mkfifo(fifo, 0o600); err != nil { + t.Skipf("FIFO unavailable: %v", err) + } + + err := snapshotHermesPlugins(sharedHome, t.TempDir(), nil) + if err == nil || !errors.Is(err, errUnsafeHermesPluginEntry) { + t.Fatalf("plugin FIFO error = %v, want unsafe-entry rejection", err) + } +} + +func TestHermesPluginSnapshotRejectsPathReplacementAfterOpen(t *testing.T) { + t.Parallel() + sharedHome := t.TempDir() + source := filepath.Join(sharedHome, "plugins", "provider", "plugin.py") + mustWrite(t, source, "original plugin") + replacement := filepath.Join(sharedHome, "replacement.py") + mustWrite(t, replacement, "replacement plugin") + + err := snapshotHermesPlugins(sharedHome, t.TempDir(), func(opened string) { + if opened != source { + return + } + if renameErr := os.Rename(replacement, source); renameErr != nil { + t.Fatalf("replace opened plugin path: %v", renameErr) + } + }) + if err == nil || !errors.Is(err, errUnsafeHermesPluginEntry) || !strings.Contains(err.Error(), "identity changed") { + t.Fatalf("plugin replacement error = %v, want identity-change rejection", err) + } +} + +func TestHermesTaskHomeRemovesUnknownEntriesOnReuse(t *testing.T) { + t.Parallel() + sharedHome := t.TempDir() + hermesHome := filepath.Join(t.TempDir(), "hermes-home") + if err := prepareHermesHome(hermesHome, sharedHome, false, nil, nil, testLogger()); err != nil { + t.Fatalf("prepareHermesHome failed: %v", err) + } + mustWrite(t, filepath.Join(hermesHome, "daemon-owner-secret.txt"), "must be removed") + mustWrite(t, filepath.Join(hermesHome, "unknown", "nested.txt"), "must be removed") + + if err := prepareHermesHome(hermesHome, sharedHome, false, nil, nil, testLogger()); err != nil { + t.Fatalf("prepareHermesHome (reuse) failed: %v", err) + } + for _, name := range []string{"daemon-owner-secret.txt", "unknown"} { + if _, err := os.Lstat(filepath.Join(hermesHome, name)); !os.IsNotExist(err) { + t.Errorf("unknown task-home entry %s survived reuse: %v", name, err) + } } } @@ -441,13 +782,9 @@ func TestResolveHermesProfile(t *testing.T) { }) } -// TestHermesExternalDirsExpandsAgainstSelectedProfileHome is the review's blocker -// 3: a ${HERMES_HOME} in a selected profile's skills.external_dirs must expand -// against the SELECTED profile home (what native Hermes sees after applying the -// profile override before loading config.yaml), not the pre-resolution/root home -// or the task overlay. The daemon sets the effective env's HERMES_HOME to the -// resolved source home for exactly this reason. -func TestHermesExternalDirsExpandsAgainstSelectedProfileHome(t *testing.T) { +// TestHermesExternalDirsDoNotExposeSelectedProfileHome ensures a selected +// profile cannot reintroduce its owner path through skills.external_dirs. +func TestHermesExternalDirsDoNotExposeSelectedProfileHome(t *testing.T) { t.Parallel() profileHome := t.TempDir() // stands in for /profiles/coder mustWrite(t, filepath.Join(profileHome, "config.yaml"), @@ -460,13 +797,8 @@ func TestHermesExternalDirsExpandsAgainstSelectedProfileHome(t *testing.T) { t.Fatalf("prepareHermesHome failed: %v", err) } - got := hermesExternalDirs(t, filepath.Join(hermesHome, "config.yaml")) - want := []string{ - filepath.Join(profileHome, "profile-skills"), - filepath.Join(profileHome, "skills"), - } - if strings.Join(got, "\n") != strings.Join(want, "\n") { - t.Errorf("external_dirs =\n%v\nwant\n%v", got, want) + if got := hermesExternalDirs(t, filepath.Join(hermesHome, "config.yaml")); len(got) != 0 { + t.Errorf("selected profile paths entered task config: %v", got) } } @@ -648,10 +980,9 @@ func TestPrepareHermesHomeFailsOnMissingNamedProfile(t *testing.T) { } } -// TestPrepareHermesNoSkillsLeavesHomeUnset is the regression for the review's -// top blocker: a Hermes task with no bound skills must NOT get a redirected -// HERMES_HOME. -func TestPrepareHermesNoSkillsLeavesHomeUnset(t *testing.T) { +// TestPrepareHermesNoSkillsUsesPrivateHome proves a skill-less Issue task does +// not fall through to daemon-owner Hermes state. +func TestPrepareHermesNoSkillsUsesPrivateHome(t *testing.T) { t.Parallel() env, err := Prepare(PrepareParams{ WorkspacesRoot: t.TempDir(), @@ -665,11 +996,11 @@ func TestPrepareHermesNoSkillsLeavesHomeUnset(t *testing.T) { } defer env.Cleanup(true) - if env.HermesHome != "" { - t.Errorf("skill-less Hermes task must not redirect HERMES_HOME, got %q", env.HermesHome) + if env.HermesHome == "" { + t.Fatal("skill-less Hermes task must use a private HERMES_HOME") } - if _, err := os.Stat(filepath.Join(env.RootDir, "hermes-home")); !os.IsNotExist(err) { - t.Error("no hermes-home overlay should be created for a skill-less task") + if info, err := os.Stat(filepath.Join(env.RootDir, "hermes-home")); err != nil || !info.IsDir() { + t.Fatalf("private hermes-home missing: %v", err) } } @@ -703,22 +1034,22 @@ func TestReuseHermesTearsDownWhenSkillsRemoved(t *testing.T) { } overlayDir := filepath.Join(env.RootDir, "hermes-home") - if reused := Reuse(ReuseParams{WorkDir: env.WorkDir, Provider: "hermes", HermesSourceHome: sharedHome, Task: withSkill}, testLogger()); reused == nil { + if reused := Reuse(ReuseParams{RootDir: env.RootDir, WorkDir: env.WorkDir, Provider: "hermes", HermesSourceHome: sharedHome, Task: withSkill}, testLogger()); reused == nil { t.Fatal("Reuse with skill returned nil") } else if reused.HermesHome == "" { t.Error("resume with a bound skill should keep the redirect") } noSkill := TaskContextForEnv{IssueID: "hermes-resume"} - reused := Reuse(ReuseParams{WorkDir: env.WorkDir, Provider: "hermes", HermesSourceHome: sharedHome, Task: noSkill}, testLogger()) + reused := Reuse(ReuseParams{RootDir: env.RootDir, WorkDir: env.WorkDir, Provider: "hermes", HermesSourceHome: sharedHome, Task: noSkill}, testLogger()) if reused == nil { t.Fatal("Reuse without skill returned nil") } - if reused.HermesHome != "" { - t.Errorf("removing the last skill should clear HERMES_HOME, got %q", reused.HermesHome) + if reused.HermesHome == "" { + t.Fatal("removing the last skill must retain the private HERMES_HOME") } - if _, err := os.Stat(overlayDir); !os.IsNotExist(err) { - t.Error("stale hermes-home overlay should be removed on teardown") + if entries, err := os.ReadDir(filepath.Join(overlayDir, "skills")); err != nil || len(entries) != 0 { + t.Fatalf("skill-less reused home must contain an empty task skills dir: entries=%v err=%v", entries, err) } } diff --git a/server/internal/daemon/execenv/openclaw_config.go b/server/internal/daemon/execenv/openclaw_config.go index 31392b6458d..6617febe3ec 100644 --- a/server/internal/daemon/execenv/openclaw_config.go +++ b/server/internal/daemon/execenv/openclaw_config.go @@ -2,68 +2,28 @@ package execenv import ( "bytes" - "context" "encoding/json" "errors" "fmt" + "io" "os" - "os/exec" "path/filepath" "sort" "strings" - "time" ) -// openclawConfigFile is the per-task synthesized OpenClaw config the daemon -// points the openclaw CLI at via OPENCLAW_CONFIG_PATH. It sits in the env -// root (alongside workdir/, output/, logs/) so the GC reaper sweeps it with -// the rest of the task env. const openclawConfigFile = "openclaw-config.json" - -// openclawUserSnapshotFile is the sanitized copy of the user's fully -// resolved openclaw config the wrapper $includes when the agent has a -// managed mcp_config. It is the user's config minus the `mcp` block so the -// wrapper's managed `mcp.servers` is the only MCP definition visible to -// OpenClaw — true strict-replace, not deep-merge-by-name. Lives in envRoot -// at 0o600 next to the wrapper. const openclawUserSnapshotFile = "openclaw-user-snapshot.json" -// openclawCLITimeout caps each `openclaw config ...` invocation during task -// setup. The CLI is fast (<200ms normal); 5s leaves headroom for a cold -// node start without letting a hung CLI stall task dispatch indefinitely. -const openclawCLITimeout = 5 * time.Second - -// OpenclawConfigPrep is the input to prepareOpenclawConfig. Only OpenclawBin -// is meaningful in production — Timeout is here for tests that need a tight -// cap to assert error paths. +// OpenclawConfigPrep contains task-authoritative OpenClaw overrides. The +// selected binary is deliberately ignored during preparation: it is not safe +// to execute Issue-selected code before the task environment is isolated. type OpenclawConfigPrep struct { - // OpenclawBin is the openclaw CLI binary to invoke for config introspection. - // Empty means resolve "openclaw" from PATH at exec time. OpenclawBin string - // Timeout caps each CLI invocation. Zero falls back to openclawCLITimeout. - Timeout time.Duration - // McpConfig is the agent's saved `mcp_config` JSON (Claude-style - // `{"mcpServers": {"": {...}}}`). When non-null the wrapper pins - // `mcp.servers` to the managed set so OpenClaw resolves MCP from the - // daemon's authoritative list instead of the user's global `mcp.servers`. - // Null / empty means inherit the user's global config — same three-state - // semantics codex uses (`hasManagedCodexMcpConfig`). - McpConfig json.RawMessage - // Gateway pins a specific OpenClaw Gateway endpoint inside the per-task - // wrapper. Only consulted when the agent is configured for gateway-mode - // openclaw (see ExecOptions.OpenclawMode); zero means "inherit whatever - // the user's global openclaw.json already configures under `gateway.*`" - // — which is the right default when the user already has a working - // gateway set up locally. See issue #3260. - Gateway OpenclawGatewayPin -} - -// OpenclawGatewayPin describes the Gateway endpoint a per-task openclaw -// wrapper should pin. Fields mirror OpenClaw's own `gateway.*` config shape -// (see ~/.openclaw/openclaw.json). All fields are optional; only non-zero -// fields are emitted into the wrapper so a partial pin (e.g. host+port -// only, token left to inherit from the user's config) does the right -// thing under OpenClaw's deep-merge $include semantics. + McpConfig json.RawMessage + Gateway OpenclawGatewayPin +} + type OpenclawGatewayPin struct { Host string Port int @@ -71,28 +31,18 @@ type OpenclawGatewayPin struct { TLS bool } -// IsZero reports whether every field is zero, i.e. there is nothing to pin. func (p OpenclawGatewayPin) IsZero() bool { return p == OpenclawGatewayPin{} } -// String masks the bearer token when the pin is rendered as a string — -// `%v` / `%+v` / direct `fmt.Stringer` use cases all go through here. The -// raw Token field still exists for the wrapper-config emitter that needs -// it; this is a belt against a future caller that logs a whole task-prep -// summary at a level a non-admin can see (issue #3260 CR). func (p OpenclawGatewayPin) String() string { - tok := "" + token := "" if p.Token != "" { - tok = "***" + token = "***" } - return fmt.Sprintf("OpenclawGatewayPin{Host:%q Port:%d Token:%s TLS:%t}", p.Host, p.Port, tok, p.TLS) + return fmt.Sprintf("OpenclawGatewayPin{Host:%q Port:%d Token:%s TLS:%t}", p.Host, p.Port, token, p.TLS) } -// MarshalJSON masks the bearer token in any default JSON dump (debug -// endpoints, error envelopes, structured-log encoders). The wrapper config -// writer goes through buildGatewayOverride which assembles a map directly, -// so it is unaffected by this masking. func (p OpenclawGatewayPin) MarshalJSON() ([]byte, error) { type alias struct { Host string `json:"host,omitempty"` @@ -107,405 +57,413 @@ func (p OpenclawGatewayPin) MarshalJSON() ([]byte, error) { return json.Marshal(masked) } -// OpenclawConfigResult is what prepareOpenclawConfig returns to its callers -// in execenv.go. ConfigPath is the wrapper file the daemon points -// OPENCLAW_CONFIG_PATH at. IncludeRoot is the directory the daemon must add -// to OPENCLAW_INCLUDE_ROOTS so OpenClaw will follow the $include link out -// of envRoot into the user's active config; it is empty when no $include -// is emitted (fresh install). type OpenclawConfigResult struct { - ConfigPath string - IncludeRoot string -} - -// prepareOpenclawConfig writes a per-task OpenClaw config to envRoot and -// returns its absolute path along with the include root the daemon must -// grant. The daemon sets OPENCLAW_CONFIG_PATH to the path on the spawned -// openclaw subprocess so the CLI resolves its `agents.defaults.workspace` -// (and every `agents.list[].workspace`) to the task workdir — which is -// what makes OpenClaw's native skill scanner pick up the per-task skills -// we write under `/skills/`. -// -// Strategy: delegate JSON5 / $include / env-substitution / state-dir -// resolution to the openclaw CLI itself rather than re-implementing the -// spec. We: -// -// 1. Run `openclaw config file` to find the user's active config path. -// For OpenClaw releases whose `config` command rejects the `file` -// subcommand shape, fall back to resolving OpenClaw's active-config -// candidates, including legacy Clawdbot/Moltbot/Moldbot locations. -// 2. Run `openclaw config get agents.list --json` to enumerate every -// registered agent ID with its resolved fields. The CLI parses JSON5, -// follows $include, and substitutes ${VAR} for us. -// 3. Write a wrapper config to envRoot/openclaw-config.json that -// `$include`s the active path and overrides -// `agents.defaults.workspace` plus every `agents.list[].workspace` to -// workDir. The original config bytes are not mutated — they are loaded -// by openclaw's own loader through the $include link, which preserves -// comments, secrets, and nested $include chains verbatim. -// -// **Cross-directory $include confinement.** OpenClaw confines `$include` -// resolution to the directory containing the wrapper file unless the -// target's parent is listed in `OPENCLAW_INCLUDE_ROOTS`. Our wrapper lives -// in envRoot but $includes the user's active config (typically -// `~/.openclaw/openclaw.json`) — a cross-directory hop. We surface -// `filepath.Dir(activePath)` as IncludeRoot so the daemon can prepend it -// to whatever the user already has in OPENCLAW_INCLUDE_ROOTS; without -// this, OpenClaw refuses to follow the link and the wrapper boots with no -// user config. Fresh install emits no $include, so IncludeRoot is "". -// -// **Intentional task isolation.** The override of every per-agent workspace -// is deliberate. OpenClaw's resolution order is -// `agents.list[id].workspace → agents.defaults.workspace → ~/.openclaw/ -// workspace`. Pinning only the default would let a per-agent workspace the -// user configured at host scope silently re-route the scanner back to the -// shared workspace, defeating the per-task skill discovery this whole flow -// exists for. The cost is that any per-agent SOUL.md / MEMORY.md / standing -// orders the user laid in `/` are NOT visible to the -// in-task openclaw run — task isolation wins over host carry-over. The -// user's on-disk config is untouched; this only affects the wrapper used -// for this single task. -// -// **Fail closed.** Missing openclaw binary, CLI errors, malformed CLI -// output, or any IO error during write surfaces as an error to the caller -// rather than degrading to a minimal config. An earlier version silently -// synthesized a minimal config on parse failure; that masked broken user -// configs by starting OpenClaw without the registered agents / model -// providers / API keys it expects, which led to tasks routing to the wrong -// agent or failing to authenticate. The only "synthesize minimal" case -// kept is a fresh install where the CLI reports a path but no file exists -// — there is no user data to lose in that case. + ConfigPath string +} + +// prepareOpenclawConfig creates a task-private OpenClaw wrapper without +// invoking OpenClaw. Existing owner configuration is read through the +// daemon's stable bounded reader, parsed as strict JSON, and projected onto a +// small allowlist. Unsupported loader features fail closed instead of being +// resolved by task-selected executable code. func prepareOpenclawConfig(envRoot, workDir string, opts OpenclawConfigPrep) (OpenclawConfigResult, error) { - bin := opts.OpenclawBin - if bin == "" { - bin = "openclaw" - } - timeout := opts.Timeout - if timeout <= 0 { - timeout = openclawCLITimeout + managedMCP, hasManagedMCP, err := openclawManagedMcpServers(opts.McpConfig) + if err != nil { + return OpenclawConfigResult{}, fmt.Errorf("render openclaw mcp_config: %w", err) } - activePath, exists, err := openclawActiveConfigPath(bin, timeout) + activePath, exists, err := openclawFallbackActiveConfigPath() if err != nil { return OpenclawConfigResult{}, fmt.Errorf("locate openclaw active config: %w", err) } - var resolvedList []any - var agentsFromRegistry bool + snapshotPath := "" if exists { - resolvedList, agentsFromRegistry, err = openclawResolvedAgentsList(bin, timeout) + raw, err := readStableRegularFile(activePath, openclawSnapshotMaxBytes, nil) if err != nil { - return OpenclawConfigResult{}, fmt.Errorf("read openclaw agents.list: %w", err) + return OpenclawConfigResult{}, fmt.Errorf("read openclaw config: %w", err) } - } - - // Parse the agent's managed mcp_config (if any) before writing the wrapper - // so a malformed value fails the prepare step rather than crashing the - // openclaw subprocess later. Same fail-closed posture as Codex's - // ensureCodexMcpConfig — silent fallback to the user's global mcp.servers - // would be indistinguishable from "the managed set applied" and is exactly - // the surprise the MCP Tab is supposed to remove. - managedMcp, hasManagedMcp, err := openclawManagedMcpServers(opts.McpConfig) - if err != nil { - return OpenclawConfigResult{}, fmt.Errorf("render openclaw mcp_config: %w", err) - } - - // **Strict replace for managed mcp_config.** When the agent has a managed - // set, deep-merging the wrapper's `mcp.servers` against the user's active - // config via `$include` would let user-only entries leak in (and an empty - // managed set would not actually clear inherited servers). To enforce the - // Codex-style "managed wins, user globals invisible" contract, fetch the - // user's resolved config, drop just the `mcp.servers` map (keep other - // `mcp.*` settings like `sessionIdleTtlMs`), write a sanitized snapshot - // in envRoot, and $include the snapshot instead of the live user file. - // The wrapper's `mcp.servers` then becomes the only MCP server definition - // the snapshot's resolution can yield, while the user's surrounding `mcp` - // tuning still flows through. - snapshotPath := "" - if hasManagedMcp && exists { - resolved, ferr := openclawResolvedFullConfig(bin, timeout) - if ferr != nil { - return OpenclawConfigResult{}, fmt.Errorf("read openclaw resolved config: %w", ferr) - } - if resolved == nil { - // CLI reports the file exists but `config get --json` returned - // nothing structured. Treat as no user-config-to-strip: the - // wrapper will carry managed mcp.servers as the sole source. - exists = false - activePath = "" - } else { - stripUserMcpServers(resolved) - snapBytes, merr := json.MarshalIndent(resolved, "", " ") - if merr != nil { - return OpenclawConfigResult{}, fmt.Errorf("marshal openclaw user snapshot: %w", merr) + owner, err := parseStrictOpenclawConfig(raw) + if err != nil { + return OpenclawConfigResult{}, fmt.Errorf("parse openclaw config: %w", err) + } + snapshot, err := allowlistedOpenclawSnapshot(owner, workDir) + if err != nil { + return OpenclawConfigResult{}, fmt.Errorf("sanitize openclaw config: %w", err) + } + if len(snapshot) > 0 { + data, err := json.MarshalIndent(snapshot, "", " ") + if err != nil { + return OpenclawConfigResult{}, fmt.Errorf("marshal openclaw snapshot: %w", err) } snapshotPath = filepath.Join(envRoot, openclawUserSnapshotFile) - // 0o600 — the snapshot is now a flat copy of the user's resolved - // config and may carry API keys / model-provider tokens that - // $include used to keep on disk in the user's own file. Lock the - // snapshot to the daemon owner; only the openclaw child reads it. - if werr := os.WriteFile(snapshotPath, snapBytes, 0o600); werr != nil { - return OpenclawConfigResult{}, fmt.Errorf("write openclaw user snapshot: %w", werr) + if err := writePrivateSnapshot(snapshotPath, data, openclawSnapshotMaxBytes); err != nil { + return OpenclawConfigResult{}, fmt.Errorf("write openclaw snapshot: %w", err) } } } - cfg := buildPerTaskOpenclawConfig(activePath, exists, snapshotPath, resolvedList, agentsFromRegistry, workDir, managedMcp, hasManagedMcp, opts.Gateway) - - data, err := json.MarshalIndent(cfg, "", " ") + wrapper := buildPerTaskOpenclawConfig(snapshotPath, workDir, managedMCP, hasManagedMCP, opts.Gateway) + data, err := json.MarshalIndent(wrapper, "", " ") if err != nil { return OpenclawConfigResult{}, fmt.Errorf("marshal openclaw config: %w", err) } outPath := filepath.Join(envRoot, openclawConfigFile) - // 0o600 — defense in depth. The wrapper itself carries no secrets (the - // $include link is just a filesystem path), but the file lives next to - // task scratch and we keep the same posture as ~/.openclaw/openclaw.json. - if err := os.WriteFile(outPath, data, 0o600); err != nil { + if err := writePrivateSnapshot(outPath, data, openclawSnapshotMaxBytes); err != nil { return OpenclawConfigResult{}, fmt.Errorf("write openclaw config: %w", err) } - result := OpenclawConfigResult{ConfigPath: outPath} - if snapshotPath != "" { - // Sanitized snapshot lives in envRoot alongside the wrapper, so the - // $include never crosses directories — daemon does not need to grant - // an extra OPENCLAW_INCLUDE_ROOTS entry. - } else if exists { - // Live user config is in its own directory; tell the daemon to grant - // it so OpenClaw's include-confinement check passes. - result.IncludeRoot = filepath.Dir(activePath) - } - return result, nil -} - -// buildPerTaskOpenclawConfig assembles the wrapper map that goes on disk. -// -// Exists=true: emit a $include link to the user's active config plus the -// workspace overrides as siblings. OpenClaw deep-merges sibling object keys -// after includes, so agents.defaults.workspace lands correctly. The -// agents.list override is emitted as a full replacement carrying every -// field of every resolved entry (id, model, prompts, tools, …) verbatim -// with only `workspace` rewritten — this is robust regardless of whether -// the runtime merges the sibling array or replaces it, because either way -// the resulting list is shape-equivalent to the user's minus workspace. -// -// Exists=false: a fresh install with no on-disk config. Emit a minimal -// config containing only the workspace override. There is no user data to -// $include here, so this is not the silent-fallback case the reviewer -// flagged. -// -// snapshotPath, when non-empty, points at a sanitized copy of the user's -// resolved config (mcp stripped) sitting in envRoot. It is the $include -// target whenever the agent has a managed mcp_config — the live user file -// would otherwise leak global `mcp.servers` past the wrapper. When -// snapshotPath is empty the wrapper falls back to $include'ing the active -// path so secrets / nested includes stay in the user's own file (no -// managed mcp means there is nothing to enforce strictness against). -// -// hasManagedMcp distinguishes "agent has a managed mcp_config (possibly an -// empty set)" from "agent inherits the user's global mcp.servers". When -// true we pin `mcp.servers` to managedMcp on the wrapper. Because the -// snapshot $include has already dropped the user's `mcp` block, the -// resulting view of `mcp.servers` is exactly the managed set — including -// `{}` for "admin saved no servers" (mirrors `hasManagedCodexMcpConfig`). -func buildPerTaskOpenclawConfig(activePath string, exists bool, snapshotPath string, resolvedList []any, agentsFromRegistry bool, workDir string, managedMcp map[string]any, hasManagedMcp bool, gateway OpenclawGatewayPin) map[string]any { - agents := map[string]any{ - "defaults": map[string]any{"workspace": workDir}, - } - // Only write per-agent overrides back to the wrapper when they came from - // the config-schema `agents.list` path (pre-2026.6). A registry-sourced - // list (OpenClaw 2026.6.x+) is NOT valid `agents.list[]` config — the - // schema validator rejects it ("agents.list.0: Invalid input") and fails - // closed before the agent runs. 2026.6.x has no in-config path for per- - // agent workspace pinning, so `agents.defaults.workspace` (set above) is - // the only knob, and it is sufficient: OpenClaw applies it to the agent it - // selects from the registry (see upstream #3028, write-side half). - if !agentsFromRegistry { - if rewritten := rewriteAgentsListWorkspaces(resolvedList, workDir); rewritten != nil { - agents["list"] = rewritten - } - } - cfg := map[string]any{ - "agents": agents, - } - if hasManagedMcp { - // Always emit `mcp.servers` (even when empty) so the wrapper's intent - // — "admin manages this set" — is grep-able on disk and visible to - // OpenClaw's loader. The snapshot $include has already dropped the - // user's `mcp` block, so this becomes the only definition. - servers := managedMcp - if servers == nil { - servers = map[string]any{} - } - cfg["mcp"] = map[string]any{"servers": servers} - } - // Gateway endpoint pin (issue #3260). Mirrors the user's openclaw.json - // `gateway.*` shape so OpenClaw's deep-merge $include semantics produce - // the right composed config: anything we set here wins over the user's - // global, anything we omit inherits from the user's global. Only emit - // fields the multica admin explicitly populated — zero strings/ints - // would override the user's value with junk. - if gw := buildGatewayOverride(gateway); gw != nil { - cfg["gateway"] = gw - } - switch { - case snapshotPath != "": - // Sanitized snapshot path; strict-replace flow for managed mcp_config. - // Array form so OpenClaw deep-merges the snapshot's content with our - // sibling keys (agents overrides, mcp.servers) rather than letting the - // include replace the whole wrapper. - cfg["$include"] = []any{snapshotPath} - case exists: - cfg["$include"] = []any{activePath} - } - return cfg -} - -// buildGatewayOverride renders the non-zero subset of a Gateway pin into the -// shape OpenClaw expects under `gateway.*` (see ~/.openclaw/openclaw.json: -// host, port, tls at the top level and an `auth: {mode, token}` sub-object). -// Returns nil when nothing is populated so the caller can skip emission. -func buildGatewayOverride(p OpenclawGatewayPin) map[string]any { - if p.IsZero() { - return nil + return OpenclawConfigResult{ConfigPath: outPath}, nil +} + +func parseStrictOpenclawConfig(raw []byte) (map[string]any, error) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var config map[string]any + if err := decoder.Decode(&config); err != nil { + return nil, err } - out := map[string]any{} - if p.Host != "" { - out["host"] = p.Host + if config == nil { + return nil, fmt.Errorf("root must be a JSON object") } - if p.Port != 0 { - out["port"] = p.Port + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + return nil, fmt.Errorf("multiple JSON values are not supported") + } + return nil, err } - if p.TLS { - out["tls"] = true + if err := rejectOpenclawLoaderFeatures(config); err != nil { + return nil, err } - if p.Token != "" { - out["auth"] = map[string]any{ - "mode": "token", - "token": p.Token, + return config, nil +} + +func rejectOpenclawLoaderFeatures(value any) error { + switch current := value.(type) { + case map[string]any: + for key, child := range current { + if key == "$include" { + return fmt.Errorf("$include is unsupported during isolated preparation") + } + if strings.Contains(key, "${") { + return fmt.Errorf("environment substitution is unsupported during isolated preparation") + } + if err := rejectOpenclawLoaderFeatures(child); err != nil { + return err + } + } + case []any: + for _, child := range current { + if err := rejectOpenclawLoaderFeatures(child); err != nil { + return err + } + } + case string: + if strings.Contains(current, "${") { + return fmt.Errorf("environment substitution is unsupported during isolated preparation") } } - if len(out) == 0 { - return nil + return nil +} + +func allowlistedOpenclawSnapshot(owner map[string]any, workDir string) (map[string]any, error) { + snapshot := make(map[string]any) + for key, value := range map[string]any{ + "models": projectOpenclawPublicModels(owner["models"]), + "providers": projectOpenclawPublicProviders(owner["providers"]), + "gateway": projectOpenclawPublicGateway(owner["gateway"]), + } { + projected, ok := value.(map[string]any) + if !ok || len(projected) == 0 { + continue + } + if err := rejectOwnerAbsolutePaths(projected); err != nil { + return nil, fmt.Errorf("%s: %w", key, err) + } + snapshot[key] = projected } - return out + + if rawAgents, ok := owner["agents"].(map[string]any); ok { + agents := make(map[string]any) + defaults := make(map[string]any) + if rawDefaults, ok := rawAgents["defaults"].(map[string]any); ok { + copyOpenclawAllowedFields(defaults, rawDefaults, []string{ + "model", "imageModel", "thinking", "reasoning", "temperature", "maxTokens", + }) + } + if err := rejectOwnerAbsolutePaths(defaults); err != nil { + return nil, fmt.Errorf("agents.defaults: %w", err) + } + defaults["workspace"] = workDir + agents["defaults"] = defaults + + if rawList, ok := rawAgents["list"].([]any); ok { + list := make([]any, 0, len(rawList)) + for index, rawEntry := range rawList { + entry, ok := rawEntry.(map[string]any) + if !ok { + return nil, fmt.Errorf("agents.list.%d must be an object", index) + } + filtered := make(map[string]any) + copyOpenclawAllowedFields(filtered, entry, []string{"id", "name", "default", "isDefault", "model"}) + if err := rejectOwnerAbsolutePaths(filtered); err != nil { + return nil, fmt.Errorf("agents.list.%d: %w", index, err) + } + filtered["workspace"] = workDir + list = append(list, filtered) + } + if len(list) > 0 { + agents["list"] = list + } + } + snapshot["agents"] = agents + } + return snapshot, nil } -// rewriteAgentsListWorkspaces copies every entry of the resolved agents.list -// and pins its `workspace` field to workDir. Returns nil when the input is -// nil or empty so buildPerTaskOpenclawConfig can omit the key entirely -// (avoiding an empty `agents.list: []` that would replace whatever the -// include carries). -func rewriteAgentsListWorkspaces(list []any, workDir string) []any { - if len(list) == 0 { - return nil +func projectOpenclawPublicModels(value any) map[string]any { + rawModels, ok := value.(map[string]any) + if !ok { + return map[string]any{} + } + + models := make(map[string]any) + copyOpenclawStringFields(models, rawModels, []string{"default", "mode"}) + if providers := projectOpenclawPublicModelProviders(rawModels["providers"]); len(providers) > 0 { + models["providers"] = providers + } + return models +} + +func projectOpenclawPublicModelProviders(value any) map[string]any { + rawProviders, ok := value.(map[string]any) + if !ok { + return map[string]any{} } - out := make([]any, 0, len(list)) - for _, item := range list { - entry, ok := item.(map[string]any) + + providers := make(map[string]any, len(rawProviders)) + for name, value := range rawProviders { + rawProvider, ok := value.(map[string]any) if !ok { - // Shape we don't recognize — skip rather than guess. Worst case - // the user loses native skill discovery on that one agent; we - // still won't crash the wrapper. continue } - copyEntry := make(map[string]any, len(entry)+1) - for k, v := range entry { - copyEntry[k] = v + projected := projectOpenclawPublicProvider(rawProvider) + if models := projectOpenclawPublicModelCatalog(rawProvider["models"]); len(models) > 0 { + projected["models"] = models + } + if len(projected) > 0 { + providers[name] = projected } - copyEntry["workspace"] = workDir - out = append(out, copyEntry) } - if len(out) == 0 { + return providers +} + +func projectOpenclawPublicModelCatalog(value any) []any { + rawModels, ok := value.([]any) + if !ok { return nil } - return out -} - -// stripUserMcpServers removes only `mcp.servers` from a resolved user -// config, leaving every other key under `mcp` (e.g. `sessionIdleTtlMs`) -// intact. The wrapper's managed `mcp.servers` becomes the sole server -// definition while the user's surrounding MCP tuning still applies — see -// https://docs.openclaw.ai/gateway/configuration-reference#mcp for the -// full list of sibling settings the snapshot should preserve. -// -// If the resulting `mcp` block has no keys left, the parent `mcp` key is -// dropped too so the snapshot doesn't carry an empty placeholder. Any -// non-object value for `mcp` is left as-is; we only know how to strip -// servers from the documented object shape. -func stripUserMcpServers(resolved map[string]any) { - mcp, ok := resolved["mcp"].(map[string]any) + + models := make([]any, 0, len(rawModels)) + for _, value := range rawModels { + rawModel, ok := value.(map[string]any) + if !ok { + continue + } + projected := make(map[string]any) + copyOpenclawStringFields(projected, rawModel, []string{"api", "description", "id", "name", "provider"}) + copyOpenclawBoolFields(projected, rawModel, []string{"reasoning"}) + copyOpenclawNumberFields(projected, rawModel, []string{"contextWindow", "maxTokens"}) + copyOpenclawStringOrStringListFields(projected, rawModel, []string{"input"}) + if len(projected) > 0 { + models = append(models, projected) + } + } + return models +} + +func projectOpenclawPublicGateway(value any) map[string]any { + rawGateway, ok := value.(map[string]any) + if !ok { + return map[string]any{} + } + + gateway := make(map[string]any) + copyOpenclawStringFields(gateway, rawGateway, []string{"bind", "endpoint", "host", "mode", "url"}) + copyOpenclawNumberFields(gateway, rawGateway, []string{"port"}) + copyOpenclawBoolFields(gateway, rawGateway, []string{"tls"}) + return gateway +} + +func projectOpenclawPublicProviders(value any) map[string]any { + rawProviders, ok := value.(map[string]any) if !ok { - return - } - delete(mcp, "servers") - if len(mcp) == 0 { - delete(resolved, "mcp") - } -} - -// openclawActiveConfigPath runs `openclaw config file` to discover the path -// the openclaw CLI considers active. Returns (absolutePath, exists, error). -// -// The CLI handles the full resolution chain — explicit config path, state -// directory, OPENCLAW_HOME / default home, legacy locations, migration, and `~` -// expansion — so we prefer it when the installed CLI supports the command. -// -// OpenClaw 2026.2.x briefly rejected `openclaw config file` with the generic -// "too many arguments for 'config'" error. For that command-shape failure only, -// fall back to the same active-config candidate shape so task prep can still -// continue without losing upgraded users' legacy config files. -// -// The reported path uses `~` shorthand for the user's home; we expand it -// so the $include reference we write is unambiguous absolute. -func openclawActiveConfigPath(bin string, timeout time.Duration) (string, bool, error) { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - out, err := openclawExec(ctx, bin, "config", "file") - if err != nil { - if isOpenclawConfigFileUnsupported(err) { - path, exists, ferr := openclawFallbackActiveConfigPath() - if ferr != nil { - return "", false, fmt.Errorf("fallback after unsupported `openclaw config file` (%v): %w", err, ferr) + return map[string]any{} + } + + providers := make(map[string]any, len(rawProviders)) + for name, value := range rawProviders { + rawProvider, ok := value.(map[string]any) + if !ok { + continue + } + projected := projectOpenclawPublicProvider(rawProvider) + if len(projected) > 0 { + providers[name] = projected + } + } + return providers +} + +func projectOpenclawPublicProvider(provider map[string]any) map[string]any { + projected := make(map[string]any) + copyOpenclawStringFields(projected, provider, []string{ + "baseUrl", "baseURL", "endpoint", "id", "name", "organization", "organizationId", + "project", "projectId", "region", "type", "api", + }) + copyOpenclawBoolFields(projected, provider, []string{"enabled"}) + return projected +} + +func copyOpenclawStringFields(dst, src map[string]any, keys []string) { + for _, key := range keys { + if value, ok := src[key].(string); ok { + dst[key] = value + } + } +} + +func copyOpenclawBoolFields(dst, src map[string]any, keys []string) { + for _, key := range keys { + if value, ok := src[key].(bool); ok { + dst[key] = value + } + } +} + +func copyOpenclawNumberFields(dst, src map[string]any, keys []string) { + for _, key := range keys { + switch value := src[key].(type) { + case json.Number, float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + dst[key] = value + } + } +} + +func copyOpenclawStringOrStringListFields(dst, src map[string]any, keys []string) { + for _, key := range keys { + switch value := src[key].(type) { + case string: + dst[key] = value + case []any: + items := make([]any, 0, len(value)) + for _, item := range value { + text, ok := item.(string) + if !ok { + items = nil + break + } + items = append(items, text) + } + if items != nil { + dst[key] = items } - return path, exists, nil } - return "", false, err } - return openclawParseActiveConfigPath(out) } -func openclawParseActiveConfigPath(out string) (string, bool, error) { - // OpenClaw may print terminal UI borders (e.g., Doctor warnings) before - // the actual path. The path is always the last non-empty line. - lines := strings.Split(strings.TrimSpace(out), "\n") - path := "" - for i := len(lines) - 1; i >= 0; i-- { - trimmed := strings.TrimSpace(lines[i]) - if trimmed != "" { - path = trimmed - break +func copyOpenclawAllowedFields(dst, src map[string]any, keys []string) { + for _, key := range keys { + if value, ok := src[key]; ok { + dst[key] = value } } - if path == "" { - return "", false, fmt.Errorf("`openclaw config file` returned empty output") +} + +func rejectOwnerAbsolutePaths(value any) error { + switch current := value.(type) { + case map[string]any: + for _, child := range current { + if err := rejectOwnerAbsolutePaths(child); err != nil { + return err + } + } + case []any: + for _, child := range current { + if err := rejectOwnerAbsolutePaths(child); err != nil { + return err + } + } + case string: + if looksLikeAbsolutePath(current) { + return fmt.Errorf("owner absolute path is not allowed") + } } - var err error - path, err = expandOpenclawPath(path) - if err != nil { - return "", false, err + return nil +} + +func looksLikeAbsolutePath(value string) bool { + if filepath.IsAbs(value) || strings.HasPrefix(value, `\\`) { + return true + } + return len(value) >= 3 && ((value[0] >= 'A' && value[0] <= 'Z') || (value[0] >= 'a' && value[0] <= 'z')) && + value[1] == ':' && (value[2] == '/' || value[2] == '\\') +} + +func buildPerTaskOpenclawConfig(snapshotPath, workDir string, managedMCP map[string]any, hasManagedMCP bool, gateway OpenclawGatewayPin) map[string]any { + config := map[string]any{ + "agents": map[string]any{ + "defaults": map[string]any{"workspace": workDir}, + }, + } + if snapshotPath != "" { + config["$include"] = []any{snapshotPath} + } + if hasManagedMCP { + if managedMCP == nil { + managedMCP = map[string]any{} + } + config["mcp"] = map[string]any{"servers": managedMCP} } - return openclawStatConfigPath(path) + if override := buildGatewayOverride(gateway); override != nil { + config["gateway"] = override + } + return config +} + +func buildGatewayOverride(pin OpenclawGatewayPin) map[string]any { + if pin.IsZero() { + return nil + } + override := make(map[string]any) + if pin.Host != "" { + override["host"] = pin.Host + } + if pin.Port != 0 { + override["port"] = pin.Port + } + if pin.TLS { + override["tls"] = true + } + if pin.Token != "" { + override["auth"] = map[string]any{"mode": "token", "token": pin.Token} + } + if len(override) == 0 { + return nil + } + return override } func openclawFallbackActiveConfigPath() (string, bool, error) { - if explicitPath := strings.TrimSpace(os.Getenv("OPENCLAW_CONFIG_PATH")); explicitPath != "" { - path, err := expandOpenclawPath(explicitPath) + if explicit := strings.TrimSpace(os.Getenv("OPENCLAW_CONFIG_PATH")); explicit != "" { + path, err := expandOpenclawPath(explicit) if err != nil { return "", false, err } return openclawStatConfigPath(path) } - candidates, canonicalPath, err := openclawFallbackConfigCandidates() + candidates, canonical, err := openclawFallbackConfigCandidates() if err != nil { return "", false, err } @@ -522,33 +480,19 @@ func openclawFallbackActiveConfigPath() (string, bool, error) { return path, true, nil } } - return openclawStatConfigPath(canonicalPath) + return openclawStatConfigPath(canonical) } -var openclawFallbackConfigFileNames = []string{ - "openclaw.json", - "clawdbot.json", - "moltbot.json", - "moldbot.json", -} - -var openclawFallbackConfigDirNames = []string{ - ".openclaw", - ".clawdbot", - ".moltbot", - ".moldbot", -} +var openclawFallbackConfigFileNames = []string{"openclaw.json", "clawdbot.json", "moltbot.json", "moldbot.json"} +var openclawFallbackConfigDirNames = []string{".openclaw", ".clawdbot", ".moltbot", ".moldbot"} func openclawFallbackConfigCandidates() ([]string, string, error) { - candidates := make([]string, 0, 1+2*len(openclawFallbackConfigFileNames)+len(openclawFallbackConfigDirNames)*len(openclawFallbackConfigFileNames)) - for _, env := range []string{"CLAWDBOT_CONFIG_PATH"} { - if path := strings.TrimSpace(os.Getenv(env)); path != "" { - candidates = append(candidates, path) - } + var candidates []string + if path := strings.TrimSpace(os.Getenv("CLAWDBOT_CONFIG_PATH")); path != "" { + candidates = append(candidates, path) } - - for _, env := range []string{"OPENCLAW_STATE_DIR", "CLAWDBOT_STATE_DIR"} { - if dir := strings.TrimSpace(os.Getenv(env)); dir != "" { + for _, key := range []string{"OPENCLAW_STATE_DIR", "CLAWDBOT_STATE_DIR"} { + if dir := strings.TrimSpace(os.Getenv(key)); dir != "" { candidates = appendOpenclawConfigFileCandidates(candidates, dir) } } @@ -557,18 +501,14 @@ func openclawFallbackConfigCandidates() ([]string, string, error) { var err error if home == "" { home, err = os.UserHomeDir() - if err != nil { - return nil, "", fmt.Errorf("resolve openclaw home: %w", err) - } } else { home, err = expandOpenclawPath(home) - if err != nil { - return nil, "", fmt.Errorf("resolve OPENCLAW_HOME: %w", err) - } } - - for _, dirName := range openclawFallbackConfigDirNames { - candidates = appendOpenclawConfigFileCandidates(candidates, filepath.Join(home, dirName)) + if err != nil { + return nil, "", fmt.Errorf("resolve openclaw home: %w", err) + } + for _, dir := range openclawFallbackConfigDirNames { + candidates = appendOpenclawConfigFileCandidates(candidates, filepath.Join(home, dir)) } return candidates, filepath.Join(home, ".openclaw", "openclaw.json"), nil } @@ -582,217 +522,44 @@ func appendOpenclawConfigFileCandidates(candidates []string, dir string) []strin func expandOpenclawPath(path string) (string, error) { if path == "~" || strings.HasPrefix(path, "~/") { - home, herr := os.UserHomeDir() - if herr != nil { - return "", fmt.Errorf("expand `~` in openclaw config path: %w", herr) - } - if path == "~" { - path = home - } else { - path = filepath.Join(home, strings.TrimPrefix(path, "~/")) + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("expand openclaw config path: %w", err) } + path = filepath.Join(home, strings.TrimPrefix(path, "~/")) } if !filepath.IsAbs(path) { - abs, err := filepath.Abs(path) + absolute, err := filepath.Abs(path) if err != nil { - return "", fmt.Errorf("resolve openclaw config path %q: %w", path, err) + return "", fmt.Errorf("resolve openclaw config path: %w", err) } - path = abs + path = absolute } - return path, nil + return filepath.Clean(path), nil } func openclawStatConfigPath(path string) (string, bool, error) { if !filepath.IsAbs(path) { - return "", false, fmt.Errorf("openclaw reported non-absolute config path %q", path) + return "", false, fmt.Errorf("openclaw config path must be absolute") } exists, err := openclawConfigPathExists(path) - if err != nil { - return "", false, err - } - return path, exists, nil + return path, exists, err } func openclawConfigPathExists(path string) (bool, error) { - info, err := os.Stat(path) + info, err := os.Lstat(path) if errors.Is(err, os.ErrNotExist) { return false, nil } if err != nil { - return false, fmt.Errorf("stat openclaw config %s: %w", path, err) + return false, fmt.Errorf("inspect openclaw config: %w", err) } if info.IsDir() { - return false, fmt.Errorf("openclaw config path %s is a directory, not a file", path) + return false, fmt.Errorf("openclaw config path is a directory") } return true, nil } -func isOpenclawConfigFileUnsupported(err error) bool { - msg := strings.ToLower(err.Error()) - return strings.Contains(msg, "too many arguments for 'config'") || - strings.Contains(msg, "expected 0 arguments but got 1") || - (strings.Contains(msg, "unknown") && strings.Contains(msg, "config") && strings.Contains(msg, "file")) -} - -// openclawResolvedFullConfig fetches the user's fully resolved openclaw -// config via `openclaw config get --json` (no key path — root). The CLI's -// loader handles JSON5 / $include / env-substitution and emits a flat JSON -// object, which is what we need to write a sanitized snapshot that the -// wrapper can $include without inheriting the user's `mcp.servers`. -// -// Returns (nil, nil) when the CLI prints empty / null output for the root -// — interpreted as "no resolvable user config" by the caller, which then -// falls through to the fresh-install code path. Any other failure -// surfaces as an error so the daemon fails closed instead of silently -// degrading to a leaky non-strict wrapper. -func openclawResolvedFullConfig(bin string, timeout time.Duration) (map[string]any, error) { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - out, err := openclawExec(ctx, bin, "config", "get", "--json") - if err != nil { - return nil, err - } - trimmed := strings.TrimSpace(out) - if trimmed == "" || trimmed == "null" { - return nil, nil - } - var cfg map[string]any - if err := json.Unmarshal([]byte(trimmed), &cfg); err != nil { - return nil, fmt.Errorf("parse `openclaw config get --json` output: %w", err) - } - return cfg, nil -} - -// openclawResolvedAgentsList fetches the user's resolved per-agent list and -// reports which schema produced it. The schema matters downstream: a config- -// sourced list is itself valid `agents.list[]` config and may be written back -// into the wrapper to pin per-agent workspaces, whereas a registry-sourced -// list MUST NOT be written back — see openclawRegistryAgentsList. -// -// Two schemas are supported: -// -// - Pre-2026.6: agents live in the config under `agents.list`. We read them -// via `openclaw config get agents.list --json`, which returns the post- -// include, post-env-substitution array. fromRegistry=false. -// - 2026.6.x and later: `agents.list` is no longer a config path — agents -// live in a sqlite registry. `config get agents.list` exits non-zero with -// "Config path not found: agents.list". We fall back to the -// `openclaw agents list --json` *subcommand*. fromRegistry=true. -// -// Returns (nil, false, nil) when neither source yields any agents. -func openclawResolvedAgentsList(bin string, timeout time.Duration) ([]any, bool, error) { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - out, err := openclawExec(ctx, bin, "config", "get", "agents.list", "--json") - if err != nil { - if isOpenclawKeyMissing(err) { - // New schema: the config path is gone; the agents live in the - // sqlite registry. Resolve them via the subcommand instead. - list, rerr := openclawRegistryAgentsList(bin, timeout) - return list, true, rerr - } - return nil, false, err - } - trimmed := strings.TrimSpace(out) - if trimmed == "" || trimmed == "null" { - return nil, false, nil - } - var list []any - if err := json.Unmarshal([]byte(trimmed), &list); err != nil { - return nil, false, fmt.Errorf("parse `openclaw config get agents.list --json` output: %w", err) - } - return list, false, nil -} - -// openclawRegistryAgentsList resolves agents from the sqlite-backed registry -// via `openclaw agents list --json` (OpenClaw 2026.6.x+). -// -// **The result is for read-side use only — it must never be written back into -// the wrapper as `agents.list`.** The registry entries carry CLI-only fields -// (identityName, identitySource, agentDir, bindings, isDefault) that are NOT -// part of the 2026.6.x config schema's `agents.list[]` shape; OpenClaw's -// validator rejects them ("agents.list.0: Invalid input") and fails closed -// before the agent runs. Worse, `agents.list` is no longer a valid config -// path at all in 2026.6.x — there is no in-config way to pin a per-agent -// workspace. The per-task workspace is instead pinned via -// `agents.defaults.workspace` alone, which the wrapper always sets and which -// OpenClaw applies to the agent it selects from the registry (verified on -// 2026.6.8). Callers gate the write-back on fromRegistry from -// openclawResolvedAgentsList. -// -// Returns nil (not an error) when the registry is empty or the subcommand -// reports no agents. -func openclawRegistryAgentsList(bin string, timeout time.Duration) ([]any, error) { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - out, err := openclawExec(ctx, bin, "agents", "list", "--json") - if err != nil { - // Older OpenClaw builds may lack the subcommand entirely; treat an - // unrecognized/missing subcommand the same as "no agents to pin" - // rather than failing closed, since the defaults.workspace override - // alone still gives correct per-task skill discovery for the common - // single-agent case. - if isOpenclawKeyMissing(err) || isOpenclawUnknownSubcommand(err) { - return nil, nil - } - return nil, err - } - trimmed := strings.TrimSpace(out) - if trimmed == "" || trimmed == "null" { - return nil, nil - } - var list []any - if err := json.Unmarshal([]byte(trimmed), &list); err != nil { - return nil, fmt.Errorf("parse `openclaw agents list --json` output: %w", err) - } - return list, nil -} - -// openclawExec is the runtime hook prepareOpenclawConfig uses to invoke the -// openclaw CLI. Production points at execOpenclawCLI; tests swap in a stub -// to avoid spawning a real binary. Production code never reassigns it. -var openclawExec = execOpenclawCLI - -// execOpenclawCLI executes an openclaw subcommand and returns its stdout. -// The daemon's environment is inherited so OPENCLAW_CONFIG_PATH / -// OPENCLAW_STATE_DIR / OPENCLAW_HOME / OPENCLAW_INCLUDE_ROOTS pass through. -// -// stderr is captured separately and appended to error messages — failures -// here surface up to the daemon log, and a `openclaw doctor` hint there is -// more useful than just an exit code. -func execOpenclawCLI(ctx context.Context, bin string, args ...string) (string, error) { - cmd := exec.CommandContext(ctx, bin, args...) - cmd.Env = os.Environ() - var stderr strings.Builder - cmd.Stderr = &stderr - raw, err := cmd.Output() - if err != nil { - stderrMsg := strings.TrimSpace(stderr.String()) - if stderrMsg != "" { - return "", fmt.Errorf("openclaw %s: %w (stderr: %s)", strings.Join(args, " "), err, stderrMsg) - } - return "", fmt.Errorf("openclaw %s: %w", strings.Join(args, " "), err) - } - return string(raw), nil -} - -// openclawManagedMcpServers parses the agent's `mcp_config` JSON and returns -// the map of server name → server config that the wrapper should emit at -// `mcp.servers`. The second return is `true` when the agent has a managed -// mcp_config saved (non-null) — including the explicit empty set -// `{}` / `{"mcpServers":{}}` — and `false` when the field is null/absent so -// the user's global config flows through unmodified. -// -// Input shape mirrors the rest of Multica: Claude-style -// `{"mcpServers": {"": {...}}}`. The server-entry fields pass through -// verbatim. OpenClaw's stdio schema uses the same camelCase keys (`command`, -// `args`, `env`) as Claude; HTTP/SSE entries should set OpenClaw's -// `transport` field directly (e.g. `"transport": "streamable-http"`) rather -// than Claude's `type` since OpenClaw does not recognise the latter. -// -// Each entry must declare either `command` (stdio) or `url` (http/sse); any -// other shape returns an error so the launch fails closed with an actionable -// message rather than running with a server OpenClaw will refuse to start. func openclawManagedMcpServers(raw json.RawMessage) (map[string]any, bool, error) { trimmed := bytes.TrimSpace(raw) if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { @@ -824,48 +591,9 @@ func openclawManagedMcpServers(raw json.RawMessage) (map[string]any, bool, error command, _ := entry["command"].(string) url, _ := entry["url"].(string) if strings.TrimSpace(command) == "" && strings.TrimSpace(url) == "" { - return nil, false, fmt.Errorf("mcp_servers.%s must declare either `command` (stdio) or `url` (http/sse)", name) + return nil, false, fmt.Errorf("mcp_servers.%s must declare command or url", name) } out[name] = entry } return out, true, nil } - -// isOpenclawKeyMissing returns true when the CLI error indicates the asked- -// for path simply isn't set, as opposed to a real failure (bad config, -// CLI bug, missing binary). The CLI's "key not found" exit text has varied -// across versions, so we match on a handful of substrings rather than the -// exit code alone. -func isOpenclawKeyMissing(err error) bool { - if err == nil { - return false - } - // Match case-insensitively: the CLI's "key not found" wording has drifted - // across versions and capitalization is not stable. Pre-2026.6 emitted - // "Path not found"; OpenClaw 2026.6.x emits "Config path not found: - // agents.list" (lowercase "path", "Config" prefix). A case-sensitive - // strings.Contains on "Path not found" silently stopped matching the - // 2026.6.x string, turning the intended graceful-skip into a fail-closed - // error that broke every OpenClaw 2026.6.x runtime (see upstream #3028). - msg := strings.ToLower(err.Error()) - return strings.Contains(msg, "no value at ") || - strings.Contains(msg, "not set") || - strings.Contains(msg, "missing key") || - strings.Contains(msg, "path not found") -} - -// isOpenclawUnknownSubcommand returns true when the CLI error indicates the -// invoked subcommand/option does not exist on this OpenClaw build (e.g. an -// older release predating `openclaw agents list --json`). Used so the -// registry fallback degrades to "no agents to pin" rather than failing -// closed on builds that never had the subcommand. -func isOpenclawUnknownSubcommand(err error) bool { - if err == nil { - return false - } - msg := strings.ToLower(err.Error()) - return strings.Contains(msg, "unknown command") || - strings.Contains(msg, "unknown option") || - strings.Contains(msg, "does not recognize") || - strings.Contains(msg, "unknown argument") -} diff --git a/server/internal/daemon/execenv/openclaw_config_test.go b/server/internal/daemon/execenv/openclaw_config_test.go index cf061edcc41..716b76b9c1f 100644 --- a/server/internal/daemon/execenv/openclaw_config_test.go +++ b/server/internal/daemon/execenv/openclaw_config_test.go @@ -1,1579 +1,395 @@ package execenv import ( - "context" + "bytes" "encoding/json" - "errors" - "fmt" "io" "log/slog" "os" "path/filepath" + "reflect" + "runtime" "strings" "testing" ) -// openclawCLIStub captures one or more (subcommand, response) pairs and -// installs itself into the package-level openclawExec hook for the duration -// of a test. Each call records the args it saw so assertions can verify the -// preparer hit `config file` and `config get agents.list --json`. -type openclawCLIStub struct { - t *testing.T - bin string - responses map[string]openclawResponse - calls []openclawCall -} - -type openclawCall struct { - bin string - args []string -} - -type openclawResponse struct { - stdout string - err error -} - -func installOpenclawStub(t *testing.T, responses map[string]openclawResponse) *openclawCLIStub { - t.Helper() - stub := &openclawCLIStub{ - t: t, - bin: "/test/stub/openclaw", - responses: responses, - } - prev := openclawExec - openclawExec = stub.exec - t.Cleanup(func() { openclawExec = prev }) - return stub -} - -func (s *openclawCLIStub) exec(_ context.Context, bin string, args ...string) (string, error) { - s.calls = append(s.calls, openclawCall{bin: bin, args: append([]string(nil), args...)}) - key := strings.Join(args, " ") - resp, ok := s.responses[key] - if !ok { - return "", fmt.Errorf("openclawCLIStub: unexpected args %q", key) - } - return resp.stdout, resp.err -} - -func mustReadJSON(t *testing.T, path string) map[string]any { +func mustReadOpenclawJSON(t *testing.T, path string) map[string]any { t.Helper() raw, err := os.ReadFile(path) if err != nil { - t.Fatalf("read synthesized cfg: %v", err) + t.Fatalf("read %s: %v", path, err) } - var got map[string]any - if err := json.Unmarshal(raw, &got); err != nil { - t.Fatalf("parse synthesized cfg: %v", err) + var value map[string]any + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatalf("parse %s: %v", path, err) } - return got + return value } -// TestPrepareOpenclawConfigDelegatesParsingToCLI is the headline assertion -// for the Elon must-fix: instead of re-parsing the user's openclaw.json -// with encoding/json (which can't read JSON5 / $include / env-var -// substitution), we delegate the read to the openclaw CLI. The wrapper -// $includes the user's active path so OpenClaw's own loader handles the -// JSON5 / $include resolution; we only emit workspace overrides. -func TestPrepareOpenclawConfigDelegatesParsingToCLI(t *testing.T) { - envRoot := t.TempDir() - workDir := filepath.Join(envRoot, "workdir") - if err := os.MkdirAll(workDir, 0o755); err != nil { - t.Fatalf("mkdir workdir: %v", err) - } - - // JSON5 user config — comments and trailing commas would break the old - // encoding/json reader. The stub doesn't actually parse this; it just - // proves the wrapper points the $include at the right file regardless - // of its on-disk syntax. - userConfigDir := t.TempDir() - userConfigPath := filepath.Join(userConfigDir, "openclaw.json") - json5Body := `// User config with JSON5 features the old parser couldn't read -{ - agents: { - defaults: { - workspace: "/Users/alice/.openclaw/workspace", - model: { primary: "anthropic/claude-sonnet-4-6" }, - }, - list: [ - { id: "scout", workspace: "/Users/alice/projects/scout", }, - { id: "coder", model: "openai/gpt-5", }, - ], - }, - gateway: { port: 18789 }, // trailing comma -} -` - if err := os.WriteFile(userConfigPath, []byte(json5Body), 0o600); err != nil { - t.Fatalf("write user cfg: %v", err) - } - - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {stdout: userConfigPath + "\n"}, - "config get agents.list --json": {stdout: `[ - { "id": "scout", "workspace": "/Users/alice/projects/scout" }, - { "id": "coder", "model": "openai/gpt-5" } - ]`}, - }) - - result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin}) - if err != nil { - t.Fatalf("prepareOpenclawConfig: %v", err) - } - cfgPath := result.ConfigPath - if cfgPath != filepath.Join(envRoot, openclawConfigFile) { - t.Errorf("cfgPath = %q, want %q", cfgPath, filepath.Join(envRoot, openclawConfigFile)) - } - - got := mustReadJSON(t, cfgPath) - - // $include must reference the user's active config so OpenClaw's own - // loader does the JSON5 / $include / env-substitution work. - include, ok := got["$include"].([]any) - if !ok || len(include) != 1 || include[0] != userConfigPath { - t.Errorf("$include = %v, want [%q]", got["$include"], userConfigPath) - } - - // The wrapper $includes a path that lives outside envRoot. OpenClaw - // confines $include resolution to the wrapper file's own directory - // unless OPENCLAW_INCLUDE_ROOTS lists the target. Surface the user - // config's dirname so the daemon can grant it. - if result.IncludeRoot != userConfigDir { - t.Errorf("IncludeRoot = %q, want %q (dirname of active config so wrapper can $include across dirs)", result.IncludeRoot, userConfigDir) - } - - agents := got["agents"].(map[string]any) - defaults := agents["defaults"].(map[string]any) - if defaults["workspace"] != workDir { - t.Errorf("agents.defaults.workspace = %v, want %q", defaults["workspace"], workDir) - } - - // Per-agent workspaces must be rewritten so a host-scope agents.list[]. - // workspace cannot silently win over our defaults override. This is - // intentional per-task isolation (see prepareOpenclawConfig doc). - list := agents["list"].([]any) - if len(list) != 2 { - t.Fatalf("agents.list length = %d, want 2", len(list)) - } - for i, item := range list { - entry := item.(map[string]any) - if entry["workspace"] != workDir { - t.Errorf("agents.list[%d].workspace = %v, want %q (per-agent overrides must be rewritten so they don't beat defaults)", i, entry["workspace"], workDir) +func readOpenclawTaskFiles(t *testing.T, wrapperPath, envRoot string) []byte { + t.Helper() + var combined []byte + for _, path := range []string{wrapperPath, filepath.Join(envRoot, openclawUserSnapshotFile)} { + raw, err := os.ReadFile(path) + if os.IsNotExist(err) { + continue } + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + combined = append(combined, raw...) } - // Non-workspace fields per entry are carried over so a sibling-replace - // merge in OpenClaw's $include semantics doesn't silently lose them. - if list[0].(map[string]any)["id"] != "scout" { - t.Errorf("agents.list[0].id lost in carryover: %v", list[0]) - } - if list[1].(map[string]any)["model"] != "openai/gpt-5" { - t.Errorf("agents.list[1].model lost in carryover: %v", list[1]) - } -} - -// TestPrepareOpenclawConfigFailsClosedOnCLIError — the headline regression -// for Elon's review. When the openclaw CLI fails (broken config, missing -// binary, etc.), prepareOpenclawConfig MUST surface the error rather than -// silently synthesize a minimal config that would mask the user's broken -// state and boot OpenClaw without their registered agents. -func TestPrepareOpenclawConfigFailsClosedOnCLIError(t *testing.T) { - envRoot := t.TempDir() - workDir := filepath.Join(envRoot, "workdir") - if err := os.MkdirAll(workDir, 0o755); err != nil { - t.Fatalf("mkdir workdir: %v", err) - } - - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {err: errors.New("exec: openclaw: no such file or directory")}, - }) - - _, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin}) - if err == nil { - t.Fatal("prepareOpenclawConfig succeeded on CLI failure; expected fail closed") - } - if !strings.Contains(err.Error(), "locate openclaw active config") { - t.Errorf("error message %q does not name the failed step", err.Error()) - } - - // No stale wrapper left behind. - if _, err := os.Stat(filepath.Join(envRoot, openclawConfigFile)); !os.IsNotExist(err) { - t.Errorf("wrapper config should not exist after fail-closed; got err = %v", err) - } -} - -// TestPrepareOpenclawConfigFallsBackWhenConfigFileUnsupported covers -// OpenClaw 2026.2.x builds that rejected `openclaw config file` with -// "too many arguments for 'config'". That command-shape mismatch should -// not make every task fail during execenv prep; the daemon can derive the -// active path from OpenClaw's documented and legacy config candidates and -// then continue using `config get ... --json` for resolved config data. -func TestPrepareOpenclawConfigFallsBackWhenConfigFileUnsupported(t *testing.T) { - envRoot := t.TempDir() - workDir := filepath.Join(envRoot, "workdir") - if err := os.MkdirAll(workDir, 0o755); err != nil { - t.Fatalf("mkdir workdir: %v", err) - } - - userConfigDir := t.TempDir() - userConfigPath := filepath.Join(userConfigDir, "openclaw.json") - if err := os.WriteFile(userConfigPath, []byte(`{}`), 0o600); err != nil { - t.Fatalf("write user cfg: %v", err) - } - t.Setenv("OPENCLAW_CONFIG_PATH", userConfigPath) - - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {err: errors.New("openclaw config file: exit status 1 (stderr: error: too many arguments for 'config'. Expected 0 arguments but got 1.)")}, - "config get agents.list --json": {stdout: `[ - { "id": "coder", "model": "openai/gpt-5" } - ]`}, - }) - - result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin}) - if err != nil { - t.Fatalf("prepareOpenclawConfig: %v", err) - } - - got := mustReadJSON(t, result.ConfigPath) - include, ok := got["$include"].([]any) - if !ok || len(include) != 1 || include[0] != userConfigPath { - t.Errorf("$include = %v, want fallback OPENCLAW_CONFIG_PATH %q", got["$include"], userConfigPath) - } - if result.IncludeRoot != userConfigDir { - t.Errorf("IncludeRoot = %q, want %q", result.IncludeRoot, userConfigDir) - } - agents := got["agents"].(map[string]any) - list := agents["list"].([]any) - if len(list) != 1 || list[0].(map[string]any)["workspace"] != workDir { - t.Errorf("agents.list workspace rewrite after fallback = %v, want workDir %q", list, workDir) - } - if len(stub.calls) != 2 { - t.Fatalf("openclaw calls = %d, want 2: %+v", len(stub.calls), stub.calls) - } - if strings.Join(stub.calls[1].args, " ") != "config get agents.list --json" { - t.Errorf("second openclaw call = %q, want config get agents.list --json", strings.Join(stub.calls[1].args, " ")) - } -} - -func TestOpenclawActiveConfigPathFallbackSources(t *testing.T) { - cases := map[string]struct { - setup func(t *testing.T) string - }{ - "config_path": { - setup: func(t *testing.T) string { - path := filepath.Join(t.TempDir(), "custom-openclaw.json") - t.Setenv("OPENCLAW_CONFIG_PATH", path) - return path - }, - }, - "legacy_config_path": { - setup: func(t *testing.T) string { - path := filepath.Join(t.TempDir(), "custom-clawdbot.json") - t.Setenv("CLAWDBOT_CONFIG_PATH", path) - return path - }, - }, - "state_dir": { - setup: func(t *testing.T) string { - stateDir := t.TempDir() - path := filepath.Join(stateDir, "openclaw.json") - t.Setenv("OPENCLAW_STATE_DIR", stateDir) - return path - }, - }, - "legacy_state_dir": { - setup: func(t *testing.T) string { - stateDir := t.TempDir() - path := filepath.Join(stateDir, "clawdbot.json") - t.Setenv("CLAWDBOT_STATE_DIR", stateDir) - return path - }, - }, - "openclaw_home": { - setup: func(t *testing.T) string { - home := t.TempDir() - path := filepath.Join(home, ".openclaw", "openclaw.json") - t.Setenv("OPENCLAW_HOME", home) - return path - }, - }, - "default_home": { - setup: func(t *testing.T) string { - home := t.TempDir() - path := filepath.Join(home, ".openclaw", "openclaw.json") - t.Setenv("HOME", home) - return path - }, - }, - "legacy_default_clawdbot": { - setup: func(t *testing.T) string { - home := t.TempDir() - path := filepath.Join(home, ".clawdbot", "clawdbot.json") - t.Setenv("HOME", home) - return path - }, - }, - "legacy_default_moltbot": { - setup: func(t *testing.T) string { - home := t.TempDir() - path := filepath.Join(home, ".moltbot", "moltbot.json") - t.Setenv("HOME", home) - return path - }, - }, - "legacy_default_moldbot": { - setup: func(t *testing.T) string { - home := t.TempDir() - path := filepath.Join(home, ".moldbot", "moldbot.json") - t.Setenv("HOME", home) - return path - }, - }, - } - - for name, tc := range cases { - t.Run(name, func(t *testing.T) { - clearOpenclawPathEnv(t) - want := tc.setup(t) - if err := os.MkdirAll(filepath.Dir(want), 0o755); err != nil { - t.Fatalf("mkdir config dir: %v", err) - } - if err := os.WriteFile(want, []byte(`{}`), 0o600); err != nil { - t.Fatalf("write config: %v", err) - } - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {err: openclawConfigFileUnsupportedErr()}, - }) - - got, exists, err := openclawActiveConfigPath(stub.bin, openclawCLITimeout) - if err != nil { - t.Fatalf("openclawActiveConfigPath: %v", err) - } - if !exists { - t.Fatal("exists = false, want true") - } - if got != want { - t.Errorf("path = %q, want %q", got, want) - } - }) - } -} - -func TestOpenclawActiveConfigPathFallbackFreshInstallUsesCanonicalPath(t *testing.T) { - clearOpenclawPathEnv(t) - home := t.TempDir() - t.Setenv("HOME", home) - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {err: openclawConfigFileUnsupportedErr()}, - }) - - got, exists, err := openclawActiveConfigPath(stub.bin, openclawCLITimeout) - if err != nil { - t.Fatalf("openclawActiveConfigPath: %v", err) - } - want := filepath.Join(home, ".openclaw", "openclaw.json") - if got != want { - t.Errorf("path = %q, want canonical fresh-install path %q", got, want) - } - if exists { - t.Fatal("exists = true, want false for fresh install") - } -} - -func TestOpenclawActiveConfigPathFallbackOpenclawConfigPathHardOverride(t *testing.T) { - clearOpenclawPathEnv(t) - home := t.TempDir() - t.Setenv("HOME", home) - explicitPath := filepath.Join(t.TempDir(), "missing-openclaw.json") - t.Setenv("OPENCLAW_CONFIG_PATH", explicitPath) - legacyPath := filepath.Join(home, ".clawdbot", "clawdbot.json") - if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil { - t.Fatalf("mkdir legacy config dir: %v", err) - } - if err := os.WriteFile(legacyPath, []byte(`{}`), 0o600); err != nil { - t.Fatalf("write legacy config: %v", err) - } - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {err: openclawConfigFileUnsupportedErr()}, - }) - - got, exists, err := openclawActiveConfigPath(stub.bin, openclawCLITimeout) - if err != nil { - t.Fatalf("openclawActiveConfigPath: %v", err) - } - if got != explicitPath { - t.Errorf("path = %q, want OPENCLAW_CONFIG_PATH hard override %q", got, explicitPath) - } - if exists { - t.Fatal("exists = true, want false when explicit OPENCLAW_CONFIG_PATH is missing") - } -} - -func TestOpenclawActiveConfigPathFallbackErrorIncludesOriginalCLIError(t *testing.T) { - clearOpenclawPathEnv(t) - badPath := filepath.Join(t.TempDir(), "openclaw.json") - if err := os.MkdirAll(badPath, 0o755); err != nil { - t.Fatalf("mkdir bad config path: %v", err) - } - t.Setenv("OPENCLAW_CONFIG_PATH", badPath) - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {err: openclawConfigFileUnsupportedErr()}, - }) - - _, _, err := openclawActiveConfigPath(stub.bin, openclawCLITimeout) - if err == nil { - t.Fatal("openclawActiveConfigPath succeeded with directory config path; expected error") - } - msg := err.Error() - if !strings.Contains(msg, "too many arguments for 'config'") { - t.Errorf("error %q lost original unsupported CLI stderr", msg) - } - if !strings.Contains(msg, "is a directory") { - t.Errorf("error %q lost fallback failure detail", msg) - } -} - -func TestIsOpenclawConfigFileUnsupportedMatchesKnownShapes(t *testing.T) { - cases := map[string]struct { - err error - want bool - }{ - "reported_too_many_arguments": { - err: errors.New("openclaw config file: exit status 1 (stderr: error: too many arguments for 'config')"), - want: true, - }, - "reported_expected_zero_args": { - err: errors.New("Expected 0 arguments but got 1."), - want: true, - }, - "unknown_config_file": { - err: errors.New("unknown subcommand `file` for `openclaw config`"), - want: true, - }, - "real_config_validation_error": { - err: errors.New("openclaw config validation failed: missing gateway.auth.token"), - want: false, - }, - } - for name, tc := range cases { - t.Run(name, func(t *testing.T) { - if got := isOpenclawConfigFileUnsupported(tc.err); got != tc.want { - t.Errorf("isOpenclawConfigFileUnsupported(%q) = %t, want %t", tc.err, got, tc.want) - } - }) - } + return combined } -func clearOpenclawPathEnv(t *testing.T) { +func writeOwnerOpenclawConfig(t *testing.T, body string) string { t.Helper() - t.Setenv("OPENCLAW_CONFIG_PATH", "") - t.Setenv("OPENCLAW_STATE_DIR", "") - t.Setenv("OPENCLAW_HOME", "") - t.Setenv("CLAWDBOT_CONFIG_PATH", "") - t.Setenv("CLAWDBOT_STATE_DIR", "") -} - -func openclawConfigFileUnsupportedErr() error { - return errors.New("openclaw config file: exit status 1 (stderr: error: too many arguments for 'config'. Expected 0 arguments but got 1.)") -} - -// TestPrepareOpenclawConfigFailsClosedOnMalformedAgentsList — the second -// fail-closed surface. When `openclaw config get agents.list --json` -// returns junk we can't parse, we fail rather than guess. -func TestPrepareOpenclawConfigFailsClosedOnMalformedAgentsList(t *testing.T) { - envRoot := t.TempDir() - workDir := filepath.Join(envRoot, "workdir") - if err := os.MkdirAll(workDir, 0o755); err != nil { - t.Fatalf("mkdir workdir: %v", err) - } - - userConfigPath := filepath.Join(t.TempDir(), "openclaw.json") - if err := os.WriteFile(userConfigPath, []byte(`{}`), 0o600); err != nil { - t.Fatalf("write user cfg: %v", err) - } - - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {stdout: userConfigPath}, - "config get agents.list --json": {stdout: "<<>>"}, - }) - - _, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin}) - if err == nil { - t.Fatal("prepareOpenclawConfig succeeded on malformed agents.list output; expected fail closed") - } - if !strings.Contains(err.Error(), "agents.list") { - t.Errorf("error message %q does not name the failed step", err.Error()) + path := filepath.Join(t.TempDir(), "openclaw.json") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write owner config: %v", err) } + t.Setenv("OPENCLAW_CONFIG_PATH", path) + return path } -// TestPrepareOpenclawConfigKeyMissingTreatedAsEmpty — `config get` exits -// non-zero when a path is unset. That is not a failure; the user simply has -// no agents.list. We must produce a valid wrapper with just the defaults -// override. -func TestPrepareOpenclawConfigKeyMissingTreatedAsEmpty(t *testing.T) { - envRoot := t.TempDir() - workDir := filepath.Join(envRoot, "workdir") - if err := os.MkdirAll(workDir, 0o755); err != nil { - t.Fatalf("mkdir workdir: %v", err) - } - - userConfigPath := filepath.Join(t.TempDir(), "openclaw.json") - if err := os.WriteFile(userConfigPath, []byte(`{}`), 0o600); err != nil { - t.Fatalf("write user cfg: %v", err) - } - - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {stdout: userConfigPath}, - "config get agents.list --json": {err: errors.New("openclaw: No value at agents.list")}, - // Pre-2026.6 single-agent installs with no per-agent overrides resolve - // to an empty registry once the config-path probe reports missing. - // (2026.6.x registry-population is covered by - // TestPrepareOpenclawConfigNewSchemaOmitsAgentsList.) - "agents list --json": {stdout: "null"}, - }) - - result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin}) - if err != nil { - t.Fatalf("prepareOpenclawConfig: %v", err) - } - cfgPath := result.ConfigPath - got := mustReadJSON(t, cfgPath) - if _, present := got["agents"].(map[string]any)["list"]; present { - t.Errorf("agents.list should be omitted when user has none, got %v", got["agents"]) - } - if got["agents"].(map[string]any)["defaults"].(map[string]any)["workspace"] != workDir { - t.Errorf("defaults.workspace not set when agents.list missing") +func TestPrepareOpenclawConfigDoesNotExecuteTaskSelectedBinary(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture is POSIX-only") } -} - -// TestPrepareOpenclawConfigFreshInstallNoOnDiskConfig — the only legitimate -// "synthesize minimal" case. `openclaw config file` reports a path (the -// default) but the file does not exist yet. We emit a wrapper with the -// workspace override and NO $include (there is nothing to include). -func TestPrepareOpenclawConfigFreshInstallNoOnDiskConfig(t *testing.T) { + writeOwnerOpenclawConfig(t, `{"agents":{"defaults":{"model":"openai/gpt-5"}}}`) envRoot := t.TempDir() - workDir := filepath.Join(envRoot, "workdir") - if err := os.MkdirAll(workDir, 0o755); err != nil { - t.Fatalf("mkdir workdir: %v", err) + marker := filepath.Join(t.TempDir(), "invoked") + fakeBin := filepath.Join(t.TempDir(), "openclaw") + if err := os.WriteFile(fakeBin, []byte("#!/bin/sh\nprintf invoked > "+marker+"\nexit 73\n"), 0o755); err != nil { + t.Fatalf("write malicious binary: %v", err) } - // CLI reports a default path that doesn't exist (fresh install). - missingPath := filepath.Join(t.TempDir(), "openclaw.json") - - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {stdout: missingPath}, - // `config get` should not be called when the file does not exist; - // the stub will fail "unexpected args" if it is. - }) - - result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin}) + _, err := prepareOpenclawConfig(envRoot, filepath.Join(envRoot, "workdir"), OpenclawConfigPrep{OpenclawBin: fakeBin}) if err != nil { t.Fatalf("prepareOpenclawConfig: %v", err) } - cfgPath := result.ConfigPath - got := mustReadJSON(t, cfgPath) - if _, present := got["$include"]; present { - t.Errorf("$include should be absent for fresh install, got %v", got["$include"]) - } - if got["agents"].(map[string]any)["defaults"].(map[string]any)["workspace"] != workDir { - t.Errorf("defaults.workspace not set on fresh-install wrapper") - } - // Fresh install emits no $include, so no extra include root is needed - // — the wrapper never steps outside envRoot. Daemon should leave the - // user's OPENCLAW_INCLUDE_ROOTS alone. - if result.IncludeRoot != "" { - t.Errorf("IncludeRoot = %q on fresh install, want empty (no $include emitted)", result.IncludeRoot) + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("task-selected OpenClaw binary was invoked: %v", err) } } -// TestPrepareOpenclawConfigExpandsTilde — `openclaw config file` reports -// paths with `~` shortened. The $include in our wrapper must be absolute so -// the loader resolves it unambiguously. -func TestPrepareOpenclawConfigExpandsTilde(t *testing.T) { +func TestPrepareOpenclawConfigExcludesOwnerCommandMCP(t *testing.T) { + writeOwnerOpenclawConfig(t, `{ + "agents":{"defaults":{"model":"openai/gpt-5"}}, + "mcp":{"servers":{"owner-admin":{"command":"/Users/owner/bin/owner-mcp","args":["--admin"]}}} + }`) envRoot := t.TempDir() - workDir := filepath.Join(envRoot, "workdir") - if err := os.MkdirAll(workDir, 0o755); err != nil { - t.Fatalf("mkdir workdir: %v", err) - } - - fakeHome := t.TempDir() - t.Setenv("HOME", fakeHome) - if err := os.MkdirAll(filepath.Join(fakeHome, ".openclaw"), 0o755); err != nil { - t.Fatalf("mkdir home/.openclaw: %v", err) - } - realPath := filepath.Join(fakeHome, ".openclaw", "openclaw.json") - if err := os.WriteFile(realPath, []byte(`{}`), 0o600); err != nil { - t.Fatalf("write user cfg: %v", err) - } - - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {stdout: "~/.openclaw/openclaw.json\n"}, - "config get agents.list --json": {stdout: "null"}, - }) - - result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin}) + result, err := prepareOpenclawConfig(envRoot, filepath.Join(envRoot, "workdir"), OpenclawConfigPrep{}) if err != nil { t.Fatalf("prepareOpenclawConfig: %v", err) } - cfgPath := result.ConfigPath - got := mustReadJSON(t, cfgPath) - include := got["$include"].([]any) - if include[0] != realPath { - t.Errorf("$include[0] = %v, want %q (tilde must be expanded to absolute)", include[0], realPath) - } - // IncludeRoot must also use the expanded absolute dirname, otherwise - // the daemon would export a `~/.openclaw`-shaped root that OpenClaw - // would not match against the resolved absolute include target. - wantRoot := filepath.Join(fakeHome, ".openclaw") - if result.IncludeRoot != wantRoot { - t.Errorf("IncludeRoot = %q, want %q (must be expanded absolute dirname)", result.IncludeRoot, wantRoot) + combined := readOpenclawTaskFiles(t, result.ConfigPath, envRoot) + for _, forbidden := range []string{"owner-admin", "owner-mcp", "--admin"} { + if bytes.Contains(combined, []byte(forbidden)) { + t.Fatalf("owner MCP leaked through %q: %s", forbidden, combined) + } } } -// TestPrepareOpenclawConfigParsesPathFromUITerminalOutput — regression test -// for the case where `openclaw config file` prints terminal UI borders -// (e.g., Doctor warnings) before the actual path. The path is always the -// last non-empty line. -func TestPrepareOpenclawConfigParsesPathFromUITerminalOutput(t *testing.T) { +func TestPrepareOpenclawConfigContainsNoOwnerAbsolutePaths(t *testing.T) { + writeOwnerOpenclawConfig(t, `{ + "agents":{"defaults":{"workspace":"/Users/owner/.openclaw/workspace","model":"openai/gpt-5"}}, + "plugins":{"load":{"paths":["/Users/owner/.openclaw/plugins/admin"]}}, + "tools":{"admin":{"command":"/Users/owner/bin/admin-tool"}}, + "gateway":{"port":18789} + }`) envRoot := t.TempDir() - workDir := filepath.Join(envRoot, "workdir") - if err := os.MkdirAll(workDir, 0o755); err != nil { - t.Fatalf("mkdir workdir: %v", err) - } - - userConfigDir := t.TempDir() - userConfigPath := filepath.Join(userConfigDir, "openclaw.json") - if err := os.WriteFile(userConfigPath, []byte(`{}`), 0o600); err != nil { - t.Fatalf("write user cfg: %v", err) - } - - // Simulate OpenClaw's output with UI borders (Doctor warnings) - stdoutWithUI := `│ -◇ Doctor warnings ──────────────────────────────────────────────────────╮ -│ │ -│ - Left plugin install index in place because shared SQLite state has │ -│ conflicting plugin install metadata for: qqbot │ -│ │ -├────────────────────────────────────────────────────────────────────────╯ -[state-migrations] Legacy state migration warnings: -- Left plugin install index in place because shared SQLite state has conflicting plugin install metadata for: qqbot -│ -◇ Doctor warnings ──────────────────────────────────────────────────────╮ -│ │ -│ - Left plugin install index in place because shared SQLite state has │ -│ conflicting plugin install metadata for: qqbot │ -│ │ -├────────────────────────────────────────────────────────────────────────╯ -` + userConfigPath + "\n" - - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {stdout: stdoutWithUI}, - "config get agents.list --json": {stdout: "null"}, - }) - - result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin}) + result, err := prepareOpenclawConfig(envRoot, filepath.Join(envRoot, "workdir"), OpenclawConfigPrep{}) if err != nil { t.Fatalf("prepareOpenclawConfig: %v", err) } - - got := mustReadJSON(t, result.ConfigPath) - include := got["$include"].([]any) - if include[0] != userConfigPath { - t.Errorf("$include[0] = %v, want %q (path must be extracted from last non-empty line)", include[0], userConfigPath) + combined := readOpenclawTaskFiles(t, result.ConfigPath, envRoot) + if bytes.Contains(combined, []byte("/Users/owner")) { + t.Fatalf("owner absolute path leaked: %s", combined) } } -// TestPrepareOpenclawConfigWrapperLoadableUnderIncludeConfinement is the -// regression test for the Elon include-confinement blocker. OpenClaw -// resolves `$include` only inside the wrapper file's own directory unless -// the target's parent dir is granted via OPENCLAW_INCLUDE_ROOTS. The -// previous PR wrote a wrapper at envRoot that $included -// `~/.openclaw/openclaw.json` (cross-directory) but never surfaced the -// dirname; OpenClaw would have refused to follow the link at runtime. -// -// This test simulates the same confinement check OpenClaw performs: -// -// - For every `$include` target, assert filepath.Dir(target) is either -// the wrapper's own dir OR matches the IncludeRoot we surface for the -// daemon to grant. -// -// It does NOT shell out to a real openclaw binary — the spec is small and -// stable enough that mirroring it in-test is more reliable than depending -// on the CLI being installed in CI. If this assertion ever drifts from the -// real loader, the upstream docs are the source of truth: -// https://github.com/openclaw/openclaw/blob/main/docs/gateway/configuration.md -func TestPrepareOpenclawConfigWrapperLoadableUnderIncludeConfinement(t *testing.T) { +func TestPrepareOpenclawConfigWritesAllowlistedSnapshot(t *testing.T) { + writeOwnerOpenclawConfig(t, `{ + "models":{"default":"openai/gpt-5"}, + "providers":{"openai":{"apiKey":"secret"}}, + "gateway":{"host":"127.0.0.1","port":18789}, + "agents":{"defaults":{"model":"openai/gpt-5","tools":{"admin":true}},"list":[{"id":"coder","name":"Coder","model":"openai/gpt-5","prompt":"owner orders"}]}, + "plugins":{"enabled":true},"hooks":{"command":"owner-hook"},"channels":{"admin":true} + }`) envRoot := t.TempDir() workDir := filepath.Join(envRoot, "workdir") - if err := os.MkdirAll(workDir, 0o755); err != nil { - t.Fatalf("mkdir workdir: %v", err) - } - - // User's active config sits in its own dir, not envRoot. This is the - // realistic shape (~/.openclaw/openclaw.json is never inside the task - // workspace) and is the exact case the bug paper-trail flagged. - userConfigDir := t.TempDir() - userConfigPath := filepath.Join(userConfigDir, "openclaw.json") - if err := os.WriteFile(userConfigPath, []byte(`{}`), 0o600); err != nil { - t.Fatalf("write user cfg: %v", err) - } - - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {stdout: userConfigPath}, - "config get agents.list --json": {stdout: "null"}, - }) - - result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin}) + result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{}) if err != nil { t.Fatalf("prepareOpenclawConfig: %v", err) } - - got := mustReadJSON(t, result.ConfigPath) - rawIncludes, ok := got["$include"].([]any) - if !ok || len(rawIncludes) == 0 { - t.Fatalf("wrapper has no $include entries, but a user config is present: %v", got) - } - - // Mirror OpenClaw's confinement check: every cross-dir $include target - // must have its dirname covered by either the wrapper's own dir or the - // IncludeRoot we surface. - wrapperDir := filepath.Dir(result.ConfigPath) - granted := []string{wrapperDir} - if result.IncludeRoot != "" { - granted = append(granted, result.IncludeRoot) - } - for _, raw := range rawIncludes { - target, ok := raw.(string) - if !ok { - t.Fatalf("$include entry is not a string: %T %v", raw, raw) - } - targetDir := filepath.Dir(target) - allowed := false - for _, g := range granted { - if targetDir == g { - allowed = true - break - } - } - if !allowed { - t.Errorf("$include target %q has dirname %q which is not in granted include roots %v — OpenClaw would refuse to load it", - target, targetDir, granted) + snapshotPath := filepath.Join(envRoot, openclawUserSnapshotFile) + snapshot := mustReadOpenclawJSON(t, snapshotPath) + for _, forbidden := range []string{"mcp", "plugins", "hooks", "channels", "tools"} { + if _, ok := snapshot[forbidden]; ok { + t.Fatalf("snapshot retained %q: %v", forbidden, snapshot) } } -} - -// TestPrepareOpenclawConfigStrictReplacesUserMcpServers — the headline -// assertion for Elon's strict-replace must-fix on PR #3450. When the user -// has a global `mcp.servers.global_one` AND the agent has a managed -// `mcp.servers.shared + managed_only`, the wrapper must NOT $include the -// live user config (which would leak global_one) and must instead -// $include a sanitized snapshot that has the user's `mcp` block stripped. -// The wrapper itself carries managed servers and nothing else. -func TestPrepareOpenclawConfigStrictReplacesUserMcpServers(t *testing.T) { - envRoot := t.TempDir() - workDir := filepath.Join(envRoot, "workdir") - if err := os.MkdirAll(workDir, 0o755); err != nil { - t.Fatalf("mkdir workdir: %v", err) + if snapshot["models"] == nil || snapshot["gateway"] == nil { + t.Fatalf("snapshot lost allowlisted model or gateway data: %v", snapshot) } - - userCfgPath := filepath.Join(t.TempDir(), "openclaw.json") - if err := os.WriteFile(userCfgPath, []byte(`{}`), 0o600); err != nil { - t.Fatalf("write user cfg: %v", err) + if _, ok := snapshot["providers"]; ok { + t.Fatalf("credential-only provider data was retained: %v", snapshot) } - // The resolved user config the CLI would return: a user global - // mcp.servers + some other non-mcp content the snapshot must preserve. - resolvedUser := `{ - "mcp": {"servers": { - "global_one": {"command": "/bin/echo", "args": ["user"]}, - "shared": {"command": "/bin/old-version"} - }}, - "gateway": {"port": 18789}, - "providers": {"anthropic": {"apiKey": "sk-user-secret"}} - }` - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {stdout: userCfgPath}, - "config get --json": {stdout: resolvedUser}, - "config get agents.list --json": {stdout: "null"}, - }) - - mcpConfig := json.RawMessage(`{ - "mcpServers": { - "shared": {"command": "/bin/new-version"}, - "managed_only": {"url": "https://mcp.example.com", "transport": "streamable-http"} + agents := snapshot["agents"].(map[string]any) + if agents["defaults"].(map[string]any)["workspace"] != workDir { + t.Fatalf("defaults workspace not isolated: %v", agents) + } + entry := agents["list"].([]any)[0].(map[string]any) + if entry["workspace"] != workDir || entry["id"] != "coder" { + t.Fatalf("agent metadata not projected safely: %v", entry) + } + if _, ok := entry["prompt"]; ok { + t.Fatalf("owner prompt leaked: %v", entry) + } + wrapper := mustReadOpenclawJSON(t, result.ConfigPath) + include := wrapper["$include"].([]any) + if len(include) != 1 || include[0] != snapshotPath { + t.Fatalf("wrapper include = %v, want private snapshot", include) + } + info, err := os.Lstat(snapshotPath) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { + t.Fatalf("snapshot mode = %v, err = %v", info, err) + } +} + +func TestPrepareOpenclawConfigProjectsPublicProviderMetadata(t *testing.T) { + writeOwnerOpenclawConfig(t, `{ + "providers":{ + "openai":{ + "apiKey":"openai-secret", + "API_KEY":"uppercase-secret", + "x-api-key":"/Users/owner/provider-key", + "accessKey":"access-key-secret", + "accountMaterial":"novel-secret", + "baseUrl":"https://api.openai.example/v1", + "organization":"org-example", + "auth":{"access_token":"nested-access-secret","oauthToken":"oauth-secret","Authorization":"Bearer auth-secret","scheme":"Bearer"}, + "fallbacks":[ + {"region":"us-east-1","clientSecret":"array-client-secret"}, + {"region":"eu-west-1","credential":{"type":"service-account","private-key":"array-private-secret","sharedSecret":"shared-secret"}} + ] + }, + "local":{"endpoint":"http://127.0.0.1:11434","enabled":true,"key":"local-key-secret"} } }`) - - result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{ - OpenclawBin: stub.bin, - McpConfig: mcpConfig, - }) + envRoot := t.TempDir() + result, err := prepareOpenclawConfig(envRoot, filepath.Join(envRoot, "workdir"), OpenclawConfigPrep{}) if err != nil { t.Fatalf("prepareOpenclawConfig: %v", err) } - got := mustReadJSON(t, result.ConfigPath) - mcp, ok := got["mcp"].(map[string]any) - if !ok { - t.Fatalf("wrapper missing mcp block: %v", got) - } - servers, ok := mcp["servers"].(map[string]any) - if !ok { - t.Fatalf("mcp.servers is not an object: %v", mcp) - } - if len(servers) != 2 { - t.Errorf("mcp.servers has %d entries, want 2 (managed only — global_one must not leak): %v", len(servers), servers) - } - if _, leaked := servers["global_one"]; leaked { - t.Errorf("mcp.servers.global_one leaked into wrapper from user config: %v", servers) - } - if shared, ok := servers["shared"].(map[string]any); !ok || shared["command"] != "/bin/new-version" { - t.Errorf("mcp.servers.shared = %v, want managed `command: /bin/new-version` (managed overrides user same-name)", shared) - } - if managed, ok := servers["managed_only"].(map[string]any); !ok || managed["url"] != "https://mcp.example.com" { - t.Errorf("mcp.servers.managed_only missing or wrong shape: %v", managed) - } - - // The wrapper's $include must point at the sanitized snapshot, NOT the - // live user config — otherwise OpenClaw would deep-merge user.mcp back in. - include, _ := got["$include"].([]any) - if len(include) != 1 { - t.Fatalf("wrapper $include has %d entries, want 1: %v", len(include), include) - } - snapshotPath, _ := include[0].(string) - if snapshotPath == userCfgPath { - t.Fatalf("wrapper $includes the live user config (%q) — strict replace requires the sanitized snapshot", userCfgPath) - } - wantSnapshot := filepath.Join(envRoot, openclawUserSnapshotFile) - if snapshotPath != wantSnapshot { - t.Errorf("$include = %q, want sanitized snapshot %q", snapshotPath, wantSnapshot) - } - - // Snapshot must exist, must drop the `mcp` block, and must preserve the - // non-mcp keys (gateway, providers, secrets) so OpenClaw still has API - // keys and other config the user relied on. - snap := mustReadJSON(t, snapshotPath) - if _, present := snap["mcp"]; present { - t.Errorf("snapshot still contains an `mcp` block — strict replace not enforced: %v", snap["mcp"]) - } - if gw, ok := snap["gateway"].(map[string]any); !ok || gw["port"] != float64(18789) { - t.Errorf("snapshot lost gateway.port carryover: %v", snap["gateway"]) - } - if _, ok := snap["providers"].(map[string]any); !ok { - t.Errorf("snapshot lost providers carryover: %v", snap) - } - - // The snapshot lives in envRoot alongside the wrapper, so the daemon - // does NOT need to grant an OPENCLAW_INCLUDE_ROOTS entry for it. - if result.IncludeRoot != "" { - t.Errorf("IncludeRoot = %q, want empty (snapshot lives in envRoot, no cross-dir include)", result.IncludeRoot) + combined := readOpenclawTaskFiles(t, result.ConfigPath, envRoot) + for _, secret := range []string{ + "openai-secret", "uppercase-secret", "/Users/owner/provider-key", "access-key-secret", + "novel-secret", "nested-access-secret", "oauth-secret", "auth-secret", + "array-client-secret", "array-private-secret", "shared-secret", "local-key-secret", + } { + if bytes.Contains(combined, []byte(secret)) { + t.Errorf("provider credential %q leaked: %s", secret, combined) + } } -} -// TestPrepareOpenclawConfigStrictPreservesNonServerMcpKeys — Elon's -// follow-up must-fix: the strict-replace path must scope only to -// `mcp.servers`, not the entire `mcp` block. OpenClaw config has -// sibling settings under `mcp` (e.g. `sessionIdleTtlMs` — see -// https://docs.openclaw.ai/gateway/configuration-reference#mcp). The -// previous implementation deleted the whole `mcp` block which silently -// reset those siblings to OpenClaw's defaults. This test fixes that -// scope: managed-MCP path drops `mcp.servers` but leaves -// `mcp.sessionIdleTtlMs` intact in the snapshot. -func TestPrepareOpenclawConfigStrictPreservesNonServerMcpKeys(t *testing.T) { - envRoot := t.TempDir() - workDir := filepath.Join(envRoot, "workdir") - if err := os.MkdirAll(workDir, 0o755); err != nil { - t.Fatalf("mkdir workdir: %v", err) - } - userCfgPath := filepath.Join(t.TempDir(), "openclaw.json") - if err := os.WriteFile(userCfgPath, []byte(`{}`), 0o600); err != nil { - t.Fatalf("write user cfg: %v", err) + snapshot := mustReadOpenclawJSON(t, filepath.Join(envRoot, openclawUserSnapshotFile)) + providers := snapshot["providers"].(map[string]any) + want := map[string]any{ + "openai": map[string]any{ + "baseUrl": "https://api.openai.example/v1", + "organization": "org-example", + }, + "local": map[string]any{ + "endpoint": "http://127.0.0.1:11434", + "enabled": true, + }, } - // User's resolved config has BOTH `mcp.servers` (must be stripped) and - // `mcp.sessionIdleTtlMs` (must survive). The snapshot is what OpenClaw - // loads via the wrapper's $include, so only the snapshot's `mcp` block - // is consulted for non-server settings. - resolvedUser := `{ - "mcp": { - "sessionIdleTtlMs": 300000, - "servers": {"global_one": {"command": "/bin/echo"}} + if !reflect.DeepEqual(providers, want) { + t.Fatalf("providers = %#v, want public projection %#v", providers, want) + } +} + +func TestPrepareOpenclawConfigExcludesOwnerModelAndGatewayCredentials(t *testing.T) { + writeOwnerOpenclawConfig(t, `{ + "models":{ + "default":"openai/gpt-5", + "mode":"merge", + "apiKey":"models-root-secret", + "providers":{ + "openai":{ + "baseUrl":"https://api.openai.example/v1", + "apiKey":"models-provider-secret", + "headers":{"Authorization":"Bearer models-header-secret"}, + "models":[{ + "id":"gpt-5", + "name":"GPT 5", + "reasoning":true, + "contextWindow":400000, + "maxTokens":128000, + "token":"model-entry-secret" + }] + } + } }, - "gateway": {"port": 18789} - }` - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {stdout: userCfgPath}, - "config get --json": {stdout: resolvedUser}, - "config get agents.list --json": {stdout: "null"}, - }) - mcpConfig := json.RawMessage(`{"mcpServers": {"managed_only": {"command": "uvx", "args": ["m"]}}}`) - - result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{ - OpenclawBin: stub.bin, - McpConfig: mcpConfig, - }) + "gateway":{ + "host":"127.0.0.1", + "port":18789, + "tls":true, + "auth":{"mode":"token","token":"gateway-token-secret","password":"gateway-password-secret"}, + "headers":{"Authorization":"Bearer gateway-header-secret"} + } + }`) + envRoot := t.TempDir() + result, err := prepareOpenclawConfig(envRoot, filepath.Join(envRoot, "workdir"), OpenclawConfigPrep{}) if err != nil { t.Fatalf("prepareOpenclawConfig: %v", err) } - snapPath := filepath.Join(envRoot, openclawUserSnapshotFile) - snap := mustReadJSON(t, snapPath) - snapMcp, ok := snap["mcp"].(map[string]any) - if !ok { - t.Fatalf("snapshot lost the mcp block entirely; mcp.sessionIdleTtlMs should have survived: %v", snap) - } - if _, leaked := snapMcp["servers"]; leaked { - t.Errorf("snapshot still has mcp.servers; strict scope must drop it: %v", snapMcp) - } - // json.Unmarshal decodes JSON numbers as float64. - if ttl, ok := snapMcp["sessionIdleTtlMs"].(float64); !ok || ttl != 300000 { - t.Errorf("snapshot lost mcp.sessionIdleTtlMs (should be preserved): %v", snapMcp) - } - - // Wrapper still emits the managed-only server set on top, so the - // effective view post-include is exactly the managed set. - got := mustReadJSON(t, result.ConfigPath) - wrapperMcp, _ := got["mcp"].(map[string]any) - servers, _ := wrapperMcp["servers"].(map[string]any) - if _, ok := servers["managed_only"]; !ok { - t.Errorf("wrapper missing managed_only: %v", servers) - } - if _, leaked := servers["global_one"]; leaked { - t.Errorf("global_one leaked into wrapper: %v", servers) - } -} - -// TestPrepareOpenclawConfigStrictEmptyManagedSetDropsUserMcp — empty -// managed set `{}` must drop the user's global mcp.servers too. Without -// strict replace, OpenClaw would still resolve user-only servers via the -// $include and the admin's "saved no servers" intent would be silently -// overridden. -func TestPrepareOpenclawConfigStrictEmptyManagedSetDropsUserMcp(t *testing.T) { - envRoot := t.TempDir() - workDir := filepath.Join(envRoot, "workdir") - if err := os.MkdirAll(workDir, 0o755); err != nil { - t.Fatalf("mkdir workdir: %v", err) - } - userCfgPath := filepath.Join(t.TempDir(), "openclaw.json") - if err := os.WriteFile(userCfgPath, []byte(`{}`), 0o600); err != nil { - t.Fatalf("write user cfg: %v", err) + combined := readOpenclawTaskFiles(t, result.ConfigPath, envRoot) + for _, secret := range []string{ + "models-root-secret", "models-provider-secret", "models-header-secret", "model-entry-secret", + "gateway-token-secret", "gateway-password-secret", "gateway-header-secret", + } { + if bytes.Contains(combined, []byte(secret)) { + t.Errorf("owner credential %q leaked: %s", secret, combined) + } } - resolvedUser := `{"mcp": {"servers": {"global_one": {"command": "/bin/echo"}}}}` - cases := map[string]json.RawMessage{ - "object_empty": json.RawMessage(`{}`), - "mcp_servers_empty_map": json.RawMessage(`{"mcpServers": {}}`), + snapshot := mustReadOpenclawJSON(t, filepath.Join(envRoot, openclawUserSnapshotFile)) + models := snapshot["models"].(map[string]any) + if models["default"] != "openai/gpt-5" || models["mode"] != "merge" { + t.Fatalf("public model selection metadata was not retained: %v", models) } - for name, raw := range cases { - t.Run(name, func(t *testing.T) { - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {stdout: userCfgPath}, - "config get --json": {stdout: resolvedUser}, - "config get agents.list --json": {stdout: "null"}, - }) - result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{ - OpenclawBin: stub.bin, - McpConfig: raw, - }) - if err != nil { - t.Fatalf("prepareOpenclawConfig: %v", err) - } - got := mustReadJSON(t, result.ConfigPath) - mcp, ok := got["mcp"].(map[string]any) - if !ok { - t.Fatalf("wrapper missing mcp block (managed empty must still be present): %v", got) - } - servers, ok := mcp["servers"].(map[string]any) - if !ok { - t.Fatalf("mcp.servers is not an object: %v", mcp) - } - if len(servers) != 0 { - t.Errorf("mcp.servers has %d entries on managed-empty, want 0 (global_one must not leak): %v", len(servers), servers) - } - // And the snapshot must have dropped the user's mcp block, so the - // $include resolves with no mcp at all. - snapPath := filepath.Join(envRoot, openclawUserSnapshotFile) - snap := mustReadJSON(t, snapPath) - if _, present := snap["mcp"]; present { - t.Errorf("snapshot still has `mcp` — strict empty must drop the user block: %v", snap["mcp"]) - } - }) + providers := models["providers"].(map[string]any) + openai := providers["openai"].(map[string]any) + if openai["baseUrl"] != "https://api.openai.example/v1" { + t.Fatalf("public model provider routing metadata was not retained: %v", openai) } -} - -// TestPrepareOpenclawConfigNullMcpConfigKeepsUserInclude — when the agent -// has no managed mcp_config (`null` / absent), the wrapper must NOT write -// a sanitized snapshot and must $include the live user config so the -// user's global mcp.servers and other config still flow through. This is -// the "inherit defaults" branch — must remain a no-op vs. the previous -// implementation. -func TestPrepareOpenclawConfigNullMcpConfigKeepsUserInclude(t *testing.T) { - envRoot := t.TempDir() - workDir := filepath.Join(envRoot, "workdir") - if err := os.MkdirAll(workDir, 0o755); err != nil { - t.Fatalf("mkdir workdir: %v", err) - } - userCfgDir := t.TempDir() - userCfgPath := filepath.Join(userCfgDir, "openclaw.json") - if err := os.WriteFile(userCfgPath, []byte(`{}`), 0o600); err != nil { - t.Fatalf("write user cfg: %v", err) + model := openai["models"].([]any)[0].(map[string]any) + if model["id"] != "gpt-5" || model["reasoning"] != true || model["contextWindow"] != float64(400000) { + t.Fatalf("public model catalog metadata was not retained: %v", model) } - cases := map[string]json.RawMessage{ - "nil": nil, - "empty": json.RawMessage(""), - "null": json.RawMessage("null"), - } - for name, raw := range cases { - t.Run(name, func(t *testing.T) { - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {stdout: userCfgPath}, - "config get agents.list --json": {stdout: "null"}, - // Note: no `config get --json` stub — the inherit path must - // not call it (would burn an extra CLI roundtrip per task). - }) - result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{ - OpenclawBin: stub.bin, - McpConfig: raw, - }) - if err != nil { - t.Fatalf("prepareOpenclawConfig: %v", err) - } - got := mustReadJSON(t, result.ConfigPath) - if _, present := got["mcp"]; present { - t.Errorf("wrapper has mcp block when mcp_config = %q: %v", name, got["mcp"]) - } - include, _ := got["$include"].([]any) - if len(include) != 1 || include[0] != userCfgPath { - t.Errorf("$include = %v, want live user config %q on inherit path", got["$include"], userCfgPath) - } - if _, err := os.Stat(filepath.Join(envRoot, openclawUserSnapshotFile)); !os.IsNotExist(err) { - t.Errorf("inherit path wrote a snapshot file (should not): err=%v", err) - } - if result.IncludeRoot != userCfgDir { - t.Errorf("IncludeRoot = %q, want %q (cross-dir hop for live $include)", result.IncludeRoot, userCfgDir) - } - }) + gateway := snapshot["gateway"].(map[string]any) + wantGateway := map[string]any{"host": "127.0.0.1", "port": float64(18789), "tls": true} + if !reflect.DeepEqual(gateway, wantGateway) { + t.Fatalf("gateway = %#v, want public projection %#v", gateway, wantGateway) } } -// TestPrepareOpenclawConfigManagedSetFreshInstall — managed mcp_config on -// a fresh install (no on-disk user config) must NOT call `config get -// --json` (there is nothing to snapshot) and must write a wrapper that -// carries managed servers as the sole MCP definition with no $include. -func TestPrepareOpenclawConfigManagedSetFreshInstall(t *testing.T) { +func TestPrepareOpenclawConfigManagedMCPIsTaskAuthoritative(t *testing.T) { + writeOwnerOpenclawConfig(t, `{"mcp":{"servers":{"owner":{"command":"owner-tool"}}}}`) envRoot := t.TempDir() - workDir := filepath.Join(envRoot, "workdir") - if err := os.MkdirAll(workDir, 0o755); err != nil { - t.Fatalf("mkdir workdir: %v", err) - } - missingPath := filepath.Join(t.TempDir(), "openclaw.json") - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {stdout: missingPath}, - // No `config get --json` stub — fresh install must not call it. - }) - mcpConfig := json.RawMessage(`{"mcpServers": {"context7": {"command": "uvx", "args": ["context7-mcp"]}}}`) - - result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{ - OpenclawBin: stub.bin, - McpConfig: mcpConfig, - }) + managed := json.RawMessage(`{"mcpServers":{"task":{"command":"task-tool","args":["serve"]}}}`) + result, err := prepareOpenclawConfig(envRoot, filepath.Join(envRoot, "workdir"), OpenclawConfigPrep{McpConfig: managed}) if err != nil { t.Fatalf("prepareOpenclawConfig: %v", err) } - got := mustReadJSON(t, result.ConfigPath) - mcp, ok := got["mcp"].(map[string]any) - if !ok { - t.Fatalf("wrapper missing mcp block: %v", got) - } - servers, ok := mcp["servers"].(map[string]any) - if !ok { - t.Fatalf("mcp.servers is not an object: %v", mcp) - } - entry, _ := servers["context7"].(map[string]any) - if entry == nil || entry["command"] != "uvx" { - t.Errorf("context7 entry missing/wrong on fresh install: %v", servers) - } - args, _ := entry["args"].([]any) - if len(args) != 1 || args[0] != "context7-mcp" { - t.Errorf("context7.args = %v", args) - } - if _, present := got["$include"]; present { - t.Errorf("fresh install should not emit $include: %v", got["$include"]) - } -} - -// TestPrepareOpenclawConfigFailsClosedOnResolvedConfigError — when the -// user has a config on disk and the agent has managed mcp_config but -// `openclaw config get --json` errors, the preparer must NOT fall back to -// `$include`ing the live user file (which would leak global mcp.servers). -// Fail closed instead, mirroring the existing fail-closed posture. -func TestPrepareOpenclawConfigFailsClosedOnResolvedConfigError(t *testing.T) { - envRoot := t.TempDir() - workDir := filepath.Join(envRoot, "workdir") - if err := os.MkdirAll(workDir, 0o755); err != nil { - t.Fatalf("mkdir workdir: %v", err) - } - userCfgPath := filepath.Join(t.TempDir(), "openclaw.json") - if err := os.WriteFile(userCfgPath, []byte(`{}`), 0o600); err != nil { - t.Fatalf("write user cfg: %v", err) - } - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {stdout: userCfgPath}, - "config get agents.list --json": {stdout: "null"}, - "config get --json": {err: errors.New("openclaw: schema validation failed")}, - }) - mcpConfig := json.RawMessage(`{"mcpServers": {"context7": {"command": "uvx"}}}`) - - _, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{ - OpenclawBin: stub.bin, - McpConfig: mcpConfig, - }) - if err == nil { - t.Fatal("prepareOpenclawConfig succeeded when `config get --json` errored; expected fail closed") - } - if !strings.Contains(err.Error(), "resolved config") { - t.Errorf("error %q does not name the resolved-config step", err.Error()) - } - // No stale wrapper / snapshot left behind. - if _, err := os.Stat(filepath.Join(envRoot, openclawConfigFile)); !os.IsNotExist(err) { - t.Errorf("wrapper exists after fail-closed: %v", err) - } - if _, err := os.Stat(filepath.Join(envRoot, openclawUserSnapshotFile)); !os.IsNotExist(err) { - t.Errorf("snapshot exists after fail-closed: %v", err) + raw := readOpenclawTaskFiles(t, result.ConfigPath, envRoot) + if !bytes.Contains(raw, []byte("task-tool")) || bytes.Contains(raw, []byte("owner-tool")) { + t.Fatalf("managed MCP isolation failed: %s", raw) } } -// TestPrepareOpenclawConfigFailsClosedOnMalformedMcpConfig — keeping with -// the fail-closed posture used for the rest of the preparer: a malformed -// mcp_config must not write any wrapper file, so the daemon surfaces the -// error instead of booting OpenClaw with an empty / inherited MCP set the -// admin didn't expect. -func TestPrepareOpenclawConfigFailsClosedOnMalformedMcpConfig(t *testing.T) { - envRoot := t.TempDir() - workDir := filepath.Join(envRoot, "workdir") - if err := os.MkdirAll(workDir, 0o755); err != nil { - t.Fatalf("mkdir workdir: %v", err) - } - userCfgPath := filepath.Join(t.TempDir(), "openclaw.json") - if err := os.WriteFile(userCfgPath, []byte(`{}`), 0o600); err != nil { - t.Fatalf("write user cfg: %v", err) - } - - cases := map[string]json.RawMessage{ - "unparseable_json": json.RawMessage(`{not-json}`), - "entry_missing_command": json.RawMessage(`{"mcpServers": {"bad": {}}}`), - "entry_wrong_shape": json.RawMessage(`{"mcpServers": {"bad": "not-an-object"}}`), +func TestPrepareOpenclawConfigFailsClosedOnUnsupportedOwnerConfig(t *testing.T) { + cases := map[string]string{ + "json5 comment": "// comment\n{}", + "trailing comma": `{"gateway":{"port":18789,}}`, + "include": `{"$include":["base.json"]}`, + "env substitution": `{"providers":{"openai":{"apiKey":"${OPENAI_API_KEY}"}}}`, + "absolute allowlist": `{"providers":{"local":{"baseUrl":"/Users/owner/provider.sock"}}}`, } - for name, raw := range cases { + for name, body := range cases { t.Run(name, func(t *testing.T) { - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {stdout: userCfgPath}, - "config get agents.list --json": {stdout: "null"}, - }) - _, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{ - OpenclawBin: stub.bin, - McpConfig: raw, - }) + writeOwnerOpenclawConfig(t, body) + envRoot := t.TempDir() + _, err := prepareOpenclawConfig(envRoot, filepath.Join(envRoot, "workdir"), OpenclawConfigPrep{}) if err == nil { - t.Fatalf("prepareOpenclawConfig succeeded on %s; expected fail closed", name) + t.Fatal("prepare succeeded, want fail closed") } - if !strings.Contains(err.Error(), "mcp_config") && !strings.Contains(err.Error(), "mcp_servers") { - t.Errorf("error %q does not name the mcp_config step", err.Error()) + if _, statErr := os.Stat(filepath.Join(envRoot, openclawConfigFile)); !os.IsNotExist(statErr) { + t.Fatalf("wrapper written after failure: %v", statErr) } }) } } -// TestPrepareOpenclawSkillWriteMatchesScanPath is the regression test the -// MUL-2219 DoD calls out: the directory Multica writes skills into MUST be -// the same directory the OpenClaw scanner reads from. We assert this by -// resolving the workspaceDir the way OpenClaw does (agents.defaults.workspace -// from the synthesized config) and proving {workspaceDir}/skills/ holds the -// skill we wrote. Previous fixes asserted "we wrote a file" without checking -// the scanner would ever see it; that is why MUL-2213 / #2621 needed a -// follow-up. -func TestPrepareOpenclawSkillWriteMatchesScanPath(t *testing.T) { - envRoot := t.TempDir() - workDir := filepath.Join(envRoot, "workdir") - for _, sub := range []string{workDir, filepath.Join(envRoot, "output"), filepath.Join(envRoot, "logs")} { - if err := os.MkdirAll(sub, 0o755); err != nil { - t.Fatalf("mkdir %s: %v", sub, err) - } +func TestPrepareOpenclawConfigRejectsSymlinkSource(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink fixture may require privileges") } - - stub := installOpenclawStub(t, map[string]openclawResponse{ - // Fresh install — no user config on disk. Wrapper carries only the - // workspace override, which is what the scanner reads. - "config file": {stdout: filepath.Join(t.TempDir(), "absent-openclaw.json")}, - }) - - skills := []SkillContextForEnv{ - {Name: "Issue Review", Content: "Review issues thoroughly."}, - {Name: "Local Dev", Content: "Spin up the local dev env."}, - } - - result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin}) - if err != nil { - t.Fatalf("prepareOpenclawConfig: %v", err) + target := filepath.Join(t.TempDir(), "owner.json") + if err := os.WriteFile(target, []byte(`{}`), 0o600); err != nil { + t.Fatal(err) } - cfgPath := result.ConfigPath - if err := writeContextFiles(workDir, "openclaw", TaskContextForEnv{ - IssueID: "issue-1", - AgentSkills: skills, - }, nil); err != nil { - t.Fatalf("writeContextFiles: %v", err) + link := filepath.Join(t.TempDir(), "openclaw.json") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) } - - cfg := mustReadJSON(t, cfgPath) - wsDir := cfg["agents"].(map[string]any)["defaults"].(map[string]any)["workspace"].(string) - for _, s := range skills { - want := filepath.Join(wsDir, "skills", sanitizeSkillName(s.Name), "SKILL.md") - if _, err := os.Stat(want); err != nil { - t.Errorf("openclaw scan target %s missing — Multica's write path and the openclaw scanner are out of sync: %v", want, err) - } + t.Setenv("OPENCLAW_CONFIG_PATH", link) + _, err := prepareOpenclawConfig(t.TempDir(), t.TempDir(), OpenclawConfigPrep{}) + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("error = %v, want symlink rejection", err) } } -// TestPrepareEnvironmentOpenclawWiresConfigPath — end-to-end: Prepare sets -// env.OpenclawConfigPath so the daemon can export OPENCLAW_CONFIG_PATH, and -// the path resolves to a file with the correct workspace override. With -// fail-closed semantics, Prepare itself errors when the CLI is unavailable; -// a stub here keeps the happy path observable. -func TestPrepareEnvironmentOpenclawWiresConfigPath(t *testing.T) { - wsRoot := t.TempDir() - - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {stdout: filepath.Join(t.TempDir(), "absent.json")}, - }) - - env, err := Prepare(PrepareParams{ - WorkspacesRoot: wsRoot, - WorkspaceID: "ws-1", - TaskID: "11111111-2222-3333-4444-555555555555", - AgentName: "scout", - Provider: "openclaw", - OpenclawBin: stub.bin, - Task: TaskContextForEnv{ - IssueID: "issue-1", - }, - }, slog.New(slog.NewTextHandler(io.Discard, nil))) +func TestPrepareOpenclawConfigFreshInstall(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("OPENCLAW_HOME", "") + t.Setenv("OPENCLAW_CONFIG_PATH", "") + t.Setenv("CLAWDBOT_CONFIG_PATH", "") + t.Setenv("OPENCLAW_STATE_DIR", "") + t.Setenv("CLAWDBOT_STATE_DIR", "") + envRoot := t.TempDir() + workDir := filepath.Join(envRoot, "workdir") + result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{}) if err != nil { - t.Fatalf("Prepare: %v", err) - } - if env.OpenclawConfigPath == "" { - t.Fatal("Prepare(openclaw) did not set OpenclawConfigPath") + t.Fatalf("prepareOpenclawConfig: %v", err) } - got := mustReadJSON(t, env.OpenclawConfigPath) - workspace := got["agents"].(map[string]any)["defaults"].(map[string]any)["workspace"] - if workspace != env.WorkDir { - t.Errorf("agents.defaults.workspace = %v, want %q", workspace, env.WorkDir) + wrapper := mustReadOpenclawJSON(t, result.ConfigPath) + if _, ok := wrapper["$include"]; ok { + t.Fatalf("fresh install has include: %v", wrapper) } - // Fresh install path emits no $include, so the Environment should - // leave OpenclawIncludeRoot empty — the daemon must NOT spuriously - // grant include roots when no cross-dir hop is being made. - if env.OpenclawIncludeRoot != "" { - t.Errorf("OpenclawIncludeRoot = %q on fresh install, want empty", env.OpenclawIncludeRoot) + if wrapper["agents"].(map[string]any)["defaults"].(map[string]any)["workspace"] != workDir { + t.Fatalf("workspace not pinned: %v", wrapper) } } -// TestPrepareEnvironmentOpenclawWiresIncludeRoot — when the user has an -// on-disk active config (the common non-fresh-install case), Prepare must -// surface the active config's dirname on the Environment so the daemon -// can export OPENCLAW_INCLUDE_ROOTS. Without this, the wrapper's -// $include into ~/.openclaw/openclaw.json is rejected at runtime. -func TestPrepareEnvironmentOpenclawWiresIncludeRoot(t *testing.T) { - wsRoot := t.TempDir() - - userCfgDir := t.TempDir() - userCfgPath := filepath.Join(userCfgDir, "openclaw.json") - if err := os.WriteFile(userCfgPath, []byte(`{}`), 0o600); err != nil { - t.Fatalf("write user cfg: %v", err) - } - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {stdout: userCfgPath}, - "config get agents.list --json": {stdout: "null"}, - }) - +func TestPrepareEnvironmentOpenclawWiresPrivateConfigOnly(t *testing.T) { + writeOwnerOpenclawConfig(t, `{"agents":{"defaults":{"model":"openai/gpt-5"}},"tools":{"command":"owner-tool"}}`) env, err := Prepare(PrepareParams{ - WorkspacesRoot: wsRoot, - WorkspaceID: "ws-1", - TaskID: "33333333-2222-3333-4444-555555555555", - AgentName: "scout", - Provider: "openclaw", - OpenclawBin: stub.bin, - Task: TaskContextForEnv{IssueID: "issue-1"}, + WorkspacesRoot: t.TempDir(), WorkspaceID: "ws-1", + TaskID: "11111111-2222-3333-4444-555555555555", AgentName: "scout", + Provider: "openclaw", OpenclawBin: "/malicious/not-invoked", + Task: TaskContextForEnv{IssueID: "issue-1"}, }, slog.New(slog.NewTextHandler(io.Discard, nil))) if err != nil { t.Fatalf("Prepare: %v", err) } - if env.OpenclawIncludeRoot != userCfgDir { - t.Errorf("OpenclawIncludeRoot = %q, want %q (dirname of active config so daemon can grant OPENCLAW_INCLUDE_ROOTS)", env.OpenclawIncludeRoot, userCfgDir) - } -} - -// TestPrepareEnvironmentOpenclawFailsClosed — when the openclaw CLI errors -// during Prepare, the whole call must fail. Previously the preparer logged -// a warning and continued with no config; we have removed that path. -func TestPrepareEnvironmentOpenclawFailsClosed(t *testing.T) { - wsRoot := t.TempDir() - - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {err: errors.New("openclaw config validation failed")}, - }) - - _, err := Prepare(PrepareParams{ - WorkspacesRoot: wsRoot, - WorkspaceID: "ws-1", - TaskID: "22222222-2222-3333-4444-555555555555", - AgentName: "scout", - Provider: "openclaw", - OpenclawBin: stub.bin, - Task: TaskContextForEnv{IssueID: "issue-1"}, - }, slog.New(slog.NewTextHandler(io.Discard, nil))) - if err == nil { - t.Fatal("Prepare(openclaw) succeeded when CLI errored; expected fail closed") - } - if !strings.Contains(err.Error(), "prepare openclaw config") { - t.Errorf("error message %q does not name the openclaw config step", err.Error()) + if env.OpenclawConfigPath == "" || !strings.HasPrefix(env.OpenclawConfigPath, env.RootDir+string(os.PathSeparator)) { + t.Fatalf("OpenclawConfigPath = %q, root = %q", env.OpenclawConfigPath, env.RootDir) } -} - -// TestPrepareEnvironmentNonOpenclawSkipsConfig — non-openclaw providers -// must not get a synthesized openclaw config (it would be dead weight on -// disk and confuse the GC reaper's idea of what an env contains). They -// also must NOT shell out to the openclaw CLI, so the stub here records -// zero calls. -func TestPrepareEnvironmentNonOpenclawSkipsConfig(t *testing.T) { - wsRoot := t.TempDir() - - stub := installOpenclawStub(t, map[string]openclawResponse{}) - - taskIDs := map[string]string{ - "claude": "aaaaaaaa-1111-2222-3333-444444444444", - "opencode": "bbbbbbbb-1111-2222-3333-444444444444", - "hermes": "cccccccc-1111-2222-3333-444444444444", - "kiro": "dddddddd-1111-2222-3333-444444444444", - } - for provider, taskID := range taskIDs { - t.Run(provider, func(t *testing.T) { - env, err := Prepare(PrepareParams{ - WorkspacesRoot: wsRoot, - WorkspaceID: "ws-1", - TaskID: taskID, - AgentName: "scout", - Provider: provider, - Task: TaskContextForEnv{IssueID: "issue-1"}, - }, slog.New(slog.NewTextHandler(io.Discard, nil))) - if err != nil { - t.Fatalf("Prepare(%s): %v", provider, err) - } - if env.OpenclawConfigPath != "" { - t.Errorf("provider %s should not get an OpenclawConfigPath, got %q", provider, env.OpenclawConfigPath) - } - if _, err := os.Stat(filepath.Join(env.RootDir, openclawConfigFile)); !os.IsNotExist(err) { - t.Errorf("provider %s left a stray openclaw-config.json", provider) - } - }) - } - if len(stub.calls) != 0 { - t.Errorf("non-openclaw providers shelled out to openclaw CLI %d times: %+v", len(stub.calls), stub.calls) - } -} - -// ── Gateway endpoint pinning (issue #3260) ── -// -// When a multica agent is configured for gateway-mode openclaw and the -// runtime_config carries a Gateway endpoint, the per-task wrapper must pin -// that endpoint in its `gateway` block. OpenClaw deep-merges sibling object -// keys after $include, so the wrapper's `gateway.*` settings override -// whatever the user's global openclaw.json carried. - -func TestBuildPerTaskOpenclawConfigOmitsGatewayWhenZero(t *testing.T) { - t.Parallel() - - cfg := buildPerTaskOpenclawConfig( - "", false, "", nil, false, "/workdir", nil, false, - OpenclawGatewayPin{}, - ) - if _, present := cfg["gateway"]; present { - t.Errorf("zero gateway must not emit a gateway block, got %v", cfg["gateway"]) - } -} - -func TestBuildPerTaskOpenclawConfigWritesGatewayBlock(t *testing.T) { - t.Parallel() - - pin := OpenclawGatewayPin{ - Host: "gw.internal", - Port: 18789, - Token: "secret-token", - TLS: true, - } - cfg := buildPerTaskOpenclawConfig( - "", false, "", nil, false, "/workdir", nil, false, - pin, - ) - - gw, ok := cfg["gateway"].(map[string]any) - if !ok { - t.Fatalf("expected gateway map, got %T: %v", cfg["gateway"], cfg["gateway"]) - } - if gw["host"] != "gw.internal" { - t.Errorf("gateway.host = %v, want %q", gw["host"], "gw.internal") - } - if gw["port"] != 18789 { - t.Errorf("gateway.port = %v, want %d", gw["port"], 18789) - } - // Token nests under gateway.auth.{mode,token} to match OpenClaw's own - // config shape (see ~/.openclaw/openclaw.json `gateway.auth`). - auth, ok := gw["auth"].(map[string]any) - if !ok { - t.Fatalf("expected gateway.auth map, got %T: %v", gw["auth"], gw["auth"]) - } - if auth["mode"] != "token" { - t.Errorf("gateway.auth.mode = %v, want %q", auth["mode"], "token") - } - if auth["token"] != "secret-token" { - t.Errorf("gateway.auth.token = %v, want %q", auth["token"], "secret-token") - } - if gw["tls"] != true { - t.Errorf("gateway.tls = %v, want true", gw["tls"]) - } -} - -func TestBuildPerTaskOpenclawConfigPartialGatewayOmitsZeroFields(t *testing.T) { - t.Parallel() - - // Users may pin only host/port and rely on the user's local openclaw.json - // for the token (which still flows in via the $include). Zero-valued - // fields must not land in the wrapper as empty strings/zeros — that - // would override the user's value with junk. - cfg := buildPerTaskOpenclawConfig( - "", false, "", nil, false, "/workdir", nil, false, - OpenclawGatewayPin{Host: "gw.internal", Port: 18789}, - ) - gw := cfg["gateway"].(map[string]any) - if _, present := gw["auth"]; present { - t.Errorf("auth block must be omitted when token is empty, got %v", gw["auth"]) - } - if _, present := gw["tls"]; present { - t.Errorf("tls field must be omitted when false, got %v", gw["tls"]) - } -} - -// TestIsOpenclawKeyMissing covers the "key not found" wordings the CLI has -// emitted across versions. The 2026.6.x string ("Config path not found: -// agents.list", lowercase "path") is the regression from upstream #3028: -// the matcher used to compare case-sensitively against "Path not found" and -// silently stopped recognizing this, turning the intended graceful-skip -// into a fail-closed error that broke every OpenClaw 2026.6.x runtime. -func TestIsOpenclawKeyMissing(t *testing.T) { - t.Parallel() - cases := []struct { - name string - err error - want bool - }{ - {"nil", nil, false}, - {"pre-2026.6 No value at", errors.New("openclaw: No value at agents.list"), true}, - {"pre-2026.6 Path not found", errors.New("openclaw config get agents.list --json: Path not found"), true}, - {"not set", errors.New("agents.list is not set"), true}, - {"missing key", errors.New("missing key: agents.list"), true}, - { - "2026.6.x Config path not found (verbatim #3028)", - errors.New("openclaw config get agents.list --json: exit status 1 (stderr: Config path not found: agents.list. Run openclaw config validate to inspect config shape.)"), - true, - }, - {"real failure stays an error", errors.New("openclaw: failed to read config: permission denied"), false}, - {"malformed json is not a missing key", errors.New("parse output: invalid character 'x'"), false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := isOpenclawKeyMissing(tc.err); got != tc.want { - t.Errorf("isOpenclawKeyMissing(%v) = %v, want %v", tc.err, got, tc.want) - } - }) - } -} - -// TestPrepareOpenclawConfigNewSchemaOmitsAgentsList — OpenClaw 2026.6.x -// removed the `agents.list` config path; `config get agents.list` exits -// non-zero with "Config path not found" and the agents live in a sqlite -// registry reachable via the `openclaw agents list --json` subcommand. -// -// The preparer must (a) treat the config-path error as "missing, fall back" -// (read-side, #3028 first half) and (b) NOT write the registry-sourced agents -// back into the wrapper as `agents.list` (write-side, #3028 second half). -// `agents.list` is not a valid 2026.6.x config path — its schema validator -// rejects the registry shape ("agents.list.0: Invalid input") and fails -// closed before the agent runs. Per-task workspace pinning for the new schema -// rides on `agents.defaults.workspace` alone, which OpenClaw applies to the -// agent it selects from the registry. -func TestPrepareOpenclawConfigNewSchemaOmitsAgentsList(t *testing.T) { - envRoot := t.TempDir() - workDir := filepath.Join(envRoot, "workdir") - if err := os.MkdirAll(workDir, 0o755); err != nil { - t.Fatalf("mkdir workdir: %v", err) - } - userConfigPath := filepath.Join(t.TempDir(), "openclaw.json") - if err := os.WriteFile(userConfigPath, []byte(`{}`), 0o600); err != nil { - t.Fatalf("write user cfg: %v", err) - } - - // Real registry shape from `openclaw agents list --json` on 2026.6.8 — - // carries CLI-only fields (identityName, agentDir, bindings, isDefault) - // that the config schema rejects if written back as agents.list[]. - registry := `[{"id":"main","identityName":"Beau","identitySource":"identity","workspace":"/Users/cob/.openclaw/workspace","agentDir":"/Users/cob/.openclaw/agents/main/agent","model":"anthropic/claude-sonnet-4-6","bindings":0,"isDefault":true}]` - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {stdout: userConfigPath}, - // New-schema error, verbatim #3028 string. - "config get agents.list --json": {err: errors.New("openclaw config get agents.list --json: exit status 1 (stderr: Config path not found: agents.list. Run openclaw config validate to inspect config shape.)")}, - // Registry subcommand returns the real agents. - "agents list --json": {stdout: registry}, - }) - - result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin}) - if err != nil { - t.Fatalf("prepareOpenclawConfig: %v", err) - } - got := mustReadJSON(t, result.ConfigPath) - agents := got["agents"].(map[string]any) - if agents["defaults"].(map[string]any)["workspace"] != workDir { - t.Errorf("defaults.workspace not pinned to workDir") - } - if _, present := agents["list"]; present { - t.Fatalf("agents.list must be omitted for a registry-sourced (2026.6.x) host — OpenClaw rejects it; got %v", agents["list"]) + for _, value := range []string{env.RootDir, env.HomeDir, env.ConfigDir, env.WorkDir, env.OpenclawConfigPath} { + if strings.Contains(value, "/Users/owner") { + t.Fatalf("environment retained owner path: %+v", env) + } } } -// TestPrepareOpenclawConfigNewSchemaEmptyRegistry — new-schema config-path -// error plus an empty registry (`[]`) is the 2026.6.x equivalent of "no -// agents.list": emit defaults.workspace only, omit agents.list, no error. -func TestPrepareOpenclawConfigNewSchemaEmptyRegistry(t *testing.T) { - envRoot := t.TempDir() - workDir := filepath.Join(envRoot, "workdir") - if err := os.MkdirAll(workDir, 0o755); err != nil { - t.Fatalf("mkdir workdir: %v", err) - } - userConfigPath := filepath.Join(t.TempDir(), "openclaw.json") - if err := os.WriteFile(userConfigPath, []byte(`{}`), 0o600); err != nil { - t.Fatalf("write user cfg: %v", err) - } - - stub := installOpenclawStub(t, map[string]openclawResponse{ - "config file": {stdout: userConfigPath}, - "config get agents.list --json": {err: errors.New("Config path not found: agents.list")}, - "agents list --json": {stdout: "[]"}, +func TestBuildPerTaskOpenclawConfigGateway(t *testing.T) { + config := buildPerTaskOpenclawConfig("", "/workdir", nil, false, OpenclawGatewayPin{ + Host: "gw.internal", Port: 18789, Token: "secret-token", TLS: true, }) - - result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin}) - if err != nil { - t.Fatalf("prepareOpenclawConfig: %v", err) - } - got := mustReadJSON(t, result.ConfigPath) - agents := got["agents"].(map[string]any) - if _, present := agents["list"]; present { - t.Errorf("agents.list should be omitted for empty registry, got %v", agents["list"]) + gateway := config["gateway"].(map[string]any) + if gateway["host"] != "gw.internal" || gateway["port"] != 18789 || gateway["tls"] != true { + t.Fatalf("gateway pin = %v", gateway) } - if agents["defaults"].(map[string]any)["workspace"] != workDir { - t.Errorf("defaults.workspace not set") + auth := gateway["auth"].(map[string]any) + if auth["mode"] != "token" || auth["token"] != "secret-token" { + t.Fatalf("gateway auth = %v", auth) } } diff --git a/server/internal/daemon/execenv/private_snapshot.go b/server/internal/daemon/execenv/private_snapshot.go new file mode 100644 index 00000000000..c5954a26c58 --- /dev/null +++ b/server/internal/daemon/execenv/private_snapshot.go @@ -0,0 +1,198 @@ +package execenv + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "reflect" +) + +const ( + codexAuthSnapshotMaxBytes = 4 << 20 + cursorAuthSnapshotMaxBytes = 16 << 20 + openclawSnapshotMaxBytes = 16 << 20 +) + +func snapshotRegularFile(source, target string, maxBytes int64) error { + data, err := readStableRegularFile(source, maxBytes, nil) + if err != nil { + return err + } + return writePrivateSnapshot(target, data, maxBytes) +} + +// readStableRegularFile rejects path indirection and verifies that the opened +// file retains one identity and metadata tuple for the entire bounded read. +// afterOpen is only used by deterministic race tests. +func readStableRegularFile(source string, maxBytes int64, afterOpen func()) ([]byte, error) { + before, err := os.Lstat(source) + if err != nil { + return nil, fmt.Errorf("inspect snapshot source: %w", err) + } + if err := validateSnapshotSource(before, maxBytes); err != nil { + return nil, err + } + + in, err := os.Open(source) + if err != nil { + return nil, fmt.Errorf("open snapshot source: %w", err) + } + defer in.Close() + + opened, err := in.Stat() + if err != nil { + return nil, fmt.Errorf("inspect opened snapshot source: %w", err) + } + if !os.SameFile(before, opened) { + return nil, fmt.Errorf("snapshot source identity changed before read") + } + if err := validateSnapshotSource(opened, maxBytes); err != nil { + return nil, err + } + if afterOpen != nil { + afterOpen() + } + + data, err := io.ReadAll(io.LimitReader(in, maxBytes+1)) + if err != nil { + return nil, fmt.Errorf("read snapshot source: %w", err) + } + if int64(len(data)) > maxBytes { + return nil, fmt.Errorf("snapshot source exceeds %d bytes", maxBytes) + } + + after, err := in.Stat() + if err != nil { + return nil, fmt.Errorf("reinspect snapshot source: %w", err) + } + pathAfter, err := os.Lstat(source) + if err != nil { + return nil, fmt.Errorf("reinspect snapshot source path: %w", err) + } + if !sameSnapshotIdentity(opened, after) || !sameSnapshotIdentity(opened, pathAfter) { + return nil, fmt.Errorf("snapshot source identity changed during read") + } + if err := validateSnapshotSource(after, maxBytes); err != nil { + return nil, err + } + if after.Size() != int64(len(data)) { + return nil, fmt.Errorf("snapshot source size changed during read") + } + return data, nil +} + +func validateSnapshotSource(info fs.FileInfo, maxBytes int64) error { + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("snapshot source must not be a symlink") + } + if !info.Mode().IsRegular() { + return fmt.Errorf("snapshot source must be a regular file") + } + if info.Size() < 0 || info.Size() > maxBytes { + return fmt.Errorf("snapshot source exceeds %d bytes", maxBytes) + } + links, ok := snapshotLinkCount(info) + if !ok { + return fmt.Errorf("snapshot source link count is unavailable") + } + if links != 1 { + return fmt.Errorf("snapshot source must have exactly one hard link") + } + return nil +} + +func sameSnapshotIdentity(a, b fs.FileInfo) bool { + return os.SameFile(a, b) && + a.Mode() == b.Mode() && + a.Size() == b.Size() && + a.ModTime().Equal(b.ModTime()) +} + +// snapshotLinkCount uses the platform stat object's Nlink field without +// importing an OS-specific syscall package into this cross-platform file. +// Unknown platforms fail closed rather than silently accepting hard links. +func snapshotLinkCount(info fs.FileInfo) (uint64, bool) { + v := reflect.ValueOf(info.Sys()) + if !v.IsValid() { + return 0, false + } + if v.Kind() == reflect.Pointer { + if v.IsNil() { + return 0, false + } + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return 0, false + } + field := v.FieldByName("Nlink") + if !field.IsValid() { + return 0, false + } + switch field.Kind() { + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return field.Uint(), true + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + value := field.Int() + if value < 0 { + return 0, false + } + return uint64(value), true + default: + return 0, false + } +} + +func writePrivateSnapshot(target string, data []byte, maxBytes int64) error { + if int64(len(data)) > maxBytes { + return fmt.Errorf("snapshot data exceeds %d bytes", maxBytes) + } + parent := filepath.Dir(target) + if err := os.MkdirAll(parent, 0o700); err != nil { + return fmt.Errorf("create snapshot directory: %w", err) + } + if info, err := os.Lstat(target); err == nil { + if info.IsDir() { + return fmt.Errorf("snapshot target is a directory") + } + } else if !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("inspect snapshot target: %w", err) + } + + tmp, err := os.CreateTemp(parent, ".snapshot-*") + if err != nil { + return fmt.Errorf("create private snapshot: %w", err) + } + tmpPath := tmp.Name() + cleanup := func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + } + if err := tmp.Chmod(0o600); err != nil { + cleanup() + return fmt.Errorf("secure private snapshot: %w", err) + } + if _, err := tmp.Write(data); err != nil { + cleanup() + return fmt.Errorf("write private snapshot: %w", err) + } + if err := tmp.Sync(); err != nil { + cleanup() + return fmt.Errorf("sync private snapshot: %w", err) + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("close private snapshot: %w", err) + } + if err := os.Rename(tmpPath, target); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("publish private snapshot: %w", err) + } + if err := os.Chmod(target, 0o600); err != nil { + return fmt.Errorf("secure published snapshot: %w", err) + } + return nil +} diff --git a/server/internal/daemon/execenv/private_snapshot_test.go b/server/internal/daemon/execenv/private_snapshot_test.go new file mode 100644 index 00000000000..9a9ea7d922c --- /dev/null +++ b/server/internal/daemon/execenv/private_snapshot_test.go @@ -0,0 +1,129 @@ +package execenv + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestReadStableRegularFileRejectsHardlinkAndNonRegular(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("FIFO fixture is POSIX-only") + } + dir := t.TempDir() + source := filepath.Join(dir, "auth.json") + if err := os.WriteFile(source, []byte(`{"token":"secret"}`), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + if err := os.Link(source, filepath.Join(dir, "auth-hardlink.json")); err != nil { + t.Fatalf("create hardlink: %v", err) + } + if _, err := readStableRegularFile(source, 1024, nil); err == nil || !strings.Contains(err.Error(), "hard link") { + t.Fatalf("hardlinked source error = %v, want hard-link rejection", err) + } + + if _, err := readStableRegularFile(dir, 1024, nil); err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("directory source error = %v, want non-regular rejection", err) + } +} + +func TestReadStableRegularFileRejectsOversizeAndIdentitySwap(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "auth.json") + if err := os.WriteFile(source, bytes.Repeat([]byte("x"), 33), 0o600); err != nil { + t.Fatalf("write oversized source: %v", err) + } + if _, err := readStableRegularFile(source, 32, nil); err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("oversized source error = %v, want size rejection", err) + } + + if err := os.WriteFile(source, []byte(`{"token":"before"}`), 0o600); err != nil { + t.Fatalf("reset source: %v", err) + } + replacement := filepath.Join(dir, "replacement.json") + if err := os.WriteFile(replacement, []byte(`{"token":"after"}`), 0o600); err != nil { + t.Fatalf("write replacement: %v", err) + } + _, err := readStableRegularFile(source, 1024, func() { + if renameErr := os.Rename(replacement, source); renameErr != nil { + t.Fatalf("swap source identity: %v", renameErr) + } + }) + if err == nil || !strings.Contains(err.Error(), "identity changed") { + t.Fatalf("identity swap error = %v, want fail closed", err) + } +} + +func TestReadStableRegularFileRejectsSymlinkSwapAndHardlinkRace(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink fixture requires elevated privileges on some Windows hosts") + } + dir := t.TempDir() + source := filepath.Join(dir, "auth.json") + if err := os.WriteFile(source, []byte(`{"token":"before"}`), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + openedPath := filepath.Join(dir, "opened-auth.json") + _, err := readStableRegularFile(source, 1024, func() { + if renameErr := os.Rename(source, openedPath); renameErr != nil { + t.Fatalf("move opened source: %v", renameErr) + } + if symlinkErr := os.Symlink(openedPath, source); symlinkErr != nil { + t.Fatalf("swap source to symlink: %v", symlinkErr) + } + }) + if err == nil || !strings.Contains(err.Error(), "identity changed") { + t.Fatalf("symlink swap error = %v, want fail closed", err) + } + + if err := os.Remove(source); err != nil { + t.Fatalf("remove swapped symlink: %v", err) + } + if err := os.Rename(openedPath, source); err != nil { + t.Fatalf("restore source: %v", err) + } + hardlink := filepath.Join(dir, "auth-race-link.json") + _, err = readStableRegularFile(source, 1024, func() { + if linkErr := os.Link(source, hardlink); linkErr != nil { + t.Fatalf("create racing hardlink: %v", linkErr) + } + }) + if err == nil || !strings.Contains(err.Error(), "hard link") { + t.Fatalf("hardlink race error = %v, want fail closed", err) + } +} + +func TestWritePrivateSnapshotReplacesSymlinkWithoutFollowing(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink fixture requires elevated privileges on some Windows hosts") + } + dir := t.TempDir() + ownerFile := filepath.Join(dir, "owner.json") + if err := os.WriteFile(ownerFile, []byte("owner-live"), 0o600); err != nil { + t.Fatalf("write owner file: %v", err) + } + target := filepath.Join(dir, "snapshot.json") + if err := os.Symlink(ownerFile, target); err != nil { + t.Fatalf("create stale target symlink: %v", err) + } + if err := writePrivateSnapshot(target, []byte("task-private"), 1024); err != nil { + t.Fatalf("write snapshot: %v", err) + } + info, err := os.Lstat(target) + if err != nil { + t.Fatalf("lstat snapshot: %v", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { + t.Fatalf("snapshot mode = %v, want regular 0600", info.Mode()) + } + ownerData, err := os.ReadFile(ownerFile) + if err != nil { + t.Fatalf("read owner file: %v", err) + } + if string(ownerData) != "owner-live" { + t.Fatalf("owner file was overwritten through symlink: %q", ownerData) + } +} diff --git a/server/internal/daemon/execenv/task_home_test.go b/server/internal/daemon/execenv/task_home_test.go index ff8fe58224e..6ad94ea930b 100644 --- a/server/internal/daemon/execenv/task_home_test.go +++ b/server/internal/daemon/execenv/task_home_test.go @@ -229,6 +229,92 @@ func TestTaskHomeEnv(t *testing.T) { } } +func TestPrepareCreatesPrivateTaskHome(t *testing.T) { + t.Parallel() + + env, err := Prepare(PrepareParams{ + WorkspacesRoot: t.TempDir(), + WorkspaceID: "ws-private-home", + TaskID: "task-private-home", + Provider: "claude", + Task: TaskContextForEnv{IssueID: "issue-private-home"}, + }, testLogger()) + if err != nil { + t.Fatalf("Prepare(): %v", err) + } + t.Cleanup(func() { _ = env.Cleanup(true) }) + + wantHome := filepath.Join(env.RootDir, "home") + wantConfig := filepath.Join(wantHome, ".config") + if env.HomeDir != wantHome { + t.Fatalf("HomeDir = %q, want %q", env.HomeDir, wantHome) + } + if env.ConfigDir != wantConfig { + t.Fatalf("ConfigDir = %q, want %q", env.ConfigDir, wantConfig) + } + for _, path := range []string{env.HomeDir, env.ConfigDir} { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %q: %v", path, err) + } + if got := info.Mode().Perm(); got != 0o700 { + t.Fatalf("mode(%q) = %#o, want 0700", path, got) + } + } + if _, err := os.Stat(filepath.Join(env.HomeDir, ".multica", "config.json")); !os.IsNotExist(err) { + t.Fatalf("private home must not contain a Multica user config, stat err = %v", err) + } +} + +func TestReuseResetsPrivateTaskHome(t *testing.T) { + t.Parallel() + + env, err := Prepare(PrepareParams{ + WorkspacesRoot: t.TempDir(), + WorkspaceID: "ws-private-home-reuse", + TaskID: "task-private-home-reuse", + Provider: "claude", + Task: TaskContextForEnv{IssueID: "issue-private-home-reuse"}, + }, testLogger()) + if err != nil { + t.Fatalf("Prepare(): %v", err) + } + t.Cleanup(func() { _ = env.Cleanup(true) }) + + staleConfig := filepath.Join(env.HomeDir, ".multica", "config.json") + if err := os.MkdirAll(filepath.Dir(staleConfig), 0o700); err != nil { + t.Fatalf("mkdir stale config dir: %v", err) + } + if err := os.WriteFile(staleConfig, []byte(`{"token":"stale"}`), 0o600); err != nil { + t.Fatalf("write stale config: %v", err) + } + + reused := Reuse(ReuseParams{ + RootDir: env.RootDir, + WorkDir: env.WorkDir, + Provider: "claude", + Task: TaskContextForEnv{IssueID: "issue-private-home-reuse"}, + }, testLogger()) + if reused == nil { + t.Fatal("Reuse() returned nil") + } + if reused.HomeDir != env.HomeDir || reused.ConfigDir != env.ConfigDir { + t.Fatalf("Reuse private paths = (%q, %q), want (%q, %q)", reused.HomeDir, reused.ConfigDir, env.HomeDir, env.ConfigDir) + } + if _, err := os.Stat(staleConfig); !os.IsNotExist(err) { + t.Fatalf("Reuse must reset stale private Multica config, stat err = %v", err) + } + for _, path := range []string{reused.HomeDir, reused.ConfigDir} { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %q: %v", path, err) + } + if got := info.Mode().Perm(); got != 0o700 { + t.Fatalf("mode(%q) = %#o, want 0700", path, got) + } + } +} + // --- helpers --- func assertSymlinkTo(t *testing.T, link, wantTarget string) { diff --git a/server/internal/daemon/gc_test.go b/server/internal/daemon/gc_test.go index 4259a65313a..b9e660280b7 100644 --- a/server/internal/daemon/gc_test.go +++ b/server/internal/daemon/gc_test.go @@ -809,7 +809,7 @@ func TestPruneWorktree_SerializesWithCreateWorktree(t *testing.T) { createDone := make(chan error, 1) go func() { - _, err := blockingCache.CreateWorktree(repocache.WorktreeParams{ + _, err := blockingCache.CreateWorktreeContext(context.Background(), repocache.WorktreeParams{ WorkspaceID: "ws1", RepoURL: sourceRepo, WorkDir: t.TempDir(), @@ -849,12 +849,16 @@ type blockingRepoCache struct { release chan struct{} } -func (c *blockingRepoCache) Lookup(workspaceID, url string) string { - return c.inner.Lookup(workspaceID, url) +func (c *blockingRepoCache) LookupContext(ctx context.Context, workspaceID, url string) string { + return c.inner.LookupContext(ctx, workspaceID, url) } -func (c *blockingRepoCache) Sync(workspaceID string, repos []repocache.RepoInfo) error { - return c.inner.Sync(workspaceID, repos) +func (c *blockingRepoCache) ResolveContext(ctx context.Context, workspaceID, url string) (repocache.ResolvedRepo, error) { + return c.inner.ResolveContext(ctx, workspaceID, url) +} + +func (c *blockingRepoCache) SyncContext(ctx context.Context, workspaceID string, repos []repocache.RepoInfo) error { + return c.inner.SyncContext(ctx, workspaceID, repos) } func (c *blockingRepoCache) WithRepoLock(barePath string, fn func() error) error { @@ -865,8 +869,8 @@ func (c *blockingRepoCache) WithRepoLock(barePath string, fn func() error) error }) } -func (c *blockingRepoCache) CreateWorktree(params repocache.WorktreeParams) (*repocache.WorktreeResult, error) { - return c.inner.CreateWorktree(params) +func (c *blockingRepoCache) CreateWorktreeContext(ctx context.Context, params repocache.WorktreeParams) (*repocache.WorktreeResult, error) { + return c.inner.CreateWorktreeContext(ctx, params) } // TestShouldCleanTaskDir_KindDispatch covers the four GCMeta kinds across diff --git a/server/internal/daemon/health.go b/server/internal/daemon/health.go index ff1b0e6624f..f8a961a464c 100644 --- a/server/internal/daemon/health.go +++ b/server/internal/daemon/health.go @@ -2,29 +2,61 @@ package daemon import ( "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" "encoding/json" "errors" "fmt" + "io" + "mime" "net" "net/http" "os" + "path/filepath" "runtime" + "sort" "strings" "time" + "github.com/multica-ai/multica/server/internal/cli" "github.com/multica-ai/multica/server/internal/daemon/repocache" ) -// HealthResponse is returned by the daemon's local health endpoint. +const ( + repoCheckoutMaxBodyBytes = 16 << 10 + shutdownCredentialBytes = 32 + + // ShutdownCredentialHeader carries the operator-only credential accepted + // by POST /shutdown. It is intentionally separate from task checkout + // capabilities and is never exposed by /health or task environments. + ShutdownCredentialHeader = "X-Multica-Shutdown-Credential" + ShutdownRequireIdleHeader = "X-Multica-Shutdown-Require-Idle" + // ShutdownCredentialFileName is the profile-local state file read by the + // lifecycle CLI. Its contents are generated afresh by each daemon process. + ShutdownCredentialFileName = "daemon.shutdown-token" +) + +// HealthResponse contains only public liveness/readiness metadata. The health +// port is reachable from task sandboxes, so management metadata belongs on the +// operator-authenticated /diagnostics endpoint instead. type HealthResponse struct { Status string `json:"status"` - PID int `json:"pid"` // OS is the daemon's runtime.GOOS. The desktop app compares it against its // own host OS to detect a daemon it cannot manage — e.g. a Windows desktop // reaching a Linux daemon inside WSL2 over localhost forwarding. The // lifecycle CLI (`daemon start/stop`) acts on the host process namespace, // so a foreign-OS daemon can't be started/stopped by the app even though // /health is reachable. See #3916. + OS string `json:"os"` +} + +// DiagnosticsResponse is returned only to an operator that presents the +// daemon's per-process credential. +type DiagnosticsResponse struct { + Status string `json:"status"` + PID int `json:"pid"` OS string `json:"os"` Uptime string `json:"uptime"` DaemonID string `json:"daemon_id"` @@ -52,48 +84,136 @@ func (d *Daemon) listenHealth() (net.Listener, error) { return ln, nil } +func createShutdownCredential(profile string) (string, error) { + raw := make([]byte, shutdownCredentialBytes) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("generate credential: %w", err) + } + credential := base64.RawURLEncoding.EncodeToString(raw) + dir, err := cli.ProfileDir(profile) + if err != nil { + return "", fmt.Errorf("resolve profile directory: %w", err) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("create profile directory: %w", err) + } + if err := writeShutdownCredential(filepath.Join(dir, ShutdownCredentialFileName), credential); err != nil { + return "", err + } + return credential, nil +} + +func writeShutdownCredential(path, credential string) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".daemon-shutdown-*.tmp") + if err != nil { + return fmt.Errorf("create temporary credential file: %w", err) + } + tmpPath := tmp.Name() + cleanup := func() { + tmp.Close() + os.Remove(tmpPath) + } + if err := tmp.Chmod(0o600); err != nil { + cleanup() + return fmt.Errorf("protect temporary credential file: %w", err) + } + if _, err := tmp.WriteString(credential + "\n"); err != nil { + cleanup() + return fmt.Errorf("write temporary credential file: %w", err) + } + if err := tmp.Sync(); err != nil { + cleanup() + return fmt.Errorf("sync temporary credential file: %w", err) + } + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("close temporary credential file: %w", err) + } + if err := os.Rename(tmpPath, path); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("publish shutdown credential: %w", err) + } + return nil +} + +func removeShutdownCredential(path, credential string) { + data, err := os.ReadFile(path) + if err != nil || strings.TrimSpace(string(data)) != credential { + return + } + _ = os.Remove(path) +} + // repoCheckoutRequest is the body of a POST /repo/checkout request. type repoCheckoutRequest struct { - URL string `json:"url"` - WorkspaceID string `json:"workspace_id"` - WorkDir string `json:"workdir"` - Ref string `json:"ref,omitempty"` - AgentName string `json:"agent_name"` - TaskID string `json:"task_id"` + URL string `json:"url"` + Ref string `json:"ref,omitempty"` } // healthHandler returns the /health HTTP handler. Extracted from serveHealth // so tests can exercise it without spinning up a listener. func (d *Daemon) healthHandler(startedAt time.Time) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + resp := HealthResponse{ + Status: d.healthStatus(), + OS: runtime.GOOS, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + } +} + +func (d *Daemon) healthStatus() string { + if d.ready.Load() { + return "running" + } + return "starting" +} + +func validOperatorCredential(expected, supplied string) bool { + supplied = strings.TrimSpace(supplied) + if expected == "" || supplied == "" { + return false + } + expectedDigest := sha256.Sum256([]byte(expected)) + suppliedDigest := sha256.Sum256([]byte(supplied)) + return subtle.ConstantTimeCompare(expectedDigest[:], suppliedDigest[:]) == 1 +} + +func (d *Daemon) diagnosticsHandler(startedAt time.Time, credential string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if credential == "" { + http.Error(w, "operator credential unavailable", http.StatusServiceUnavailable) + return + } + if !validOperatorCredential(credential, r.Header.Get(ShutdownCredentialHeader)) { + http.Error(w, "invalid operator credential", http.StatusUnauthorized) + return + } + d.mu.Lock() - var wsList []healthWorkspace + wsList := make([]healthWorkspace, 0, len(d.workspaces)) for id, ws := range d.workspaces { - wsList = append(wsList, healthWorkspace{ - ID: id, - Runtimes: ws.runtimeIDs, - }) + runtimes := append([]string(nil), ws.runtimeIDs...) + sort.Strings(runtimes) + wsList = append(wsList, healthWorkspace{ID: id, Runtimes: runtimes}) } d.mu.Unlock() + sort.Slice(wsList, func(i, j int) bool { return wsList[i].ID < wsList[j].ID }) agents := make([]string, 0, len(d.cfg.Agents)) for name := range d.cfg.Agents { agents = append(agents, name) } + sort.Strings(agents) - // "starting" until preflight (PAT renew + initial workspace sync + - // runtime registration) completes; "running" once the daemon can - // actually claim tasks. The health port is bound before preflight for - // liveness/diagnostics, so callers must not treat a reachable endpoint - // as ready — they gate on this status. Consumers that only know - // "running" (older CLI/desktop) safely treat "starting" as not-ready. - status := "starting" - if d.ready.Load() { - status = "running" - } - - resp := HealthResponse{ - Status: status, + resp := DiagnosticsResponse{ + Status: d.healthStatus(), PID: os.Getpid(), OS: runtime.GOOS, Uptime: time.Since(startedAt).Truncate(time.Second).String(), @@ -105,7 +225,6 @@ func (d *Daemon) healthHandler(startedAt time.Time) http.HandlerFunc { Agents: agents, Workspaces: wsList, } - w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } @@ -115,14 +234,32 @@ func (d *Daemon) healthHandler(startedAt time.Time) http.HandlerFunc { // top-level context. Used by `multica daemon stop` so we don't depend on // OS-signal delivery, which is unreliable on Windows once the daemon is // spawned with DETACHED_PROCESS (no shared console with the stop caller). -// The listener is bound to 127.0.0.1 only, so only local processes can hit -// this endpoint. -func (d *Daemon) shutdownHandler() http.HandlerFunc { +// Loopback reachability alone does not grant shutdown authority: callers must +// also present the per-process operator credential generated at daemon start. +func (d *Daemon) shutdownHandler(credential string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } + if credential == "" { + http.Error(w, "shutdown credential unavailable", http.StatusServiceUnavailable) + return + } + if !validOperatorCredential(credential, r.Header.Get(ShutdownCredentialHeader)) { + http.Error(w, "invalid shutdown credential", http.StatusUnauthorized) + return + } + requireIdle := r.Header.Get(ShutdownRequireIdleHeader) == "true" + if requireIdle && !d.trySetClaimBarrier() { + http.Error(w, "daemon is busy", http.StatusConflict) + return + } + if requireIdle && d.cancelFunc == nil { + d.releaseClaimBarrier() + http.Error(w, "daemon shutdown unavailable", http.StatusServiceUnavailable) + return + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "shutting down"}) if d.cancelFunc != nil { @@ -136,15 +273,41 @@ func (d *Daemon) shutdownHandler() http.HandlerFunc { // serveHealth runs the health HTTP server on the given listener. // Blocks until ctx is cancelled. func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt time.Time) { + credential, err := createShutdownCredential(d.cfg.Profile) + cleanupCredential := func() {} + if err != nil { + d.logger.Error("operator shutdown credential unavailable; /shutdown will fail closed", "error", err) + } else { + dir, pathErr := cli.ProfileDir(d.cfg.Profile) + if pathErr != nil { + d.logger.Error("operator shutdown credential path unavailable; /shutdown will fail closed", "error", pathErr) + credential = "" + } else { + credentialPath := filepath.Join(dir, ShutdownCredentialFileName) + cleanupCredential = func() { removeShutdownCredential(credentialPath, credential) } + defer cleanupCredential() + } + } mux := http.NewServeMux() mux.HandleFunc("/health", d.healthHandler(startedAt)) - mux.HandleFunc("/shutdown", d.shutdownHandler()) + mux.HandleFunc("/diagnostics", d.diagnosticsHandler(startedAt, credential)) + mux.HandleFunc("/shutdown", d.shutdownHandler(credential)) mux.HandleFunc("/repo/checkout", d.repoCheckoutHandler()) - srv := &http.Server{Handler: mux} + srv := &http.Server{ + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } go func() { <-ctx.Done() + // Remove this process's credential before releasing the port. A new + // daemon cannot publish its successor credential until this listener + // has closed, so normal shutdown cannot delete the successor's file. + cleanupCredential() srv.Close() }() @@ -161,22 +324,56 @@ func (d *Daemon) repoCheckoutHandler() http.HandlerFunc { return } + token := strings.TrimSpace(r.Header.Get(repoCheckoutCapabilityHeader)) + binding, release, ok := d.acquireRepoCheckoutCapability(token) + if !ok { + http.Error(w, "invalid or expired repo checkout capability", http.StatusUnauthorized) + return + } + defer release() + + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || mediaType != "application/json" { + http.Error(w, "content type must be application/json", http.StatusUnsupportedMediaType) + return + } + var req repoCheckoutRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, repoCheckoutMaxBodyBytes)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&req); err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + http.Error(w, "request body too large", http.StatusRequestEntityTooLarge) + return + } http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) return } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + http.Error(w, "invalid request body: expected one JSON object", http.StatusBadRequest) + return + } req.URL = strings.TrimSpace(req.URL) if req.URL == "" { http.Error(w, "url is required", http.StatusBadRequest) return } - if req.WorkspaceID == "" { - http.Error(w, "workspace_id is required", http.StatusBadRequest) + boundRef, allowed := binding.repoRefs[req.URL] + if !allowed { + http.Error(w, "repo is not assigned to this task", http.StatusForbidden) return } - if req.WorkDir == "" { - http.Error(w, "workdir is required", http.StatusBadRequest) + checkoutRef := strings.TrimSpace(req.Ref) + if checkoutRef != "" && checkoutRef != boundRef { + http.Error(w, "ref is not assigned to this task", http.StatusForbidden) + return + } + if checkoutRef == "" { + checkoutRef = boundRef + } + if binding.claimRepo == nil || !binding.claimRepo(req.URL) { + http.Error(w, "repo checkout capability already used for this repo", http.StatusConflict) return } @@ -185,29 +382,19 @@ func (d *Daemon) repoCheckoutHandler() http.HandlerFunc { return } - if err := d.ensureRepoReady(r.Context(), req.WorkspaceID, req.URL); err != nil { - statusCode := http.StatusInternalServerError - if errors.Is(err, ErrRepoNotConfigured) { - statusCode = http.StatusBadRequest - } - d.logger.Error("repo checkout readiness failed", "workspace_id", req.WorkspaceID, "url", req.URL, "error", err) - http.Error(w, err.Error(), statusCode) + if d.repoCache.LookupContext(r.Context(), binding.workspaceID, req.URL) == "" { + http.Error(w, "assigned repo cache is not ready", http.StatusConflict) return } - checkoutRef := strings.TrimSpace(req.Ref) - if checkoutRef == "" { - checkoutRef = d.taskRepoDefaultRef(req.WorkspaceID, req.TaskID, req.URL) - } - - result, err := d.repoCache.CreateWorktree(repocache.WorktreeParams{ - WorkspaceID: req.WorkspaceID, + result, err := d.repoCache.CreateWorktreeContext(r.Context(), repocache.WorktreeParams{ + WorkspaceID: binding.workspaceID, RepoURL: req.URL, - WorkDir: req.WorkDir, + WorkDir: binding.workDir, Ref: checkoutRef, - AgentName: req.AgentName, - TaskID: req.TaskID, - CoAuthoredByEnabled: d.workspaceCoAuthoredByEnabled(req.WorkspaceID), + AgentName: binding.agentName, + TaskID: binding.taskID, + CoAuthoredByEnabled: binding.coAuthoredByEnabled, }) if err != nil { d.logger.Error("repo checkout failed", "url", req.URL, "error", err) diff --git a/server/internal/daemon/health_test.go b/server/internal/daemon/health_test.go index 620d8aec5f2..42adeacfa01 100644 --- a/server/internal/daemon/health_test.go +++ b/server/internal/daemon/health_test.go @@ -2,10 +2,17 @@ package daemon import ( "context" + "encoding/base64" "encoding/json" + "errors" + "io" "log/slog" + "net" "net/http" "net/http/httptest" + "os" + "path/filepath" + "reflect" "runtime" "strings" "sync" @@ -15,7 +22,171 @@ import ( "github.com/multica-ai/multica/server/internal/daemon/repocache" ) -func TestHealthHandlerReportsCLIVersionAndActiveTaskCount(t *testing.T) { +func TestCreateShutdownCredentialWritesFreshOperatorOnlySecret(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + first, err := createShutdownCredential("test-profile") + if err != nil { + t.Fatalf("create first shutdown credential: %v", err) + } + second, err := createShutdownCredential("test-profile") + if err != nil { + t.Fatalf("create second shutdown credential: %v", err) + } + if first == second { + t.Fatal("successive daemon starts reused the shutdown credential") + } + raw, err := base64.RawURLEncoding.DecodeString(second) + if err != nil { + t.Fatalf("decode credential: %v", err) + } + if len(raw) != shutdownCredentialBytes { + t.Fatalf("credential entropy bytes = %d, want %d", len(raw), shutdownCredentialBytes) + } + + path := filepath.Join(home, ".multica", "profiles", "test-profile", ShutdownCredentialFileName) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read credential file: %v", err) + } + if got := strings.TrimSpace(string(data)); got != second { + t.Fatalf("credential file = %q, want latest credential", got) + } + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat credential file: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("credential mode = %#o, want 0600", got) + } + } +} + +func TestServeHealthOwnsShutdownCredentialLifecycle(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + d := &Daemon{ + cfg: Config{Profile: "test-profile"}, + workspaces: map[string]*workspaceState{}, + logger: slog.Default(), + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + d.serveHealth(ctx, ln, time.Now()) + }() + + credentialPath := filepath.Join(home, ".multica", "profiles", "test-profile", ShutdownCredentialFileName) + var credential string + deadline := time.Now().Add(time.Second) + for { + if data, readErr := os.ReadFile(credentialPath); readErr == nil && strings.TrimSpace(string(data)) != "" { + credential = strings.TrimSpace(string(data)) + break + } + if time.Now().After(deadline) { + cancel() + <-done + t.Fatal("serveHealth did not publish a shutdown credential") + } + time.Sleep(10 * time.Millisecond) + } + + baseURL := "http://" + ln.Addr().String() + healthResp, err := http.Get(baseURL + "/health") + if err != nil { + cancel() + <-done + t.Fatalf("GET /health: %v", err) + } + var public map[string]any + if err := json.NewDecoder(healthResp.Body).Decode(&public); err != nil { + healthResp.Body.Close() + cancel() + <-done + t.Fatalf("decode /health: %v", err) + } + healthResp.Body.Close() + if healthResp.StatusCode != http.StatusOK || len(public) != 2 || public["status"] != "starting" || public["os"] != runtime.GOOS { + cancel() + <-done + t.Fatalf("public /health status=%d body=%#v, want exact starting/status+os", healthResp.StatusCode, public) + } + + for name, configure := range map[string]func(*http.Request){ + "unauthenticated": func(*http.Request) {}, + "task bearer": func(req *http.Request) { + req.Header.Set("Authorization", "Bearer task-token") + }, + } { + req, err := http.NewRequest(http.MethodGet, baseURL+"/diagnostics", nil) + if err != nil { + cancel() + <-done + t.Fatalf("create %s diagnostics request: %v", name, err) + } + configure(req) + resp, err := http.DefaultClient.Do(req) + if err != nil { + cancel() + <-done + t.Fatalf("%s diagnostics request: %v", name, err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + cancel() + <-done + t.Fatalf("%s /diagnostics status = %d, want 401", name, resp.StatusCode) + } + } + + req, err := http.NewRequest(http.MethodGet, baseURL+"/diagnostics", nil) + if err != nil { + cancel() + <-done + t.Fatalf("create operator diagnostics request: %v", err) + } + req.Header.Set(ShutdownCredentialHeader, credential) + resp, err := http.DefaultClient.Do(req) + if err != nil { + cancel() + <-done + t.Fatalf("operator diagnostics request: %v", err) + } + var diagnostics map[string]any + if err := json.NewDecoder(resp.Body).Decode(&diagnostics); err != nil { + resp.Body.Close() + cancel() + <-done + t.Fatalf("decode operator /diagnostics: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK || diagnostics["pid"] != float64(os.Getpid()) { + cancel() + <-done + t.Fatalf("operator /diagnostics status=%d body=%#v", resp.StatusCode, diagnostics) + } + + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("serveHealth did not stop after context cancellation") + } + if _, err := os.Stat(credentialPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("shutdown credential still exists after server stop: %v", err) + } +} + +func TestHealthHandlerExposesOnlyPublicLivenessMetadata(t *testing.T) { t.Parallel() d := &Daemon{ @@ -39,19 +210,12 @@ func TestHealthHandlerReportsCLIVersionAndActiveTaskCount(t *testing.T) { t.Fatalf("expected 200, got %d", rec.Code) } - // Decode into a raw map so the test locks in the exact wire-level JSON - // keys — the desktop TS client depends on snake_case (cli_version, - // active_task_count), so a silent struct-tag rename must fail here. var raw map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil { t.Fatalf("decode raw response: %v", err) } - if got, want := raw["cli_version"], "v9.9.9"; got != want { - t.Errorf("cli_version key: got %v, want %q", got, want) - } - // JSON numbers decode to float64 through map[string]any. - if got, want := raw["active_task_count"], float64(3); got != want { - t.Errorf("active_task_count key: got %v, want %v", got, want) + if len(raw) != 2 { + t.Fatalf("public health keys = %v, want exactly status and os", raw) } if got, want := raw["status"], "running"; got != want { t.Errorf("status key: got %v, want %q", got, want) @@ -62,18 +226,121 @@ func TestHealthHandlerReportsCLIVersionAndActiveTaskCount(t *testing.T) { if got, want := raw["os"], runtime.GOOS; got != want { t.Errorf("os key: got %v, want %q", got, want) } + for _, forbidden := range []string{ + "pid", "uptime", "daemon_id", "device_name", "server_url", + "cli_version", "active_task_count", "agents", "workspaces", + } { + if _, ok := raw[forbidden]; ok { + t.Errorf("public health exposed management field %q", forbidden) + } + } +} - // Also round-trip into the typed struct as a separate check that the - // field values match, independent of key naming. - var resp HealthResponse - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode typed response: %v", err) +func TestDiagnosticsHandlerRequiresOperatorCredential(t *testing.T) { + t.Parallel() + + const credential = "operator-diagnostics-credential" + d := &Daemon{ + cfg: Config{ + CLIVersion: "v9.9.9", + DaemonID: "daemon-test", + DeviceName: "dev", + ServerBaseURL: "http://localhost:8080", + Agents: map[string]AgentEntry{"codex": {}}, + }, + workspaces: map[string]*workspaceState{ + "workspace-test": {runtimeIDs: []string{"runtime-test"}}, + }, + logger: slog.Default(), + } + d.activeTasks.Store(3) + d.ready.Store(true) + handler := d.diagnosticsHandler(time.Now(), credential) + + for name, configure := range map[string]func(*http.Request){ + "missing": func(*http.Request) {}, + "wrong operator credential": func(req *http.Request) { + req.Header.Set(ShutdownCredentialHeader, "wrong") + }, + "task bearer token": func(req *http.Request) { + req.Header.Set("Authorization", "Bearer task-token") + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/diagnostics", nil) + configure(req) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401: %s", rec.Code, rec.Body.String()) + } + }) + } + + req := httptest.NewRequest(http.MethodGet, "/diagnostics", nil) + req.Header.Set(ShutdownCredentialHeader, credential) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + var raw map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil { + t.Fatalf("decode diagnostics: %v", err) + } + for key, want := range map[string]any{ + "status": "running", + "os": runtime.GOOS, + "pid": float64(os.Getpid()), + "daemon_id": "daemon-test", + "device_name": "dev", + "server_url": "http://localhost:8080", + "cli_version": "v9.9.9", + "active_task_count": float64(3), + } { + if got := raw[key]; got != want { + t.Errorf("%s = %v, want %v", key, got, want) + } } - if resp.CLIVersion != "v9.9.9" { - t.Errorf("CLIVersion: got %q, want %q", resp.CLIVersion, "v9.9.9") + if _, ok := raw["uptime"].(string); !ok { + t.Errorf("uptime = %T, want string", raw["uptime"]) } - if resp.ActiveTaskCount != 3 { - t.Errorf("ActiveTaskCount: got %d, want 3", resp.ActiveTaskCount) + if got := raw["agents"]; !reflect.DeepEqual(got, []any{"codex"}) { + t.Errorf("agents = %#v, want [codex]", got) + } + wantWorkspaces := []any{map[string]any{ + "id": "workspace-test", + "runtimes": []any{"runtime-test"}, + }} + if got := raw["workspaces"]; !reflect.DeepEqual(got, wantWorkspaces) { + t.Errorf("workspaces = %#v, want %#v", got, wantWorkspaces) + } +} + +func TestDiagnosticsHandlerFailsClosedWithoutServerCredential(t *testing.T) { + t.Parallel() + + d := &Daemon{workspaces: map[string]*workspaceState{}} + req := httptest.NewRequest(http.MethodGet, "/diagnostics", nil) + req.Header.Set(ShutdownCredentialHeader, "caller-controlled") + rec := httptest.NewRecorder() + d.diagnosticsHandler(time.Now(), "").ServeHTTP(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503: %s", rec.Code, rec.Body.String()) + } +} + +func TestDiagnosticsHandlerRejectsNonGetMethods(t *testing.T) { + t.Parallel() + + d := &Daemon{workspaces: map[string]*workspaceState{}} + req := httptest.NewRequest(http.MethodPost, "/diagnostics", nil) + req.Header.Set(ShutdownCredentialHeader, "operator-credential") + rec := httptest.NewRecorder() + d.diagnosticsHandler(time.Now(), "operator-credential").ServeHTTP(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want 405: %s", rec.Code, rec.Body.String()) } } @@ -113,7 +380,7 @@ func TestHealthHandlerReportsStartingUntilReady(t *testing.T) { } } -func TestHealthHandlerActiveTaskCountTracksCounter(t *testing.T) { +func TestDiagnosticsHandlerActiveTaskCountTracksCounter(t *testing.T) { t.Parallel() d := &Daemon{ @@ -121,30 +388,32 @@ func TestHealthHandlerActiveTaskCountTracksCounter(t *testing.T) { workspaces: map[string]*workspaceState{}, logger: slog.Default(), } - handler := d.healthHandler(time.Now()) + handler := d.diagnosticsHandler(time.Now(), "operator-credential") // Simulate the pollLoop increment/decrement protocol. d.activeTasks.Add(1) d.activeTasks.Add(1) - assertActiveTaskCount(t, handler, 2) + assertActiveTaskCount(t, handler, "operator-credential", 2) d.activeTasks.Add(-1) - assertActiveTaskCount(t, handler, 1) + assertActiveTaskCount(t, handler, "operator-credential", 1) d.activeTasks.Add(-1) - assertActiveTaskCount(t, handler, 0) + assertActiveTaskCount(t, handler, "operator-credential", 0) } func TestShutdownHandlerPostCancelsDaemonContext(t *testing.T) { t.Parallel() + const credential = "operator-shutdown-credential" ctx, cancel := context.WithCancel(context.Background()) defer cancel() d := &Daemon{cancelFunc: cancel} rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/shutdown", nil) - d.shutdownHandler().ServeHTTP(rec, req) + req.Header.Set(ShutdownCredentialHeader, credential) + d.shutdownHandler(credential).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) @@ -157,6 +426,142 @@ func TestShutdownHandlerPostCancelsDaemonContext(t *testing.T) { } } +func TestShutdownHandlerRequireIdleAtomicallyBlocksNewClaims(t *testing.T) { + t.Parallel() + + const credential = "operator-shutdown-credential" + cancelled := make(chan struct{}, 1) + d := &Daemon{cancelFunc: func() { cancelled <- struct{}{} }} + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/shutdown", nil) + req.Header.Set(ShutdownCredentialHeader, credential) + req.Header.Set(ShutdownRequireIdleHeader, "true") + + d.shutdownHandler(credential).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + if d.tryEnterClaim() { + t.Fatal("idle shutdown released the claim barrier before process cancellation") + } + select { + case <-cancelled: + case <-time.After(time.Second): + t.Fatal("idle shutdown did not cancel daemon context") + } +} + +func TestShutdownHandlerRequireIdleRejectsTaskOrClaimRace(t *testing.T) { + t.Parallel() + + const credential = "operator-shutdown-credential" + for name, makeBusy := range map[string]func(*Daemon){ + "active task": func(d *Daemon) { d.activeTasks.Add(1) }, + "claim in flight": func(d *Daemon) { + if !d.tryEnterClaim() { + t.Fatal("failed to establish in-flight claim") + } + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + cancelled := make(chan struct{}, 1) + d := &Daemon{cancelFunc: func() { cancelled <- struct{}{} }} + makeBusy(d) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/shutdown", nil) + req.Header.Set(ShutdownCredentialHeader, credential) + req.Header.Set(ShutdownRequireIdleHeader, "true") + + d.shutdownHandler(credential).ServeHTTP(rec, req) + + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409: %s", rec.Code, rec.Body.String()) + } + select { + case <-cancelled: + t.Fatal("busy idle shutdown cancelled daemon context") + case <-time.After(20 * time.Millisecond): + } + }) + } +} + +func TestShutdownHandlerRequireIdleReleasesBarrierWhenShutdownUnavailable(t *testing.T) { + t.Parallel() + + const credential = "operator-shutdown-credential" + d := &Daemon{} + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/shutdown", nil) + req.Header.Set(ShutdownCredentialHeader, credential) + req.Header.Set(ShutdownRequireIdleHeader, "true") + + d.shutdownHandler(credential).ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503: %s", rec.Code, rec.Body.String()) + } + if !d.tryEnterClaim() { + t.Fatal("failed idle shutdown left the claim barrier set") + } + d.exitClaim() +} + +func TestShutdownHandlerRejectsMissingOrInvalidOperatorCredential(t *testing.T) { + t.Parallel() + + const credential = "operator-shutdown-credential" + for name, supplied := range map[string]string{ + "missing": "", + "invalid": "task-controlled-credential", + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + cancelled := make(chan struct{}, 1) + d := &Daemon{cancelFunc: func() { cancelled <- struct{}{} }} + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/shutdown", nil) + if supplied != "" { + req.Header.Set(ShutdownCredentialHeader, supplied) + } + + d.shutdownHandler(credential).ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401: %s", rec.Code, rec.Body.String()) + } + select { + case <-cancelled: + t.Fatal("unauthorized shutdown request cancelled daemon context") + case <-time.After(20 * time.Millisecond): + } + }) + } +} + +func TestShutdownHandlerFailsClosedWithoutServerCredential(t *testing.T) { + t.Parallel() + + cancelled := make(chan struct{}, 1) + d := &Daemon{cancelFunc: func() { cancelled <- struct{}{} }} + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/shutdown", nil) + + d.shutdownHandler("").ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503: %s", rec.Code, rec.Body.String()) + } + select { + case <-cancelled: + t.Fatal("shutdown without a server credential cancelled daemon context") + case <-time.After(20 * time.Millisecond): + } +} + func TestShutdownHandlerRejectsNonPost(t *testing.T) { t.Parallel() @@ -165,7 +570,7 @@ func TestShutdownHandlerRejectsNonPost(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/shutdown", nil) - d.shutdownHandler().ServeHTTP(rec, req) + d.shutdownHandler("operator-shutdown-credential").ServeHTTP(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("expected 405, got %d", rec.Code) @@ -178,98 +583,544 @@ func TestShutdownHandlerRejectsNonPost(t *testing.T) { } } -func TestHealthHandlerRespondsWhileTaskRepoLookupWaits(t *testing.T) { - const workspaceID = "ws-health" +func TestRepoCheckoutUsesTaskScopedProjectRefByDefault(t *testing.T) { + t.Parallel() + + const workspaceID = "ws-checkout" + const repoURL = "https://github.com/org/repo.git" + cache := &recordingRepoCache{lookupPath: "/cache/org/repo.git"} + d := newRepoCheckoutTestDaemon(t, workspaceID, repoURL, cache) + token, err := d.registerRepoCheckoutCapability(repoCheckoutCapabilityParams{ + WorkspaceID: workspaceID, + TaskID: "task-1", + WorkDir: "/tmp/work", + AgentName: "implementer", + Repos: []RepoData{{URL: repoURL, Ref: "release/v2"}}, + CoAuthoredByEnabled: true, + }) + if err != nil { + t.Fatalf("register capability: %v", err) + } + + rec := httptest.NewRecorder() + body := strings.NewReader(`{"url":"` + repoURL + `"}`) + req := httptest.NewRequest(http.MethodPost, "/repo/checkout", body) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(repoCheckoutCapabilityHeader, token) + d.repoCheckoutHandler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if got := cache.lastCreateParams().Ref; got != "release/v2" { + t.Fatalf("CreateWorktree Ref = %q, want release/v2", got) + } +} + +func TestRepoCheckoutRejectsRefOutsideTaskBinding(t *testing.T) { + t.Parallel() + + const workspaceID = "ws-checkout" + const repoURL = "https://github.com/org/repo.git" + cache := &recordingRepoCache{lookupPath: "/cache/org/repo.git"} + d := newRepoCheckoutTestDaemon(t, workspaceID, repoURL, cache) + token, err := d.registerRepoCheckoutCapability(repoCheckoutCapabilityParams{ + WorkspaceID: workspaceID, + TaskID: "task-1", + WorkDir: "/tmp/work", + AgentName: "implementer", + Repos: []RepoData{{URL: repoURL, Ref: "release/v2"}}, + CoAuthoredByEnabled: true, + }) + if err != nil { + t.Fatalf("register capability: %v", err) + } + + rec := httptest.NewRecorder() + body := strings.NewReader(`{"url":"` + repoURL + `","ref":"hotfix"}`) + req := httptest.NewRequest(http.MethodPost, "/repo/checkout", body) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(repoCheckoutCapabilityHeader, token) + d.repoCheckoutHandler().ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d: %s", rec.Code, rec.Body.String()) + } + if got := cache.lastCreateParams(); got != (repocache.WorktreeParams{}) { + t.Fatalf("CreateWorktree called for denied ref: %#v", got) + } +} + +func TestRepoCheckoutCapabilityIsTaskBoundAndRevocable(t *testing.T) { + t.Parallel() + + const ( + workspaceID = "ws-checkout" + repoA = "https://github.com/org/repo-a.git" + repoB = "https://github.com/org/repo-b.git" + ) + cache := &recordingRepoCache{lookupPath: "/cache/org/repo.git"} + d := newRepoCheckoutTestDaemon(t, workspaceID, repoA, cache) + d.workspaces[workspaceID].allowedRepoURLs[repoB] = struct{}{} + + tokenA, err := d.registerRepoCheckoutCapability(repoCheckoutCapabilityParams{ + WorkspaceID: workspaceID, + TaskID: "task-a", + WorkDir: "/tmp/task-a", + AgentName: "agent-a", + Repos: []RepoData{{URL: repoA, Ref: "release/a"}}, + CoAuthoredByEnabled: true, + }) + if err != nil { + t.Fatalf("register task A capability: %v", err) + } + tokenB, err := d.registerRepoCheckoutCapability(repoCheckoutCapabilityParams{ + WorkspaceID: workspaceID, + TaskID: "task-b", + WorkDir: "/tmp/task-b", + AgentName: "agent-b", + Repos: []RepoData{{URL: repoB, Ref: "release/b"}}, + CoAuthoredByEnabled: true, + }) + if err != nil { + t.Fatalf("register task B capability: %v", err) + } + if tokenA == tokenB { + t.Fatal("concurrent task capabilities must be unique") + } + + request := func(token, body string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/repo/checkout", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set(repoCheckoutCapabilityHeader, token) + } + d.repoCheckoutHandler().ServeHTTP(rec, req) + return rec + } + + for name, tc := range map[string]struct { + token string + body string + code int + }{ + "missing capability": {body: `{"url":"` + repoA + `"}`, code: http.StatusUnauthorized}, + "unknown capability": {token: "unknown", body: `{"url":"` + repoA + `"}`, code: http.StatusUnauthorized}, + "task A using task B repo": {token: tokenA, body: `{"url":"` + repoB + `"}`, code: http.StatusForbidden}, + "legacy caller identity": {token: tokenA, body: `{"url":"` + repoA + `","workspace_id":"forged","workdir":"/tmp/escape","task_id":"task-b"}`, code: http.StatusBadRequest}, + } { + t.Run(name, func(t *testing.T) { + rec := request(tc.token, tc.body) + if rec.Code != tc.code { + t.Fatalf("status = %d, want %d: %s", rec.Code, tc.code, rec.Body.String()) + } + }) + } + + rec := request(tokenA, `{"url":"`+repoA+`"}`) + if rec.Code != http.StatusOK { + t.Fatalf("valid task A checkout status = %d: %s", rec.Code, rec.Body.String()) + } + params := cache.lastCreateParams() + if params.WorkspaceID != workspaceID || params.TaskID != "task-a" || params.WorkDir != "/tmp/task-a" || params.AgentName != "agent-a" || params.Ref != "release/a" { + t.Fatalf("checkout used caller-controlled or wrong binding: %#v", params) + } + + d.revokeRepoCheckoutCapability(tokenA) + rec = request(tokenA, `{"url":"`+repoA+`"}`) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("revoked capability status = %d, want 401: %s", rec.Code, rec.Body.String()) + } + rec = request(tokenB, `{"url":"`+repoB+`"}`) + if rec.Code != http.StatusOK { + t.Fatalf("revoking task A affected task B: status = %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestRepoCheckoutCapabilityRejectsSequentialRepoReplay(t *testing.T) { + t.Parallel() + + const repoURL = "https://github.com/org/repo.git" + cache := &recordingRepoCache{lookupPath: "/cache/org/repo.git"} + d := newRepoCheckoutTestDaemon(t, "ws-replay", repoURL, cache) + token, err := d.registerRepoCheckoutCapability(repoCheckoutCapabilityParams{ + WorkspaceID: "ws-replay", + TaskID: "task-replay", + WorkDir: "/tmp/task-replay", + AgentName: "agent-replay", + Repos: []RepoData{{URL: repoURL}}, + }) + if err != nil { + t.Fatalf("register capability: %v", err) + } + + request := func() *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/repo/checkout", strings.NewReader(`{"url":"`+repoURL+`"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(repoCheckoutCapabilityHeader, token) + d.repoCheckoutHandler().ServeHTTP(rec, req) + return rec + } + + if rec := request(); rec.Code != http.StatusOK { + t.Fatalf("initial checkout status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + if rec := request(); rec.Code != http.StatusConflict { + t.Fatalf("replayed checkout status = %d, want 409: %s", rec.Code, rec.Body.String()) + } + if got := cache.createCount(); got != 1 { + t.Fatalf("CreateWorktree calls = %d, want 1 after replay", got) + } +} + +func TestRepoCheckoutCapabilityRejectsConcurrentRepoReplay(t *testing.T) { + t.Parallel() + + const repoURL = "https://github.com/org/repo.git" + cache := newBlockingCreateRepoCache("/cache/org/repo.git") + d := &Daemon{repoCache: cache, logger: slog.Default()} + token, err := d.registerRepoCheckoutCapability(repoCheckoutCapabilityParams{ + WorkspaceID: "ws-concurrent-replay", + TaskID: "task-concurrent-replay", + WorkDir: "/tmp/task-concurrent-replay", + AgentName: "agent-concurrent-replay", + Repos: []RepoData{{URL: repoURL}}, + }) + if err != nil { + t.Fatalf("register capability: %v", err) + } + + request := func() *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/repo/checkout", strings.NewReader(`{"url":"`+repoURL+`"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(repoCheckoutCapabilityHeader, token) + d.repoCheckoutHandler().ServeHTTP(rec, req) + return rec + } + + firstDone := make(chan *httptest.ResponseRecorder, 1) + go func() { firstDone <- request() }() + cache.waitForCreate(t) + + secondDone := make(chan *httptest.ResponseRecorder, 1) + go func() { secondDone <- request() }() + var second *httptest.ResponseRecorder + select { + case second = <-secondDone: + case <-time.After(100 * time.Millisecond): + cache.release() + <-firstDone + <-secondDone + t.Fatal("concurrent replay blocked instead of returning 409") + } + if second.Code != http.StatusConflict { + cache.release() + <-firstDone + t.Fatalf("concurrent replay status = %d, want 409: %s", second.Code, second.Body.String()) + } + if got := cache.createCount(); got != 1 { + cache.release() + <-firstDone + t.Fatalf("CreateWorktree calls = %d, want 1 during concurrent replay", got) + } + + cache.release() + select { + case rec := <-firstDone: + if rec.Code != http.StatusOK { + t.Fatalf("initial checkout status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + case <-time.After(time.Second): + t.Fatal("initial checkout did not finish") + } +} + +func TestRepoCheckoutPropagatesRequestCancellationToWorktreeCreation(t *testing.T) { + t.Parallel() + + const repoURL = "https://github.com/org/repo.git" + cache := newBlockingCreateRepoCache("/cache/org/repo.git") + d := &Daemon{repoCache: cache, logger: slog.Default()} + token, err := d.registerRepoCheckoutCapability(repoCheckoutCapabilityParams{ + WorkspaceID: "ws-cancel", + TaskID: "task-cancel", + WorkDir: "/tmp/task-cancel", + AgentName: "agent-cancel", + Repos: []RepoData{{URL: repoURL}}, + }) + if err != nil { + t.Fatalf("register capability: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan *httptest.ResponseRecorder, 1) + go func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/repo/checkout", strings.NewReader(`{"url":"`+repoURL+`"}`)).WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(repoCheckoutCapabilityHeader, token) + d.repoCheckoutHandler().ServeHTTP(rec, req) + done <- rec + }() + + cache.waitForCreate(t) + cancel() + select { + case rec := <-done: + if rec.Code != http.StatusInternalServerError { + t.Fatalf("cancelled checkout status = %d, want 500: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), context.Canceled.Error()) { + t.Fatalf("cancelled checkout body = %q, want context cancellation", rec.Body.String()) + } + case <-time.After(time.Second): + cache.release() + t.Fatal("repo checkout ignored request cancellation") + } +} + +func TestRepoCheckoutCapabilityRevokeWaitsForActiveLease(t *testing.T) { + t.Parallel() + + d := &Daemon{} + token, err := d.registerRepoCheckoutCapability(repoCheckoutCapabilityParams{ + WorkspaceID: "ws-lease", + TaskID: "task-lease", + WorkDir: "/tmp/task-lease", + AgentName: "agent-lease", + Repos: []RepoData{{URL: "https://github.com/org/repo.git"}}, + }) + if err != nil { + t.Fatalf("register capability: %v", err) + } + + _, release, ok := d.acquireRepoCheckoutCapability(token) + if !ok { + t.Fatal("initial acquire rejected") + } + + revokeDone := make(chan struct{}) + go func() { + d.revokeRepoCheckoutCapability(token) + close(revokeDone) + }() + + deadline := time.Now().Add(time.Second) + for { + _, extraRelease, acquired := d.acquireRepoCheckoutCapability(token) + if !acquired { + break + } + extraRelease() + if time.Now().After(deadline) { + t.Fatal("revoke did not prevent new capability acquisition") + } + runtime.Gosched() + } + + select { + case <-revokeDone: + t.Fatal("revoke returned while an acquired lease was still active") + case <-time.After(20 * time.Millisecond): + } + + release() + release() // Release must be idempotent for defensive handler cleanup. + select { + case <-revokeDone: + case <-time.After(time.Second): + t.Fatal("revoke did not return after the active lease was released") + } +} + +func TestRepoCheckoutHandlerRevokeWaitsForInFlightRequest(t *testing.T) { + t.Parallel() + const repoURL = "https://github.com/org/repo.git" cache := newBlockingLookupRepoCache("/cache/org/repo.git") - d := &Daemon{ - cfg: Config{CLIVersion: "v1.0.0"}, - workspaces: map[string]*workspaceState{ - workspaceID: { - workspaceID: workspaceID, - runtimeIDs: []string{"rt-1"}, - allowedRepoURLs: map[string]struct{}{repoURL: {}}, - taskRepoURLs: map[string]struct{}{}, - }, - }, - repoCache: cache, - logger: slog.Default(), + d := &Daemon{repoCache: cache, logger: slog.Default()} + token, err := d.registerRepoCheckoutCapability(repoCheckoutCapabilityParams{ + WorkspaceID: "ws-handler", + TaskID: "task-handler", + WorkDir: "/tmp/task-handler", + AgentName: "agent-handler", + Repos: []RepoData{{URL: repoURL}}, + }) + if err != nil { + t.Fatalf("register capability: %v", err) } - defer cache.release() - registerDone := make(chan struct{}) + handlerDone := make(chan *httptest.ResponseRecorder, 1) go func() { - d.registerTaskRepos(workspaceID, "task-health", []RepoData{{URL: repoURL}}) - close(registerDone) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/repo/checkout", strings.NewReader(`{"url":"`+repoURL+`"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(repoCheckoutCapabilityHeader, token) + d.repoCheckoutHandler().ServeHTTP(rec, req) + handlerDone <- rec }() cache.waitForLookup(t) - rec := httptest.NewRecorder() - healthDone := make(chan struct{}) + revokeDone := make(chan struct{}) go func() { - d.healthHandler(time.Now()).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) - close(healthDone) + d.revokeRepoCheckoutCapability(token) + close(revokeDone) }() select { - case <-healthDone: + case <-revokeDone: + t.Fatal("revoke returned before the in-flight checkout released its lease") + case <-time.After(20 * time.Millisecond): + } + + cache.release() + select { + case rec := <-handlerDone: if rec.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", rec.Code) + t.Fatalf("in-flight checkout status = %d, want 200: %s", rec.Code, rec.Body.String()) } case <-time.After(time.Second): - t.Fatal("/health blocked behind task repo cache lookup") + t.Fatal("in-flight checkout did not finish") } - - cache.release() select { - case <-registerDone: + case <-revokeDone: case <-time.After(time.Second): - t.Fatal("registerTaskRepos did not unblock after repo lookup finished") + t.Fatal("revoke did not finish after the checkout handler returned") + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/repo/checkout", strings.NewReader(`{"url":"`+repoURL+`"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(repoCheckoutCapabilityHeader, token) + d.repoCheckoutHandler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("post-revoke checkout status = %d, want 401: %s", rec.Code, rec.Body.String()) } } -func TestRepoCheckoutUsesTaskScopedProjectRefByDefault(t *testing.T) { +func TestRepoCheckoutUsesFrozenPolicyWithoutManagementRead(t *testing.T) { t.Parallel() - const workspaceID = "ws-checkout" - const repoURL = "https://github.com/org/repo.git" - cache := &recordingRepoCache{lookupPath: "/cache/org/repo.git"} - d := newRepoCheckoutTestDaemon(t, workspaceID, repoURL, cache) - d.registerTaskRepos(workspaceID, "task-1", []RepoData{{URL: repoURL, Ref: "release/v2"}}) + const ( + workspaceID = "ws-frozen-policy" + repoURL = "https://github.com/org/project-only.git" + ) + var managementReads int + client := NewClient("http://multica.invalid") + client.client.Transport = roundTripperFunc(func(*http.Request) (*http.Response, error) { + managementReads++ + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"workspace_id":"ws-frozen-policy","repos":[]}`)), + }, nil + }) + cache := &recordingRepoCache{lookupPath: "/cache/project-only.git"} + d := &Daemon{ + client: client, + repoCache: cache, + workspaces: map[string]*workspaceState{ + workspaceID: newWorkspaceState(workspaceID, nil, "", nil, json.RawMessage(`{"github_enabled":true,"co_authored_by_enabled":true}`)), + }, + logger: slog.Default(), + } + token, err := d.registerRepoCheckoutCapability(repoCheckoutCapabilityParams{ + WorkspaceID: workspaceID, + TaskID: "task-frozen", + WorkDir: "/tmp/task-frozen", + AgentName: "implementer", + Repos: []RepoData{{URL: repoURL}}, + CoAuthoredByEnabled: false, + }) + if err != nil { + t.Fatalf("register capability: %v", err) + } rec := httptest.NewRecorder() - body := strings.NewReader(`{"url":"` + repoURL + `","workspace_id":"` + workspaceID + `","workdir":"/tmp/work","task_id":"task-1"}`) - d.repoCheckoutHandler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/repo/checkout", body)) + req := httptest.NewRequest(http.MethodPost, "/repo/checkout", strings.NewReader(`{"url":"`+repoURL+`"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(repoCheckoutCapabilityHeader, token) + d.repoCheckoutHandler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) } - if got := cache.lastCreateParams().Ref; got != "release/v2" { - t.Fatalf("CreateWorktree Ref = %q, want release/v2", got) + if managementReads != 0 { + t.Fatalf("task checkout performed %d management API reads, want 0", managementReads) + } + if got := cache.lastCreateParams().CoAuthoredByEnabled; got { + t.Fatal("checkout used live workspace policy instead of capability snapshot") } } -func TestRepoCheckoutExplicitRefOverridesProjectDefault(t *testing.T) { +func TestRepoCheckoutFailsClosedWhenAssignedRepoCacheIsNotReady(t *testing.T) { t.Parallel() - const workspaceID = "ws-checkout" - const repoURL = "https://github.com/org/repo.git" - cache := &recordingRepoCache{lookupPath: "/cache/org/repo.git"} - d := newRepoCheckoutTestDaemon(t, workspaceID, repoURL, cache) - d.registerTaskRepos(workspaceID, "task-1", []RepoData{{URL: repoURL, Ref: "release/v2"}}) - + const repoURL = "https://github.com/org/not-ready.git" + d := &Daemon{repoCache: &recordingRepoCache{}, logger: slog.Default()} + token, err := d.registerRepoCheckoutCapability(repoCheckoutCapabilityParams{ + WorkspaceID: "ws-1", TaskID: "task-1", WorkDir: "/tmp/task-1", AgentName: "agent", + Repos: []RepoData{{URL: repoURL}}, + }) + if err != nil { + t.Fatalf("register capability: %v", err) + } rec := httptest.NewRecorder() - body := strings.NewReader(`{"url":"` + repoURL + `","workspace_id":"` + workspaceID + `","workdir":"/tmp/work","task_id":"task-1","ref":"hotfix"}`) - d.repoCheckoutHandler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/repo/checkout", body)) + req := httptest.NewRequest(http.MethodPost, "/repo/checkout", strings.NewReader(`{"url":"`+repoURL+`"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(repoCheckoutCapabilityHeader, token) + d.repoCheckoutHandler().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409: %s", rec.Code, rec.Body.String()) } - if got := cache.lastCreateParams().Ref; got != "hotfix" { - t.Fatalf("CreateWorktree Ref = %q, want explicit hotfix", got) +} + +func TestRepoCheckoutRequiresJSONAndLimitsBody(t *testing.T) { + t.Parallel() + + const repoURL = "https://github.com/org/repo.git" + d := &Daemon{repoCache: &recordingRepoCache{lookupPath: "/cache/repo.git"}, logger: slog.Default()} + token, err := d.registerRepoCheckoutCapability(repoCheckoutCapabilityParams{ + WorkspaceID: "ws-1", TaskID: "task-1", WorkDir: "/tmp/task-1", AgentName: "agent", + Repos: []RepoData{{URL: repoURL}}, + }) + if err != nil { + t.Fatalf("register capability: %v", err) + } + + for name, tc := range map[string]struct { + contentType string + body string + want int + }{ + "missing content type": {body: `{"url":"` + repoURL + `"}`, want: http.StatusUnsupportedMediaType}, + "wrong content type": {contentType: "text/plain", body: `{"url":"` + repoURL + `"}`, want: http.StatusUnsupportedMediaType}, + "oversized body": {contentType: "application/json", body: `{"url":"` + repoURL + `","padding":"` + strings.Repeat("x", 20<<10) + `"}`, want: http.StatusRequestEntityTooLarge}, + } { + t.Run(name, func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/repo/checkout", strings.NewReader(tc.body)) + if tc.contentType != "" { + req.Header.Set("Content-Type", tc.contentType) + } + req.Header.Set(repoCheckoutCapabilityHeader, token) + d.repoCheckoutHandler().ServeHTTP(rec, req) + if rec.Code != tc.want { + t.Fatalf("status = %d, want %d: %s", rec.Code, tc.want, rec.Body.String()) + } + }) } } +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + func newRepoCheckoutTestDaemon(t *testing.T, workspaceID, repoURL string, cache *recordingRepoCache) *Daemon { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -310,17 +1161,29 @@ func newBlockingLookupRepoCache(path string) *blockingLookupRepoCache { } } -func (c *blockingLookupRepoCache) Lookup(_, _ string) string { +func (c *blockingLookupRepoCache) LookupContext(ctx context.Context, _, _ string) string { select { case <-c.lookupSeen: default: close(c.lookupSeen) } - <-c.releaseLookup - return c.path + select { + case <-c.releaseLookup: + return c.path + case <-ctx.Done(): + return "" + } +} + +func (c *blockingLookupRepoCache) ResolveContext(ctx context.Context, _ string, url string) (repocache.ResolvedRepo, error) { + path := c.LookupContext(ctx, "", url) + if path == "" { + return repocache.ResolvedRepo{}, errors.New("repo unavailable") + } + return repocache.ResolvedRepo{URL: url, BarePath: path}, nil } -func (c *blockingLookupRepoCache) Sync(string, []repocache.RepoInfo) error { +func (c *blockingLookupRepoCache) SyncContext(context.Context, string, []repocache.RepoInfo) error { return nil } @@ -328,7 +1191,7 @@ func (c *blockingLookupRepoCache) WithRepoLock(_ string, fn func() error) error return fn() } -func (c *blockingLookupRepoCache) CreateWorktree(repocache.WorktreeParams) (*repocache.WorktreeResult, error) { +func (c *blockingLookupRepoCache) CreateWorktreeContext(context.Context, repocache.WorktreeParams) (*repocache.WorktreeResult, error) { return nil, nil } @@ -338,11 +1201,85 @@ type recordingRepoCache struct { params []repocache.WorktreeParams } -func (c *recordingRepoCache) Lookup(_, _ string) string { +type blockingCreateRepoCache struct { + path string + createSeen chan struct{} + releaseCreate chan struct{} + releaseOnce sync.Once + mu sync.Mutex + creates int +} + +func newBlockingCreateRepoCache(path string) *blockingCreateRepoCache { + return &blockingCreateRepoCache{ + path: path, + createSeen: make(chan struct{}), + releaseCreate: make(chan struct{}), + } +} + +func (c *blockingCreateRepoCache) LookupContext(context.Context, string, string) string { + return c.path +} + +func (c *blockingCreateRepoCache) ResolveContext(_ context.Context, _ string, url string) (repocache.ResolvedRepo, error) { + return repocache.ResolvedRepo{URL: url, BarePath: c.path}, nil +} + +func (c *blockingCreateRepoCache) SyncContext(context.Context, string, []repocache.RepoInfo) error { + return nil +} + +func (c *blockingCreateRepoCache) WithRepoLock(_ string, fn func() error) error { return fn() } + +func (c *blockingCreateRepoCache) CreateWorktreeContext(ctx context.Context, params repocache.WorktreeParams) (*repocache.WorktreeResult, error) { + c.mu.Lock() + c.creates++ + c.mu.Unlock() + select { + case <-c.createSeen: + default: + close(c.createSeen) + } + select { + case <-c.releaseCreate: + return &repocache.WorktreeResult{Path: params.WorkDir, BranchName: "agent/test"}, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (c *blockingCreateRepoCache) waitForCreate(t *testing.T) { + t.Helper() + select { + case <-c.createSeen: + case <-time.After(time.Second): + t.Fatal("repo checkout did not call CreateWorktree") + } +} + +func (c *blockingCreateRepoCache) release() { + c.releaseOnce.Do(func() { close(c.releaseCreate) }) +} + +func (c *blockingCreateRepoCache) createCount() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.creates +} + +func (c *recordingRepoCache) LookupContext(context.Context, string, string) string { return c.lookupPath } -func (c *recordingRepoCache) Sync(string, []repocache.RepoInfo) error { +func (c *recordingRepoCache) ResolveContext(_ context.Context, _ string, url string) (repocache.ResolvedRepo, error) { + if c.lookupPath == "" { + return repocache.ResolvedRepo{}, errors.New("repo unavailable") + } + return repocache.ResolvedRepo{URL: url, BarePath: c.lookupPath}, nil +} + +func (c *recordingRepoCache) SyncContext(context.Context, string, []repocache.RepoInfo) error { return nil } @@ -350,7 +1287,7 @@ func (c *recordingRepoCache) WithRepoLock(_ string, fn func() error) error { return fn() } -func (c *recordingRepoCache) CreateWorktree(params repocache.WorktreeParams) (*repocache.WorktreeResult, error) { +func (c *recordingRepoCache) CreateWorktreeContext(_ context.Context, params repocache.WorktreeParams) (*repocache.WorktreeResult, error) { c.mu.Lock() defer c.mu.Unlock() c.params = append(c.params, params) @@ -366,12 +1303,18 @@ func (c *recordingRepoCache) lastCreateParams() repocache.WorktreeParams { return c.params[len(c.params)-1] } +func (c *recordingRepoCache) createCount() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.params) +} + func (c *blockingLookupRepoCache) waitForLookup(t *testing.T) { t.Helper() select { case <-c.lookupSeen: case <-time.After(time.Second): - t.Fatal("registerTaskRepos did not call repo lookup") + t.Fatal("repo checkout did not call cache lookup") } } @@ -381,11 +1324,13 @@ func (c *blockingLookupRepoCache) release() { }) } -func assertActiveTaskCount(t *testing.T, h http.HandlerFunc, want int64) { +func assertActiveTaskCount(t *testing.T, h http.HandlerFunc, credential string, want int64) { t.Helper() rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) - var resp HealthResponse + req := httptest.NewRequest(http.MethodGet, "/diagnostics", nil) + req.Header.Set(ShutdownCredentialHeader, credential) + h.ServeHTTP(rec, req) + var resp DiagnosticsResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } diff --git a/server/internal/daemon/local_directory.go b/server/internal/daemon/local_directory.go index 6e78e4266e9..43e999de98f 100644 --- a/server/internal/daemon/local_directory.go +++ b/server/internal/daemon/local_directory.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "runtime" "strings" "sync" @@ -212,6 +213,70 @@ func validateLocalPath(absPath string) error { return nil } +// validateLocalDirectoryTree prevents a writable local_directory from +// exposing another daemon-owner path through a pre-existing hard link. The +// sandbox grants write access to the complete tree, so every regular file +// beneath the selected root must have exactly one directory entry. +func validateLocalDirectoryTree(root string) error { + return filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return fmt.Errorf("local_directory: inspect tree entry %q: %w", path, walkErr) + } + if entry.Type()&os.ModeSymlink != 0 { + return nil + } + info, err := entry.Info() + if err != nil { + return fmt.Errorf("local_directory: inspect tree entry %q: %w", path, err) + } + if !info.Mode().IsRegular() { + return nil + } + links, ok := localPathLinkCount(info) + if !ok { + return fmt.Errorf("local_directory: regular file link count is unavailable: %q", path) + } + if links != 1 { + return fmt.Errorf("local_directory: regular file must have exactly one hard link: %q", path) + } + return nil + }) +} + +// localPathLinkCount reads the platform stat object's Nlink field without +// importing an OS-specific syscall package. Unknown platforms fail closed. +func localPathLinkCount(info os.FileInfo) (uint64, bool) { + v := reflect.ValueOf(info.Sys()) + if !v.IsValid() { + return 0, false + } + if v.Kind() == reflect.Pointer { + if v.IsNil() { + return 0, false + } + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return 0, false + } + field := v.FieldByName("Nlink") + if !field.IsValid() { + return 0, false + } + switch field.Kind() { + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return field.Uint(), true + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + value := field.Int() + if value < 0 { + return 0, false + } + return uint64(value), true + default: + return 0, false + } +} + // isBlacklistedLocalPath rejects paths that map to the whole machine or an // entire user profile. The intent is to keep the daemon from accidentally // stamping context files (.agent_context/, .claude/skills/, .multica/) at @@ -314,7 +379,7 @@ func systemRootBlacklist() []string { if runtime.GOOS == "windows" { return []string{`C:\Users`, `C:\ProgramData`, `C:\Program Files`, `C:\Program Files (x86)`, `C:\Windows`} } - return []string{"/", "/Users", "/Users/Shared", "/home", "/root", "/var", "/etc", "/tmp", "/usr", "/opt"} + return []string{"/", "/Users", "/Users/Shared", "/home", "/root", "/var", "/etc", "/tmp", "/usr", "/opt", "/run"} } // checkDirReadWrite verifies the daemon process can both read directory diff --git a/server/internal/daemon/local_directory_test.go b/server/internal/daemon/local_directory_test.go index a6d11171826..eed68fc2a02 100644 --- a/server/internal/daemon/local_directory_test.go +++ b/server/internal/daemon/local_directory_test.go @@ -215,6 +215,70 @@ func TestAcquireLocalDirectoryLockSkipsSquadLeaderTasks(t *testing.T) { } } +func TestAcquireLocalDirectoryLockRejectsHardlinkedTreeAndReleasesLock(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "project") + if err := os.Mkdir(root, 0o755); err != nil { + t.Fatalf("mkdir project: %v", err) + } + ownerFile := filepath.Join(parent, "owner-secret") + if err := os.WriteFile(ownerFile, []byte("owner-only\n"), 0o600); err != nil { + t.Fatalf("write owner file: %v", err) + } + if err := os.Link(ownerFile, filepath.Join(root, "borrowed-secret")); err != nil { + t.Skipf("hardlinks unavailable on test filesystem: %v", err) + } + realRoot, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatalf("resolve project root: %v", err) + } + + var failCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/fail") { + t.Errorf("unexpected daemon call: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + failCalls.Add(1) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + const daemonID = "d-test" + raw, err := json.Marshal(localDirectoryRef{LocalPath: root, DaemonID: daemonID}) + if err != nil { + t.Fatalf("marshal resource: %v", err) + } + locker := NewLocalPathLocker() + d := &Daemon{ + client: NewClient(srv.URL), + cfg: Config{DaemonID: daemonID}, + localPathLocks: locker, + logger: slog.Default(), + } + task := Task{ + ID: "task-hardlink", + ProjectResources: []ProjectResourceData{ + {ID: "r1", ResourceType: localDirectoryResourceType, ResourceRef: raw}, + }, + } + + release, abort := d.acquireLocalDirectoryLockIfNeeded(context.Background(), task, slog.Default()) + if !abort { + t.Fatal("hardlinked local_directory returned execution authority") + } + if release != nil { + t.Fatal("hardlinked local_directory returned a release callback") + } + if got := failCalls.Load(); got != 1 { + t.Fatalf("fail calls = %d, want 1", got) + } + if holder := locker.Holder(realRoot); holder != "" { + t.Fatalf("path lock retained after rejection by %q", holder) + } +} + func TestValidateLocalPath(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("blacklist constants are POSIX-only in this test") @@ -368,6 +432,58 @@ func TestValidateLocalPath(t *testing.T) { } }) + t.Run("rejects runtime authority root", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX runtime root") + } + reason, blocked := isBlacklistedLocalPath("/run") + if !blocked || !strings.Contains(reason, "protected system root") { + t.Fatalf("isBlacklistedLocalPath(/run) = (%q, %v), want protected root", reason, blocked) + } + }) + +} + +func TestValidateLocalDirectoryTree(t *testing.T) { + t.Run("accepts ordinary nested files", func(t *testing.T) { + root := t.TempDir() + nested := filepath.Join(root, "nested") + if err := os.Mkdir(nested, 0o755); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + if err := os.WriteFile(filepath.Join(nested, "source.go"), []byte("package example\n"), 0o644); err != nil { + t.Fatalf("write nested file: %v", err) + } + + if err := validateLocalDirectoryTree(root); err != nil { + t.Fatalf("ordinary local_directory tree rejected: %v", err) + } + }) + + t.Run("rejects nested hardlink to owner file", func(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "project") + nested := filepath.Join(root, "nested") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + ownerFile := filepath.Join(parent, "owner-secret") + if err := os.WriteFile(ownerFile, []byte("owner-only\n"), 0o600); err != nil { + t.Fatalf("write owner file: %v", err) + } + linkedPath := filepath.Join(nested, "borrowed-secret") + if err := os.Link(ownerFile, linkedPath); err != nil { + t.Skipf("hardlinks unavailable on test filesystem: %v", err) + } + + err := validateLocalDirectoryTree(root) + if err == nil { + t.Fatal("expected nested hardlink to be rejected") + } + if !strings.Contains(err.Error(), "hard link") { + t.Fatalf("error %q did not identify the hardlink violation", err) + } + }) } // TestIsDriveRoot covers the Windows drive-root generalisation. Static diff --git a/server/internal/daemon/repo_checkout_capability.go b/server/internal/daemon/repo_checkout_capability.go new file mode 100644 index 00000000000..592f2290a67 --- /dev/null +++ b/server/internal/daemon/repo_checkout_capability.go @@ -0,0 +1,152 @@ +package daemon + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "path/filepath" + "strings" +) + +const repoCheckoutCapabilityHeader = "X-Multica-Repo-Checkout-Capability" + +type repoCheckoutCapabilityParams struct { + WorkspaceID string + TaskID string + WorkDir string + AgentName string + Repos []RepoData + CoAuthoredByEnabled bool +} + +type repoCheckoutCapability struct { + workspaceID string + taskID string + workDir string + agentName string + repoRefs map[string]string + claimedRepos map[string]struct{} + coAuthoredByEnabled bool + activeRequests int + revoked bool + drained chan struct{} + claimRepo func(string) bool +} + +func (d *Daemon) registerRepoCheckoutCapability(params repoCheckoutCapabilityParams) (string, error) { + params.WorkspaceID = strings.TrimSpace(params.WorkspaceID) + params.TaskID = strings.TrimSpace(params.TaskID) + params.WorkDir = strings.TrimSpace(params.WorkDir) + params.AgentName = strings.TrimSpace(params.AgentName) + if params.WorkspaceID == "" || params.TaskID == "" || params.WorkDir == "" || params.AgentName == "" { + return "", fmt.Errorf("repo checkout capability requires workspace, task, workdir, and agent identity") + } + if !filepath.IsAbs(params.WorkDir) { + return "", fmt.Errorf("repo checkout capability workdir must be absolute") + } + + repoRefs := make(map[string]string, len(params.Repos)) + for _, repo := range params.Repos { + url := strings.TrimSpace(repo.URL) + if url == "" { + return "", fmt.Errorf("repo checkout capability contains an empty repo URL") + } + ref := strings.TrimSpace(repo.Ref) + if existing, ok := repoRefs[url]; ok && existing != ref { + return "", fmt.Errorf("repo checkout capability contains conflicting refs for %s", url) + } + repoRefs[url] = ref + } + if len(repoRefs) == 0 { + return "", fmt.Errorf("repo checkout capability requires at least one repo") + } + + binding := &repoCheckoutCapability{ + workspaceID: params.WorkspaceID, + taskID: params.TaskID, + workDir: filepath.Clean(params.WorkDir), + agentName: params.AgentName, + repoRefs: repoRefs, + claimedRepos: make(map[string]struct{}, len(repoRefs)), + coAuthoredByEnabled: params.CoAuthoredByEnabled, + drained: make(chan struct{}), + } + for { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("generate repo checkout capability: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(raw) + + d.repoCheckoutCapabilityMu.Lock() + if d.repoCheckoutCapabilities == nil { + d.repoCheckoutCapabilities = make(map[string]*repoCheckoutCapability) + } + if _, exists := d.repoCheckoutCapabilities[token]; !exists { + d.repoCheckoutCapabilities[token] = binding + d.repoCheckoutCapabilityMu.Unlock() + return token, nil + } + d.repoCheckoutCapabilityMu.Unlock() + } +} + +func (d *Daemon) acquireRepoCheckoutCapability(token string) (repoCheckoutCapability, func(), bool) { + token = strings.TrimSpace(token) + if token == "" { + return repoCheckoutCapability{}, nil, false + } + d.repoCheckoutCapabilityMu.Lock() + entry, ok := d.repoCheckoutCapabilities[token] + if !ok || entry.revoked { + d.repoCheckoutCapabilityMu.Unlock() + return repoCheckoutCapability{}, nil, false + } + entry.activeRequests++ + binding := *entry + binding.claimRepo = func(repoURL string) bool { + d.repoCheckoutCapabilityMu.Lock() + defer d.repoCheckoutCapabilityMu.Unlock() + if _, claimed := entry.claimedRepos[repoURL]; claimed { + return false + } + entry.claimedRepos[repoURL] = struct{}{} + return true + } + d.repoCheckoutCapabilityMu.Unlock() + + var released bool + release := func() { + d.repoCheckoutCapabilityMu.Lock() + defer d.repoCheckoutCapabilityMu.Unlock() + if released { + return + } + released = true + entry.activeRequests-- + if entry.revoked && entry.activeRequests == 0 { + close(entry.drained) + } + } + return binding, release, true +} + +func (d *Daemon) revokeRepoCheckoutCapability(token string) { + token = strings.TrimSpace(token) + if token == "" { + return + } + d.repoCheckoutCapabilityMu.Lock() + entry, ok := d.repoCheckoutCapabilities[token] + if !ok { + d.repoCheckoutCapabilityMu.Unlock() + return + } + delete(d.repoCheckoutCapabilities, token) + entry.revoked = true + if entry.activeRequests == 0 { + close(entry.drained) + } + d.repoCheckoutCapabilityMu.Unlock() + <-entry.drained +} diff --git a/server/internal/daemon/repocache/cache.go b/server/internal/daemon/repocache/cache.go index 20e8576d236..c7494877c1b 100644 --- a/server/internal/daemon/repocache/cache.go +++ b/server/internal/daemon/repocache/cache.go @@ -4,6 +4,8 @@ package repocache import ( "context" + "crypto/sha256" + "errors" "fmt" "log/slog" "net/url" @@ -11,44 +13,36 @@ import ( "os/exec" "path/filepath" "regexp" - "strconv" + "runtime" "strings" "sync" "time" ) -// gitEnv returns an environment for git subprocesses that contact remotes. -// It passes the full daemon environment so credential helpers (e.g. gh) can -// locate their config, and disables TTY prompting so auth failures produce -// clear errors instead of blocking on a non-existent terminal. -// -// safe.directory=* is set via GIT_CONFIG_* env vars so git trusts all -// directories regardless of ownership. The daemon manages its own bare -// caches and worktrees, so the ownership check adds no security value -// and breaks CI environments where the runner UID differs from the -// directory owner. +// gitEnv is deliberately independent of the daemon owner's environment. +// Repository broker commands must not inherit credentials, helpers, config, +// hooks, filters, URL rewrites, remote helpers, or interactive prompt state. func gitEnv() []string { - base := os.Environ() - - // Find the existing GIT_CONFIG_COUNT so we append at the next index - // rather than overwriting any env-scoped git config (auth, URL - // rewrites, extra headers, etc.). - existing := 0 - for _, e := range base { - if strings.HasPrefix(e, "GIT_CONFIG_COUNT=") { - if n, err := strconv.Atoi(strings.TrimPrefix(e, "GIT_CONFIG_COUNT=")); err == nil { - existing = n - } - } + nullDevice := "/dev/null" + if runtime.GOOS == "windows" { + nullDevice = "NUL" } - - idx := strconv.Itoa(existing) - return append(base, + return []string{ "GIT_TERMINAL_PROMPT=0", - "GIT_CONFIG_COUNT="+strconv.Itoa(existing+1), - "GIT_CONFIG_KEY_"+idx+"=safe.directory", - "GIT_CONFIG_VALUE_"+idx+"=*", - ) + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_SYSTEM=" + nullDevice, + "GIT_CONFIG_GLOBAL=" + nullDevice, + "GIT_ALLOW_PROTOCOL=file:http:https:git", + "GIT_CONFIG_COUNT=3", + "GIT_CONFIG_KEY_0=credential.helper", + "GIT_CONFIG_VALUE_0=", + "GIT_CONFIG_KEY_1=core.hooksPath", + "GIT_CONFIG_VALUE_1=" + nullDevice, + "GIT_CONFIG_KEY_2=protocol.ext.allow", + "GIT_CONFIG_VALUE_2=never", + "LC_ALL=C", + "LANG=C", + } } var agentGitExcludePatterns = []string{ @@ -64,61 +58,202 @@ var agentGitExcludePatterns = []string{ const repoCacheGitTimeout = 10 * time.Minute -func newGitCommand(ctx context.Context, args ...string) *exec.Cmd { - cmd := exec.CommandContext(ctx, "git", args...) - cmd.Env = gitEnv() +// GitBrokerOptions configures the only executable and environment permitted +// for repository-cache Git operations. +type GitBrokerOptions struct { + Executable string + Timeout time.Duration +} + +type gitBroker struct { + executable string + env []string + timeout time.Duration +} + +func newGitBroker(options GitBrokerOptions) (*gitBroker, error) { + if !filepath.IsAbs(options.Executable) { + return nil, fmt.Errorf("git executable must be absolute: %q", options.Executable) + } + resolved, err := filepath.EvalSymlinks(options.Executable) + if err != nil { + return nil, fmt.Errorf("resolve git executable: %w", err) + } + info, err := os.Stat(resolved) + if err != nil { + return nil, fmt.Errorf("stat git executable: %w", err) + } + if !info.Mode().IsRegular() || info.Mode()&0o111 == 0 { + return nil, fmt.Errorf("git executable is not an executable regular file: %s", resolved) + } + if info.Mode().Perm()&0o022 != 0 { + return nil, fmt.Errorf("git executable is group/world writable: %s", resolved) + } + if err := validateTrustedExecutablePath(resolved); err != nil { + return nil, err + } + timeout := options.Timeout + if timeout <= 0 { + timeout = repoCacheGitTimeout + } + return &gitBroker{executable: resolved, env: gitEnv(), timeout: timeout}, nil +} + +func defaultGitBroker() (*gitBroker, error) { + candidates := []string{"/usr/bin/git", "/bin/git"} + if runtime.GOOS == "windows" { + candidates = []string{`C:\\Program Files\\Git\\cmd\\git.exe`} + } + for _, candidate := range candidates { + broker, err := newGitBroker(GitBrokerOptions{Executable: candidate}) + if err == nil { + return broker, nil + } + } + return nil, fmt.Errorf("no trusted absolute Git executable found") +} + +func (g *gitBroker) command(ctx context.Context, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, g.executable, args...) + cmd.Env = append([]string(nil), g.env...) cmd.WaitDelay = 5 * time.Second return cmd } -func runGitCombinedOutput(args ...string) ([]byte, error) { - return runGitCombinedOutputWithTimeout(repoCacheGitTimeout, args...) +func (g *gitBroker) withTimeout(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { + if timeout <= 0 { + timeout = g.timeout + } + return context.WithTimeout(parent, timeout) } -func runGitCombinedOutputWithTimeout(timeout time.Duration, args ...string) ([]byte, error) { - ctx, cancel := context.WithTimeout(context.Background(), timeout) +func (g *gitBroker) combinedOutput(ctx context.Context, timeout time.Duration, args ...string) ([]byte, error) { + ctx, cancel := g.withTimeout(ctx, timeout) defer cancel() + out, err := g.command(ctx, args...).CombinedOutput() + if ctx.Err() != nil { + return out, ctx.Err() + } + return out, err +} - cmd := newGitCommand(ctx, args...) +func (g *gitBroker) combinedOutputInWorktree(ctx context.Context, timeout time.Duration, handle *worktreeHandle, args ...string) ([]byte, error) { + ctx, cancel := g.withTimeout(ctx, timeout) + defer cancel() + cmd, err := g.worktreeCommand(ctx, handle, args...) + if err != nil { + return nil, err + } out, err := cmd.CombinedOutput() - if ctx.Err() == context.DeadlineExceeded { - return out, fmt.Errorf("git command timed out after %s: %w", timeout, ctx.Err()) + if ctx.Err() != nil { + return out, ctx.Err() } return out, err } -func runGitOutput(args ...string) ([]byte, error) { - return runGitOutputWithTimeout(repoCacheGitTimeout, args...) +func (g *gitBroker) combinedOutputInBareCache(ctx context.Context, timeout time.Duration, handle *bareCacheHandle, args ...string) ([]byte, error) { + ctx, cancel := g.withTimeout(ctx, timeout) + defer cancel() + cmd, err := g.bareCacheCommand(ctx, handle, args...) + if err != nil { + return nil, err + } + out, err := cmd.CombinedOutput() + if ctx.Err() != nil { + return out, ctx.Err() + } + if identityErr := handle.RecheckPath(); identityErr != nil { + return out, identityErr + } + return out, err } -func runGitOutputWithTimeout(timeout time.Duration, args ...string) ([]byte, error) { - ctx, cancel := context.WithTimeout(context.Background(), timeout) +func (g *gitBroker) outputInBareCache(ctx context.Context, timeout time.Duration, handle *bareCacheHandle, args ...string) ([]byte, error) { + ctx, cancel := g.withTimeout(ctx, timeout) defer cancel() - - cmd := newGitCommand(ctx, args...) + cmd, err := g.bareCacheCommand(ctx, handle, args...) + if err != nil { + return nil, err + } out, err := cmd.Output() - if ctx.Err() == context.DeadlineExceeded { - return out, fmt.Errorf("git command timed out after %s: %w", timeout, ctx.Err()) + if ctx.Err() != nil { + return out, ctx.Err() + } + if identityErr := handle.RecheckPath(); identityErr != nil { + return out, identityErr } return out, err } -func runGit(args ...string) error { - return runGitWithTimeout(repoCacheGitTimeout, args...) +func (g *gitBroker) runInBareCache(ctx context.Context, timeout time.Duration, handle *bareCacheHandle, args ...string) error { + ctx, cancel := g.withTimeout(ctx, timeout) + defer cancel() + cmd, err := g.bareCacheCommand(ctx, handle, args...) + if err != nil { + return err + } + err = cmd.Run() + if ctx.Err() != nil { + return ctx.Err() + } + if identityErr := handle.RecheckPath(); identityErr != nil { + return identityErr + } + return err } -func runGitWithTimeout(timeout time.Duration, args ...string) error { - ctx, cancel := context.WithTimeout(context.Background(), timeout) +func (g *gitBroker) output(ctx context.Context, timeout time.Duration, args ...string) ([]byte, error) { + ctx, cancel := g.withTimeout(ctx, timeout) defer cancel() + out, err := g.command(ctx, args...).Output() + if ctx.Err() != nil { + return out, ctx.Err() + } + return out, err +} - cmd := newGitCommand(ctx, args...) - err := cmd.Run() - if ctx.Err() == context.DeadlineExceeded { - return fmt.Errorf("git command timed out after %s: %w", timeout, ctx.Err()) +func (g *gitBroker) run(ctx context.Context, timeout time.Duration, args ...string) error { + ctx, cancel := g.withTimeout(ctx, timeout) + defer cancel() + err := g.command(ctx, args...).Run() + if ctx.Err() != nil { + return ctx.Err() } return err } +func legacyGitBroker() *gitBroker { + broker, err := defaultGitBroker() + if err != nil { + return &gitBroker{executable: filepath.Join(string(filepath.Separator), "trusted-git-unavailable"), env: gitEnv(), timeout: repoCacheGitTimeout} + } + return broker +} + +func runGitCombinedOutput(args ...string) ([]byte, error) { + return runGitCombinedOutputWithTimeout(repoCacheGitTimeout, args...) +} + +func runGitCombinedOutputWithTimeout(timeout time.Duration, args ...string) ([]byte, error) { + return legacyGitBroker().combinedOutput(context.Background(), timeout, args...) +} + +func runGitOutput(args ...string) ([]byte, error) { + return runGitOutputWithTimeout(repoCacheGitTimeout, args...) +} + +func runGitOutputWithTimeout(timeout time.Duration, args ...string) ([]byte, error) { + return legacyGitBroker().output(context.Background(), timeout, args...) +} + +func runGit(args ...string) error { + return runGitWithTimeout(repoCacheGitTimeout, args...) +} + +func runGitWithTimeout(timeout time.Duration, args ...string) error { + return legacyGitBroker().run(context.Background(), timeout, args...) +} + // RepoInfo describes a repository to cache. type RepoInfo struct { URL string @@ -130,10 +265,31 @@ type CachedRepo struct { LocalPath string // absolute path to the bare clone } +// ResolvedRepo binds an assigned repository URL to the exact canonical bare +// cache path whose origin still matches that assignment. +type ResolvedRepo struct { + URL string + BarePath string +} + // Cache manages bare git clones for workspace repositories. type Cache struct { root string // base directory for all caches (e.g. ~/multica_workspaces/.repos) logger *slog.Logger + git *gitBroker + gitErr error + // existingWorktreeHook is test-only synchronization for adversarial + // replacement scenarios. Production caches leave it nil. + existingWorktreeHook func(stage, worktreePath string) + // worktreeAddHook is test-only synchronization after Git starts creating a + // new worktree but before the daemon waits for it to exit. + worktreeAddHook func(stage, worktreePath string) + // worktreePublicationHook is test-only synchronization immediately before + // an identity-bound staging worktree is published into the task workdir. + worktreePublicationHook func(stage, worktreePath string) + // bareCacheHook is test-only synchronization for clone postcondition + // scenarios. Production caches leave it nil. + bareCacheHook func(stage, barePath string) // repoLocks maps bare repo path → dedicated mutex. Any mutating operation // on a given bare repo (clone, fetch, worktree add, ref update) must // hold its lock — git's own lockfiles (packed-refs.lock, config.lock, @@ -144,7 +300,28 @@ type Cache struct { // New creates a new repo cache rooted at the given directory. func New(root string, logger *slog.Logger) *Cache { - return &Cache{root: root, logger: logger} + broker, err := defaultGitBroker() + return &Cache{root: root, logger: logger, git: broker, gitErr: err} +} + +// NewWithGitBroker creates a cache bound to a caller-selected absolute Git +// executable. It fails closed instead of consulting PATH. +func NewWithGitBroker(root string, logger *slog.Logger, options GitBrokerOptions) (*Cache, error) { + broker, err := newGitBroker(options) + if err != nil { + return nil, err + } + return &Cache{root: root, logger: logger, git: broker}, nil +} + +func (c *Cache) requireGit() error { + if c.gitErr != nil { + return c.gitErr + } + if c.git == nil { + return fmt.Errorf("repository Git broker is not configured") + } + return nil } // lockForRepo returns the mutex dedicated to the given bare repo path. See @@ -167,33 +344,68 @@ func (c *Cache) lockForRepo(barePath string) *sync.Mutex { // but concurrent Sync calls (different workspaces, or the same workspace // re-synced while checkouts are running) do not block each other. func (c *Cache) Sync(workspaceID string, repos []RepoInfo) error { - wsDir := filepath.Join(c.root, workspaceID) - if err := os.MkdirAll(wsDir, 0o755); err != nil { - return fmt.Errorf("create workspace cache dir: %w", err) + return c.SyncContext(context.Background(), workspaceID, repos) +} + +// SyncContext is the task-safe Sync entry point. Every Git subprocess derives +// cancellation and deadlines from ctx and executes through the cache broker. +func (c *Cache) SyncContext(ctx context.Context, workspaceID string, repos []RepoInfo) error { + if err := c.requireGit(); err != nil { + return err + } + wsDir, err := c.workspaceCacheDir(workspaceID, true) + if err != nil { + return err } var firstErr error for _, repo := range repos { - if repo.URL == "" { + barePath, pathErr := cacheTargetPath(wsDir, repo.URL) + if pathErr != nil { + if firstErr == nil { + firstErr = pathErr + } continue } - barePath := filepath.Join(wsDir, bareDirName(repo.URL)) repoLock := c.lockForRepo(barePath) repoLock.Lock() - if isBareRepo(barePath) { - // Already cached — fetch latest. - c.logger.Info("repo cache: fetching", "url", repo.URL, "path", barePath) - if err := gitFetch(barePath); err != nil { - c.logger.Warn("repo cache: fetch failed", "url", repo.URL, "error", err) + _, statErr := os.Lstat(barePath) + if statErr == nil { + handle, err := c.openValidatedBareCache(barePath) + if err != nil { if firstErr == nil { firstErr = err } + repoLock.Unlock() + continue + } + if c.isBareRepoHandle(ctx, handle) { + // Already cached — fetch latest. + c.logger.Info("repo cache: fetching", "url", repo.URL, "path", barePath) + if err := c.gitFetchHandle(ctx, handle); err != nil { + c.logger.Warn("repo cache: fetch failed", "url", repo.URL, "error", err) + if firstErr == nil { + firstErr = err + } + } + } else { + err := fmt.Errorf("repo cache target already exists and is not a bare git repository: %s", barePath) + c.logger.Error("repo cache: unsafe existing target", "url", repo.URL, "path", barePath, "error", err) + if firstErr == nil { + firstErr = err + } + } + _ = handle.Close() + } else if !os.IsNotExist(statErr) { + wrapped := fmt.Errorf("stat repo cache target %s: %w", barePath, statErr) + if firstErr == nil { + firstErr = wrapped } } else { // Not cached — bare clone. c.logger.Info("repo cache: cloning", "url", repo.URL, "path", barePath) - if err := gitCloneBare(repo.URL, barePath); err != nil { + if err := c.gitCloneBare(ctx, repo.URL, barePath); err != nil { c.logger.Error("repo cache: clone failed", "url", repo.URL, "error", err) if firstErr == nil { firstErr = err @@ -208,13 +420,90 @@ func (c *Cache) Sync(workspaceID string, repos []RepoInfo) error { // Lookup returns the local bare clone path for a repo URL within a workspace. // Returns "" if not cached. func (c *Cache) Lookup(workspaceID, url string) string { - barePath := filepath.Join(c.root, workspaceID, bareDirName(url)) - if isBareRepo(barePath) { + return c.LookupContext(context.Background(), workspaceID, url) +} + +// LookupContext verifies a cached repository through the broker using ctx. +func (c *Cache) LookupContext(ctx context.Context, workspaceID, url string) string { + if c.requireGit() != nil { + return "" + } + barePath, err := c.lookupPath(ctx, workspaceID, url) + if err != nil { + return "" + } + handle, err := c.openValidatedBareCache(barePath) + if err != nil { + return "" + } + defer handle.Close() + if c.isBareRepoHandle(ctx, handle) { return barePath } return "" } +// Resolve returns a stable repository identity suitable for freezing into a +// task isolation policy. It rejects a cache whose on-disk origin has drifted +// from the URL that selected the cache path. +func (c *Cache) Resolve(workspaceID, rawURL string) (ResolvedRepo, error) { + return c.ResolveContext(context.Background(), workspaceID, rawURL) +} + +// ResolveContext resolves and verifies repository identity through the broker. +func (c *Cache) ResolveContext(ctx context.Context, workspaceID, rawURL string) (ResolvedRepo, error) { + if err := c.requireGit(); err != nil { + return ResolvedRepo{}, err + } + assignedURL := strings.TrimSpace(rawURL) + barePath, err := c.lookupPath(ctx, workspaceID, assignedURL) + if err != nil { + return ResolvedRepo{}, err + } + canonical, err := filepath.EvalSymlinks(barePath) + if err != nil { + return ResolvedRepo{}, fmt.Errorf("resolve cached repo path %q: %w", barePath, err) + } + canonical, err = filepath.Abs(canonical) + if err != nil { + return ResolvedRepo{}, fmt.Errorf("make cached repo path absolute: %w", err) + } + if canonical != barePath { + return ResolvedRepo{}, fmt.Errorf("cached repo path %q resolves through symlink to %q", barePath, canonical) + } + handle, err := c.openValidatedBareCache(canonical) + if err != nil { + return ResolvedRepo{}, err + } + defer handle.Close() + if !c.isBareRepoHandle(ctx, handle) { + return ResolvedRepo{}, fmt.Errorf("repo not found in cache: %s (workspace: %s)", rawURL, workspaceID) + } + origin, err := c.git.outputInBareCache(ctx, 0, handle, "remote", "get-url", "origin") + if err != nil { + return ResolvedRepo{}, fmt.Errorf("read cached repo origin for %q: %w", assignedURL, err) + } + if got := strings.TrimSpace(string(origin)); got != assignedURL { + return ResolvedRepo{}, fmt.Errorf("cached repo origin mismatch: got %q, want %q", got, assignedURL) + } + return ResolvedRepo{URL: assignedURL, BarePath: canonical}, nil +} + +func (c *Cache) lookupPath(ctx context.Context, workspaceID, rawURL string) (string, error) { + wsDir, err := c.workspaceCacheDir(workspaceID, false) + if err != nil { + return "", fmt.Errorf("repo not found in cache: %s (workspace: %s): %w", rawURL, workspaceID, err) + } + barePath, err := cacheTargetPath(wsDir, rawURL) + if err != nil { + return "", err + } + if err := c.validateBareCachePath(barePath); err != nil { + return "", fmt.Errorf("repo not found in cache: %s (workspace: %s): %w", rawURL, workspaceID, err) + } + return barePath, nil +} + // WithRepoLock serializes caller-supplied mutations on a bare repo against all // other same-repo operations that use the cache's lock (Sync, Fetch, // CreateWorktree, and daemon GC maintenance). @@ -227,11 +516,200 @@ func (c *Cache) WithRepoLock(barePath string, fn func() error) error { // Fetch runs `git fetch origin` on a cached bare clone to get latest refs. func (c *Cache) Fetch(barePath string) error { + return c.FetchContext(context.Background(), barePath) +} + +// FetchContext fetches a cache through the broker using caller cancellation. +func (c *Cache) FetchContext(ctx context.Context, barePath string) error { + if err := c.requireGit(); err != nil { + return err + } return c.WithRepoLock(barePath, func() error { - return gitFetch(barePath) + handle, err := c.openValidatedBareCache(barePath) + if err != nil { + return err + } + defer handle.Close() + return c.gitFetchHandle(ctx, handle) }) } +func (c *Cache) workspaceCacheDir(workspaceID string, create bool) (string, error) { + workspaceID = strings.TrimSpace(workspaceID) + if !isSafePathComponent(workspaceID) { + return "", fmt.Errorf("unsafe workspace ID %q", workspaceID) + } + + root, err := canonicalExistingDir(c.root) + if err != nil { + if !create || !os.IsNotExist(err) { + return "", fmt.Errorf("resolve repo cache root: %w", err) + } + if err := os.MkdirAll(c.root, 0o755); err != nil { + return "", fmt.Errorf("create repo cache root: %w", err) + } + root, err = canonicalExistingDir(c.root) + if err != nil { + return "", fmt.Errorf("resolve repo cache root: %w", err) + } + } + if err := validateDaemonOwnedDirectoryPath(root, "repo cache root"); err != nil { + return "", err + } + + wsDir := filepath.Join(root, workspaceID) + info, statErr := os.Lstat(wsDir) + if statErr != nil { + if !os.IsNotExist(statErr) { + return "", fmt.Errorf("stat workspace cache dir: %w", statErr) + } + if !create { + return "", fmt.Errorf("workspace cache not found: %s", workspaceID) + } + if err := os.Mkdir(wsDir, 0o755); err != nil { + return "", fmt.Errorf("create workspace cache dir: %w", err) + } + info, statErr = os.Lstat(wsDir) + } + if statErr != nil { + return "", fmt.Errorf("stat workspace cache dir: %w", statErr) + } + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("workspace cache dir must not be a symlink: %s", wsDir) + } + if !info.IsDir() { + return "", fmt.Errorf("workspace cache path is not a directory: %s", wsDir) + } + + canonical, err := filepath.EvalSymlinks(wsDir) + if err != nil { + return "", fmt.Errorf("resolve workspace cache dir: %w", err) + } + canonical, err = filepath.Abs(canonical) + if err != nil { + return "", fmt.Errorf("make workspace cache dir absolute: %w", err) + } + if !pathWithin(root, canonical) { + return "", fmt.Errorf("workspace cache dir escapes repo cache root: %s", canonical) + } + if err := validateDaemonOwnedDirectoryPath(canonical, "workspace cache directory"); err != nil { + return "", err + } + return filepath.Clean(canonical), nil +} + +// validateBareCachePath establishes the pathname trust boundary required +// before any Git process may inspect or mutate an existing bare cache. +func (c *Cache) validateBareCachePath(barePath string) error { + abs, err := filepath.Abs(barePath) + if err != nil { + return fmt.Errorf("make bare repository cache path absolute: %w", err) + } + abs = filepath.Clean(abs) + root, err := canonicalExistingDir(c.root) + if err != nil { + return fmt.Errorf("resolve repo cache root: %w", err) + } + canonical, err := canonicalExistingDir(barePath) + if err != nil { + return fmt.Errorf("resolve bare repository cache: %w", err) + } + if canonical != abs { + return fmt.Errorf("bare repository cache path resolves through symlink: got %s, want %s", abs, canonical) + } + if !pathWithin(root, canonical) { + return fmt.Errorf("bare repository cache escapes repo cache root: %s", canonical) + } + if err := validateDaemonOwnedDirectoryPath(root, "repo cache root"); err != nil { + return err + } + for current := filepath.Dir(canonical); ; current = filepath.Dir(current) { + if !pathWithin(root, current) && current != root { + break + } + if err := validateDaemonOwnedDirectoryPath(current, "repository cache parent"); err != nil { + return err + } + if current == root { + break + } + } + return validateDaemonOwnedDirectoryPath(canonical, "bare repository cache") +} + +func (c *Cache) openValidatedBareCache(barePath string) (*bareCacheHandle, error) { + if err := c.validateBareCachePath(barePath); err != nil { + return nil, err + } + handle, err := openBareCacheHandle(barePath) + if err != nil { + return nil, fmt.Errorf("open bare repository cache identity: %w", err) + } + if err := c.validateBareCachePath(barePath); err != nil { + _ = handle.Close() + return nil, err + } + if err := handle.RecheckPath(); err != nil { + _ = handle.Close() + return nil, err + } + return handle, nil +} + +func cacheTargetPath(wsDir, rawURL string) (string, error) { + name, err := bareDirNameSafe(rawURL) + if err != nil { + return "", err + } + target := filepath.Join(wsDir, name) + if !pathWithin(wsDir, target) { + return "", fmt.Errorf("repo cache target escapes workspace cache: %s", target) + } + return target, nil +} + +func canonicalExistingDir(path string) (string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + info, err := os.Stat(abs) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", fmt.Errorf("not a directory: %s", abs) + } + canonical, err := filepath.EvalSymlinks(abs) + if err != nil { + return "", err + } + return filepath.Clean(canonical), nil +} + +func pathWithin(parent, target string) bool { + parentAbs, err := filepath.Abs(parent) + if err != nil { + return false + } + targetAbs, err := filepath.Abs(target) + if err != nil { + return false + } + rel, err := filepath.Rel(filepath.Clean(parentAbs), filepath.Clean(targetAbs)) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +func isSafePathComponent(name string) bool { + if name == "" || name == "." || name == ".." || filepath.IsAbs(name) { + return false + } + return !strings.ContainsAny(name, `/\\`) +} + // bareDirName returns a filesystem-safe, collision-free directory name for // the bare clone of rawURL. The name is built from the host plus each // path segment, joined by '+'. '+' is disallowed in GitHub and GitLab @@ -250,6 +728,17 @@ func (c *Cache) Fetch(barePath string) error { // git@gitlab.example.com-22:org/repo.git -> gitlab.example.com-22+org+repo.git // my-repo -> my-repo.git (bare name fallback) func bareDirName(rawURL string) string { + name, err := bareDirNameSafe(rawURL) + if err != nil { + return "" + } + return name +} + +func bareDirNameSafe(rawURL string) (string, error) { + if err := validateRepoURL(rawURL); err != nil { + return "", err + } rawURL = strings.TrimRight(rawURL, "/") host, path := splitHostAndPath(rawURL) @@ -277,9 +766,12 @@ func bareDirName(rawURL string) string { name += ".git" } if name == "" || name == ".git" { - name = "repo.git" + return "", fmt.Errorf("unsafe repo URL %q: empty repository name", rawURL) } - return name + if !isSafePathComponent(name) { + return "", fmt.Errorf("unsafe repo URL %q: invalid cache directory name", rawURL) + } + return name, nil } // splitHostAndPath extracts the host and path-with-namespace from the @@ -305,11 +797,101 @@ func splitHostAndPath(rawURL string) (host, path string) { return "", s } +func validateRepoURL(rawURL string) error { + trimmed := strings.TrimSpace(rawURL) + if trimmed == "" || trimmed == "." || trimmed == ".." { + return fmt.Errorf("unsafe repo URL %q: empty or reserved repository name", rawURL) + } + if strings.ContainsAny(trimmed, "\x00\r\n\t") { + return fmt.Errorf("unsafe repo URL %q: control characters are not allowed", rawURL) + } + if strings.Contains(trimmed, `\`) { + return fmt.Errorf("unsafe repo URL %q: backslashes are not allowed", rawURL) + } + if parsed, err := url.Parse(trimmed); err == nil && parsed.Scheme != "" { + if parsed.RawQuery != "" || parsed.Fragment != "" { + return fmt.Errorf("unsafe repo URL %q: query and fragment are not allowed", rawURL) + } + } + + _, repoPath := splitHostAndPath(strings.TrimRight(trimmed, "/")) + if repoPath == "" { + return fmt.Errorf("unsafe repo URL %q: empty repository path", rawURL) + } + for _, segment := range strings.Split(repoPath, "/") { + if segment == "" { + continue + } + decoded, err := url.PathUnescape(segment) + if err != nil { + return fmt.Errorf("unsafe repo URL %q: invalid path escape", rawURL) + } + if decoded == "." || decoded == ".." || strings.ContainsAny(decoded, `/\\`) { + return fmt.Errorf("unsafe repo URL %q: path traversal is not allowed", rawURL) + } + } + + name := repoPath + if i := strings.LastIndex(name, "/"); i >= 0 { + name = name[i+1:] + } + name = strings.TrimSuffix(name, ".git") + decodedName, err := url.PathUnescape(name) + if err != nil { + return fmt.Errorf("unsafe repo URL %q: invalid repository name escape", rawURL) + } + if !isSafeRepoName(decodedName) { + return fmt.Errorf("unsafe repo URL %q: invalid repository name %q", rawURL, decodedName) + } + return nil +} + +func isSafeRepoName(name string) bool { + if name == "" || name == "." || name == ".." { + return false + } + for _, r := range name { + switch { + case r >= 'a' && r <= 'z': + case r >= 'A' && r <= 'Z': + case r >= '0' && r <= '9': + case r == '-', r == '_', r == '.': + default: + return false + } + } + return true +} + // isBareRepo checks if a path looks like a bare git repository. func isBareRepo(path string) bool { - // A bare repo has a HEAD file at the root. - _, err := os.Stat(filepath.Join(path, "HEAD")) - return err == nil + return legacyGitBroker().isBareRepo(context.Background(), path) +} + +func (c *Cache) isBareRepo(ctx context.Context, path string) bool { + if c.git == nil { + return false + } + handle, err := c.openValidatedBareCache(path) + if err != nil { + return false + } + defer handle.Close() + return c.isBareRepoHandle(ctx, handle) +} + +func (g *gitBroker) isBareRepo(ctx context.Context, path string) bool { + info, err := os.Lstat(path) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return false + } + out, err := g.output(ctx, 0, "-C", path, "rev-parse", "--is-bare-repository") + return err == nil && strings.TrimSpace(string(out)) == "true" +} + +func (c *Cache) isBareRepoHandle(ctx context.Context, handle *bareCacheHandle) bool { + out, err := c.git.outputInBareCache(ctx, 0, handle, "rev-parse", "--is-bare-repository") + return err == nil && strings.TrimSpace(string(out)) == "true" } // modernFetchRefspec is the remote-tracking refspec that keeps fetched heads @@ -320,19 +902,34 @@ func isBareRepo(path string) bool { const modernFetchRefspec = "+refs/heads/*:refs/remotes/origin/*" func gitCloneBare(url, dest string) error { - if out, err := runGitCombinedOutput("clone", "--bare", url, dest); err != nil { + return legacyCache().gitCloneBare(context.Background(), url, dest) +} + +func (c *Cache) gitCloneBare(ctx context.Context, url, dest string) error { + if out, err := c.git.combinedOutput(ctx, 0, "clone", "--bare", url, dest); err != nil { // Clean up partial clone. os.RemoveAll(dest) return fmt.Errorf("git clone --bare: %s: %w", strings.TrimSpace(string(out)), err) } + if c.bareCacheHook != nil { + c.bareCacheHook("after-clone", dest) + } + if err := c.validateBareCachePath(dest); err != nil { + os.RemoveAll(dest) + return fmt.Errorf("validate cloned bare repository: %w", err) + } // `git clone --bare` populates refs/heads/* as a snapshot and defaults to // a mirror-style fetch refspec. Convert the bare repo to the standard // remote-tracking layout immediately so subsequent fetches write to // refs/remotes/origin/* and can't conflict with worktree-locked heads. - if err := ensureRemoteTrackingLayout(dest); err != nil { + if err := c.ensureRemoteTrackingLayout(ctx, dest); err != nil { os.RemoveAll(dest) return fmt.Errorf("configure fetch refspec: %w", err) } + if err := c.validateBareCachePath(dest); err != nil { + os.RemoveAll(dest) + return fmt.Errorf("validate cloned bare repository: %w", err) + } return nil } @@ -346,10 +943,23 @@ func gitCloneBare(url, dest string) error { // would keep basing new worktrees on the original default branch forever // after the remote flipped. func gitFetch(barePath string) error { - if err := ensureRemoteTrackingLayout(barePath); err != nil { + return legacyCacheForBarePath(barePath).gitFetch(context.Background(), barePath) +} + +func (c *Cache) gitFetch(ctx context.Context, barePath string) error { + handle, err := c.openValidatedBareCache(barePath) + if err != nil { + return err + } + defer handle.Close() + return c.gitFetchHandle(ctx, handle) +} + +func (c *Cache) gitFetchHandle(ctx context.Context, handle *bareCacheHandle) error { + if err := c.ensureRemoteTrackingLayoutHandle(ctx, handle); err != nil { return fmt.Errorf("ensure refspec: %w", err) } - if err := runGitFetch(barePath); err != nil { + if err := c.runGitFetchHandle(ctx, handle); err != nil { return err } // Refresh refs/remotes/origin/HEAD after every successful fetch. @@ -358,14 +968,27 @@ func gitFetch(barePath string) error { // getRemoteDefaultBranch, but the modern-cache default-branch-change // path (the only path that can't be recovered any other way) relies // on this call. - _ = runGit("-C", barePath, "remote", "set-head", "origin", "--auto") + _ = c.git.runInBareCache(ctx, 0, handle, "remote", "set-head", "origin", "--auto") return nil } // runGitFetch is the raw `git fetch origin` wrapper. Callers should go through // gitFetch, which migrates legacy caches first. func runGitFetch(barePath string) error { - if out, err := runGitCombinedOutput("-C", barePath, "fetch", "origin"); err != nil { + return legacyCacheForBarePath(barePath).runGitFetch(context.Background(), barePath) +} + +func (c *Cache) runGitFetch(ctx context.Context, barePath string) error { + handle, err := c.openValidatedBareCache(barePath) + if err != nil { + return err + } + defer handle.Close() + return c.runGitFetchHandle(ctx, handle) +} + +func (c *Cache) runGitFetchHandle(ctx context.Context, handle *bareCacheHandle) error { + if out, err := c.git.combinedOutputInBareCache(ctx, 0, handle, "fetch", "origin"); err != nil { return fmt.Errorf("git fetch: %s: %w", strings.TrimSpace(string(out)), err) } return nil @@ -379,25 +1002,38 @@ func runGitFetch(barePath string) error { // and runs `git remote set-head origin --auto` so getRemoteDefaultBranch can // resolve the remote's default branch. func ensureRemoteTrackingLayout(barePath string) error { - cur, err := readFetchRefspec(barePath) + return legacyCacheForBarePath(barePath).ensureRemoteTrackingLayout(context.Background(), barePath) +} + +func (c *Cache) ensureRemoteTrackingLayout(ctx context.Context, barePath string) error { + handle, err := c.openValidatedBareCache(barePath) + if err != nil { + return err + } + defer handle.Close() + return c.ensureRemoteTrackingLayoutHandle(ctx, handle) +} + +func (c *Cache) ensureRemoteTrackingLayoutHandle(ctx context.Context, handle *bareCacheHandle) error { + cur, err := c.readFetchRefspecHandle(ctx, handle) if err != nil { return err } if cur == modernFetchRefspec || cur == strings.TrimPrefix(modernFetchRefspec, "+") { return nil // already modern } - if err := setFetchRefspec(barePath, modernFetchRefspec); err != nil { + if err := c.setFetchRefspecHandle(ctx, handle, modernFetchRefspec); err != nil { return err } // Backfill refs/remotes/origin/* by fetching with the new refspec. This // writes to the origin/* namespace, so even worktree-locked refs/heads/* // branches can't collide. - if err := runGitFetch(barePath); err != nil { + if err := c.runGitFetchHandle(ctx, handle); err != nil { return fmt.Errorf("backfill fetch after refspec migration: %w", err) } // Set refs/remotes/origin/HEAD so getRemoteDefaultBranch can read it. // Non-fatal: if this fails we fall back to origin/main, origin/master. - _ = runGit("-C", barePath, "remote", "set-head", "origin", "--auto") + _ = c.git.runInBareCache(ctx, 0, handle, "remote", "set-head", "origin", "--auto") return nil } @@ -405,7 +1041,20 @@ func ensureRemoteTrackingLayout(barePath string) error { // the empty string if it's not set. Distinguishes "missing" (exit 1) from // real git errors. func readFetchRefspec(barePath string) (string, error) { - out, err := runGitOutput("-C", barePath, "config", "--get", "remote.origin.fetch") + return legacyCacheForBarePath(barePath).readFetchRefspec(context.Background(), barePath) +} + +func (c *Cache) readFetchRefspec(ctx context.Context, barePath string) (string, error) { + handle, err := c.openValidatedBareCache(barePath) + if err != nil { + return "", err + } + defer handle.Close() + return c.readFetchRefspecHandle(ctx, handle) +} + +func (c *Cache) readFetchRefspecHandle(ctx context.Context, handle *bareCacheHandle) (string, error) { + out, err := c.git.outputInBareCache(ctx, 0, handle, "config", "--get", "remote.origin.fetch") if err != nil { if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { return "", nil // key missing, not an error @@ -416,13 +1065,40 @@ func readFetchRefspec(barePath string) (string, error) { } func setFetchRefspec(barePath, refspec string) error { - out, err := runGitCombinedOutput("-C", barePath, "config", "remote.origin.fetch", refspec) + return legacyCacheForBarePath(barePath).setFetchRefspec(context.Background(), barePath, refspec) +} + +func (c *Cache) setFetchRefspec(ctx context.Context, barePath, refspec string) error { + handle, err := c.openValidatedBareCache(barePath) + if err != nil { + return err + } + defer handle.Close() + return c.setFetchRefspecHandle(ctx, handle, refspec) +} + +func (c *Cache) setFetchRefspecHandle(ctx context.Context, handle *bareCacheHandle, refspec string) error { + out, err := c.git.combinedOutputInBareCache(ctx, 0, handle, "config", "remote.origin.fetch", refspec) if err != nil { return fmt.Errorf("set remote.origin.fetch: %s: %w", strings.TrimSpace(string(out)), err) } return nil } +func legacyCache() *Cache { + broker := legacyGitBroker() + return &Cache{git: broker, logger: slog.Default()} +} + +func legacyCacheForBarePath(barePath string) *Cache { + cache := legacyCache() + abs, err := filepath.Abs(barePath) + if err == nil { + cache.root = filepath.Dir(filepath.Clean(abs)) + } + return cache +} + // WorktreeParams holds inputs for creating a worktree from a cached bare clone. type WorktreeParams struct { WorkspaceID string // workspace that owns the repo @@ -432,6 +1108,7 @@ type WorktreeParams struct { AgentName string // for branch naming TaskID string // for branch naming uniqueness CoAuthoredByEnabled bool // install prepare-commit-msg hook for Co-authored-by trailer + LocalDirectory bool // reject checkout when the task runs in a user-owned local directory } // WorktreeResult describes a successfully created worktree. @@ -445,9 +1122,29 @@ type WorktreeResult struct { // at the target path (reused environment), it updates the existing worktree to // the latest remote default branch instead of failing. func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) { - barePath := c.Lookup(params.WorkspaceID, params.RepoURL) - if barePath == "" { - return nil, fmt.Errorf("repo not found in cache: %s (workspace: %s)", params.RepoURL, params.WorkspaceID) + return c.CreateWorktreeContext(context.Background(), params) +} + +// CreateWorktreeContext is the task-safe checkout entry point. All Git +// subprocesses use ctx and the cache's immutable broker configuration. +func (c *Cache) CreateWorktreeContext(ctx context.Context, params WorktreeParams) (_ *WorktreeResult, returnErr error) { + if err := c.requireGit(); err != nil { + return nil, err + } + if params.LocalDirectory || c.isGitCheckoutRoot(ctx, params.WorkDir) { + return nil, fmt.Errorf("local_directory mode forbids creating an additional repo checkout") + } + dirName, err := worktreeDirName(params.RepoURL) + if err != nil { + return nil, err + } + worktreePath, err := canonicalWorktreeTarget(params.WorkDir, dirName) + if err != nil { + return nil, err + } + barePath, err := c.lookupPath(ctx, params.WorkspaceID, params.RepoURL) + if err != nil { + return nil, err } // Serialize concurrent CreateWorktree calls on the same bare repo. Git's @@ -456,12 +1153,23 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) { repoLock := c.lockForRepo(barePath) repoLock.Lock() defer repoLock.Unlock() + bareHandle, err := c.openValidatedBareCache(barePath) + if err != nil { + return nil, err + } + defer bareHandle.Close() + if c.bareCacheHook != nil { + c.bareCacheHook("after-open", barePath) + } + if err := bareHandle.RecheckPath(); err != nil { + return nil, err + } // Fetch latest from origin. This also migrates the bare cache's refspec // to the modern remote-tracking layout on first run, so subsequent fetches // never collide with the refs/heads/agent/* branches that worktree creation // locks in this same bare repo. - if err := gitFetch(barePath); err != nil { + if err := c.gitFetchHandle(ctx, bareHandle); err != nil { // Non-fatal: preserve cached state and continue, but make the warning // loud enough that it's findable in the daemon log. The agent will // receive an older snapshot than the remote head. @@ -478,7 +1186,7 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) { // Callers may request a specific branch, tag, or commit so review/QA agents // can inspect the exact revision without trying to mutate the daemon-owned // worktree metadata themselves. - baseRef, err := resolveBaseRef(barePath, params.Ref) + baseRef, err := c.resolveBaseRefHandle(ctx, bareHandle, params.Ref) if err != nil { return nil, err } @@ -496,20 +1204,36 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) { // Build branch name: agent/{sanitized-name}/{short-task-id} branchName := fmt.Sprintf("agent/%s/%s", sanitizeName(params.AgentName), shortID(params.TaskID)) - // Derive directory name from repo URL. - dirName := repoNameFromURL(params.RepoURL) - worktreePath := filepath.Join(params.WorkDir, dirName) - // If worktree already exists (reused environment from a prior task), // update it to the latest remote code instead of creating a new one. if isGitWorktree(worktreePath) { - actualBranch, err := updateExistingWorktree(worktreePath, branchName, baseRef) + handle, err := openWorktreeHandle(worktreePath) + if err != nil { + return nil, fmt.Errorf("open existing worktree identity: %w", err) + } + defer handle.Close() + if err := c.verifyWorktreeOwnerHandle(ctx, handle, barePath); err != nil { + return nil, err + } + if err := c.rejectExecutableRepositoryConfig(ctx, handle); err != nil { + return nil, err + } + if c.existingWorktreeHook != nil { + c.existingWorktreeHook("after-owner-proof", worktreePath) + } + if err := handle.RecheckPaths(); err != nil { + return nil, err + } + actualBranch, err := c.updateExistingWorktreeHandle(ctx, handle, branchName, baseRef) if err != nil { return nil, fmt.Errorf("update existing worktree: %w", err) } + if err := handle.RecheckPaths(); err != nil { + return nil, err + } for _, pattern := range agentGitExcludePatterns { - _ = excludeFromGit(worktreePath, pattern) + _ = excludeFromGitDirHandle(handle, pattern) } // Install or remove the Co-authored-by hook based on the workspace @@ -518,14 +1242,17 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) { // installed hook keeps appending the trailer to every commit even // after the user toggles the setting off. if params.CoAuthoredByEnabled { - if err := installCoAuthoredByHook(worktreePath); err != nil { + if err := installCoAuthoredByHookInCommonDirHandle(handle); err != nil { c.logger.Warn("repo checkout: install co-authored-by hook failed (non-fatal)", "error", err) } } else { - if err := removeCoAuthoredByHook(worktreePath); err != nil { + if err := removeCoAuthoredByHookInCommonDirHandle(handle); err != nil { c.logger.Warn("repo checkout: remove co-authored-by hook failed (non-fatal)", "error", err) } } + if err := handle.RecheckPaths(); err != nil { + return nil, err + } c.logger.Info("repo checkout: existing worktree updated", "url", params.RepoURL, @@ -540,30 +1267,134 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) { }, nil } - // Create a new worktree. createWorktree may rename the branch to avoid - // collisions with stale per-task refs left over from previous runs. - actualBranch, err := createWorktree(barePath, worktreePath, branchName, baseRef) + // Create new worktrees outside the task-visible workdir, then publish the + // completed checkout atomically. This keeps Git from following a target + // path that the task replaces while `git worktree add` is still running. + publication, err := newWorktreePublication(c.root, worktreePath) + if err != nil { + return nil, fmt.Errorf("prepare worktree publication: %w", err) + } + defer func() { + returnErr = errors.Join(returnErr, publication.Close()) + }() + actualBranch, err := c.createWorktreeHandle(ctx, bareHandle, publication.StagingPath(), branchName, baseRef) if err != nil { return nil, fmt.Errorf("create worktree: %w", err) } + committed := false + var publicationHandle *worktreeHandle + defer func() { + if committed { + if publicationHandle != nil { + returnErr = errors.Join(returnErr, publicationHandle.Close()) + } + return + } + cleanupCtx := context.WithoutCancel(ctx) + rollbackErr := publication.Rollback(publicationHandle) + var cleanupErr error + if publication.CleanupAllowed() { + cleanupErr = c.cleanupUnpublishedWorktreeHandle(cleanupCtx, bareHandle, publication.StagingPath(), actualBranch) + } + var closeErr error + if publicationHandle != nil { + closeErr = publicationHandle.Close() + } + returnErr = errors.Join(returnErr, rollbackErr, cleanupErr, closeErr) + }() - // Exclude agent context files from git tracking. - for _, pattern := range agentGitExcludePatterns { - _ = excludeFromGit(worktreePath, pattern) - } + if identityBoundWorktreeAccessSupported() { + handle, err := openWorktreeHandle(publication.StagingPath()) + if err != nil { + return nil, fmt.Errorf("open new worktree identity: %w", err) + } + publicationHandle = handle + if err := c.verifyWorktreeOwnerHandle(ctx, handle, barePath); err != nil { + return nil, fmt.Errorf("verify new worktree ownership: %w", err) + } + if err := publication.Prepare(handle); err != nil { + return nil, fmt.Errorf("prepare new worktree backlink: %w", err) + } + if c.worktreePublicationHook != nil { + c.worktreePublicationHook("before-publication", worktreePath) + } + if err := publication.Publish(handle); err != nil { + return nil, fmt.Errorf("publish new worktree: %w", err) + } + if c.worktreePublicationHook != nil { + c.worktreePublicationHook("after-publication", worktreePath) + } + if err := handle.VerifyGitDirBacklink(); err != nil { + return nil, fmt.Errorf("verify published worktree backlink: %w", err) + } + if err := handle.RecheckPaths(); err != nil { + return nil, err + } + if c.existingWorktreeHook != nil { + c.existingWorktreeHook("after-worktree-add", worktreePath) + } + + if c.existingWorktreeHook != nil { + c.existingWorktreeHook("before-exclude", worktreePath) + } + if err := handle.RecheckPaths(); err != nil { + return nil, err + } + for _, pattern := range agentGitExcludePatterns { + if err := excludeFromGitDirHandle(handle, pattern); err != nil { + c.logger.Warn("repo checkout: exclude agent context failed (non-fatal)", "pattern", pattern, "error", err) + } + } + if err := handle.RecheckPaths(); err != nil { + return nil, err + } - // Install or remove the Co-authored-by hook based on the workspace - // setting. See the existing-worktree branch above for why removal is - // required when the setting is disabled. - if params.CoAuthoredByEnabled { - if err := installCoAuthoredByHook(worktreePath); err != nil { - c.logger.Warn("repo checkout: install co-authored-by hook failed (non-fatal)", "error", err) + if c.existingWorktreeHook != nil { + c.existingWorktreeHook("before-hook", worktreePath) + } + if err := handle.RecheckPaths(); err != nil { + return nil, err + } + if params.CoAuthoredByEnabled { + if err := installCoAuthoredByHookInCommonDirHandle(handle); err != nil { + c.logger.Warn("repo checkout: install co-authored-by hook failed (non-fatal)", "error", err) + } + } else { + if err := removeCoAuthoredByHookInCommonDirHandle(handle); err != nil { + c.logger.Warn("repo checkout: remove co-authored-by hook failed (non-fatal)", "error", err) + } + } + if err := handle.RecheckPaths(); err != nil { + return nil, err + } + if c.existingWorktreeHook != nil { + c.existingWorktreeHook("before-return", worktreePath) + } + if err := handle.RecheckPaths(); err != nil { + return nil, err } } else { - if err := removeCoAuthoredByHook(worktreePath); err != nil { - c.logger.Warn("repo checkout: remove co-authored-by hook failed (non-fatal)", "error", err) + if err := publication.Publish(nil); err != nil { + return nil, fmt.Errorf("publish new worktree: %w", err) + } + // Windows does not advertise task execution and lacks the descriptor- + // relative primitives used above. Preserve operator-only worktree + // creation compatibility while reused worktrees remain fail closed. + for _, pattern := range agentGitExcludePatterns { + _ = c.excludeFromGit(ctx, worktreePath, pattern) + } + if params.CoAuthoredByEnabled { + if err := c.installCoAuthoredByHook(ctx, worktreePath); err != nil { + c.logger.Warn("repo checkout: install co-authored-by hook failed (non-fatal)", "error", err) + } + } else { + if err := c.removeCoAuthoredByHook(ctx, worktreePath); err != nil { + c.logger.Warn("repo checkout: remove co-authored-by hook failed (non-fatal)", "error", err) + } } } + publication.Commit() + committed = true c.logger.Info("repo checkout: worktree created", "url", params.RepoURL, @@ -579,9 +1410,22 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) { } func resolveBaseRef(barePath, requestedRef string) (string, error) { + return legacyCacheForBarePath(barePath).resolveBaseRef(context.Background(), barePath, requestedRef) +} + +func (c *Cache) resolveBaseRef(ctx context.Context, barePath, requestedRef string) (string, error) { + handle, err := c.openValidatedBareCache(barePath) + if err != nil { + return "", err + } + defer handle.Close() + return c.resolveBaseRefHandle(ctx, handle, requestedRef) +} + +func (c *Cache) resolveBaseRefHandle(ctx context.Context, handle *bareCacheHandle, requestedRef string) (string, error) { ref := strings.TrimSpace(requestedRef) if ref == "" { - return getRemoteDefaultBranch(barePath), nil + return c.getRemoteDefaultBranchHandle(ctx, handle), nil } // Prefer remote-tracking branches for human branch names. Then allow full @@ -592,21 +1436,47 @@ func resolveBaseRef(barePath, requestedRef string) (string, error) { ref, } for _, candidate := range candidates { - if gitRefExists(barePath, candidate+"^{commit}") { + if c.gitRefExistsHandle(ctx, handle, candidate+"^{commit}") { return candidate, nil } } - return "", fmt.Errorf("cannot resolve requested ref %q in repo cache at %s", ref, barePath) + return "", fmt.Errorf("cannot resolve requested ref %q in repo cache at %s", ref, handle.Path()) } func gitRefExists(repoPath, ref string) bool { - return runGit("-C", repoPath, "rev-parse", "--verify", "--quiet", ref) == nil + return legacyCacheForBarePath(repoPath).gitRefExists(context.Background(), repoPath, ref) +} + +func (c *Cache) gitRefExists(ctx context.Context, repoPath, ref string) bool { + handle, err := c.openValidatedBareCache(repoPath) + if err != nil { + return false + } + defer handle.Close() + return c.gitRefExistsHandle(ctx, handle, ref) +} + +func (c *Cache) gitRefExistsHandle(ctx context.Context, handle *bareCacheHandle, ref string) bool { + return c.git.runInBareCache(ctx, 0, handle, "rev-parse", "--verify", "--quiet", ref) == nil } // createWorktree creates a git worktree at the given path with a new branch. // Returns the actual branch name used — which may differ from the requested // branchName if a collision was resolved by appending a timestamp suffix. func createWorktree(gitRoot, worktreePath, branchName, baseRef string) (string, error) { + return legacyCacheForBarePath(gitRoot).createWorktree(context.Background(), gitRoot, worktreePath, branchName, baseRef) +} + +func (c *Cache) createWorktree(ctx context.Context, gitRoot, worktreePath, branchName, baseRef string) (string, error) { + handle, err := c.openValidatedBareCache(gitRoot) + if err != nil { + return "", err + } + defer handle.Close() + return c.createWorktreeHandle(ctx, handle, worktreePath, branchName, baseRef) +} + +func (c *Cache) createWorktreeHandle(ctx context.Context, handle *bareCacheHandle, worktreePath, branchName, baseRef string) (string, error) { // Pre-check: if the worktree path already exists we would get a confusing // "already exists" error from `git worktree add` — which used to be // misclassified as a branch collision, causing the retry to leak branches @@ -616,11 +1486,11 @@ func createWorktree(gitRoot, worktreePath, branchName, baseRef string) (string, return "", fmt.Errorf("worktree path already exists and is not a valid git worktree: %s", worktreePath) } - err := runWorktreeAdd(gitRoot, worktreePath, branchName, baseRef) + err := c.runWorktreeAddHandle(ctx, handle, worktreePath, branchName, baseRef) if err != nil && isBranchCollisionError(err) { // Branch name collision: append timestamp and retry once. branchName = fmt.Sprintf("%s-%d", branchName, time.Now().Unix()) - err = runWorktreeAdd(gitRoot, worktreePath, branchName, baseRef) + err = c.runWorktreeAddHandle(ctx, handle, worktreePath, branchName, baseRef) } if err != nil { return "", err @@ -629,12 +1499,60 @@ func createWorktree(gitRoot, worktreePath, branchName, baseRef string) (string, } func runWorktreeAdd(gitRoot, worktreePath, branchName, baseRef string) error { - if out, err := runGitCombinedOutput("-C", gitRoot, "worktree", "add", "-b", branchName, worktreePath, baseRef); err != nil { + return legacyCacheForBarePath(gitRoot).runWorktreeAdd(context.Background(), gitRoot, worktreePath, branchName, baseRef) +} + +func (c *Cache) runWorktreeAdd(ctx context.Context, gitRoot, worktreePath, branchName, baseRef string) error { + handle, err := c.openValidatedBareCache(gitRoot) + if err != nil { + return err + } + defer handle.Close() + return c.runWorktreeAddHandle(ctx, handle, worktreePath, branchName, baseRef) +} + +func (c *Cache) runWorktreeAddHandle(ctx context.Context, handle *bareCacheHandle, worktreePath, branchName, baseRef string) error { + ctx, cancel := c.git.withTimeout(ctx, 0) + defer cancel() + cmd, err := c.git.bareCacheCommand(ctx, handle, "worktree", "add", "-b", branchName, worktreePath, baseRef) + if err != nil { + return err + } + var output strings.Builder + cmd.Stdout = &output + cmd.Stderr = &output + if err := cmd.Start(); err != nil { + return fmt.Errorf("start git worktree add: %w", err) + } + if c.worktreeAddHook != nil { + c.worktreeAddHook("after-start", worktreePath) + } + err = cmd.Wait() + if ctx.Err() != nil { + return ctx.Err() + } + if identityErr := handle.RecheckPath(); identityErr != nil { + return identityErr + } + if err != nil { + out := output.String() return fmt.Errorf("git worktree add: %s: %w", strings.TrimSpace(string(out)), err) } return nil } +func (c *Cache) cleanupUnpublishedWorktreeHandle(ctx context.Context, handle *bareCacheHandle, worktreePath, branchName string) error { + out, err := c.git.combinedOutputInBareCache(ctx, 0, handle, "worktree", "remove", "--force", worktreePath) + if err != nil { + return fmt.Errorf("remove unpublished worktree: %s: %w", strings.TrimSpace(string(out)), err) + } + out, err = c.git.combinedOutputInBareCache(ctx, 0, handle, "branch", "-D", branchName) + if err != nil { + return fmt.Errorf("delete unpublished worktree branch: %s: %w", strings.TrimSpace(string(out)), err) + } + return nil +} + // isBranchCollisionError returns true if err is specifically about a branch // name already existing. Git's other "already exists" messages (notably path // collisions from `git worktree add`) must NOT be treated as branch @@ -656,42 +1574,246 @@ func isGitWorktree(path string) bool { return err == nil && !info.IsDir() } -// updateExistingWorktree resets the worktree to a clean state and checks out a -// new branch from the default branch. The caller is responsible for fetching -// the bare cache beforehand (worktrees share the same object store). -// Returns the actual branch name used (may differ from input on collision). -func updateExistingWorktree(worktreePath, branchName, baseRef string) (string, error) { - // Discard any leftover uncommitted changes from the previous task. - if out, err := runGitCombinedOutput("-C", worktreePath, "reset", "--hard"); err != nil { - return "", fmt.Errorf("git reset --hard: %s: %w", strings.TrimSpace(string(out)), err) +func isGitCheckoutRoot(path string) bool { + return legacyCache().isGitCheckoutRoot(context.Background(), path) +} + +func (c *Cache) isGitCheckoutRoot(ctx context.Context, path string) bool { + if strings.TrimSpace(path) == "" { + return false + } + out, err := c.git.output(ctx, 0, "-C", path, "rev-parse", "--show-toplevel") + if err != nil { + return false + } + root, err := canonicalExistingDir(strings.TrimSpace(string(out))) + if err != nil { + return false + } + want, err := canonicalExistingDir(path) + return err == nil && samePath(root, want) +} + +func (c *Cache) verifyWorktreeOwnerHandle(_ context.Context, handle *worktreeHandle, expectedBarePath string) error { + gitFile, err := readFileAt(handle.worktree.file, ".git") + if err != nil { + return fmt.Errorf("prove existing worktree ownership: read .git file: %w", err) + } + const gitDirPrefix = "gitdir: " + gitFileValue := strings.TrimSpace(string(gitFile)) + if !strings.HasPrefix(gitFileValue, gitDirPrefix) { + return fmt.Errorf("prove existing worktree ownership: invalid .git file") + } + gitDir := strings.TrimSpace(strings.TrimPrefix(gitFileValue, gitDirPrefix)) + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(handle.Path(), gitDir) + } + gitDir, err = canonicalExistingDir(gitDir) + if err != nil { + return fmt.Errorf("prove existing worktree ownership: resolve git dir: %w", err) + } + handle.gitDir, err = openDirectoryHandle(gitDir) + if err != nil { + return fmt.Errorf("open worktree git-dir identity: %w", err) + } + commonFile, err := readFileAt(handle.gitDir.file, "commondir") + if err != nil { + return fmt.Errorf("prove existing worktree ownership: read common dir: %w", err) + } + commonDir := strings.TrimSpace(string(commonFile)) + if commonDir == "" { + return fmt.Errorf("prove existing worktree ownership: empty common dir") + } + if !filepath.IsAbs(commonDir) { + commonDir = filepath.Join(gitDir, commonDir) + } + commonDir, err = canonicalExistingDir(commonDir) + if err != nil { + return fmt.Errorf("prove existing worktree ownership: resolve common dir: %w", err) + } + expectedBarePath, err = canonicalExistingDir(expectedBarePath) + if err != nil { + return fmt.Errorf("prove existing worktree ownership: resolve expected bare cache: %w", err) + } + if !samePath(commonDir, expectedBarePath) { + return fmt.Errorf("existing worktree belongs to a different bare cache: got %s, want %s", commonDir, expectedBarePath) + } + worktreesRoot := filepath.Join(expectedBarePath, "worktrees") + if !samePath(filepath.Dir(gitDir), worktreesRoot) { + return fmt.Errorf("existing worktree git dir escapes expected bare cache: %s", gitDir) + } + handle.common, err = openDirectoryHandle(commonDir) + if err != nil { + return fmt.Errorf("open git common-dir identity: %w", err) + } + if err := validateRepositoryMetadataIdentity(&handle.gitDir); err != nil { + return err + } + if err := validateRepositoryMetadataIdentity(&handle.common); err != nil { + return err + } + if err := handle.VerifyGitDirBacklink(); err != nil { + return fmt.Errorf("prove existing worktree ownership: %w", err) + } + return handle.RecheckPaths() +} + +func canonicalWorktreeTarget(workDir, dirName string) (string, error) { + if strings.TrimSpace(workDir) == "" { + return "", fmt.Errorf("workdir is empty") + } + if !isSafePathComponent(dirName) { + return "", fmt.Errorf("unsafe repo directory name %q", dirName) + } + canonicalWorkDir, err := canonicalExistingDir(workDir) + if err != nil { + return "", fmt.Errorf("resolve workdir: %w", err) + } + target := filepath.Join(canonicalWorkDir, dirName) + if !pathWithin(canonicalWorkDir, target) { + return "", fmt.Errorf("worktree target escapes workdir: %s", target) } - // Clean untracked files (e.g. build artifacts from previous task). - if out, err := runGitCombinedOutput("-C", worktreePath, "clean", "-fd"); err != nil { - return "", fmt.Errorf("git clean -fd: %s: %w", strings.TrimSpace(string(out)), err) + info, err := os.Lstat(target) + if err != nil { + if os.IsNotExist(err) { + return target, nil + } + return "", fmt.Errorf("stat worktree target: %w", err) } + if info.Mode()&os.ModeSymlink != 0 { + resolved, resolveErr := filepath.EvalSymlinks(target) + if resolveErr != nil { + return "", fmt.Errorf("worktree target is a symlink: %s", target) + } + if !pathWithin(canonicalWorkDir, resolved) { + return "", fmt.Errorf("worktree target symlink escapes workdir: %s -> %s", target, resolved) + } + return "", fmt.Errorf("worktree target must not be a symlink: %s", target) + } + canonicalTarget, err := filepath.EvalSymlinks(target) + if err != nil { + return "", fmt.Errorf("resolve worktree target: %w", err) + } + if !pathWithin(canonicalWorkDir, canonicalTarget) { + return "", fmt.Errorf("canonical worktree target escapes workdir: %s", canonicalTarget) + } + return filepath.Clean(canonicalTarget), nil +} - // Create a new branch from the resolved default-branch ref and switch to - // it. baseRef is a ref path returned by getRemoteDefaultBranch — usually - // "refs/remotes/origin/" but may be "refs/heads/" on a - // legacy/migration-pending cache. Either form is valid as a checkout - // startpoint. - out, err := runGitCombinedOutput("-C", worktreePath, "checkout", "-b", branchName, baseRef) - if err == nil { +func samePath(a, b string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(filepath.Clean(a), filepath.Clean(b)) + } + return filepath.Clean(a) == filepath.Clean(b) +} + +// updateExistingWorktreeHandle resets a reused worktree and checks out a new +// branch while keeping every content operation bound to the retained directory +// identity established by openWorktreeHandle. +func (c *Cache) updateExistingWorktreeHandle(ctx context.Context, handle *worktreeHandle, branchName, baseRef string) (string, error) { + gitArgs := func(args ...string) []string { + return append([]string{"--git-dir=" + handle.gitDir.path, "--work-tree=.", "-c", "core.bare=false"}, args...) + } + type commandResult struct { + output []byte + commandErr error + identityErr error + } + run := func(stage string, args ...string) commandResult { + if err := handle.RecheckPaths(); err != nil { + return commandResult{identityErr: err} + } + out, commandErr := c.git.combinedOutputInWorktree(ctx, 0, handle, gitArgs(args...)...) + if commandErr == nil && c.existingWorktreeHook != nil { + c.existingWorktreeHook(stage, handle.Path()) + } + return commandResult{ + output: out, + commandErr: commandErr, + identityErr: handle.RecheckPaths(), + } + } + + reset := run("after-reset", "reset", "--hard") + if reset.identityErr != nil { + return "", fmt.Errorf("git reset --hard identity check: %w", reset.identityErr) + } + if reset.commandErr != nil { + return "", fmt.Errorf("git reset --hard: %s: %w", strings.TrimSpace(string(reset.output)), reset.commandErr) + } + clean := run("after-clean", "clean", "-fd") + if clean.identityErr != nil { + return "", fmt.Errorf("git clean -fd identity check: %w", clean.identityErr) + } + if clean.commandErr != nil { + return "", fmt.Errorf("git clean -fd: %s: %w", strings.TrimSpace(string(clean.output)), clean.commandErr) + } + checkout := run("after-checkout", "checkout", "-b", branchName, baseRef) + if checkout.identityErr != nil { + return "", fmt.Errorf("git checkout -b identity check: %w", checkout.identityErr) + } + if checkout.commandErr == nil { return branchName, nil } - wrapped := fmt.Errorf("git checkout -b: %s: %w", strings.TrimSpace(string(out)), err) + wrapped := fmt.Errorf("git checkout -b: %s: %w", strings.TrimSpace(string(checkout.output)), checkout.commandErr) if !isBranchCollisionError(wrapped) { return "", wrapped } - // Branch name collision: append timestamp and retry once. branchName = fmt.Sprintf("%s-%d", branchName, time.Now().Unix()) - if out2, err2 := runGitCombinedOutput("-C", worktreePath, "checkout", "-b", branchName, baseRef); err2 != nil { - return "", fmt.Errorf("git checkout -b (retry): %s: %w", strings.TrimSpace(string(out2)), err2) + retry := run("after-checkout-retry", "checkout", "-b", branchName, baseRef) + if retry.identityErr != nil { + return "", fmt.Errorf("git checkout -b (retry) identity check: %w", retry.identityErr) + } + if retry.commandErr != nil { + return "", fmt.Errorf("git checkout -b (retry): %s: %w", strings.TrimSpace(string(retry.output)), retry.commandErr) } return branchName, nil } +var executableRepositoryConfig = regexp.MustCompile(`(?i)^(include\.path$|includeif\..*\.path$|alias\.|core\.(fsmonitor|sshcommand|hookspath)$|credential\.helper$|filter\..*\.(clean|smudge|process)$|diff\..*\.(command|textconv)$|merge\..*\.driver$|gpg\..*\.program$)`) + +func (c *Cache) rejectExecutableRepositoryConfig(ctx context.Context, handle *worktreeHandle) error { + if err := handle.RecheckPaths(); err != nil { + return err + } + cmd, err := c.git.worktreeCommand(ctx, handle, + "--git-dir="+handle.gitDir.path, "--work-tree=.", "-c", "core.bare=false", + "config", "--local", "--no-includes", "--name-only", "--list") + if err != nil { + return fmt.Errorf("prepare repository-local executable Git config inspection: %w", err) + } + cmd.Env = removeEnv(cmd.Env, "GIT_CONFIG") + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("inspect repository-local executable Git config: %s: %w", strings.TrimSpace(string(out)), err) + } + if err := handle.RecheckPaths(); err != nil { + return err + } + var rejected []string + for _, key := range strings.Fields(string(out)) { + if executableRepositoryConfig.MatchString(key) { + rejected = append(rejected, key) + } + } + if len(rejected) != 0 { + return fmt.Errorf("existing worktree repository config contains executable settings: %s", strings.Join(rejected, ", ")) + } + return nil +} + +func removeEnv(env []string, key string) []string { + prefix := key + "=" + result := make([]string, 0, len(env)) + for _, entry := range env { + if !strings.HasPrefix(entry, prefix) { + result = append(result, entry) + } + } + return result +} + // getRemoteDefaultBranch returns a ref path (e.g. "refs/remotes/origin/main") // that points at the remote's default branch in a bare cache. The return value // is usable directly as a `git worktree add` / `git checkout -b` startpoint. @@ -718,23 +1840,36 @@ func updateExistingWorktree(worktreePath, branchName, baseRef string) (string, e // Returns "" only when none of the above resolve — which the caller treats // as a hard error with a clear "cache has no usable refs" message. func getRemoteDefaultBranch(barePath string) string { + return legacyCacheForBarePath(barePath).getRemoteDefaultBranch(context.Background(), barePath) +} + +func (c *Cache) getRemoteDefaultBranch(ctx context.Context, barePath string) string { + handle, err := c.openValidatedBareCache(barePath) + if err != nil { + return "" + } + defer handle.Close() + return c.getRemoteDefaultBranchHandle(ctx, handle) +} + +func (c *Cache) getRemoteDefaultBranchHandle(ctx context.Context, handle *bareCacheHandle) string { // 1) Primary: refs/remotes/origin/HEAD set by `git remote set-head // origin --auto` during ensureRemoteTrackingLayout. Verify the // target actually exists — a partial set-head or a manually-broken // repo can leave a symref pointing at a deleted ref, and returning // it here would later fail in `git worktree add` with a confusing // "invalid reference" error. - if out, err := runGitOutput("-C", barePath, "symbolic-ref", "refs/remotes/origin/HEAD"); err == nil { + if out, err := c.git.outputInBareCache(ctx, 0, handle, "symbolic-ref", "refs/remotes/origin/HEAD"); err == nil { ref := strings.TrimSpace(string(out)) if ref != "" { - if err := runGit("-C", barePath, "rev-parse", "--verify", ref); err == nil { + if err := c.git.runInBareCache(ctx, 0, handle, "rev-parse", "--verify", ref); err == nil { return ref } } } // 2) Common default branch names under the origin namespace. for _, candidate := range []string{"refs/remotes/origin/main", "refs/remotes/origin/master"} { - if err := runGit("-C", barePath, "rev-parse", "--verify", candidate); err == nil { + if err := c.git.runInBareCache(ctx, 0, handle, "rev-parse", "--verify", candidate); err == nil { return candidate } } @@ -744,10 +1879,10 @@ func getRemoteDefaultBranch(barePath string) string { // didn't populate refs/remotes/origin/HEAD. We only return when the // matching origin/ exists, so we still pick up up-to-date code // rather than a stale local head. - bareRef := bareHeadBranch(barePath) + bareRef := c.bareHeadBranchHandle(ctx, handle) if bareRef != "" { originRef := "refs/remotes/origin/" + strings.TrimPrefix(bareRef, "refs/heads/") - if err := runGit("-C", barePath, "rev-parse", "--verify", originRef); err == nil { + if err := c.git.runInBareCache(ctx, 0, handle, "rev-parse", "--verify", originRef); err == nil { return originRef } } @@ -759,7 +1894,7 @@ func getRemoteDefaultBranch(barePath string) string { // "legacy empty" apart from "ambiguous". originCount := 0 var singleton string - if out, err := runGitOutput("-C", barePath, "for-each-ref", "--format=%(refname)", "refs/remotes/origin/"); err == nil { + if out, err := c.git.outputInBareCache(ctx, 0, handle, "for-each-ref", "--format=%(refname)", "refs/remotes/origin/"); err == nil { for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { line = strings.TrimSpace(line) if line == "" || line == "refs/remotes/origin/HEAD" { @@ -795,7 +1930,20 @@ func getRemoteDefaultBranch(barePath string) string { // modern caches should never reach this path because origin/* resolution // succeeds first. func bareHeadBranch(barePath string) string { - out, err := runGitOutput("-C", barePath, "symbolic-ref", "HEAD") + return legacyCacheForBarePath(barePath).bareHeadBranch(context.Background(), barePath) +} + +func (c *Cache) bareHeadBranch(ctx context.Context, barePath string) string { + handle, err := c.openValidatedBareCache(barePath) + if err != nil { + return "" + } + defer handle.Close() + return c.bareHeadBranchHandle(ctx, handle) +} + +func (c *Cache) bareHeadBranchHandle(ctx context.Context, handle *bareCacheHandle) string { + out, err := c.git.outputInBareCache(ctx, 0, handle, "symbolic-ref", "HEAD") if err != nil { return "" } @@ -803,7 +1951,7 @@ func bareHeadBranch(barePath string) string { if ref == "" { return "" } - if err := runGit("-C", barePath, "rev-parse", "--verify", ref); err != nil { + if err := c.git.runInBareCache(ctx, 0, handle, "rev-parse", "--verify", ref); err != nil { return "" } return ref @@ -859,7 +2007,11 @@ git interpret-trailers --in-place --trailer "$TRAILER" "$COMMIT_MSG_FILE" // git common directory (the bare repo for worktrees) so it applies to all // worktrees created from this cache. func installCoAuthoredByHook(worktreePath string) error { - out, err := runGitOutput("-C", worktreePath, "rev-parse", "--git-common-dir") + return legacyCache().installCoAuthoredByHook(context.Background(), worktreePath) +} + +func (c *Cache) installCoAuthoredByHook(ctx context.Context, worktreePath string) error { + out, err := c.git.output(ctx, 0, "-C", worktreePath, "rev-parse", "--git-common-dir") if err != nil { return fmt.Errorf("resolve git common dir: %w", err) } @@ -867,7 +2019,10 @@ func installCoAuthoredByHook(worktreePath string) error { if !filepath.IsAbs(commonDir) { commonDir = filepath.Join(worktreePath, commonDir) } + return installCoAuthoredByHookInCommonDir(commonDir) +} +func installCoAuthoredByHookInCommonDir(commonDir string) error { hooksDir := filepath.Join(commonDir, "hooks") if err := os.MkdirAll(hooksDir, 0o755); err != nil { return fmt.Errorf("create hooks dir: %w", err) @@ -901,7 +2056,11 @@ func isDaemonInstalledHook(contents []byte) bool { // Returns nil when no hook is present or when an unrelated hook occupies // the path. func removeCoAuthoredByHook(worktreePath string) error { - out, err := runGitOutput("-C", worktreePath, "rev-parse", "--git-common-dir") + return legacyCache().removeCoAuthoredByHook(context.Background(), worktreePath) +} + +func (c *Cache) removeCoAuthoredByHook(ctx context.Context, worktreePath string) error { + out, err := c.git.output(ctx, 0, "-C", worktreePath, "rev-parse", "--git-common-dir") if err != nil { return fmt.Errorf("resolve git common dir: %w", err) } @@ -909,7 +2068,10 @@ func removeCoAuthoredByHook(worktreePath string) error { if !filepath.IsAbs(commonDir) { commonDir = filepath.Join(worktreePath, commonDir) } + return removeCoAuthoredByHookInCommonDir(commonDir) +} +func removeCoAuthoredByHookInCommonDir(commonDir string) error { hookPath := filepath.Join(commonDir, "hooks", "prepare-commit-msg") contents, err := os.ReadFile(hookPath) if err != nil { @@ -930,7 +2092,11 @@ func removeCoAuthoredByHook(worktreePath string) error { // excludeFromGit adds a pattern to the worktree's .git/info/exclude file. func excludeFromGit(worktreePath, pattern string) error { - out, err := runGitOutput("-C", worktreePath, "rev-parse", "--git-dir") + return legacyCache().excludeFromGit(context.Background(), worktreePath, pattern) +} + +func (c *Cache) excludeFromGit(ctx context.Context, worktreePath, pattern string) error { + out, err := c.git.output(ctx, 0, "-C", worktreePath, "rev-parse", "--git-dir") if err != nil { return fmt.Errorf("resolve git dir: %w", err) } @@ -939,7 +2105,10 @@ func excludeFromGit(worktreePath, pattern string) error { if !filepath.IsAbs(gitDir) { gitDir = filepath.Join(worktreePath, gitDir) } + return excludeFromGitDir(gitDir, pattern) +} +func excludeFromGitDir(gitDir, pattern string) error { excludePath := filepath.Join(gitDir, "info", "exclude") if err := os.MkdirAll(filepath.Dir(excludePath), 0o755); err != nil { @@ -963,25 +2132,30 @@ func excludeFromGit(worktreePath, pattern string) error { return nil } -// repoNameFromURL extracts a short directory name from a git remote URL. -// e.g. "https://github.com/org/my-repo.git" → "my-repo" -func repoNameFromURL(url string) string { - url = strings.TrimRight(url, "/") - url = strings.TrimSuffix(url, ".git") - - if i := strings.LastIndex(url, "/"); i >= 0 { - url = url[i+1:] +func worktreeDirName(rawURL string) (string, error) { + if err := validateRepoURL(rawURL); err != nil { + return "", err } - if i := strings.LastIndex(url, ":"); i >= 0 { - url = url[i+1:] - if j := strings.LastIndex(url, "/"); j >= 0 { - url = url[j+1:] - } + _, repoPath := splitHostAndPath(strings.TrimRight(strings.TrimSpace(rawURL), "/")) + base := repoPath + if i := strings.LastIndex(base, "/"); i >= 0 { + base = base[i+1:] } + base = strings.TrimSuffix(base, ".git") + base, err := url.PathUnescape(base) + if err != nil || !isSafeRepoName(base) { + return "", fmt.Errorf("unsafe repo URL %q: invalid repository name", rawURL) + } + digest := sha256.Sum256([]byte(strings.TrimSpace(rawURL))) + return fmt.Sprintf("%s-%x", base, digest[:6]), nil +} - name := strings.TrimSpace(url) - if name == "" { - return "repo" +// repoNameFromURL is retained for internal tests and legacy callers. New +// checkout paths use worktreeDirName so equal basenames cannot collide. +func repoNameFromURL(rawURL string) string { + name, err := worktreeDirName(rawURL) + if err != nil { + return "" } return name } diff --git a/server/internal/daemon/repocache/cache_test.go b/server/internal/daemon/repocache/cache_test.go index f9ed8975530..e67403621a5 100644 --- a/server/internal/daemon/repocache/cache_test.go +++ b/server/internal/daemon/repocache/cache_test.go @@ -3,51 +3,100 @@ package repocache import ( "context" "errors" + "fmt" "log/slog" "os" "os/exec" "path/filepath" + "runtime" + "strconv" "strings" "testing" + "time" ) -func testLogger() *slog.Logger { - return slog.Default() +func trustedGitForTest(t *testing.T) string { + t.Helper() + path, err := exec.LookPath("git") + if err != nil { + t.Skipf("git not available: %v", err) + } + path, err = filepath.Abs(path) + if err != nil { + t.Fatalf("make git path absolute: %v", err) + } + path, err = filepath.EvalSymlinks(path) + if err != nil { + t.Fatalf("resolve git path: %v", err) + } + return path } -func TestGitEnv(t *testing.T) { +func TestWorktreeDirNameRejectsUnsafeRepoNames(t *testing.T) { t.Parallel() - env := gitEnv() - // Must contain GIT_TERMINAL_PROMPT=0. - found := false - for _, entry := range env { - if entry == "GIT_TERMINAL_PROMPT=0" { - found = true - break - } - } - if !found { - t.Error("gitEnv() must include GIT_TERMINAL_PROMPT=0") + tests := []string{ + "", + ".", + "..", + "../escape", + "org/../escape", + "https://example.com/org/../escape.git", + "https://example.com/org/%2e%2e/escape.git", + "https://example.com/org/bad name.git", + "https://example.com/org/bad\\name.git", + "https://example.com/org/repo?.git", + } + for _, rawURL := range tests { + rawURL := rawURL + t.Run(rawURL, func(t *testing.T) { + t.Parallel() + if _, err := worktreeDirName(rawURL); err == nil { + t.Fatalf("worktreeDirName(%q) succeeded, want unsafe repo name error", rawURL) + } + }) } +} + +func TestWorktreeDirNameDisambiguatesSameBasenameDeterministically(t *testing.T) { + t.Parallel() - // Must contain HOME from the current environment. - home := os.Getenv("HOME") - if home == "" { - t.Skip("HOME not set in test environment") + urlA := "https://git.example.com/team-a/app.git" + urlB := "https://git.example.com/team-b/app.git" + nameA, err := worktreeDirName(urlA) + if err != nil { + t.Fatalf("worktreeDirName(%q): %v", urlA, err) } - foundHome := false - for _, entry := range env { - if entry == "HOME="+home { - foundHome = true - break - } + nameB, err := worktreeDirName(urlB) + if err != nil { + t.Fatalf("worktreeDirName(%q): %v", urlB, err) + } + if nameA == nameB { + t.Fatalf("same-basename URLs share worktree dir %q", nameA) + } + if !strings.HasPrefix(nameA, "app-") || !strings.HasPrefix(nameB, "app-") { + t.Fatalf("worktree dirs should retain the safe basename: A=%q B=%q", nameA, nameB) + } + again, err := worktreeDirName(urlA) + if err != nil { + t.Fatalf("second worktreeDirName(%q): %v", urlA, err) } - if !foundHome { - t.Error("gitEnv() must include HOME from os.Environ()") + if again != nameA { + t.Fatalf("worktree dir is not deterministic: first=%q second=%q", nameA, again) } +} + +func testLogger() *slog.Logger { + return slog.Default() +} + +func TestGitEnv(t *testing.T) { + t.Setenv("HOME", "/owner/home/must-not-leak") + t.Setenv("GH_TOKEN", "owner-token-must-not-leak") + t.Setenv("GIT_ASKPASS", "/owner/bin/askpass") + t.Setenv("SSH_AUTH_SOCK", "/owner/ssh-agent.sock") + env := gitEnv() - // Must set safe.directory=* via GIT_CONFIG env vars. envHas := func(env []string, want string) bool { for _, e := range env { if e == want { @@ -56,15 +105,27 @@ func TestGitEnv(t *testing.T) { } return false } - if !envHas(env, "GIT_CONFIG_KEY_0=safe.directory") { - t.Error("gitEnv() must include GIT_CONFIG_KEY_0=safe.directory (no pre-existing config)") + for _, want := range []string{ + "GIT_TERMINAL_PROMPT=0", + "GIT_CONFIG_NOSYSTEM=1", + } { + if !envHas(env, want) { + t.Errorf("gitEnv() missing required broker setting %q", want) + } } - if !envHas(env, "GIT_CONFIG_VALUE_0=*") { - t.Error("gitEnv() must include GIT_CONFIG_VALUE_0=*") + for _, forbidden := range []string{ + "HOME=/owner/home/must-not-leak", + "GH_TOKEN=owner-token-must-not-leak", + "GIT_ASKPASS=/owner/bin/askpass", + "SSH_AUTH_SOCK=/owner/ssh-agent.sock", + } { + if envHas(env, forbidden) { + t.Errorf("gitEnv() inherited owner setting %q", forbidden) + } } } -func TestGitEnvPreservesExistingConfig(t *testing.T) { +func TestGitEnvRejectsExistingOwnerConfig(t *testing.T) { // GIT_CONFIG_COUNT env vars are process-wide; cannot use t.Setenv in // parallel tests, so run sequentially. t.Setenv("GIT_CONFIG_COUNT", "2") @@ -84,36 +145,171 @@ func TestGitEnvPreservesExistingConfig(t *testing.T) { return false } - // safe.directory must be appended at index 2 (next available). - if !envHas("GIT_CONFIG_COUNT=3") { - t.Error("expected GIT_CONFIG_COUNT=3") + for _, forbidden := range []string{ + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=url.https://github.com/.insteadOf", + "GIT_CONFIG_VALUE_0=gh:", + "GIT_CONFIG_KEY_1=http.extraHeader", + "GIT_CONFIG_VALUE_1=Authorization: Bearer tok", + } { + if envHas(forbidden) { + t.Errorf("gitEnv() inherited owner Git config %q", forbidden) + } + } +} + +func TestNewWithGitBrokerRejectsRelativeExecutable(t *testing.T) { + _, err := NewWithGitBroker(t.TempDir(), testLogger(), GitBrokerOptions{ + Executable: "git", + }) + if err == nil || !strings.Contains(err.Error(), "absolute") { + t.Fatalf("NewWithGitBroker error = %v, want absolute executable rejection", err) + } +} + +func TestNewWithGitBrokerRejectsExecutableUnderWritableParent(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("windows uses a different executable trust model") + } + + contents, err := os.ReadFile(trustedGitForTest(t)) + if err != nil { + t.Fatalf("read trusted Git executable: %v", err) + } + writableParent := t.TempDir() + gitCopy := filepath.Join(writableParent, "git") + if err := os.WriteFile(gitCopy, contents, 0o755); err != nil { + t.Fatalf("write Git executable copy: %v", err) + } + + _, err = NewWithGitBroker(t.TempDir(), testLogger(), GitBrokerOptions{Executable: gitCopy}) + if err == nil || (!strings.Contains(err.Error(), "git executable is not owned by root") && + !strings.Contains(err.Error(), "root-owned system trust anchor")) { + t.Fatalf("NewWithGitBroker error = %v, want writable parent trust rejection", err) + } +} + +func TestTrustedSystemGitExecutablePassesOwnerValidation(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("windows uses a different executable trust model") + } + + gitPath := trustedGitForTest(t) + if err := validateTrustedExecutablePath(gitPath); err != nil { + t.Fatalf("validateTrustedExecutablePath(%q): %v", gitPath, err) + } +} + +func TestGitBrokerUsesCallerContextAndAbsoluteExecutable(t *testing.T) { + cache, err := NewWithGitBroker(t.TempDir(), testLogger(), GitBrokerOptions{ + Executable: trustedGitForTest(t), + }) + if err != nil { + t.Fatalf("NewWithGitBroker: %v", err) + } + if !filepath.IsAbs(cache.git.executable) { + t.Fatalf("broker executable is not absolute: %q", cache.git.executable) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err = cache.git.command(ctx, "--version").Run() + if !errors.Is(err, context.Canceled) { + t.Fatalf("canceled broker command error = %v, want context.Canceled", err) } - if !envHas("GIT_CONFIG_KEY_2=safe.directory") { - t.Error("expected GIT_CONFIG_KEY_2=safe.directory") +} + +func TestTaskGitIgnoresOwnerConfigHooksFiltersAndURLRewrite(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test uses POSIX hook and filter fixtures") + } + + sourceA := createTestRepo(t) + sourceB := createTestRepo(t) + if err := os.WriteFile(filepath.Join(sourceA, ".gitattributes"), []byte("payload.txt filter=owner-filter\n"), 0o644); err != nil { + t.Fatalf("write attributes: %v", err) } - if !envHas("GIT_CONFIG_VALUE_2=*") { - t.Error("expected GIT_CONFIG_VALUE_2=*") + if err := os.WriteFile(filepath.Join(sourceA, "payload.txt"), []byte("assigned-source\n"), 0o644); err != nil { + t.Fatalf("write source A payload: %v", err) } + runGitAuthored(t, sourceA, "add", ".") + runGitAuthored(t, sourceA, "commit", "-m", "assigned payload") + if err := os.WriteFile(filepath.Join(sourceB, "payload.txt"), []byte("rewritten-source\n"), 0o644); err != nil { + t.Fatalf("write source B payload: %v", err) + } + runGitAuthored(t, sourceB, "add", ".") + runGitAuthored(t, sourceB, "commit", "-m", "rewrite payload") - // Original entries must still be present. - if !envHas("GIT_CONFIG_KEY_0=url.https://github.com/.insteadOf") { - t.Error("existing GIT_CONFIG_KEY_0 was lost") + ownerRoot := t.TempDir() + marker := filepath.Join(ownerRoot, "owner-command-ran") + hooksDir := filepath.Join(ownerRoot, "hooks") + if err := os.MkdirAll(hooksDir, 0o755); err != nil { + t.Fatalf("mkdir hooks: %v", err) + } + hook := "#!/bin/sh\nprintf hook >" + strconv.Quote(marker) + "\n" + if err := os.WriteFile(filepath.Join(hooksDir, "post-checkout"), []byte(hook), 0o755); err != nil { + t.Fatalf("write owner hook: %v", err) + } + filter := "sh -c 'printf filter >" + marker + "; cat'" + ownerConfig := filepath.Join(ownerRoot, "gitconfig") + config := fmt.Sprintf("[url %q]\n\tinsteadOf = %s\n[core]\n\thooksPath = %s\n[filter \"owner-filter\"]\n\tsmudge = %s\n\tclean = %s\n[credential]\n\thelper = !sh -c 'printf credential >%s'\n", + sourceB, sourceA, hooksDir, filter, filter, marker) + if err := os.WriteFile(ownerConfig, []byte(config), 0o600); err != nil { + t.Fatalf("write owner Git config: %v", err) + } + t.Setenv("HOME", ownerRoot) + t.Setenv("GIT_CONFIG_GLOBAL", ownerConfig) + t.Setenv("GIT_ASKPASS", filepath.Join(ownerRoot, "askpass")) + t.Setenv("GIT_CONFIG_COUNT", "1") + t.Setenv("GIT_CONFIG_KEY_0", "http.extraHeader") + t.Setenv("GIT_CONFIG_VALUE_0", "Authorization: Bearer owner-token") + + cache, err := NewWithGitBroker(t.TempDir(), testLogger(), GitBrokerOptions{ + Executable: trustedGitForTest(t), + }) + if err != nil { + t.Fatalf("NewWithGitBroker: %v", err) + } + ctx := context.Background() + if err := cache.SyncContext(ctx, "ws-1", []RepoInfo{{URL: sourceA}}); err != nil { + t.Fatalf("SyncContext: %v", err) + } + result, err := cache.CreateWorktreeContext(ctx, WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceA, + WorkDir: t.TempDir(), + AgentName: "isolated", + TaskID: "owner-config-isolation", + }) + if err != nil { + t.Fatalf("CreateWorktreeContext: %v", err) + } + payload, err := os.ReadFile(filepath.Join(result.Path, "payload.txt")) + if err != nil { + t.Fatalf("read checked-out payload: %v", err) } - if !envHas("GIT_CONFIG_VALUE_0=gh:") { - t.Error("existing GIT_CONFIG_VALUE_0 was lost") + if got := string(payload); got != "assigned-source\n" { + t.Fatalf("checked-out payload = %q, want assigned repository content", got) } - if !envHas("GIT_CONFIG_KEY_1=http.extraHeader") { - t.Error("existing GIT_CONFIG_KEY_1 was lost") + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("owner hook/filter/helper executed, marker stat error = %v", err) } } -func TestRunGitOutputTimesOut(t *testing.T) { - _, err := runGitOutputWithTimeout(0, "--version") - if !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("runGitOutputWithTimeout error = %v, want deadline exceeded", err) +func TestGitBrokerZeroTimeoutUsesConfiguredDefault(t *testing.T) { + const configuredTimeout = 37 * time.Second + broker := &gitBroker{timeout: configuredTimeout} + + started := time.Now() + ctx, cancel := broker.withTimeout(context.Background(), 0) + defer cancel() + + deadline, ok := ctx.Deadline() + if !ok { + t.Fatal("broker context has no deadline") } - if !strings.Contains(err.Error(), "timed out after 0s") { - t.Fatalf("runGitOutputWithTimeout error = %v, want timeout context", err) + if got := deadline.Sub(started); got <= configuredTimeout-time.Second || got >= configuredTimeout+time.Second { + t.Fatalf("broker default timeout = %v, want approximately %v", got, configuredTimeout) } } @@ -133,13 +329,15 @@ func TestBareDirName(t *testing.T) { {"ssh://git@gitlab.example.com:22/relisty/app.git", "gitlab.example.com%3A22+relisty+app.git"}, {"ssh://git@gitlab.example.com:22/listbridge/app.git", "gitlab.example.com%3A22+listbridge+app.git"}, {"my-repo", "my-repo.git"}, - {"", "repo.git"}, } for _, tt := range tests { if got := bareDirName(tt.input); got != tt.want { t.Errorf("bareDirName(%q) = %q, want %q", tt.input, got, tt.want) } } + if _, err := bareDirNameSafe(""); err == nil { + t.Error("bareDirNameSafe(\"\") succeeded, want unsafe repo URL error") + } } // TestBareDirNameDistinctsSegmentBoundaryColliders covers the collision class @@ -206,9 +404,12 @@ func TestBareDirNameDistinctsHostPortFromDashedHostname(t *testing.T) { func TestIsBareRepo(t *testing.T) { t.Parallel() - // A directory with a HEAD file should be detected as bare. + // A real bare repository should be detected as bare. dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644) + cmd := exec.Command("git", "init", "--bare", dir) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init --bare: %v\n%s", err, output) + } if !isBareRepo(dir) { t.Error("expected bare repo to be detected") } @@ -282,6 +483,355 @@ func TestSyncAndLookup(t *testing.T) { } } +func TestCacheOperationsRejectWritableBareRepository(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("windows uses a different repository metadata trust model") + } + + operations := []struct { + name string + run func(*Cache, string, string, string) error + }{ + { + name: "sync", + run: func(cache *Cache, sourceRepo, _, _ string) error { + return cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}) + }, + }, + { + name: "lookup", + run: func(cache *Cache, sourceRepo, _, _ string) error { + if got := cache.Lookup("ws-1", sourceRepo); got != "" { + return fmt.Errorf("Lookup returned untrusted bare cache %q", got) + } + return nil + }, + }, + { + name: "resolve", + run: func(cache *Cache, sourceRepo, _, _ string) error { + _, err := cache.Resolve("ws-1", sourceRepo) + return err + }, + }, + { + name: "fetch", + run: func(cache *Cache, _, barePath, _ string) error { + return cache.Fetch(barePath) + }, + }, + { + name: "create-worktree", + run: func(cache *Cache, sourceRepo, _, workDir string) error { + _, err := cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: workDir, + AgentName: "agent", + TaskID: "untrusted-cache", + }) + return err + }, + }, + } + + for _, operation := range operations { + operation := operation + t.Run(operation.name, func(t *testing.T) { + sourceRepo := createTestRepo(t) + cache := New(t.TempDir(), testLogger()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil { + t.Fatalf("initial Sync: %v", err) + } + barePath := cache.Lookup("ws-1", sourceRepo) + if barePath == "" { + t.Fatal("initial Lookup returned no bare cache") + } + beforeHead := gitOutputForTest(t, barePath, "rev-parse", "HEAD") + if err := os.Chmod(barePath, 0o777); err != nil { + t.Fatalf("make bare cache writable: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(barePath, 0o755) }) + + workDir := t.TempDir() + err := operation.run(cache, sourceRepo, barePath, workDir) + if operation.name == "lookup" { + if err != nil { + t.Fatal(err) + } + } else if err == nil || !strings.Contains(err.Error(), "group/world writable") { + t.Fatalf("%s error = %v, want writable bare cache rejection", operation.name, err) + } + if got := gitOutputForTest(t, barePath, "rev-parse", "HEAD"); got != beforeHead { + t.Fatalf("%s changed bare cache HEAD: got %q want %q", operation.name, got, beforeHead) + } + entries, readErr := os.ReadDir(workDir) + if readErr != nil { + t.Fatalf("read work directory: %v", readErr) + } + if len(entries) != 0 { + t.Fatalf("%s created worktree content before trust rejection: %v", operation.name, entries) + } + }) + } +} + +func TestWorkspaceCacheTrustRejectsWritableDirectories(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("windows uses a different repository metadata trust model") + } + + for _, target := range []string{"root", "workspace"} { + t.Run(target, func(t *testing.T) { + root := t.TempDir() + sourceRepo := createTestRepo(t) + cache := New(root, testLogger()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil { + t.Fatalf("initial Sync: %v", err) + } + path := root + if target == "workspace" { + path = filepath.Join(root, "ws-1") + } + if err := os.Chmod(path, 0o777); err != nil { + t.Fatalf("make %s writable: %v", target, err) + } + t.Cleanup(func() { _ = os.Chmod(path, 0o755) }) + + err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}) + if err == nil || !strings.Contains(err.Error(), "group/world writable") { + t.Fatalf("Sync error = %v, want writable %s rejection", err, target) + } + if got := cache.Lookup("ws-1", sourceRepo); got != "" { + t.Fatalf("Lookup returned cache through writable %s: %q", target, got) + } + }) + } +} + +func TestValidateDaemonOwnedDirectoryRejectsDifferentOwner(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("windows uses a different repository metadata trust model") + } + + path := t.TempDir() + if err := validateDaemonOwnedDirectoryPathForUID(path, "test metadata", os.Geteuid()+1); err == nil || !strings.Contains(err.Error(), "daemon effective UID") { + t.Fatalf("owner validation error = %v, want daemon UID mismatch", err) + } +} + +func TestValidateDaemonOwnedDirectoryStickyParentPolicy(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("linux-specific root-owned sticky temp-parent policy") + } + + child, err := os.MkdirTemp(os.TempDir(), "multica-sticky-parent-") + if err != nil { + t.Fatalf("create cache below system temp: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(child) }) + if err := os.Chmod(child, 0o755); err != nil { + t.Fatalf("secure cache child: %v", err) + } + if err := validateDaemonOwnedDirectoryPathForUID(child, "test cache", os.Geteuid()); err != nil { + t.Fatalf("root/daemon-owned sticky ancestor rejected: %v", err) + } + if err := validateDaemonOwnedDirectoryPathForUID(os.TempDir(), "test cache root", os.Geteuid()); err == nil { + t.Fatal("sticky system temp directory was accepted as cache root") + } + + parent := t.TempDir() + child = filepath.Join(parent, "daemon-cache") + if err := os.Mkdir(child, 0o755); err != nil { + t.Fatalf("create daemon cache child: %v", err) + } + if err := os.Chmod(parent, 0o777); err != nil { + t.Fatalf("make non-sticky parent writable: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(parent, 0o755) }) + if err := validateDaemonOwnedDirectoryPathForUID(child, "test cache", os.Geteuid()); err == nil || !strings.Contains(err.Error(), "group/world writable") { + t.Fatalf("non-sticky writable ancestor error = %v, want rejection", err) + } +} + +func TestCreateWorktreeRejectsBareCacheReplacementAfterOpen(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("windows uses a different repository metadata trust model") + } + + sourceRepo := createTestRepo(t) + cache := New(t.TempDir(), testLogger()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil { + t.Fatalf("Sync: %v", err) + } + barePath := cache.Lookup("ws-1", sourceRepo) + if barePath == "" { + t.Fatal("Lookup returned no bare cache") + } + originalPath := barePath + ".validated" + replaced := false + cache.bareCacheHook = func(stage, path string) { + if stage != "after-open" || replaced { + return + } + replaced = true + if err := os.Rename(path, originalPath); err != nil { + t.Fatalf("rename validated bare cache: %v", err) + } + if err := os.Mkdir(path, 0o755); err != nil { + t.Fatalf("install replacement bare cache: %v", err) + } + if err := os.WriteFile(filepath.Join(path, "replacement-sentinel.txt"), []byte("untouched"), 0o644); err != nil { + t.Fatalf("write replacement sentinel: %v", err) + } + } + + _, err := cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: t.TempDir(), + AgentName: "agent", + TaskID: "bare-cache-replacement", + }) + if err == nil || !strings.Contains(err.Error(), "path changed after ownership proof") { + t.Fatalf("CreateWorktree error = %v, want bare-cache identity rejection", err) + } + if !replaced { + t.Fatal("bare-cache replacement hook did not run") + } + contents, readErr := os.ReadFile(filepath.Join(barePath, "replacement-sentinel.txt")) + if readErr != nil || string(contents) != "untouched" { + t.Fatalf("replacement bare cache was modified: contents=%q err=%v", contents, readErr) + } +} + +func TestClonePostconditionRejectsUntrustedBareRepository(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("windows uses a different repository metadata trust model") + } + + root := t.TempDir() + sourceRepo := createTestRepo(t) + cache := New(root, testLogger()) + cache.bareCacheHook = func(stage, barePath string) { + if stage == "after-clone" { + if err := os.Chmod(barePath, 0o777); err != nil { + t.Fatalf("make cloned bare cache writable: %v", err) + } + } + } + + err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}) + if err == nil || !strings.Contains(err.Error(), "group/world writable") { + t.Fatalf("Sync error = %v, want clone postcondition rejection", err) + } + wsDir, pathErr := cache.workspaceCacheDir("ws-1", false) + if pathErr != nil { + t.Fatalf("workspaceCacheDir: %v", pathErr) + } + barePath, pathErr := cacheTargetPath(wsDir, sourceRepo) + if pathErr != nil { + t.Fatalf("cacheTargetPath: %v", pathErr) + } + if _, statErr := os.Lstat(barePath); !os.IsNotExist(statErr) { + t.Fatalf("untrusted cloned cache was not removed: %v", statErr) + } +} + +func TestFetchRejectsBareRepositorySymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("windows uses a different repository metadata trust model") + } + + sourceRepo := createTestRepo(t) + cache := New(t.TempDir(), testLogger()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil { + t.Fatalf("Sync: %v", err) + } + barePath := cache.Lookup("ws-1", sourceRepo) + linkPath := filepath.Join(filepath.Dir(barePath), "bare-link.git") + if err := os.Symlink(barePath, linkPath); err != nil { + t.Fatalf("create bare cache symlink: %v", err) + } + + err := cache.Fetch(linkPath) + if err == nil || !strings.Contains(err.Error(), "resolves through symlink") { + t.Fatalf("Fetch error = %v, want bare cache symlink rejection", err) + } +} + +func TestWorktreeLauncherSelection(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("linux uses the kernel-bound /proc/self/exe launcher") + } + launcher, err := worktreeLauncherExecutable() + if err != nil { + t.Fatalf("worktreeLauncherExecutable: %v", err) + } + if launcher != "/proc/self/exe" { + t.Fatalf("launcher = %q, want /proc/self/exe", launcher) + } +} + +func gitOutputForTest(t *testing.T, repoPath string, args ...string) string { + t.Helper() + commandArgs := append([]string{"-C", repoPath}, args...) + out, err := exec.Command("git", commandArgs...).CombinedOutput() + if err != nil { + t.Fatalf("git %v: %s: %v", commandArgs, strings.TrimSpace(string(out)), err) + } + return strings.TrimSpace(string(out)) +} + +func TestResolveReturnsCanonicalBareRepoIdentity(t *testing.T) { + t.Parallel() + + root := t.TempDir() + sourceRepo := createTestRepo(t) + cache := New(filepath.Join(root, "cache"), slog.Default()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil { + t.Fatalf("Sync: %v", err) + } + + resolved, err := cache.Resolve("ws-1", sourceRepo) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + wantPath, err := filepath.EvalSymlinks(cache.Lookup("ws-1", sourceRepo)) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + if resolved.URL != sourceRepo { + t.Fatalf("resolved URL = %q, want %q", resolved.URL, sourceRepo) + } + if resolved.BarePath != wantPath { + t.Fatalf("resolved bare path = %q, want %q", resolved.BarePath, wantPath) + } + if !filepath.IsAbs(resolved.BarePath) { + t.Fatalf("resolved bare path must be absolute: %q", resolved.BarePath) + } +} + +func TestResolveRejectsBareRepoWhoseOriginDoesNotMatchAssignment(t *testing.T) { + t.Parallel() + + root := t.TempDir() + sourceA := createTestRepo(t) + sourceB := createTestRepo(t) + cache := New(filepath.Join(root, "cache"), slog.Default()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceA}}); err != nil { + t.Fatalf("Sync: %v", err) + } + barePath := cache.Lookup("ws-1", sourceA) + if out, err := exec.Command("git", "-C", barePath, "remote", "set-url", "origin", sourceB).CombinedOutput(); err != nil { + t.Fatalf("replace origin: %s: %v", strings.TrimSpace(string(out)), err) + } + + if _, err := cache.Resolve("ws-1", sourceA); err == nil || !strings.Contains(err.Error(), "origin") { + t.Fatalf("Resolve error = %v, want origin identity mismatch", err) + } +} + // TestSyncKeepsDistinctCachesForSegmentBoundaryColliders proves that two // URLs differing only at a path-segment boundary don't share a bare cache // and don't silently reuse each other's origin. Both conditions would have @@ -514,6 +1064,1116 @@ func TestCreateWorktree(t *testing.T) { } } +func TestSyncRejectsUnsafeWorkspaceIDWithoutModifyingEscapeTarget(t *testing.T) { + t.Parallel() + + sourceRepo := createTestRepo(t) + parent := t.TempDir() + cacheRoot := filepath.Join(parent, "cache") + escapeTarget := filepath.Join(parent, "escape") + if err := os.MkdirAll(escapeTarget, 0o755); err != nil { + t.Fatalf("mkdir escape target: %v", err) + } + sentinel := filepath.Join(escapeTarget, "sentinel.txt") + if err := os.WriteFile(sentinel, []byte("keep\n"), 0o644); err != nil { + t.Fatalf("write sentinel: %v", err) + } + + cache := New(cacheRoot, testLogger()) + err := cache.Sync("../escape", []RepoInfo{{URL: sourceRepo}}) + if err == nil { + t.Fatal("Sync succeeded with traversal workspace ID") + } + if got, readErr := os.ReadFile(sentinel); readErr != nil || string(got) != "keep\n" { + t.Fatalf("escape target modified: contents=%q err=%v", got, readErr) + } + entries, readErr := os.ReadDir(escapeTarget) + if readErr != nil { + t.Fatalf("read escape target: %v", readErr) + } + if len(entries) != 1 || entries[0].Name() != "sentinel.txt" { + t.Fatalf("Sync wrote outside cache root: entries=%v", entries) + } +} + +func TestSyncRejectsSymlinkEscapeWithoutModifyingTarget(t *testing.T) { + t.Parallel() + + sourceRepo := createTestRepo(t) + cacheRoot := t.TempDir() + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(cacheRoot, "ws-1")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + sentinel := filepath.Join(outside, "sentinel.txt") + if err := os.WriteFile(sentinel, []byte("keep\n"), 0o644); err != nil { + t.Fatalf("write sentinel: %v", err) + } + + cache := New(cacheRoot, testLogger()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err == nil { + t.Fatal("Sync succeeded through workspace symlink escape") + } + entries, err := os.ReadDir(outside) + if err != nil { + t.Fatalf("read outside target: %v", err) + } + if len(entries) != 1 || entries[0].Name() != "sentinel.txt" { + t.Fatalf("Sync modified symlink target: entries=%v", entries) + } +} + +func TestSyncPreservesExistingNonGitCacheTarget(t *testing.T) { + t.Parallel() + + sourceRepo := createTestRepo(t) + cacheRoot := t.TempDir() + cache := New(cacheRoot, testLogger()) + wsDir := filepath.Join(cacheRoot, "ws-1") + if err := os.MkdirAll(wsDir, 0o755); err != nil { + t.Fatalf("mkdir workspace cache: %v", err) + } + target := filepath.Join(wsDir, bareDirName(sourceRepo)) + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("mkdir cache target: %v", err) + } + sentinel := filepath.Join(target, "sentinel.txt") + if err := os.WriteFile(sentinel, []byte("keep\n"), 0o644); err != nil { + t.Fatalf("write sentinel: %v", err) + } + + err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}) + if err == nil { + t.Fatal("Sync succeeded with pre-existing non-git cache target") + } + if got, readErr := os.ReadFile(sentinel); readErr != nil || string(got) != "keep\n" { + t.Fatalf("pre-existing cache target modified: contents=%q err=%v", got, readErr) + } +} + +func TestCreateWorktreeRejectsUnsafeRepoURLBeforeLookup(t *testing.T) { + t.Parallel() + + cacheRoot := t.TempDir() + cache := New(cacheRoot, testLogger()) + workDir := t.TempDir() + outside := filepath.Join(filepath.Dir(workDir), "escape") + if err := os.MkdirAll(outside, 0o755); err != nil { + t.Fatalf("mkdir outside target: %v", err) + } + sentinel := filepath.Join(outside, "sentinel.txt") + if err := os.WriteFile(sentinel, []byte("keep\n"), 0o644); err != nil { + t.Fatalf("write sentinel: %v", err) + } + + _, err := cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: "../escape", + WorkDir: workDir, + AgentName: "agent", + TaskID: "unsafe-repo-name", + }) + if err == nil || !strings.Contains(err.Error(), "unsafe repo") { + t.Fatalf("CreateWorktree error = %v, want unsafe repo error", err) + } + if got, readErr := os.ReadFile(sentinel); readErr != nil || string(got) != "keep\n" { + t.Fatalf("outside target modified: contents=%q err=%v", got, readErr) + } +} + +func TestCreateWorktreeRejectsLocalDirectoryBeforeCacheAccess(t *testing.T) { + t.Parallel() + + cacheRoot := t.TempDir() + cache := New(cacheRoot, testLogger()) + localDir := createTestRepo(t) + headBefore := gitHead(t, localDir) + + _, err := cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: "https://example.com/org/repo.git", + WorkDir: localDir, + AgentName: "agent", + TaskID: "local-directory", + LocalDirectory: true, + }) + if err == nil || !strings.Contains(err.Error(), "local_directory") { + t.Fatalf("CreateWorktree error = %v, want local_directory prohibition", err) + } + if got := gitHead(t, localDir); got != headBefore { + t.Fatalf("local directory HEAD changed: got %s want %s", got, headBefore) + } + entries, readErr := os.ReadDir(cacheRoot) + if readErr != nil { + t.Fatalf("read cache root: %v", readErr) + } + if len(entries) != 0 { + t.Fatalf("local_directory rejection touched cache: entries=%v", entries) + } +} + +func TestCreateWorktreeRejectsWorkDirThatIsExistingCheckout(t *testing.T) { + t.Parallel() + + sourceRepo := createTestRepo(t) + cache := New(t.TempDir(), testLogger()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil { + t.Fatalf("sync failed: %v", err) + } + localDir := createTestRepo(t) + headBefore := gitHead(t, localDir) + + _, err := cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: localDir, + AgentName: "agent", + TaskID: "implicit-local-directory", + }) + if err == nil || !strings.Contains(err.Error(), "local_directory") { + t.Fatalf("CreateWorktree error = %v, want local_directory prohibition", err) + } + if got := gitHead(t, localDir); got != headBefore { + t.Fatalf("existing checkout HEAD changed: got %s want %s", got, headBefore) + } +} + +func TestCreateWorktreeRejectsSymlinkEscapeTarget(t *testing.T) { + t.Parallel() + + sourceRepo := createTestRepo(t) + cache := New(t.TempDir(), testLogger()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil { + t.Fatalf("sync failed: %v", err) + } + workDir := t.TempDir() + dirName, err := worktreeDirName(sourceRepo) + if err != nil { + t.Fatalf("worktreeDirName: %v", err) + } + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(workDir, dirName)); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + sentinel := filepath.Join(outside, "sentinel.txt") + if err := os.WriteFile(sentinel, []byte("keep\n"), 0o644); err != nil { + t.Fatalf("write sentinel: %v", err) + } + + _, err = cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: workDir, + AgentName: "agent", + TaskID: "symlink-escape", + }) + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("CreateWorktree error = %v, want symlink escape error", err) + } + if got, readErr := os.ReadFile(sentinel); readErr != nil || string(got) != "keep\n" { + t.Fatalf("symlink target modified: contents=%q err=%v", got, readErr) + } +} + +func TestCreateWorktreeRejectsExistingWorktreeFromDifferentBareCache(t *testing.T) { + t.Parallel() + + sourceA := createTestRepo(t) + sourceB := createTestRepo(t) + cache := New(t.TempDir(), testLogger()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceA}, {URL: sourceB}}); err != nil { + t.Fatalf("sync failed: %v", err) + } + bareB := cache.Lookup("ws-1", sourceB) + workDir := t.TempDir() + dirName, err := worktreeDirName(sourceA) + if err != nil { + t.Fatalf("worktreeDirName: %v", err) + } + foreignPath := filepath.Join(workDir, dirName) + if out, err := exec.Command("git", "-C", bareB, "worktree", "add", "-b", "foreign-worktree", foreignPath, "HEAD").CombinedOutput(); err != nil { + t.Fatalf("create foreign worktree: %s: %v", out, err) + } + foreignHead := gitHead(t, foreignPath) + if err := os.WriteFile(filepath.Join(foreignPath, "untracked.txt"), []byte("keep\n"), 0o644); err != nil { + t.Fatalf("write foreign untracked file: %v", err) + } + + _, err = cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceA, + WorkDir: workDir, + AgentName: "agent", + TaskID: "foreign-worktree", + }) + if err == nil || !strings.Contains(err.Error(), "different bare cache") { + t.Fatalf("CreateWorktree error = %v, want ownership error", err) + } + if got := gitHead(t, foreignPath); got != foreignHead { + t.Fatalf("foreign worktree HEAD changed: got %s want %s", got, foreignHead) + } + if got, readErr := os.ReadFile(filepath.Join(foreignPath, "untracked.txt")); readErr != nil || string(got) != "keep\n" { + t.Fatalf("foreign worktree modified before ownership proof: contents=%q err=%v", got, readErr) + } +} + +func TestCreateWorktreeRejectsBorrowedWorktreeAdminDir(t *testing.T) { + sourceRepo := createTestRepo(t) + cache := New(t.TempDir(), testLogger()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil { + t.Fatalf("sync failed: %v", err) + } + + firstWorkDir := t.TempDir() + first, err := cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: firstWorkDir, + AgentName: "agent", + TaskID: "owner", + }) + if err != nil { + t.Fatalf("create owned worktree: %v", err) + } + gitFile, err := os.ReadFile(filepath.Join(first.Path, ".git")) + if err != nil { + t.Fatalf("read owned worktree git file: %v", err) + } + + secondWorkDir := t.TempDir() + dirName, err := worktreeDirName(sourceRepo) + if err != nil { + t.Fatalf("worktreeDirName: %v", err) + } + borrowerPath := filepath.Join(secondWorkDir, dirName) + if err := os.MkdirAll(borrowerPath, 0o755); err != nil { + t.Fatalf("create borrower checkout: %v", err) + } + if err := os.WriteFile(filepath.Join(borrowerPath, ".git"), gitFile, 0o644); err != nil { + t.Fatalf("borrow worktree admin dir: %v", err) + } + sentinel := filepath.Join(borrowerPath, "borrower-sentinel.txt") + if err := os.WriteFile(sentinel, []byte("keep\n"), 0o644); err != nil { + t.Fatalf("write borrower sentinel: %v", err) + } + + _, err = cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: secondWorkDir, + AgentName: "agent", + TaskID: "borrower", + }) + if err == nil || !strings.Contains(err.Error(), "backlink identifies a different checkout") { + t.Fatalf("CreateWorktree error = %v, want git-dir backlink ownership error", err) + } + if got, readErr := os.ReadFile(sentinel); readErr != nil || string(got) != "keep\n" { + t.Fatalf("borrower checkout was modified: contents=%q err=%v", got, readErr) + } +} + +func TestCreateWorktreeRejectsExecutableRepositoryConfigBeforeUpdate(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("identity-bound existing worktree updates are unsupported on windows") + } + + sourceRepo := createTestRepo(t) + cache := New(t.TempDir(), testLogger()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil { + t.Fatalf("sync failed: %v", err) + } + workDir := t.TempDir() + first, err := cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: workDir, + AgentName: "agent", + TaskID: "config-first", + }) + if err != nil { + t.Fatalf("create initial worktree: %v", err) + } + + marker := filepath.Join(t.TempDir(), "filter-process-ran") + filter := "sh -c 'printf filter >" + marker + "; cat'" + runGitAuthored(t, first.Path, "config", "--local", "filter.task-process.process", filter) + sentinel := filepath.Join(first.Path, "untracked-sentinel.txt") + if err := os.WriteFile(sentinel, []byte("keep\n"), 0o644); err != nil { + t.Fatalf("write untracked sentinel: %v", err) + } + + _, err = cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: workDir, + AgentName: "agent", + TaskID: "config-second", + }) + if err == nil || !strings.Contains(err.Error(), "filter.task-process.process") { + t.Fatalf("CreateWorktree error = %v, want executable repository config rejection", err) + } + if _, statErr := os.Stat(marker); !os.IsNotExist(statErr) { + t.Fatalf("repository filter process executed, marker stat error = %v", statErr) + } + if got, readErr := os.ReadFile(sentinel); readErr != nil || string(got) != "keep\n" { + t.Fatalf("worktree was modified before config rejection: contents=%q err=%v", got, readErr) + } +} + +func TestCreateWorktreeRejectsRepositoryConfigIncludesBeforeUpdate(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("identity-bound existing worktree updates are unsupported on windows") + } + + for _, includeKey := range []string{ + "include.path", + "includeIf.gitdir:**.path", + } { + t.Run(includeKey, func(t *testing.T) { + sourceRepo := createTestRepo(t) + if err := os.WriteFile(filepath.Join(sourceRepo, ".gitattributes"), []byte("payload.txt filter=included-filter\n"), 0o644); err != nil { + t.Fatalf("write attributes: %v", err) + } + if err := os.WriteFile(filepath.Join(sourceRepo, "payload.txt"), []byte("source\n"), 0o644); err != nil { + t.Fatalf("write payload: %v", err) + } + runGitAuthored(t, sourceRepo, "add", ".") + runGitAuthored(t, sourceRepo, "commit", "-m", "add filtered payload") + + cache := New(t.TempDir(), testLogger()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil { + t.Fatalf("sync failed: %v", err) + } + workDir := t.TempDir() + first, err := cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: workDir, + AgentName: "agent", + TaskID: "include-first", + }) + if err != nil { + t.Fatalf("create initial worktree: %v", err) + } + + marker := filepath.Join(t.TempDir(), "included-filter-ran") + includedConfig := filepath.Join(t.TempDir(), "included-config") + filter := "sh -c 'printf filter >" + marker + "; cat'" + contents := fmt.Sprintf("[filter \"included-filter\"]\n\tsmudge = %s\n\tclean = %s\n", filter, filter) + if err := os.WriteFile(includedConfig, []byte(contents), 0o600); err != nil { + t.Fatalf("write included config: %v", err) + } + runGitAuthored(t, first.Path, "config", "--local", includeKey, includedConfig) + if err := os.WriteFile(filepath.Join(first.Path, "payload.txt"), []byte("dirty\n"), 0o644); err != nil { + t.Fatalf("dirty filtered payload: %v", err) + } + sentinel := filepath.Join(first.Path, "untracked-sentinel.txt") + if err := os.WriteFile(sentinel, []byte("keep\n"), 0o644); err != nil { + t.Fatalf("write untracked sentinel: %v", err) + } + + _, err = cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: workDir, + AgentName: "agent", + TaskID: "include-second", + }) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(includeKey)) { + t.Fatalf("CreateWorktree error = %v, want repository include config rejection", err) + } + if _, statErr := os.Stat(marker); !os.IsNotExist(statErr) { + t.Fatalf("included repository filter executed, marker stat error = %v", statErr) + } + if got, readErr := os.ReadFile(sentinel); readErr != nil || string(got) != "keep\n" { + t.Fatalf("worktree was modified before include rejection: contents=%q err=%v", got, readErr) + } + }) + } +} + +func TestCreateWorktreeRejectsWritableRepositoryMetadataBeforeUpdate(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("identity-bound existing worktree updates are unsupported on windows") + } + + sourceRepo := createTestRepo(t) + cache := New(t.TempDir(), testLogger()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil { + t.Fatalf("sync failed: %v", err) + } + workDir := t.TempDir() + first, err := cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: workDir, + AgentName: "agent", + TaskID: "metadata-mode-first", + }) + if err != nil { + t.Fatalf("create initial worktree: %v", err) + } + + gitFile, err := os.ReadFile(filepath.Join(first.Path, ".git")) + if err != nil { + t.Fatalf("read worktree .git file: %v", err) + } + gitDir := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(string(gitFile)), "gitdir: ")) + if err := os.Chmod(gitDir, 0o777); err != nil { + t.Fatalf("make worktree metadata writable: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(gitDir, 0o755) }) + sentinel := filepath.Join(first.Path, "untracked-sentinel.txt") + if err := os.WriteFile(sentinel, []byte("keep\n"), 0o644); err != nil { + t.Fatalf("write untracked sentinel: %v", err) + } + + _, err = cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: workDir, + AgentName: "agent", + TaskID: "metadata-mode-second", + }) + if err == nil || !strings.Contains(err.Error(), "group/world writable") { + t.Fatalf("CreateWorktree error = %v, want writable metadata rejection", err) + } + if got, readErr := os.ReadFile(sentinel); readErr != nil || string(got) != "keep\n" { + t.Fatalf("worktree was modified before metadata rejection: contents=%q err=%v", got, readErr) + } +} + +func TestCreateWorktreeRejectsWorktreeReplacementDuringUpdate(t *testing.T) { + for _, replacementKind := range []string{"worktree", "parent"} { + for _, replacementStage := range []string{"after-owner-proof", "after-reset", "after-clean", "after-checkout", "after-checkout-retry"} { + t.Run(replacementKind+"/"+replacementStage, func(t *testing.T) { + sourceRepo := createTestRepo(t) + cache := New(t.TempDir(), testLogger()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil { + t.Fatalf("sync failed: %v", err) + } + workDir := t.TempDir() + first, err := cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: workDir, + AgentName: "agent", + TaskID: "replacement-first", + }) + if err != nil { + t.Fatalf("create initial worktree: %v", err) + } + + movedPath := first.Path + "-moved" + movedParent := workDir + "-moved" + foreignSource := createTestRepo(t) + foreignSourceSentinel := filepath.Join(foreignSource, "foreign-sentinel.txt") + if err := os.WriteFile(foreignSourceSentinel, []byte("keep\n"), 0o644); err != nil { + t.Fatalf("write foreign source sentinel: %v", err) + } + runGitAuthored(t, foreignSource, "add", "foreign-sentinel.txt") + runGitAuthored(t, foreignSource, "commit", "-m", "add foreign sentinel") + foreignSentinel := filepath.Join(first.Path, "foreign-sentinel.txt") + replaced := false + cache.existingWorktreeHook = func(stage, worktreePath string) { + if stage != replacementStage || replaced { + return + } + replaced = true + if replacementKind == "parent" { + if err := os.Rename(workDir, movedParent); err != nil { + t.Fatalf("rename validated worktree parent: %v", err) + } + if err := os.MkdirAll(workDir, 0o755); err != nil { + t.Fatalf("install replacement parent: %v", err) + } + } else if err := os.Rename(worktreePath, movedPath); err != nil { + t.Fatalf("rename validated worktree: %v", err) + } + if out, err := exec.Command("git", "clone", foreignSource, worktreePath).CombinedOutput(); err != nil { + t.Fatalf("install replacement checkout: %s: %v", out, err) + } + } + + agentName := "agent" + if replacementStage == "after-checkout" { + agentName = "fresh-agent" + } + _, err = cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: workDir, + AgentName: agentName, + TaskID: "replacement-second", + }) + if err == nil { + t.Fatal("CreateWorktree succeeded after the validated worktree path was replaced") + } + if !replaced { + t.Fatalf("replacement hook did not run at %s: create error=%v", replacementStage, err) + } + if got, readErr := os.ReadFile(foreignSentinel); readErr != nil || string(got) != "keep\n" { + t.Fatalf("replacement checkout was modified: contents=%q err=%v create error=%v", got, readErr, err) + } + }) + } + } +} + +func TestCreateWorktreeRejectsRepositoryMetadataReplacementDuringUpdate(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("identity-bound existing worktree updates are unsupported on windows") + } + + for _, replacementKind := range []string{"worktree-git-dir", "git-common-dir"} { + for _, replacementStage := range []string{"after-owner-proof", "after-reset", "after-clean", "after-checkout", "after-checkout-retry"} { + t.Run(replacementKind+"/"+replacementStage, func(t *testing.T) { + sourceRepo := createTestRepo(t) + cache := New(t.TempDir(), testLogger()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil { + t.Fatalf("sync failed: %v", err) + } + workDir := t.TempDir() + first, err := cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: workDir, + AgentName: "agent", + TaskID: "metadata-first", + }) + if err != nil { + t.Fatalf("create initial worktree: %v", err) + } + + gitFile, err := os.ReadFile(filepath.Join(first.Path, ".git")) + if err != nil { + t.Fatalf("read worktree .git file: %v", err) + } + gitDir := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(string(gitFile)), "gitdir: ")) + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(first.Path, gitDir) + } + gitDir, err = filepath.EvalSymlinks(gitDir) + if err != nil { + t.Fatalf("resolve worktree git dir: %v", err) + } + commonFile, err := os.ReadFile(filepath.Join(gitDir, "commondir")) + if err != nil { + t.Fatalf("read worktree common dir: %v", err) + } + commonDir := strings.TrimSpace(string(commonFile)) + if !filepath.IsAbs(commonDir) { + commonDir = filepath.Join(gitDir, commonDir) + } + commonDir, err = filepath.EvalSymlinks(commonDir) + if err != nil { + t.Fatalf("resolve git common dir: %v", err) + } + + target := gitDir + if replacementKind == "git-common-dir" { + target = commonDir + } + movedTarget := target + "-moved" + sentinel := filepath.Join(target, "replacement-sentinel.txt") + replaced := false + cache.existingWorktreeHook = func(stage, _ string) { + if stage != replacementStage || replaced { + return + } + replaced = true + if err := os.Rename(target, movedTarget); err != nil { + t.Fatalf("rename validated %s: %v", replacementKind, err) + } + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("install replacement %s: %v", replacementKind, err) + } + if err := os.WriteFile(sentinel, []byte("keep\n"), 0o644); err != nil { + t.Fatalf("write replacement sentinel: %v", err) + } + } + + agentName := "agent" + if replacementStage == "after-checkout" { + agentName = "fresh-agent" + } + _, err = cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: workDir, + AgentName: agentName, + TaskID: "metadata-second", + }) + if err == nil { + t.Fatalf("CreateWorktree succeeded after %s replacement", replacementKind) + } + if !replaced { + t.Fatalf("replacement hook did not run at %s: %v", replacementStage, err) + } + if got, readErr := os.ReadFile(sentinel); readErr != nil || string(got) != "keep\n" { + t.Fatalf("replacement metadata directory was modified: contents=%q err=%v create error=%v", got, readErr, err) + } + }) + } + } +} + +func TestCreateWorktreeRejectsNewWorktreeReplacement(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("identity-bound new worktree validation is unsupported on windows") + } + + for _, replacementKind := range []string{"worktree", "worktree-git-dir", "git-common-dir"} { + for _, replacementStage := range []string{"after-worktree-add", "before-exclude", "before-hook", "before-return"} { + t.Run(replacementKind+"/"+replacementStage, func(t *testing.T) { + sourceRepo := createTestRepo(t) + cache := New(t.TempDir(), testLogger()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil { + t.Fatalf("sync failed: %v", err) + } + workDir := t.TempDir() + dirName, err := worktreeDirName(sourceRepo) + if err != nil { + t.Fatalf("derive worktree directory name: %v", err) + } + worktreePath, err := canonicalWorktreeTarget(workDir, dirName) + if err != nil { + t.Fatalf("derive canonical worktree target: %v", err) + } + foreignSource := createTestRepo(t) + foreignSourceSentinel := filepath.Join(foreignSource, "foreign-sentinel.txt") + if err := os.WriteFile(foreignSourceSentinel, []byte("keep\n"), 0o644); err != nil { + t.Fatalf("write foreign source sentinel: %v", err) + } + runGitAuthored(t, foreignSource, "add", "foreign-sentinel.txt") + runGitAuthored(t, foreignSource, "commit", "-m", "add foreign sentinel") + + replaced := false + var replacementSentinel string + cache.existingWorktreeHook = func(stage, worktreePath string) { + if stage != replacementStage || replaced { + return + } + replaced = true + gitFile, err := os.ReadFile(filepath.Join(worktreePath, ".git")) + if err != nil { + t.Fatalf("read new worktree .git file: %v", err) + } + gitDir := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(string(gitFile)), "gitdir: ")) + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(worktreePath, gitDir) + } + gitDir, err = filepath.EvalSymlinks(gitDir) + if err != nil { + t.Fatalf("resolve new worktree git dir: %v", err) + } + commonFile, err := os.ReadFile(filepath.Join(gitDir, "commondir")) + if err != nil { + t.Fatalf("read new worktree common dir: %v", err) + } + commonDir := strings.TrimSpace(string(commonFile)) + if !filepath.IsAbs(commonDir) { + commonDir = filepath.Join(gitDir, commonDir) + } + commonDir, err = filepath.EvalSymlinks(commonDir) + if err != nil { + t.Fatalf("resolve new worktree common dir: %v", err) + } + + switch replacementKind { + case "worktree": + if err := os.Rename(worktreePath, worktreePath+"-moved"); err != nil { + t.Fatalf("rename new worktree: %v", err) + } + if out, err := exec.Command("git", "clone", foreignSource, worktreePath).CombinedOutput(); err != nil { + t.Fatalf("install replacement checkout: %s: %v", out, err) + } + replacementSentinel = filepath.Join(worktreePath, "foreign-sentinel.txt") + case "worktree-git-dir", "git-common-dir": + target := gitDir + if replacementKind == "git-common-dir" { + target = commonDir + } + if err := os.Rename(target, target+"-moved"); err != nil { + t.Fatalf("rename new %s: %v", replacementKind, err) + } + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("install replacement %s: %v", replacementKind, err) + } + replacementSentinel = filepath.Join(target, "replacement-sentinel.txt") + if err := os.WriteFile(replacementSentinel, []byte("keep\n"), 0o644); err != nil { + t.Fatalf("write replacement sentinel: %v", err) + } + } + } + + _, err = cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: workDir, + AgentName: "agent", + TaskID: "new-replacement", + CoAuthoredByEnabled: true, + }) + if err == nil { + t.Fatal("CreateWorktree succeeded after new worktree identity replacement") + } + if !replaced { + t.Fatalf("replacement hook did not run at %s: create error=%v", replacementStage, err) + } + if got, readErr := os.ReadFile(replacementSentinel); readErr != nil || string(got) != "keep\n" { + t.Fatalf("replacement target was modified: contents=%q err=%v create error=%v", got, readErr, err) + } + if replacementKind == "worktree" { + entries, readErr := os.ReadDir(worktreePath + "-moved") + if readErr != nil || len(entries) == 0 { + t.Fatalf("displaced provisional checkout was modified: entries=%v err=%v create error=%v", entries, readErr, err) + } + } + }) + } + } +} + +func TestCreateWorktreeDoesNotCreateInsideReplacementDuringWorktreeAdd(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("identity-bound worktree publication is unsupported on windows") + } + + sourceRepo := createTestRepo(t) + cache := New(t.TempDir(), testLogger()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil { + t.Fatalf("sync failed: %v", err) + } + workDir := t.TempDir() + dirName, err := worktreeDirName(sourceRepo) + if err != nil { + t.Fatalf("derive worktree directory name: %v", err) + } + worktreePath, err := canonicalWorktreeTarget(workDir, dirName) + if err != nil { + t.Fatalf("derive canonical worktree target: %v", err) + } + attackerTarget := t.TempDir() + attackerSentinel := filepath.Join(attackerTarget, "keep.txt") + if err := os.WriteFile(attackerSentinel, []byte("keep\n"), 0o644); err != nil { + t.Fatalf("write attacker sentinel: %v", err) + } + + hookRan := false + cache.worktreeAddHook = func(stage, addPath string) { + if stage != "after-start" || hookRan { + return + } + hookRan = true + if addPath == worktreePath { + t.Fatalf("Git received the task-visible final worktree path: %s", addPath) + } + stagingRoot, canonicalErr := canonicalExistingDir(filepath.Join(cache.root, ".worktree-staging")) + if canonicalErr != nil { + t.Fatalf("resolve staging root: %v", canonicalErr) + } + canonicalAddParent, canonicalErr := canonicalExistingDir(filepath.Dir(addPath)) + if canonicalErr != nil || canonicalAddParent != stagingRoot { + t.Fatalf("Git worktree path is outside daemon staging: path=%s parent=%s root=%s err=%v", addPath, canonicalAddParent, stagingRoot, canonicalErr) + } + if err := os.Symlink(attackerTarget, worktreePath); err != nil { + t.Fatalf("replace worktree target during add: %v", err) + } + } + + _, err = cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: workDir, + AgentName: "agent", + TaskID: "during-add-replacement", + }) + if err == nil { + t.Fatal("CreateWorktree succeeded after the final target was replaced during git worktree add") + } + if !hookRan { + t.Fatalf("worktree add hook did not run: %v", err) + } + if got, readErr := os.ReadFile(attackerSentinel); readErr != nil || string(got) != "keep\n" { + t.Fatalf("attacker sentinel changed: contents=%q err=%v create error=%v", got, readErr, err) + } + entries, readErr := os.ReadDir(attackerTarget) + if readErr != nil { + t.Fatalf("read attacker target: %v", readErr) + } + if len(entries) != 1 || entries[0].Name() != "keep.txt" { + t.Fatalf("Git wrote into attacker replacement: entries=%v create error=%v", entries, err) + } + barePath, lookupErr := cache.lookupPath(context.Background(), "ws-1", sourceRepo) + if lookupErr != nil { + t.Fatalf("lookup bare cache: %v", lookupErr) + } + branch := "refs/heads/agent/agent/during-a" + if out, refErr := exec.Command("git", "-C", barePath, "rev-parse", "--verify", "--quiet", branch).CombinedOutput(); refErr == nil { + t.Fatalf("unpublished branch remains: ref=%s output=%s", branch, out) + } + worktreeEntries, readErr := os.ReadDir(filepath.Join(barePath, "worktrees")) + if readErr != nil && !os.IsNotExist(readErr) { + t.Fatalf("read bare worktree metadata: %v", readErr) + } + if len(worktreeEntries) != 0 { + t.Fatalf("unpublished worktree metadata remains: %v", worktreeEntries) + } + stagingEntries, readErr := os.ReadDir(filepath.Join(cache.root, ".worktree-staging")) + if readErr != nil { + t.Fatalf("read staging root after rejected publication: %v", readErr) + } + if len(stagingEntries) != 0 { + t.Fatalf("unpublished staging entries remain: %v", stagingEntries) + } +} + +func TestCreateWorktreePublishesFinalBacklinksWithoutStagingIdentity(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("identity-bound worktree publication is unsupported on windows") + } + + sourceRepo := createTestRepo(t) + cache := New(t.TempDir(), testLogger()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil { + t.Fatalf("sync failed: %v", err) + } + result, err := cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: t.TempDir(), + AgentName: "agent", + TaskID: "published-backlinks", + }) + if err != nil { + t.Fatalf("create worktree: %v", err) + } + gitFile, err := os.ReadFile(filepath.Join(result.Path, ".git")) + if err != nil { + t.Fatalf("read published .git file: %v", err) + } + gitDir := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(string(gitFile)), "gitdir: ")) + if !filepath.IsAbs(gitDir) { + t.Fatalf("published .git path is not absolute: %q", gitDir) + } + backlink, err := os.ReadFile(filepath.Join(gitDir, "gitdir")) + if err != nil { + t.Fatalf("read published git-dir backlink: %v", err) + } + wantBacklink := filepath.Join(result.Path, ".git") + if got := strings.TrimSpace(string(backlink)); got != wantBacklink { + t.Fatalf("published git-dir backlink mismatch: got %q want %q", got, wantBacklink) + } + if strings.Contains(string(gitFile), ".worktree-staging") || strings.Contains(string(backlink), ".worktree-staging") { + t.Fatalf("published metadata retains staging identity: gitFile=%q backlink=%q", gitFile, backlink) + } + entries, err := os.ReadDir(filepath.Join(cache.root, ".worktree-staging")) + if err != nil { + t.Fatalf("read staging root: %v", err) + } + if len(entries) != 0 { + t.Fatalf("published staging root is not empty: %v", entries) + } +} + +func TestCreateWorktreeRejectsFinalParentReplacementBeforePublication(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("identity-bound worktree publication is unsupported on windows") + } + + sourceRepo := createTestRepo(t) + cache := New(t.TempDir(), testLogger()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil { + t.Fatalf("sync failed: %v", err) + } + workDir := t.TempDir() + replacementSentinel := filepath.Join(workDir, "replacement-sentinel.txt") + hookRan := false + cache.worktreePublicationHook = func(stage, _ string) { + if stage != "before-publication" || hookRan { + return + } + hookRan = true + if err := os.Rename(workDir, workDir+"-moved"); err != nil { + t.Fatalf("rename final parent: %v", err) + } + if err := os.Mkdir(workDir, 0o755); err != nil { + t.Fatalf("install replacement final parent: %v", err) + } + if err := os.WriteFile(replacementSentinel, []byte("keep\n"), 0o644); err != nil { + t.Fatalf("write replacement parent sentinel: %v", err) + } + } + + _, err := cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: workDir, + AgentName: "agent", + TaskID: "parent-replacement", + }) + if err == nil { + t.Fatal("CreateWorktree succeeded after final parent replacement") + } + if !hookRan { + t.Fatalf("publication hook did not run: %v", err) + } + if got, readErr := os.ReadFile(replacementSentinel); readErr != nil || string(got) != "keep\n" { + t.Fatalf("replacement parent was modified: contents=%q err=%v create error=%v", got, readErr, err) + } + entries, readErr := os.ReadDir(workDir) + if readErr != nil { + t.Fatalf("read replacement parent: %v", readErr) + } + if len(entries) != 1 || entries[0].Name() != "replacement-sentinel.txt" { + t.Fatalf("published into replacement parent: entries=%v create error=%v", entries, err) + } +} + +func TestCreateWorktreeRollsBackWhenFinalParentChangesAfterPublication(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("identity-bound worktree publication is unsupported on windows") + } + + sourceRepo := createTestRepo(t) + cache := New(t.TempDir(), testLogger()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil { + t.Fatalf("sync failed: %v", err) + } + workDir := t.TempDir() + movedWorkDir := workDir + "-moved" + replacementSentinel := filepath.Join(workDir, "replacement-sentinel.txt") + hookRan := false + cache.worktreePublicationHook = func(stage, _ string) { + if stage != "after-publication" || hookRan { + return + } + hookRan = true + if err := os.Rename(workDir, movedWorkDir); err != nil { + t.Fatalf("rename published final parent: %v", err) + } + if err := os.Mkdir(workDir, 0o755); err != nil { + t.Fatalf("install replacement final parent: %v", err) + } + if err := os.WriteFile(replacementSentinel, []byte("keep\n"), 0o644); err != nil { + t.Fatalf("write replacement parent sentinel: %v", err) + } + } + + _, err := cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: workDir, + AgentName: "agent", + TaskID: "post-publish-parent-replacement", + }) + if err == nil { + t.Fatal("CreateWorktree succeeded after post-publication parent replacement") + } + if !hookRan { + t.Fatalf("post-publication hook did not run: %v", err) + } + if got, readErr := os.ReadFile(replacementSentinel); readErr != nil || string(got) != "keep\n" { + t.Fatalf("replacement parent was modified: contents=%q err=%v create error=%v", got, readErr, err) + } + dirName, nameErr := worktreeDirName(sourceRepo) + if nameErr != nil { + t.Fatalf("derive worktree directory name: %v", nameErr) + } + if _, statErr := os.Lstat(filepath.Join(movedWorkDir, dirName)); !os.IsNotExist(statErr) { + t.Fatalf("provisional checkout remains in moved final parent: err=%v create error=%v", statErr, err) + } + barePath, lookupErr := cache.lookupPath(context.Background(), "ws-1", sourceRepo) + if lookupErr != nil { + t.Fatalf("lookup bare cache: %v", lookupErr) + } + branch := "refs/heads/agent/agent/post-pub" + if out, refErr := exec.Command("git", "-C", barePath, "rev-parse", "--verify", "--quiet", branch).CombinedOutput(); refErr == nil { + t.Fatalf("rolled-back branch remains: ref=%s output=%s", branch, out) + } + worktreeEntries, readErr := os.ReadDir(filepath.Join(barePath, "worktrees")) + if readErr != nil && !os.IsNotExist(readErr) { + t.Fatalf("read bare worktree metadata: %v", readErr) + } + if len(worktreeEntries) != 0 { + t.Fatalf("rolled-back worktree metadata remains: %v", worktreeEntries) + } + stagingEntries, readErr := os.ReadDir(filepath.Join(cache.root, ".worktree-staging")) + if readErr != nil { + t.Fatalf("read staging root after rollback: %v", readErr) + } + if len(stagingEntries) != 0 { + t.Fatalf("rolled-back staging entries remain: %v", stagingEntries) + } +} + +func TestCreateWorktreeRejectsFinalTargetRaceBeforePublication(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("identity-bound worktree publication is unsupported on windows") + } + + for _, replacementKind := range []string{"directory", "symlink"} { + t.Run(replacementKind, func(t *testing.T) { + sourceRepo := createTestRepo(t) + cache := New(t.TempDir(), testLogger()) + if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil { + t.Fatalf("sync failed: %v", err) + } + workDir := t.TempDir() + dirName, err := worktreeDirName(sourceRepo) + if err != nil { + t.Fatalf("derive worktree directory name: %v", err) + } + worktreePath, err := canonicalWorktreeTarget(workDir, dirName) + if err != nil { + t.Fatalf("derive canonical worktree target: %v", err) + } + attackerTarget := t.TempDir() + sentinel := filepath.Join(attackerTarget, "keep.txt") + if err := os.WriteFile(sentinel, []byte("keep\n"), 0o644); err != nil { + t.Fatalf("write attacker sentinel: %v", err) + } + cache.worktreePublicationHook = func(stage, _ string) { + if stage != "before-publication" { + return + } + if replacementKind == "directory" { + if err := os.Mkdir(worktreePath, 0o755); err != nil { + t.Fatalf("install target directory: %v", err) + } + if err := os.WriteFile(filepath.Join(worktreePath, "keep.txt"), []byte("keep\n"), 0o644); err != nil { + t.Fatalf("write target directory sentinel: %v", err) + } + return + } + if err := os.Symlink(attackerTarget, worktreePath); err != nil { + t.Fatalf("install target symlink: %v", err) + } + } + + _, err = cache.CreateWorktree(WorktreeParams{ + WorkspaceID: "ws-1", + RepoURL: sourceRepo, + WorkDir: workDir, + AgentName: "agent", + TaskID: "target-race-" + replacementKind, + }) + if err == nil { + t.Fatal("CreateWorktree succeeded after final target race") + } + if replacementKind == "symlink" { + entries, readErr := os.ReadDir(attackerTarget) + if readErr != nil || len(entries) != 1 || entries[0].Name() != "keep.txt" { + t.Fatalf("symlink target was modified: entries=%v err=%v create error=%v", entries, readErr, err) + } + } else if got, readErr := os.ReadFile(filepath.Join(worktreePath, "keep.txt")); readErr != nil || string(got) != "keep\n" { + t.Fatalf("target directory was modified: contents=%q err=%v create error=%v", got, readErr, err) + } + }) + } +} + func TestCreateWorktreeExcludesOpenCodeSkills(t *testing.T) { t.Parallel() sourceRepo := createTestRepo(t) diff --git a/server/internal/daemon/repocache/worktree_identity.go b/server/internal/daemon/repocache/worktree_identity.go new file mode 100644 index 00000000000..ce930cc6ce6 --- /dev/null +++ b/server/internal/daemon/repocache/worktree_identity.go @@ -0,0 +1,126 @@ +package repocache + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +type worktreeHandle struct { + worktree pathHandle + gitDir pathHandle + common pathHandle +} + +type bareCacheHandle struct { + identity pathHandle +} + +func (h *bareCacheHandle) Path() string { + if h == nil { + return "" + } + return h.identity.path +} + +func (h *bareCacheHandle) RecheckPath() error { + if h == nil { + return fmt.Errorf("bare repository cache identity is unavailable") + } + return h.identity.recheck("bare repository cache") +} + +func (h *bareCacheHandle) Close() error { + if h == nil || h.identity.file == nil { + return nil + } + err := h.identity.file.Close() + h.identity.file = nil + return err +} + +func (h *worktreeHandle) Path() string { + return h.worktree.path +} + +func (h *worktreeHandle) Close() error { + if h == nil { + return nil + } + var first error + for _, identity := range []*pathHandle{&h.worktree, &h.gitDir, &h.common} { + if identity.file != nil { + if err := identity.file.Close(); err != nil && first == nil { + first = err + } + identity.file = nil + } + } + return first +} + +type pathHandle struct { + path string + file *os.File + info os.FileInfo +} + +func (p *pathHandle) recheck(kind string) error { + if p == nil || p.file == nil || p.info == nil { + return fmt.Errorf("%s identity is unavailable", kind) + } + opened, err := p.file.Stat() + if err != nil || !os.SameFile(p.info, opened) || opened.Mode() != p.info.Mode() { + return fmt.Errorf("%s descriptor identity changed: %s", kind, p.path) + } + info, err := os.Lstat(p.path) + if err != nil { + return fmt.Errorf("recheck %s identity %q: %w", kind, p.path, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() || !os.SameFile(p.info, info) { + return fmt.Errorf("%s path changed after ownership proof: %s", kind, p.path) + } + return nil +} + +func (h *worktreeHandle) RecheckPaths() error { + if h == nil { + return fmt.Errorf("worktree identity is unavailable") + } + for _, check := range []struct { + kind string + identity *pathHandle + }{ + {"worktree", &h.worktree}, + {"worktree git dir", &h.gitDir}, + {"git common dir", &h.common}, + } { + if err := check.identity.recheck(check.kind); err != nil { + return err + } + } + return nil +} + +func (h *worktreeHandle) VerifyGitDirBacklink() error { + if h == nil || h.worktree.file == nil || h.worktree.info == nil || h.gitDir.file == nil { + return fmt.Errorf("worktree identity is unavailable") + } + contents, err := readFileAt(h.gitDir.file, "gitdir") + if err != nil { + return fmt.Errorf("read worktree git-dir backlink: %w", err) + } + gitFile := strings.TrimSpace(string(contents)) + if !filepath.IsAbs(gitFile) || filepath.Base(gitFile) != ".git" || filepath.Clean(gitFile) != gitFile { + return fmt.Errorf("worktree git-dir backlink is invalid: %q", gitFile) + } + info, err := os.Stat(filepath.Dir(gitFile)) + if err != nil { + return fmt.Errorf("stat worktree git-dir backlink owner: %w", err) + } + if !info.IsDir() || !os.SameFile(h.worktree.info, info) { + return fmt.Errorf("worktree git-dir backlink identifies a different checkout: %s", gitFile) + } + return nil +} diff --git a/server/internal/daemon/repocache/worktree_identity_unix.go b/server/internal/daemon/repocache/worktree_identity_unix.go new file mode 100644 index 00000000000..a3966cc19d3 --- /dev/null +++ b/server/internal/daemon/repocache/worktree_identity_unix.go @@ -0,0 +1,738 @@ +//go:build linux || darwin + +package repocache + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +const internalGitWorktreeMode = "__multica_internal_git_worktree_fd_v1" +const internalGitBareCacheMode = "__multica_internal_git_bare_cache_fd_v1" + +type worktreePublication struct { + stagingParent pathHandle + finalParent pathHandle + stagingName string + finalName string + stagingPath string + finalPath string + prepared bool + moved bool + committed bool + cleanupAllowed bool +} + +func newWorktreePublication(cacheRoot, finalPath string) (*worktreePublication, error) { + if !filepath.IsAbs(finalPath) || filepath.Clean(finalPath) != finalPath || !isSafePathComponent(filepath.Base(finalPath)) { + return nil, fmt.Errorf("final worktree path must be absolute and canonical: %q", finalPath) + } + if err := validateDaemonOwnedDirectoryPath(cacheRoot, "repository cache root"); err != nil { + return nil, err + } + cacheRootHandle, err := openDirectoryHandle(cacheRoot) + if err != nil { + return nil, fmt.Errorf("open repository cache root: %w", err) + } + defer cacheRootHandle.file.Close() + if err := cacheRootHandle.recheck("repository cache root"); err != nil { + return nil, err + } + stagingRoot := filepath.Join(cacheRoot, ".worktree-staging") + stagingFD, err := ensureDirectoryAt(cacheRootHandle.file, ".worktree-staging", 0o700) + if err != nil { + return nil, fmt.Errorf("create worktree staging root: %w", err) + } + stagingFile := os.NewFile(uintptr(stagingFD), stagingRoot) + if err := unix.Fchmod(stagingFD, 0o700); err != nil { + _ = stagingFile.Close() + return nil, fmt.Errorf("restrict worktree staging root: %w", err) + } + stagingInfo, err := stagingFile.Stat() + if err != nil { + _ = stagingFile.Close() + return nil, fmt.Errorf("stat worktree staging root: %w", err) + } + stagingParent := pathHandle{path: stagingRoot, file: stagingFile, info: stagingInfo} + if err := validateDaemonOwnedDirectoryPath(stagingRoot, "worktree staging root"); err != nil { + _ = stagingParent.file.Close() + return nil, err + } + if err := stagingParent.recheck("worktree staging root"); err != nil { + _ = stagingParent.file.Close() + return nil, err + } + finalParentPath := filepath.Dir(finalPath) + finalParent, err := openDirectoryHandle(finalParentPath) + if err != nil { + _ = stagingParent.file.Close() + return nil, fmt.Errorf("open final worktree parent: %w", err) + } + if !sameFilesystem(stagingParent.info, finalParent.info) { + _ = stagingParent.file.Close() + _ = finalParent.file.Close() + return nil, fmt.Errorf("worktree staging root and final parent are on different filesystems") + } + if err := ensurePathAbsentAt(finalParent.file, filepath.Base(finalPath)); err != nil { + _ = stagingParent.file.Close() + _ = finalParent.file.Close() + return nil, err + } + name, err := reserveStagingName(stagingParent.file) + if err != nil { + _ = stagingParent.file.Close() + _ = finalParent.file.Close() + return nil, err + } + return &worktreePublication{ + stagingParent: stagingParent, + finalParent: finalParent, + stagingName: name, + finalName: filepath.Base(finalPath), + stagingPath: filepath.Join(stagingRoot, name), + finalPath: finalPath, + cleanupAllowed: true, + }, nil +} + +func (p *worktreePublication) StagingPath() string { return p.stagingPath } + +func (p *worktreePublication) Prepare(handle *worktreeHandle) error { + if p == nil || handle == nil || handle.gitDir.file == nil { + return fmt.Errorf("worktree publication identity is unavailable") + } + if err := p.recheckParents(); err != nil { + return err + } + if err := handle.RecheckPaths(); err != nil { + return err + } + if handle.Path() != p.stagingPath { + return fmt.Errorf("worktree staging identity mismatch: got %s want %s", handle.Path(), p.stagingPath) + } + if err := writeFileAtFDAtomic(int(handle.gitDir.file.Fd()), "gitdir", []byte(filepath.Join(p.finalPath, ".git")+"\n"), 0o644); err != nil { + return fmt.Errorf("rewrite worktree git-dir backlink: %w", err) + } + p.prepared = true + return p.recheckParents() +} + +func (p *worktreePublication) Publish(handle *worktreeHandle) error { + if p == nil { + return fmt.Errorf("worktree publication identity is unavailable") + } + if err := p.recheckParents(); err != nil { + return err + } + if err := ensurePathAbsentAt(p.finalParent.file, p.finalName); err != nil { + return err + } + if err := renameDirectoryNoReplace(int(p.stagingParent.file.Fd()), p.stagingName, int(p.finalParent.file.Fd()), p.finalName); err != nil { + return err + } + p.moved = true + if handle != nil { + handle.worktree.path = p.finalPath + } + return nil +} + +func (p *worktreePublication) Commit() { + if p != nil { + p.committed = true + } +} + +func (p *worktreePublication) Rollback(handle *worktreeHandle) error { + if p == nil || p.committed { + return nil + } + var rollbackErr error + if p.moved { + // Move the published name into the task-inaccessible staging directory + // before checking identity. This makes the rename and ownership transfer + // one atomic operation instead of checking an attacker-controlled source + // pathname and then acting on it. + if err := renameDirectoryNoReplace(int(p.finalParent.file.Fd()), p.finalName, int(p.stagingParent.file.Fd()), p.stagingName); err != nil { + p.cleanupAllowed = false + rollbackErr = errors.Join(rollbackErr, fmt.Errorf("return provisional worktree to staging: %w", err)) + } else { + matches, err := pathEntryMatchesHandle(p.stagingParent.file, p.stagingName, handle) + if err != nil || !matches { + p.cleanupAllowed = false + if err != nil { + rollbackErr = errors.Join(rollbackErr, fmt.Errorf("verify quarantined provisional worktree: %w", err)) + } else { + rollbackErr = errors.Join(rollbackErr, fmt.Errorf("published worktree identity changed before rollback")) + } + if restoreErr := renameDirectoryNoReplace(int(p.stagingParent.file.Fd()), p.stagingName, int(p.finalParent.file.Fd()), p.finalName); restoreErr != nil { + rollbackErr = errors.Join(rollbackErr, fmt.Errorf("restore quarantined replacement: %w", restoreErr)) + } + } else { + p.moved = false + if handle != nil { + handle.worktree.path = p.stagingPath + } + } + } + } + if p.prepared && !p.moved && p.cleanupAllowed && handle != nil && handle.gitDir.file != nil { + if err := writeFileAtFDAtomic(int(handle.gitDir.file.Fd()), "gitdir", []byte(filepath.Join(p.stagingPath, ".git")+"\n"), 0o644); err != nil { + p.cleanupAllowed = false + rollbackErr = errors.Join(rollbackErr, fmt.Errorf("restore staged worktree backlink: %w", err)) + } + } + return rollbackErr +} + +func (p *worktreePublication) CleanupAllowed() bool { + return p != nil && p.cleanupAllowed +} + +func pathEntryMatchesHandle(parent *os.File, name string, handle *worktreeHandle) (bool, error) { + if parent == nil || handle == nil || handle.worktree.file == nil || handle.worktree.info == nil { + return false, fmt.Errorf("worktree rollback identity is unavailable") + } + var entry unix.Stat_t + if err := unix.Fstatat(int(parent.Fd()), name, &entry, unix.AT_SYMLINK_NOFOLLOW); err != nil { + if err == unix.ENOENT { + return false, nil + } + return false, err + } + var opened unix.Stat_t + if err := unix.Fstat(int(handle.worktree.file.Fd()), &opened); err != nil { + return false, err + } + return entry.Dev == opened.Dev && entry.Ino == opened.Ino && entry.Mode == opened.Mode && entry.Mode&unix.S_IFMT == unix.S_IFDIR, nil +} + +func (p *worktreePublication) Close() error { + if p == nil { + return nil + } + var first error + for _, identity := range []*pathHandle{&p.stagingParent, &p.finalParent} { + if identity.file != nil { + if err := identity.file.Close(); err != nil && first == nil { + first = err + } + identity.file = nil + } + } + return first +} + +func (p *worktreePublication) recheckParents() error { + if err := p.stagingParent.recheck("worktree staging parent"); err != nil { + return err + } + return p.finalParent.recheck("final worktree parent") +} + +func sameFilesystem(a, b os.FileInfo) bool { + aStat, aOK := a.Sys().(*syscall.Stat_t) + bStat, bOK := b.Sys().(*syscall.Stat_t) + return aOK && bOK && aStat.Dev == bStat.Dev +} + +func reserveStagingName(parent *os.File) (string, error) { + for attempts := 0; attempts < 32; attempts++ { + var random [16]byte + if _, err := rand.Read(random[:]); err != nil { + return "", fmt.Errorf("generate worktree staging name: %w", err) + } + name := "worktree-" + hex.EncodeToString(random[:]) + var stat unix.Stat_t + err := unix.Fstatat(int(parent.Fd()), name, &stat, unix.AT_SYMLINK_NOFOLLOW) + if err == unix.ENOENT { + return name, nil + } + if err != nil { + return "", fmt.Errorf("check worktree staging name: %w", err) + } + } + return "", fmt.Errorf("cannot allocate unique worktree staging name") +} + +func ensurePathAbsentAt(parent *os.File, name string) error { + var stat unix.Stat_t + err := unix.Fstatat(int(parent.Fd()), name, &stat, unix.AT_SYMLINK_NOFOLLOW) + if err == unix.ENOENT { + return nil + } + if err != nil { + return fmt.Errorf("check final worktree target: %w", err) + } + return fmt.Errorf("final worktree target already exists: %s", name) +} + +func identityBoundWorktreeAccessSupported() bool { + return true +} + +func init() { + if len(os.Args) >= 5 && os.Args[1] == internalGitBareCacheMode { + if err := verifyDirectoryFDPath(3, os.Args[2]); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "verify bare repository cache identity: %v\n", err) + os.Exit(126) + } + if err := unix.Fchdir(3); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "bind bare repository cache cwd: %v\n", err) + os.Exit(126) + } + boundCache, err := os.Getwd() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "resolve bound bare repository cache cwd: %v\n", err) + os.Exit(126) + } + unix.CloseOnExec(3) + execTrustedGit(os.Args[3], os.Args[4:], boundCache) + } + if len(os.Args) < 6 || os.Args[1] != internalGitWorktreeMode { + return + } + for _, identity := range []struct { + fd int + path string + kind string + }{ + {3, os.Args[2], "worktree"}, + {4, os.Args[3], "worktree git dir"}, + {5, os.Args[4], "git common dir"}, + } { + if err := verifyDirectoryFDPath(identity.fd, identity.path); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "verify %s identity: %v\n", identity.kind, err) + os.Exit(126) + } + } + if err := unix.Fchdir(3); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "bind git worktree cwd: %v\n", err) + os.Exit(126) + } + boundWorktree, err := os.Getwd() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "resolve bound git worktree cwd: %v\n", err) + os.Exit(126) + } + for fd := 3; fd <= 5; fd++ { + unix.CloseOnExec(fd) + } + execTrustedGit(os.Args[5], os.Args[6:], boundWorktree) +} + +func execTrustedGit(gitPath string, args []string, boundPWD string) { + if !filepath.IsAbs(gitPath) { + _, _ = fmt.Fprintln(os.Stderr, "trusted git path is not absolute") + os.Exit(126) + } + resolvedGitPath, err := filepath.EvalSymlinks(gitPath) + if err != nil || resolvedGitPath != gitPath { + _, _ = fmt.Fprintln(os.Stderr, "trusted git path identity changed") + os.Exit(126) + } + info, err := os.Stat(gitPath) + if err != nil || !info.Mode().IsRegular() || info.Mode()&0o111 == 0 || info.Mode().Perm()&0o022 != 0 { + _, _ = fmt.Fprintln(os.Stderr, "trusted git path is not an immutable executable file") + os.Exit(126) + } + env := replaceEnv(os.Environ(), "PWD", boundPWD) + if err := unix.Exec(gitPath, append([]string{gitPath}, args...), env); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "exec trusted git: %v\n", err) + os.Exit(126) + } +} + +func openBareCacheHandle(path string) (*bareCacheHandle, error) { + identity, err := openDirectoryHandle(path) + if err != nil { + return nil, err + } + handle := &bareCacheHandle{identity: identity} + if err := handle.RecheckPath(); err != nil { + _ = handle.Close() + return nil, err + } + return handle, nil +} + +func verifyDirectoryFDPath(fd int, path string) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return fmt.Errorf("path must be absolute and canonical: %q", path) + } + var opened unix.Stat_t + if err := unix.Fstat(fd, &opened); err != nil { + return err + } + var current unix.Stat_t + if err := unix.Lstat(path, ¤t); err != nil { + return err + } + if opened.Dev != current.Dev || opened.Ino != current.Ino || opened.Mode != current.Mode || current.Mode&unix.S_IFMT != unix.S_IFDIR { + return fmt.Errorf("descriptor and path identify different directories: %s", path) + } + return nil +} + +func replaceEnv(env []string, key, value string) []string { + prefix := key + "=" + result := make([]string, 0, len(env)+1) + for _, entry := range env { + if !strings.HasPrefix(entry, prefix) { + result = append(result, entry) + } + } + return append(result, prefix+value) +} + +func openWorktreeHandle(path string) (*worktreeHandle, error) { + identity, err := openDirectoryHandle(path) + if err != nil { + return nil, err + } + handle := &worktreeHandle{worktree: identity} + if err := handle.worktree.recheck("worktree"); err != nil { + _ = handle.Close() + return nil, err + } + return handle, nil +} + +func openDirectoryHandle(path string) (pathHandle, error) { + if path == "" || !filepath.IsAbs(path) || filepath.Clean(path) != path { + return pathHandle{}, fmt.Errorf("directory path must be absolute and canonical: %q", path) + } + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return pathHandle{}, err + } + file := os.NewFile(uintptr(fd), path) + info, err := file.Stat() + if err != nil { + _ = file.Close() + return pathHandle{}, err + } + if !info.IsDir() { + _ = file.Close() + return pathHandle{}, fmt.Errorf("path is not a directory: %s", path) + } + return pathHandle{path: path, file: file, info: info}, nil +} + +func (g *gitBroker) worktreeCommand(ctx context.Context, handle *worktreeHandle, args ...string) (*exec.Cmd, error) { + if handle == nil || handle.worktree.file == nil { + return nil, fmt.Errorf("worktree identity is required") + } + launcher, err := worktreeLauncherExecutable() + if err != nil { + return nil, err + } + if err := handle.RecheckPaths(); err != nil { + return nil, err + } + cmdArgs := append([]string{ + internalGitWorktreeMode, + handle.worktree.path, + handle.gitDir.path, + handle.common.path, + g.executable, + }, args...) + cmd := exec.CommandContext(ctx, launcher, cmdArgs...) + cmd.Env = append([]string(nil), g.env...) + cmd.Env = replaceEnv(cmd.Env, "GIT_CONFIG", "/dev/null") + cmd.ExtraFiles = []*os.File{handle.worktree.file, handle.gitDir.file, handle.common.file} + cmd.WaitDelay = 5 * time.Second + return cmd, nil +} + +func (g *gitBroker) bareCacheCommand(ctx context.Context, handle *bareCacheHandle, args ...string) (*exec.Cmd, error) { + if handle == nil || handle.identity.file == nil { + return nil, fmt.Errorf("bare repository cache identity is required") + } + launcher, err := worktreeLauncherExecutable() + if err != nil { + return nil, err + } + if err := handle.RecheckPath(); err != nil { + return nil, err + } + cmdArgs := append([]string{internalGitBareCacheMode, handle.Path(), g.executable}, args...) + cmd := exec.CommandContext(ctx, launcher, cmdArgs...) + cmd.Env = append([]string(nil), g.env...) + cmd.ExtraFiles = []*os.File{handle.identity.file} + cmd.WaitDelay = 5 * time.Second + return cmd, nil +} + +func worktreeLauncherExecutable() (string, error) { + if runtime.GOOS == "linux" { + // /proc/self/exe is resolved by the kernel against the forked child, so + // the launcher cannot be redirected by replacing the daemon's path. + const procSelfExecutable = "/proc/self/exe" + info, err := os.Stat(procSelfExecutable) + if err != nil { + return "", fmt.Errorf("resolve kernel-bound worktree git launcher: %w", err) + } + if !info.Mode().IsRegular() || info.Mode()&0o111 == 0 { + return "", fmt.Errorf("kernel-bound worktree git launcher is not an executable regular file") + } + return procSelfExecutable, nil + } + + // Darwin has no procfs executable descriptor suitable for exec. Product + // task execution remains disabled there; operator-only repository work gets + // a path identity check immediately before exec construction. + self, err := os.Executable() + if err != nil { + return "", fmt.Errorf("resolve worktree git launcher: %w", err) + } + self, err = filepath.EvalSymlinks(self) + if err != nil { + return "", fmt.Errorf("resolve worktree git launcher identity: %w", err) + } + info, err := os.Lstat(self) + if err != nil || !info.Mode().IsRegular() || info.Mode()&0o111 == 0 { + return "", fmt.Errorf("worktree git launcher is not an executable regular file: %s", self) + } + return self, nil +} + +func readFileAt(dir *os.File, name string) ([]byte, error) { + fd, err := unix.Openat(int(dir.Fd()), name, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(fd), name) + defer file.Close() + return io.ReadAll(file) +} + +func validateTrustedExecutablePath(path string) error { + info, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("stat trusted executable %q: %w", path, err) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Uid != 0 { + return fmt.Errorf("git executable is not owned by root: %s", path) + } + for current := filepath.Dir(path); ; current = filepath.Dir(current) { + info, err := os.Stat(current) + if err != nil { + return fmt.Errorf("stat trusted executable parent %q: %w", current, err) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Uid != 0 || info.Mode().Perm()&0o022 != 0 { + return fmt.Errorf("git executable parent is not a root-owned system trust anchor: %s", current) + } + if current == filepath.Dir(current) { + break + } + } + return nil +} + +func validateRepositoryMetadataIdentity(identity *pathHandle) error { + if err := identity.recheck("repository metadata"); err != nil { + return err + } + return validateDaemonOwnedDirectoryPath(identity.path, "repository metadata directory") +} + +func validateDaemonOwnedDirectoryPath(path, kind string) error { + return validateDaemonOwnedDirectoryPathForUID(path, kind, os.Geteuid()) +} + +func validateDaemonOwnedDirectoryPathForUID(path, kind string, daemonUID int) error { + canonical, err := canonicalExistingDir(path) + if err != nil { + return fmt.Errorf("resolve %s: %w", kind, err) + } + for current := canonical; ; current = filepath.Dir(current) { + info, err := os.Lstat(current) + if err != nil { + return fmt.Errorf("stat %s %q: %w", kind, current, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("%s is not a canonical directory: %s", kind, current) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("read %s owner identity: %s", kind, current) + } + if current == canonical { + if int(stat.Uid) != daemonUID { + return fmt.Errorf("%s is not owned by daemon effective UID %d: %s", kind, daemonUID, current) + } + } else if stat.Uid != 0 && int(stat.Uid) != daemonUID { + return fmt.Errorf("%s parent is not owned by root or daemon effective UID %d: %s", kind, daemonUID, current) + } + writable := info.Mode().Perm()&0o022 != 0 + rootOwnedStickyParent := current != canonical && stat.Uid == 0 && info.Mode()&os.ModeSticky != 0 + if writable && !rootOwnedStickyParent { + return fmt.Errorf("%s is group/world writable: %s", kind, current) + } + if current == filepath.Dir(current) { + break + } + } + return nil +} + +func excludeFromGitDirHandle(handle *worktreeHandle, pattern string) error { + if err := handle.RecheckPaths(); err != nil { + return err + } + infoFD, err := ensureDirectoryAt(handle.gitDir.file, "info", 0o755) + if err != nil { + return fmt.Errorf("create info dir: %w", err) + } + defer unix.Close(infoFD) + existing, err := readFileAtFD(infoFD, "exclude") + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("read exclude file: %w", err) + } + if strings.Contains(string(existing), pattern) { + return handle.RecheckPaths() + } + fd, err := unix.Openat(infoFD, "exclude", unix.O_APPEND|unix.O_CREAT|unix.O_WRONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o644) + if err != nil { + return fmt.Errorf("open exclude file: %w", err) + } + file := os.NewFile(uintptr(fd), "exclude") + _, writeErr := fmt.Fprintf(file, "\n%s\n", pattern) + closeErr := file.Close() + if writeErr != nil { + return fmt.Errorf("write exclude pattern: %w", writeErr) + } + if closeErr != nil { + return closeErr + } + return handle.RecheckPaths() +} + +func installCoAuthoredByHookInCommonDirHandle(handle *worktreeHandle) error { + if err := handle.RecheckPaths(); err != nil { + return err + } + hooksFD, err := ensureDirectoryAt(handle.common.file, "hooks", 0o755) + if err != nil { + return fmt.Errorf("create hooks dir: %w", err) + } + defer unix.Close(hooksFD) + if err := writeFileAtFD(hooksFD, "prepare-commit-msg", []byte(prepareCommitMsgHook), 0o755); err != nil { + return fmt.Errorf("write prepare-commit-msg hook: %w", err) + } + return handle.RecheckPaths() +} + +func removeCoAuthoredByHookInCommonDirHandle(handle *worktreeHandle) error { + if err := handle.RecheckPaths(); err != nil { + return err + } + hooksFD, err := unix.Openat(int(handle.common.file.Fd()), "hooks", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + if err == unix.ENOENT { + return nil + } + return fmt.Errorf("open hooks dir: %w", err) + } + defer unix.Close(hooksFD) + contents, err := readFileAtFD(hooksFD, "prepare-commit-msg") + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read prepare-commit-msg hook: %w", err) + } + if isDaemonInstalledHook(contents) { + if err := unix.Unlinkat(hooksFD, "prepare-commit-msg", 0); err != nil && err != unix.ENOENT { + return fmt.Errorf("remove prepare-commit-msg hook: %w", err) + } + } + return handle.RecheckPaths() +} + +func ensureDirectoryAt(parent *os.File, name string, mode uint32) (int, error) { + fd, err := unix.Openat(int(parent.Fd()), name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err == nil { + return fd, nil + } + if err != unix.ENOENT { + return -1, err + } + if err := unix.Mkdirat(int(parent.Fd()), name, mode); err != nil && err != unix.EEXIST { + return -1, err + } + return unix.Openat(int(parent.Fd()), name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) +} + +func readFileAtFD(parentFD int, name string) ([]byte, error) { + fd, err := unix.Openat(parentFD, name, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return nil, &os.PathError{Op: "openat", Path: name, Err: err} + } + file := os.NewFile(uintptr(fd), name) + defer file.Close() + return io.ReadAll(file) +} + +func writeFileAtFD(parentFD int, name string, contents []byte, mode uint32) error { + fd, err := unix.Openat(parentFD, name, unix.O_WRONLY|unix.O_CREAT|unix.O_TRUNC|unix.O_CLOEXEC|unix.O_NOFOLLOW, mode) + if err != nil { + return err + } + file := os.NewFile(uintptr(fd), name) + if _, err := file.Write(contents); err != nil { + _ = file.Close() + return err + } + return file.Close() +} + +func writeFileAtFDAtomic(parentFD int, name string, contents []byte, mode uint32) error { + var random [8]byte + if _, err := rand.Read(random[:]); err != nil { + return err + } + temporary := ".multica-" + name + "-" + hex.EncodeToString(random[:]) + fd, err := unix.Openat(parentFD, temporary, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC|unix.O_NOFOLLOW, mode) + if err != nil { + return err + } + file := os.NewFile(uintptr(fd), temporary) + cleanup := true + defer func() { + _ = file.Close() + if cleanup { + _ = unix.Unlinkat(parentFD, temporary, 0) + } + }() + if _, err := file.Write(contents); err != nil { + return err + } + if err := file.Sync(); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + if err := unix.Renameat(parentFD, temporary, parentFD, name); err != nil { + return err + } + cleanup = false + return unix.Fsync(parentFD) +} diff --git a/server/internal/daemon/repocache/worktree_identity_windows.go b/server/internal/daemon/repocache/worktree_identity_windows.go new file mode 100644 index 00000000000..a917b9cf149 --- /dev/null +++ b/server/internal/daemon/repocache/worktree_identity_windows.go @@ -0,0 +1,111 @@ +//go:build windows + +package repocache + +import ( + "context" + "fmt" + "os" + "os/exec" + "time" +) + +type worktreePublication struct { + stagingPath string + finalPath string +} + +func newWorktreePublication(_ string, finalPath string) (*worktreePublication, error) { + return &worktreePublication{stagingPath: finalPath, finalPath: finalPath}, nil +} + +func (p *worktreePublication) StagingPath() string { return p.stagingPath } +func (p *worktreePublication) Prepare(*worktreeHandle) error { return nil } +func (p *worktreePublication) Publish(*worktreeHandle) error { return nil } +func (p *worktreePublication) Commit() {} +func (p *worktreePublication) Rollback(*worktreeHandle) error { return nil } +func (p *worktreePublication) Close() error { return nil } +func (p *worktreePublication) CleanupAllowed() bool { return true } + +func identityBoundWorktreeAccessSupported() bool { + return false +} + +func openWorktreeHandle(string) (*worktreeHandle, error) { + return nil, fmt.Errorf("identity-bound existing worktree updates are unsupported on windows") +} + +func openBareCacheHandle(path string) (*bareCacheHandle, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, err + } + if !info.IsDir() { + _ = file.Close() + return nil, fmt.Errorf("path is not a directory: %s", path) + } + return &bareCacheHandle{identity: pathHandle{path: path, file: file, info: info}}, nil +} + +func openDirectoryHandle(string) (pathHandle, error) { + return pathHandle{}, fmt.Errorf("identity-bound directory access is unsupported on windows") +} + +func (g *gitBroker) worktreeCommand(context.Context, *worktreeHandle, ...string) (*exec.Cmd, error) { + return nil, fmt.Errorf("identity-bound existing worktree updates are unsupported on windows") +} + +func (g *gitBroker) bareCacheCommand(ctx context.Context, handle *bareCacheHandle, args ...string) (*exec.Cmd, error) { + if err := handle.RecheckPath(); err != nil { + return nil, err + } + cmdArgs := append([]string{"-C", handle.Path()}, args...) + cmd := exec.CommandContext(ctx, g.executable, cmdArgs...) + cmd.Env = append([]string(nil), g.env...) + cmd.WaitDelay = 5 * time.Second + return cmd, nil +} + +func worktreeLauncherExecutable() (string, error) { + return "", fmt.Errorf("identity-bound existing worktree updates are unsupported on windows") +} + +func readFileAt(*os.File, string) ([]byte, error) { + return nil, fmt.Errorf("identity-bound metadata access is unsupported on windows") +} + +func validateTrustedExecutablePath(string) error { + return nil +} + +func validateRepositoryMetadataIdentity(*pathHandle) error { + return fmt.Errorf("identity-bound repository metadata is unsupported on windows") +} + +func validateDaemonOwnedDirectoryPath(string, string) error { + // Windows product task execution is not advertised. Preserve the existing + // operator repo-cache behavior while reused worktree access remains + // explicitly fail closed in openWorktreeHandle. + return nil +} + +func validateDaemonOwnedDirectoryPathForUID(string, string, int) error { + return nil +} + +func excludeFromGitDirHandle(*worktreeHandle, string) error { + return fmt.Errorf("identity-bound repository metadata is unsupported on windows") +} + +func installCoAuthoredByHookInCommonDirHandle(*worktreeHandle) error { + return fmt.Errorf("identity-bound repository metadata is unsupported on windows") +} + +func removeCoAuthoredByHookInCommonDirHandle(*worktreeHandle) error { + return fmt.Errorf("identity-bound repository metadata is unsupported on windows") +} diff --git a/server/internal/daemon/repocache/worktree_publication_darwin.go b/server/internal/daemon/repocache/worktree_publication_darwin.go new file mode 100644 index 00000000000..ef8e35782c4 --- /dev/null +++ b/server/internal/daemon/repocache/worktree_publication_darwin.go @@ -0,0 +1,9 @@ +//go:build darwin + +package repocache + +import "golang.org/x/sys/unix" + +func renameDirectoryNoReplace(oldFD int, oldName string, newFD int, newName string) error { + return unix.RenameatxNp(oldFD, oldName, newFD, newName, unix.RENAME_EXCL) +} diff --git a/server/internal/daemon/repocache/worktree_publication_linux.go b/server/internal/daemon/repocache/worktree_publication_linux.go new file mode 100644 index 00000000000..10328e9f0aa --- /dev/null +++ b/server/internal/daemon/repocache/worktree_publication_linux.go @@ -0,0 +1,9 @@ +//go:build linux + +package repocache + +import "golang.org/x/sys/unix" + +func renameDirectoryNoReplace(oldFD int, oldName string, newFD int, newName string) error { + return unix.Renameat2(oldFD, oldName, newFD, newName, unix.RENAME_NOREPLACE) +} diff --git a/server/internal/daemon/runtime_isolation_test.go b/server/internal/daemon/runtime_isolation_test.go index 837aa54f06e..8cc282c2cbd 100644 --- a/server/internal/daemon/runtime_isolation_test.go +++ b/server/internal/daemon/runtime_isolation_test.go @@ -163,6 +163,7 @@ func TestRunBatchPollerClaimsAcrossRuntimes(t *testing.T) { PollInterval: 20 * time.Millisecond, MaxConcurrentTasks: 4, }, slog.New(slog.NewTextHandler(noopWriter{}, nil))) + d.taskExecutionCapable = true d.workspaces["ws-1"] = &workspaceState{workspaceID: "ws-1", runtimeIDs: []string{"rt-1", "rt-2"}} d.cancelPollInterval = time.Hour // no server-side cancellation polling in this test @@ -199,6 +200,34 @@ func TestRunBatchPollerClaimsAcrossRuntimes(t *testing.T) { taskWG.Wait() } +func TestRunBatchPollerDoesNotClaimWithoutTaskExecutionCapability(t *testing.T) { + t.Parallel() + + var requests atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"tasks":[]}`)) + })) + defer srv.Close() + + d := New(Config{ + ServerBaseURL: srv.URL, + HeartbeatInterval: time.Hour, + PollInterval: 10 * time.Millisecond, + MaxConcurrentTasks: 1, + }, slog.New(slog.NewTextHandler(noopWriter{}, nil))) + d.workspaces["ws-1"] = &workspaceState{workspaceID: "ws-1", runtimeIDs: []string{"rt-1"}} + + ctx, cancel := context.WithTimeout(context.Background(), 75*time.Millisecond) + defer cancel() + var taskWG sync.WaitGroup + d.runBatchPoller(ctx, ctx, newTaskSlotSemaphore(1), make(chan struct{}, 1), &taskWG) + if got := requests.Load(); got != 0 { + t.Fatalf("incapable daemon emitted %d claim requests, want 0", got) + } +} + // TestRunBatchPollerWakesAfterTaskExit guards the gap where a queued task is // temporarily unclaimable (for example, because the same agent/issue task is // still running), the batch claim returns empty, and the poller goes to sleep @@ -257,6 +286,7 @@ func testRunBatchPollerTaskExitWakeup(t *testing.T, maxConcurrent int, releaseDe PollInterval: time.Hour, MaxConcurrentTasks: maxConcurrent, }, slog.New(slog.NewTextHandler(noopWriter{}, nil))) + d.taskExecutionCapable = true d.workspaces["ws-1"] = &workspaceState{workspaceID: "ws-1", runtimeIDs: []string{"rt-1"}} d.runtimeIndex["rt-1"] = Runtime{ID: "rt-1"} d.cancelPollInterval = time.Hour @@ -356,6 +386,7 @@ func TestRunBatchPollerSkipsClaimWhenAtCapacity(t *testing.T) { PollInterval: 20 * time.Millisecond, MaxConcurrentTasks: 1, }, slog.New(slog.NewTextHandler(noopWriter{}, nil))) + d.taskExecutionCapable = true d.workspaces["ws-1"] = &workspaceState{workspaceID: "ws-1", runtimeIDs: []string{"rt-1"}} sem := newTaskSlotSemaphore(d.cfg.MaxConcurrentTasks) @@ -395,6 +426,7 @@ func TestPollLoopBatchShutdown(t *testing.T) { PollInterval: 20 * time.Millisecond, MaxConcurrentTasks: 1, }, slog.New(slog.NewTextHandler(noopWriter{}, nil))) + d.taskExecutionCapable = true d.workspaces["ws-1"] = &workspaceState{workspaceID: "ws-1", runtimeIDs: []string{"rt-1"}} d.cancelPollInterval = time.Hour d.runner = taskRunnerFunc(func(ctx context.Context, task Task, provider string, slot int, log *slog.Logger) (TaskResult, error) { diff --git a/server/internal/daemon/runtime_profile_test.go b/server/internal/daemon/runtime_profile_test.go index d09d6b339e1..67a92016ab6 100644 --- a/server/internal/daemon/runtime_profile_test.go +++ b/server/internal/daemon/runtime_profile_test.go @@ -8,6 +8,8 @@ import ( "strconv" "strings" "testing" + + "github.com/multica-ai/multica/server/pkg/protocol" ) // stubLookPath swaps the package-level lookPath indirection used by @@ -210,6 +212,88 @@ func TestRegisterRuntimes_AppendsProfileRuntime(t *testing.T) { } } +func assertTaskExecutionCapability(t *testing.T, runtime map[string]any, want bool) { + t.Helper() + capabilities, ok := runtime["capabilities"] + if !want { + if ok { + t.Fatalf("capabilities = %#v, want field omitted", capabilities) + } + return + } + if !ok { + t.Fatal("capabilities field omitted, want task execution capability") + } + values, ok := capabilities.([]any) + if !ok || len(values) != 1 || values[0] != protocol.RuntimeCapabilityTaskExecution { + t.Fatalf("capabilities = %#v, want [%q]", capabilities, protocol.RuntimeCapabilityTaskExecution) + } +} + +func TestRegisterRuntimes_BuiltInCapabilityMatchesProbe(t *testing.T) { + for _, tc := range []struct { + name string + capable bool + }{ + {name: "capable", capable: true}, + {name: "incapable", capable: false}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Cleanup(stubAgentVersion(t)) + stubLookPath(t, map[string]string{}) + + fx := newProfileRegisterFixture(t, nil, http.StatusNotFound) + d := fx.daemon + d.cfg.Agents = map[string]AgentEntry{"codex": {Path: "/usr/bin/true"}} + d.taskExecutionCapable = tc.capable + + if _, _, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1"); err != nil { + t.Fatalf("registerRuntimesForWorkspace: %v", err) + } + if len(fx.sentRuntimes) != 1 { + t.Fatalf("sent runtimes = %d, want 1: %+v", len(fx.sentRuntimes), fx.sentRuntimes) + } + assertTaskExecutionCapability(t, fx.sentRuntimes[0], tc.capable) + }) + } +} + +func TestRegisterRuntimes_CustomProfileCapabilityMatchesProbe(t *testing.T) { + for _, tc := range []struct { + name string + capable bool + }{ + {name: "capable", capable: true}, + {name: "incapable", capable: false}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Cleanup(stubAgentVersion(t)) + stubLookPath(t, map[string]string{"company-codex": "/opt/bin/company-codex"}) + + profiles := []RuntimeProfile{{ + ID: "prof-1", + WorkspaceID: "ws-1", + DisplayName: "Company Codex", + ProtocolFamily: "codex", + CommandName: "company-codex", + Enabled: true, + }} + fx := newProfileRegisterFixture(t, profiles, http.StatusOK) + d := fx.daemon + d.cfg.Agents = map[string]AgentEntry{} + d.taskExecutionCapable = tc.capable + + if _, _, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1"); err != nil { + t.Fatalf("registerRuntimesForWorkspace: %v", err) + } + if len(fx.sentRuntimes) != 1 { + t.Fatalf("sent runtimes = %d, want 1: %+v", len(fx.sentRuntimes), fx.sentRuntimes) + } + assertTaskExecutionCapability(t, fx.sentRuntimes[0], tc.capable) + }) + } +} + // TestRegisterRuntimes_ReportsProfileNotOnPath verifies a profile whose command // is missing on this host is reported to the server as a failed profile so the // UI can show an actionable registration error. diff --git a/server/internal/daemon/task_isolation.go b/server/internal/daemon/task_isolation.go new file mode 100644 index 00000000000..16b7ac68f91 --- /dev/null +++ b/server/internal/daemon/task_isolation.go @@ -0,0 +1,303 @@ +package daemon + +import ( + "bufio" + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/multica-ai/multica/server/internal/taskauth" + "github.com/multica-ai/multica/server/pkg/agent" +) + +const maxTaskShebangBytes = 4096 + +func buildTaskIsolationPolicy(params taskIsolationParams) (agent.TaskIsolationPolicy, string, error) { + if params.Environment == nil { + return agent.TaskIsolationPolicy{}, "", fmt.Errorf("task environment is required") + } + if params.LookupPath == nil { + params.LookupPath = exec.LookPath + } + + envRoot, err := existingTaskDirectory("environment root", params.Environment.RootDir) + if err != nil { + return agent.TaskIsolationPolicy{}, "", err + } + workDir, err := existingTaskDirectory("work directory", params.Environment.WorkDir) + if err != nil { + return agent.TaskIsolationPolicy{}, "", err + } + taskTempDir, err := existingTaskDirectory("task temp directory", params.TaskTempDir) + if err != nil { + return agent.TaskIsolationPolicy{}, "", err + } + taskAuthority, err := existingTaskRegularFile("task authority", params.TaskAuthority) + if err != nil { + return agent.TaskIsolationPolicy{}, "", err + } + providerExecutable, err := existingTaskExecutable("provider executable", params.Executable) + if err != nil { + return agent.TaskIsolationPolicy{}, "", err + } + selfExecutable, err := existingTaskExecutable("Multica executable", params.SelfExecutable) + if err != nil { + return agent.TaskIsolationPolicy{}, "", err + } + + writable := []string{envRoot, taskTempDir} + if params.Environment.LocalDirectory && !taskPathWithin(workDir, envRoot) { + writable = append(writable, workDir) + } + + readOnly := []string{ + taskProviderRoot(providerExecutable), + filepath.Dir(selfExecutable), + } + for _, repo := range params.Repos { + barePath, pathErr := existingTaskDirectory("task repository", repo.BarePath) + if pathErr != nil { + return agent.TaskIsolationPolicy{}, "", fmt.Errorf("repository %q: %w", repo.URL, pathErr) + } + readOnly = append(readOnly, barePath) + } + + pathDirs := make([]string, 0, 4) + shebang, err := readTaskShebang(providerExecutable, params.LookupPath) + if err != nil { + return agent.TaskIsolationPolicy{}, "", err + } + if shebang != nil { + readOnly = append(readOnly, taskRuntimeRoot(shebang.launcher)) + if shebang.runtime != "" { + readOnly = append(readOnly, taskRuntimeRoot(shebang.runtime)) + pathDirs = append(pathDirs, filepath.Dir(shebang.runtime)) + } else { + pathDirs = append(pathDirs, filepath.Dir(shebang.launcher)) + } + } + pathDirs = append(pathDirs, filepath.Dir(providerExecutable), filepath.Dir(selfExecutable)) + + forbidden, err := existingOwnerConfigRoots(params.OwnerHome) + if err != nil { + return agent.TaskIsolationPolicy{}, "", err + } + if params.HermesSourceHome != "" { + if _, statErr := os.Lstat(params.HermesSourceHome); statErr == nil { + hermesSource, sourceErr := existingTaskDirectory("Hermes source home", params.HermesSourceHome) + if sourceErr != nil { + return agent.TaskIsolationPolicy{}, "", sourceErr + } + forbidden = append(forbidden, hermesSource) + } else if !os.IsNotExist(statErr) { + return agent.TaskIsolationPolicy{}, "", fmt.Errorf("inspect Hermes source home %q: %w", params.HermesSourceHome, statErr) + } + } + policy := agent.TaskIsolationPolicy{ + WritableRoots: uniqueTaskPaths(writable), + ReadOnlyRoots: uniqueTaskPaths(readOnly), + ReadOnlyFiles: []agent.ReadOnlyFileMount{{ + Source: taskAuthority, + Target: taskauth.FixedPath, + }}, + SystemRoots: existingTaskSystemRoots(), + ForbiddenRoots: uniqueTaskPaths(forbidden), + Network: agent.NetworkAccessPublicAndLoopback, + } + validated, err := policy.Validated() + if err != nil { + return agent.TaskIsolationPolicy{}, "", fmt.Errorf("validate task isolation authority: %w", err) + } + return validated, strings.Join(uniqueTaskPaths(pathDirs), string(os.PathListSeparator)), nil +} + +type taskShebang struct { + launcher string + runtime string +} + +func readTaskShebang(executable string, lookupPath func(string) (string, error)) (*taskShebang, error) { + file, err := os.Open(executable) + if err != nil { + return nil, fmt.Errorf("open provider executable %q: %w", executable, err) + } + defer file.Close() + + line, err := bufio.NewReaderSize(file, maxTaskShebangBytes).ReadBytes('\n') + if err != nil && len(line) == 0 { + return nil, nil + } + if len(line) > maxTaskShebangBytes { + return nil, fmt.Errorf("provider executable %q has an oversized shebang", executable) + } + line = bytes.TrimSpace(line) + if !bytes.HasPrefix(line, []byte("#!")) { + return nil, nil + } + fields := strings.Fields(strings.TrimSpace(string(line[2:]))) + if len(fields) == 0 || !filepath.IsAbs(fields[0]) { + return nil, fmt.Errorf("provider executable %q has a non-absolute shebang interpreter", executable) + } + launcher, err := existingTaskExecutable("shebang interpreter", fields[0]) + if err != nil { + return nil, err + } + result := &taskShebang{launcher: launcher} + if filepath.Base(launcher) != "env" { + return result, nil + } + if len(fields) != 2 || strings.HasPrefix(fields[1], "-") { + return nil, fmt.Errorf("provider executable %q has unsupported env shebang", executable) + } + runtimePath, err := lookupPath(fields[1]) + if err != nil { + return nil, fmt.Errorf("resolve shebang runtime %q: %w", fields[1], err) + } + result.runtime, err = existingTaskExecutable("shebang runtime", runtimePath) + if err != nil { + return nil, err + } + return result, nil +} + +func existingTaskDirectory(kind, path string) (string, error) { + resolved, err := existingTaskPath(kind, path) + if err != nil { + return "", err + } + info, err := os.Stat(resolved) + if err != nil { + return "", fmt.Errorf("stat %s %q: %w", kind, resolved, err) + } + if !info.IsDir() { + return "", fmt.Errorf("%s %q is not a directory", kind, resolved) + } + return resolved, nil +} + +func existingTaskExecutable(kind, path string) (string, error) { + resolved, info, err := existingTaskRegularFileInfo(kind, path) + if err != nil { + return "", err + } + if info.Mode().Perm()&0o111 == 0 { + return "", fmt.Errorf("%s %q is not executable", kind, resolved) + } + return resolved, nil +} + +func existingTaskRegularFile(kind, path string) (string, error) { + resolved, _, err := existingTaskRegularFileInfo(kind, path) + return resolved, err +} + +func existingTaskRegularFileInfo(kind, path string) (string, os.FileInfo, error) { + resolved, err := existingTaskPath(kind, path) + if err != nil { + return "", nil, err + } + info, err := os.Stat(resolved) + if err != nil { + return "", nil, fmt.Errorf("stat %s %q: %w", kind, resolved, err) + } + if !info.Mode().IsRegular() { + return "", nil, fmt.Errorf("%s %q is not a regular file", kind, resolved) + } + return resolved, info, nil +} + +func existingTaskPath(kind, path string) (string, error) { + if path == "" || !filepath.IsAbs(path) || filepath.Clean(path) != path { + return "", fmt.Errorf("%s %q must be an absolute canonical path", kind, path) + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return "", fmt.Errorf("resolve %s %q: %w", kind, path, err) + } + return resolved, nil +} + +func taskProviderRoot(executable string) string { + for current := filepath.Dir(executable); ; current = filepath.Dir(current) { + parent := filepath.Dir(current) + if filepath.Base(parent) == "node_modules" { + return current + } + grandparent := filepath.Dir(parent) + if strings.HasPrefix(filepath.Base(parent), "@") && filepath.Base(grandparent) == "node_modules" { + return current + } + if parent == current { + return filepath.Dir(executable) + } + } +} + +func taskRuntimeRoot(executable string) string { + return filepath.Dir(executable) +} + +func existingOwnerConfigRoots(ownerHome string) ([]string, error) { + if ownerHome == "" || !filepath.IsAbs(ownerHome) || filepath.Clean(ownerHome) != ownerHome { + return nil, fmt.Errorf("daemon owner home %q must be an absolute canonical path", ownerHome) + } + var roots []string + for _, relative := range []string{".multica", ".codex", filepath.Join(".config", "opencode"), ".openclaw", ".cursor"} { + path := filepath.Join(ownerHome, relative) + _, err := os.Lstat(path) + if os.IsNotExist(err) { + continue + } + if err != nil { + return nil, fmt.Errorf("inspect owner config root %q: %w", path, err) + } + resolved, err := existingTaskDirectory("owner config root", path) + if err != nil { + return nil, err + } + roots = append(roots, resolved) + } + return uniqueTaskPaths(roots), nil +} + +func existingTaskSystemRoots() []string { + var candidates []string + switch runtime.GOOS { + case "darwin": + candidates = []string{"/System/Library", "/usr/lib", "/private/etc/ssl"} + case "linux": + candidates = []string{"/lib", "/lib64", "/usr/lib", "/usr/lib64", "/etc/ssl", "/etc/pki", "/etc/ca-certificates"} + } + var roots []string + for _, candidate := range candidates { + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + roots = append(roots, candidate) + } + } + return uniqueTaskPaths(roots) +} + +func uniqueTaskPaths(paths []string) []string { + seen := make(map[string]struct{}, len(paths)) + result := make([]string, 0, len(paths)) + for _, path := range paths { + if path == "" { + continue + } + if _, ok := seen[path]; ok { + continue + } + seen[path] = struct{}{} + result = append(result, path) + } + return result +} + +func taskPathWithin(path, root string) bool { + relative, err := filepath.Rel(root, path) + return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) +} diff --git a/server/internal/daemon/workdir_race_test.go b/server/internal/daemon/workdir_race_test.go index 3e07b56fd5c..c704e693d6c 100644 --- a/server/internal/daemon/workdir_race_test.go +++ b/server/internal/daemon/workdir_race_test.go @@ -16,6 +16,7 @@ import ( "time" "github.com/multica-ai/multica/server/internal/daemon/execenv" + "github.com/multica-ai/multica/server/internal/taskauth" ) // TestHandleTask_DoesNotCallStartTaskItself is the regression guard for @@ -122,11 +123,13 @@ func TestRunTask_StartTaskCalledAfterWorkdirOnDisk(t *testing.T) { // regression guard is the order of /start vs. os.MkdirAll(envRoot). missingBin := filepath.Join(t.TempDir(), "definitely-not-claude") d := &Daemon{ - client: NewClient(srv.URL), - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - workspaces: make(map[string]*workspaceState), - runtimeIndex: map[string]Runtime{"rt-1": {ID: "rt-1", Provider: "claude"}}, - activeEnvRoots: make(map[string]int), + taskExecutionCapable: true, + client: NewClient(srv.URL), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + workspaces: make(map[string]*workspaceState), + runtimeIndex: map[string]Runtime{"rt-1": {ID: "rt-1", Provider: "claude"}}, + activeEnvRoots: make(map[string]int), + taskLauncher: directTaskLauncherFactory, cfg: Config{ WorkspacesRoot: workspacesRoot, Agents: map[string]AgentEntry{ @@ -164,8 +167,9 @@ func TestRunTask_InjectsPrivateTaskTempDir(t *testing.T) { } workspacesRoot := filepath.Join(t.TempDir(), strings.Repeat("long-workspaces-root-", 3)) - workspaceID := "ws-private-temp" - taskID := "task-private-temp-with-long-id-that-would-overflow-socket-paths" + workspaceID := "50000000-0000-0000-0000-000000000001" + taskID := "50000000-0000-0000-0000-000000000002" + agentID := "50000000-0000-0000-0000-000000000003" envRoot := execenv.PredictRootDir(workspacesRoot, workspaceID, taskID) captureFile := filepath.Join(t.TempDir(), "agent-env.txt") @@ -176,7 +180,14 @@ if [ -d "$TMPDIR" ]; then else tmpdir_exists=no fi -printf 'TMPDIR=%s\nTMP=%s\nTEMP=%s\nTMPDIR_EXISTS=%s\n' "$TMPDIR" "$TMP" "$TEMP" "$tmpdir_exists" > "$CAPTURE_FILE" +authority="$TMPDIR/task-authority.json" +if [ -e "$authority" ]; then + authority_source_exposed=yes +else + authority_source_exposed=no +fi +printf 'TMPDIR=%s\nTMP=%s\nTEMP=%s\nTMPDIR_EXISTS=%s\nAUTHORITY_SOURCE_EXPOSED=%s\n' \ + "$TMPDIR" "$TMP" "$TEMP" "$tmpdir_exists" "$authority_source_exposed" > "$CAPTURE_FILE" IFS= read -r _ printf '%s\n' '{"type":"system","session_id":"sess-private-temp"}' printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"session_id":"sess-private-temp","result":"done"}' @@ -194,14 +205,16 @@ printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"session_id t.Cleanup(srv.Close) d := &Daemon{ - client: NewClient(srv.URL), - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - workspaces: make(map[string]*workspaceState), - runtimeIndex: map[string]Runtime{"rt-1": {ID: "rt-1", Provider: "claude"}}, - activeEnvRoots: make(map[string]int), + taskExecutionCapable: true, + client: NewClient(srv.URL), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + workspaces: make(map[string]*workspaceState), + runtimeIndex: map[string]Runtime{"rt-1": {ID: "rt-1", Provider: "claude"}}, + activeEnvRoots: make(map[string]int), + taskLauncher: directTaskLauncherFactory, cfg: Config{ WorkspacesRoot: workspacesRoot, - AgentTimeout: 5 * time.Second, + AgentTimeout: 15 * time.Second, ServerBaseURL: srv.URL, Agents: map[string]AgentEntry{ "claude": {Path: fakeBin, Model: ""}, @@ -212,11 +225,12 @@ printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"session_id task := Task{ ID: taskID, WorkspaceID: workspaceID, + AgentID: agentID, RuntimeID: "rt-1", IssueID: "issue-private-temp", AuthToken: "mat_private_temp", Agent: &AgentData{ - ID: "agent-private-temp", + ID: agentID, Name: "test-agent", CustomEnv: map[string]string{ "CAPTURE_FILE": captureFile, @@ -259,6 +273,9 @@ printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"session_id if got["TMPDIR_EXISTS"] != "yes" { t.Fatalf("fake agent saw TMPDIR_EXISTS=%q, want yes", got["TMPDIR_EXISTS"]) } + if got["AUTHORITY_SOURCE_EXPOSED"] != "no" { + t.Fatalf("task authority source alias was exposed at %q", filepath.Join(got["TMPDIR"], taskauth.FileName)) + } taskTempDir := got["TMPDIR"] if strings.HasPrefix(taskTempDir, envRoot) { t.Fatalf("task temp dir %q must not live under long env root %q", taskTempDir, envRoot) @@ -314,11 +331,13 @@ func TestRunTask_ExtendsPrepareLeaseDuringStartTask(t *testing.T) { missingBin := filepath.Join(t.TempDir(), "definitely-not-claude") d := &Daemon{ - client: NewClient(srv.URL), - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - workspaces: make(map[string]*workspaceState), - runtimeIndex: map[string]Runtime{"rt-1": {ID: "rt-1", Provider: "claude"}}, - activeEnvRoots: make(map[string]int), + taskExecutionCapable: true, + client: NewClient(srv.URL), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + workspaces: make(map[string]*workspaceState), + runtimeIndex: map[string]Runtime{"rt-1": {ID: "rt-1", Provider: "claude"}}, + activeEnvRoots: make(map[string]int), + taskLauncher: directTaskLauncherFactory, cfg: Config{ WorkspacesRoot: workspacesRoot, Agents: map[string]AgentEntry{ diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index 40b91211bef..ca3b1621c17 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -188,6 +188,9 @@ type DaemonRegisterRequest struct { // Type carries the protocol family for both built-in and custom rows // so task routing (agent.New) is unchanged. ProfileID string `json:"profile_id"` + // Capabilities are persisted into this runtime row's metadata. The + // server recognizes only exact, allow-listed runtime capabilities. + Capabilities []string `json:"capabilities,omitempty"` } `json:"runtimes"` FailedProfiles []struct { ProfileID string `json:"profile_id"` @@ -333,6 +336,29 @@ func sharedDaemonCustomName(names []pgtype.Text) (string, bool) { return first, true } +func normalizeRuntimeCapabilities(values []string) []string { + if len(values) == 1 && values[0] == protocol.RuntimeCapabilityTaskExecution { + return []string{protocol.RuntimeCapabilityTaskExecution} + } + return []string{} +} + +func runtimeCanExecuteTasks(rt db.AgentRuntime) bool { + var metadata map[string]json.RawMessage + if len(rt.Metadata) == 0 || json.Unmarshal(rt.Metadata, &metadata) != nil || metadata == nil { + return false + } + raw, ok := metadata["capabilities"] + if !ok { + return false + } + var capabilities []string + if json.Unmarshal(raw, &capabilities) != nil { + return false + } + return len(capabilities) == 1 && capabilities[0] == protocol.RuntimeCapabilityTaskExecution +} + func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) { var req DaemonRegisterRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -411,9 +437,10 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) { status = "offline" } metadata, _ := json.Marshal(map[string]any{ - "version": runtime.Version, - "cli_version": req.CLIVersion, - "launched_by": req.LaunchedBy, + "version": runtime.Version, + "cli_version": req.CLIVersion, + "launched_by": req.LaunchedBy, + "capabilities": normalizeRuntimeCapabilities(runtime.Capabilities), }) var registered db.AgentRuntime @@ -1472,6 +1499,9 @@ func (h *Handler) ClaimTasksByRuntime(w http.ResponseWriter, r *http.Request) { if rt.DaemonID.Valid && rt.DaemonID.String != req.DaemonID { continue } + if !runtimeCanExecuteTasks(rt) { + continue + } runtimeByID[uuidToString(rt.ID)] = rt authorized = append(authorized, rt.ID) } @@ -2423,6 +2453,11 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) { } runtimeWorkspaceID := uuidToString(runtime.WorkspaceID) authMs = time.Since(start).Milliseconds() + if !runtimeCanExecuteTasks(runtime) { + payloadBytes, _ = writeMeasuredJSON(w, http.StatusOK, map[string]any{"task": nil}) + outcome = "runtime_incapable" + return + } claimStart := time.Now() task, err := h.TaskService.ClaimTaskForRuntime(r.Context(), parseUUID(runtimeID)) diff --git a/server/internal/handler/daemon_batch_claim_test.go b/server/internal/handler/daemon_batch_claim_test.go index 80f0aae5f51..31f10eda764 100644 --- a/server/internal/handler/daemon_batch_claim_test.go +++ b/server/internal/handler/daemon_batch_claim_test.go @@ -87,6 +87,79 @@ func TestClaimTasksByRuntime_RoutesAcrossRuntimesAndMintsTokens(t *testing.T) { } } +func TestClaimTasksByRuntimeSkipsIncapableRuntimeWithoutChangingTask(t *testing.T) { + if testHandler == nil || testPool == nil { + t.Skip("database not available") + } + ctx := context.Background() + var runtimeID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO agent_runtime (workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, visibility, owner_id) + VALUES ($1, NULL, 'Incapable runtime', 'cloud', 'handler_test_runtime', 'online', 'x', '{}'::jsonb, now(), 'private', $2) + RETURNING id`, testWorkspaceID, testUserID).Scan(&runtimeID); err != nil { + t.Fatalf("create incapable runtime: %v", err) + } + t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_runtime WHERE id = $1`, runtimeID) }) + agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Incapable runtime agent") + taskID := seedQueuedIssueTask(t, ctx, agentID, runtimeID, issueID) + + w := postBatchClaim(t, testWorkspaceID, []string{runtimeID}, 1) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp batchClaimResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Tasks) != 0 { + t.Fatalf("incapable runtime claimed %d tasks, want 0", len(resp.Tasks)) + } + var status string + var dispatchedAt, startedAt any + if err := testPool.QueryRow(ctx, `SELECT status, dispatched_at, started_at FROM agent_task_queue WHERE id = $1`, taskID).Scan(&status, &dispatchedAt, &startedAt); err != nil { + t.Fatalf("read task state: %v", err) + } + if status != "queued" || dispatchedAt != nil || startedAt != nil { + t.Fatalf("incapable claim changed task: status=%s dispatched_at=%v started_at=%v", status, dispatchedAt, startedAt) + } +} + +func TestClaimTasksByRuntimeClaimsOnlyCapableRuntime(t *testing.T) { + if testHandler == nil || testPool == nil { + t.Skip("database not available") + } + ctx := context.Background() + capableID := createClaimReclaimRuntime(t, ctx, "Mixed capable runtime") + var incapableID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO agent_runtime (workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, visibility, owner_id) + VALUES ($1, NULL, 'Mixed incapable runtime', 'cloud', 'handler_test_runtime', 'online', 'x', '{}'::jsonb, now(), 'private', $2) + RETURNING id`, testWorkspaceID, testUserID).Scan(&incapableID); err != nil { + t.Fatalf("create incapable runtime: %v", err) + } + t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_runtime WHERE id = $1`, incapableID) }) + capableAgent, capableIssue := createClaimReclaimAgentAndIssue(t, ctx, capableID, "Mixed capable agent") + incapableAgent, incapableIssue := createClaimReclaimAgentAndIssue(t, ctx, incapableID, "Mixed incapable agent") + seedQueuedIssueTask(t, ctx, capableAgent, capableID, capableIssue) + incapableTask := seedQueuedIssueTask(t, ctx, incapableAgent, incapableID, incapableIssue) + + w := postBatchClaim(t, testWorkspaceID, []string{incapableID, capableID}, 2) + var resp batchClaimResponse + if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &resp) != nil { + t.Fatalf("mixed claim failed: code=%d body=%s", w.Code, w.Body.String()) + } + if len(resp.Tasks) != 1 || resp.Tasks[0].RuntimeID != capableID { + t.Fatalf("mixed claim tasks = %+v, want only capable runtime %s", resp.Tasks, capableID) + } + var status string + if err := testPool.QueryRow(ctx, `SELECT status FROM agent_task_queue WHERE id = $1`, incapableTask).Scan(&status); err != nil { + t.Fatalf("read incapable task: %v", err) + } + if status != "queued" { + t.Fatalf("incapable task status = %s, want queued", status) + } +} + // TestClaimTasksByRuntime_SkipsCrossWorkspaceRuntime is the security-critical // case: a daemon token scoped to workspace A must not claim a task routed to a // runtime in workspace B, even when B's runtime_id is included in the request. @@ -164,7 +237,7 @@ func TestClaimTasksByRuntime_CancelsTaskWhenRuntimeOwnerMissing(t *testing.T) { var rtNull string if err := testPool.QueryRow(ctx, ` INSERT INTO agent_runtime (workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, visibility, owner_id) - VALUES ($1, NULL, 'Ownerless RT', 'cloud', 'handler_test_runtime', 'online', 'x', '{}'::jsonb, now(), 'private', NULL) + VALUES ($1, NULL, 'Ownerless RT', 'cloud', 'handler_test_runtime', 'online', 'x', '{"capabilities":["linux-bubblewrap-fd-v1"]}'::jsonb, now(), 'private', NULL) RETURNING id`, testWorkspaceID).Scan(&rtNull); err != nil { t.Fatalf("ownerless runtime: %v", err) } diff --git a/server/internal/handler/daemon_test.go b/server/internal/handler/daemon_test.go index 3fd132c47cb..db35654441e 100644 --- a/server/internal/handler/daemon_test.go +++ b/server/internal/handler/daemon_test.go @@ -181,7 +181,8 @@ func createClaimReclaimRuntime(t *testing.T, ctx context.Context, name string) s workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, visibility, owner_id ) - VALUES ($1, NULL, $2, 'cloud', 'handler_test_runtime', 'online', 'claim reclaim fixture', '{}'::jsonb, now(), 'private', $3) + VALUES ($1, NULL, $2, 'cloud', 'handler_test_runtime', 'online', 'claim reclaim fixture', + '{"capabilities":["linux-bubblewrap-fd-v1"]}'::jsonb, now(), 'private', $3) RETURNING id `, testWorkspaceID, name, testUserID).Scan(&runtimeID); err != nil { t.Fatalf("setup: create runtime: %v", err) @@ -191,6 +192,55 @@ func createClaimReclaimRuntime(t *testing.T, ctx context.Context, name string) s return runtimeID } +func TestRuntimeCanExecuteTasksRequiresExactPersistedCapability(t *testing.T) { + tests := []struct { + name string + metadata string + want bool + }{ + {name: "exact", metadata: `{"capabilities":["linux-bubblewrap-fd-v1"]}`, want: true}, + {name: "missing", metadata: `{}`}, + {name: "null", metadata: `null`}, + {name: "array metadata", metadata: `[]`}, + {name: "invalid", metadata: `{`}, + {name: "non-array", metadata: `{"capabilities":"linux-bubblewrap-fd-v1"}`}, + {name: "mixed type", metadata: `{"capabilities":["linux-bubblewrap-fd-v1",1]}`}, + {name: "unknown", metadata: `{"capabilities":["unknown"]}`}, + {name: "case mismatch", metadata: `{"capabilities":["Linux-bubblewrap-fd-v1"]}`}, + {name: "mixed capability", metadata: `{"capabilities":["linux-bubblewrap-fd-v1","unknown"]}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rt := db.AgentRuntime{Metadata: []byte(tt.metadata)} + if got := runtimeCanExecuteTasks(rt); got != tt.want { + t.Fatalf("runtimeCanExecuteTasks(%s) = %v, want %v", tt.metadata, got, tt.want) + } + }) + } +} + +func TestNormalizeRuntimeCapabilitiesRequiresExactSingleton(t *testing.T) { + tests := []struct { + name string + values []string + want int + }{ + {name: "exact", values: []string{"linux-bubblewrap-fd-v1"}, want: 1}, + {name: "missing"}, + {name: "unknown", values: []string{"unknown"}}, + {name: "case mismatch", values: []string{"Linux-bubblewrap-fd-v1"}}, + {name: "mixed", values: []string{"linux-bubblewrap-fd-v1", "unknown"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeRuntimeCapabilities(tt.values) + if len(got) != tt.want { + t.Fatalf("normalizeRuntimeCapabilities(%v) = %v, want length %d", tt.values, got, tt.want) + } + }) + } +} + func createClaimReclaimAgentAndIssue(t *testing.T, ctx context.Context, runtimeID, name string) (string, string) { t.Helper() @@ -862,7 +912,8 @@ func TestClaimTaskByRuntime_MissingRuntimeOwnerCancelsAndRejects(t *testing.T) { workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, visibility ) - VALUES ($1, NULL, 'Missing owner claim runtime', 'cloud', 'handler_test_runtime', 'online', 'claim missing owner fixture', '{}'::jsonb, now(), 'private') + VALUES ($1, NULL, 'Missing owner claim runtime', 'cloud', 'handler_test_runtime', 'online', 'claim missing owner fixture', + '{"capabilities":["linux-bubblewrap-fd-v1"]}'::jsonb, now(), 'private') RETURNING id `, testWorkspaceID).Scan(&runtimeID); err != nil { t.Fatalf("setup: create runtime: %v", err) @@ -893,6 +944,55 @@ func TestClaimTaskByRuntime_MissingRuntimeOwnerCancelsAndRejects(t *testing.T) { } } +func TestClaimTaskByRuntimeHeaderCannotGrantTaskExecutionCapability(t *testing.T) { + if testHandler == nil || testPool == nil { + t.Skip("database not available") + } + ctx := context.Background() + var runtimeID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO agent_runtime ( + workspace_id, daemon_id, name, runtime_mode, provider, + status, device_info, metadata, last_seen_at, visibility, owner_id + ) + VALUES ($1, NULL, 'Header cannot grant runtime', 'cloud', 'handler_test_runtime', + 'online', 'header capability fixture', '{}'::jsonb, now(), 'private', $2) + RETURNING id + `, testWorkspaceID, testUserID).Scan(&runtimeID); err != nil { + t.Fatalf("setup: create runtime: %v", err) + } + t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_runtime WHERE id = $1`, runtimeID) }) + agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Header cannot grant agent") + taskID := seedQueuedIssueTask(t, ctx, agentID, runtimeID, issueID) + + w := httptest.NewRecorder() + req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil, + testWorkspaceID, "header-cannot-grant") + req.Header.Set("X-Client-Capabilities", protocol.RuntimeCapabilityTaskExecution) + req = withURLParam(req, "runtimeId", runtimeID) + testHandler.ClaimTaskByRuntime(w, req) + if w.Code != http.StatusOK { + t.Fatalf("ClaimTaskByRuntime: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp struct { + Task any `json:"task"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Task != nil { + t.Fatalf("header granted task execution authority: %s", w.Body.String()) + } + var status string + var dispatchedAt, startedAt any + if err := testPool.QueryRow(ctx, `SELECT status, dispatched_at, started_at FROM agent_task_queue WHERE id = $1`, taskID).Scan(&status, &dispatchedAt, &startedAt); err != nil { + t.Fatalf("read task state: %v", err) + } + if status != "queued" || dispatchedAt != nil || startedAt != nil { + t.Fatalf("header-only claim changed task: status=%s dispatched_at=%v started_at=%v", status, dispatchedAt, startedAt) + } +} + func TestDaemonRegister_WithDaemonToken(t *testing.T) { if testHandler == nil { t.Skip("database not available") @@ -929,6 +1029,59 @@ func TestDaemonRegister_WithDaemonToken(t *testing.T) { testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, runtimeID) } +func TestDaemonRegisterPersistsAndRevokesTaskExecutionCapability(t *testing.T) { + if testHandler == nil || testPool == nil { + t.Skip("database not available") + } + ctx := context.Background() + daemonID := "task-capability-registration" + register := func(capabilities []string) string { + t.Helper() + w := httptest.NewRecorder() + req := newDaemonTokenRequest("POST", "/api/daemon/register", map[string]any{ + "workspace_id": testWorkspaceID, + "daemon_id": daemonID, + "device_name": "capability-test-device", + "runtimes": []map[string]any{{ + "name": "capability-runtime", + "type": "codex", + "version": "test", + "status": "online", + "capabilities": capabilities, + }}, + }, testWorkspaceID, daemonID) + testHandler.DaemonRegister(w, req) + if w.Code != http.StatusOK { + t.Fatalf("DaemonRegister: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode register response: %v", err) + } + return resp["runtimes"].([]any)[0].(map[string]any)["id"].(string) + } + + runtimeID := register([]string{protocol.RuntimeCapabilityTaskExecution}) + t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_runtime WHERE id = $1`, runtimeID) }) + var metadata []byte + if err := testPool.QueryRow(ctx, `SELECT metadata FROM agent_runtime WHERE id = $1`, runtimeID).Scan(&metadata); err != nil { + t.Fatalf("read capable runtime metadata: %v", err) + } + if !runtimeCanExecuteTasks(db.AgentRuntime{Metadata: metadata}) { + t.Fatalf("registered metadata did not retain exact task capability: %s", metadata) + } + + if got := register(nil); got != runtimeID { + t.Fatalf("re-registration created runtime %s, want existing %s", got, runtimeID) + } + if err := testPool.QueryRow(ctx, `SELECT metadata FROM agent_runtime WHERE id = $1`, runtimeID).Scan(&metadata); err != nil { + t.Fatalf("read revoked runtime metadata: %v", err) + } + if runtimeCanExecuteTasks(db.AgentRuntime{Metadata: metadata}) { + t.Fatalf("re-registration without capability retained stale authority: %s", metadata) + } +} + func TestDaemonRegister_RecordsRuntimeProfileRegistrationFailure(t *testing.T) { if testHandler == nil { t.Skip("database not available") @@ -3132,7 +3285,13 @@ func createRuntimeGuardAgent(t *testing.T, ctx context.Context) (agentID, runtim "daemon_id": daemonID, "device_name": "runtime-guard-test", "runtimes": []map[string]any{ - {"name": "runtime-guard-current", "type": "opencode", "version": "test", "status": "online"}, + { + "name": "runtime-guard-current", + "type": "opencode", + "version": "test", + "status": "online", + "capabilities": []string{protocol.RuntimeCapabilityTaskExecution}, + }, }, }, testWorkspaceID, daemonID) diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index aea8919f870..58f4e0b7c5e 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -124,7 +124,8 @@ func setupHandlerTestFixture(ctx context.Context, pool *pgxpool.Pool) (string, s INSERT INTO agent_runtime ( workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, owner_id, last_seen_at ) - VALUES ($1, NULL, $2, 'cloud', $3, 'online', $4, '{}'::jsonb, $5, now()) + VALUES ($1, NULL, $2, 'cloud', $3, 'online', $4, + '{"capabilities":["linux-bubblewrap-fd-v1"]}'::jsonb, $5, now()) RETURNING id `, workspaceID, "Handler Test Runtime", "handler_test_runtime", "Handler test runtime", userID).Scan(&runtimeID); err != nil { return "", "", err diff --git a/server/internal/handler/quick_create_parent_test.go b/server/internal/handler/quick_create_parent_test.go index d9f6999b023..a3d1caa13fa 100644 --- a/server/internal/handler/quick_create_parent_test.go +++ b/server/internal/handler/quick_create_parent_test.go @@ -54,15 +54,22 @@ func TestQuickCreateIssueParentTrustBoundary(t *testing.T) { ).Scan(&runtimeID); err != nil { t.Fatalf("fetch agent runtime: %v", err) } + var originalMetadata []byte + if err := testPool.QueryRow(ctx, + `SELECT metadata FROM agent_runtime WHERE id = $1`, + runtimeID, + ).Scan(&originalMetadata); err != nil { + t.Fatalf("fetch agent runtime metadata: %v", err) + } if _, err := testPool.Exec(ctx, - `UPDATE agent_runtime SET metadata = jsonb_build_object('cli_version', $1::text) WHERE id = $2`, + `UPDATE agent_runtime SET metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object('cli_version', $1::text) WHERE id = $2`, agent.MinQuickCreateCLIVersion, runtimeID, ); err != nil { t.Fatalf("bump runtime cli_version: %v", err) } t.Cleanup(func() { testPool.Exec(context.Background(), - `UPDATE agent_runtime SET metadata = '{}'::jsonb WHERE id = $1`, runtimeID) + `UPDATE agent_runtime SET metadata = $1 WHERE id = $2`, originalMetadata, runtimeID) }) // Same-workspace parent — must be accepted and threaded through. diff --git a/server/internal/middleware/task_scope.go b/server/internal/middleware/task_scope.go new file mode 100644 index 00000000000..d3b31444f51 --- /dev/null +++ b/server/internal/middleware/task_scope.go @@ -0,0 +1,387 @@ +package middleware + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/multica-ai/multica/server/internal/util" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +const taskScopeMaxBodyBytes = 64 << 10 + +var taskScopeCanonicalUUID = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + +var taskScopeIssueStatuses = map[string]struct{}{ + "backlog": {}, "todo": {}, "in_progress": {}, "in_review": {}, + "done": {}, "blocked": {}, +} + +var taskScopeCommentsQuery = map[string]struct{}{ + "since": {}, "thread": {}, "recent": {}, "tail": {}, "roots_only": {}, + "summary": {}, "fold": {}, "before": {}, "before_id": {}, +} + +var taskScopeMessagesQuery = map[string]struct{}{"since": {}} + +// taskScopeQuerier is the narrow database surface needed to bind a task token +// request to the single issue that owns the running task. +type taskScopeQuerier interface { + GetAgent(context.Context, pgtype.UUID) (db.Agent, error) + GetAgentTask(context.Context, pgtype.UUID) (db.AgentTaskQueue, error) + GetComment(context.Context, pgtype.UUID) (db.Comment, error) + GetIssue(context.Context, pgtype.UUID) (db.Issue, error) + GetIssueByNumber(context.Context, db.GetIssueByNumberParams) (db.Issue, error) + GetWorkspace(context.Context, pgtype.UUID) (db.Workspace, error) +} + +// TaskTokenScopeGuard is the final authorization boundary for mat_ task +// tokens. Human JWT/PAT requests keep their existing behavior. A task token is +// default-denied and may access only an explicit set of operations on the issue +// bound to its currently executing task. +// +// This guard intentionally lives server-side. CLI checks improve diagnostics, +// but a copied binary or a direct HTTP client must receive the same denial. +func TaskTokenScopeGuard(queries taskScopeQuerier) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Actor-Source") != "task_token" { + next.ServeHTTP(w, r) + return + } + if queries == nil || taskScopeAttemptsWorkspaceOverride(r) { + writeTaskScopeDenied(w) + return + } + + taskID, err := util.ParseUUID(r.Header.Get("X-Task-ID")) + if err != nil { + writeTaskScopeDenied(w) + return + } + task, err := queries.GetAgentTask(r.Context(), taskID) + if err != nil || task.ID != taskID || !task.IssueID.Valid || !taskScopeTaskIsActive(task.Status) { + writeTaskScopeDenied(w) + return + } + + agentID, err := util.ParseUUID(r.Header.Get("X-Agent-ID")) + if err != nil || task.AgentID != agentID { + writeTaskScopeDenied(w) + return + } + workspaceID, err := util.ParseUUID(r.Header.Get("X-Workspace-ID")) + if err != nil { + writeTaskScopeDenied(w) + return + } + agent, err := queries.GetAgent(r.Context(), agentID) + if err != nil || agent.ID != agentID || agent.WorkspaceID != workspaceID || agent.ArchivedAt.Valid { + writeTaskScopeDenied(w) + return + } + boundIssue, err := queries.GetIssue(r.Context(), task.IssueID) + if err != nil || boundIssue.ID != task.IssueID || boundIssue.WorkspaceID != workspaceID { + writeTaskScopeDenied(w) + return + } + + allowed, err := taskScopeAllowsRequest(r, queries, task, boundIssue, workspaceID) + if err != nil || !allowed { + writeTaskScopeDenied(w) + return + } + next.ServeHTTP(w, r) + }) + } +} + +func taskScopeTaskIsActive(status string) bool { + switch status { + case "dispatched", "running": + return true + default: + return false + } +} + +func taskScopeAttemptsWorkspaceOverride(r *http.Request) bool { + query, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + return true + } + return r.Header.Get("X-Workspace-Slug") != "" || query.Has("workspace_id") || query.Has("workspace_slug") +} + +func taskScopeAllowsRequest( + r *http.Request, + queries taskScopeQuerier, + boundTask db.AgentTaskQueue, + boundIssue db.Issue, + workspaceID pgtype.UUID, +) (bool, error) { + parts, ok := taskScopeStrictPathParts(r) + if !ok { + return false, nil + } + + if len(parts) == 4 && parts[0] == "api" && parts[1] == "tasks" && parts[3] == "messages" { + if r.Method != http.MethodGet || !taskScopeQueryAllowed(r, taskScopeMessagesQuery) { + return false, nil + } + messageTaskID, err := taskScopeParseCanonicalUUID(parts[2]) + if err != nil { + return false, nil + } + return messageTaskID == boundTask.ID, nil + } + + if len(parts) < 3 || len(parts) > 4 || parts[0] != "api" || parts[1] != "issues" { + return false, nil + } + requestIssue, err := taskScopeResolveIssue(r.Context(), queries, parts[2], workspaceID) + if err != nil || requestIssue.ID != boundIssue.ID { + return false, err + } + + if len(parts) == 3 { + switch r.Method { + case http.MethodGet: + return taskScopeQueryAllowed(r, nil), nil + case http.MethodPut: + if !taskScopeQueryAllowed(r, nil) { + return false, nil + } + return taskScopeAllowIssueUpdateBody(r) + default: + return false, nil + } + } + + switch parts[3] { + case "comments": + switch r.Method { + case http.MethodGet: + return taskScopeQueryAllowed(r, taskScopeCommentsQuery), nil + case http.MethodPost: + if !taskScopeQueryAllowed(r, nil) { + return false, nil + } + return taskScopeAllowCommentBody(r, queries, boundIssue, workspaceID) + default: + return false, nil + } + case "rerun": + return false, nil + case "task-runs": + return false, nil + default: + return false, nil + } +} + +func taskScopeResolveIssue(ctx context.Context, queries taskScopeQuerier, ref string, workspaceID pgtype.UUID) (db.Issue, error) { + if issueID, err := taskScopeParseCanonicalUUID(ref); err == nil { + issue, queryErr := queries.GetIssue(ctx, issueID) + if queryErr != nil || issue.WorkspaceID != workspaceID { + return db.Issue{}, queryErr + } + return issue, nil + } + + dash := strings.LastIndex(ref, "-") + if dash <= 0 || dash == len(ref)-1 { + return db.Issue{}, nil + } + number, err := strconv.ParseInt(ref[dash+1:], 10, 32) + if err != nil || number <= 0 || strconv.FormatInt(number, 10) != ref[dash+1:] || !taskScopeAlphaNumeric(ref[:dash]) { + return db.Issue{}, nil + } + workspace, err := queries.GetWorkspace(ctx, workspaceID) + if err != nil || !strings.EqualFold(ref[:dash], workspace.IssuePrefix) { + return db.Issue{}, err + } + return queries.GetIssueByNumber(ctx, db.GetIssueByNumberParams{ + WorkspaceID: workspaceID, + Number: int32(number), + }) +} + +func taskScopeAllowIssueUpdateBody(r *http.Request) (bool, error) { + body, err := taskScopeReadJSONObject(r, false) + if err != nil { + return false, err + } + if len(body) == 1 { + rawStatus, statusOnly := body["status"] + if !statusOnly { + return false, nil + } + var status string + if err := json.Unmarshal(rawStatus, &status); err != nil { + return false, nil + } + _, allowed := taskScopeIssueStatuses[status] + return allowed, nil + } + return false, nil +} + +func taskScopeReadJSONObject(r *http.Request, allowEmpty bool) (map[string]json.RawMessage, error) { + limited := io.LimitReader(r.Body, taskScopeMaxBodyBytes+1) + data, err := io.ReadAll(limited) + if err != nil { + return nil, err + } + r.Body = io.NopCloser(bytes.NewReader(data)) + if len(data) > taskScopeMaxBodyBytes { + return nil, io.ErrShortBuffer + } + if len(bytes.TrimSpace(data)) == 0 { + if allowEmpty { + return map[string]json.RawMessage{}, nil + } + return nil, io.ErrUnexpectedEOF + } + decoder := json.NewDecoder(bytes.NewReader(data)) + opening, err := decoder.Token() + if err != nil { + return nil, err + } + delim, ok := opening.(json.Delim) + if !ok || delim != '{' { + return nil, errors.New("request body must be one JSON object") + } + body := make(map[string]json.RawMessage) + for decoder.More() { + rawKey, err := decoder.Token() + if err != nil { + return nil, err + } + key, ok := rawKey.(string) + if !ok { + return nil, errors.New("invalid JSON object key") + } + if _, duplicate := body[key]; duplicate { + return nil, errors.New("duplicate JSON object key") + } + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return nil, err + } + body[key] = value + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim('}') { + return nil, errors.New("invalid JSON object") + } + if token, err := decoder.Token(); err != io.EOF || token != nil { + return nil, errors.New("request body must contain exactly one JSON object") + } + return body, nil +} + +func taskScopeAllowCommentBody(r *http.Request, queries taskScopeQuerier, boundIssue db.Issue, workspaceID pgtype.UUID) (bool, error) { + body, err := taskScopeReadJSONObject(r, false) + if err != nil || !taskScopeKeysAllowed(body, map[string]struct{}{"content": {}, "parent_id": {}}) { + return false, err + } + rawContent, present := body["content"] + if !present { + return false, nil + } + var content string + if err := json.Unmarshal(rawContent, &content); err != nil || content == "" { + return false, nil + } + rawParent, present := body["parent_id"] + if !present || bytes.Equal(bytes.TrimSpace(rawParent), []byte("null")) { + return true, nil + } + var parentIDString string + if err := json.Unmarshal(rawParent, &parentIDString); err != nil { + return false, nil + } + parentID, err := taskScopeParseCanonicalUUID(parentIDString) + if err != nil { + return false, nil + } + parent, err := queries.GetComment(r.Context(), parentID) + if err != nil { + return false, err + } + return parent.ID == parentID && parent.IssueID == boundIssue.ID && parent.WorkspaceID == workspaceID, nil +} + +func taskScopeParseCanonicalUUID(value string) (pgtype.UUID, error) { + if !taskScopeCanonicalUUID.MatchString(value) { + return pgtype.UUID{}, errors.New("UUID is not canonical") + } + return util.ParseUUID(value) +} + +func taskScopeAlphaNumeric(value string) bool { + if value == "" { + return false + } + for _, r := range value { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) { + return false + } + } + return true +} + +func taskScopeStrictPathParts(r *http.Request) ([]string, bool) { + path := r.URL.Path + if path == "" || path[0] != '/' || path == "/" || strings.HasSuffix(path, "/") || strings.Contains(path, "//") { + return nil, false + } + // No allowlisted route needs an escaped path segment. Reject RawPath and + // any escaped representation so the guard and Chi can never disagree on + // segment boundaries or dot/slash normalization. + if r.URL.RawPath != "" || r.URL.EscapedPath() != path || strings.Contains(path, "/./") || strings.Contains(path, "/../") { + return nil, false + } + return strings.Split(strings.TrimPrefix(path, "/"), "/"), true +} + +func taskScopeQueryAllowed(r *http.Request, allowed map[string]struct{}) bool { + query, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + return false + } + for key, values := range query { + if len(values) != 1 { + return false + } + if _, ok := allowed[key]; !ok { + return false + } + } + return true +} + +func taskScopeKeysAllowed(body map[string]json.RawMessage, allowed map[string]struct{}) bool { + for key := range body { + if _, ok := allowed[key]; !ok { + return false + } + } + return true +} + +func writeTaskScopeDenied(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(w, `{"error":"task_scope_denied"}`) +} diff --git a/server/internal/middleware/task_scope_test.go b/server/internal/middleware/task_scope_test.go new file mode 100644 index 00000000000..4784bddd0b8 --- /dev/null +++ b/server/internal/middleware/task_scope_test.go @@ -0,0 +1,495 @@ +package middleware + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/multica-ai/multica/server/internal/util" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +const ( + taskScopeTaskID = "00000000-0000-0000-0000-000000000101" + taskScopeAgentID = "00000000-0000-0000-0000-000000000102" + taskScopeWorkspaceID = "00000000-0000-0000-0000-000000000103" + taskScopeIssueID = "00000000-0000-0000-0000-000000000104" + taskScopeHistoryTaskID = "00000000-0000-0000-0000-000000000105" + taskScopeCommentID = "00000000-0000-0000-0000-000000000106" + taskScopeForeignIssueID = "00000000-0000-0000-0000-000000000201" + taskScopeForeignTaskID = "00000000-0000-0000-0000-000000000202" + taskScopeForeignComment = "00000000-0000-0000-0000-000000000203" +) + +type fakeTaskScopeQueries struct { + agents map[string]db.Agent + tasks map[string]db.AgentTaskQueue + comments map[string]db.Comment + issues map[string]db.Issue + byNumber map[int32]db.Issue + workspaces map[string]db.Workspace +} + +func (f *fakeTaskScopeQueries) GetAgent(_ context.Context, id pgtype.UUID) (db.Agent, error) { + if agent, ok := f.agents[util.UUIDToString(id)]; ok { + return agent, nil + } + return db.Agent{}, errors.New("agent not found") +} + +func (f *fakeTaskScopeQueries) GetComment(_ context.Context, id pgtype.UUID) (db.Comment, error) { + if comment, ok := f.comments[util.UUIDToString(id)]; ok { + return comment, nil + } + return db.Comment{}, errors.New("comment not found") +} + +func (f *fakeTaskScopeQueries) GetAgentTask(_ context.Context, id pgtype.UUID) (db.AgentTaskQueue, error) { + if task, ok := f.tasks[util.UUIDToString(id)]; ok { + return task, nil + } + return db.AgentTaskQueue{}, errors.New("task not found") +} + +func (f *fakeTaskScopeQueries) GetIssue(_ context.Context, id pgtype.UUID) (db.Issue, error) { + if issue, ok := f.issues[util.UUIDToString(id)]; ok { + return issue, nil + } + return db.Issue{}, errors.New("issue not found") +} + +func (f *fakeTaskScopeQueries) GetIssueByNumber(_ context.Context, arg db.GetIssueByNumberParams) (db.Issue, error) { + issue, ok := f.byNumber[arg.Number] + if !ok || issue.WorkspaceID != arg.WorkspaceID { + return db.Issue{}, errors.New("issue not found") + } + return issue, nil +} + +func (f *fakeTaskScopeQueries) GetWorkspace(_ context.Context, id pgtype.UUID) (db.Workspace, error) { + if workspace, ok := f.workspaces[util.UUIDToString(id)]; ok { + return workspace, nil + } + return db.Workspace{}, errors.New("workspace not found") +} + +func newTaskScopeFixture() *fakeTaskScopeQueries { + workspaceID := util.MustParseUUID(taskScopeWorkspaceID) + issueID := util.MustParseUUID(taskScopeIssueID) + foreignIssueID := util.MustParseUUID(taskScopeForeignIssueID) + issue := db.Issue{ID: issueID, WorkspaceID: workspaceID, Number: 75} + foreignIssue := db.Issue{ID: foreignIssueID, WorkspaceID: workspaceID, Number: 76} + return &fakeTaskScopeQueries{ + agents: map[string]db.Agent{ + taskScopeAgentID: { + ID: util.MustParseUUID(taskScopeAgentID), WorkspaceID: workspaceID, + }, + }, + tasks: map[string]db.AgentTaskQueue{ + taskScopeTaskID: { + ID: util.MustParseUUID(taskScopeTaskID), AgentID: util.MustParseUUID(taskScopeAgentID), + IssueID: issueID, Status: "running", + }, + taskScopeHistoryTaskID: { + ID: util.MustParseUUID(taskScopeHistoryTaskID), AgentID: util.MustParseUUID(taskScopeAgentID), + IssueID: issueID, Status: "completed", + }, + taskScopeForeignTaskID: { + ID: util.MustParseUUID(taskScopeForeignTaskID), AgentID: util.MustParseUUID(taskScopeAgentID), + IssueID: foreignIssueID, Status: "completed", + }, + }, + comments: map[string]db.Comment{ + taskScopeCommentID: { + ID: util.MustParseUUID(taskScopeCommentID), IssueID: issueID, WorkspaceID: workspaceID, + }, + taskScopeForeignComment: { + ID: util.MustParseUUID(taskScopeForeignComment), IssueID: foreignIssueID, WorkspaceID: workspaceID, + }, + }, + issues: map[string]db.Issue{ + taskScopeIssueID: issue, + taskScopeForeignIssueID: foreignIssue, + }, + byNumber: map[int32]db.Issue{75: issue, 76: foreignIssue}, + workspaces: map[string]db.Workspace{ + taskScopeWorkspaceID: {ID: workspaceID, IssuePrefix: "ATH"}, + }, + } +} + +func serveTaskScopeRequest(t *testing.T, queries taskScopeQuerier, method, path, body string, mutate func(*http.Request)) (int, string, bool) { + t.Helper() + called := false + handler := TaskTokenScopeGuard(queries)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusNoContent) + })) + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("X-Actor-Source", "task_token") + req.Header.Set("X-Task-ID", taskScopeTaskID) + req.Header.Set("X-Agent-ID", taskScopeAgentID) + req.Header.Set("X-Workspace-ID", taskScopeWorkspaceID) + if mutate != nil { + mutate(req) + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + return recorder.Code, recorder.Body.String(), called +} + +func TestTaskTokenScopeGuard_HumanCredentialsKeepExistingBehavior(t *testing.T) { + called := false + handler := TaskTokenScopeGuard(nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusNoContent) + })) + req := httptest.NewRequest(http.MethodPatch, "/api/workspaces/anything", nil) + req.Header.Set("X-Actor-Source", "member") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + if recorder.Code != http.StatusNoContent || !called { + t.Fatalf("human request should pass unchanged, code=%d called=%v", recorder.Code, called) + } +} + +func TestTaskTokenScopeGuard_DeniesInactiveOrForeignAgentIdentity(t *testing.T) { + tests := []struct { + name string + mutate func(*fakeTaskScopeQueries) + }{ + { + name: "archived agent", + mutate: func(queries *fakeTaskScopeQueries) { + agent := queries.agents[taskScopeAgentID] + agent.ArchivedAt = pgtype.Timestamptz{Valid: true} + queries.agents[taskScopeAgentID] = agent + }, + }, + { + name: "agent moved to another workspace", + mutate: func(queries *fakeTaskScopeQueries) { + agent := queries.agents[taskScopeAgentID] + agent.WorkspaceID = util.MustParseUUID("00000000-0000-0000-0000-000000000999") + queries.agents[taskScopeAgentID] = agent + }, + }, + { + name: "agent no longer exists", + mutate: func(queries *fakeTaskScopeQueries) { + delete(queries.agents, taskScopeAgentID) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + queries := newTaskScopeFixture() + tc.mutate(queries) + code, response, called := serveTaskScopeRequest(t, queries, http.MethodGet, "/api/issues/"+taskScopeIssueID, "", nil) + if code != http.StatusForbidden || called || response != `{"error":"task_scope_denied"}` { + t.Fatalf("expected task scope denial, code=%d called=%v body=%q", code, called, response) + } + }) + } +} + +func TestTaskTokenScopeGuard_AllowsOnlyBoundIssueOperations(t *testing.T) { + queries := newTaskScopeFixture() + tests := []struct { + name, method, path, body string + }{ + {"get issue by uuid", http.MethodGet, "/api/issues/" + taskScopeIssueID, ""}, + {"get issue by identifier", http.MethodGet, "/api/issues/ATH-75", ""}, + {"list comments", http.MethodGet, "/api/issues/" + taskScopeIssueID + "/comments", ""}, + {"add comment", http.MethodPost, "/api/issues/" + taskScopeIssueID + "/comments", `{"content":"done","parent_id":null}`}, + {"reply to bound comment", http.MethodPost, "/api/issues/" + taskScopeIssueID + "/comments", `{"content":"done","parent_id":"` + taskScopeCommentID + `"}`}, + {"change status", http.MethodPut, "/api/issues/" + taskScopeIssueID, `{"status":"in_review"}`}, + {"read bound task messages", http.MethodGet, "/api/tasks/" + taskScopeTaskID + "/messages", ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + code, response, called := serveTaskScopeRequest(t, queries, tc.method, tc.path, tc.body, nil) + if code != http.StatusNoContent || !called { + t.Fatalf("expected pass, code=%d called=%v body=%s", code, called, response) + } + }) + } +} + +func TestTaskTokenScopeGuard_DeniesSameIssueTaskOrchestrationAndForeignTaskReads(t *testing.T) { + queries := newTaskScopeFixture() + tests := []struct { + name, method, path, body string + }{ + {"read same issue foreign task messages", http.MethodGet, "/api/tasks/" + taskScopeHistoryTaskID + "/messages", ""}, + {"rerun current assignee", http.MethodPost, "/api/issues/" + taskScopeIssueID + "/rerun", `{}`}, + {"rerun bound task", http.MethodPost, "/api/issues/" + taskScopeIssueID + "/rerun", `{"task_id":"` + taskScopeTaskID + `"}`}, + {"rerun same issue foreign task", http.MethodPost, "/api/issues/" + taskScopeIssueID + "/rerun", `{"task_id":"` + taskScopeHistoryTaskID + `"}`}, + {"enumerate same issue task runs", http.MethodGet, "/api/issues/" + taskScopeIssueID + "/task-runs", ""}, + {"cancel all same issue tasks", http.MethodPut, "/api/issues/" + taskScopeIssueID, `{"status":"cancelled"}`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + code, response, called := serveTaskScopeRequest(t, queries, tc.method, tc.path, tc.body, nil) + if code != http.StatusForbidden || called || !strings.Contains(response, "task_scope_denied") { + t.Fatalf("expected task isolation denial, code=%d called=%v body=%s", code, called, response) + } + }) + } +} + +func TestTaskTokenScopeGuard_DeniesWorkspaceAndManagementAPIs(t *testing.T) { + queries := newTaskScopeFixture() + tests := []struct { + resource string + method string + path string + }{ + {"workspace read", http.MethodGet, "/api/workspaces"}, + {"workspace write", http.MethodPatch, "/api/workspaces/" + taskScopeWorkspaceID}, + {"project read", http.MethodGet, "/api/projects"}, + {"project write", http.MethodPost, "/api/projects"}, + {"repo read", http.MethodGet, "/api/repos"}, + {"repo write", http.MethodPatch, "/api/workspaces/" + taskScopeWorkspaceID}, + {"agent read", http.MethodGet, "/api/agents"}, + {"agent write", http.MethodPatch, "/api/agents/" + taskScopeAgentID}, + {"runtime read", http.MethodGet, "/api/runtimes"}, + {"runtime write", http.MethodPost, "/api/runtimes"}, + {"autopilot read", http.MethodGet, "/api/autopilots"}, + {"autopilot write", http.MethodPost, "/api/autopilots"}, + {"skill read", http.MethodGet, "/api/skills"}, + {"skill write", http.MethodPost, "/api/skills"}, + {"squad read", http.MethodGet, "/api/squads"}, + {"squad write", http.MethodPost, "/api/squads"}, + {"account read", http.MethodGet, "/api/me"}, + {"token write", http.MethodPost, "/api/tokens"}, + {"upload write", http.MethodPost, "/api/upload-file"}, + } + for _, tc := range tests { + t.Run(tc.resource, func(t *testing.T) { + code, response, called := serveTaskScopeRequest(t, queries, tc.method, tc.path, "", nil) + if code != http.StatusForbidden || called || !strings.Contains(response, "task_scope_denied") { + t.Fatalf("expected task scope denial, code=%d called=%v body=%s", code, called, response) + } + }) + } +} + +func TestTaskTokenScopeGuard_DeniesForeignIssueAndTaskAccess(t *testing.T) { + queries := newTaskScopeFixture() + tests := []struct { + method, path, body string + }{ + {http.MethodGet, "/api/issues/" + taskScopeForeignIssueID, ""}, + {http.MethodGet, "/api/issues/ATH-76/comments", ""}, + {http.MethodPost, "/api/issues/" + taskScopeForeignIssueID + "/comments", `{"content":"cross issue"}`}, + {http.MethodPut, "/api/issues/" + taskScopeForeignIssueID, `{"status":"done"}`}, + {http.MethodPost, "/api/issues/" + taskScopeForeignIssueID + "/rerun", `{}`}, + {http.MethodGet, "/api/issues/" + taskScopeForeignIssueID + "/task-runs", ""}, + {http.MethodGet, "/api/tasks/" + taskScopeForeignTaskID + "/messages", ""}, + {http.MethodPost, "/api/issues/" + taskScopeIssueID + "/rerun", `{"task_id":"` + taskScopeForeignTaskID + `"}`}, + } + for _, tc := range tests { + code, response, called := serveTaskScopeRequest(t, queries, tc.method, tc.path, tc.body, nil) + if code != http.StatusForbidden || called || !strings.Contains(response, "task_scope_denied") { + t.Fatalf("expected task scope denial for %s %s, code=%d called=%v body=%s", tc.method, tc.path, code, called, response) + } + } +} + +func TestTaskTokenScopeGuard_DeniesBodyWidening(t *testing.T) { + queries := newTaskScopeFixture() + tests := []struct{ method, path, body string }{ + {http.MethodPut, "/api/issues/" + taskScopeIssueID, `{"status":"done","project_id":"00000000-0000-0000-0000-000000000999"}`}, + {http.MethodPut, "/api/issues/" + taskScopeIssueID, `{"title":"changed"}`}, + {http.MethodPost, "/api/issues/" + taskScopeIssueID + "/comments", `{"content":"x","suppress_agent_ids":["` + taskScopeAgentID + `"]}`}, + {http.MethodPost, "/api/issues/" + taskScopeIssueID + "/rerun", `{"force":true}`}, + } + for _, tc := range tests { + code, response, called := serveTaskScopeRequest(t, queries, tc.method, tc.path, tc.body, nil) + if code != http.StatusForbidden || called || !strings.Contains(response, "task_scope_denied") { + t.Fatalf("expected widened body denial, code=%d called=%v body=%s", code, called, response) + } + } +} + +func TestTaskTokenScopeGuard_DeniesAllAssigneeMutations(t *testing.T) { + queries := newTaskScopeFixture() + tests := []struct { + name string + body string + }{ + {"member", `{"assignee_type":"member","assignee_id":"` + taskScopeAgentID + `"}`}, + {"agent", `{"assignee_type":"agent","assignee_id":"` + taskScopeAgentID + `"}`}, + {"squad", `{"assignee_type":"squad","assignee_id":"` + taskScopeAgentID + `"}`}, + {"unassign", `{"assignee_type":null,"assignee_id":null}`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + code, response, called := serveTaskScopeRequest(t, queries, http.MethodPut, "/api/issues/"+taskScopeIssueID, tc.body, nil) + if code != http.StatusForbidden || called || !strings.Contains(response, "task_scope_denied") { + t.Fatalf("expected assignee mutation denial, code=%d called=%v body=%s", code, called, response) + } + }) + } +} + +func TestTaskTokenScopeGuard_DeniesInvalidIssueMutationValues(t *testing.T) { + queries := newTaskScopeFixture() + tests := []struct{ name, body string }{ + {"status null", `{"status":null}`}, + {"status empty", `{"status":""}`}, + {"status number", `{"status":7}`}, + {"status unknown", `{"status":"active"}`}, + {"assignee empty object", `{}`}, + {"assignee missing id", `{"assignee_type":"agent"}`}, + {"assignee missing type", `{"assignee_id":"` + taskScopeAgentID + `"}`}, + {"assignee mixed null", `{"assignee_type":null,"assignee_id":"` + taskScopeAgentID + `"}`}, + {"assignee unknown type", `{"assignee_type":"runtime","assignee_id":"` + taskScopeAgentID + `"}`}, + {"assignee invalid uuid", `{"assignee_type":"agent","assignee_id":"agent-1"}`}, + {"assignee non canonical uuid", `{"assignee_type":"agent","assignee_id":"{` + taskScopeAgentID + `}"}`}, + {"duplicate key", `{"status":"done","status":"todo"}`}, + {"trailing json", `{"status":"done"}{"status":"todo"}`}, + {"array", `[{"status":"done"}]`}, + {"null body", `null`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + code, response, called := serveTaskScopeRequest(t, queries, http.MethodPut, "/api/issues/"+taskScopeIssueID, tc.body, nil) + if code != http.StatusForbidden || called || !strings.Contains(response, "task_scope_denied") { + t.Fatalf("expected denial, code=%d called=%v body=%s", code, called, response) + } + }) + } +} + +func TestTaskTokenScopeGuard_DeniesInvalidCommentValues(t *testing.T) { + queries := newTaskScopeFixture() + tests := []struct{ name, body string }{ + {"missing content", `{}`}, + {"empty content", `{"content":""}`}, + {"null content", `{"content":null}`}, + {"numeric content", `{"content":3}`}, + {"invalid parent uuid", `{"content":"x","parent_id":"comment-1"}`}, + {"foreign parent", `{"content":"x","parent_id":"` + taskScopeForeignComment + `"}`}, + {"parent object", `{"content":"x","parent_id":{}}`}, + {"extra key", `{"content":"x","type":"system"}`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + code, response, called := serveTaskScopeRequest(t, queries, http.MethodPost, "/api/issues/"+taskScopeIssueID+"/comments", tc.body, nil) + if code != http.StatusForbidden || called || !strings.Contains(response, "task_scope_denied") { + t.Fatalf("expected denial, code=%d called=%v body=%s", code, called, response) + } + }) + } +} + +func TestTaskTokenScopeGuard_DeniesInvalidRerunValues(t *testing.T) { + queries := newTaskScopeFixture() + for _, body := range []string{ + `{"task_id":null}`, + `{"task_id":"task-1"}`, + `{"task_id":7}`, + `{"task_id":"{` + taskScopeHistoryTaskID + `}"}`, + } { + code, response, called := serveTaskScopeRequest(t, queries, http.MethodPost, "/api/issues/"+taskScopeIssueID+"/rerun", body, nil) + if code != http.StatusForbidden || called || !strings.Contains(response, "task_scope_denied") { + t.Fatalf("expected rerun denial for %s, code=%d called=%v body=%s", body, code, called, response) + } + } +} + +func TestTaskTokenScopeGuard_EnforcesQueryAllowlist(t *testing.T) { + queries := newTaskScopeFixture() + allowed := []string{ + "/api/issues/" + taskScopeIssueID + "/comments?since=1&thread=" + taskScopeCommentID + "&recent=1&tail=1&roots_only=false&summary=true&fold=true&before=cursor&before_id=" + taskScopeCommentID, + "/api/tasks/" + taskScopeTaskID + "/messages?since=1", + } + for _, path := range allowed { + code, response, called := serveTaskScopeRequest(t, queries, http.MethodGet, path, "", nil) + if code != http.StatusNoContent || !called { + t.Fatalf("expected allowed query %s, code=%d called=%v body=%s", path, code, called, response) + } + } + + denied := []string{ + "/api/issues/" + taskScopeIssueID + "?expand=workspace", + "/api/issues/" + taskScopeIssueID + "/task-runs?include=agent", + "/api/issues/" + taskScopeIssueID + "/comments?workspace_id=" + taskScopeWorkspaceID, + "/api/issues/" + taskScopeIssueID + "/comments?unknown=1", + "/api/issues/" + taskScopeIssueID + "/comments?since=1&since=2", + "/api/tasks/" + taskScopeTaskID + "/messages?unknown=1", + "/api/tasks/" + taskScopeTaskID + "/messages?since=1&since=2", + "/api/tasks/" + taskScopeTaskID + "/messages?since=%zz", + "/api/issues/" + taskScopeIssueID + "/comments?since=1;workspace_id=" + taskScopeWorkspaceID, + } + for _, path := range denied { + code, response, called := serveTaskScopeRequest(t, queries, http.MethodGet, path, "", nil) + if code != http.StatusForbidden || called || !strings.Contains(response, "task_scope_denied") { + t.Fatalf("expected query denial %s, code=%d called=%v body=%s", path, code, called, response) + } + } +} + +func TestTaskTokenScopeGuard_DeniesAmbiguousPaths(t *testing.T) { + queries := newTaskScopeFixture() + paths := []string{ + "/api/issues/" + taskScopeIssueID + "/", + "/api//issues/" + taskScopeIssueID, + "/api/issues/./" + taskScopeIssueID, + "/api/issues/../issues/" + taskScopeIssueID, + "/api/issues/ATH-075", + "/api/issues/ATH_75", + "/api/issues/%7B" + taskScopeIssueID + "%7D", + "/api/issues/ATH%2F75", + } + for _, path := range paths { + code, response, called := serveTaskScopeRequest(t, queries, http.MethodGet, path, "", nil) + if code != http.StatusForbidden || called || !strings.Contains(response, "task_scope_denied") { + t.Fatalf("expected path denial %s, code=%d called=%v body=%s", path, code, called, response) + } + } +} + +func TestTaskTokenScopeGuard_DeniesOversizedBody(t *testing.T) { + queries := newTaskScopeFixture() + body := `{"content":"` + strings.Repeat("x", taskScopeMaxBodyBytes) + `"}` + code, response, called := serveTaskScopeRequest(t, queries, http.MethodPost, "/api/issues/"+taskScopeIssueID+"/comments", body, nil) + if code != http.StatusForbidden || called || !strings.Contains(response, "task_scope_denied") { + t.Fatalf("expected oversized body denial, code=%d called=%v body=%s", code, called, response) + } +} + +func TestTaskTokenScopeGuard_DeniesForgedBindingHeadersAndInactiveTask(t *testing.T) { + queries := newTaskScopeFixture() + tests := []struct { + name string + mutate func(*http.Request) + }{ + {"agent", func(r *http.Request) { r.Header.Set("X-Agent-ID", "00000000-0000-0000-0000-000000000999") }}, + {"workspace", func(r *http.Request) { r.Header.Set("X-Workspace-ID", "00000000-0000-0000-0000-000000000999") }}, + {"task", func(r *http.Request) { r.Header.Set("X-Task-ID", taskScopeForeignTaskID) }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + code, _, called := serveTaskScopeRequest(t, queries, http.MethodGet, "/api/issues/"+taskScopeIssueID, "", tc.mutate) + if code != http.StatusForbidden || called { + t.Fatalf("expected forged %s binding to be denied, code=%d called=%v", tc.name, code, called) + } + }) + } + + task := queries.tasks[taskScopeTaskID] + task.Status = "completed" + queries.tasks[taskScopeTaskID] = task + code, _, called := serveTaskScopeRequest(t, queries, http.MethodGet, "/api/issues/"+taskScopeIssueID, "", nil) + if code != http.StatusForbidden || called { + t.Fatalf("completed token-bound task must be denied, code=%d called=%v", code, called) + } +} diff --git a/server/internal/service/builtin_skills/multica-runtimes-and-repos/SKILL.md b/server/internal/service/builtin_skills/multica-runtimes-and-repos/SKILL.md index acb6094c931..6b118988d18 100644 --- a/server/internal/service/builtin_skills/multica-runtimes-and-repos/SKILL.md +++ b/server/internal/service/builtin_skills/multica-runtimes-and-repos/SKILL.md @@ -47,7 +47,7 @@ multica repo checkout --ref `runtime update` and `runtime delete` are writes. Starting a runtime update is limited to its owner or a workspace owner/admin; the original initiator may keep polling that specific in-flight request if their admin role changes. `runtime delete` removes a runtime registration; if active agents are still bound, it refuses unless the user explicitly passes `--cascade`, which archives those agents and cancels their queued/running tasks before deleting the runtime. `repo checkout` creates a git worktree in the task working directory. -`repo checkout` requires `MULTICA_DAEMON_PORT`; it is intended to run inside a daemon task. If absent, you are not in the normal agent checkout path. When a project `github_repo` resource has `resource_ref.ref`, `repo checkout ` uses that ref by default for the current task; an explicit `repo checkout --ref ` overrides it. +`repo checkout` requires both `MULTICA_DAEMON_PORT` and a daemon-issued, task-scoped checkout capability. It is intended to run inside a daemon task. The daemon binds the capability to the task's workspace, workdir, agent identity, exact repo URLs, and refs; the CLI cannot select or override those identities. When a project `github_repo` resource has `resource_ref.ref`, `repo checkout ` uses that ref. An explicit `--ref` is accepted only when it exactly matches the ref assigned to the task. ## Debugging an agent that did not run diff --git a/server/internal/service/builtin_skills/multica-runtimes-and-repos/references/runtimes-and-repos-source-map.md b/server/internal/service/builtin_skills/multica-runtimes-and-repos/references/runtimes-and-repos-source-map.md index bdd69a8fe4d..71c72f28ae5 100644 --- a/server/internal/service/builtin_skills/multica-runtimes-and-repos/references/runtimes-and-repos-source-map.md +++ b/server/internal/service/builtin_skills/multica-runtimes-and-repos/references/runtimes-and-repos-source-map.md @@ -5,8 +5,9 @@ - `runtime update` posts to `/api/runtimes/{runtime-id}/update`; with `--wait` it polls update status. Initiation enforces runtime-owner or workspace-owner/admin access through `canEditRuntime`; status polling additionally permits that request's immutable initiator so an in-flight poll survives an admin-role change (`server/internal/handler/runtime_update.go` and `runtime.go`). - `runtime delete` deletes `/api/runtimes/{runtime-id}`; with `--cascade`, it first reads the `runtime_has_active_agents` conflict payload and posts those ids to `/api/runtimes/{runtime-id}/archive-agents-and-delete`. - `server/cmd/multica/cmd_repo.go` registers `repo checkout [--ref]`. -- `repo checkout` requires `MULTICA_DAEMON_PORT`, sends `workspace_id`, `workdir`, `ref`, `agent_name`, and `task_id` to local daemon `/repo/checkout`, then prints the checked-out path. -- `server/internal/daemon/health.go` resolves the checkout ref: request `ref` wins; otherwise it asks `server/internal/daemon/daemon.go` for the current task's project repo default ref. +- `repo checkout` requires `MULTICA_DAEMON_PORT` plus the daemon-issued `MULTICA_REPO_CHECKOUT_TOKEN`. It sends only the opaque capability header, repo URL, and optional ref to local daemon `/repo/checkout`, then prints the checked-out path. +- `server/internal/daemon/repo_checkout_capability.go` binds each random capability to one task's workspace, workdir, agent identity, exact repo URLs, and assigned refs, and revokes it when the task exits. +- `server/internal/daemon/health.go` rejects caller-supplied identity fields and derives all checkout identity and placement from the capability. An explicit ref must exactly match the task binding. - `server/cmd/server/router.go` registers daemon APIs under `/api/daemon`, including workspace repos and task claim. - `server/internal/daemon/daemon.go` claims tasks, prepares workdirs, launches provider CLIs, and reports completion. - `server/internal/daemon/execenv/runtime_config.go` injects task/project/repo context into agent workdirs. diff --git a/server/internal/taskauth/task_authority.go b/server/internal/taskauth/task_authority.go new file mode 100644 index 00000000000..590f519fd62 --- /dev/null +++ b/server/internal/taskauth/task_authority.go @@ -0,0 +1,193 @@ +package taskauth + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" +) + +const ( + ManagedBy = "multica-daemon-task-authority" + Version = 1 + MaxBytes = 16 * 1024 + FileName = "task-authority.json" + FixedPath = "/run/multica/task-authority.json" +) + +type Authority struct { + ManagedBy string `json:"managed_by"` + Version int `json:"version"` + ServerURL string `json:"server_url"` + WorkspaceID string `json:"workspace_id"` + Token string `json:"token"` + TaskID string `json:"task_id"` + AgentID string `json:"agent_id"` +} + +func Write(root string, authority Authority) (string, error) { + validated, err := validate(authority) + if err != nil { + return "", err + } + root = filepath.Clean(root) + if !filepath.IsAbs(root) { + return "", fmt.Errorf("authority root must be absolute") + } + body, err := json.Marshal(validated) + if err != nil { + return "", fmt.Errorf("encode task authority: %w", err) + } + body = append(body, '\n') + temp, err := os.CreateTemp(root, ".task-authority-*") + if err != nil { + return "", fmt.Errorf("create task authority: %w", err) + } + tempPath := temp.Name() + keep := false + defer func() { + _ = temp.Close() + if !keep { + _ = os.Remove(tempPath) + } + }() + if err := temp.Chmod(0o600); err != nil { + return "", fmt.Errorf("secure task authority: %w", err) + } + if _, err := temp.Write(body); err != nil { + return "", fmt.Errorf("write task authority: %w", err) + } + if err := temp.Sync(); err != nil { + return "", fmt.Errorf("sync task authority: %w", err) + } + if err := temp.Close(); err != nil { + return "", fmt.Errorf("close task authority: %w", err) + } + target := filepath.Join(root, FileName) + if err := os.Rename(tempPath, target); err != nil { + return "", fmt.Errorf("publish task authority: %w", err) + } + keep = true + if _, err := Load(target); err != nil { + _ = os.Remove(target) + return "", fmt.Errorf("verify task authority: %w", err) + } + return target, nil +} + +func Load(path string) (Authority, error) { + info, err := os.Lstat(path) + if err != nil { + return Authority{}, fmt.Errorf("inspect task authority: %w", err) + } + if !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { + return Authority{}, fmt.Errorf("task authority must be a regular 0600 file") + } + file, err := os.Open(path) + if err != nil { + return Authority{}, fmt.Errorf("open task authority: %w", err) + } + defer file.Close() + opened, err := file.Stat() + if err != nil || !os.SameFile(info, opened) { + return Authority{}, fmt.Errorf("task authority changed while opening") + } + body, err := io.ReadAll(io.LimitReader(file, MaxBytes+1)) + if err != nil { + return Authority{}, fmt.Errorf("read task authority: %w", err) + } + if len(body) > MaxBytes { + return Authority{}, fmt.Errorf("task authority exceeds size limit") + } + if err := rejectDuplicateKeys(body); err != nil { + return Authority{}, err + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + var authority Authority + if err := decoder.Decode(&authority); err != nil { + return Authority{}, fmt.Errorf("decode task authority: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return Authority{}, fmt.Errorf("task authority contains trailing JSON") + } + return validate(authority) +} + +func rejectDuplicateKeys(body []byte) error { + decoder := json.NewDecoder(bytes.NewReader(body)) + token, err := decoder.Token() + if err != nil { + return fmt.Errorf("decode task authority: %w", err) + } + if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' { + return fmt.Errorf("task authority must be a JSON object") + } + seen := map[string]struct{}{} + for decoder.More() { + token, err := decoder.Token() + if err != nil { + return fmt.Errorf("decode task authority: %w", err) + } + key, ok := token.(string) + if !ok { + return fmt.Errorf("task authority contains an invalid key") + } + if _, exists := seen[key]; exists { + return fmt.Errorf("task authority contains duplicate field %q", key) + } + seen[key] = struct{}{} + var discard json.RawMessage + if err := decoder.Decode(&discard); err != nil { + return fmt.Errorf("decode task authority: %w", err) + } + } + if _, err := decoder.Token(); err != nil { + return fmt.Errorf("decode task authority: %w", err) + } + return nil +} + +func validate(authority Authority) (Authority, error) { + if authority.ManagedBy != ManagedBy || authority.Version != Version { + return Authority{}, fmt.Errorf("unsupported task authority identity") + } + normalized, err := normalizeServerURL(authority.ServerURL) + if err != nil { + return Authority{}, err + } + authority.ServerURL = normalized + for name, value := range map[string]string{ + "workspace_id": authority.WorkspaceID, + "task_id": authority.TaskID, + "agent_id": authority.AgentID, + } { + parsed, err := uuid.Parse(value) + if err != nil || parsed.String() != value { + return Authority{}, fmt.Errorf("task authority %s must be a canonical UUID", name) + } + } + if !strings.HasPrefix(authority.Token, "mat_") || strings.TrimSpace(authority.Token) != authority.Token || authority.Token == "mat_" { + return Authority{}, fmt.Errorf("task authority token is invalid") + } + return authority, nil +} + +func normalizeServerURL(raw string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return "", fmt.Errorf("task authority server_url must be an absolute HTTP URL") + } + if parsed.User != nil || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" { + return "", fmt.Errorf("task authority server_url contains unsupported components") + } + parsed.Path = "" + return strings.TrimSuffix(parsed.String(), "/"), nil +} diff --git a/server/internal/taskauth/task_authority_test.go b/server/internal/taskauth/task_authority_test.go new file mode 100644 index 00000000000..2547cccb79d --- /dev/null +++ b/server/internal/taskauth/task_authority_test.go @@ -0,0 +1,108 @@ +package taskauth + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const ( + testWorkspaceID = "68a5f501-c34f-417f-a29c-c65199b9ff33" + testTaskID = "c6a0dc62-89aa-4fd2-ad29-79d586147f99" + testAgentID = "b7eef942-0738-463b-9920-abda697da1c6" +) + +func validAuthority() Authority { + return Authority{ + ManagedBy: ManagedBy, + Version: Version, + ServerURL: "https://api.example.test", + WorkspaceID: testWorkspaceID, + Token: "mat_task_token", + TaskID: testTaskID, + AgentID: testAgentID, + } +} + +func TestWriteAndLoadAuthorityStrictRoundTrip(t *testing.T) { + root := t.TempDir() + path, err := Write(root, validAuthority()) + if err != nil { + t.Fatalf("Write: %v", err) + } + if filepath.Dir(path) != root { + t.Fatalf("authority path %q escaped root %q", path, root) + } + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + if !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { + t.Fatalf("authority mode = %v, want regular 0600", info.Mode()) + } + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded != validAuthority() { + t.Fatalf("loaded authority = %#v", loaded) + } +} + +func TestLoadAuthorityRejectsMalformedAndAmbiguousInputs(t *testing.T) { + base := `{"managed_by":"multica-daemon-task-authority","version":1,"server_url":"https://api.example.test","workspace_id":"` + testWorkspaceID + `","token":"mat_task_token","task_id":"` + testTaskID + `","agent_id":"` + testAgentID + `"}` + tests := []struct { + name string + body string + }{ + {name: "malformed", body: `{"managed_by":`}, + {name: "unknown field", body: strings.TrimSuffix(base, "}") + `,"extra":true}`}, + {name: "duplicate field", body: strings.Replace(base, `"version":1`, `"version":1,"version":1`, 1)}, + {name: "trailing JSON", body: base + `{}`}, + {name: "wrong managed by", body: strings.Replace(base, ManagedBy, "attacker", 1)}, + {name: "wrong version", body: strings.Replace(base, `"version":1`, `"version":2`, 1)}, + {name: "bad URL scheme", body: strings.Replace(base, "https://api.example.test", "file:///tmp/socket", 1)}, + {name: "URL credentials", body: strings.Replace(base, "https://api.example.test", "https://user:pass@api.example.test", 1)}, + {name: "bad workspace UUID", body: strings.Replace(base, testWorkspaceID, "workspace", 1)}, + {name: "bad task UUID", body: strings.Replace(base, testTaskID, "task", 1)}, + {name: "bad agent UUID", body: strings.Replace(base, testAgentID, "agent", 1)}, + {name: "member token", body: strings.Replace(base, "mat_task_token", "pat_member", 1)}, + {name: "oversized", body: base + strings.Repeat(" ", MaxBytes)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "authority.json") + if err := os.WriteFile(path, []byte(tt.body), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatalf("invalid authority unexpectedly loaded: %s", tt.body) + } + }) + } +} + +func TestLoadAuthorityRejectsSymlinkAndPermissiveMode(t *testing.T) { + root := t.TempDir() + path, err := Write(root, validAuthority()) + if err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("world-readable authority unexpectedly loaded") + } + if err := os.Chmod(path, 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(root, "authority-link.json") + if err := os.Symlink(path, link); err != nil { + t.Fatal(err) + } + if _, err := Load(link); err == nil { + t.Fatal("symlink authority unexpectedly loaded") + } +} diff --git a/server/pkg/agent/agent.go b/server/pkg/agent/agent.go index deb944f6166..3440f586127 100644 --- a/server/pkg/agent/agent.go +++ b/server/pkg/agent/agent.go @@ -152,12 +152,16 @@ type Result struct { type Config struct { ExecutablePath string // path to CLI binary (claude, codebuddy, codex, copilot, opencode, openclaw, hermes, pi, cursor, kimi, kiro-cli, agy, qodercli, traecli, grok) CLIVersion string // detected version paired with ExecutablePath; observation only, never used to choose behavior - Env map[string]string // extra environment variables + Env map[string]string // complete task environment; the daemon environment is not inherited Logger *slog.Logger TaskID string RuntimeID string DaemonVersion string CodexVersion string + Launcher CommandBuilder + Isolation *TaskIsolationPolicy + TaskTempDir string + TaskStateDir string // daemon-issued persistent root reused with the task workdir } // New creates a Backend for the given agent type. @@ -210,7 +214,6 @@ func New(agentType string, cfg Config) (Backend, error) { if cfg.Logger == nil { cfg.Logger = slog.Default() } - switch agentType { case "claude": return &claudeBackend{cfg: cfg}, nil diff --git a/server/pkg/agent/antigravity.go b/server/pkg/agent/antigravity.go index af842309f13..1da77aa2cb8 100644 --- a/server/pkg/agent/antigravity.go +++ b/server/pkg/agent/antigravity.go @@ -46,7 +46,8 @@ func (b *antigravityBackend) Execute(ctx context.Context, prompt string, opts Ex if execPath == "" { execPath = "agy" } - if _, err := exec.LookPath(execPath); err != nil { + lookedUp, err := exec.LookPath(execPath) + if err != nil { return nil, fmt.Errorf("agy executable not found at %q: %w", execPath, err) } @@ -61,7 +62,7 @@ func (b *antigravityBackend) Execute(ctx context.Context, prompt string, opts Ex // resolve the value itself rather than blocking the run on a discovery // hiccup (see antigravityModelError). if opts.Model != "" { - catalog, _ := ListModels(ctx, "antigravity", execPath) + catalog, _ := b.discoverModels(ctx, lookedUp, opts.Cwd) if err := antigravityModelError(opts.Model, catalog); err != nil { return nil, err } @@ -70,7 +71,11 @@ func (b *antigravityBackend) Execute(ctx context.Context, prompt string, opts Ex timeout := opts.Timeout runCtx, cancel := runContext(ctx, timeout) - logFile, err := os.CreateTemp("", "multica-agy-log-*.log") + if b.cfg.TaskTempDir == "" { + cancel() + return nil, fmt.Errorf("task temp dir is required for agy log file") + } + logFile, err := os.CreateTemp(b.cfg.TaskTempDir, "multica-agy-log-*.log") if err != nil { cancel() return nil, fmt.Errorf("create agy log file: %w", err) @@ -80,14 +85,13 @@ func (b *antigravityBackend) Execute(ctx context.Context, prompt string, opts Ex args := buildAntigravityArgs(prompt, logPath, timeout, opts, b.cfg.Logger) - cmd := exec.CommandContext(runCtx, execPath, args...) - hideAgentWindow(cmd) - b.cfg.Logger.Info("agent command", "exec", execPath, "args", args) - cmd.WaitDelay = 10 * time.Second - if opts.Cwd != "" { - cmd.Dir = opts.Cwd + cmd, err := b.cfg.command(runCtx, lookedUp, args, opts.Cwd, 10*time.Second) + if err != nil { + cancel() + _ = os.Remove(logPath) + return nil, fmt.Errorf("create agy command: %w", err) } - cmd.Env = buildEnv(b.cfg.Env) + b.cfg.Logger.Info("agent command", "exec", lookedUp, "args", args) stdout, err := cmd.StdoutPipe() if err != nil { @@ -96,7 +100,7 @@ func (b *antigravityBackend) Execute(ctx context.Context, prompt string, opts Ex return nil, fmt.Errorf("agy stdout pipe: %w", err) } stderrBuf := newStderrTail(newLogWriter(b.cfg.Logger, "[agy:stderr] "), agentStderrTailBytes) - cmd.Stderr = stderrBuf + _ = cmd.SetStderr(stderrBuf) if err := cmd.Start(); err != nil { cancel() @@ -104,7 +108,7 @@ func (b *antigravityBackend) Execute(ctx context.Context, prompt string, opts Ex return nil, fmt.Errorf("start agy: %w", err) } - b.cfg.Logger.Info("agy started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model) + b.cfg.Logger.Info("agy started", "pid", cmd.Process().Pid, "cwd", opts.Cwd, "model", opts.Model) msgCh := make(chan Message, 256) resCh := make(chan Result, 1) @@ -197,7 +201,7 @@ func (b *antigravityBackend) Execute(ctx context.Context, prompt string, opts Ex } } - b.cfg.Logger.Info("agy finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) + b.cfg.Logger.Info("agy finished", "pid", cmd.Process().Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) resCh <- Result{ Status: finalStatus, @@ -215,6 +219,24 @@ func (b *antigravityBackend) Execute(ctx context.Context, prompt string, opts Ex return &Session{Messages: msgCh, Result: resCh}, nil } +// discoverModels runs the task-local `agy models` probe through the same +// launcher and isolation policy as the main provider process. Discovery is +// fail-open: callers ignore errors and let agy resolve the requested model. +func (b *antigravityBackend) discoverModels(ctx context.Context, execPath, cwd string) ([]Model, error) { + runCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + cmd, err := b.cfg.command(runCtx, execPath, []string{"models"}, cwd, 0) + if err != nil { + return nil, err + } + out, err := cmd.Output() + if err != nil && len(out) == 0 { + return nil, err + } + return parseAntigravityModels(string(out)), nil +} + // antigravityConversationIDRe matches the glog line printmode.go writes when // the CLI dispatches the user's message — the only place in the log that // reliably surfaces the conversation UUID for both fresh and resumed turns. diff --git a/server/pkg/agent/antigravity_test.go b/server/pkg/agent/antigravity_test.go index 7e55df64f8f..7cf480806c6 100644 --- a/server/pkg/agent/antigravity_test.go +++ b/server/pkg/agent/antigravity_test.go @@ -374,7 +374,8 @@ func TestAntigravityBackendPrintTimeoutSurfacesAsTimeout(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "agy") writeTestExecutable(t, fakePath, []byte(fakeAgyPrintTimeoutScript())) - backend, err := New("antigravity", Config{ExecutablePath: fakePath, Logger: quietAntigravityLogger()}) + cfg, cwd := providerCommandTestConfig(t, fakePath, quietAntigravityLogger()) + backend, err := New("antigravity", cfg) if err != nil { t.Fatalf("new antigravity backend: %v", err) } @@ -384,7 +385,7 @@ func TestAntigravityBackendPrintTimeoutSurfacesAsTimeout(t *testing.T) { // Timeout: 0 ("no cap") so runContext never trips — the only signal that the // turn died is agy's own print-timeout marker in the log. - session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{}) + session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Cwd: cwd}) if err != nil { t.Fatalf("execute: %v", err) } @@ -421,7 +422,8 @@ func TestAntigravityBackendProviderErrorSurfacesAsFailed(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "agy") writeTestExecutable(t, fakePath, []byte(fakeAgyProviderErrorScript())) - backend, err := New("antigravity", Config{ExecutablePath: fakePath, Logger: quietAntigravityLogger()}) + cfg, cwd := providerCommandTestConfig(t, fakePath, quietAntigravityLogger()) + backend, err := New("antigravity", cfg) if err != nil { t.Fatalf("new antigravity backend: %v", err) } @@ -429,7 +431,7 @@ func TestAntigravityBackendProviderErrorSurfacesAsFailed(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{}) + session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Cwd: cwd}) if err != nil { t.Fatalf("execute: %v", err) } @@ -512,6 +514,75 @@ func TestAntigravityModelError(t *testing.T) { } } +func TestAntigravityDiscoverModelsUsesTaskLauncher(t *testing.T) { + t.Parallel() + + fakePath := filepath.Join(t.TempDir(), "agy") + writeTestExecutable(t, fakePath, []byte("#!/bin/sh\nprintf '%s\\n' 'Gemini 3.5 Flash (Medium)' 'Claude Opus 4.6 (Thinking)'\n")) + cfg, cwd := providerCommandTestConfig(t, fakePath, quietAntigravityLogger()) + launcher, ok := cfg.Launcher.(*CommandLauncher) + if !ok { + t.Fatalf("launcher = %T, want *CommandLauncher", cfg.Launcher) + } + isolation := launcher.isolation.(*recordingIsolation) + + backend := &antigravityBackend{cfg: cfg} + models, err := backend.discoverModels(t.Context(), fakePath, cwd) + if err != nil { + t.Fatalf("discover models: %v", err) + } + if len(models) != 2 || models[0].ID != "Gemini 3.5 Flash (Medium)" || models[1].ID != "Claude Opus 4.6 (Thinking)" { + t.Fatalf("models = %#v", models) + } + if isolation.executable != fakePath || !slices.Equal(isolation.args, []string{"models"}) { + t.Fatalf("launcher received (%q, %#v), want (%q, %#v)", isolation.executable, isolation.args, fakePath, []string{"models"}) + } +} + +func TestAntigravityBackendUsesTaskTempDirForLog(t *testing.T) { + t.Parallel() + + root := t.TempDir() + fakePath := filepath.Join(root, "agy") + markerPath := filepath.Join(root, "log-path") + script := `#!/bin/sh +log="" +while [ $# -gt 0 ]; do + case "$1" in + --log-file) log="$2"; shift 2 ;; + *) shift ;; + esac +done +printf '%s' "$log" > "` + markerPath + `" +printf '%s\n' 'done' +` + writeTestExecutable(t, fakePath, []byte(script)) + cfg, cwd := providerCommandTestConfig(t, fakePath, quietAntigravityLogger()) + + backend, err := New("antigravity", cfg) + if err != nil { + t.Fatalf("new antigravity backend: %v", err) + } + session, err := backend.Execute(t.Context(), "prompt-ignored", ExecOptions{Cwd: cwd, Timeout: 5 * time.Second}) + if err != nil { + t.Fatalf("execute: %v", err) + } + for range session.Messages { + } + result := <-session.Result + if result.Status != "completed" { + t.Fatalf("status = %q, error = %q", result.Status, result.Error) + } + logPath, err := os.ReadFile(markerPath) + if err != nil { + t.Fatalf("read captured log path: %v", err) + } + wantPrefix := cfg.TaskTempDir + string(filepath.Separator) + if !strings.HasPrefix(string(logPath), wantPrefix) { + t.Fatalf("log path = %q, want prefix %q", logPath, wantPrefix) + } +} + // seedAntigravityTranscript writes a transcript.jsonl under appDataDir for the // given conversation id, at the real path agy uses // (/brain//.system_generated/logs/transcript.jsonl). @@ -661,7 +732,8 @@ func TestAntigravityBackendRecoversEmptyStdoutFromTranscript(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "agy") writeTestExecutable(t, fakePath, []byte(fakeAgyEmptyStdoutScript(appDataDir, cid))) - backend, err := New("antigravity", Config{ExecutablePath: fakePath, Logger: quietAntigravityLogger()}) + cfg, cwd := providerCommandTestConfig(t, fakePath, quietAntigravityLogger()) + backend, err := New("antigravity", cfg) if err != nil { t.Fatalf("new antigravity backend: %v", err) } @@ -669,7 +741,7 @@ func TestAntigravityBackendRecoversEmptyStdoutFromTranscript(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{}) + session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Cwd: cwd}) if err != nil { t.Fatalf("execute: %v", err) } diff --git a/server/pkg/agent/claude.go b/server/pkg/agent/claude.go index 89de2d2682e..fabdb6d9fae 100644 --- a/server/pkg/agent/claude.go +++ b/server/pkg/agent/claude.go @@ -26,7 +26,8 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt if execPath == "" { execPath = "claude" } - if _, err := exec.LookPath(execPath); err != nil { + resolvedExecPath, err := exec.LookPath(execPath) + if err != nil { return nil, fmt.Errorf("claude executable not found at %q: %w", execPath, err) } @@ -41,7 +42,7 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt var mcpConfigPath string var mcpFileCleanup func() // non-nil while this function owns the temp file if hasManagedMcpConfig(opts.McpConfig) { - path, err := writeMcpConfigToTemp(opts.McpConfig) + path, err := writeMcpConfigToTemp(b.cfg.TaskTempDir, opts.McpConfig) if err != nil { cancel() return nil, err @@ -57,15 +58,13 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt } }() - cmd := exec.CommandContext(runCtx, execPath, args...) - hideAgentWindow(cmd) - b.cfg.Logger.Info("agent command", "exec", execPath, "args", args) - cmd.WaitDelay = 10 * time.Second - if opts.Cwd != "" { - cmd.Dir = opts.Cwd + cmd, err := b.cfg.command(runCtx, resolvedExecPath, args, opts.Cwd, 10*time.Second) + if err != nil { + cancel() + return nil, fmt.Errorf("build claude command: %w", err) } - cmd.Env = buildEnv(b.cfg.Env) - if err := claudeRootSudoPreflight(args, cmd.Env); err != nil { + b.cfg.Logger.Info("agent command", "exec", execPath, "args", args) + if err := claudeRootSudoPreflight(args, cmd.Environment()); err != nil { cancel() return nil, err } @@ -88,7 +87,7 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt // like "claude exited with error: exit status 3" — which is useless for // root-causing V8 aborts, Bun panics, or any other CLI-side crash. stderrBuf := newStderrTail(newLogWriter(b.cfg.Logger, "[claude:stderr] "), agentStderrTailBytes) - cmd.Stderr = stderrBuf + _ = cmd.SetStderr(stderrBuf) if err := cmd.Start(); err != nil { closeStdin() @@ -96,7 +95,7 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt return nil, fmt.Errorf("start claude: %w", err) } - b.cfg.Logger.Info("claude started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model) + b.cfg.Logger.Info("claude started", "pid", cmd.Process().Pid, "cwd", opts.Cwd, "model", opts.Model) // cmd.Start() succeeded — transfer temp file ownership to the goroutine. mcpFileCleanup = nil @@ -278,7 +277,7 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt anthropicBaseURLConfigured: strings.TrimSpace(b.cfg.Env["ANTHROPIC_BASE_URL"]) != "", }) - b.cfg.Logger.Info("claude finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) + b.cfg.Logger.Info("claude finished", "pid", cmd.Process().Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) reportedSessionID := resolveSessionID(opts.ResumeSessionID, sessionID, finalStatus == "failed", stderrTail) if reportedSessionID != sessionID { @@ -704,7 +703,7 @@ func claudeNoConversationFound(stderrTail ...string) bool { } func buildEnv(extra map[string]string) []string { - return mergeEnv(os.Environ(), extra) + return mergeEnv(nil, extra) } func claudeRootSudoPreflight(args, env []string) error { @@ -885,10 +884,13 @@ func stripSurroundingQuotes(s string) (string, bool) { return s, false } -// writeMcpConfigToTemp writes MCP config JSON to a temporary file and returns -// its path. The caller is responsible for removing it via cleanupMcpConfigTemp. -func writeMcpConfigToTemp(raw json.RawMessage) (string, error) { - dir, err := os.MkdirTemp("", "multica-mcp-*") +// writeMcpConfigToTemp writes MCP config JSON beneath the task temp directory +// and returns its path. The caller removes it via cleanupMcpConfigTemp. +func writeMcpConfigToTemp(taskTempDir string, raw json.RawMessage) (string, error) { + if taskTempDir == "" { + return "", fmt.Errorf("task temp dir is required for mcp config") + } + dir, err := os.MkdirTemp(taskTempDir, "multica-mcp-*") if err != nil { return "", fmt.Errorf("create mcp config temp dir: %w", err) } @@ -930,17 +932,22 @@ func cleanupMcpConfigTemp(path string) { var detectVersionTimeout = 10 * time.Second func detectCLIVersion(ctx context.Context, execPath string) (string, error) { + return detectCLIVersionWithRunner(ctx, execPath, operatorModelDiscoveryRunner()) +} + +func detectCLIVersionWithRunner(ctx context.Context, execPath string, runner modelDiscoveryRunner) (string, error) { ctx, cancel := context.WithTimeout(ctx, detectVersionTimeout) defer cancel() - cmd := exec.CommandContext(ctx, execPath, "--version") - hideAgentWindow(cmd) + cmd, err := runner.command(ctx, execPath, []string{"--version"}, 2*time.Second) + if err != nil { + return "", fmt.Errorf("build version command for %s: %w", execPath, err) + } // exec.CommandContext only kills the direct child on timeout. A broken CLI // (node/bun shim) can leave grandchildren that inherited and still hold our // stdout pipe open, and cmd.Output() blocks in Wait() until that pipe // closes — defeating the timeout above. WaitDelay forces the pipes shut and // reaps shortly after the context fires so this call always returns. - cmd.WaitDelay = 2 * time.Second data, err := cmd.Output() if err != nil { return "", fmt.Errorf("detect version for %s: %w", execPath, err) diff --git a/server/pkg/agent/claude_deadlock_test.go b/server/pkg/agent/claude_deadlock_test.go index afb87cc2932..f4b941dda1d 100644 --- a/server/pkg/agent/claude_deadlock_test.go +++ b/server/pkg/agent/claude_deadlock_test.go @@ -7,11 +7,38 @@ import ( "fmt" "log/slog" "os" + "os/exec" "strings" "testing" "time" ) +type claudeDeadlockTestLauncher struct{} + +func (claudeDeadlockTestLauncher) Command(ctx context.Context, req CommandRequest) (TaskCommand, error) { + cmd := exec.CommandContext(ctx, req.Executable, req.Args...) + cmd.Dir = req.Cwd + var err error + cmd.Env, err = explicitEnvironment(req.Env) + if err != nil { + return nil, err + } + cmd.WaitDelay = req.WaitDelay + if len(req.LeadingExtraFiles) > 0 { + cmd.ExtraFiles = append([]*os.File(nil), req.LeadingExtraFiles...) + } + return newPreparedCommand(cmd), nil +} + +func claudeDeadlockConfig(executable, mode string) Config { + return Config{ + ExecutablePath: executable, + Env: map[string]string{"CLAUDE_FAKE_MODE": mode, "IS_SANDBOX": "1"}, + Logger: slog.Default(), + Launcher: claudeDeadlockTestLauncher{}, + } +} + // TestMain intercepts when the test binary is re-executed as a fake // child process by the agent backend. The fake's behavior is selected via // CLAUDE_FAKE_MODE; absent that env var, this is a normal `go test` run. @@ -165,11 +192,7 @@ func TestClaudeExecuteDoesNotDeadlockOnStartupStdoutBurst(t *testing.T) { t.Fatalf("os.Executable: %v", err) } - backend, err := New("claude", Config{ - ExecutablePath: self, - Env: map[string]string{"CLAUDE_FAKE_MODE": "startup_stdout_burst", "IS_SANDBOX": "1"}, - Logger: slog.Default(), - }) + backend, err := New("claude", claudeDeadlockConfig(self, "startup_stdout_burst")) if err != nil { t.Fatalf("new claude backend: %v", err) } @@ -212,11 +235,7 @@ func TestClaudeExecuteRespondsToControlRequest(t *testing.T) { t.Fatalf("os.Executable: %v", err) } - backend, err := New("claude", Config{ - ExecutablePath: self, - Env: map[string]string{"CLAUDE_FAKE_MODE": "control_request", "IS_SANDBOX": "1"}, - Logger: slog.Default(), - }) + backend, err := New("claude", claudeDeadlockConfig(self, "control_request")) if err != nil { t.Fatalf("new claude backend: %v", err) } @@ -260,11 +279,7 @@ func TestClaudeExecuteForcesBackgroundControlRequestForeground(t *testing.T) { t.Fatalf("os.Executable: %v", err) } - backend, err := New("claude", Config{ - ExecutablePath: self, - Env: map[string]string{"CLAUDE_FAKE_MODE": "background_control_request", "IS_SANDBOX": "1"}, - Logger: slog.Default(), - }) + backend, err := New("claude", claudeDeadlockConfig(self, "background_control_request")) if err != nil { t.Fatalf("new claude backend: %v", err) } @@ -308,11 +323,7 @@ func TestClaudeExecuteFailsLoudlyOnAsyncLaunchedToolResult(t *testing.T) { t.Fatalf("os.Executable: %v", err) } - backend, err := New("claude", Config{ - ExecutablePath: self, - Env: map[string]string{"CLAUDE_FAKE_MODE": "async_launched_tool_result", "IS_SANDBOX": "1"}, - Logger: slog.Default(), - }) + backend, err := New("claude", claudeDeadlockConfig(self, "async_launched_tool_result")) if err != nil { t.Fatalf("new claude backend: %v", err) } diff --git a/server/pkg/agent/claude_test.go b/server/pkg/agent/claude_test.go index 7b0a2631341..6cf6e27e57b 100644 --- a/server/pkg/agent/claude_test.go +++ b/server/pkg/agent/claude_test.go @@ -656,8 +656,28 @@ func TestBuildEnvNilExtras(t *testing.T) { t.Parallel() env := buildEnv(nil) - if len(env) == 0 { - t.Fatal("expected at least system env vars") + if len(env) != 0 { + t.Fatalf("expected no inherited environment, got %v", env) + } +} + +func TestBuildEnvAppendsTaskHomeAfterInheritedHome(t *testing.T) { + t.Parallel() + + const privateHome = "/task/private/home" + env := mergeEnv([]string{"HOME=/human/home", "PATH=/usr/bin"}, map[string]string{"HOME": privateHome}) + + var homes []string + for _, entry := range env { + if strings.HasPrefix(entry, "HOME=") { + homes = append(homes, strings.TrimPrefix(entry, "HOME=")) + } + } + if len(homes) != 2 { + t.Fatalf("HOME entries = %v, want inherited and task-private values", homes) + } + if got := homes[len(homes)-1]; got != privateHome { + t.Fatalf("last HOME = %q, want task-private HOME %q", got, privateHome) } } @@ -770,10 +790,14 @@ func TestWriteMcpConfigToTemp(t *testing.T) { t.Parallel() raw := json.RawMessage(`{"mcpServers":{"test":{"command":"echo","args":["hello"]}}}`) - path, err := writeMcpConfigToTemp(raw) + taskTempDir := t.TempDir() + path, err := writeMcpConfigToTemp(taskTempDir, raw) if err != nil { t.Fatalf("writeMcpConfigToTemp: %v", err) } + if !pathWithin(path, taskTempDir) { + t.Fatalf("mcp config path %q must be beneath task temp dir %q", path, taskTempDir) + } // File should exist and contain exactly the raw JSON. data, err := os.ReadFile(path) @@ -791,6 +815,62 @@ func TestWriteMcpConfigToTemp(t *testing.T) { } } +func TestClaudeExecuteUsesTaskLauncherAndCleansManagedMcpTemp(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("shell-script fixture is POSIX-only") + } + + root := t.TempDir() + fakePath := filepath.Join(root, "claude") + script := "#!/bin/sh\n" + + "IFS= read -r _\n" + + `printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"session_id":"sess-mcp","result":"done"}'` + "\n" + writeTestExecutable(t, fakePath, []byte(script)) + + isolation := &recordingIsolation{} + backend := &claudeBackend{cfg: Config{ + ExecutablePath: fakePath, + Env: map[string]string{"IS_SANDBOX": "1"}, + Logger: slog.Default(), + Launcher: newCommandLauncher(isolation), + Isolation: &TaskIsolationPolicy{ + WritableRoots: []string{root}, + SystemRoots: existingSystemRootsForTest(t), + }, + TaskTempDir: root, + }} + + session, err := backend.Execute(context.Background(), "prompt", ExecOptions{ + Cwd: root, + Timeout: 5 * time.Second, + McpConfig: json.RawMessage(`{"mcpServers":{}}`), + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + for range session.Messages { + } + result := <-session.Result + if result.Status != "completed" { + t.Fatalf("result status = %q, error = %q", result.Status, result.Error) + } + if isolation.executable != fakePath { + t.Fatalf("launcher executable = %q, want %q", isolation.executable, fakePath) + } + mcpArg := slices.Index(isolation.args, "--mcp-config") + if mcpArg == -1 || mcpArg+1 >= len(isolation.args) { + t.Fatalf("launcher args missing managed MCP path: %v", isolation.args) + } + mcpPath := isolation.args[mcpArg+1] + if !pathWithin(mcpPath, root) { + t.Fatalf("managed MCP path %q must be beneath task temp dir %q", mcpPath, root) + } + if _, err := os.Stat(mcpPath); !os.IsNotExist(err) { + t.Fatalf("managed MCP temp was not cleaned up: stat error = %v", err) + } +} + func TestResolveSessionID(t *testing.T) { t.Parallel() @@ -896,6 +976,11 @@ func TestClaudeExecuteSurfacesStderrWhenChildExitsEarly(t *testing.T) { ExecutablePath: fakePath, Env: map[string]string{"IS_SANDBOX": "1"}, Logger: slog.Default(), + Launcher: newCommandLauncher(&recordingIsolation{}), + Isolation: &TaskIsolationPolicy{ + WritableRoots: []string{filepath.Dir(fakePath)}, + SystemRoots: existingSystemRootsForTest(t), + }, }) if err != nil { t.Fatalf("new claude backend: %v", err) @@ -903,7 +988,7 @@ func TestClaudeExecuteSurfacesStderrWhenChildExitsEarly(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second}) + session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Cwd: filepath.Dir(fakePath), Timeout: 5 * time.Second}) if err != nil { t.Fatalf("execute: %v", err) } @@ -952,6 +1037,11 @@ func TestClaudeExecuteRecordsResultModelUsage(t *testing.T) { ExecutablePath: fakePath, Env: map[string]string{"IS_SANDBOX": "1"}, Logger: slog.Default(), + Launcher: newCommandLauncher(&recordingIsolation{}), + Isolation: &TaskIsolationPolicy{ + WritableRoots: []string{filepath.Dir(fakePath)}, + SystemRoots: existingSystemRootsForTest(t), + }, }) if err != nil { t.Fatalf("new claude backend: %v", err) @@ -959,7 +1049,7 @@ func TestClaudeExecuteRecordsResultModelUsage(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second}) + session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Cwd: filepath.Dir(fakePath), Timeout: 5 * time.Second}) if err != nil { t.Fatalf("execute: %v", err) } diff --git a/server/pkg/agent/codebuddy.go b/server/pkg/agent/codebuddy.go index c41892e841c..3130e328f0d 100644 --- a/server/pkg/agent/codebuddy.go +++ b/server/pkg/agent/codebuddy.go @@ -71,7 +71,8 @@ func (b *codebuddyBackend) Execute(ctx context.Context, prompt string, opts Exec if execPath == "" { execPath = "codebuddy" } - if _, err := exec.LookPath(execPath); err != nil { + resolvedExecPath, err := exec.LookPath(execPath) + if err != nil { return nil, fmt.Errorf("codebuddy executable not found at %q: %w", execPath, err) } @@ -85,7 +86,7 @@ func (b *codebuddyBackend) Execute(ctx context.Context, prompt string, opts Exec var mcpConfigPath string var mcpFileCleanup func() if len(opts.McpConfig) > 0 { - path, err := writeMcpConfigToTemp(opts.McpConfig) + path, err := writeMcpConfigToTemp(b.cfg.TaskTempDir, opts.McpConfig) if err != nil { cancel() return nil, err @@ -101,14 +102,12 @@ func (b *codebuddyBackend) Execute(ctx context.Context, prompt string, opts Exec } }() - cmd := exec.CommandContext(runCtx, execPath, args...) - hideAgentWindow(cmd) - b.cfg.Logger.Info("agent command", "exec", execPath, "args", args) - cmd.WaitDelay = 10 * time.Second - if opts.Cwd != "" { - cmd.Dir = opts.Cwd + cmd, err := b.cfg.command(runCtx, resolvedExecPath, args, opts.Cwd, 10*time.Second) + if err != nil { + cancel() + return nil, fmt.Errorf("build codebuddy command: %w", err) } - cmd.Env = buildEnv(b.cfg.Env) + b.cfg.Logger.Info("agent command", "exec", execPath, "args", args) stdout, err := cmd.StdoutPipe() if err != nil { @@ -124,7 +123,7 @@ func (b *codebuddyBackend) Execute(ctx context.Context, prompt string, opts Exec closeStdin := func() { closeStdinOnce.Do(func() { _ = stdin.Close() }) } stderrBuf := newStderrTail(newLogWriter(b.cfg.Logger, "[codebuddy:stderr] "), agentStderrTailBytes) - cmd.Stderr = stderrBuf + _ = cmd.SetStderr(stderrBuf) if err := cmd.Start(); err != nil { closeStdin() @@ -132,7 +131,7 @@ func (b *codebuddyBackend) Execute(ctx context.Context, prompt string, opts Exec return nil, fmt.Errorf("start codebuddy: %w", err) } - b.cfg.Logger.Info("codebuddy started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model) + b.cfg.Logger.Info("codebuddy started", "pid", cmd.Process().Pid, "cwd", opts.Cwd, "model", opts.Model) // cmd.Start() succeeded — transfer temp file ownership to the goroutine. mcpFileCleanup = nil @@ -291,7 +290,7 @@ func (b *codebuddyBackend) Execute(ctx context.Context, prompt string, opts Exec anthropicBaseURLConfigured: strings.TrimSpace(b.cfg.Env["ANTHROPIC_BASE_URL"]) != "", }) - b.cfg.Logger.Info("codebuddy finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) + b.cfg.Logger.Info("codebuddy finished", "pid", cmd.Process().Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) reportedSessionID := resolveSessionID(opts.ResumeSessionID, sessionID, finalStatus == "failed") if reportedSessionID != sessionID { diff --git a/server/pkg/agent/codebuddy_test.go b/server/pkg/agent/codebuddy_test.go index a8aeac93ae9..39ec83a2f82 100644 --- a/server/pkg/agent/codebuddy_test.go +++ b/server/pkg/agent/codebuddy_test.go @@ -158,12 +158,21 @@ func TestCodebuddyExecute_Success(t *testing.T) { `printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"session_id":"sess-cb-001","result":"Hello from codebuddy","modelUsage":{"claude-sonnet-4-20250514":{"inputTokens":100,"outputTokens":50,"cacheReadInputTokens":10,"cacheCreationInputTokens":5}}}'` + "\n" writeTestExecutable(t, fakePath, []byte(script)) - b := &codebuddyBackend{cfg: Config{ExecutablePath: fakePath, Logger: slog.Default()}} + isolation := &recordingIsolation{} + b := &codebuddyBackend{cfg: Config{ + ExecutablePath: fakePath, + Logger: slog.Default(), + Launcher: newCommandLauncher(isolation), + Isolation: &TaskIsolationPolicy{ + WritableRoots: []string{filepath.Dir(fakePath)}, + SystemRoots: existingSystemRootsForTest(t), + }, + }} ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - session, err := b.Execute(ctx, "say hello", ExecOptions{Timeout: 5 * time.Second}) + session, err := b.Execute(ctx, "say hello", ExecOptions{Cwd: filepath.Dir(fakePath), Timeout: 5 * time.Second}) if err != nil { t.Fatalf("execute: %v", err) } @@ -178,6 +187,9 @@ func TestCodebuddyExecute_Success(t *testing.T) { if !gotText { t.Fatal("expected text message 'Hello from codebuddy'") } + if isolation.executable != fakePath { + t.Fatalf("launcher executable = %q, want %q", isolation.executable, fakePath) + } select { case result, ok := <-session.Result: @@ -233,12 +245,20 @@ func TestCodebuddyExecuteSurfacesStderr(t *testing.T) { "exit 1\n" writeTestExecutable(t, fakePath, []byte(script)) - b := &codebuddyBackend{cfg: Config{ExecutablePath: fakePath, Logger: slog.Default()}} + b := &codebuddyBackend{cfg: Config{ + ExecutablePath: fakePath, + Logger: slog.Default(), + Launcher: newCommandLauncher(&recordingIsolation{}), + Isolation: &TaskIsolationPolicy{ + WritableRoots: []string{filepath.Dir(fakePath)}, + SystemRoots: existingSystemRootsForTest(t), + }, + }} ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - session, err := b.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second}) + session, err := b.Execute(ctx, "prompt-ignored", ExecOptions{Cwd: filepath.Dir(fakePath), Timeout: 5 * time.Second}) if err != nil { t.Fatalf("execute: %v", err) } diff --git a/server/pkg/agent/codex.go b/server/pkg/agent/codex.go index 0c8ada0c592..ef6947a7836 100644 --- a/server/pkg/agent/codex.go +++ b/server/pkg/agent/codex.go @@ -649,9 +649,11 @@ func (b *codexBackend) executeOnce(ctx context.Context, prompt string, opts Exec if execPath == "" { execPath = "codex" } - if _, err := exec.LookPath(execPath); err != nil { + resolved, err := exec.LookPath(execPath) + if err != nil { return nil, fmt.Errorf("codex executable not found at %q: %w", execPath, err) } + execPath = resolved timeout := opts.Timeout semanticInactivityTimeout := opts.SemanticInactivityTimeout @@ -694,37 +696,33 @@ func (b *codexBackend) executeOnce(ctx context.Context, prompt string, opts Exec } codexArgs := buildCodexArgs(opts, b.cfg.Logger) - cmd := exec.CommandContext(runCtx, execPath, codexArgs...) - hideAgentWindow(cmd) - // Run codex in its own process group so a cancel-on-stuck cleanup - // reaches the whole tree — the codex Node wrapper plus the native - // Rust app-server it spawns — not just the direct child. Without - // this, killing the leader leaves grandchildren as orphans that - // keep consuming memory until the OS reaps them; see #4520, where a - // scanner overflow during thread/resume otherwise leaked Codex - // processes indefinitely. configureProcessGroup is a no-op on - // Windows. - configureProcessGroup(cmd) + cmd, err := b.cfg.command(runCtx, execPath, codexArgs, opts.Cwd, 10*time.Second) + if err != nil { + cancel() + return nil, fmt.Errorf("prepare codex command: %w", err) + } + // cfg.command runs codex in its own process group so a cancel-on-stuck + // cleanup reaches the whole tree — the codex Node wrapper plus the native + // Rust app-server it spawns — not just the direct child. Without this, + // killing the leader leaves grandchildren as orphans that keep consuming + // memory until the OS reaps them; see #4520, where a scanner overflow during + // thread/resume otherwise leaked Codex processes indefinitely. // Override the default exec.CommandContext cancel behaviour. The // default sends SIGKILL only to cmd.Process (the leader); we instead // signal the whole process group so descendants die too. Returning - // nil keeps exec from logging a spurious error; cmd.WaitDelay below - // still backstops cmd.Wait() if the kill leaves an open pipe. - cmd.Cancel = func() error { - if cmd.Process != nil { - signalProcessGroup(cmd.Process, syscall.SIGKILL) + // nil keeps exec from logging a spurious error; the WaitDelay supplied + // through cfg.command still backstops cmd.Wait() if the kill leaves an + // open pipe. + if err := cmd.SetCancel(func() error { + if cmd.Process() != nil { + signalProcessGroup(cmd.Process(), syscall.SIGKILL) } return nil + }); err != nil { + cancel() + return nil, fmt.Errorf("configure codex cancellation: %w", err) } - // Bound the wait after the context is cancelled so a stuck child (or an - // open pipe held by a grandchild) can't hang cmd.Wait() forever. Matches - // the other long-lived backends (claude, copilot, cursor, …). - cmd.WaitDelay = 10 * time.Second b.cfg.Logger.Info("agent command", "exec", execPath, "args", codexArgs) - if opts.Cwd != "" { - cmd.Dir = opts.Cwd - } - cmd.Env = buildEnv(b.cfg.Env) stdout, err := cmd.StdoutPipe() if err != nil { @@ -739,12 +737,21 @@ func (b *codexBackend) executeOnce(ctx context.Context, prompt string, opts Exec // Codex stderr can contain auth/provider diagnostics. Capture a bounded // tail and emit it only through the sanitizer in the cleanup event. stderrBuf := newStderrTail(io.Discard, codexStderrTailBytes) - cmd.Stderr = stderrBuf + if err := cmd.SetStderr(stderrBuf); err != nil { + cancel() + return nil, fmt.Errorf("configure codex stderr: %w", err) + } if err := cmd.Start(); err != nil { cancel() return nil, fmt.Errorf("start codex: %w", err) } + process := cmd.Process() + if process == nil { + cancel() + return nil, fmt.Errorf("start codex: process identity is unavailable") + } + pid := process.Pid activeLaunches := activeCodexLaunches.Add(1) for { maxSeen := maxActiveCodexLaunchesObserved.Load() @@ -758,7 +765,7 @@ func (b *codexBackend) executeOnce(ctx context.Context, prompt string, opts Exec codexVersion = "unknown" } - b.cfg.Logger.Info("codex lifecycle", "phase", "spawn", "task_id", b.cfg.TaskID, "runtime_id", b.cfg.RuntimeID, "pid", cmd.Process.Pid, "process_group", cmd.Process.Pid, "cwd", opts.Cwd, "attempt", attempt, "active_launches", activeLaunches, "codex_version", codexVersion, "daemon_version", b.cfg.DaemonVersion) + b.cfg.Logger.Info("codex lifecycle", "phase", "spawn", "task_id", b.cfg.TaskID, "runtime_id", b.cfg.RuntimeID, "pid", pid, "process_group", pid, "cwd", opts.Cwd, "attempt", attempt, "active_launches", activeLaunches, "codex_version", codexVersion, "daemon_version", b.cfg.DaemonVersion) msgCh := make(chan Message, 256) resCh := make(chan Result, 1) @@ -889,7 +896,7 @@ func (b *codexBackend) executeOnce(ctx context.Context, prompt string, opts Exec // group-kills the tree, the reader unblocks when stdout // EOFs, and we proceed to phase 2. b.cfg.Logger.Warn("codex did not close stdout after stdin EOF; forcing shutdown", - "pid", cmd.Process.Pid, + "pid", pid, "grace", grace.String(), ) cancel() @@ -910,7 +917,7 @@ func (b *codexBackend) executeOnce(ctx context.Context, prompt string, opts Exec // reaped cleanly. case <-time.After(grace): b.cfg.Logger.Warn("codex process still alive after reader exited; forcing shutdown", - "pid", cmd.Process.Pid, + "pid", pid, "grace", grace.String(), ) cancel() @@ -924,7 +931,8 @@ func (b *codexBackend) executeOnce(ctx context.Context, prompt string, opts Exec // Wait returning with a ProcessState is the os/exec reap boundary. // On Unix, ProcessState.Exited reports false for a process terminated // by SIGKILL even though Wait successfully reaped it. - cleanupConfirmed = waitReturned && cmd.ProcessState != nil + processState := cmd.ProcessState() + cleanupConfirmed = waitReturned && processState != nil if codexCleanupConfirmationOverride.Load() < 0 { cleanupConfirmed = false } @@ -932,12 +940,12 @@ func (b *codexBackend) executeOnce(ctx context.Context, prompt string, opts Exec "phase", "cleanup", "task_id", b.cfg.TaskID, "runtime_id", b.cfg.RuntimeID, - "pid", cmd.Process.Pid, - "process_group", cmd.Process.Pid, + "pid", pid, + "process_group", pid, "attempt", attempt, "latency", time.Since(launchStarted).Round(time.Millisecond).String(), "reaped", cleanupConfirmed, - "exit_status", codexProcessExitStatus(cmd.ProcessState), + "exit_status", codexProcessExitStatus(processState), "wait_error", cleanupWaitErr, "stderr_bytes", stderrBuf.TotalBytes(), "stderr_truncated", stderrBuf.TotalBytes() > codexStderrTailBytes, @@ -963,7 +971,7 @@ func (b *codexBackend) executeOnce(ctx context.Context, prompt string, opts Exec // 1. Initialize handshake initializeStarted := time.Now() - b.cfg.Logger.Info("codex lifecycle", "phase", "initialize_sent", "task_id", b.cfg.TaskID, "runtime_id", b.cfg.RuntimeID, "pid", cmd.Process.Pid, "attempt", attempt, "active_launches", activeLaunches) + b.cfg.Logger.Info("codex lifecycle", "phase", "initialize_sent", "task_id", b.cfg.TaskID, "runtime_id", b.cfg.RuntimeID, "pid", pid, "attempt", attempt, "active_launches", activeLaunches) _, err := c.request(runCtx, "initialize", map[string]any{ "clientInfo": map[string]any{ "name": "multica-agent-sdk", @@ -986,11 +994,11 @@ func (b *codexBackend) executeOnce(ctx context.Context, prompt string, opts Exec } else if errors.As(err, &handshakeErr) && handshakeErr.Method == "initialize" && cleanupConfirmed && !codexInitializeRetrySupported() { finalError += "; retry suppressed: process-tree cleanup cannot be confirmed on this platform" } - b.cfg.Logger.Warn("codex lifecycle", "phase", "initialize_failure", "task_id", b.cfg.TaskID, "runtime_id", b.cfg.RuntimeID, "pid", cmd.Process.Pid, "attempt", attempt, "latency", initializeLatency.Round(time.Millisecond).String(), "semantic_activity", semanticObserved.Load(), "cleanup_confirmed", cleanupConfirmed, "retry_safe", retrySafe) + b.cfg.Logger.Warn("codex lifecycle", "phase", "initialize_failure", "task_id", b.cfg.TaskID, "runtime_id", b.cfg.RuntimeID, "pid", pid, "attempt", attempt, "latency", initializeLatency.Round(time.Millisecond).String(), "semantic_activity", semanticObserved.Load(), "cleanup_confirmed", cleanupConfirmed, "retry_safe", retrySafe) resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds(), codexInitializeRetrySafe: retrySafe} return } - b.cfg.Logger.Info("codex lifecycle", "phase", "initialize_response", "task_id", b.cfg.TaskID, "runtime_id", b.cfg.RuntimeID, "pid", cmd.Process.Pid, "attempt", attempt, "latency", time.Since(initializeStarted).Round(time.Millisecond).String()) + b.cfg.Logger.Info("codex lifecycle", "phase", "initialize_response", "task_id", b.cfg.TaskID, "runtime_id", b.cfg.RuntimeID, "pid", pid, "attempt", attempt, "latency", time.Since(initializeStarted).Round(time.Millisecond).String()) c.notify("initialized") // 2. Start a new thread, or resume the prior one for this issue. When @@ -1119,7 +1127,7 @@ func (b *codexBackend) executeOnce(ctx context.Context, prompt string, opts Exec Model: opts.Model, } b.cfg.Logger.Warn(CodexFirstTurnNoProgressMarker, - "pid", cmd.Process.Pid, + "pid", pid, "thread_id", threadID, "turn_id", c.turnID, "timeout", firstTurnNoProgressTimeout.String(), @@ -1137,7 +1145,7 @@ func (b *codexBackend) executeOnce(ctx context.Context, prompt string, opts Exec Model: opts.Model, } b.cfg.Logger.Warn(CodexSemanticInactivityMarker, - "pid", cmd.Process.Pid, + "pid", pid, "thread_id", threadID, "turn_id", c.turnID, "timeout", semanticInactivityTimeout.String(), @@ -1167,7 +1175,7 @@ func (b *codexBackend) executeOnce(ctx context.Context, prompt string, opts Exec } duration := time.Since(startTime) - b.cfg.Logger.Info("codex finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) + b.cfg.Logger.Info("codex finished", "pid", pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) // Run cleanup. drainAndWait handles the graceful-then-cancel pattern // in two bounded phases (see its declaration): wait for the reader, @@ -1180,7 +1188,7 @@ func (b *codexBackend) executeOnce(ctx context.Context, prompt string, opts Exec finalError = withAgentStderr(processExitErr.Error(), "codex", sanitizeCodexDiagnostic(stderrBuf.Tail())) } if timeoutDiagnostic.Kind != codexTimeoutNone { - timeoutDiagnostic.CodexVersion = detectCodexVersionForDiagnostics(context.Background(), execPath, cmd.Env, b.cfg.Logger) + timeoutDiagnostic.CodexVersion = detectCodexVersionForDiagnostics(context.Background(), b.cfg, execPath, opts.Cwd, b.cfg.Logger) finalError = buildCodexTimeoutDiagnosticError(timeoutDiagnostic, sanitizeCodexDiagnostic(stderrBuf.Tail())) } @@ -1461,12 +1469,25 @@ func appendCodexKnownStderrHint(msg, stderrTail string) string { return msg } -func detectCodexVersionForDiagnostics(ctx context.Context, execPath string, env []string, logger *slog.Logger) string { +func detectCodexVersionForDiagnostics(ctx context.Context, cfg Config, execPath, cwd string, logger *slog.Logger) string { versionCtx, cancel := context.WithTimeout(ctx, codexVersionDiagnosticTimeout) defer cancel() - cmd := exec.CommandContext(versionCtx, execPath, "--version") - cmd.Env = env + cmd, err := cfg.command(versionCtx, execPath, []string{"--version"}, cwd, 2*time.Second) + if err != nil { + if logger != nil { + logger.Debug("codex version diagnostic command failed", "error", err) + } + return "unknown" + } + if err := cmd.SetCancel(func() error { + if cmd.Process() != nil { + signalProcessGroup(cmd.Process(), syscall.SIGKILL) + } + return nil + }); err != nil { + return "unknown" + } data, err := cmd.Output() if err != nil { if logger != nil { diff --git a/server/pkg/agent/codex_test.go b/server/pkg/agent/codex_test.go index ba5e6bd0b33..6849b843f63 100644 --- a/server/pkg/agent/codex_test.go +++ b/server/pkg/agent/codex_test.go @@ -17,6 +17,35 @@ import ( "time" ) +type codexTestIsolation struct{} + +func (*codexTestIsolation) WrapBound(_ *boundIsolationPolicy, executable, cwd pathIdentity, args []string, leadingExtraFiles int) (string, []string, []*os.File, error) { + _ = cwd + _ = leadingExtraFiles + return executable.Path, args, nil, nil +} + +func codexTestConfig(t *testing.T, executable, cwd string, env map[string]string) Config { + t.Helper() + taskEnv := make(map[string]string, len(env)+1) + for key, value := range env { + taskEnv[key] = value + } + if taskEnv["PATH"] == "" { + taskEnv["PATH"] = os.Getenv("PATH") + } + return Config{ + ExecutablePath: executable, + Logger: slog.Default(), + Env: taskEnv, + Launcher: newCommandLauncher(&codexTestIsolation{}), + Isolation: &TaskIsolationPolicy{ + WritableRoots: []string{filepath.Dir(executable), cwd}, + Network: NetworkAccessPublicAndLoopback, + }, + } +} + func newTestCodexClient(t *testing.T) (*codexClient, *fakeStdin, []Message) { t.Helper() fs := &fakeStdin{} @@ -1559,14 +1588,15 @@ func TestCodexExecuteSurfacesStderrWhenChildExitsEarly(t *testing.T) { "exit 2\n" writeTestExecutable(t, fakePath, []byte(script)) - backend, err := New("codex", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + workDir := t.TempDir() + backend, err := New("codex", codexTestConfig(t, fakePath, workDir, nil)) if err != nil { t.Fatalf("new codex backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second}) + session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Cwd: workDir, Timeout: 5 * time.Second}) if err != nil { t.Fatalf("execute: %v", err) } @@ -1798,8 +1828,8 @@ func TestCodexExecuteDoesNotSerializeConcurrentLaunches(t *testing.T) { results := make(chan Result, 2) for i := 0; i < 2; i++ { go func() { - backend, _ := New("codex", Config{ExecutablePath: fakePath, Logger: slog.Default()}) - session, err := backend.Execute(context.Background(), "prompt", ExecOptions{Timeout: 5 * time.Second}) + backend, _ := New("codex", codexTestConfig(t, fakePath, filepath.Dir(fakePath), nil)) + session, err := backend.Execute(context.Background(), "prompt", ExecOptions{Cwd: filepath.Dir(fakePath), Timeout: 5 * time.Second}) if err != nil { results <- Result{Status: "failed", Error: err.Error()} return @@ -1848,11 +1878,13 @@ func TestCodexExecuteRedactsStderrFromResultAndLogs(t *testing.T) { `exit 2`+"\n") var logs bytes.Buffer logger := slog.New(slog.NewJSONHandler(&logs, nil)) - backend, err := New("codex", Config{ExecutablePath: fakePath, Logger: logger}) + cfg := codexTestConfig(t, fakePath, filepath.Dir(fakePath), nil) + cfg.Logger = logger + backend, err := New("codex", cfg) if err != nil { t.Fatal(err) } - session, err := backend.Execute(context.Background(), "prompt", ExecOptions{Timeout: 5 * time.Second}) + session, err := backend.Execute(context.Background(), "prompt", ExecOptions{Cwd: filepath.Dir(fakePath), Timeout: 5 * time.Second}) if err != nil { t.Fatal(err) } @@ -1889,12 +1921,14 @@ func TestCodexExecuteDoesNotProbeVersionBeforeInitialize(t *testing.T) { data = bytes.Replace(data, []byte(`echo "codex-cli 0.0.0-test"; exit 0`), []byte(`sleep 2; echo "codex-cli 0.0.0-test"; exit 0`), 1) writeTestExecutable(t, fakePath, data) - backend, err := New("codex", Config{ExecutablePath: fakePath, Logger: slog.Default(), CodexVersion: "cached-test-version"}) + cfg := codexTestConfig(t, fakePath, filepath.Dir(fakePath), nil) + cfg.CodexVersion = "cached-test-version" + backend, err := New("codex", cfg) if err != nil { t.Fatal(err) } started := time.Now() - session, err := backend.Execute(context.Background(), "prompt", ExecOptions{Timeout: 5 * time.Second}) + session, err := backend.Execute(context.Background(), "prompt", ExecOptions{Cwd: filepath.Dir(fakePath), Timeout: 5 * time.Second}) if err != nil { t.Fatal(err) } @@ -2392,7 +2426,10 @@ func writeFakeCodexAppServer(t *testing.T, body string) string { func executeFakeCodex(t *testing.T, fakePath string, opts ExecOptions) Result { t.Helper() - backend, err := New("codex", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + if opts.Cwd == "" { + opts.Cwd = t.TempDir() + } + backend, err := New("codex", codexTestConfig(t, fakePath, opts.Cwd, nil)) if err != nil { t.Fatalf("new codex backend: %v", err) } diff --git a/server/pkg/agent/command.go b/server/pkg/agent/command.go new file mode 100644 index 00000000000..2362a3bec97 --- /dev/null +++ b/server/pkg/agent/command.go @@ -0,0 +1,447 @@ +package agent + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "sort" + "strings" + "sync" + "time" +) + +// CommandRequest describes one task-scoped child process. Env is the complete +// child environment; daemon process environment variables are never inherited. +// LeadingExtraFiles occupy child FDs starting at 3 before isolation descriptors. +type CommandRequest struct { + Executable string + Args []string + Cwd string + Env map[string]string + Isolation *TaskIsolationPolicy + WaitDelay time.Duration + LeadingExtraFiles []*os.File +} + +// CommandBuilder constructs one validated task process. Production callers use +// CommandLauncher; the interface exists so daemon tests can exercise runTask +// without nesting the host isolation facility inside another sandbox. +type CommandBuilder interface { + Command(context.Context, CommandRequest) (TaskCommand, error) +} + +// TaskCommand exposes only the lifecycle and I/O surface required by agent +// backends. The underlying exec.Cmd is intentionally never exposed. +type TaskCommand interface { + Start() error + Wait() error + Run() error + Output() ([]byte, error) + CombinedOutput() ([]byte, error) + Close() error + StdoutPipe() (io.ReadCloser, error) + StdinPipe() (io.WriteCloser, error) + StderrPipe() (io.ReadCloser, error) + SetStderr(io.Writer) error + SetCancel(func() error) error + Process() *os.Process + ProcessState() *os.ProcessState + Environment() []string +} + +// CommandLauncher is the only supported constructor for task-time agent +// processes. It validates the complete request before returning a command. +type CommandLauncher struct { + isolation platformIsolation +} + +func newCommandLauncher(isolation platformIsolation) *CommandLauncher { + return &CommandLauncher{isolation: isolation} +} + +// NewCommandLauncher returns a launcher backed by the host's mandatory +// kernel-enforced isolation facility. Unsupported hosts fail closed when used. +func NewCommandLauncher() *CommandLauncher { + return newCommandLauncher(newPlatformIsolation()) +} + +// PreparedCommand is the only production handle for a validated task process. +type PreparedCommand struct { + cmd *exec.Cmd + resources []io.Closer + recheck func() error + mu sync.Mutex + state preparedCommandState + closeErr error +} + +type preparedCommandState uint8 + +const ( + preparedCommandReady preparedCommandState = iota + preparedCommandStarting + preparedCommandStarted + preparedCommandWaiting + preparedCommandWaited + preparedCommandClosed + preparedCommandFailed +) + +func newPreparedCommand(cmd *exec.Cmd) *PreparedCommand { + return &PreparedCommand{cmd: cmd, state: preparedCommandReady} +} + +// Command validates and wraps a task-time command without starting it. +func (l *CommandLauncher) Command(ctx context.Context, req CommandRequest) (TaskCommand, error) { + if l == nil || l.isolation == nil { + return nil, fmt.Errorf("command launcher isolation is unavailable") + } + if req.Isolation == nil { + return nil, fmt.Errorf("command isolation policy is required") + } + + executablePath, err := resolveStableSystemPathAlias("executable", req.Executable) + if err != nil { + return nil, err + } + cwdPath, err := validateAbsolutePath("cwd", req.Cwd) + if err != nil { + return nil, err + } + + bound, err := bindTaskIsolationPolicy(*req.Isolation) + if err != nil { + return nil, fmt.Errorf("validate command isolation policy: %w", err) + } + owned := true + defer func() { + if owned { + _ = bound.Close() + } + }() + + cwd, err := currentWorkingDirectoryIdentity(cwdPath) + if err != nil { + return nil, err + } + defer func() { + if owned { + _ = cwd.Close() + } + }() + executable, err := executableIdentity(executablePath) + if err != nil { + return nil, err + } + defer func() { + if owned { + _ = executable.Close() + } + }() + + allowedCwd := append(append([]pathIdentity{}, bound.WritableRoots...), bound.ReadOnlyRoots...) + if !identityWithinAny(cwd.Path, allowedCwd) { + return nil, fmt.Errorf("cwd %q is outside task roots", cwd.Path) + } + allowedExec := append(append(append([]pathIdentity{}, bound.WritableRoots...), bound.ReadOnlyRoots...), bound.SystemRoots...) + if !identityWithinAny(executable.Path, allowedExec) { + return nil, fmt.Errorf("executable %q is outside isolated roots", executable.Path) + } + if err := validateProtectedTargetsAgainstCommandPaths(bound.ReadOnlyFiles, executable, cwd); err != nil { + return nil, err + } + + if err := recheckBoundIsolation(bound); err != nil { + return nil, err + } + if err := recheckPathIdentity(&cwd); err != nil { + return nil, err + } + if err := recheckPathIdentity(&executable); err != nil { + return nil, err + } + + leading := append([]*os.File(nil), req.LeadingExtraFiles...) + wrappedExecutable, wrappedArgs, isolationFiles, err := l.isolation.WrapBound(bound, executable, cwd, append([]string(nil), req.Args...), len(leading)) + if err != nil { + return nil, fmt.Errorf("apply command isolation: %w", err) + } + + cmd := exec.CommandContext(ctx, wrappedExecutable, wrappedArgs...) + // Linux bubblewrap starts from "/" and enters the validated CWD via --chdir. + // Darwin seatbelt is pathname-based and has no object-bound chdir, so the + // revalidated CWD path is used as cmd.Dir and rechecked again at Start. + if dir := isolationLaunchDirectory(l.isolation); dir != "" { + cmd.Dir = dir + } else { + cmd.Dir = cwd.Path + } + cmd.Env, err = explicitEnvironment(req.Env) + if err != nil { + return nil, err + } + cmd.WaitDelay = req.WaitDelay + if len(leading) > 0 || len(isolationFiles) > 0 { + cmd.ExtraFiles = append(leading, isolationFiles...) + } + configureProcessGroup(cmd) + hideAgentWindow(cmd) + + recheck := func() error { + if err := recheckBoundIsolation(bound); err != nil { + return err + } + if err := recheckPathIdentity(&cwd); err != nil { + return err + } + return recheckPathIdentity(&executable) + } + + prepared := &PreparedCommand{ + cmd: cmd, + resources: joinIdentityResources(bound, &cwd, &executable), + recheck: recheck, + state: preparedCommandReady, + } + owned = false + return prepared, nil +} + +func (p *PreparedCommand) Start() error { + if p == nil || p.cmd == nil { + return fmt.Errorf("prepared command is nil") + } + p.mu.Lock() + if p.state != preparedCommandReady { + state := p.state + p.mu.Unlock() + return fmt.Errorf("prepared command cannot start from state %d", state) + } + p.state = preparedCommandStarting + if p.recheck != nil { + if err := p.recheck(); err != nil { + p.state = preparedCommandFailed + p.releaseLocked() + p.mu.Unlock() + return err + } + } + err := p.cmd.Start() + // Isolation descriptors are only required until the helper is launched. + // After Start returns, the child either inherited them or failed to start. + p.releaseLocked() + if err != nil { + p.state = preparedCommandFailed + p.mu.Unlock() + return err + } + p.state = preparedCommandStarted + p.mu.Unlock() + return nil +} + +func (p *PreparedCommand) Wait() error { + if p == nil || p.cmd == nil { + return fmt.Errorf("prepared command is nil") + } + p.mu.Lock() + if p.state != preparedCommandStarted { + state := p.state + p.mu.Unlock() + return fmt.Errorf("prepared command cannot wait from state %d", state) + } + p.state = preparedCommandWaiting + p.mu.Unlock() + err := p.cmd.Wait() + p.mu.Lock() + p.state = preparedCommandWaited + p.mu.Unlock() + return err +} + +func (p *PreparedCommand) Run() error { + if err := p.Start(); err != nil { + return err + } + return p.Wait() +} + +func (p *PreparedCommand) Output() ([]byte, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + captureStderr := false + if err := p.configure(func(cmd *exec.Cmd) error { + if cmd.Stdout != nil { + return fmt.Errorf("exec: Stdout already set") + } + cmd.Stdout = &stdout + if cmd.Stderr == nil { + cmd.Stderr = &stderr + captureStderr = true + } + return nil + }); err != nil { + return nil, err + } + + if captureStderr { + err := p.Run() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + ee.Stderr = stderr.Bytes() + return stdout.Bytes(), ee + } + return stdout.Bytes(), err + } + return stdout.Bytes(), nil + } + err := p.Run() + return stdout.Bytes(), err +} + +func (p *PreparedCommand) CombinedOutput() ([]byte, error) { + var b bytes.Buffer + if err := p.configure(func(cmd *exec.Cmd) error { + if cmd.Stdout != nil { + return fmt.Errorf("exec: Stdout already set") + } + if cmd.Stderr != nil { + return fmt.Errorf("exec: Stderr already set") + } + cmd.Stdout = &b + cmd.Stderr = &b + return nil + }); err != nil { + return nil, err + } + err := p.Run() + return b.Bytes(), err +} + +func (p *PreparedCommand) Close() error { + if p == nil { + return nil + } + p.mu.Lock() + defer p.mu.Unlock() + if p.state == preparedCommandReady { + p.state = preparedCommandClosed + } + p.releaseLocked() + return p.closeErr +} + +func (p *PreparedCommand) releaseLocked() { + if p.resources == nil { + return + } + p.closeErr = closeAll(p.resources...) + p.resources = nil + p.recheck = nil +} + +func (p *PreparedCommand) configure(fn func(*exec.Cmd) error) error { + if p == nil || p.cmd == nil { + return fmt.Errorf("prepared command is nil") + } + p.mu.Lock() + defer p.mu.Unlock() + if p.state != preparedCommandReady { + return fmt.Errorf("prepared command cannot be configured after launch") + } + return fn(p.cmd) +} + +func (p *PreparedCommand) StdoutPipe() (io.ReadCloser, error) { + var pipe io.ReadCloser + err := p.configure(func(cmd *exec.Cmd) (err error) { pipe, err = cmd.StdoutPipe(); return err }) + return pipe, err +} + +func (p *PreparedCommand) StdinPipe() (io.WriteCloser, error) { + var pipe io.WriteCloser + err := p.configure(func(cmd *exec.Cmd) (err error) { pipe, err = cmd.StdinPipe(); return err }) + return pipe, err +} + +func (p *PreparedCommand) StderrPipe() (io.ReadCloser, error) { + var pipe io.ReadCloser + err := p.configure(func(cmd *exec.Cmd) (err error) { pipe, err = cmd.StderrPipe(); return err }) + return pipe, err +} + +func (p *PreparedCommand) SetStderr(stderr io.Writer) error { + return p.configure(func(cmd *exec.Cmd) error { cmd.Stderr = stderr; return nil }) +} + +func (p *PreparedCommand) SetCancel(cancel func() error) error { + return p.configure(func(cmd *exec.Cmd) error { cmd.Cancel = cancel; return nil }) +} + +func (p *PreparedCommand) Process() *os.Process { + if p == nil || p.cmd == nil { + return nil + } + p.mu.Lock() + defer p.mu.Unlock() + return p.cmd.Process +} + +func (p *PreparedCommand) ProcessState() *os.ProcessState { + if p == nil || p.cmd == nil { + return nil + } + p.mu.Lock() + defer p.mu.Unlock() + return p.cmd.ProcessState +} + +func (p *PreparedCommand) Environment() []string { + if p == nil || p.cmd == nil { + return nil + } + p.mu.Lock() + defer p.mu.Unlock() + return append([]string(nil), p.cmd.Env...) +} + +func (c Config) command(ctx context.Context, executable string, args []string, cwd string, waitDelay time.Duration) (TaskCommand, error) { + return c.commandWithLeadingExtraFiles(ctx, executable, args, cwd, waitDelay, nil) +} + +func (c Config) commandWithLeadingExtraFiles(ctx context.Context, executable string, args []string, cwd string, waitDelay time.Duration, leading []*os.File) (TaskCommand, error) { + if c.Launcher == nil { + return nil, fmt.Errorf("agent command launcher is required") + } + return c.Launcher.Command(ctx, CommandRequest{ + Executable: executable, + Args: args, + Cwd: cwd, + Env: c.Env, + Isolation: c.Isolation, + WaitDelay: waitDelay, + LeadingExtraFiles: leading, + }) +} + +func explicitEnvironment(values map[string]string) ([]string, error) { + keys := make([]string, 0, len(values)) + for key, value := range values { + if key == "" || strings.ContainsAny(key, "=\x00") { + return nil, fmt.Errorf("invalid environment key %q", key) + } + if strings.ContainsRune(value, '\x00') { + return nil, fmt.Errorf("environment value for %q contains NUL", key) + } + keys = append(keys, key) + } + sort.Strings(keys) + env := make([]string, 0, len(keys)) + for _, key := range keys { + env = append(env, key+"="+values[key]) + } + return env, nil +} diff --git a/server/pkg/agent/command_test.go b/server/pkg/agent/command_test.go new file mode 100644 index 00000000000..826fc8304ba --- /dev/null +++ b/server/pkg/agent/command_test.go @@ -0,0 +1,309 @@ +package agent + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "strings" + "sync" + "testing" + "time" +) + +type recordingIsolation struct { + executable string + args []string + err error +} + +func TestPreparedCommandCloseBeforeStartIsTerminal(t *testing.T) { + cmd := newPreparedCommand(exec.Command("/usr/bin/true")) + if err := cmd.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if err := cmd.Start(); err == nil { + t.Fatal("Start succeeded after Close") + } +} + +func TestPreparedCommandDuplicateStartAndWaitFail(t *testing.T) { + cmd := newPreparedCommand(exec.Command("/usr/bin/true")) + if err := cmd.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + if err := cmd.Start(); err == nil { + t.Fatal("duplicate Start succeeded") + } + if err := cmd.Wait(); err != nil { + t.Fatalf("Wait: %v", err) + } + if err := cmd.Wait(); err == nil { + t.Fatal("duplicate Wait succeeded") + } +} + +func TestPreparedCommandConcurrentStartClose(t *testing.T) { + for i := 0; i < 100; i++ { + cmd := newPreparedCommand(exec.Command("/usr/bin/true")) + start := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + <-start + if err := cmd.Start(); err == nil { + _ = cmd.Wait() + } + }() + go func() { + defer wg.Done() + <-start + _ = cmd.Close() + }() + close(start) + wg.Wait() + } +} + +func TestPreparedCommandConcurrentWaitAllowsOneCaller(t *testing.T) { + cmd := newPreparedCommand(exec.Command("/usr/bin/true")) + if err := cmd.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + start := make(chan struct{}) + errs := make(chan error, 2) + for i := 0; i < 2; i++ { + go func() { + <-start + errs <- cmd.Wait() + }() + } + close(start) + var successes int + for i := 0; i < 2; i++ { + if err := <-errs; err == nil { + successes++ + } + } + if successes != 1 { + t.Fatalf("successful Wait calls = %d, want 1", successes) + } +} + +func TestPreparedCommandRejectsConfigurationAfterStartOrClose(t *testing.T) { + t.Run("started", func(t *testing.T) { + cmd := newPreparedCommand(exec.Command("/usr/bin/true")) + if err := cmd.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + defer cmd.Wait() + if err := cmd.SetStderr(os.Stderr); err == nil { + t.Fatal("SetStderr succeeded after Start") + } + if _, err := cmd.StdoutPipe(); err == nil { + t.Fatal("StdoutPipe succeeded after Start") + } + }) + t.Run("closed", func(t *testing.T) { + cmd := newPreparedCommand(exec.Command("/usr/bin/true")) + if err := cmd.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if err := cmd.SetCancel(func() error { return nil }); err == nil { + t.Fatal("SetCancel succeeded after Close") + } + if _, err := cmd.CombinedOutput(); err == nil { + t.Fatal("CombinedOutput succeeded after Close") + } + }) +} + +func (r *recordingIsolation) WrapBound(_ *boundIsolationPolicy, executable, cwd pathIdentity, args []string, leadingExtraFiles int) (string, []string, []*os.File, error) { + _ = cwd + _ = leadingExtraFiles + r.executable = executable.Path + r.args = append([]string(nil), args...) + if r.err != nil { + return "", nil, nil, r.err + } + return executable.Path, args, nil, nil +} + +func TestCommandLauncherRejectsMissingPolicyBeforeExecution(t *testing.T) { + t.Parallel() + + marker := filepath.Join(t.TempDir(), "marker") + launcher := newCommandLauncher(&recordingIsolation{}) + _, err := launcher.Command(context.Background(), CommandRequest{ + Executable: "/usr/bin/touch", + Args: []string{marker}, + Cwd: t.TempDir(), + Env: map[string]string{"PATH": "/usr/bin:/bin"}, + }) + if err == nil || !strings.Contains(err.Error(), "isolation policy") { + t.Fatalf("expected missing isolation policy error, got %v", err) + } + if _, statErr := os.Stat(marker); !os.IsNotExist(statErr) { + t.Fatalf("command ran without an isolation policy: stat error = %v", statErr) + } +} + +func TestCommandLauncherOwnsExecutableArgsCwdAndExplicitEnv(t *testing.T) { + root := t.TempDir() + workDir := filepath.Join(root, "work") + if err := os.Mkdir(workDir, 0o700); err != nil { + t.Fatal(err) + } + executable := filepath.Join(root, "print-env.sh") + writeTestExecutable(t, executable, []byte("#!/bin/sh\nprintf '%s\\n%s\\n%s\\n' \"$PWD\" \"$TASK_VALUE\" \"${DAEMON_ONLY_SECRET-unset}\"\n")) + + t.Setenv("DAEMON_ONLY_SECRET", "must-not-leak") + isolation := &recordingIsolation{} + launcher := newCommandLauncher(isolation) + policy := &TaskIsolationPolicy{ + WritableRoots: []string{root}, + SystemRoots: existingSystemRootsForTest(t), + Network: NetworkAccessPublicAndLoopback, + } + cmd, err := launcher.Command(context.Background(), CommandRequest{ + Executable: executable, + Args: []string{"arg-one"}, + Cwd: workDir, + Env: map[string]string{ + "PATH": "/usr/bin:/bin", + "TASK_VALUE": "task-only", + }, + Isolation: policy, + WaitDelay: 2 * time.Second, + }) + if err != nil { + t.Fatalf("Command: %v", err) + } + out, err := cmd.Output() + if err != nil { + t.Fatalf("Output: %v", err) + } + gotLines := strings.Split(strings.TrimSpace(string(out)), "\n") + wantWorkDir, err := filepath.EvalSymlinks(workDir) + if err != nil { + t.Fatalf("resolve workdir: %v", err) + } + wantLines := []string{wantWorkDir, "task-only", "unset"} + if !reflect.DeepEqual(gotLines, wantLines) { + t.Fatalf("child output = %#v, want %#v", gotLines, wantLines) + } + if isolation.executable != executable || !reflect.DeepEqual(isolation.args, []string{"arg-one"}) { + t.Fatalf("isolation received (%q, %#v), want (%q, %#v)", isolation.executable, isolation.args, executable, []string{"arg-one"}) + } + prepared := cmd.(*PreparedCommand) + if prepared.cmd.Dir != workDir { + t.Fatalf("cmd.Dir = %q, want %q", prepared.cmd.Dir, workDir) + } + if prepared.cmd.WaitDelay != 2*time.Second { + t.Fatalf("cmd.WaitDelay = %v, want 2s", prepared.cmd.WaitDelay) + } + for _, entry := range prepared.cmd.Env { + if strings.HasPrefix(entry, "DAEMON_ONLY_SECRET=") { + t.Fatalf("daemon-only environment leaked: %q", entry) + } + } +} + +func TestCommandLauncherRejectsRelativeAndParentTraversalPaths(t *testing.T) { + t.Parallel() + + root := t.TempDir() + policy := &TaskIsolationPolicy{WritableRoots: []string{root}, SystemRoots: existingSystemRootsForTest(t)} + launcher := newCommandLauncher(&recordingIsolation{}) + + tests := []struct { + name string + executable string + cwd string + }{ + {name: "relative executable", executable: "sh", cwd: root}, + {name: "relative cwd", executable: "/bin/sh", cwd: "relative"}, + {name: "executable traversal", executable: root + "/child/../tool", cwd: root}, + {name: "cwd traversal", executable: "/bin/sh", cwd: root + "/child/.."}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := launcher.Command(context.Background(), CommandRequest{ + Executable: tt.executable, + Cwd: tt.cwd, + Env: map[string]string{"PATH": "/usr/bin:/bin"}, + Isolation: policy, + }) + if err == nil { + t.Fatal("expected path validation error") + } + }) + } +} + +func existingSystemRootsForTest(t *testing.T) []string { + t.Helper() + var roots []string + for _, root := range []string{"/bin", "/usr", "/lib", "/lib64", "/System", "/Library"} { + if _, err := os.Stat(root); err == nil { + roots = append(roots, root) + } + } + return roots +} + +func TestCommandLauncherCanonicalizesStableSystemExecutableAlias(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Linux usr-merge aliases") + } + resolved, err := filepath.EvalSymlinks("/bin/sh") + canonical, stableAlias := stableSystemAliasPathForOS("linux", "/bin/sh") + if err != nil || !stableAlias || !pathWithin(resolved, canonicalRoot(canonical)) { + t.Skip("host does not use /bin as a symlink alias") + } + root := t.TempDir() + launcher := newCommandLauncher(&recordingIsolation{}) + cmd, err := launcher.Command(context.Background(), CommandRequest{ + Executable: "/bin/sh", + Args: []string{"-c", "exit 0"}, + Cwd: root, + Env: map[string]string{"PATH": "/usr/bin:/bin"}, + Isolation: &TaskIsolationPolicy{ + WritableRoots: []string{root}, + SystemRoots: []string{"/bin"}, + }, + }) + if err != nil { + t.Fatalf("Command: %v", err) + } + defer cmd.Close() + prepared := cmd.(*PreparedCommand) + if prepared.cmd.Args[len(prepared.cmd.Args)-3] != resolved { + t.Fatalf("wrapped executable = %q, want %q", prepared.cmd.Args[len(prepared.cmd.Args)-3], resolved) + } +} + +func TestResolvedStableSystemExecutableAliasStaysInCanonicalRoot(t *testing.T) { + tests := []struct { + name string + path string + resolved string + want bool + }{ + {name: "direct usr merge target", path: "/bin/sh", resolved: "/usr/bin/sh", want: true}, + {name: "command alias in usr bin", path: "/bin/sh", resolved: "/usr/bin/dash", want: true}, + {name: "usr local escape", path: "/bin/tool", resolved: "/usr/local/bin/tool"}, + {name: "opt escape", path: "/bin/tool", resolved: "/opt/tool"}, + {name: "unlisted alias", path: "/sbin/tool", resolved: "/usr/sbin/tool"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isResolvedStableSystemAliasForOS("linux", tt.path, tt.resolved); got != tt.want { + t.Fatalf("isResolvedStableSystemAliasForOS(%q, %q) = %v, want %v", tt.path, tt.resolved, got, tt.want) + } + }) + } +} diff --git a/server/pkg/agent/copilot.go b/server/pkg/agent/copilot.go index 23715fa0985..43a5e198449 100644 --- a/server/pkg/agent/copilot.go +++ b/server/pkg/agent/copilot.go @@ -206,16 +206,14 @@ func (b *copilotBackend) Execute(ctx context.Context, prompt string, opts ExecOp runCtx, cancel := runContext(ctx, timeout) args := buildCopilotArgs(prompt, opts, b.cfg.Logger) - argv0, cmdArgs := chooseCopilotInvocation(execName, lookedUp, args, b.cfg.Logger) + argv0, cmdArgs := chooseCopilotInvocation(lookedUp, lookedUp, args, b.cfg.Logger) - cmd := exec.CommandContext(runCtx, argv0, cmdArgs...) - hideAgentWindow(cmd) - b.cfg.Logger.Info("agent command", "exec", argv0, "args", cmdArgs) - cmd.WaitDelay = 10 * time.Second - if opts.Cwd != "" { - cmd.Dir = opts.Cwd + cmd, err := b.cfg.command(runCtx, argv0, cmdArgs, opts.Cwd, 10*time.Second) + if err != nil { + cancel() + return nil, fmt.Errorf("create copilot command: %w", err) } - cmd.Env = buildEnv(b.cfg.Env) + b.cfg.Logger.Info("agent command", "exec", argv0, "args", cmdArgs) stdout, err := cmd.StdoutPipe() if err != nil { @@ -223,14 +221,14 @@ func (b *copilotBackend) Execute(ctx context.Context, prompt string, opts ExecOp return nil, fmt.Errorf("copilot stdout pipe: %w", err) } stderrBuf := newStderrTail(newLogWriter(b.cfg.Logger, "[copilot:stderr] "), agentStderrTailBytes) - cmd.Stderr = stderrBuf + _ = cmd.SetStderr(stderrBuf) if err := cmd.Start(); err != nil { cancel() return nil, fmt.Errorf("start copilot: %w", err) } - b.cfg.Logger.Info("copilot started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model) + b.cfg.Logger.Info("copilot started", "pid", cmd.Process().Pid, "cwd", opts.Cwd, "model", opts.Model) msgCh := make(chan Message, 256) resCh := make(chan Result, 1) @@ -292,7 +290,7 @@ func (b *copilotBackend) Execute(ctx context.Context, prompt string, opts ExecOp st.finalError = withAgentStderr(st.finalError, "copilot", stderrBuf.Tail()) } - b.cfg.Logger.Info("copilot finished", "pid", cmd.Process.Pid, "status", st.finalStatus, "duration", duration.Round(time.Millisecond).String()) + b.cfg.Logger.Info("copilot finished", "pid", cmd.Process().Pid, "status", st.finalStatus, "duration", duration.Round(time.Millisecond).String()) resCh <- Result{ Status: st.finalStatus, diff --git a/server/pkg/agent/copilot_test.go b/server/pkg/agent/copilot_test.go index 78156def05d..5bc927e8840 100644 --- a/server/pkg/agent/copilot_test.go +++ b/server/pkg/agent/copilot_test.go @@ -667,14 +667,15 @@ func TestCopilotExecuteSurfacesStderrOnNonZeroResult(t *testing.T) { "exit 1\n" writeTestExecutable(t, fakePath, []byte(script)) - backend, err := New("copilot", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + cfg, cwd := providerCommandTestConfig(t, fakePath, slog.Default()) + backend, err := New("copilot", cfg) if err != nil { t.Fatalf("new copilot backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second}) + session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Cwd: cwd, Timeout: 5 * time.Second}) if err != nil { t.Fatalf("execute: %v", err) } diff --git a/server/pkg/agent/cursor.go b/server/pkg/agent/cursor.go index 6291b08d717..7e0e3d97f56 100644 --- a/server/pkg/agent/cursor.go +++ b/server/pkg/agent/cursor.go @@ -34,30 +34,28 @@ func (b *cursorBackend) Execute(ctx context.Context, prompt string, opts ExecOpt runCtx, cancel := runContext(ctx, timeout) args := buildCursorArgs(prompt, opts, b.cfg.Logger) - argv0, cmdArgs := chooseCursorInvocation(execName, lookedUp, args, b.cfg.Logger) + argv0, cmdArgs := chooseCursorInvocation(lookedUp, lookedUp, args, b.cfg.Logger) - cmd := exec.CommandContext(runCtx, argv0, cmdArgs...) - hideAgentWindow(cmd) - b.cfg.Logger.Info("agent command", "exec", argv0, "args", cmdArgs) - cmd.WaitDelay = 500 * time.Millisecond - if opts.Cwd != "" { - cmd.Dir = opts.Cwd + cmd, err := b.cfg.command(runCtx, argv0, cmdArgs, opts.Cwd, 500*time.Millisecond) + if err != nil { + cancel() + return nil, fmt.Errorf("create cursor-agent command: %w", err) } - cmd.Env = buildEnv(b.cfg.Env) + b.cfg.Logger.Info("agent command", "exec", argv0, "args", cmdArgs) stdout, err := cmd.StdoutPipe() if err != nil { cancel() return nil, fmt.Errorf("cursor stdout pipe: %w", err) } - cmd.Stderr = newLogWriter(b.cfg.Logger, "[cursor:stderr] ") + _ = cmd.SetStderr(newLogWriter(b.cfg.Logger, "[cursor:stderr] ")) if err := cmd.Start(); err != nil { cancel() return nil, fmt.Errorf("start cursor-agent: %w", err) } - b.cfg.Logger.Info("cursor-agent started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model) + b.cfg.Logger.Info("cursor-agent started", "pid", cmd.Process().Pid, "cwd", opts.Cwd, "model", opts.Model) msgCh := make(chan Message, 256) resCh := make(chan Result, 1) @@ -211,7 +209,7 @@ func (b *cursorBackend) Execute(ctx context.Context, prompt string, opts ExecOpt finalError = fmt.Sprintf("cursor-agent exited with error: %v", exitErr) } - b.cfg.Logger.Info("cursor-agent finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) + b.cfg.Logger.Info("cursor-agent finished", "pid", cmd.Process().Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) resCh <- Result{ Status: finalStatus, diff --git a/server/pkg/agent/cursor_execute_unix_test.go b/server/pkg/agent/cursor_execute_unix_test.go index 4df15561f35..fdbf9e43a73 100644 --- a/server/pkg/agent/cursor_execute_unix_test.go +++ b/server/pkg/agent/cursor_execute_unix_test.go @@ -40,11 +40,12 @@ printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"result":"f fakePath := filepath.Join(t.TempDir(), "cursor-agent") writeTestExecutable(t, fakePath, []byte(script)) - backend, err := New("cursor", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + cfg, cwd := providerCommandTestConfig(t, fakePath, slog.Default()) + backend, err := New("cursor", cfg) if err != nil { t.Fatalf("New(cursor): %v", err) } - session, err := backend.Execute(t.Context(), "hello", ExecOptions{Timeout: 5 * time.Second}) + session, err := backend.Execute(t.Context(), "hello", ExecOptions{Cwd: cwd, Timeout: 5 * time.Second}) if err != nil { t.Fatalf("Execute: %v", err) } @@ -105,11 +106,12 @@ func executeFakeCursor(t *testing.T, script string) Result { fakePath := filepath.Join(t.TempDir(), "cursor-agent") writeTestExecutable(t, fakePath, []byte(script)) - backend, err := New("cursor", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + cfg, cwd := providerCommandTestConfig(t, fakePath, slog.Default()) + backend, err := New("cursor", cfg) if err != nil { t.Fatalf("New(cursor): %v", err) } - session, err := backend.Execute(t.Context(), "hello", ExecOptions{Timeout: 5 * time.Second}) + session, err := backend.Execute(t.Context(), "hello", ExecOptions{Cwd: cwd, Timeout: 5 * time.Second}) if err != nil { t.Fatalf("Execute: %v", err) } diff --git a/server/pkg/agent/cursor_test.go b/server/pkg/agent/cursor_test.go index 46492330607..9568f507448 100644 --- a/server/pkg/agent/cursor_test.go +++ b/server/pkg/agent/cursor_test.go @@ -3,10 +3,35 @@ package agent import ( "encoding/json" "log/slog" + "os" + "path/filepath" "strings" "testing" ) +func providerCommandTestConfig(t *testing.T, executable string, logger *slog.Logger) (Config, string) { + t.Helper() + + root := filepath.Dir(executable) + taskTempDir := filepath.Join(root, "task-tmp") + if err := os.MkdirAll(taskTempDir, 0o700); err != nil { + t.Fatalf("create task temp dir: %v", err) + } + return Config{ + ExecutablePath: executable, + Env: map[string]string{"PATH": os.Getenv("PATH")}, + Logger: logger, + Launcher: newCommandLauncher(&recordingIsolation{}), + Isolation: &TaskIsolationPolicy{ + WritableRoots: []string{root}, + SystemRoots: existingSystemRootsForTest(t), + Network: NetworkAccessPublicAndLoopback, + }, + TaskTempDir: taskTempDir, + TaskStateDir: root, + }, root +} + func TestNewReturnsCursorBackend(t *testing.T) { t.Parallel() b, err := New("cursor", Config{ExecutablePath: "/nonexistent/cursor-agent"}) @@ -255,10 +280,10 @@ func TestCursorUsageModelFallback(t *testing.T) { t.Parallel() tests := []struct { - name string - evtModel string - configuredModel string - want string + name string + evtModel string + configuredModel string + want string }{ {"event model wins", "gpt-5.3-codex", "composer-2.5", "gpt-5.3-codex"}, {"configured model fallback", "", "composer-2.5", "composer-2.5"}, diff --git a/server/pkg/agent/deveco.go b/server/pkg/agent/deveco.go index fc1361caedc..1380c407aa0 100644 --- a/server/pkg/agent/deveco.go +++ b/server/pkg/agent/deveco.go @@ -120,47 +120,44 @@ func (b *devecoBackend) Execute(ctx context.Context, prompt string, opts ExecOpt args = append(args, filterCustomArgs(opts.CustomArgs, devecoBlockedArgs, b.cfg.Logger)...) args = append(args, prompt) - cmd := exec.CommandContext(runCtx, execPath, args...) - hideAgentWindow(cmd) - // Run deveco in its own process group so cancellation can reach the whole - // tree (deveco plus any tool subprocess it spawns), not just the direct - // child — otherwise a cancelled or restarted run can orphan a descendant. - configureProcessGroup(cmd) + env := make(map[string]string, len(b.cfg.Env)+1) + for key, value := range b.cfg.Env { + env[key] = value + } + // Override PWD so the child DevEco process resolves its discovery root to + // the task workdir. The launcher also sets cmd.Dir, but DevEco reads PWD + // before falling back to process.cwd() when locating AGENTS.md and skills. + if opts.Cwd != "" { + env["PWD"] = opts.Cwd + } + commandCfg := b.cfg + commandCfg.Env = env + cmd, err := commandCfg.command(runCtx, execPath, args, opts.Cwd, 10*time.Second) + if err != nil { + cancel() + return nil, fmt.Errorf("prepare deveco command: %w", err) + } // Take over context cancellation: drive a graceful, group-wide // SIGTERM→SIGKILL from the cancellation goroutine below and close the // stdout read end only after the tree has been signalled. Returning nil // here keeps os/exec from racing us with its own kill; WaitDelay is the // hard backstop. - cmd.Cancel = func() error { return nil } + _ = cmd.SetCancel(func() error { return nil }) b.cfg.Logger.Info("agent command", "exec", execPath, "args", args) - cmd.WaitDelay = 10 * time.Second - if opts.Cwd != "" { - cmd.Dir = opts.Cwd - } - - env := buildEnv(b.cfg.Env) - // Override PWD so the child DevEco process resolves its discovery root to - // the task workdir. cmd.Dir alone is not enough: DevEco reads PWD - // (inherited from the parent daemon) before falling back to process.cwd() - // when computing the directory it walks for AGENTS.md / .deveco/skills. - if opts.Cwd != "" { - env = append(env, "PWD="+opts.Cwd) - } - cmd.Env = env stdout, err := cmd.StdoutPipe() if err != nil { cancel() return nil, fmt.Errorf("deveco stdout pipe: %w", err) } - cmd.Stderr = newLogWriter(b.cfg.Logger, "[deveco:stderr] ") + _ = cmd.SetStderr(newLogWriter(b.cfg.Logger, "[deveco:stderr] ")) if err := cmd.Start(); err != nil { cancel() return nil, fmt.Errorf("start deveco: %w", err) } - b.cfg.Logger.Info("deveco started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model) + b.cfg.Logger.Info("deveco started", "pid", cmd.Process().Pid, "cwd", opts.Cwd, "model", opts.Model) msgCh := make(chan Message, 256) resCh := make(chan Result, 1) @@ -182,12 +179,12 @@ func (b *devecoBackend) Execute(ctx context.Context, prompt string, opts ExecOpt return // finished on its own; nothing to terminate case <-runCtx.Done(): } - if cmd.Process != nil { - signalProcessGroup(cmd.Process, syscall.SIGTERM) + if cmd.Process() != nil { + signalProcessGroup(cmd.Process(), syscall.SIGTERM) select { case <-procDone: // exited within the grace window case <-time.After(devecoTerminateGrace()): - signalProcessGroup(cmd.Process, syscall.SIGKILL) + signalProcessGroup(cmd.Process(), syscall.SIGKILL) } } _ = stdout.Close() @@ -216,7 +213,7 @@ func (b *devecoBackend) Execute(ctx context.Context, prompt string, opts ExecOpt scanResult.errMsg = fmt.Sprintf("deveco exited with error: %v", exitErr) } - b.cfg.Logger.Info("deveco finished", "pid", cmd.Process.Pid, "status", scanResult.status, "duration", duration.Round(time.Millisecond).String()) + b.cfg.Logger.Info("deveco finished", "pid", cmd.Process().Pid, "status", scanResult.status, "duration", duration.Round(time.Millisecond).String()) // Build usage map. DevEco doesn't report model per-step, so attribute // all usage to the configured model (or "unknown"). diff --git a/server/pkg/agent/deveco_test.go b/server/pkg/agent/deveco_test.go index 835a5bed328..917523b0f24 100644 --- a/server/pkg/agent/deveco_test.go +++ b/server/pkg/agent/deveco_test.go @@ -9,6 +9,7 @@ import ( "context" "encoding/json" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -17,6 +18,29 @@ import ( "log/slog" ) +type recordingDevecoCommandBuilder struct { + requests []CommandRequest + commands []*PreparedCommand +} + +func (b *recordingDevecoCommandBuilder) Command(ctx context.Context, req CommandRequest) (TaskCommand, error) { + b.requests = append(b.requests, req) + cmd := exec.CommandContext(ctx, req.Executable, req.Args...) + cmd.Dir = req.Cwd + env, err := explicitEnvironment(req.Env) + if err != nil { + return nil, err + } + cmd.Env = env + cmd.WaitDelay = req.WaitDelay + if len(req.LeadingExtraFiles) > 0 { + cmd.ExtraFiles = append([]*os.File(nil), req.LeadingExtraFiles...) + } + prepared := newPreparedCommand(cmd) + b.commands = append(b.commands, prepared) + return prepared, nil +} + func TestNewReturnsDevecoBackend(t *testing.T) { t.Parallel() b, err := New("deveco", Config{ExecutablePath: "/nonexistent/deveco"}) @@ -59,11 +83,7 @@ func TestDevecoBackendArgvShapeAndNoPrompt(t *testing.T) { workDir := t.TempDir() - backend, err := New("deveco", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{"DEVECO_ARGS_FILE": argsFile}, - }) + backend, err := New("deveco", acpProviderTestConfigForCwd(t, fakePath, workDir, map[string]string{"DEVECO_ARGS_FILE": argsFile})) if err != nil { t.Fatalf("new deveco backend: %v", err) } @@ -121,6 +141,132 @@ func TestDevecoBackendArgvShapeAndNoPrompt(t *testing.T) { } } +func TestDevecoBackendUsesTaskCommandBuilder(t *testing.T) { + root := t.TempDir() + fakePath := filepath.Join(root, "deveco") + writeTestExecutable(t, fakePath, []byte(fakeDevecoScript())) + t.Setenv("DAEMON_ONLY_SECRET", "must-not-leak") + + launcher := &recordingDevecoCommandBuilder{} + policy := &TaskIsolationPolicy{ + WritableRoots: []string{root}, + SystemRoots: existingSystemRootsForTest(t), + Network: NetworkAccessPublicAndLoopback, + } + taskEnv := map[string]string{ + "PATH": os.Getenv("PATH"), + "TASK_VALUE": "task-only", + "PWD": "/owner/workdir", + } + backend, err := New("deveco", Config{ + ExecutablePath: fakePath, + Logger: slog.Default(), + Env: taskEnv, + Launcher: launcher, + Isolation: policy, + }) + if err != nil { + t.Fatalf("new deveco backend: %v", err) + } + + session, err := backend.Execute(t.Context(), "task", ExecOptions{ + Cwd: root, + Timeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + go func() { + for range session.Messages { + } + }() + if result := <-session.Result; result.Status != "completed" { + t.Fatalf("result = %+v, want completed", result) + } + + if len(launcher.requests) != 1 { + t.Fatalf("launcher requests = %d, want 1", len(launcher.requests)) + } + req := launcher.requests[0] + if req.Executable != fakePath { + t.Fatalf("executable = %q, want %q", req.Executable, fakePath) + } + wantArgs := []string{"run", "--format", "json", "--dangerously-skip-permissions", "--dir", root, "task"} + if strings.Join(req.Args, "\x00") != strings.Join(wantArgs, "\x00") { + t.Fatalf("args = %#v, want %#v", req.Args, wantArgs) + } + if req.Cwd != root { + t.Fatalf("cwd = %q, want %q", req.Cwd, root) + } + if req.Env["PATH"] != taskEnv["PATH"] || req.Env["TASK_VALUE"] != "task-only" { + t.Fatalf("env = %#v, want explicit task environment", req.Env) + } + if req.Env["PWD"] != root { + t.Fatalf("PWD = %q, want task cwd %q", req.Env["PWD"], root) + } + if _, ok := req.Env["DAEMON_ONLY_SECRET"]; ok { + t.Fatalf("daemon-only environment leaked: %#v", req.Env) + } + if len(req.Env) != len(taskEnv) { + t.Fatalf("env = %#v, want only task values plus overridden PWD", req.Env) + } + if req.Isolation != policy { + t.Fatalf("isolation = %p, want task policy %p", req.Isolation, policy) + } + if req.WaitDelay != 10*time.Second { + t.Fatalf("wait delay = %s, want 10s", req.WaitDelay) + } + if len(launcher.commands) != 1 || launcher.commands[0].cmd.Cancel == nil { + t.Fatal("deveco-specific cancellation override was not installed") + } + if err := launcher.commands[0].cmd.Cancel(); err != nil { + t.Fatalf("deveco cancellation override returned %v, want nil", err) + } +} + +func TestDevecoBackendFailsClosedWithoutTaskExecutionAuthority(t *testing.T) { + root := t.TempDir() + marker := filepath.Join(root, "started") + fakePath := filepath.Join(root, "deveco") + writeTestExecutable(t, fakePath, []byte("#!/bin/sh\ntouch '"+marker+"'\nexit 95\n")) + policy := &TaskIsolationPolicy{ + WritableRoots: []string{root}, + SystemRoots: existingSystemRootsForTest(t), + Network: NetworkAccessPublicAndLoopback, + } + + tests := []struct { + name string + launcher CommandBuilder + isolation *TaskIsolationPolicy + wantError string + }{ + {name: "missing launcher", isolation: policy, wantError: "launcher is required"}, + {name: "missing isolation", launcher: newCommandLauncher(&acpProviderTestIsolation{}), wantError: "isolation policy is required"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + backend, err := New("deveco", Config{ + ExecutablePath: fakePath, + Logger: slog.Default(), + Env: map[string]string{"PATH": os.Getenv("PATH")}, + Launcher: tt.launcher, + Isolation: tt.isolation, + }) + if err != nil { + t.Fatalf("new deveco backend: %v", err) + } + _, err = backend.Execute(t.Context(), "task", ExecOptions{Cwd: root, Timeout: time.Second}) + if err == nil || !strings.Contains(err.Error(), tt.wantError) { + t.Fatalf("execute error = %v, want %q", err, tt.wantError) + } + if _, statErr := os.Stat(marker); !os.IsNotExist(statErr) { + t.Fatalf("deveco process started without task authority: stat error = %v", statErr) + } + }) + } +} + // ── Event parsing tests with a real `deveco run --format json` fixture ── func TestDevecoEventParsingTextFixture(t *testing.T) { diff --git a/server/pkg/agent/grok.go b/server/pkg/agent/grok.go index 1b834208c82..432bff6e1c5 100644 --- a/server/pkg/agent/grok.go +++ b/server/pkg/agent/grok.go @@ -113,7 +113,8 @@ func (b *grokBackend) Execute(ctx context.Context, prompt string, opts ExecOptio if execPath == "" { execPath = "grok" } - if _, err := exec.LookPath(execPath); err != nil { + resolvedExecPath, err := exec.LookPath(execPath) + if err != nil { return nil, fmt.Errorf("grok executable not found at %q: %w", execPath, err) } @@ -144,14 +145,12 @@ func (b *grokBackend) Execute(ctx context.Context, prompt string, opts ExecOptio grokArgs = append(grokArgs, filterCustomArgs(opts.CustomArgs, grokBlockedArgs, b.cfg.Logger)...) grokArgs = append(grokArgs, "stdio") - cmd := exec.CommandContext(runCtx, execPath, grokArgs...) - hideAgentWindow(cmd) - b.cfg.Logger.Info("agent command", "exec", execPath, "args", grokArgs) - if opts.Cwd != "" { - cmd.Dir = opts.Cwd + cmd, err := b.cfg.command(runCtx, resolvedExecPath, grokArgs, opts.Cwd, 0) + if err != nil { + cancel() + return nil, fmt.Errorf("build grok command: %w", err) } - childEnv := buildEnv(b.cfg.Env) - cmd.Env = childEnv + b.cfg.Logger.Info("agent command", "exec", resolvedExecPath, "args", grokArgs) stdout, err := cmd.StdoutPipe() if err != nil { @@ -185,7 +184,7 @@ func (b *grokBackend) Execute(ctx context.Context, prompt string, opts ExecOptio _, _ = io.Copy(stderrSink, stderr) }() - b.cfg.Logger.Info("grok acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd) + b.cfg.Logger.Info("grok acp started", "pid", cmd.Process().Pid, "cwd", opts.Cwd) msgStream := newGrokMessageStream(256) resCh := make(chan Result, 1) @@ -292,7 +291,7 @@ func (b *grokBackend) Execute(ctx context.Context, prompt string, opts ExecOptio // documented preference is the API key when XAI_API_KEY is set and // offered, otherwise the cached login token. // Ref: https://docs.x.ai/build/cli/headless-scripting - methodID, err := selectGrokAuthMethod(extractACPAuthMethods(initResult), envHasNonEmpty(childEnv, "XAI_API_KEY")) + methodID, err := selectGrokAuthMethod(extractACPAuthMethods(initResult), envHasNonEmpty(cmd.Environment(), "XAI_API_KEY")) if err != nil { finalStatus = "failed" finalError = fmt.Sprintf("grok authentication setup failed: %v", err) @@ -447,7 +446,7 @@ func (b *grokBackend) Execute(ctx context.Context, prompt string, opts ExecOptio } duration := time.Since(startTime) - b.cfg.Logger.Info("grok finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) + b.cfg.Logger.Info("grok finished", "pid", cmd.Process().Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) stdin.Close() cancel() diff --git a/server/pkg/agent/grok_test.go b/server/pkg/agent/grok_test.go index 983ee94decd..28c80338546 100644 --- a/server/pkg/agent/grok_test.go +++ b/server/pkg/agent/grok_test.go @@ -7,11 +7,37 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" "time" ) +type recordingGrokCommandBuilder struct { + requests []CommandRequest +} + +func (b *recordingGrokCommandBuilder) Command(ctx context.Context, req CommandRequest) (TaskCommand, error) { + b.requests = append(b.requests, req) + cmd := exec.CommandContext(ctx, req.Executable, req.Args...) + cmd.Dir = req.Cwd + env, err := explicitEnvironment(req.Env) + if err != nil { + return nil, err + } + cmd.Env = env + cmd.WaitDelay = req.WaitDelay + if len(req.LeadingExtraFiles) > 0 { + cmd.ExtraFiles = append([]*os.File(nil), req.LeadingExtraFiles...) + } + return newPreparedCommand(cmd), nil +} + +func grokTestConfig(t *testing.T, executable string, env map[string]string) Config { + t.Helper() + return acpProviderTestConfigForCwd(t, executable, filepath.Dir(executable), env) +} + func TestNewReturnsGrokBackend(t *testing.T) { t.Parallel() b, err := New("grok", Config{ExecutablePath: "/nonexistent/grok"}) @@ -127,14 +153,17 @@ func TestGrokBackendStreamsAndCompletes(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "grok") writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript())) - backend, err := New("grok", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("grok", grokTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new grok backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - session, err := backend.Execute(ctx, "say pong", ExecOptions{Timeout: 5 * time.Second}) + session, err := backend.Execute(ctx, "say pong", ExecOptions{ + Cwd: filepath.Dir(fakePath), + Timeout: 5 * time.Second, + }) if err != nil { t.Fatalf("execute: %v", err) } @@ -175,6 +204,112 @@ func TestGrokBackendStreamsAndCompletes(t *testing.T) { } } +func TestGrokBackendUsesTaskCommandBuilder(t *testing.T) { + root := t.TempDir() + fakePath := filepath.Join(root, "grok") + writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript())) + + launcher := &recordingGrokCommandBuilder{} + policy := &TaskIsolationPolicy{ + WritableRoots: []string{root}, + SystemRoots: existingSystemRootsForTest(t), + Network: NetworkAccessPublicAndLoopback, + } + taskEnv := map[string]string{ + "PATH": os.Getenv("PATH"), + "TASK_VALUE": "task-only", + } + backend, err := New("grok", Config{ + ExecutablePath: fakePath, + Logger: slog.Default(), + Env: taskEnv, + Launcher: launcher, + Isolation: policy, + }) + if err != nil { + t.Fatalf("new grok backend: %v", err) + } + + session, err := backend.Execute(t.Context(), "say pong", ExecOptions{ + Cwd: root, + Timeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + go func() { + for range session.Messages { + } + }() + if result := <-session.Result; result.Status != "completed" { + t.Fatalf("result = %+v, want completed", result) + } + + if len(launcher.requests) != 1 { + t.Fatalf("launcher requests = %d, want 1", len(launcher.requests)) + } + req := launcher.requests[0] + if req.Executable != fakePath { + t.Fatalf("executable = %q, want %q", req.Executable, fakePath) + } + wantArgs := []string{"--no-auto-update", "agent", "--always-approve", "stdio"} + if strings.Join(req.Args, "\x00") != strings.Join(wantArgs, "\x00") { + t.Fatalf("args = %#v, want %#v", req.Args, wantArgs) + } + if req.Cwd != root { + t.Fatalf("cwd = %q, want %q", req.Cwd, root) + } + if req.Env["TASK_VALUE"] != "task-only" || len(req.Env) != len(taskEnv) { + t.Fatalf("env = %#v, want explicit task env %#v", req.Env, taskEnv) + } + if req.Isolation != policy { + t.Fatalf("isolation = %p, want task policy %p", req.Isolation, policy) + } +} + +func TestGrokBackendFailsClosedWithoutTaskExecutionAuthority(t *testing.T) { + root := t.TempDir() + marker := filepath.Join(root, "started") + fakePath := filepath.Join(root, "grok") + writeTestExecutable(t, fakePath, []byte("#!/bin/sh\ntouch '"+marker+"'\nexit 95\n")) + policy := &TaskIsolationPolicy{ + WritableRoots: []string{root}, + SystemRoots: existingSystemRootsForTest(t), + Network: NetworkAccessPublicAndLoopback, + } + + tests := []struct { + name string + launcher CommandBuilder + isolation *TaskIsolationPolicy + wantError string + }{ + {name: "missing launcher", isolation: policy, wantError: "launcher is required"}, + {name: "missing isolation", launcher: newCommandLauncher(&acpProviderTestIsolation{}), wantError: "isolation policy is required"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + backend, err := New("grok", Config{ + ExecutablePath: fakePath, + Logger: slog.Default(), + Env: map[string]string{"PATH": os.Getenv("PATH")}, + Launcher: tt.launcher, + Isolation: tt.isolation, + }) + if err != nil { + t.Fatalf("new grok backend: %v", err) + } + _, err = backend.Execute(t.Context(), "task", ExecOptions{Cwd: root, Timeout: time.Second}) + if err == nil || !strings.Contains(err.Error(), tt.wantError) { + t.Fatalf("execute error = %v, want %q", err, tt.wantError) + } + if _, statErr := os.Stat(marker); !os.IsNotExist(statErr) { + t.Fatalf("grok process started without task authority: stat error = %v", statErr) + } + }) + } +} + func TestGrokBlockedArgsFiltering(t *testing.T) { t.Parallel() tempDir := t.TempDir() @@ -182,11 +317,7 @@ func TestGrokBlockedArgsFiltering(t *testing.T) { fakePath := filepath.Join(tempDir, "grok") writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript())) - backend, err := New("grok", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{"GROK_ARGS_FILE": argsFile}, - }) + backend, err := New("grok", grokTestConfig(t, fakePath, map[string]string{"GROK_ARGS_FILE": argsFile})) if err != nil { t.Fatalf("new grok backend: %v", err) } @@ -194,6 +325,7 @@ func TestGrokBlockedArgsFiltering(t *testing.T) { defer cancel() session, err := backend.Execute(ctx, "task", ExecOptions{ + Cwd: tempDir, Timeout: 5 * time.Second, ThinkingLevel: "high", // Users must not strip ACP mode, disable auto-approve, or switch @@ -257,14 +389,18 @@ func TestGrokSetModelFailureFailsTask(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "grok") writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript())) - backend, err := New("grok", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("grok", grokTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new grok backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - session, err := backend.Execute(ctx, "task", ExecOptions{Model: "bogus-model", Timeout: 5 * time.Second}) + session, err := backend.Execute(ctx, "task", ExecOptions{ + Cwd: filepath.Dir(fakePath), + Model: "bogus-model", + Timeout: 5 * time.Second, + }) if err != nil { t.Fatalf("execute: %v", err) } @@ -291,11 +427,7 @@ func TestGrokUsesSessionLoadForResume(t *testing.T) { fakePath := filepath.Join(tempDir, "grok") writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript())) - backend, err := New("grok", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{"GROK_REQUESTS_FILE": requestsFile}, - }) + backend, err := New("grok", grokTestConfig(t, fakePath, map[string]string{"GROK_REQUESTS_FILE": requestsFile})) if err != nil { t.Fatalf("new grok backend: %v", err) } @@ -303,6 +435,7 @@ func TestGrokUsesSessionLoadForResume(t *testing.T) { defer cancel() session, err := backend.Execute(ctx, "continue", ExecOptions{ + Cwd: tempDir, ResumeSessionID: "ses_existing", Timeout: 5 * time.Second, }) @@ -356,11 +489,7 @@ func TestGrokAuthenticatesBeforeSession(t *testing.T) { fakePath := filepath.Join(tempDir, "grok") writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript())) - backend, err := New("grok", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{"GROK_REQUESTS_FILE": requestsFile}, - }) + backend, err := New("grok", grokTestConfig(t, fakePath, map[string]string{"GROK_REQUESTS_FILE": requestsFile})) if err != nil { t.Fatalf("new grok backend: %v", err) } @@ -368,6 +497,7 @@ func TestGrokAuthenticatesBeforeSession(t *testing.T) { defer cancel() session, err := backend.Execute(ctx, "task", ExecOptions{ + Cwd: tempDir, ResumeSessionID: tc.resume, Timeout: 5 * time.Second, }) @@ -427,18 +557,17 @@ func TestGrokAuthFailureFailsTask(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "grok") writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript())) - backend, err := New("grok", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{"GROK_AUTH_FAIL": "1"}, - }) + backend, err := New("grok", grokTestConfig(t, fakePath, map[string]string{"GROK_AUTH_FAIL": "1"})) if err != nil { t.Fatalf("new grok backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - session, err := backend.Execute(ctx, "task", ExecOptions{Timeout: 5 * time.Second}) + session, err := backend.Execute(ctx, "task", ExecOptions{ + Cwd: filepath.Dir(fakePath), + Timeout: 5 * time.Second, + }) if err != nil { t.Fatalf("execute: %v", err) } @@ -463,19 +592,18 @@ func TestGrokNoUsableAuthMethodFailsBeforeSession(t *testing.T) { fakePath := filepath.Join(tempDir, "grok") writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript())) - backend, err := New("grok", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{ - "GROK_AUTH_METHODS": methods, - "GROK_REQUESTS_FILE": requestsFile, - "XAI_API_KEY": "", - }, - }) + backend, err := New("grok", grokTestConfig(t, fakePath, map[string]string{ + "GROK_AUTH_METHODS": methods, + "GROK_REQUESTS_FILE": requestsFile, + "XAI_API_KEY": "", + })) if err != nil { t.Fatalf("new grok backend: %v", err) } - session, err := backend.Execute(context.Background(), "task", ExecOptions{Timeout: 5 * time.Second}) + session, err := backend.Execute(context.Background(), "task", ExecOptions{ + Cwd: tempDir, + Timeout: 5 * time.Second, + }) if err != nil { t.Fatalf("execute: %v", err) } @@ -504,19 +632,18 @@ func TestGrokUsesAdvertisedAPIKeyMethod(t *testing.T) { fakePath := filepath.Join(tempDir, "grok") writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript())) - backend, err := New("grok", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{ - "GROK_AUTH_METHODS": "api", - "GROK_REQUESTS_FILE": requestsFile, - "XAI_API_KEY": "test-only-key", - }, - }) + backend, err := New("grok", grokTestConfig(t, fakePath, map[string]string{ + "GROK_AUTH_METHODS": "api", + "GROK_REQUESTS_FILE": requestsFile, + "XAI_API_KEY": "test-only-key", + })) if err != nil { t.Fatalf("new grok backend: %v", err) } - session, err := backend.Execute(context.Background(), "task", ExecOptions{Timeout: 5 * time.Second}) + session, err := backend.Execute(context.Background(), "task", ExecOptions{ + Cwd: tempDir, + Timeout: 5 * time.Second, + }) if err != nil { t.Fatalf("execute: %v", err) } @@ -540,15 +667,14 @@ func TestGrokUsesAdvertisedAPIKeyMethod(t *testing.T) { func TestGrokDrainsNotificationsAfterPromptResponse(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "grok") writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript())) - backend, err := New("grok", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{"GROK_LATE_CHUNK": "1"}, - }) + backend, err := New("grok", grokTestConfig(t, fakePath, map[string]string{"GROK_LATE_CHUNK": "1"})) if err != nil { t.Fatalf("new grok backend: %v", err) } - session, err := backend.Execute(context.Background(), "task", ExecOptions{Timeout: 5 * time.Second}) + session, err := backend.Execute(context.Background(), "task", ExecOptions{ + Cwd: filepath.Dir(fakePath), + Timeout: 5 * time.Second, + }) if err != nil { t.Fatalf("execute: %v", err) } @@ -570,18 +696,15 @@ func TestGrokPropagatesMCPAndUsage(t *testing.T) { requestsFile := filepath.Join(tempDir, "requests.jsonl") fakePath := filepath.Join(tempDir, "grok") writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript())) - backend, err := New("grok", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{ - "GROK_REQUESTS_FILE": requestsFile, - "GROK_USAGE": "1", - }, - }) + backend, err := New("grok", grokTestConfig(t, fakePath, map[string]string{ + "GROK_REQUESTS_FILE": requestsFile, + "GROK_USAGE": "1", + })) if err != nil { t.Fatalf("new grok backend: %v", err) } session, err := backend.Execute(context.Background(), "task", ExecOptions{ + Cwd: tempDir, Timeout: 5 * time.Second, McpConfig: json.RawMessage(`{"mcpServers":{"fetch":{"command":"uvx","args":["mcp-server-fetch"]}}}`), }) @@ -628,20 +751,19 @@ func TestGrokTimeoutAndCancellation(t *testing.T) { fakePath := filepath.Join(tempDir, "grok") requestsFile := filepath.Join(tempDir, "requests.jsonl") writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript())) - backend, err := New("grok", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{ - "GROK_HANG_PROMPT": "1", - "GROK_REQUESTS_FILE": requestsFile, - }, - }) + backend, err := New("grok", grokTestConfig(t, fakePath, map[string]string{ + "GROK_HANG_PROMPT": "1", + "GROK_REQUESTS_FILE": requestsFile, + })) if err != nil { t.Fatalf("new grok backend: %v", err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - session, err := backend.Execute(ctx, "task", ExecOptions{Timeout: tc.timeout}) + session, err := backend.Execute(ctx, "task", ExecOptions{ + Cwd: tempDir, + Timeout: tc.timeout, + }) if err != nil { t.Fatalf("execute: %v", err) } @@ -793,6 +915,31 @@ func TestGrokValidateThinkingLevelUsesPerModelCatalog(t *testing.T) { } } +func TestGrokTaskThinkingValidationUsesStaticCatalogWithoutRuntimeExecution(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("malicious runtime fixture is a POSIX shell script") + } + root := t.TempDir() + marker := filepath.Join(root, "grok-executed") + executable := filepath.Join(root, "grok") + writeTestExecutable(t, executable, []byte("#!/bin/sh\ntouch '"+marker+"'\nexit 95\n")) + + ok, err := ValidateThinkingLevelForTask( + t.Context(), + "grok", + executable, + "grok-4.5", + "high", + TaskModelDiscovery{}, + ) + if err != nil || !ok { + t.Fatalf("static task validation = (%v, %v), want (true, nil)", ok, err) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("grok runtime executed during static validation: stat error = %v", err) + } +} + // TestGrokSelectAuthMethod covers the auth-method selection preference. func TestGrokSelectAuthMethod(t *testing.T) { t.Parallel() @@ -850,7 +997,8 @@ func TestGrokRealACPSmoke(t *testing.T) { t.Logf("grok CLI version unavailable: %v (%s)", err, strings.TrimSpace(string(version))) } - backend, err := New("grok", Config{ExecutablePath: path, Logger: slog.Default()}) + workDir := t.TempDir() + backend, err := New("grok", acpProviderTestConfigForCwd(t, path, workDir, inheritedEnvironmentForTest())) if err != nil { t.Fatalf("new grok backend: %v", err) } @@ -858,7 +1006,7 @@ func TestGrokRealACPSmoke(t *testing.T) { defer cancel() session, err := backend.Execute(ctx, "Reply with exactly one word: pong. Do not use any tools.", ExecOptions{ - Cwd: t.TempDir(), + Cwd: workDir, Timeout: 80 * time.Second, }) if err != nil { diff --git a/server/pkg/agent/hermes.go b/server/pkg/agent/hermes.go index c321387487c..5f4b753c778 100644 --- a/server/pkg/agent/hermes.go +++ b/server/pkg/agent/hermes.go @@ -166,9 +166,14 @@ func (b *hermesBackend) Execute(ctx context.Context, prompt string, opts ExecOpt if execPath == "" { execPath = "hermes" } - if _, err := exec.LookPath(execPath); err != nil { + resolvedExecPath, err := exec.LookPath(execPath) + if err != nil { return nil, fmt.Errorf("hermes executable not found at %q: %w", execPath, err) } + resolvedExecPath, err = filepath.Abs(resolvedExecPath) + if err != nil { + return nil, fmt.Errorf("resolve hermes executable %q: %w", resolvedExecPath, err) + } // Translate the agent's mcp_config (Claude-style object of objects) // into the array shape ACP `session/new` expects. Fail closed on @@ -183,12 +188,29 @@ func (b *hermesBackend) Execute(ctx context.Context, prompt string, opts ExecOpt runCtx, cancel := runContext(ctx, timeout) hermesArgs := append([]string{"acp"}, filterCustomArgs(opts.CustomArgs, hermesBlockedArgs, b.cfg.Logger)...) - cmd := exec.CommandContext(runCtx, execPath, hermesArgs...) - hideAgentWindow(cmd) - b.cfg.Logger.Info("agent command", "exec", execPath, "args", hermesArgs) + cwd := opts.Cwd + if cwd == "" { + cwd, err = os.Getwd() + if err != nil { + cancel() + return nil, fmt.Errorf("resolve hermes working directory: %w", err) + } + } + + commandCfg := b.cfg + commandCfg.Env = make(map[string]string, len(b.cfg.Env)+1) + for key, value := range b.cfg.Env { + commandCfg.Env[key] = value + } + commandCfg.Env["HERMES_YOLO_MODE"] = "1" + cmd, err := commandCfg.command(runCtx, resolvedExecPath, hermesArgs, cwd, 10*time.Second) + if err != nil { + cancel() + return nil, fmt.Errorf("build hermes command: %w", err) + } + b.cfg.Logger.Info("agent command", "exec", resolvedExecPath, "args", hermesArgs) agentsMDPresent := false if opts.Cwd != "" { - cmd.Dir = opts.Cwd if _, err := os.Stat(filepath.Join(opts.Cwd, "AGENTS.md")); err == nil { agentsMDPresent = true } @@ -198,11 +220,6 @@ func (b *hermesBackend) Execute(ctx context.Context, prompt string, opts ExecOpt b.cfg.Logger.Debug("hermes ignoring ExecOptions.SystemPrompt; using cwd-scoped context files", "cwd", opts.Cwd) } - env := buildEnv(b.cfg.Env) - // Enable yolo mode so Hermes auto-approves all tool executions. - env = append(env, "HERMES_YOLO_MODE=1") - cmd.Env = env - stdout, err := cmd.StdoutPipe() if err != nil { cancel() @@ -249,7 +266,7 @@ func (b *hermesBackend) Execute(ctx context.Context, prompt string, opts ExecOpt _, _ = io.Copy(stderrSink, stderr) }() - b.cfg.Logger.Info("hermes acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd) + b.cfg.Logger.Info("hermes acp started", "pid", cmd.Process().Pid, "cwd", opts.Cwd) msgCh := make(chan Message, 256) resCh := make(chan Result, 1) @@ -512,7 +529,7 @@ func (b *hermesBackend) Execute(ctx context.Context, prompt string, opts ExecOpt } duration := time.Since(startTime) - b.cfg.Logger.Info("hermes finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) + b.cfg.Logger.Info("hermes finished", "pid", cmd.Process().Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) // Close stdin and cancel context to signal hermes acp to exit. stdin.Close() diff --git a/server/pkg/agent/hermes_test.go b/server/pkg/agent/hermes_test.go index d1eda15921b..fec86c2c5b7 100644 --- a/server/pkg/agent/hermes_test.go +++ b/server/pkg/agent/hermes_test.go @@ -13,6 +13,55 @@ import ( "time" ) +type acpProviderTestIsolation struct{} + +func (*acpProviderTestIsolation) WrapBound(_ *boundIsolationPolicy, executable, cwd pathIdentity, args []string, leadingExtraFiles int) (string, []string, []*os.File, error) { + _ = cwd + _ = leadingExtraFiles + return executable.Path, args, nil, nil +} + +func acpProviderTestConfig(t *testing.T, executable string, env map[string]string) Config { + t.Helper() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("get provider test working directory: %v", err) + } + return acpProviderTestConfigForCwd(t, executable, cwd, env) +} + +func acpProviderTestConfigForCwd(t *testing.T, executable, cwd string, env map[string]string) Config { + t.Helper() + taskEnv := make(map[string]string, len(env)+1) + for key, value := range env { + taskEnv[key] = value + } + if taskEnv["PATH"] == "" { + taskEnv["PATH"] = os.Getenv("PATH") + } + return Config{ + ExecutablePath: executable, + Logger: slog.Default(), + Env: taskEnv, + Launcher: newCommandLauncher(&acpProviderTestIsolation{}), + Isolation: &TaskIsolationPolicy{ + WritableRoots: []string{filepath.Dir(executable), cwd}, + Network: NetworkAccessPublicAndLoopback, + }, + } +} + +func inheritedEnvironmentForTest() map[string]string { + env := make(map[string]string) + for _, entry := range os.Environ() { + key, value, ok := strings.Cut(entry, "=") + if ok { + env[key] = value + } + } + return env +} + func TestNewReturnsHermesBackend(t *testing.T) { t.Parallel() b, err := New("hermes", Config{ExecutablePath: "/nonexistent/hermes"}) @@ -1470,7 +1519,7 @@ func TestHermesBackendAttributesUsageToACPDefaultModel(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "hermes") writeTestExecutable(t, fakePath, []byte(fakeHermesACPUsageWithDefaultModelScript())) - backend, err := New("hermes", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("hermes", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new hermes backend: %v", err) } @@ -1614,7 +1663,7 @@ func TestHermesBackendPromotesProviderErrorWithNonEmptyOutput(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "hermes") writeTestExecutable(t, fakePath, []byte(fakeHermesACPRateLimitScript())) - backend, err := New("hermes", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("hermes", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new hermes backend: %v", err) } @@ -1760,7 +1809,7 @@ func TestHermesBackendClearsSessionIDWhenResumedSessionNotFound(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "hermes") writeTestExecutable(t, fakePath, []byte(fakeHermesACPStaleResumeScript())) - backend, err := New("hermes", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("hermes", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new hermes backend: %v", err) } @@ -1837,7 +1886,7 @@ func TestHermesBackendClearsSessionIDWhenSetModelSessionNotFound(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "hermes") writeTestExecutable(t, fakePath, []byte(fakeHermesACPStaleResumeSetModelScript())) - backend, err := New("hermes", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("hermes", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new hermes backend: %v", err) } @@ -1921,7 +1970,7 @@ func TestHermesBackendDoesNotPromoteOnTransientRetry(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "hermes") writeTestExecutable(t, fakePath, []byte(fakeHermesACPTransientRetryScript())) - backend, err := New("hermes", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("hermes", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new hermes backend: %v", err) } @@ -2094,7 +2143,7 @@ func TestHermesExecuteFailsClosedOnMalformedMcpConfig(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "hermes") writeTestExecutable(t, fakePath, []byte("#!/bin/sh\nexit 0\n")) - backend, err := New("hermes", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("hermes", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new hermes backend: %v", err) } @@ -2182,7 +2231,7 @@ func TestHermesSetModelPreservesCustomModelIDWithColon(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "hermes") writeTestExecutable(t, fakePath, []byte(fakeACPRecordingScript(recordPath, "ses_new", `{}`))) - backend, err := New("hermes", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("hermes", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new hermes backend: %v", err) } @@ -2233,7 +2282,7 @@ func TestHermesResumeIncludesMcpServers(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "hermes") writeTestExecutable(t, fakePath, []byte(fakeACPRecordingScript(recordPath, "ses_resume", `{}`))) - backend, err := New("hermes", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("hermes", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new hermes backend: %v", err) } @@ -2288,7 +2337,7 @@ func TestHermesDropsRemoteMcpWhenCapabilityNotAdvertised(t *testing.T) { // agentCapabilities = {} → neither http nor sse advertised. writeTestExecutable(t, fakePath, []byte(fakeACPRecordingScript(recordPath, "ses_new", `{}`))) - backend, err := New("hermes", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("hermes", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new hermes backend: %v", err) } @@ -2340,7 +2389,7 @@ func TestHermesKeepsRemoteMcpWhenCapabilityAdvertised(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "hermes") writeTestExecutable(t, fakePath, []byte(fakeACPRecordingScript(recordPath, "ses_new", `{"mcpCapabilities":{"http":true,"sse":true}}`))) - backend, err := New("hermes", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("hermes", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new hermes backend: %v", err) } diff --git a/server/pkg/agent/isolation.go b/server/pkg/agent/isolation.go new file mode 100644 index 00000000000..fd545ae2bce --- /dev/null +++ b/server/pkg/agent/isolation.go @@ -0,0 +1,330 @@ +package agent + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" +) + +// NetworkAccess is the network namespace exposed to an agent task. +type NetworkAccess uint8 + +const ( + NetworkAccessNone NetworkAccess = iota + NetworkAccessPublicAndLoopback +) + +// TaskIsolationPolicy is the complete filesystem and network authority for one +// task. Roots are resolved to stable, existing paths before a process is built. +type TaskIsolationPolicy struct { + WritableRoots []string + ReadOnlyRoots []string + ReadOnlyFiles []ReadOnlyFileMount + SystemRoots []string + ForbiddenRoots []string + Network NetworkAccess +} + +type ReadOnlyFileMount struct { + Source string + Target string +} + +type platformIsolation interface { + // leadingExtraFiles is the number of caller-owned ExtraFiles that will + // occupy child FDs starting at 3 before isolation-owned descriptors. + WrapBound(*boundIsolationPolicy, pathIdentity, pathIdentity, []string, int) (string, []string, []*os.File, error) +} + +func newPlatformIsolation() platformIsolation { + switch runtime.GOOS { + case "darwin": + return newDarwinIsolation("/usr/bin/sandbox-exec") + case "linux": + return newLinuxIsolation("/usr/bin/bwrap") + default: + return newUnsupportedIsolation(runtime.GOOS) + } +} + +// Validated returns a canonical copy whose roots exist and contain no symlink +// aliases. Any overlap with a forbidden root rejects the entire policy. +func (p TaskIsolationPolicy) Validated() (TaskIsolationPolicy, error) { + if len(p.WritableRoots) == 0 { + return TaskIsolationPolicy{}, fmt.Errorf("at least one writable root is required") + } + if p.Network != NetworkAccessNone && p.Network != NetworkAccessPublicAndLoopback { + return TaskIsolationPolicy{}, fmt.Errorf("unsupported network access %d", p.Network) + } + + var err error + if p.WritableRoots, err = validateRoots("writable", p.WritableRoots); err != nil { + return TaskIsolationPolicy{}, err + } + if p.ReadOnlyRoots, err = validateRoots("read-only", p.ReadOnlyRoots); err != nil { + return TaskIsolationPolicy{}, err + } + if p.SystemRoots, err = validateRoots("system", p.SystemRoots); err != nil { + return TaskIsolationPolicy{}, err + } + if p.ForbiddenRoots, err = validateRoots("forbidden", p.ForbiddenRoots); err != nil { + return TaskIsolationPolicy{}, err + } + allowedRoots := append(append(append([]string(nil), p.WritableRoots...), p.ReadOnlyRoots...), p.SystemRoots...) + if p.ReadOnlyFiles, err = validateReadOnlyFiles(p.ReadOnlyFiles, p.WritableRoots, p.ForbiddenRoots); err != nil { + return TaskIsolationPolicy{}, err + } + + for _, root := range allowedRoots { + for _, forbidden := range p.ForbiddenRoots { + if pathsOverlap(root, forbidden) { + return TaskIsolationPolicy{}, fmt.Errorf("allowed root %q overlaps forbidden root %q", root, forbidden) + } + } + } + return p, nil +} + +func validateReadOnlyFiles(files []ReadOnlyFileMount, writableRoots, forbiddenRoots []string) ([]ReadOnlyFileMount, error) { + seenTargets := make(map[string]struct{}, len(files)) + validated := make([]ReadOnlyFileMount, 0, len(files)) + for _, mount := range files { + source, err := validateAbsolutePath("read-only file source", mount.Source) + if err != nil { + return nil, err + } + target, err := validateAbsolutePath("read-only file target", mount.Target) + if err != nil { + return nil, err + } + if pathsOverlap(target, "/dev") || pathsOverlap(target, "/proc") { + return nil, fmt.Errorf("read-only file target %q overlaps an isolated pseudo-filesystem", target) + } + if _, exists := seenTargets[target]; exists { + return nil, fmt.Errorf("duplicate read-only file target %q", target) + } + seenTargets[target] = struct{}{} + for _, writable := range writableRoots { + if pathWithin(source, writable) { + return nil, fmt.Errorf("read-only file source %q is inside writable root %q", source, writable) + } + if pathsOverlap(target, writable) { + return nil, fmt.Errorf("read-only file target %q overlaps writable root %q", target, writable) + } + } + for _, forbidden := range forbiddenRoots { + if pathWithin(source, forbidden) { + return nil, fmt.Errorf("read-only file source %q is inside forbidden root %q", source, forbidden) + } + } + identity, err := openFileIdentity("read-only file source", source) + if err != nil { + return nil, err + } + _ = identity.Close() + validated = append(validated, ReadOnlyFileMount{Source: source, Target: target}) + } + sort.Slice(validated, func(i, j int) bool { return validated[i].Target < validated[j].Target }) + return validated, nil +} + +func validateRoots(kind string, roots []string) ([]string, error) { + seen := make(map[string]struct{}, len(roots)) + validated := make([]string, 0, len(roots)) + for _, root := range roots { + clean, err := validateAbsolutePath(kind+" root", root) + if err != nil { + return nil, err + } + resolved, err := filepath.EvalSymlinks(clean) + if err != nil { + return nil, fmt.Errorf("resolve %s root %q: %w", kind, clean, err) + } + info, err := os.Stat(resolved) + if err != nil { + return nil, fmt.Errorf("stat %s root %q: %w", kind, resolved, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("%s root %q is not a directory", kind, resolved) + } + stableAlias := isStableSystemPathAlias(clean, resolved) + if resolved != clean && !stableAlias { + return nil, fmt.Errorf("%s root %q resolves through symlink to %q", kind, clean, resolved) + } + if !stableAlias || kind != "system" || runtime.GOOS != "linux" { + resolved = clean + } + if _, ok := seen[resolved]; ok { + continue + } + seen[resolved] = struct{}{} + validated = append(validated, resolved) + } + sort.Strings(validated) + return validated, nil +} + +func isStableSystemPathAlias(path, resolved string) bool { + return isStableSystemPathAliasForOS(runtime.GOOS, path, resolved) +} + +func resolveStableSystemPathAlias(kind, path string) (string, error) { + clean, err := validateAbsolutePath(kind, path) + if err != nil { + return "", err + } + if runtime.GOOS != "linux" { + return clean, nil + } + resolved, err := filepath.EvalSymlinks(clean) + if err != nil { + return "", fmt.Errorf("resolve %s %q: %w", kind, clean, err) + } + if resolved == clean { + return clean, nil + } + if isStableSystemPathAlias(clean, resolved) { + return resolved, nil + } + if isResolvedStableSystemAliasForOS(runtime.GOOS, clean, resolved) { + return resolved, nil + } + return "", fmt.Errorf("%s %q resolves through symlink to %q", kind, clean, resolved) +} + +func isResolvedStableSystemAliasForOS(goos, path, resolved string) bool { + canonical, ok := stableSystemAliasPathForOS(goos, path) + return ok && pathWithin(resolved, canonicalRoot(canonical)) +} + +func stableSystemAliasPathForOS(goos, path string) (string, bool) { + for alias, canonical := range stableSystemAliasesForOS(goos) { + if path == alias { + return canonical, true + } + if strings.HasPrefix(path, alias+"/") { + return canonical + strings.TrimPrefix(path, alias), true + } + } + return "", false +} + +func canonicalRoot(path string) string { + parts := strings.Split(strings.TrimPrefix(path, "/"), "/") + if len(parts) < 2 { + return path + } + return "/" + filepath.Join(parts[0], parts[1]) +} + +func stableSystemAliasesForOS(goos string) map[string]string { + switch goos { + case "darwin": + return map[string]string{ + "/etc": "/private/etc", + "/tmp": "/private/tmp", + "/var": "/private/var", + } + case "linux": + return map[string]string{ + "/bin": "/usr/bin", + "/lib": "/usr/lib", + "/lib64": "/usr/lib64", + } + default: + return nil + } +} + +func isStableSystemPathAliasForOS(goos, path, resolved string) bool { + for alias, canonical := range stableSystemAliasesForOS(goos) { + if path == alias && resolved == canonical { + return true + } + if strings.HasPrefix(path, alias+"/") && resolved == canonical+strings.TrimPrefix(path, alias) { + return true + } + } + return false +} + +func validateAbsolutePath(kind, path string) (string, error) { + if path == "" || !filepath.IsAbs(path) { + return "", fmt.Errorf("%s %q must be absolute", kind, path) + } + clean := filepath.Clean(path) + if clean != path || containsParentTraversal(path) { + return "", fmt.Errorf("%s %q must be canonical and contain no parent traversal", kind, path) + } + return clean, nil +} + +func containsParentTraversal(path string) bool { + for _, part := range strings.Split(filepath.ToSlash(path), "/") { + if part == ".." { + return true + } + } + return false +} + +func pathWithinAny(path string, roots []string) bool { + for _, root := range roots { + if pathWithin(path, root) { + return true + } + } + return false +} + +func pathWithin(path, root string) bool { + rel, err := filepath.Rel(root, path) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func pathsOverlap(left, right string) bool { + return pathWithin(left, right) || pathWithin(right, left) +} + +func validateProtectedTargetsAgainstCommandPaths(files []boundReadOnlyFileMount, executable, cwd pathIdentity) error { + for _, file := range files { + for _, commandPath := range []struct { + kind string + path string + }{ + {kind: "cwd", path: cwd.Path}, + {kind: "executable", path: executable.Path}, + } { + if pathsOverlap(file.Target, commandPath.path) { + return fmt.Errorf("read-only file target %q overlaps command %s %q", file.Target, commandPath.kind, commandPath.path) + } + } + } + return nil +} + +type unsupportedIsolation struct { + goos string +} + +func newUnsupportedIsolation(goos string) platformIsolation { + return &unsupportedIsolation{goos: goos} +} + +func (u *unsupportedIsolation) WrapBound(*boundIsolationPolicy, pathIdentity, pathIdentity, []string, int) (string, []string, []*os.File, error) { + return "", nil, nil, fmt.Errorf("task process isolation is unsupported on %s", u.goos) +} + +func isolationLaunchDirectory(platform platformIsolation) string { + switch platform.(type) { + case *linuxIsolation: + return "/" + default: + // Darwin seatbelt authorizes by path and has no fd-bound chdir primitive. + // Callers still re-validate cwd identity immediately before Start. + return "" + } +} diff --git a/server/pkg/agent/isolation_bubblewrap.go b/server/pkg/agent/isolation_bubblewrap.go new file mode 100644 index 00000000000..fe4f512b6c6 --- /dev/null +++ b/server/pkg/agent/isolation_bubblewrap.go @@ -0,0 +1,235 @@ +package agent + +import ( + "fmt" + "os" + "path/filepath" + "sort" +) + +type linuxIsolation struct { + helper string +} + +func newLinuxIsolation(helper string) platformIsolation { + return &linuxIsolation{helper: helper} +} + +func (l *linuxIsolation) WrapBound(policy *boundIsolationPolicy, executable, cwd pathIdentity, args []string, leadingExtraFiles int) (string, []string, []*os.File, error) { + if err := validateIsolationHelper(l.helper); err != nil { + return "", nil, nil, err + } + if policy == nil { + return "", nil, nil, fmt.Errorf("bound isolation policy is required") + } + if err := recheckBoundIsolation(policy); err != nil { + return "", nil, nil, err + } + if err := recheckPathIdentity(&executable); err != nil { + return "", nil, nil, err + } + if err := recheckPathIdentity(&cwd); err != nil { + return "", nil, nil, err + } + if err := rejectLinuxHostPseudoFilesystemBindings(policy.policy()); err != nil { + return "", nil, nil, err + } + + wrapped, extraFiles, err := renderLinuxBubblewrapArgsBound(policy, executable, cwd, args, leadingExtraFiles) + if err != nil { + return "", nil, nil, err + } + return l.helper, wrapped, extraFiles, nil +} + +func renderLinuxBubblewrapArgs(policy TaskIsolationPolicy, executable string, commandArgs []string) ([]string, error) { + // Compatibility helper for pure path-based unit tests. Production launch + // uses renderLinuxBubblewrapArgsBound with retained directory descriptors. + validated, err := policy.Validated() + if err != nil { + return nil, err + } + if _, err := validateAbsolutePath("executable", executable); err != nil { + return nil, err + } + if err := rejectLinuxHostPseudoFilesystemBindings(validated); err != nil { + return nil, err + } + args := []string{ + "--die-with-parent", + "--new-session", + "--unshare-all", + } + if validated.Network == NetworkAccessPublicAndLoopback { + args = append(args, "--share-net") + } + args = append(args, + "--clearenv", + "--dev", "/dev", + "--proc", "/proc", + ) + roots := append(append([]string(nil), validated.ReadOnlyRoots...), validated.SystemRoots...) + sort.Strings(roots) + created := make(map[string]struct{}) + for _, root := range append(append([]string(nil), roots...), validated.WritableRoots...) { + for _, parent := range missingNamespaceParents(root) { + if _, ok := created[parent]; ok { + continue + } + created[parent] = struct{}{} + args = append(args, "--dir", parent) + } + } + for _, root := range roots { + args = append(args, "--ro-bind", root, root) + } + for _, root := range validated.WritableRoots { + args = append(args, "--bind", root, root) + } + args = append(args, "--", executable) + args = append(args, commandArgs...) + return args, nil +} + +func renderLinuxBubblewrapArgsBound(policy *boundIsolationPolicy, executable, cwd pathIdentity, commandArgs []string, leadingExtraFiles int) ([]string, []*os.File, error) { + if policy == nil { + return nil, nil, fmt.Errorf("bound isolation policy is required") + } + if executable.File == nil || cwd.File == nil { + return nil, nil, fmt.Errorf("linux isolation requires open executable and cwd descriptors") + } + + args := []string{ + "--die-with-parent", + "--new-session", + "--unshare-all", + } + if policy.Network == NetworkAccessPublicAndLoopback { + args = append(args, "--share-net") + } + args = append(args, + "--clearenv", + "--dev", "/dev", + "--proc", "/proc", + ) + + type mount struct { + identity pathIdentity + writable bool + } + var mounts []mount + for _, root := range policy.ReadOnlyRoots { + mounts = append(mounts, mount{identity: root, writable: false}) + } + for _, root := range policy.SystemRoots { + mounts = append(mounts, mount{identity: root, writable: false}) + } + for _, root := range policy.WritableRoots { + mounts = append(mounts, mount{identity: root, writable: true}) + } + sort.SliceStable(mounts, func(i, j int) bool { + if mounts[i].identity.Path == mounts[j].identity.Path { + return !mounts[i].writable && mounts[j].writable + } + return mounts[i].identity.Path < mounts[j].identity.Path + }) + + created := make(map[string]struct{}) + for _, m := range mounts { + for _, parent := range missingNamespaceParents(m.identity.Path) { + if _, ok := created[parent]; ok { + continue + } + created[parent] = struct{}{} + args = append(args, "--dir", parent) + } + } + for _, file := range policy.ReadOnlyFiles { + for _, parent := range missingNamespaceParents(file.Target) { + if _, ok := created[parent]; ok { + continue + } + created[parent] = struct{}{} + args = append(args, "--dir", parent) + } + } + + if leadingExtraFiles < 0 { + return nil, nil, fmt.Errorf("leading extra files count must not be negative") + } + extraFiles := make([]*os.File, 0, len(mounts)+2) + // exec.Cmd.ExtraFiles starts at child FD 3. Callers may reserve leading + // descriptors (for example Pi session FD 3) before isolation mounts. + childFD := 3 + leadingExtraFiles + for _, m := range mounts { + if m.identity.File == nil { + return nil, nil, fmt.Errorf("linux isolation root %q is missing a descriptor", m.identity.Path) + } + if m.writable { + args = append(args, "--bind-fd", fmt.Sprintf("%d", childFD), m.identity.Path) + } else { + args = append(args, "--ro-bind-fd", fmt.Sprintf("%d", childFD), m.identity.Path) + } + extraFiles = append(extraFiles, m.identity.File) + childFD++ + } + // Overmount cwd and executable at their original namespace paths from the + // retained descriptors. This preserves expected absolute paths while the + // final chdir and exec resolve to the validated objects, not host pathnames. + args = append(args, "--bind-fd", fmt.Sprintf("%d", childFD), cwd.Path) + extraFiles = append(extraFiles, cwd.File) + childFD++ + args = append(args, "--ro-bind-fd", fmt.Sprintf("%d", childFD), executable.Path) + extraFiles = append(extraFiles, executable.File) + childFD++ + + // Protected exact files are mounted last so no namespace destination added + // by this renderer can shadow daemon-published task authority. + for _, file := range policy.ReadOnlyFiles { + if file.Identity.File == nil { + return nil, nil, fmt.Errorf("linux isolation file %q is missing a descriptor", file.Identity.Path) + } + args = append(args, "--ro-bind-fd", fmt.Sprintf("%d", childFD), file.Target) + extraFiles = append(extraFiles, file.Identity.File) + childFD++ + } + args = append(args, "--chdir", cwd.Path) + args = append(args, "--", executable.Path) + args = append(args, commandArgs...) + return args, extraFiles, nil +} + +func rejectLinuxHostPseudoFilesystemBindings(policy TaskIsolationPolicy) error { + roots := append(append(append([]string(nil), policy.WritableRoots...), policy.ReadOnlyRoots...), policy.SystemRoots...) + for _, root := range roots { + for _, isolated := range []string{"/dev", "/proc"} { + if pathsOverlap(root, isolated) { + return fmt.Errorf("allowed root %q overlaps isolated Linux pseudo-filesystem %q", root, isolated) + } + } + } + for _, file := range policy.ReadOnlyFiles { + for _, isolated := range []string{"/dev", "/proc"} { + if pathsOverlap(file.Target, isolated) { + return fmt.Errorf("read-only file target %q overlaps isolated Linux pseudo-filesystem %q", file.Target, isolated) + } + } + } + return nil +} + +func missingNamespaceParents(path string) []string { + var reversed []string + for parent := filepath.Dir(path); parent != "/" && parent != "."; parent = filepath.Dir(parent) { + reversed = append(reversed, parent) + } + parents := make([]string, 0, len(reversed)) + for i := len(reversed) - 1; i >= 0; i-- { + parents = append(parents, reversed[i]) + } + return parents +} + +func (l *linuxIsolation) String() string { + return fmt.Sprintf("bubblewrap(%s)", l.helper) +} diff --git a/server/pkg/agent/isolation_capability.go b/server/pkg/agent/isolation_capability.go new file mode 100644 index 00000000000..0492fa82a9e --- /dev/null +++ b/server/pkg/agent/isolation_capability.go @@ -0,0 +1,98 @@ +package agent + +import ( + "context" + "fmt" + "os" + "os/exec" + "runtime" + "time" +) + +const ( + linuxTaskIsolationHelper = "/usr/bin/bwrap" + taskExecutionProbeTimeout = 5 * time.Second +) + +type taskExecutionProbe struct { + goos string + helper string + timeout time.Duration + validateHelper func(string) error + smoke func(context.Context, string) error +} + +// ProbeTaskExecutionCapability verifies that this host can execute tasks with +// the Linux bubblewrap FD-bound isolation contract. Unsupported or uncertain +// environments fail closed. +func ProbeTaskExecutionCapability(ctx context.Context) error { + return probeTaskExecutionCapability(ctx, taskExecutionProbe{ + goos: runtime.GOOS, + helper: linuxTaskIsolationHelper, + timeout: taskExecutionProbeTimeout, + validateHelper: validateIsolationHelper, + smoke: smokeLinuxFDIsolation, + }) +} + +func probeTaskExecutionCapability(ctx context.Context, probe taskExecutionProbe) error { + if probe.goos != "linux" { + return fmt.Errorf("task execution capability is unsupported on %s", probe.goos) + } + if probe.validateHelper == nil || probe.smoke == nil || probe.timeout <= 0 { + return fmt.Errorf("task execution capability probe is incomplete") + } + if err := probe.validateHelper(probe.helper); err != nil { + return fmt.Errorf("validate Linux task isolation helper: %w", err) + } + + probeCtx, cancel := context.WithTimeout(ctx, probe.timeout) + defer cancel() + if err := probe.smoke(probeCtx, probe.helper); err != nil { + return fmt.Errorf("smoke test Linux FD-bound task isolation: %w", err) + } + return nil +} + +func smokeLinuxFDIsolation(ctx context.Context, helper string) error { + root, err := os.MkdirTemp("", "multica-isolation-probe-") + if err != nil { + return fmt.Errorf("create probe root: %w", err) + } + defer os.RemoveAll(root) + + rootFD, err := os.Open(root) + if err != nil { + return fmt.Errorf("open probe root: %w", err) + } + defer rootFD.Close() + usrFD, err := os.Open("/usr") + if err != nil { + return fmt.Errorf("open system root: %w", err) + } + defer usrFD.Close() + + cmd := exec.CommandContext(ctx, helper, + "--die-with-parent", + "--new-session", + "--unshare-all", + "--clearenv", + "--dir", "/probe", + "--bind-fd", "3", "/probe", + "--ro-bind-fd", "4", "/usr", + "--symlink", "usr/bin", "/bin", + "--symlink", "usr/lib", "/lib", + "--symlink", "usr/lib64", "/lib64", + "--chdir", "/probe", + "--", + "/usr/bin/true", + ) + cmd.ExtraFiles = []*os.File{rootFD, usrFD} + if output, err := cmd.CombinedOutput(); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("run %s: %w: %s", helper, err, output) + } + return nil +} diff --git a/server/pkg/agent/isolation_capability_test.go b/server/pkg/agent/isolation_capability_test.go new file mode 100644 index 00000000000..44f38720b28 --- /dev/null +++ b/server/pkg/agent/isolation_capability_test.go @@ -0,0 +1,117 @@ +package agent + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +func TestTaskExecutionCapabilityProbeRejectsUnsupportedOS(t *testing.T) { + called := false + err := probeTaskExecutionCapability(context.Background(), taskExecutionProbe{ + goos: "darwin", + helper: linuxTaskIsolationHelper, + timeout: time.Second, + validateHelper: func(string) error { + called = true + return nil + }, + smoke: func(context.Context, string) error { + called = true + return nil + }, + }) + if err == nil || !strings.Contains(err.Error(), "unsupported on darwin") { + t.Fatalf("probe error = %v, want unsupported OS", err) + } + if called { + t.Fatal("unsupported OS invoked Linux probe dependencies") + } +} + +func TestTaskExecutionCapabilityProbeRejectsInvalidHelper(t *testing.T) { + want := errors.New("untrusted helper") + smokeCalled := false + err := probeTaskExecutionCapability(context.Background(), taskExecutionProbe{ + goos: "linux", + helper: linuxTaskIsolationHelper, + timeout: time.Second, + validateHelper: func(helper string) error { + if helper != linuxTaskIsolationHelper { + t.Fatalf("helper = %q", helper) + } + return want + }, + smoke: func(context.Context, string) error { + smokeCalled = true + return nil + }, + }) + if !errors.Is(err, want) { + t.Fatalf("probe error = %v, want %v", err, want) + } + if smokeCalled { + t.Fatal("smoke ran after helper validation failed") + } +} + +func TestTaskExecutionCapabilityProbeRequiresSuccessfulSmoke(t *testing.T) { + want := errors.New("fd mounts unavailable") + err := probeTaskExecutionCapability(context.Background(), taskExecutionProbe{ + goos: "linux", + helper: linuxTaskIsolationHelper, + timeout: time.Second, + validateHelper: func(string) error { return nil }, + smoke: func(context.Context, string) error { return want }, + }) + if !errors.Is(err, want) { + t.Fatalf("probe error = %v, want %v", err, want) + } +} + +func TestTaskExecutionCapabilityProbeSucceedsOnlyAfterValidationAndSmoke(t *testing.T) { + var calls []string + err := probeTaskExecutionCapability(context.Background(), taskExecutionProbe{ + goos: "linux", + helper: linuxTaskIsolationHelper, + timeout: time.Second, + validateHelper: func(string) error { + calls = append(calls, "validate") + return nil + }, + smoke: func(ctx context.Context, helper string) error { + if _, ok := ctx.Deadline(); !ok { + t.Fatal("smoke context has no deadline") + } + if helper != linuxTaskIsolationHelper { + t.Fatalf("helper = %q", helper) + } + calls = append(calls, "smoke") + return nil + }, + }) + if err != nil { + t.Fatalf("probe: %v", err) + } + if got := strings.Join(calls, ","); got != "validate,smoke" { + t.Fatalf("calls = %q, want validate,smoke", got) + } +} + +func TestTaskExecutionCapabilityProbeFailsOnTimeout(t *testing.T) { + err := probeTaskExecutionCapability(context.Background(), taskExecutionProbe{ + goos: "linux", + helper: linuxTaskIsolationHelper, + timeout: time.Millisecond, + validateHelper: func(string) error { return nil }, + smoke: func(ctx context.Context, _ string) error { + <-ctx.Done() + return ctx.Err() + }, + }) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("probe error = %v, want deadline exceeded", err) + } +} diff --git a/server/pkg/agent/isolation_fifo_unix_test.go b/server/pkg/agent/isolation_fifo_unix_test.go new file mode 100644 index 00000000000..c3ea2c6aa88 --- /dev/null +++ b/server/pkg/agent/isolation_fifo_unix_test.go @@ -0,0 +1,26 @@ +//go:build unix + +package agent + +import ( + "path/filepath" + "syscall" + "testing" +) + +func assertFIFOReadOnlyFileRejected(t *testing.T, writable string) { + t.Helper() + fifo := filepath.Join(writable, "authority.fifo") + if err := syscall.Mkfifo(fifo, 0o600); err != nil { + t.Fatal(err) + } + if _, err := (TaskIsolationPolicy{ + WritableRoots: []string{writable}, + ReadOnlyFiles: []ReadOnlyFileMount{{ + Source: fifo, + Target: "/run/multica/task-authority.json", + }}, + }).Validated(); err == nil { + t.Fatal("FIFO exact file unexpectedly accepted") + } +} diff --git a/server/pkg/agent/isolation_fifo_windows_test.go b/server/pkg/agent/isolation_fifo_windows_test.go new file mode 100644 index 00000000000..ff9f5b6956c --- /dev/null +++ b/server/pkg/agent/isolation_fifo_windows_test.go @@ -0,0 +1,9 @@ +//go:build windows + +package agent + +import "testing" + +func assertFIFOReadOnlyFileRejected(t *testing.T, writable string) { + t.Helper() +} diff --git a/server/pkg/agent/isolation_identity.go b/server/pkg/agent/isolation_identity.go new file mode 100644 index 00000000000..1fd8ee29341 --- /dev/null +++ b/server/pkg/agent/isolation_identity.go @@ -0,0 +1,331 @@ +package agent + +import ( + "fmt" + "io" + "os" + "path/filepath" + "runtime" +) + +// pathIdentity is a validated filesystem object retained by descriptor where +// the host allows it. Path is retained only for policy rendering and error +// messages; authorization decisions recheck the open descriptor. +type pathIdentity struct { + Path string + File *os.File + Info os.FileInfo +} + +func (p *pathIdentity) Close() error { + if p == nil || p.File == nil { + return nil + } + err := p.File.Close() + p.File = nil + return err +} + +type boundIsolationPolicy struct { + WritableRoots []pathIdentity + ReadOnlyRoots []pathIdentity + ReadOnlyFiles []boundReadOnlyFileMount + SystemRoots []pathIdentity + ForbiddenRoots []pathIdentity + Network NetworkAccess +} + +type boundReadOnlyFileMount struct { + Identity pathIdentity + Target string +} + +func (p *boundIsolationPolicy) Close() error { + if p == nil { + return nil + } + var first error + for _, group := range [][]pathIdentity{ + p.WritableRoots, + p.ReadOnlyRoots, + p.SystemRoots, + p.ForbiddenRoots, + } { + for i := range group { + if err := group[i].Close(); err != nil && first == nil { + first = err + } + } + } + for i := range p.ReadOnlyFiles { + if err := p.ReadOnlyFiles[i].Identity.Close(); err != nil && first == nil { + first = err + } + } + return first +} + +func (p boundIsolationPolicy) policy() TaskIsolationPolicy { + return TaskIsolationPolicy{ + WritableRoots: identityPaths(p.WritableRoots), + ReadOnlyRoots: identityPaths(p.ReadOnlyRoots), + ReadOnlyFiles: identityFileMounts(p.ReadOnlyFiles), + SystemRoots: identityPaths(p.SystemRoots), + ForbiddenRoots: identityPaths(p.ForbiddenRoots), + Network: p.Network, + } +} + +func identityFileMounts(values []boundReadOnlyFileMount) []ReadOnlyFileMount { + out := make([]ReadOnlyFileMount, 0, len(values)) + for _, value := range values { + out = append(out, ReadOnlyFileMount{Source: value.Identity.Path, Target: value.Target}) + } + return out +} + +func identityPaths(values []pathIdentity) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + out = append(out, value.Path) + } + return out +} + +func bindTaskIsolationPolicy(policy TaskIsolationPolicy) (*boundIsolationPolicy, error) { + validated, err := policy.Validated() + if err != nil { + return nil, err + } + bound := &boundIsolationPolicy{Network: validated.Network} + owned := true + defer func() { + if owned { + _ = bound.Close() + } + }() + + if bound.WritableRoots, err = openDirectoryIdentities("writable", validated.WritableRoots); err != nil { + return nil, err + } + if bound.ReadOnlyRoots, err = openDirectoryIdentities("read-only", validated.ReadOnlyRoots); err != nil { + return nil, err + } + for _, mount := range validated.ReadOnlyFiles { + identity, openErr := openFileIdentity("read-only file source", mount.Source) + if openErr != nil { + return nil, openErr + } + bound.ReadOnlyFiles = append(bound.ReadOnlyFiles, boundReadOnlyFileMount{Identity: identity, Target: mount.Target}) + } + if bound.SystemRoots, err = openDirectoryIdentities("system", validated.SystemRoots); err != nil { + return nil, err + } + if bound.ForbiddenRoots, err = openDirectoryIdentities("forbidden", validated.ForbiddenRoots); err != nil { + return nil, err + } + if err := recheckBoundIsolation(bound); err != nil { + return nil, err + } + owned = false + return bound, nil +} + +func openDirectoryIdentities(kind string, roots []string) ([]pathIdentity, error) { + out := make([]pathIdentity, 0, len(roots)) + for _, root := range roots { + identity, err := openDirectoryIdentity(kind+" root", root) + if err != nil { + for i := range out { + _ = out[i].Close() + } + return nil, err + } + out = append(out, identity) + } + return out, nil +} + +func openDirectoryIdentity(kind, path string) (pathIdentity, error) { + clean, err := validateAbsolutePath(kind, path) + if err != nil { + return pathIdentity{}, err + } + file, info, err := openPathIdentity(clean, true) + if err != nil { + return pathIdentity{}, fmt.Errorf("open %s %q: %w", kind, clean, err) + } + if !info.IsDir() { + _ = file.Close() + return pathIdentity{}, fmt.Errorf("%s %q is not a directory", kind, clean) + } + return pathIdentity{Path: clean, File: file, Info: info}, nil +} + +func openFileIdentity(kind, path string) (pathIdentity, error) { + clean, err := validateAbsolutePath(kind, path) + if err != nil { + return pathIdentity{}, err + } + file, info, err := openPathIdentity(clean, false) + if err != nil { + return pathIdentity{}, fmt.Errorf("open %s %q: %w", kind, clean, err) + } + if !info.Mode().IsRegular() { + _ = file.Close() + return pathIdentity{}, fmt.Errorf("%s %q is not a regular file", kind, clean) + } + return pathIdentity{Path: clean, File: file, Info: info}, nil +} + +func recheckBoundIsolation(policy *boundIsolationPolicy) error { + if policy == nil { + return fmt.Errorf("bound isolation policy is required") + } + for _, group := range [][]pathIdentity{ + policy.WritableRoots, + policy.ReadOnlyRoots, + policy.SystemRoots, + policy.ForbiddenRoots, + } { + for i := range group { + if err := recheckPathIdentity(&group[i]); err != nil { + return err + } + } + } + for i := range policy.ReadOnlyFiles { + if err := recheckPathIdentity(&policy.ReadOnlyFiles[i].Identity); err != nil { + return err + } + } + return nil +} + +func recheckPathIdentity(identity *pathIdentity) error { + if identity == nil || identity.File == nil || identity.Info == nil { + return fmt.Errorf("path identity is incomplete") + } + info, err := identity.File.Stat() + if err != nil { + return fmt.Errorf("recheck %q: %w", identity.Path, err) + } + if !os.SameFile(identity.Info, info) || info.Mode() != identity.Info.Mode() { + return fmt.Errorf("path identity changed for %q", identity.Path) + } + // Pathname replacement detection for platforms that can only authorize by + // path (Darwin seatbelt). A replaced directory at the same path fails closed. + pathInfo, err := os.Lstat(identity.Path) + if err != nil { + return fmt.Errorf("recheck path %q: %w", identity.Path, err) + } + if pathInfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("path %q became a symlink after validation", identity.Path) + } + if !os.SameFile(identity.Info, pathInfo) { + return fmt.Errorf("path %q was replaced after validation", identity.Path) + } + return nil +} + +func closeAll(resources ...io.Closer) error { + var first error + for _, resource := range resources { + if resource == nil { + continue + } + if err := resource.Close(); err != nil && first == nil { + first = err + } + } + return first +} + +func openPathIdentity(path string, directory bool) (*os.File, os.FileInfo, error) { + switch runtime.GOOS { + case "linux": + return openPathIdentityLinux(path, directory) + default: + return openPathIdentityPortable(path, directory) + } +} + +func openPathIdentityPortable(path string, directory bool) (*os.File, os.FileInfo, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, nil, err + } + if info.Mode()&os.ModeSymlink != 0 { + // Stable system aliases are already normalized by Validated(). Any + // remaining symlink is treated as an unsafe alias. + return nil, nil, fmt.Errorf("path %q must not be a symlink", path) + } + if directory && !info.IsDir() { + return nil, nil, fmt.Errorf("path %q is not a directory", path) + } + if !directory && !info.Mode().IsRegular() { + return nil, nil, fmt.Errorf("path %q is not a regular file", path) + } + file, err := os.OpenFile(path, os.O_RDONLY, 0) + if err != nil { + return nil, nil, err + } + opened, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, nil, err + } + if !os.SameFile(info, opened) { + _ = file.Close() + return nil, nil, fmt.Errorf("path %q changed while opening", path) + } + if directory && !opened.IsDir() { + _ = file.Close() + return nil, nil, fmt.Errorf("path %q is not a directory", path) + } + if !directory && !opened.Mode().IsRegular() { + _ = file.Close() + return nil, nil, fmt.Errorf("path %q is not a regular file", path) + } + return file, opened, nil +} + +func identityWithinAny(path string, roots []pathIdentity) bool { + for _, root := range roots { + if pathWithin(path, root.Path) { + return true + } + } + return false +} + +func joinIdentityResources(policy *boundIsolationPolicy, extra ...io.Closer) []io.Closer { + resources := make([]io.Closer, 0, 8+len(extra)) + if policy != nil { + resources = append(resources, policy) + } + resources = append(resources, extra...) + return resources +} + +func currentWorkingDirectoryIdentity(path string) (pathIdentity, error) { + return openDirectoryIdentity("cwd", path) +} + +func executableIdentity(path string) (pathIdentity, error) { + identity, err := openFileIdentity("executable", path) + if err != nil { + return pathIdentity{}, err + } + // Executability is checked from the validated mode bits. The descriptor is + // retained so launch revalidation can detect replacement. + if identity.Info.Mode().Perm()&0o111 == 0 && runtime.GOOS != "windows" { + _ = identity.Close() + return pathIdentity{}, fmt.Errorf("executable %q is not executable", path) + } + return identity, nil +} + +func mustAbsClean(path string) string { + return filepath.Clean(path) +} diff --git a/server/pkg/agent/isolation_identity_linux.go b/server/pkg/agent/isolation_identity_linux.go new file mode 100644 index 00000000000..d07acd317fc --- /dev/null +++ b/server/pkg/agent/isolation_identity_linux.go @@ -0,0 +1,95 @@ +//go:build linux + +package agent + +import ( + "errors" + "fmt" + "os" + "strings" + + "golang.org/x/sys/unix" +) + +func openPathIdentityLinux(path string, directory bool) (*os.File, os.FileInfo, error) { + flags := unix.O_CLOEXEC | unix.O_NOFOLLOW + if directory { + flags |= unix.O_PATH | unix.O_DIRECTORY + } else { + flags |= unix.O_RDONLY | unix.O_NONBLOCK + } + + fd, err := openat2NoSymlinks(unix.AT_FDCWD, path, flags) + if err != nil { + if !errors.Is(err, unix.ENOSYS) { + return nil, nil, err + } + fd, err = openPathNoSymlinksFallback(path, flags) + if err != nil { + return nil, nil, err + } + } + file := os.NewFile(uintptr(fd), path) + if file == nil { + _ = unix.Close(fd) + return nil, nil, fmt.Errorf("adopt path identity descriptor for %q", path) + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, nil, err + } + if directory && !info.IsDir() { + _ = file.Close() + return nil, nil, fmt.Errorf("path %q is not a directory", path) + } + if !directory && !info.Mode().IsRegular() { + _ = file.Close() + return nil, nil, fmt.Errorf("path %q is not a regular file", path) + } + return file, info, nil +} + +// openPathNoSymlinksFallback provides the same no-symlink property on kernels +// without openat2 by resolving every component relative to an already-open +// directory descriptor. A single unix.Open(path, O_NOFOLLOW) would leave all +// ancestor components exposed to replacement and symlink traversal. +func openPathNoSymlinksFallback(path string, finalFlags int) (int, error) { + if !strings.HasPrefix(path, "/") { + return -1, unix.EINVAL + } + current, err := unix.Open("/", unix.O_CLOEXEC|unix.O_PATH|unix.O_DIRECTORY, 0) + if err != nil { + return -1, err + } + parts := strings.Split(strings.TrimPrefix(path, "/"), "/") + if len(parts) == 1 && parts[0] == "" { + return current, nil + } + for i, part := range parts { + if part == "" || part == "." || part == ".." { + _ = unix.Close(current) + return -1, unix.EINVAL + } + flags := unix.O_CLOEXEC | unix.O_NOFOLLOW | unix.O_PATH | unix.O_DIRECTORY + if i == len(parts)-1 { + flags = finalFlags + } + next, openErr := unix.Openat(current, part, flags, 0) + _ = unix.Close(current) + if openErr != nil { + return -1, openErr + } + current = next + } + return current, nil +} + +func openat2NoSymlinks(dirfd int, path string, flags int) (int, error) { + how := &unix.OpenHow{ + Flags: uint64(flags), + Mode: 0, + Resolve: unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_MAGICLINKS, + } + return unix.Openat2(dirfd, path, how) +} diff --git a/server/pkg/agent/isolation_identity_linux_test.go b/server/pkg/agent/isolation_identity_linux_test.go new file mode 100644 index 00000000000..024d732158f --- /dev/null +++ b/server/pkg/agent/isolation_identity_linux_test.go @@ -0,0 +1,153 @@ +//go:build linux + +package agent + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/unix" +) + +func TestLinuxIsolationExecutesDescriptorBoundScriptAndCwd(t *testing.T) { + const helper = "/usr/bin/bwrap" + if _, err := os.Stat(helper); err != nil { + t.Skip("bubblewrap is unavailable") + } + root := t.TempDir() + work := filepath.Join(root, "work") + if err := os.Mkdir(work, 0o700); err != nil { + t.Fatal(err) + } + executable := filepath.Join(root, "tool.sh") + if err := os.WriteFile(executable, []byte("#!/bin/sh\nprintf '%s\\n' \"$PWD\"\n"), 0o700); err != nil { + t.Fatal(err) + } + launcher := newCommandLauncher(newLinuxIsolation(helper)) + cmd, err := launcher.Command(context.Background(), CommandRequest{ + Executable: executable, + Cwd: work, + Env: map[string]string{"PATH": "/usr/bin:/bin"}, + Isolation: &TaskIsolationPolicy{ + WritableRoots: []string{root}, + SystemRoots: existingSystemRootsForTest(t), + }, + }) + if err != nil { + t.Fatalf("Command: %v", err) + } + out, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + t.Fatalf("Output: %v: %s", err, exitErr.Stderr) + } + t.Fatalf("Output: %v", err) + } + if got := strings.TrimSpace(string(out)); got != work { + t.Fatalf("PWD = %q, want %q", got, work) + } +} + +func TestLinuxIsolationPreservesProtectedFileAfterAllMounts(t *testing.T) { + const helper = "/usr/bin/bwrap" + if _, err := os.Stat(helper); err != nil { + t.Skip("bubblewrap is unavailable") + } + root := t.TempDir() + work := filepath.Join(root, "work") + if err := os.Mkdir(work, 0o700); err != nil { + t.Fatal(err) + } + authority := filepath.Join(t.TempDir(), "task-authority.json") + const authorityContent = `{"managed_by":"multica-daemon-task-authority","task_id":"expected"}` + if err := os.WriteFile(authority, []byte(authorityContent), 0o600); err != nil { + t.Fatal(err) + } + executable := filepath.Join(root, "tool.sh") + if err := os.WriteFile(executable, []byte("#!/bin/sh\ncat /run/multica/task-authority.json\nif printf tampered > /run/multica/task-authority.json 2>/dev/null; then exit 91; fi\nexit 0\n"), 0o700); err != nil { + t.Fatal(err) + } + + launcher := newCommandLauncher(newLinuxIsolation(helper)) + cmd, err := launcher.Command(context.Background(), CommandRequest{ + Executable: executable, + Cwd: work, + Env: map[string]string{"PATH": "/usr/bin:/bin"}, + Isolation: &TaskIsolationPolicy{ + WritableRoots: []string{root}, + SystemRoots: existingSystemRootsForTest(t), + ReadOnlyFiles: []ReadOnlyFileMount{{Source: authority, Target: "/run/multica/task-authority.json"}}, + }, + }) + if err != nil { + t.Fatalf("Command: %v", err) + } + out, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + t.Fatalf("Output: %v: %s", err, exitErr.Stderr) + } + t.Fatalf("Output: %v", err) + } + if got := strings.TrimSpace(string(out)); got != authorityContent { + t.Fatalf("protected authority content = %q, want %q", got, authorityContent) + } + hostContent, err := os.ReadFile(authority) + if err != nil { + t.Fatal(err) + } + if string(hostContent) != authorityContent { + t.Fatalf("host authority content changed to %q", hostContent) + } +} + +func TestOpenPathNoSymlinksFallbackRejectsAncestorSymlink(t *testing.T) { + root := t.TempDir() + real := filepath.Join(root, "real") + if err := os.Mkdir(real, 0o700); err != nil { + t.Fatal(err) + } + file := filepath.Join(real, "tool") + if err := os.WriteFile(file, []byte("tool"), 0o700); err != nil { + t.Fatal(err) + } + alias := filepath.Join(root, "alias") + if err := os.Symlink(real, alias); err != nil { + t.Fatal(err) + } + flags := unix.O_CLOEXEC | unix.O_NOFOLLOW | unix.O_RDONLY + fd, err := openPathNoSymlinksFallback(filepath.Join(alias, "tool"), flags) + if err == nil { + _ = unix.Close(fd) + t.Fatal("ancestor symlink unexpectedly accepted") + } +} + +func TestOpenPathNoSymlinksFallbackOpensCanonicalPath(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "tool") + if err := os.WriteFile(file, []byte("tool"), 0o700); err != nil { + t.Fatal(err) + } + fd, err := openPathNoSymlinksFallback(file, unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_RDONLY) + if err != nil { + t.Fatalf("open fallback: %v", err) + } + defer unix.Close(fd) + opened := os.NewFile(uintptr(fd), file) + if opened == nil { + t.Fatal("adopt fallback descriptor") + } + defer opened.Close() + info, err := opened.Stat() + if err != nil { + t.Fatal(err) + } + if info.IsDir() { + t.Fatal("opened file reported as directory") + } +} diff --git a/server/pkg/agent/isolation_identity_stub.go b/server/pkg/agent/isolation_identity_stub.go new file mode 100644 index 00000000000..24648bc0e5d --- /dev/null +++ b/server/pkg/agent/isolation_identity_stub.go @@ -0,0 +1,9 @@ +//go:build !linux + +package agent + +import "os" + +func openPathIdentityLinux(path string, directory bool) (*os.File, os.FileInfo, error) { + return openPathIdentityPortable(path, directory) +} diff --git a/server/pkg/agent/isolation_seatbelt.go b/server/pkg/agent/isolation_seatbelt.go new file mode 100644 index 00000000000..eb22b1073e1 --- /dev/null +++ b/server/pkg/agent/isolation_seatbelt.go @@ -0,0 +1,193 @@ +package agent + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "sort" + "strings" +) + +var darwinTaskMachServices = []string{ + "com.apple.SystemConfiguration.configd", + "com.apple.cfprefsd.agent", + "com.apple.system.opendirectoryd.libinfo", + "com.apple.trustd.agent", +} + +type darwinIsolation struct { + helper string +} + +func newDarwinIsolation(helper string) platformIsolation { + return &darwinIsolation{helper: helper} +} + +func (d *darwinIsolation) WrapBound(policy *boundIsolationPolicy, executable, cwd pathIdentity, args []string, leadingExtraFiles int) (string, []string, []*os.File, error) { + return "", nil, nil, fmt.Errorf("task process isolation on darwin cannot bind cwd and executable identity through final exec") +} + +func renderDarwinProfile(policy TaskIsolationPolicy) (string, error) { + validated, err := policy.Validated() + if err != nil { + return "", err + } + var lines []string + lines = append(lines, + "(version 1)", + "(deny default)", + "(allow process-exec process-fork process-info*)", + "(allow signal (target self))", + "(allow sysctl-read)", + ) + for _, service := range darwinTaskMachServices { + lines = append(lines, fmt.Sprintf("(allow mach-lookup (global-name \"%s\"))", escapeSandboxString(service))) + } + for _, root := range append(append([]string(nil), validated.ReadOnlyRoots...), validated.SystemRoots...) { + lines = append(lines, sandboxPathRule("file-read*", root)) + } + for _, root := range validated.WritableRoots { + lines = append(lines, sandboxPathRule("file-read* file-write*", root)) + } + if validated.Network == NetworkAccessPublicAndLoopback { + lines = append(lines, "(allow network-outbound)", "(allow network-inbound (local ip))") + } + return strings.Join(lines, "\n") + "\n", nil +} + +func sandboxPathRule(operations, path string) string { + return fmt.Sprintf("(allow %s (subpath \"%s\"))", operations, escapeSandboxString(path)) +} + +func escapeSandboxString(value string) string { + value = strings.ReplaceAll(value, `\`, `\\`) + value = strings.ReplaceAll(value, `"`, `\"`) + value = strings.ReplaceAll(value, "\n", `\n`) + value = strings.ReplaceAll(value, "\r", `\r`) + return value +} + +func validateIsolationHelper(path string) error { + clean, err := validateAbsolutePath("isolation helper", path) + if err != nil { + return err + } + + type pathIdentity struct { + path string + info os.FileInfo + owner uint64 + } + chain := trustedPathChain(clean) + identities := make([]pathIdentity, 0, len(chain)) + for i, component := range chain { + info, err := os.Lstat(component) + if err != nil { + return fmt.Errorf("lstat isolation helper path %q: %w", component, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("isolation helper path %q must not be a symlink", component) + } + if i == len(chain)-1 { + if !info.Mode().IsRegular() || info.Mode().Perm()&0o111 == 0 { + return fmt.Errorf("isolation helper %q is not an executable regular file", clean) + } + } else if !info.IsDir() { + return fmt.Errorf("isolation helper parent %q is not a directory", component) + } + identities = append(identities, pathIdentity{path: component, info: info}) + } + + for i := len(identities) - 1; i >= 0; i-- { + identity := &identities[i] + owner, err := fileOwnerUID(identity.info) + if err != nil { + return fmt.Errorf("verify isolation helper path %q ownership: %w", identity.path, err) + } + identity.owner = owner + if identity.owner != 0 { + return fmt.Errorf("isolation helper path %q is owned by uid %d, want root", identity.path, identity.owner) + } + if identity.info.Mode().Perm()&0o022 != 0 { + return fmt.Errorf("isolation helper path %q is writable by group or other", identity.path) + } + } + + // Re-read the complete path after validation so a concurrently replaced + // component fails closed instead of being used under a stale trust decision. + for _, identity := range identities { + info, err := os.Lstat(identity.path) + if err != nil { + return fmt.Errorf("recheck isolation helper path %q: %w", identity.path, err) + } + owner, err := fileOwnerUID(info) + if err != nil { + return fmt.Errorf("recheck isolation helper path %q ownership: %w", identity.path, err) + } + if !os.SameFile(identity.info, info) || info.Mode() != identity.info.Mode() || owner != identity.owner { + return fmt.Errorf("isolation helper path %q changed during validation", identity.path) + } + } + return nil +} + +func trustedPathChain(path string) []string { + var reversed []string + for current := path; ; current = filepath.Dir(current) { + reversed = append(reversed, current) + parent := filepath.Dir(current) + if parent == current { + break + } + } + chain := make([]string, len(reversed)) + for i := range reversed { + chain[len(reversed)-1-i] = reversed[i] + } + return chain +} + +// fileOwnerUID uses reflection so the package still compiles on platforms +// whose syscall.Stat_t has no Unix uid field. Isolation helpers are supported +// only where the host exposes a stable numeric owner identity. +func fileOwnerUID(info os.FileInfo) (uint64, error) { + value := reflect.ValueOf(info.Sys()) + if !value.IsValid() { + return 0, fmt.Errorf("file identity is unavailable") + } + if value.Kind() == reflect.Pointer { + if value.IsNil() { + return 0, fmt.Errorf("file identity is unavailable") + } + value = value.Elem() + } + if value.Kind() != reflect.Struct { + return 0, fmt.Errorf("file identity has unsupported type %T", info.Sys()) + } + uid := value.FieldByName("Uid") + if !uid.IsValid() { + return 0, fmt.Errorf("file identity does not expose an owner uid") + } + switch uid.Kind() { + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return uid.Uint(), nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + owner := uid.Int() + if owner < 0 { + return 0, fmt.Errorf("file identity exposes invalid owner uid %d", owner) + } + return uint64(owner), nil + default: + return 0, fmt.Errorf("file identity owner uid has unsupported type %s", uid.Kind()) + } +} + +func sortedDarwinRoots(values ...[]string) []string { + var roots []string + for _, value := range values { + roots = append(roots, value...) + } + sort.Strings(roots) + return roots +} diff --git a/server/pkg/agent/isolation_test.go b/server/pkg/agent/isolation_test.go new file mode 100644 index 00000000000..e833f03cf36 --- /dev/null +++ b/server/pkg/agent/isolation_test.go @@ -0,0 +1,490 @@ +package agent + +import ( + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +func TestTaskIsolationPolicyRejectsSymlinkEscapeAndForbiddenOverlap(t *testing.T) { + t.Parallel() + + root := t.TempDir() + ownerConfig := filepath.Join(root, "owner", ".multica") + if err := os.MkdirAll(ownerConfig, 0o700); err != nil { + t.Fatal(err) + } + taskRoot := filepath.Join(root, "task") + if err := os.Mkdir(taskRoot, 0o700); err != nil { + t.Fatal(err) + } + escape := filepath.Join(taskRoot, "escape") + if err := os.Symlink(ownerConfig, escape); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + policy TaskIsolationPolicy + }{ + { + name: "writable symlink escape", + policy: TaskIsolationPolicy{ + WritableRoots: []string{escape}, + ForbiddenRoots: []string{ownerConfig}, + }, + }, + { + name: "read-only forbidden child", + policy: TaskIsolationPolicy{ + ReadOnlyRoots: []string{filepath.Join(root, "owner")}, + ForbiddenRoots: []string{ownerConfig}, + }, + }, + { + name: "writable forbidden parent", + policy: TaskIsolationPolicy{ + WritableRoots: []string{root}, + ForbiddenRoots: []string{ownerConfig}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := tt.policy.Validated(); err == nil { + t.Fatal("expected forbidden-root overlap to fail") + } + }) + } +} + +func TestTaskIsolationPolicyRejectsRelativeDotDotAndMissingRoots(t *testing.T) { + t.Parallel() + + root := t.TempDir() + missing := filepath.Join(root, "missing") + tests := []TaskIsolationPolicy{ + {WritableRoots: []string{"relative"}}, + {WritableRoots: []string{root + "/child/.."}}, + {WritableRoots: []string{missing}}, + } + for _, policy := range tests { + if _, err := policy.Validated(); err == nil { + t.Fatalf("Validated(%#v) unexpectedly succeeded", policy) + } + } +} + +func TestTaskIsolationPolicyValidatesExactReadOnlyFiles(t *testing.T) { + root := t.TempDir() + writable := filepath.Join(root, "task") + private := filepath.Join(root, "daemon-private") + forbidden := filepath.Join(root, "owner") + if err := os.MkdirAll(writable, 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(private, 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(forbidden, 0o700); err != nil { + t.Fatal(err) + } + authority := filepath.Join(private, "task-authority.json") + if err := os.WriteFile(authority, []byte(`{"version":1}`), 0o600); err != nil { + t.Fatal(err) + } + + validated, err := (TaskIsolationPolicy{ + WritableRoots: []string{writable}, + ReadOnlyFiles: []ReadOnlyFileMount{{Source: authority, Target: "/run/multica/task-authority.json"}}, + }).Validated() + if err != nil { + t.Fatalf("valid exact file rejected: %v", err) + } + if len(validated.ReadOnlyFiles) != 1 || validated.ReadOnlyFiles[0].Source != authority || validated.ReadOnlyFiles[0].Target != "/run/multica/task-authority.json" { + t.Fatalf("validated exact files = %#v", validated.ReadOnlyFiles) + } + + writableAuthority := filepath.Join(writable, "task-authority.json") + if err := os.WriteFile(writableAuthority, []byte(`{"version":1}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := (TaskIsolationPolicy{ + WritableRoots: []string{writable}, + ReadOnlyFiles: []ReadOnlyFileMount{{Source: writableAuthority, Target: "/run/multica/task-authority.json"}}, + }).Validated(); err == nil { + t.Fatal("read-only file source inside writable root unexpectedly accepted") + } + + directory := filepath.Join(writable, "directory") + if err := os.Mkdir(directory, 0o700); err != nil { + t.Fatal(err) + } + symlink := filepath.Join(writable, "authority-link") + if err := os.Symlink(authority, symlink); err != nil { + t.Fatal(err) + } + forbiddenFile := filepath.Join(forbidden, "authority.json") + if err := os.WriteFile(forbiddenFile, []byte("forbidden"), 0o600); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + source string + target string + }{ + {name: "missing", source: filepath.Join(writable, "missing"), target: "/run/multica/task-authority.json"}, + {name: "directory", source: directory, target: "/run/multica/task-authority.json"}, + {name: "symlink", source: symlink, target: "/run/multica/task-authority.json"}, + {name: "inside forbidden root", source: forbiddenFile, target: "/run/multica/task-authority.json"}, + {name: "relative target", source: authority, target: "run/multica/task-authority.json"}, + {name: "proc target", source: authority, target: "/proc/task-authority.json"}, + {name: "dev target", source: authority, target: "/dev/task-authority.json"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := (TaskIsolationPolicy{ + WritableRoots: []string{writable}, + ReadOnlyFiles: []ReadOnlyFileMount{{Source: tt.source, Target: tt.target}}, + ForbiddenRoots: []string{forbidden}, + }).Validated() + if err == nil { + t.Fatalf("exact file mount %#v unexpectedly accepted", tt) + } + }) + } + + assertFIFOReadOnlyFileRejected(t, writable) + + if _, err := (TaskIsolationPolicy{ + WritableRoots: []string{writable}, + ReadOnlyFiles: []ReadOnlyFileMount{ + {Source: authority, Target: "/run/multica/task-authority.json"}, + {Source: forbiddenFile, Target: "/run/multica/task-authority.json"}, + }, + }).Validated(); err == nil { + t.Fatal("duplicate exact-file target unexpectedly accepted") + } +} + +func TestTaskIsolationPolicyRejectsReadOnlyFileTargetWritableOverlap(t *testing.T) { + root := t.TempDir() + writable := filepath.Join(root, "task") + private := filepath.Join(root, "daemon-private") + if err := os.MkdirAll(writable, 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(private, 0o700); err != nil { + t.Fatal(err) + } + authority := filepath.Join(private, "task-authority.json") + if err := os.WriteFile(authority, []byte(`{"version":1}`), 0o600); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + target string + }{ + {name: "target inside writable root", target: filepath.Join(writable, "task-authority.json")}, + {name: "target equals writable root", target: writable}, + {name: "target contains writable root", target: root}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := (TaskIsolationPolicy{ + WritableRoots: []string{writable}, + ReadOnlyFiles: []ReadOnlyFileMount{{Source: authority, Target: tt.target}}, + }).Validated() + if err == nil { + t.Fatalf("read-only target %q overlapping writable root %q unexpectedly accepted", tt.target, writable) + } + }) + } +} + +func TestStableSystemPathAliasesArePlatformBoundAndExact(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + goos string + path string + resolved string + want bool + }{ + {name: "linux bin", goos: "linux", path: "/bin", resolved: "/usr/bin", want: true}, + {name: "linux bin child", goos: "linux", path: "/bin/sh", resolved: "/usr/bin/sh", want: true}, + {name: "linux lib", goos: "linux", path: "/lib", resolved: "/usr/lib", want: true}, + {name: "linux lib64", goos: "linux", path: "/lib64", resolved: "/usr/lib64", want: true}, + {name: "linux unexpected target", goos: "linux", path: "/lib", resolved: "/opt/lib"}, + {name: "linux reverse alias", goos: "linux", path: "/usr/bin", resolved: "/bin"}, + {name: "linux extended target", goos: "linux", path: "/bin", resolved: "/usr/bin/extra"}, + {name: "linux alias prefix confusion", goos: "linux", path: "/library", resolved: "/usr/library"}, + {name: "linux bin boundary confusion", goos: "linux", path: "/binx", resolved: "/usr/binx"}, + {name: "linux lib64 boundary confusion", goos: "linux", path: "/lib64evil", resolved: "/usr/lib64evil"}, + {name: "linux canonical child mismatch", goos: "linux", path: "/bin/sh", resolved: "/usr/bin/bash"}, + {name: "linux unlisted alias", goos: "linux", path: "/sbin", resolved: "/usr/sbin"}, + {name: "darwin var", goos: "darwin", path: "/var", resolved: "/private/var", want: true}, + {name: "darwin var child", goos: "darwin", path: "/var/db", resolved: "/private/var/db", want: true}, + {name: "darwin alias on linux", goos: "linux", path: "/var", resolved: "/private/var"}, + {name: "linux alias on darwin", goos: "darwin", path: "/bin", resolved: "/usr/bin"}, + {name: "unsupported platform", goos: "windows", path: "/bin", resolved: "/usr/bin"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isStableSystemPathAliasForOS(tt.goos, tt.path, tt.resolved); got != tt.want { + t.Fatalf("isStableSystemPathAliasForOS(%q, %q, %q) = %v, want %v", tt.goos, tt.path, tt.resolved, got, tt.want) + } + }) + } +} + +func TestValidatedCanonicalizesOnlyStableSystemAliases(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Linux usr-merge aliases") + } + resolved, err := filepath.EvalSymlinks("/bin") + if err != nil || resolved == "/bin" { + t.Skip("host does not use /bin as a symlink alias") + } + root := t.TempDir() + policy, err := (TaskIsolationPolicy{ + WritableRoots: []string{root}, + SystemRoots: []string{"/bin"}, + }).Validated() + if err != nil { + t.Fatalf("Validated: %v", err) + } + if !reflect.DeepEqual(policy.SystemRoots, []string{resolved}) { + t.Fatalf("system roots = %#v, want %#v", policy.SystemRoots, []string{resolved}) + } + + alias := filepath.Join(root, "alias") + if err := os.Symlink(resolved, alias); err != nil { + t.Fatal(err) + } + if _, err := (TaskIsolationPolicy{WritableRoots: []string{alias}}).Validated(); err == nil { + t.Fatal("user-controlled alias unexpectedly accepted") + } +} + +func TestDarwinProfileRenderingIsDeterministicAndQuotesPaths(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("POSIX paths") + } + root := t.TempDir() + spaceRoot := filepath.Join(root, "task root") + if err := os.Mkdir(spaceRoot, 0o700); err != nil { + t.Fatal(err) + } + policy := TaskIsolationPolicy{ + WritableRoots: []string{spaceRoot}, + ReadOnlyRoots: []string{root}, + SystemRoots: existingSystemRootsForTest(t), + Network: NetworkAccessPublicAndLoopback, + } + first, err := renderDarwinProfile(policy) + if err != nil { + t.Fatalf("first render: %v", err) + } + second, err := renderDarwinProfile(policy) + if err != nil { + t.Fatalf("second render: %v", err) + } + if first != second { + t.Fatalf("profile rendering is nondeterministic:\nfirst:\n%s\nsecond:\n%s", first, second) + } + if !strings.Contains(first, `(subpath "`+escapeSandboxString(spaceRoot)+`")`) { + t.Fatalf("profile does not contain escaped writable root %q:\n%s", spaceRoot, first) + } + if !strings.Contains(first, "(allow network-outbound)") || !strings.Contains(first, "(allow network-inbound") { + t.Fatalf("profile does not explicitly allow public + loopback networking:\n%s", first) + } +} + +func TestDarwinProfileAllowsOnlyExplicitMachServices(t *testing.T) { + t.Parallel() + + root := t.TempDir() + profile, err := renderDarwinProfile(TaskIsolationPolicy{WritableRoots: []string{root}}) + if err != nil { + t.Fatalf("renderDarwinProfile: %v", err) + } + if strings.Contains(profile, "(allow mach-lookup)\n") { + t.Fatalf("profile contains unrestricted mach-lookup authority:\n%s", profile) + } + wantServices := []string{ + "com.apple.SystemConfiguration.configd", + "com.apple.cfprefsd.agent", + "com.apple.system.opendirectoryd.libinfo", + "com.apple.trustd.agent", + } + for _, service := range wantServices { + want := `(allow mach-lookup (global-name "` + service + `"))` + if !strings.Contains(profile, want) { + t.Fatalf("profile does not allow required service %q:\n%s", service, profile) + } + } + for _, line := range strings.Split(profile, "\n") { + if strings.Contains(line, "mach-lookup") && !containsAnyString(line, wantServices) { + t.Fatalf("profile contains unreviewed mach service rule %q", line) + } + } +} + +func TestLinuxArgsUseEmptyNamespaceWithoutHostRootBind(t *testing.T) { + t.Parallel() + + root := t.TempDir() + policy := TaskIsolationPolicy{ + WritableRoots: []string{root}, + SystemRoots: existingSystemRootsForTest(t), + Network: NetworkAccessPublicAndLoopback, + } + args, err := renderLinuxBubblewrapArgs(policy, "/usr/bin/tool", []string{"arg"}) + if err != nil { + t.Fatalf("renderLinuxBubblewrapArgs: %v", err) + } + if isolationContainsAdjacent(args, "--ro-bind", "/") || isolationContainsAdjacent(args, "--bind", "/") { + t.Fatalf("bubblewrap args expose host root: %#v", args) + } + if !isolationContainsAdjacent(args, "--bind", root) { + t.Fatalf("bubblewrap args do not bind writable task root: %#v", args) + } + if !isolationContainsString(args, "--unshare-all") || !isolationContainsString(args, "--share-net") { + t.Fatalf("bubblewrap args do not isolate namespaces while preserving public/loopback network: %#v", args) + } + if !isolationContainsAdjacent(args, "--dev", "/dev") { + t.Fatalf("bubblewrap args do not create a minimal device namespace: %#v", args) + } + if !isolationContainsAdjacent(args, "--proc", "/proc") { + t.Fatalf("bubblewrap args do not mount proc for the isolated PID namespace: %#v", args) + } + for _, hostRoot := range []string{"/dev", "/proc"} { + if isolationContainsAdjacent(args, "--bind", hostRoot) || isolationContainsAdjacent(args, "--ro-bind", hostRoot) { + t.Fatalf("bubblewrap args bind host %s instead of creating an isolated mount: %#v", hostRoot, args) + } + } + wantTail := []string{"--", "/usr/bin/tool", "arg"} + if len(args) < len(wantTail) || !reflect.DeepEqual(args[len(args)-len(wantTail):], wantTail) { + t.Fatalf("bubblewrap argv tail = %#v, want %#v", args, wantTail) + } +} + +func TestLinuxArgsRejectHostDeviceAndProcRootBindings(t *testing.T) { + t.Parallel() + + root := t.TempDir() + for _, exposed := range []string{"/", "/dev", "/proc"} { + t.Run(exposed, func(t *testing.T) { + _, err := renderLinuxBubblewrapArgs(TaskIsolationPolicy{ + WritableRoots: []string{root}, + ReadOnlyRoots: []string{exposed}, + }, "/usr/bin/tool", nil) + if err == nil { + t.Fatalf("host pseudo-filesystem root %q unexpectedly accepted", exposed) + } + }) + } +} + +func TestIsolationHelperRejectsUserWritableAndSymlinkPaths(t *testing.T) { + t.Parallel() + + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("resolve temporary root: %v", err) + } + helper := filepath.Join(root, "sandbox-helper") + if err := os.WriteFile(helper, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + if err := validateIsolationHelper(helper); err == nil { + t.Fatal("user-owned isolation helper unexpectedly trusted") + } else if !strings.Contains(err.Error(), "want root") { + t.Fatalf("user-owned helper rejected for the wrong reason: %v", err) + } + + symlink := filepath.Join(root, "sandbox-helper-link") + if err := os.Symlink(helper, symlink); err != nil { + t.Fatal(err) + } + if err := validateIsolationHelper(symlink); err == nil { + t.Fatal("symlinked isolation helper unexpectedly trusted") + } else if !strings.Contains(err.Error(), "must not be a symlink") { + t.Fatalf("symlinked helper rejected for the wrong reason: %v", err) + } +} + +func TestSystemIsolationHelperHasTrustedStableChain(t *testing.T) { + t.Parallel() + + var helper string + switch runtime.GOOS { + case "darwin": + helper = "/usr/bin/sandbox-exec" + case "linux": + helper = "/usr/bin/bwrap" + default: + t.Skip("no supported platform isolation helper") + } + if _, err := os.Lstat(helper); os.IsNotExist(err) { + t.Skipf("platform helper %s is not installed", helper) + } else if err != nil { + t.Fatalf("lstat platform helper: %v", err) + } + if err := validateIsolationHelper(helper); err != nil { + t.Fatalf("trusted platform helper rejected: %v", err) + } +} + +func TestPlatformIsolationFailsClosedForMissingHelperAndUnsupportedOS(t *testing.T) { + t.Parallel() + + root := t.TempDir() + for _, platform := range []platformIsolation{ + newDarwinIsolation(filepath.Join(root, "missing-sandbox-exec")), + newLinuxIsolation(filepath.Join(root, "missing-bwrap")), + newUnsupportedIsolation("plan9"), + } { + executable := pathIdentity{Path: "/usr/bin/tool"} + cwd := pathIdentity{Path: root} + if _, _, _, err := platform.WrapBound(nil, executable, cwd, nil, 0); err == nil { + t.Fatalf("%T unexpectedly accepted unavailable isolation", platform) + } + } +} + +func isolationContainsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +func isolationContainsAdjacent(values []string, first, second string) bool { + for i := 0; i+1 < len(values); i++ { + if values[i] == first && values[i+1] == second { + return true + } + } + return false +} + +func containsAnyString(value string, candidates []string) bool { + for _, candidate := range candidates { + if strings.Contains(value, candidate) { + return true + } + } + return false +} diff --git a/server/pkg/agent/isolation_toctou_test.go b/server/pkg/agent/isolation_toctou_test.go new file mode 100644 index 00000000000..37aaae28f4b --- /dev/null +++ b/server/pkg/agent/isolation_toctou_test.go @@ -0,0 +1,370 @@ +package agent + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +// TestCommandLauncherDetectsRootReplacementBeforeStart exercises the +// validate-to-start TOCTOU window: a root is opened and bound, then replaced +// at the same pathname before Start. Launch must fail closed. +func TestCommandLauncherDetectsRootReplacementBeforeStart(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX path replacement") + } + + root := t.TempDir() + writable := filepath.Join(root, "task") + if err := os.Mkdir(writable, 0o700); err != nil { + t.Fatal(err) + } + executable := filepath.Join(root, "tool.sh") + writeTestExecutable(t, executable, []byte("#!/bin/sh\nprintf ok\n")) + + // Keep system roots minimal and present. + systemRoots := existingSystemRootsForTest(t) + policy := &TaskIsolationPolicy{ + WritableRoots: []string{writable, root}, + SystemRoots: systemRoots, + Network: NetworkAccessNone, + } + + // Darwin intentionally fails closed because sandbox-exec cannot preserve + // cwd and executable identity through final exec. Exercise the launch-time + // replacement check with the deterministic recording isolation instead. + var launcher *CommandLauncher + switch runtime.GOOS { + case "linux": + if _, err := os.Stat("/usr/bin/bwrap"); err == nil { + launcher = newCommandLauncher(newLinuxIsolation("/usr/bin/bwrap")) + } + } + if launcher == nil { + launcher = newCommandLauncher(&recordingIsolation{}) + } + + cmd, err := launcher.Command(context.Background(), CommandRequest{ + Executable: executable, + Args: nil, + Cwd: writable, + Env: map[string]string{"PATH": "/usr/bin:/bin"}, + Isolation: policy, + WaitDelay: time.Second, + }) + if err != nil { + t.Fatalf("Command prepare: %v", err) + } + defer cmd.Close() + + // Replace the writable root directory with a new inode at the same path. + replacementParent := filepath.Join(root, "replacement-staging") + if err := os.Mkdir(replacementParent, 0o700); err != nil { + t.Fatal(err) + } + replacement := filepath.Join(replacementParent, "task") + if err := os.Mkdir(replacement, 0o700); err != nil { + t.Fatal(err) + } + // Move original aside and put replacement at the validated path. + originalMoved := filepath.Join(root, "task-original") + if err := os.Rename(writable, originalMoved); err != nil { + t.Fatalf("rename original root: %v", err) + } + if err := os.Rename(replacement, writable); err != nil { + t.Fatalf("install replacement root: %v", err) + } + + err = cmd.Start() + if err == nil { + _ = cmd.Wait() + t.Fatal("Start unexpectedly succeeded after root replacement") + } + if !strings.Contains(err.Error(), "replaced") && !strings.Contains(err.Error(), "changed") && !strings.Contains(err.Error(), "identity") { + t.Fatalf("Start failed for unexpected reason: %v", err) + } +} + +func TestDarwinCommandLauncherFailsClosedWithoutIdentityBoundExec(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("Darwin isolation contract") + } + root := t.TempDir() + executable := filepath.Join(root, "tool.sh") + writeTestExecutable(t, executable, []byte("#!/bin/sh\nexit 0\n")) + launcher := newCommandLauncher(newDarwinIsolation("/usr/bin/sandbox-exec")) + _, err := launcher.Command(context.Background(), CommandRequest{ + Executable: executable, + Cwd: root, + Env: map[string]string{"PATH": "/usr/bin:/bin"}, + Isolation: &TaskIsolationPolicy{ + WritableRoots: []string{root}, + SystemRoots: existingSystemRootsForTest(t), + }, + }) + if err == nil || !strings.Contains(err.Error(), "cannot bind cwd and executable identity") { + t.Fatalf("Command error = %v, want identity-bound exec failure", err) + } +} + +// TestCommandLauncherDetectsExecutableReplacementBeforeStart ensures a swapped +// executable path fails closed on Start after prepare-time validation. +func TestCommandLauncherDetectsExecutableReplacementBeforeStart(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX path replacement") + } + + root := t.TempDir() + executable := filepath.Join(root, "tool.sh") + writeTestExecutable(t, executable, []byte("#!/bin/sh\nprintf original\n")) + policy := &TaskIsolationPolicy{ + WritableRoots: []string{root}, + SystemRoots: existingSystemRootsForTest(t), + } + launcher := newCommandLauncher(&recordingIsolation{}) + cmd, err := launcher.Command(context.Background(), CommandRequest{ + Executable: executable, + Cwd: root, + Env: map[string]string{"PATH": "/usr/bin:/bin"}, + Isolation: policy, + }) + if err != nil { + t.Fatalf("Command prepare: %v", err) + } + defer cmd.Close() + + // Replace executable inode at the same path. + tmp := filepath.Join(root, "tool-new.sh") + writeTestExecutable(t, tmp, []byte("#!/bin/sh\nprintf replaced\n")) + if err := os.Rename(executable, filepath.Join(root, "tool-old.sh")); err != nil { + t.Fatal(err) + } + if err := os.Rename(tmp, executable); err != nil { + t.Fatal(err) + } + + if err := cmd.Start(); err == nil { + _ = cmd.Wait() + t.Fatal("Start unexpectedly succeeded after executable replacement") + } else if !strings.Contains(err.Error(), "replaced") && !strings.Contains(err.Error(), "changed") && !strings.Contains(err.Error(), "identity") { + t.Fatalf("Start failed for unexpected reason: %v", err) + } +} + +// TestLinuxBoundArgsReserveLeadingExtraFiles ensures isolation mount FDs start +// after caller-reserved leading descriptors (Pi session FD 3). +func TestLinuxBoundArgsReserveLeadingExtraFiles(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Linux bubblewrap FD layout") + } + + root := t.TempDir() + policy, err := bindTaskIsolationPolicy(TaskIsolationPolicy{ + WritableRoots: []string{root}, + SystemRoots: existingSystemRootsForTest(t), + }) + if err != nil { + t.Fatalf("bind policy: %v", err) + } + defer policy.Close() + + executablePath := filepath.Join(root, "tool") + if err := os.WriteFile(executablePath, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + executable, err := executableIdentity(executablePath) + if err != nil { + t.Fatalf("executable identity: %v", err) + } + defer executable.Close() + cwd, err := currentWorkingDirectoryIdentity(root) + if err != nil { + t.Fatalf("cwd identity: %v", err) + } + defer cwd.Close() + + args, extraFiles, err := renderLinuxBubblewrapArgsBound(policy, executable, cwd, []string{"arg"}, 1) + if err != nil { + t.Fatalf("render bound args: %v", err) + } + if len(extraFiles) == 0 { + t.Fatal("expected isolation extra files") + } + // With one leading ExtraFile, first isolation mount is child FD 4. + if !isolationContainsAdjacent(args, "--bind-fd", "4") && !isolationContainsAdjacent(args, "--ro-bind-fd", "4") { + t.Fatalf("expected isolation mounts to start at FD 4 after leading ExtraFile, args=%#v", args) + } + if isolationContainsAdjacent(args, "--bind-fd", "3") || isolationContainsAdjacent(args, "--ro-bind-fd", "3") { + t.Fatalf("isolation mounts collided with leading FD 3: %#v", args) + } + if len(extraFiles) < 2 || extraFiles[len(extraFiles)-2] != cwd.File || extraFiles[len(extraFiles)-1] != executable.File { + t.Fatalf("cwd/executable descriptors are not final inherited files: %#v", extraFiles) + } + wantCwdFD := fmt.Sprintf("%d", 3+1+len(extraFiles)-2) + wantExecutableFD := fmt.Sprintf("%d", 3+1+len(extraFiles)-1) + if !isolationContainsSequence(args, "--bind-fd", wantCwdFD, cwd.Path) || !isolationContainsSequence(args, "--ro-bind-fd", wantExecutableFD, executable.Path) { + t.Fatalf("cwd/executable FD offsets are incorrect: %#v", args) + } + if !isolationContainsAdjacent(args, "--chdir", cwd.Path) || !containsString(args, executable.Path) { + t.Fatalf("cwd/executable namespace paths are not preserved: %#v", args) + } +} + +func TestBoundIsolationRejectsExactReadOnlyFileReplacement(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX path replacement") + } + + root := t.TempDir() + authority := filepath.Join(t.TempDir(), "task-authority.json") + if err := os.WriteFile(authority, []byte("original"), 0o600); err != nil { + t.Fatal(err) + } + policy, err := bindTaskIsolationPolicy(TaskIsolationPolicy{ + WritableRoots: []string{root}, + ReadOnlyFiles: []ReadOnlyFileMount{{Source: authority, Target: "/run/multica/task-authority.json"}}, + }) + if err != nil { + t.Fatalf("bind policy: %v", err) + } + defer policy.Close() + + old := filepath.Join(filepath.Dir(authority), "old-authority") + if err := os.Rename(authority, old); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(authority, []byte("replacement"), 0o600); err != nil { + t.Fatal(err) + } + if err := recheckBoundIsolation(policy); err == nil { + t.Fatal("exact read-only file replacement unexpectedly passed recheck") + } +} + +func TestLinuxBoundArgsMountExactReadOnlyFileAfterWritableRoots(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Linux bubblewrap FD layout") + } + + root := t.TempDir() + authority := filepath.Join(t.TempDir(), "task-authority.json") + executablePath := filepath.Join(root, "tool") + if err := os.WriteFile(authority, []byte("authority"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(executablePath, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + policy, err := bindTaskIsolationPolicy(TaskIsolationPolicy{ + WritableRoots: []string{root}, + ReadOnlyFiles: []ReadOnlyFileMount{{Source: authority, Target: "/run/multica/task-authority.json"}}, + }) + if err != nil { + t.Fatalf("bind policy: %v", err) + } + defer policy.Close() + executable, err := executableIdentity(executablePath) + if err != nil { + t.Fatal(err) + } + defer executable.Close() + cwd, err := currentWorkingDirectoryIdentity(root) + if err != nil { + t.Fatal(err) + } + defer cwd.Close() + + args, _, err := renderLinuxBubblewrapArgsBound(policy, executable, cwd, nil, 0) + if err != nil { + t.Fatal(err) + } + bindIndex := isolationSequenceIndex(args, "--bind-fd", root) + readOnlyIndex := isolationSequenceIndex(args, "--ro-bind-fd", "/run/multica/task-authority.json") + cwdIndex := isolationSequenceIndex(args, "--bind-fd", cwd.Path) + executableIndex := isolationSequenceIndex(args, "--ro-bind-fd", executable.Path) + if bindIndex < 0 || cwdIndex < 0 || executableIndex < 0 || readOnlyIndex < 0 || readOnlyIndex <= bindIndex || readOnlyIndex <= cwdIndex || readOnlyIndex <= executableIndex { + t.Fatalf("exact file was not mounted read-only after every namespace destination: %#v", args) + } +} + +func TestCommandLauncherRejectsReadOnlyFileTargetShadowedByCWDOrExecutable(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX task namespace paths") + } + + root := t.TempDir() + readOnlyRoot := filepath.Join(root, "read-only") + writableRoot := filepath.Join(root, "writable") + privateRoot := filepath.Join(root, "daemon-private") + for _, dir := range []string{readOnlyRoot, writableRoot, privateRoot} { + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + } + executable := filepath.Join(readOnlyRoot, "tool.sh") + writeTestExecutable(t, executable, []byte("#!/bin/sh\nexit 0\n")) + authority := filepath.Join(privateRoot, "task-authority.json") + if err := os.WriteFile(authority, []byte(`{"version":1}`), 0o600); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + target string + }{ + {name: "cwd shadows descendant target", target: filepath.Join(readOnlyRoot, "task-authority.json")}, + {name: "target equals cwd", target: readOnlyRoot}, + {name: "target equals executable", target: executable}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + launcher := newCommandLauncher(&recordingIsolation{}) + cmd, err := launcher.Command(context.Background(), CommandRequest{ + Executable: executable, + Cwd: readOnlyRoot, + Env: map[string]string{"PATH": "/usr/bin:/bin"}, + Isolation: &TaskIsolationPolicy{ + WritableRoots: []string{writableRoot}, + ReadOnlyRoots: []string{readOnlyRoot}, + ReadOnlyFiles: []ReadOnlyFileMount{{Source: authority, Target: tt.target}}, + }, + }) + if cmd != nil { + _ = cmd.Close() + } + if err == nil { + t.Fatalf("read-only target %q shadowed by cwd or executable unexpectedly accepted", tt.target) + } + }) + } +} + +func isolationSequenceIndex(values []string, operation, target string) int { + for i := 0; i+2 < len(values); i++ { + if values[i] == operation && values[i+2] == target { + return i + } + } + return -1 +} + +func isolationContainsSequence(values []string, sequence ...string) bool { + for i := 0; i+len(sequence) <= len(values); i++ { + match := true + for j := range sequence { + if values[i+j] != sequence[j] { + match = false + break + } + } + if match { + return true + } + } + return false +} diff --git a/server/pkg/agent/kimi.go b/server/pkg/agent/kimi.go index 0402e7605f4..60bbcf4156f 100644 --- a/server/pkg/agent/kimi.go +++ b/server/pkg/agent/kimi.go @@ -5,7 +5,9 @@ import ( "context" "fmt" "io" + "os" "os/exec" + "path/filepath" "strings" "sync" "time" @@ -35,9 +37,14 @@ func (b *kimiBackend) Execute(ctx context.Context, prompt string, opts ExecOptio if execPath == "" { execPath = "kimi" } - if _, err := exec.LookPath(execPath); err != nil { + resolvedExecPath, err := exec.LookPath(execPath) + if err != nil { return nil, fmt.Errorf("kimi executable not found at %q: %w", execPath, err) } + resolvedExecPath, err = filepath.Abs(resolvedExecPath) + if err != nil { + return nil, fmt.Errorf("resolve kimi executable %q: %w", resolvedExecPath, err) + } // Translate the agent's mcp_config (Claude-style object of objects) // into the array shape ACP `session/new` expects. Fail closed on @@ -57,13 +64,20 @@ func (b *kimiBackend) Execute(ctx context.Context, prompt string, opts ExecOptio // a safe granting option the agent offered (see // selectACPApprovalOptionID) for each session/request_permission request. kimiArgs := append([]string{"acp"}, filterCustomArgs(opts.CustomArgs, kimiBlockedArgs, b.cfg.Logger)...) - cmd := exec.CommandContext(runCtx, execPath, kimiArgs...) - hideAgentWindow(cmd) - b.cfg.Logger.Info("agent command", "exec", execPath, "args", kimiArgs) - if opts.Cwd != "" { - cmd.Dir = opts.Cwd + cwd := opts.Cwd + if cwd == "" { + cwd, err = os.Getwd() + if err != nil { + cancel() + return nil, fmt.Errorf("resolve kimi working directory: %w", err) + } + } + cmd, err := b.cfg.command(runCtx, resolvedExecPath, kimiArgs, cwd, 10*time.Second) + if err != nil { + cancel() + return nil, fmt.Errorf("build kimi command: %w", err) } - cmd.Env = buildEnv(b.cfg.Env) + b.cfg.Logger.Info("agent command", "exec", resolvedExecPath, "args", kimiArgs) stdout, err := cmd.StdoutPipe() if err != nil { @@ -106,7 +120,7 @@ func (b *kimiBackend) Execute(ctx context.Context, prompt string, opts ExecOptio _, _ = io.Copy(stderrSink, stderr) }() - b.cfg.Logger.Info("kimi acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd) + b.cfg.Logger.Info("kimi acp started", "pid", cmd.Process().Pid, "cwd", opts.Cwd) msgCh := make(chan Message, 256) resCh := make(chan Result, 1) @@ -349,7 +363,7 @@ func (b *kimiBackend) Execute(ctx context.Context, prompt string, opts ExecOptio } duration := time.Since(startTime) - b.cfg.Logger.Info("kimi finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) + b.cfg.Logger.Info("kimi finished", "pid", cmd.Process().Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) stdin.Close() cancel() diff --git a/server/pkg/agent/kimi_test.go b/server/pkg/agent/kimi_test.go index 57349081305..ceb3a4c9b2a 100644 --- a/server/pkg/agent/kimi_test.go +++ b/server/pkg/agent/kimi_test.go @@ -3,7 +3,6 @@ package agent import ( "context" "encoding/json" - "log/slog" "os" "path/filepath" "strings" @@ -66,7 +65,7 @@ func fakeKimiACPScript() string { # # Writes the full argv (one arg per line) to $KIMI_ARGS_FILE if that env # var is set, so tests can assert that the daemon invokes us with the -# right flags (`+"`--yolo acp`"+`, not bare `+"`acp`"+`). +# right flags (` + "`--yolo acp`" + `, not bare ` + "`acp`" + `). # # Then reads one JSON-RPC request per line from stdin, matches on the # method name, and writes back a canned response. Exits after set_model @@ -106,7 +105,7 @@ func TestKimiBackendSetModelFailureFailsTask(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "kimi") writeTestExecutable(t, fakePath, []byte(fakeKimiACPScript())) - backend, err := New("kimi", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("kimi", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new kimi backend: %v", err) } @@ -188,7 +187,7 @@ func TestKimiBackendClearsSessionIDWhenSetModelSessionNotFound(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "kimi") writeTestExecutable(t, fakePath, []byte(fakeKimiACPStaleResumeSetModelScript())) - backend, err := New("kimi", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("kimi", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new kimi backend: %v", err) } @@ -244,11 +243,7 @@ func TestKimiBackendInvokesACPSubcommand(t *testing.T) { fakePath := filepath.Join(tempDir, "kimi") writeTestExecutable(t, fakePath, []byte(fakeKimiACPScript())) - backend, err := New("kimi", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{"KIMI_ARGS_FILE": argsFile}, - }) + backend, err := New("kimi", acpProviderTestConfig(t, fakePath, map[string]string{"KIMI_ARGS_FILE": argsFile})) if err != nil { t.Fatalf("new kimi backend: %v", err) } @@ -300,7 +295,7 @@ func TestKimiResumeIncludesMcpServers(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "kimi") writeTestExecutable(t, fakePath, []byte(fakeACPRecordingScript(recordPath, "ses_resume", `{}`))) - backend, err := New("kimi", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("kimi", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new kimi backend: %v", err) } diff --git a/server/pkg/agent/kiro.go b/server/pkg/agent/kiro.go index 6875b71173d..d6541dbff8e 100644 --- a/server/pkg/agent/kiro.go +++ b/server/pkg/agent/kiro.go @@ -6,7 +6,9 @@ import ( "errors" "fmt" "io" + "os" "os/exec" + "path/filepath" "strings" "sync" "sync/atomic" @@ -41,9 +43,14 @@ func (b *kiroBackend) Execute(ctx context.Context, prompt string, opts ExecOptio if execPath == "" { execPath = "kiro-cli" } - if _, err := exec.LookPath(execPath); err != nil { + resolvedExecPath, err := exec.LookPath(execPath) + if err != nil { return nil, fmt.Errorf("kiro executable not found at %q: %w", execPath, err) } + resolvedExecPath, err = filepath.Abs(resolvedExecPath) + if err != nil { + return nil, fmt.Errorf("resolve kiro executable %q: %w", resolvedExecPath, err) + } // Translate the agent's mcp_config (Claude-style object of objects) // into the array shape ACP `session/new` and `session/load` expect. @@ -58,13 +65,20 @@ func (b *kiroBackend) Execute(ctx context.Context, prompt string, opts ExecOptio runCtx, cancel := runContext(ctx, timeout) kiroArgs := append([]string{"acp", "--trust-all-tools"}, filterCustomArgs(opts.CustomArgs, kiroBlockedArgs, b.cfg.Logger)...) - cmd := exec.CommandContext(runCtx, execPath, kiroArgs...) - hideAgentWindow(cmd) - b.cfg.Logger.Info("agent command", "exec", execPath, "args", kiroArgs) - if opts.Cwd != "" { - cmd.Dir = opts.Cwd + cwd := opts.Cwd + if cwd == "" { + cwd, err = os.Getwd() + if err != nil { + cancel() + return nil, fmt.Errorf("resolve kiro working directory: %w", err) + } + } + cmd, err := b.cfg.command(runCtx, resolvedExecPath, kiroArgs, cwd, 10*time.Second) + if err != nil { + cancel() + return nil, fmt.Errorf("build kiro command: %w", err) } - cmd.Env = buildEnv(b.cfg.Env) + b.cfg.Logger.Info("agent command", "exec", resolvedExecPath, "args", kiroArgs) stdout, err := cmd.StdoutPipe() if err != nil { @@ -99,7 +113,7 @@ func (b *kiroBackend) Execute(ctx context.Context, prompt string, opts ExecOptio _, _ = io.Copy(stderrSink, stderr) }() - b.cfg.Logger.Info("kiro acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd) + b.cfg.Logger.Info("kiro acp started", "pid", cmd.Process().Pid, "cwd", opts.Cwd) msgCh := make(chan Message, 256) resCh := make(chan Result, 1) @@ -410,7 +424,7 @@ func (b *kiroBackend) Execute(ctx context.Context, prompt string, opts ExecOptio } duration := time.Since(startTime) - b.cfg.Logger.Info("kiro finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) + b.cfg.Logger.Info("kiro finished", "pid", cmd.Process().Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) stdin.Close() cancel() diff --git a/server/pkg/agent/kiro_test.go b/server/pkg/agent/kiro_test.go index e284567c853..43a1be6504c 100644 --- a/server/pkg/agent/kiro_test.go +++ b/server/pkg/agent/kiro_test.go @@ -3,7 +3,6 @@ package agent import ( "context" "encoding/json" - "log/slog" "os" "path/filepath" "strings" @@ -120,7 +119,7 @@ func TestKiroBackendSetModelFailureFailsTask(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "kiro-cli") writeTestExecutable(t, fakePath, []byte(fakeKiroACPScript())) - backend, err := New("kiro", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("kiro", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new kiro backend: %v", err) } @@ -168,7 +167,7 @@ func TestKiroBackendAttributesUsageToCurrentModel(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "kiro-cli") writeTestExecutable(t, fakePath, []byte(fakeKiroACPScript())) - backend, err := New("kiro", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("kiro", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new kiro backend: %v", err) } @@ -231,7 +230,7 @@ func TestKiroBackendTreatsGoalCompleteCloseErrorAsCompleted(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "kiro-cli") writeTestExecutable(t, fakePath, []byte(fakeKiroACPGoalCompleteCloseErrorScript("completed"))) - backend, err := New("kiro", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("kiro", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new kiro backend: %v", err) } @@ -275,7 +274,7 @@ func TestKiroBackendDoesNotCompleteAfterFailedGoalComplete(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "kiro-cli") writeTestExecutable(t, fakePath, []byte(fakeKiroACPGoalCompleteCloseErrorScript("failed"))) - backend, err := New("kiro", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("kiro", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new kiro backend: %v", err) } @@ -341,7 +340,7 @@ func TestKiroBackendTreatsCommentAddCloseErrorAsCompleted(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "kiro-cli") writeTestExecutable(t, fakePath, []byte(fakeKiroACPIssueCommentCloseErrorScript())) - backend, err := New("kiro", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("kiro", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new kiro backend: %v", err) } @@ -456,7 +455,7 @@ func runKiroCloseErrorScript(t *testing.T, script string) Result { fakePath := filepath.Join(t.TempDir(), "kiro-cli") writeTestExecutable(t, fakePath, []byte(script)) - backend, err := New("kiro", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("kiro", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new kiro backend: %v", err) } @@ -788,7 +787,7 @@ func TestKiroBackendClearsSessionIDWhenSetModelSessionNotFound(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "kiro-cli") writeTestExecutable(t, fakePath, []byte(fakeKiroACPStaleLoadSetModelScript())) - backend, err := New("kiro", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("kiro", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new kiro backend: %v", err) } @@ -866,7 +865,7 @@ func TestKiroBackendClearsSessionIDWhenPromptSessionNotFound(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "kiro-cli") writeTestExecutable(t, fakePath, []byte(fakeKiroACPStalePromptScript())) - backend, err := New("kiro", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("kiro", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new kiro backend: %v", err) } @@ -910,11 +909,7 @@ func TestKiroBackendInvokesACPWithTrustAllTools(t *testing.T) { fakePath := filepath.Join(tempDir, "kiro-cli") writeTestExecutable(t, fakePath, []byte(fakeKiroACPScript())) - backend, err := New("kiro", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{"KIRO_ARGS_FILE": argsFile}, - }) + backend, err := New("kiro", acpProviderTestConfig(t, fakePath, map[string]string{"KIRO_ARGS_FILE": argsFile})) if err != nil { t.Fatalf("new kiro backend: %v", err) } @@ -970,11 +965,7 @@ func TestKiroBackendUsesSessionLoadForResume(t *testing.T) { fakePath := filepath.Join(tempDir, "kiro-cli") writeTestExecutable(t, fakePath, []byte(fakeKiroACPScript())) - backend, err := New("kiro", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{"KIRO_REQUESTS_FILE": requestsFile}, - }) + backend, err := New("kiro", acpProviderTestConfig(t, fakePath, map[string]string{"KIRO_REQUESTS_FILE": requestsFile})) if err != nil { t.Fatalf("new kiro backend: %v", err) } @@ -1068,7 +1059,7 @@ func TestKiroLoadIncludesMcpServersFromConfig(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "kiro-cli") writeTestExecutable(t, fakePath, []byte(fakeACPRecordingScript(recordPath, "ses_load", `{}`))) - backend, err := New("kiro", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("kiro", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new kiro backend: %v", err) } diff --git a/server/pkg/agent/models.go b/server/pkg/agent/models.go index 6aef34b17b0..cf48eee1754 100644 --- a/server/pkg/agent/models.go +++ b/server/pkg/agent/models.go @@ -65,6 +65,100 @@ type ThinkingLevel struct { Description string `json:"description,omitempty"` } +// ModelDiscoveryExecution is the complete process authority for task-time +// model discovery. Unlike operator discovery, task discovery must not inherit +// the daemon cwd or environment and must use the task's isolation policy. +type ModelDiscoveryExecution struct { + Launcher CommandBuilder + Cwd string + Env map[string]string + Isolation *TaskIsolationPolicy +} + +// ModelCatalogSnapshot is a trusted, registration-time catalog bound to one +// provider and executable identity. The caller is responsible for obtaining +// it outside Issue-task authority; task validation verifies the binding before +// consuming it and returns a defensive copy. +type ModelCatalogSnapshot struct { + ProviderType string + ExecutablePath string + Models []Model +} + +// TaskModelDiscovery supplies exactly one source of task-time model truth. +// Execution performs discovery through the task launcher. Snapshot avoids +// launching a runtime and is preferred when the daemon has a trusted catalog. +type TaskModelDiscovery struct { + Execution *ModelDiscoveryExecution + Snapshot *ModelCatalogSnapshot +} + +type modelDiscoveryRunner struct { + execution *ModelDiscoveryExecution +} + +func operatorModelDiscoveryRunner() modelDiscoveryRunner { + return modelDiscoveryRunner{} +} + +func taskModelDiscoveryRunner(execution *ModelDiscoveryExecution) (modelDiscoveryRunner, error) { + if execution == nil { + return modelDiscoveryRunner{}, fmt.Errorf("task model discovery execution is required") + } + if execution.Launcher == nil { + return modelDiscoveryRunner{}, fmt.Errorf("task model discovery launcher is required") + } + if execution.Cwd == "" { + return modelDiscoveryRunner{}, fmt.Errorf("task model discovery cwd is required") + } + if execution.Env == nil { + return modelDiscoveryRunner{}, fmt.Errorf("task model discovery environment is required") + } + if execution.Isolation == nil { + return modelDiscoveryRunner{}, fmt.Errorf("task model discovery isolation policy is required") + } + return modelDiscoveryRunner{execution: execution}, nil +} + +func (r modelDiscoveryRunner) command(ctx context.Context, executable string, args []string, waitDelay time.Duration) (TaskCommand, error) { + if r.execution == nil { + cmd := exec.CommandContext(ctx, executable, args...) + hideAgentWindow(cmd) + cmd.WaitDelay = waitDelay + return newPreparedCommand(cmd), nil + } + return r.execution.Launcher.Command(ctx, CommandRequest{ + Executable: executable, + Args: append([]string(nil), args...), + Cwd: r.execution.Cwd, + Env: cloneModelDiscoveryEnv(r.execution.Env), + Isolation: r.execution.Isolation, + WaitDelay: waitDelay, + }) +} + +func cloneModelDiscoveryEnv(env map[string]string) map[string]string { + cloned := make(map[string]string, len(env)) + for key, value := range env { + cloned[key] = value + } + return cloned +} + +func cloneModels(models []Model) []Model { + cloned := make([]Model, len(models)) + for i, model := range models { + cloned[i] = model + if model.Thinking == nil { + continue + } + thinking := *model.Thinking + thinking.SupportedLevels = append([]ThinkingLevel(nil), model.Thinking.SupportedLevels...) + cloned[i].Thinking = &thinking + } + return cloned +} + // modelCache memoizes dynamic discovery calls so repeated UI loads // don't re-shell the agent CLI. Entries expire after cacheTTL. type modelCacheEntry struct { @@ -177,6 +271,55 @@ func ListModels(ctx context.Context, providerType, executablePath string) ([]Mod } } +// ListModelsForTask discovers a catalog without granting Issue-task code the +// daemon's process authority. Exactly one trusted snapshot or explicit task +// execution context is required. Task discovery is intentionally uncached so +// an operator-discovery cache entry cannot cross the authority boundary. +func ListModelsForTask(ctx context.Context, providerType, executablePath string, discovery TaskModelDiscovery) ([]Model, error) { + if discovery.Snapshot != nil { + if discovery.Execution != nil { + return nil, fmt.Errorf("task model discovery must use either a snapshot or execution, not both") + } + if discovery.Snapshot.ProviderType != providerType { + return nil, fmt.Errorf("task model snapshot provider %q does not match %q", discovery.Snapshot.ProviderType, providerType) + } + if discovery.Snapshot.ExecutablePath != executablePath { + return nil, fmt.Errorf("task model snapshot executable %q does not match %q", discovery.Snapshot.ExecutablePath, executablePath) + } + return cloneModels(discovery.Snapshot.Models), nil + } + if providerType == "grok" { + // Grok's verified thinking catalog is static and per-model. Do not + // start the ACP runtime merely to validate an Issue task's --effort. + return grokStaticModels(), nil + } + + runner, err := taskModelDiscoveryRunner(discovery.Execution) + if err != nil { + return nil, err + } + switch providerType { + case "claude": + models := claudeStaticModels() + annotateClaudeThinkingWithRunner(ctx, models, executablePath, runner) + return models, nil + case "codex": + return discoverCodexModelsWithRunner(ctx, executablePath, runner), nil + case "codebuddy": + helpOutput := codebuddyHelpOutputWithRunner(ctx, executablePath, runner) + models := parseCodebuddyModels(helpOutput) + if len(models) == 0 { + models = codebuddyStaticModels() + } + annotateCodebuddyThinkingFromHelp(models, helpOutput) + return models, nil + case "opencode": + return discoverOpenCodeModelsWithRunner(ctx, executablePath, runner) + default: + return nil, fmt.Errorf("task model discovery is unsupported for agent type %q", providerType) + } +} + // ModelSelectionSupported reports whether setting `agent.model` has // any effect for the given provider. Every built-in provider now honours // `opts.Model` end-to-end — Hermes routes it through the ACP @@ -480,14 +623,23 @@ func discoverOpenCodeModels(ctx context.Context, executablePath string) ([]Model if _, err := exec.LookPath(executablePath); err != nil { return []Model{}, nil } + return discoverOpenCodeModelsWithRunner(ctx, executablePath, operatorModelDiscoveryRunner()) +} + +func discoverOpenCodeModelsWithRunner(ctx context.Context, executablePath string, runner modelDiscoveryRunner) ([]Model, error) { + if executablePath == "" { + executablePath = "opencode" + } // Newer opencode (1.15+) syncs its hosted free-model catalog over the // network on `opencode models`, which can take ~6s; the previous 5s cap // timed out and returned an empty list, so the runtime showed online but // the model picker was empty. See multica-ai/multica#3627. runCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() - cmd := exec.CommandContext(runCtx, executablePath, "models", "--verbose") - hideAgentWindow(cmd) + cmd, err := runner.command(runCtx, executablePath, []string{"models", "--verbose"}, 0) + if err != nil { + return nil, fmt.Errorf("build opencode verbose model discovery: %w", err) + } // Parse whatever the verbose command printed, even on a non-zero exit — a // stale config entry can make `opencode models` exit non-zero while still // listing the resolvable catalog (mirrors the pi path; see #3729/#3627). @@ -497,8 +649,10 @@ func discoverOpenCodeModels(ctx context.Context, executablePath string) ([]Model // Verbose yielded nothing usable (unsupported flag, error text, or an // empty list). Retry the plain command, which omits the per-model JSON // but still prints the IDs. - cmd = exec.CommandContext(runCtx, executablePath, "models") - hideAgentWindow(cmd) + cmd, err = runner.command(runCtx, executablePath, []string{"models"}, 0) + if err != nil { + return nil, fmt.Errorf("build opencode model discovery: %w", err) + } out, _ = cmd.Output() models = parseOpenCodeModels(string(out)) } diff --git a/server/pkg/agent/openclaw.go b/server/pkg/agent/openclaw.go index 5ba51adeae6..238a0ac3d2f 100644 --- a/server/pkg/agent/openclaw.go +++ b/server/pkg/agent/openclaw.go @@ -57,11 +57,12 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO if execPath == "" { execPath = "openclaw" } - if _, err := exec.LookPath(execPath); err != nil { + lookedUp, err := exec.LookPath(execPath) + if err != nil { return nil, fmt.Errorf("openclaw executable not found at %q: %w", execPath, err) } - if err := checkOpenclawVersion(ctx, execPath); err != nil { + if err := checkOpenclawVersion(ctx, b.cfg, lookedUp, opts.Cwd); err != nil { return nil, err } @@ -74,14 +75,12 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO } args := buildOpenclawArgs(prompt, sessionID, opts, b.cfg.Logger) - cmd := exec.CommandContext(runCtx, execPath, args...) - hideAgentWindow(cmd) - b.cfg.Logger.Info("agent command", "exec", execPath, "args", args) - cmd.WaitDelay = 10 * time.Second - if opts.Cwd != "" { - cmd.Dir = opts.Cwd + cmd, err := b.cfg.command(runCtx, lookedUp, args, opts.Cwd, 10*time.Second) + if err != nil { + cancel() + return nil, fmt.Errorf("create openclaw command: %w", err) } - cmd.Env = buildEnv(b.cfg.Env) + b.cfg.Logger.Info("agent command", "exec", lookedUp, "args", args) // openclaw writes its --json output to stdout. Stderr carries log // overflow (security warnings, tool errors, etc.) — capture it via a @@ -92,14 +91,14 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO cancel() return nil, fmt.Errorf("openclaw stdout pipe: %w", err) } - cmd.Stderr = newLogWriter(b.cfg.Logger, "[openclaw:stderr] ") + _ = cmd.SetStderr(newLogWriter(b.cfg.Logger, "[openclaw:stderr] ")) if err := cmd.Start(); err != nil { cancel() return nil, fmt.Errorf("start openclaw: %w", err) } - b.cfg.Logger.Info("openclaw started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model) + b.cfg.Logger.Info("openclaw started", "pid", cmd.Process().Pid, "cwd", opts.Cwd, "model", opts.Model) msgCh := make(chan Message, 256) resCh := make(chan Result, 1) @@ -133,7 +132,7 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO scanResult.errMsg = fmt.Sprintf("openclaw exited with error: %v", exitErr) } - b.cfg.Logger.Info("openclaw finished", "pid", cmd.Process.Pid, "status", scanResult.status, "duration", duration.Round(time.Millisecond).String()) + b.cfg.Logger.Info("openclaw finished", "pid", cmd.Process().Pid, "status", scanResult.status, "duration", duration.Round(time.Millisecond).String()) // Build usage map. Prefer the model openclaw reported in // `meta.agentMeta.model` (the actual LLM, e.g. `deepseek-chat`). @@ -231,9 +230,11 @@ func customArgsContains(args []string, flag string) bool { // minOpenclawVersion. The returned error becomes the task's failure // comment, so the message intentionally names the detected version // and the upgrade command. -func checkOpenclawVersion(ctx context.Context, execPath string) error { - cmd := exec.CommandContext(ctx, execPath, "--version") - hideAgentWindow(cmd) +func checkOpenclawVersion(ctx context.Context, cfg Config, execPath, cwd string) error { + cmd, err := cfg.command(ctx, execPath, []string{"--version"}, cwd, 0) + if err != nil { + return fmt.Errorf("create openclaw --version command: %w", err) + } out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("openclaw --version failed: %w", err) diff --git a/server/pkg/agent/openclaw_test.go b/server/pkg/agent/openclaw_test.go index 9ebbc1d1387..94c334e02db 100644 --- a/server/pkg/agent/openclaw_test.go +++ b/server/pkg/agent/openclaw_test.go @@ -1504,14 +1504,15 @@ func TestOpenclawExecuteRejectsOldVersion(t *testing.T) { "exit 99\n" writeTestExecutable(t, fakePath, []byte(script)) - backend, err := New("openclaw", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + cfg, cwd := providerCommandTestConfig(t, fakePath, slog.Default()) + backend, err := New("openclaw", cfg) if err != nil { t.Fatalf("new openclaw backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - _, err = backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second}) + _, err = backend.Execute(ctx, "prompt-ignored", ExecOptions{Cwd: cwd, Timeout: 5 * time.Second}) if err == nil { t.Fatal("expected Execute to return a version error, got nil") } @@ -1543,14 +1544,15 @@ func TestOpenclawExecuteAllowsCurrentVersion(t *testing.T) { "exit 0\n" writeTestExecutable(t, fakePath, []byte(script)) - backend, err := New("openclaw", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + cfg, cwd := providerCommandTestConfig(t, fakePath, slog.Default()) + backend, err := New("openclaw", cfg) if err != nil { t.Fatalf("new openclaw backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second}) + session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Cwd: cwd, Timeout: 5 * time.Second}) if err != nil { t.Fatalf("Execute returned synchronous error past the version gate: %v", err) } diff --git a/server/pkg/agent/opencode.go b/server/pkg/agent/opencode.go index cca771ce345..8beba0ba6a4 100644 --- a/server/pkg/agent/opencode.go +++ b/server/pkg/agent/opencode.go @@ -97,27 +97,10 @@ func (b *opencodeBackend) Execute(ctx context.Context, prompt string, opts ExecO args = append(args, filterCustomArgs(opts.CustomArgs, opencodeBlockedArgs, b.cfg.Logger)...) args = append(args, prompt) - cmd := exec.CommandContext(runCtx, execPath, args...) - hideAgentWindow(cmd) - // Run opencode in its own process group so cancellation can reach the - // whole tree (opencode plus any tool subprocess it spawns), not just the - // direct child — otherwise a cancelled or restarted run can orphan a - // descendant that keeps spinning (#4533). - configureProcessGroup(cmd) - // Take over context cancellation. The default CommandContext behaviour - // SIGKILLs only the leader the instant runCtx is done; we instead drive a - // graceful, group-wide SIGTERM→SIGKILL from the cancellation goroutine - // below and close the stdout read end only after the tree has been - // signalled. Returning nil here keeps os/exec from racing us with its own - // kill; WaitDelay remains the hard backstop. - cmd.Cancel = func() error { return nil } - b.cfg.Logger.Info("agent command", "exec", execPath, "args", args) - cmd.WaitDelay = 10 * time.Second - if opts.Cwd != "" { - cmd.Dir = opts.Cwd + env := make(map[string]string, len(b.cfg.Env)+2) + for key, value := range b.cfg.Env { + env[key] = value } - - env := buildEnv(b.cfg.Env) // Keep daemon-mode runs non-interactive without relying on // OPENCODE_PERMISSION. OpenCode deep-merges that env override into user // config while preserving existing key order, so a pre-existing @@ -131,7 +114,7 @@ func (b *opencodeBackend) Execute(ctx context.Context, prompt string, opts ExecO // when computing the directory it walks for AGENTS.md / .opencode/skills. // See packages/opencode/src/cli/cmd/run.ts in the upstream source. if opts.Cwd != "" { - env = append(env, "PWD="+opts.Cwd) + env["PWD"] = opts.Cwd } // Project agent.mcp_config into OpenCode via OPENCODE_CONFIG_CONTENT — // OpenCode's general inline-config injection mechanism that merges at @@ -153,23 +136,42 @@ func (b *opencodeBackend) Execute(ctx context.Context, prompt string, opts ExecO if _, dup := b.cfg.Env["OPENCODE_CONFIG_CONTENT"]; dup { b.cfg.Logger.Warn("agent.custom_env sets OPENCODE_CONFIG_CONTENT but agent.mcp_config takes precedence and overrides it") } - env = append(env, "OPENCODE_CONFIG_CONTENT="+mcpContent) + env["OPENCODE_CONFIG_CONTENT"] = mcpContent + } + commandCfg := b.cfg + commandCfg.Env = env + cmd, err := commandCfg.command(runCtx, execPath, args, opts.Cwd, 10*time.Second) + if err != nil { + cancel() + return nil, fmt.Errorf("prepare opencode command: %w", err) } - cmd.Env = env + // cfg.command runs opencode in its own process group so cancellation can + // reach the whole tree (opencode plus any tool subprocess it spawns), not + // just the direct child — otherwise a cancelled or restarted run can orphan + // a descendant that keeps spinning (#4533). + // + // Take over context cancellation. The default CommandContext behaviour + // SIGKILLs only the leader the instant runCtx is done; we instead drive a + // graceful, group-wide SIGTERM→SIGKILL from the cancellation goroutine + // below and close the stdout read end only after the tree has been + // signalled. Returning nil here keeps os/exec from racing us with its own + // kill; WaitDelay remains the hard backstop. + _ = cmd.SetCancel(func() error { return nil }) + b.cfg.Logger.Info("agent command", "exec", execPath, "args", args) stdout, err := cmd.StdoutPipe() if err != nil { cancel() return nil, fmt.Errorf("opencode stdout pipe: %w", err) } - cmd.Stderr = newLogWriter(b.cfg.Logger, "[opencode:stderr] ") + _ = cmd.SetStderr(newLogWriter(b.cfg.Logger, "[opencode:stderr] ")) if err := cmd.Start(); err != nil { cancel() return nil, fmt.Errorf("start opencode: %w", err) } - b.cfg.Logger.Info("opencode started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model) + b.cfg.Logger.Info("opencode started", "pid", cmd.Process().Pid, "cwd", opts.Cwd, "model", opts.Model) msgCh := make(chan Message, 256) resCh := make(chan Result, 1) @@ -194,12 +196,12 @@ func (b *opencodeBackend) Execute(ctx context.Context, prompt string, opts ExecO return // finished on its own; nothing to terminate case <-runCtx.Done(): } - if cmd.Process != nil { - signalProcessGroup(cmd.Process, syscall.SIGTERM) + if cmd.Process() != nil { + signalProcessGroup(cmd.Process(), syscall.SIGTERM) select { case <-procDone: // exited within the grace window case <-time.After(opencodeTerminateGrace()): - signalProcessGroup(cmd.Process, syscall.SIGKILL) + signalProcessGroup(cmd.Process(), syscall.SIGKILL) } } _ = stdout.Close() @@ -234,7 +236,7 @@ func (b *opencodeBackend) Execute(ctx context.Context, prompt string, opts ExecO scanResult.errMsg = fmt.Sprintf("%s; opencode exited with error: %v", scanResult.errMsg, exitErr) } - b.cfg.Logger.Info("opencode finished", "pid", cmd.Process.Pid, "status", scanResult.status, "duration", duration.Round(time.Millisecond).String()) + b.cfg.Logger.Info("opencode finished", "pid", cmd.Process().Pid, "status", scanResult.status, "duration", duration.Round(time.Millisecond).String()) // Build usage map. OpenCode doesn't report model per-step, so we // attribute all usage to the configured model (or "unknown"). diff --git a/server/pkg/agent/opencode_cancel_unix_test.go b/server/pkg/agent/opencode_cancel_unix_test.go index 661186dc8b7..280dd07afe1 100644 --- a/server/pkg/agent/opencode_cancel_unix_test.go +++ b/server/pkg/agent/opencode_cancel_unix_test.go @@ -4,7 +4,6 @@ package agent import ( "context" - "log/slog" "os" "path/filepath" "strconv" @@ -68,11 +67,7 @@ func runOpencodeCancellationTest(t *testing.T, script string) { fakePath := filepath.Join(tempDir, "opencode") writeTestExecutable(t, fakePath, []byte(script)) - backend, err := New("opencode", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{"OPENCODE_PID_FILE": pidFile}, - }) + backend, err := New("opencode", opencodeTestConfig(t, fakePath, tempDir, map[string]string{"OPENCODE_PID_FILE": pidFile})) if err != nil { t.Fatalf("new opencode backend: %v", err) } diff --git a/server/pkg/agent/opencode_mcp_test.go b/server/pkg/agent/opencode_mcp_test.go index dd2456b557b..9edeba47829 100644 --- a/server/pkg/agent/opencode_mcp_test.go +++ b/server/pkg/agent/opencode_mcp_test.go @@ -3,7 +3,6 @@ package agent import ( "context" "encoding/json" - "log/slog" "os" "path/filepath" "strings" @@ -407,13 +406,9 @@ func TestOpencodeBackendInjectsMCPConfigViaEnv(t *testing.T) { writeTestExecutable(t, fakePath, []byte(fakeOpencodeScriptCapturingEnv())) workDir := t.TempDir() - backend, err := New("opencode", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{ - "OPENCODE_CAPTURE_FILE": captureFile, - }, - }) + backend, err := New("opencode", opencodeTestConfig(t, fakePath, workDir, map[string]string{ + "OPENCODE_CAPTURE_FILE": captureFile, + })) if err != nil { t.Fatalf("new backend: %v", err) } @@ -468,14 +463,10 @@ func TestOpencodeBackendOmitsMCPEnvWhenEmpty(t *testing.T) { const userContent = `{"mcp":{"user_only":{"type":"remote","url":"https://user.example/mcp"}}}` workDir := t.TempDir() - backend, err := New("opencode", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{ - "OPENCODE_CAPTURE_FILE": captureFile, - "OPENCODE_CONFIG_CONTENT": userContent, - }, - }) + backend, err := New("opencode", opencodeTestConfig(t, fakePath, workDir, map[string]string{ + "OPENCODE_CAPTURE_FILE": captureFile, + "OPENCODE_CONFIG_CONTENT": userContent, + })) if err != nil { t.Fatalf("new backend: %v", err) } @@ -518,14 +509,10 @@ func TestOpencodeBackendOverridesUserOpenCodeConfigContent(t *testing.T) { const userBogus = `{"this-should-not-survive":true}` workDir := t.TempDir() - backend, err := New("opencode", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{ - "OPENCODE_CAPTURE_FILE": captureFile, - "OPENCODE_CONFIG_CONTENT": userBogus, - }, - }) + backend, err := New("opencode", opencodeTestConfig(t, fakePath, workDir, map[string]string{ + "OPENCODE_CAPTURE_FILE": captureFile, + "OPENCODE_CONFIG_CONTENT": userBogus, + })) if err != nil { t.Fatalf("new backend: %v", err) } diff --git a/server/pkg/agent/opencode_test.go b/server/pkg/agent/opencode_test.go index 47dc0cfb085..e58db47372f 100644 --- a/server/pkg/agent/opencode_test.go +++ b/server/pkg/agent/opencode_test.go @@ -13,6 +13,35 @@ import ( "time" ) +type opencodeTestIsolation struct{} + +func (*opencodeTestIsolation) WrapBound(_ *boundIsolationPolicy, executable, cwd pathIdentity, args []string, leadingExtraFiles int) (string, []string, []*os.File, error) { + _ = cwd + _ = leadingExtraFiles + return executable.Path, args, nil, nil +} + +func opencodeTestConfig(t *testing.T, executable, cwd string, env map[string]string) Config { + t.Helper() + taskEnv := make(map[string]string, len(env)+1) + for key, value := range env { + taskEnv[key] = value + } + if taskEnv["PATH"] == "" { + taskEnv["PATH"] = os.Getenv("PATH") + } + return Config{ + ExecutablePath: executable, + Logger: slog.Default(), + Env: taskEnv, + Launcher: newCommandLauncher(&opencodeTestIsolation{}), + Isolation: &TaskIsolationPolicy{ + WritableRoots: []string{filepath.Dir(executable), cwd}, + Network: NetworkAccessPublicAndLoopback, + }, + } +} + func TestNewReturnsOpencodeBackend(t *testing.T) { t.Parallel() b, err := New("opencode", Config{ExecutablePath: "/nonexistent/opencode"}) @@ -1159,14 +1188,10 @@ func TestOpencodeBackendAnchorsDirAndPWD(t *testing.T) { workDir := t.TempDir() - backend, err := New("opencode", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{ - "OPENCODE_ARGS_FILE": argsFile, - "OPENCODE_PWD_FILE": pwdFile, - }, - }) + backend, err := New("opencode", opencodeTestConfig(t, fakePath, workDir, map[string]string{ + "OPENCODE_ARGS_FILE": argsFile, + "OPENCODE_PWD_FILE": pwdFile, + })) if err != nil { t.Fatalf("new opencode backend: %v", err) } @@ -1230,14 +1255,11 @@ func TestOpencodeBackendInjectsThinkingVariant(t *testing.T) { argsFile := filepath.Join(tempDir, "argv.txt") fakePath := filepath.Join(tempDir, "opencode") writeTestExecutable(t, fakePath, []byte(fakeOpencodeScript())) + workDir := t.TempDir() - backend, err := New("opencode", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{ - "OPENCODE_ARGS_FILE": argsFile, - }, - }) + backend, err := New("opencode", opencodeTestConfig(t, fakePath, workDir, map[string]string{ + "OPENCODE_ARGS_FILE": argsFile, + })) if err != nil { t.Fatalf("new opencode backend: %v", err) } @@ -1246,6 +1268,7 @@ func TestOpencodeBackendInjectsThinkingVariant(t *testing.T) { defer cancel() session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{ + Cwd: workDir, Model: "opencode/deepseek-v4", ThinkingLevel: "max", CustomArgs: []string{"--variant", "low", "--keep-me"}, @@ -1298,14 +1321,11 @@ func TestOpencodeBackendDoesNotUsePermissionEnvOverride(t *testing.T) { permissionFile := filepath.Join(tempDir, "permission.json") fakePath := filepath.Join(tempDir, "opencode") writeTestExecutable(t, fakePath, []byte(fakeOpencodeScript())) + workDir := t.TempDir() - backend, err := New("opencode", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{ - "OPENCODE_PERMISSION_FILE": permissionFile, - }, - }) + backend, err := New("opencode", opencodeTestConfig(t, fakePath, workDir, map[string]string{ + "OPENCODE_PERMISSION_FILE": permissionFile, + })) if err != nil { t.Fatalf("new opencode backend: %v", err) } @@ -1314,6 +1334,7 @@ func TestOpencodeBackendDoesNotUsePermissionEnvOverride(t *testing.T) { defer cancel() session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{ + Cwd: workDir, Timeout: 5 * time.Second, }) if err != nil { @@ -1351,13 +1372,9 @@ func TestOpencodeBackendQuestionDenySurvivesUserConfig(t *testing.T) { t.Fatalf("write opencode config: %v", err) } - backend, err := New("opencode", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{ - "OPENCODE_ARGS_FILE": argsFile, - }, - }) + backend, err := New("opencode", opencodeTestConfig(t, fakePath, workDir, map[string]string{ + "OPENCODE_ARGS_FILE": argsFile, + })) if err != nil { t.Fatalf("new opencode backend: %v", err) } @@ -1405,13 +1422,9 @@ func TestOpencodeBackendBlocksDirOverride(t *testing.T) { workDir := t.TempDir() bogusDir := t.TempDir() - backend, err := New("opencode", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{ - "OPENCODE_ARGS_FILE": argsFile, - }, - }) + backend, err := New("opencode", opencodeTestConfig(t, fakePath, workDir, map[string]string{ + "OPENCODE_ARGS_FILE": argsFile, + })) if err != nil { t.Fatalf("new opencode backend: %v", err) } @@ -1475,10 +1488,7 @@ func TestOpencodeBackendFailsOnStreamEndingMidTool(t *testing.T) { fakePath := filepath.Join(tempDir, "opencode") writeTestExecutable(t, fakePath, []byte(fakeOpencodeMidToolScript())) - backend, err := New("opencode", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - }) + backend, err := New("opencode", opencodeTestConfig(t, fakePath, tempDir, nil)) if err != nil { t.Fatalf("new opencode backend: %v", err) } @@ -1487,6 +1497,7 @@ func TestOpencodeBackendFailsOnStreamEndingMidTool(t *testing.T) { defer cancel() session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{ + Cwd: tempDir, Timeout: 5 * time.Second, }) if err != nil { @@ -1528,10 +1539,7 @@ func TestOpencodeBackendAppendsExitDetailOnMidStepCrash(t *testing.T) { fakePath := filepath.Join(tempDir, "opencode") writeTestExecutable(t, fakePath, []byte(fakeOpencodeStepThenExit1Script())) - backend, err := New("opencode", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - }) + backend, err := New("opencode", opencodeTestConfig(t, fakePath, tempDir, nil)) if err != nil { t.Fatalf("new opencode backend: %v", err) } @@ -1540,6 +1548,7 @@ func TestOpencodeBackendAppendsExitDetailOnMidStepCrash(t *testing.T) { defer cancel() session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{ + Cwd: tempDir, Timeout: 5 * time.Second, }) if err != nil { diff --git a/server/pkg/agent/pi.go b/server/pkg/agent/pi.go index f8284eccaa0..a6aedf5f98c 100644 --- a/server/pkg/agent/pi.go +++ b/server/pkg/agent/pi.go @@ -3,6 +3,7 @@ package agent import ( "bufio" "context" + "crypto/rand" "encoding/json" "fmt" "log/slog" @@ -23,6 +24,7 @@ type piBackend struct { var ( piControlTokenRE = regexp.MustCompile(`<\|[A-Za-z0-9_-]+>[A-Za-z0-9_-]*|<[A-Za-z0-9_-]+\|>`) + piSessionIDRE = regexp.MustCompile(`^[a-f0-9]{32}$`) ) func stripPiToolCallMarkup(s string) string { @@ -185,34 +187,28 @@ func (b *piBackend) Execute(ctx context.Context, prompt string, opts ExecOptions timeout := opts.Timeout - // Pi's --session flag expects a file path where events are appended. - // The path doubles as our opaque session identifier: we return it as - // SessionID and expect it back as ResumeSessionID on the next turn. - sessionPath := opts.ResumeSessionID - if sessionPath == "" { - p, err := newPiSessionPath() - if err != nil { - return nil, fmt.Errorf("pi session path: %w", err) - } - sessionPath = p - } - if err := ensurePiSessionFile(sessionPath); err != nil { + // Pi expects a filesystem path, but task/session APIs expose only an opaque + // identifier. Resolve that identifier beneath the daemon-issued task temp + // root before constructing the isolated child process. + sessionID, sessionFile, err := preparePiSession(b.cfg.TaskStateDir, opts.ResumeSessionID) + if err != nil { return nil, fmt.Errorf("pi session file: %w", err) } + defer sessionFile.Close() runCtx, cancel := runContext(ctx, timeout) - args := buildPiArgs(prompt, sessionPath, opts, b.cfg.Logger) - argv0, cmdArgs := choosePiInvocation(execName, lookedUp, args, b.cfg.Logger) + args := buildPiArgs(prompt, sessionFile.childPath(), opts, b.cfg.Logger) + argv0, cmdArgs := choosePiInvocation(lookedUp, lookedUp, args, b.cfg.Logger) - cmd := exec.CommandContext(runCtx, argv0, cmdArgs...) - hideAgentWindow(cmd) - b.cfg.Logger.Info("agent command", "exec", argv0, "args", cmdArgs) - cmd.WaitDelay = 10 * time.Second - if opts.Cwd != "" { - cmd.Dir = opts.Cwd + // Reserve child FD 3 for the verified session descriptor before isolation + // mounts claim subsequent ExtraFiles slots on Linux. + cmd, err := b.cfg.commandWithLeadingExtraFiles(runCtx, argv0, cmdArgs, opts.Cwd, 10*time.Second, []*os.File{sessionFile.file}) + if err != nil { + cancel() + return nil, fmt.Errorf("create pi command: %w", err) } - cmd.Env = buildEnv(b.cfg.Env) + b.cfg.Logger.Info("agent command", "exec", argv0, "args", cmdArgs) stdout, err := cmd.StdoutPipe() if err != nil { @@ -231,7 +227,7 @@ func (b *piBackend) Execute(ctx context.Context, prompt string, opts ExecOptions cancel() return nil, fmt.Errorf("pi stdin pipe: %w", err) } - cmd.Stderr = newLogWriter(b.cfg.Logger, "[pi:stderr] ") + _ = cmd.SetStderr(newLogWriter(b.cfg.Logger, "[pi:stderr] ")) if err := cmd.Start(); err != nil { _ = stdin.Close() @@ -240,7 +236,7 @@ func (b *piBackend) Execute(ctx context.Context, prompt string, opts ExecOptions } _ = stdin.Close() - b.cfg.Logger.Info("pi started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model) + b.cfg.Logger.Info("pi started", "pid", cmd.Process().Pid, "cwd", opts.Cwd, "model", opts.Model) msgCh := make(chan Message, 256) resCh := make(chan Result, 1) @@ -376,14 +372,14 @@ func (b *piBackend) Execute(ctx context.Context, prompt string, opts ExecOptions finalError = fmt.Sprintf("pi exited with error: %v", waitErr) } - b.cfg.Logger.Info("pi finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) + b.cfg.Logger.Info("pi finished", "pid", cmd.Process().Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) resCh <- Result{ Status: finalStatus, Output: output.String(), Error: finalError, DurationMs: duration.Milliseconds(), - SessionID: sessionPath, + SessionID: sessionID, Usage: usage, } }() @@ -535,43 +531,57 @@ func splitPiModel(s string) (provider, model string) { return "", s } -// ── Session path ── +// ── Task-private sessions ── -// piSessionDir returns the directory where Pi session JSONL files live. -// Exported via a helper so the usage scanner (package usage) can point at -// the same location without duplicating the path construction. -func piSessionDir() (string, error) { - home, err := os.UserHomeDir() +func preparePiSession(taskTempDir, resumeID string) (string, *piSessionFile, error) { + root, err := validateAbsolutePath("pi task state directory", taskTempDir) if err != nil { - return "", err + return "", nil, err + } + + sessionID := strings.TrimSpace(resumeID) + if sessionID == "" { + sessionID, err = newPiSessionID() + if err != nil { + return "", nil, err + } + } + if !piSessionIDRE.MatchString(sessionID) { + return "", nil, fmt.Errorf("session id %q must be a 32-character lowercase hexadecimal identifier", resumeID) } - return filepath.Join(home, ".multica", "pi-sessions"), nil -} -func newPiSessionPath() (string, error) { - dir, err := piSessionDir() + sessionFile, err := createPiSessionFile(root, sessionID) if err != nil { - return "", err + return "", nil, err } - name := fmt.Sprintf("%s.jsonl", time.Now().UTC().Format("20060102T150405.000000000")) - return filepath.Join(dir, name), nil + return sessionID, sessionFile, nil } -// ensurePiSessionFile creates an empty session file if one does not yet -// exist at path. Pi refuses to start when --session points at a missing -// file; paths that already exist (a resumed session) are left untouched. -func ensurePiSessionFile(path string) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0o644) - if err != nil { - return err +// IsPiSessionID reports whether id is a daemon-issued path-free Pi session +// identifier. It lets the daemon discard legacy path-valued resume pointers +// without handing those paths back to the provider backend. +func IsPiSessionID(id string) bool { + return piSessionIDRE.MatchString(strings.TrimSpace(id)) +} + +// PiSessionPresent reports whether an existing resume target is a regular, +// non-symlink file in the task-private state root. createPiSessionFile repeats +// the identity checks at open time, so this preflight is only for deciding +// whether the daemon should advertise a resume or start fresh. +func PiSessionPresent(taskStateDir, id string) bool { + root, err := validateAbsolutePath("pi task state directory", taskStateDir) + if err != nil || !IsPiSessionID(id) { + return false } - return f.Close() + path := filepath.Join(root, "pi-sessions", strings.TrimSpace(id)+".jsonl") + info, err := os.Lstat(path) + return err == nil && info.Mode().IsRegular() && info.Mode()&os.ModeSymlink == 0 } -// PiSessionDir exposes piSessionDir to other packages in this module. -func PiSessionDir() (string, error) { - return piSessionDir() +func newPiSessionID() (string, error) { + var raw [16]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", fmt.Errorf("generate session id: %w", err) + } + return fmt.Sprintf("%x", raw[:]), nil } diff --git a/server/pkg/agent/pi_session_darwin.go b/server/pkg/agent/pi_session_darwin.go new file mode 100644 index 00000000000..3e9dbca09d9 --- /dev/null +++ b/server/pkg/agent/pi_session_darwin.go @@ -0,0 +1,19 @@ +//go:build darwin + +package agent + +import ( + "fmt" + "os" +) + +type piSessionFile struct { + file *os.File +} + +func (s *piSessionFile) Close() error { return nil } +func (s *piSessionFile) childPath() string { return "" } + +func createPiSessionFile(taskTempDir, sessionID string) (*piSessionFile, error) { + return nil, fmt.Errorf("Pi execution is disabled on macOS until secure session descriptor handoff is available") +} diff --git a/server/pkg/agent/pi_session_linux.go b/server/pkg/agent/pi_session_linux.go new file mode 100644 index 00000000000..3f1f0181e40 --- /dev/null +++ b/server/pkg/agent/pi_session_linux.go @@ -0,0 +1,74 @@ +//go:build linux + +package agent + +import ( + "fmt" + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +type piSessionFile struct { + file *os.File +} + +func (s *piSessionFile) Close() error { + if s == nil || s.file == nil { + return nil + } + return s.file.Close() +} + +func (s *piSessionFile) childPath() string { + return "/proc/self/fd/3" +} + +func createPiSessionFile(taskTempDir, sessionID string) (*piSessionFile, error) { + rootFD, err := unix.Open(taskTempDir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return nil, fmt.Errorf("open task temp directory: %w", err) + } + defer unix.Close(rootFD) + + const sessionDir = "pi-sessions" + if err := unix.Mkdirat(rootFD, sessionDir, 0o700); err != nil && err != unix.EEXIST { + return nil, fmt.Errorf("create session directory: %w", err) + } + sessionDirFD, err := unix.Openat(rootFD, sessionDir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return nil, fmt.Errorf("open session directory: %w", err) + } + defer unix.Close(sessionDirFD) + + name := sessionID + ".jsonl" + fileFD, err := unix.Openat(sessionDirFD, name, unix.O_RDWR|unix.O_APPEND|unix.O_CREAT|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0o600) + if err != nil { + return nil, fmt.Errorf("open session file: %w", err) + } + owned := true + defer func() { + if owned { + _ = unix.Close(fileFD) + } + }() + + var stat unix.Stat_t + if err := unix.Fstat(fileFD, &stat); err != nil { + return nil, fmt.Errorf("stat session file: %w", err) + } + if stat.Mode&unix.S_IFMT != unix.S_IFREG { + return nil, fmt.Errorf("session file is not a regular file") + } + if stat.Nlink != 1 { + return nil, fmt.Errorf("session file link count is %d, want 1", stat.Nlink) + } + + file := os.NewFile(uintptr(fileFD), filepath.Join(taskTempDir, sessionDir, name)) + if file == nil { + return nil, fmt.Errorf("adopt session file descriptor") + } + owned = false + return &piSessionFile{file: file}, nil +} diff --git a/server/pkg/agent/pi_session_windows.go b/server/pkg/agent/pi_session_windows.go new file mode 100644 index 00000000000..05bdea65ce0 --- /dev/null +++ b/server/pkg/agent/pi_session_windows.go @@ -0,0 +1,19 @@ +//go:build windows + +package agent + +import ( + "fmt" + "os" +) + +type piSessionFile struct { + file *os.File +} + +func (s *piSessionFile) Close() error { return nil } +func (s *piSessionFile) childPath() string { return "" } + +func createPiSessionFile(taskTempDir, sessionID string) (*piSessionFile, error) { + return nil, fmt.Errorf("Pi execution is disabled on Windows until secure session handle inheritance is available") +} diff --git a/server/pkg/agent/pi_test.go b/server/pkg/agent/pi_test.go index e2f21c2e318..1e46096cedb 100644 --- a/server/pkg/agent/pi_test.go +++ b/server/pkg/agent/pi_test.go @@ -3,6 +3,8 @@ package agent import ( "context" "log/slog" + "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -90,14 +92,19 @@ func TestPiExecuteAttachesStdinPipe(t *testing.T) { "exit 1\n" writeTestExecutable(t, fakePath, []byte(script)) - backend, err := New("pi", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + cfg, cwd := providerCommandTestConfig(t, fakePath, slog.Default()) + backend, err := New("pi", cfg) if err != nil { t.Fatalf("new pi backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second}) + session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{ + Cwd: cwd, + Timeout: 5 * time.Second, + ResumeSessionID: "0123456789abcdef0123456789abcdef", + }) if err != nil { t.Fatalf("execute: %v", err) } @@ -132,12 +139,238 @@ func piEventStreamScript(events []string) string { return b.String() } +func newPiSessionSecurityBackend(t *testing.T) (Backend, Config, string) { + t.Helper() + if runtime.GOOS != "linux" { + t.Skip("secure Pi session descriptor handoff is currently Linux-only") + } + + fakePath := filepath.Join(t.TempDir(), "pi") + writeTestExecutable(t, fakePath, []byte(piEventStreamScript([]string{ + `{"type":"agent_start"}`, + `{"type":"turn_end","message":{"role":"assistant","model":"test","usage":{"input":1,"output":1}}}`, + }))) + cfg, cwd := providerCommandTestConfig(t, fakePath, slog.Default()) + backend, err := New("pi", cfg) + if err != nil { + t.Fatalf("new pi backend: %v", err) + } + return backend, cfg, cwd +} + +func TestPiExecuteStoresNewSessionUnderTaskTempDir(t *testing.T) { + backend, cfg, cwd := newPiSessionSecurityBackend(t) + ownerHome := t.TempDir() + t.Setenv("HOME", ownerHome) + + session, err := backend.Execute(context.Background(), "prompt", ExecOptions{Cwd: cwd, Timeout: 5 * time.Second}) + if err != nil { + t.Fatalf("execute: %v", err) + } + go func() { + for range session.Messages { + } + }() + result := <-session.Result + if result.Status != "completed" { + t.Fatalf("status = %q, error = %q", result.Status, result.Error) + } + if filepath.IsAbs(result.SessionID) || strings.ContainsAny(result.SessionID, `/\\`) { + t.Fatalf("SessionID = %q, want opaque path-free identifier", result.SessionID) + } + wantPath := filepath.Join(cfg.TaskStateDir, "pi-sessions", result.SessionID+".jsonl") + info, err := os.Lstat(wantPath) + if err != nil { + t.Fatalf("task-private session file %q: %v", wantPath, err) + } + if !info.Mode().IsRegular() { + t.Fatalf("task-private session mode = %v, want regular file", info.Mode()) + } + if _, err := os.Stat(filepath.Join(ownerHome, ".multica", "pi-sessions")); !os.IsNotExist(err) { + t.Fatalf("daemon-owner Pi session directory must not be created, stat err=%v", err) + } +} + +func TestPiExecuteRejectsCallerSelectedSessionPathsBeforeCreation(t *testing.T) { + tests := map[string]func(root, taskTemp string) string{ + "absolute outside task root": func(root, _ string) string { + return filepath.Join(root, "owner-session.jsonl") + }, + "parent traversal": func(root, taskState string) string { + return filepath.Join(taskState, "..", filepath.Base(root), "traversal-session.jsonl") + }, + } + for name, sessionID := range tests { + t.Run(name, func(t *testing.T) { + backend, cfg, cwd := newPiSessionSecurityBackend(t) + outside := t.TempDir() + requestedPath := sessionID(outside, cfg.TaskStateDir) + session, err := backend.Execute(context.Background(), "prompt", ExecOptions{ + Cwd: cwd, + Timeout: 5 * time.Second, + ResumeSessionID: requestedPath, + }) + if err == nil { + if session != nil { + go func() { + for range session.Messages { + } + <-session.Result + }() + } + t.Fatalf("Execute accepted caller-selected session path %q", requestedPath) + } + if _, statErr := os.Lstat(requestedPath); !os.IsNotExist(statErr) { + t.Fatalf("caller-selected path %q was touched, stat err=%v", requestedPath, statErr) + } + }) + } +} + +func TestPiExecuteRejectsSymlinkSessionBeforeProcessStart(t *testing.T) { + backend, cfg, cwd := newPiSessionSecurityBackend(t) + sessionDir := filepath.Join(cfg.TaskStateDir, "pi-sessions") + if err := os.MkdirAll(sessionDir, 0o700); err != nil { + t.Fatalf("create session dir: %v", err) + } + target := filepath.Join(t.TempDir(), "owner-secret") + if err := os.WriteFile(target, []byte("unchanged"), 0o600); err != nil { + t.Fatalf("write target: %v", err) + } + link := filepath.Join(sessionDir, "0123456789abcdef0123456789abcdef.jsonl") + if err := os.Symlink(target, link); err != nil { + t.Fatalf("create symlink: %v", err) + } + + session, err := backend.Execute(context.Background(), "prompt", ExecOptions{ + Cwd: cwd, + Timeout: 5 * time.Second, + ResumeSessionID: "0123456789abcdef0123456789abcdef", + }) + if err == nil { + if session != nil { + go func() { + for range session.Messages { + } + <-session.Result + }() + } + t.Fatal("Execute accepted symlink session file") + } + got, readErr := os.ReadFile(target) + if readErr != nil || string(got) != "unchanged" { + t.Fatalf("symlink target changed: bytes=%q err=%v", got, readErr) + } +} + +func TestPiExecuteRequiresTaskStateDirBeforeProcessStart(t *testing.T) { + backend, cfg, cwd := newPiSessionSecurityBackend(t) + outsidePath := filepath.Join(t.TempDir(), "owner-session.jsonl") + pi := backend.(*piBackend) + pi.cfg.TaskStateDir = "" + + session, err := pi.Execute(context.Background(), "prompt", ExecOptions{ + Cwd: cwd, + Timeout: 5 * time.Second, + ResumeSessionID: outsidePath, + }) + if err == nil { + if session != nil { + go func() { + for range session.Messages { + } + <-session.Result + }() + } + t.Fatal("Execute succeeded without TaskStateDir") + } + if _, statErr := os.Lstat(outsidePath); !os.IsNotExist(statErr) { + t.Fatalf("path was touched without TaskStateDir, stat err=%v (original state root=%q)", statErr, cfg.TaskStateDir) + } +} + +func TestPiSessionRejectsHardLinkedFile(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("secure Pi session descriptor handoff is currently Linux-only") + } + root := t.TempDir() + sessionDir := filepath.Join(root, "pi-sessions") + if err := os.Mkdir(sessionDir, 0o700); err != nil { + t.Fatalf("create session directory: %v", err) + } + sessionID := "0123456789abcdef0123456789abcdef" + path := filepath.Join(sessionDir, sessionID+".jsonl") + if err := os.WriteFile(path, []byte("original\n"), 0o600); err != nil { + t.Fatalf("write session: %v", err) + } + if err := os.Link(path, filepath.Join(t.TempDir(), "foreign-link")); err != nil { + t.Fatalf("create hard link: %v", err) + } + + if _, _, err := preparePiSession(root, sessionID); err == nil || !strings.Contains(err.Error(), "link count") { + t.Fatalf("preparePiSession hard-linked file error = %v, want link-count rejection", err) + } +} + +func TestPiSessionInheritedFDResistsPathReplacement(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("secure Pi session descriptor handoff is currently Linux-only") + } + root := t.TempDir() + sessionID := "0123456789abcdef0123456789abcdef" + _, sessionFile, err := preparePiSession(root, sessionID) + if err != nil { + t.Fatalf("preparePiSession: %v", err) + } + defer sessionFile.Close() + + path := filepath.Join(root, "pi-sessions", sessionID+".jsonl") + originalPath := filepath.Join(root, "pi-sessions", "original-unlinked.jsonl") + if err := os.Rename(path, originalPath); err != nil { + t.Fatalf("rename verified session: %v", err) + } + if err := os.WriteFile(path, []byte("replacement\n"), 0o600); err != nil { + t.Fatalf("write replacement session: %v", err) + } + + cmd := exec.Command("/bin/sh", "-c", `printf 'child-append\n' >> "$1"`, "sh", sessionFile.childPath()) + cmd.ExtraFiles = []*os.File{sessionFile.file} + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("append through inherited descriptor: %v, output=%s", err, output) + } + + original, err := os.ReadFile(originalPath) + if err != nil { + t.Fatalf("read original inode: %v", err) + } + replacement, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read replacement path: %v", err) + } + if string(original) != "child-append\n" { + t.Fatalf("original inode bytes = %q, want inherited-FD append", original) + } + if string(replacement) != "replacement\n" { + t.Fatalf("replacement path was modified: %q", replacement) + } +} + +func TestPiExecutionFailsClosedWithoutSecureDescriptorHandoff(t *testing.T) { + if runtime.GOOS == "linux" { + t.Skip("Linux supports secure Pi session descriptor handoff") + } + _, _, err := preparePiSession(t.TempDir(), "0123456789abcdef0123456789abcdef") + if err == nil || !strings.Contains(err.Error(), "disabled") { + t.Fatalf("preparePiSession error = %v, want platform fail-closed error", err) + } +} + // TestPiExecuteRetainsOnlyLastTurnOutput verifies turn_start resets the // output buffer so Result.Output keeps only the final turn's text. func TestPiExecuteRetainsOnlyLastTurnOutput(t *testing.T) { t.Parallel() - if runtime.GOOS == "windows" { - t.Skip("shell-script fixture is POSIX-only") + if runtime.GOOS != "linux" { + t.Skip("secure Pi session descriptor handoff is currently Linux-only") } events := []string{ @@ -156,14 +389,19 @@ func TestPiExecuteRetainsOnlyLastTurnOutput(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "pi") writeTestExecutable(t, fakePath, []byte(piEventStreamScript(events))) - backend, err := New("pi", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + cfg, cwd := providerCommandTestConfig(t, fakePath, slog.Default()) + backend, err := New("pi", cfg) if err != nil { t.Fatalf("new pi backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second}) + session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{ + Cwd: cwd, + Timeout: 5 * time.Second, + ResumeSessionID: "0123456789abcdef0123456789abcdef", + }) if err != nil { t.Fatalf("execute: %v", err) } diff --git a/server/pkg/agent/qoder.go b/server/pkg/agent/qoder.go index b04d49cfe02..9cce95f38b3 100644 --- a/server/pkg/agent/qoder.go +++ b/server/pkg/agent/qoder.go @@ -5,7 +5,9 @@ import ( "context" "fmt" "io" + "os" "os/exec" + "path/filepath" "strings" "sync" "sync/atomic" @@ -72,9 +74,14 @@ func (b *qoderBackend) Execute(ctx context.Context, prompt string, opts ExecOpti if execPath == "" { execPath = "qodercli" } - if _, err := exec.LookPath(execPath); err != nil { + resolvedExecPath, err := exec.LookPath(execPath) + if err != nil { return nil, fmt.Errorf("qoder executable not found at %q: %w", execPath, err) } + resolvedExecPath, err = filepath.Abs(resolvedExecPath) + if err != nil { + return nil, fmt.Errorf("resolve qoder executable %q: %w", resolvedExecPath, err) + } // Translate the agent's mcp_config (Claude-style object of objects) into // the array shape ACP session/new and session/resume expect. Reuse the @@ -94,13 +101,20 @@ func (b *qoderBackend) Execute(ctx context.Context, prompt string, opts ExecOpti []string{"--yolo", "--acp"}, filterCustomArgs(opts.CustomArgs, qoderBlockedArgs, b.cfg.Logger)..., ) - cmd := exec.CommandContext(runCtx, execPath, qoderArgs...) - hideAgentWindow(cmd) - b.cfg.Logger.Info("agent command", "exec", execPath, "args", qoderArgs) - if opts.Cwd != "" { - cmd.Dir = opts.Cwd + cwd := opts.Cwd + if cwd == "" { + cwd, err = os.Getwd() + if err != nil { + cancel() + return nil, fmt.Errorf("resolve qoder working directory: %w", err) + } + } + cmd, err := b.cfg.command(runCtx, resolvedExecPath, qoderArgs, cwd, 10*time.Second) + if err != nil { + cancel() + return nil, fmt.Errorf("build qoder command: %w", err) } - cmd.Env = buildEnv(b.cfg.Env) + b.cfg.Logger.Info("agent command", "exec", resolvedExecPath, "args", qoderArgs) stdout, err := cmd.StdoutPipe() if err != nil { @@ -136,7 +150,7 @@ func (b *qoderBackend) Execute(ctx context.Context, prompt string, opts ExecOpti _, _ = io.Copy(stderrSink, stderr) }() - b.cfg.Logger.Info("qoder acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd) + b.cfg.Logger.Info("qoder acp started", "pid", cmd.Process().Pid, "cwd", opts.Cwd) msgStream := newQoderMessageStream(256) resCh := make(chan Result, 1) @@ -369,7 +383,7 @@ func (b *qoderBackend) Execute(ctx context.Context, prompt string, opts ExecOpti } duration := time.Since(startTime) - b.cfg.Logger.Info("qoder finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) + b.cfg.Logger.Info("qoder finished", "pid", cmd.Process().Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) stdin.Close() cancel() diff --git a/server/pkg/agent/qoder_test.go b/server/pkg/agent/qoder_test.go index 889ed76f0f6..8542344e6a4 100644 --- a/server/pkg/agent/qoder_test.go +++ b/server/pkg/agent/qoder_test.go @@ -3,7 +3,6 @@ package agent import ( "context" "encoding/json" - "log/slog" "os" "path/filepath" "strings" @@ -213,7 +212,7 @@ func TestQoderBackendSetModelFailureFailsTask(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScript())) - backend, err := New("qoder", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("qoder", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new qoder backend: %v", err) } @@ -263,11 +262,7 @@ func TestQoderBackendInvokesACPFlagAndFiltersBlockedArgs(t *testing.T) { fakePath := filepath.Join(tempDir, "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScript())) - backend, err := New("qoder", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{"QODER_ARGS_FILE": argsFile}, - }) + backend, err := New("qoder", acpProviderTestConfig(t, fakePath, map[string]string{"QODER_ARGS_FILE": argsFile})) if err != nil { t.Fatalf("new qoder backend: %v", err) } @@ -328,7 +323,7 @@ func TestQoderBackendHappyPath(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScript())) - backend, err := New("qoder", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("qoder", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new qoder backend: %v", err) } @@ -366,7 +361,7 @@ func TestQoderBackendNonPositiveTimeoutDoesNotImposeDeadline(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScript())) - backend, err := New("qoder", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("qoder", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new qoder backend: %v", err) } @@ -401,7 +396,7 @@ func TestQoderBackendDoesNotWaitForeverForReaderAfterPromptDone(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScriptWithLeakedStdout())) - backend, err := New("qoder", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("qoder", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new qoder backend: %v", err) } @@ -455,7 +450,7 @@ func TestQoderBackendIgnoresLateReaderOutputAfterGrace(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScriptWithLateStdoutAfterResult())) - backend, err := New("qoder", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("qoder", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new qoder backend: %v", err) } @@ -500,14 +495,10 @@ func TestQoderForwardsMcpAuthHeaderToSessionNew(t *testing.T) { fakePath := filepath.Join(tempDir, "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScriptCapturingRPC())) - backend, err := New("qoder", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{ - "QODER_RPC_FILE": rpcFile, - "QODER_INIT_MCP_SSE": "1", - }, - }) + backend, err := New("qoder", acpProviderTestConfig(t, fakePath, map[string]string{ + "QODER_RPC_FILE": rpcFile, + "QODER_INIT_MCP_SSE": "1", + })) if err != nil { t.Fatalf("new qoder backend: %v", err) } @@ -561,11 +552,7 @@ func TestQoderFiltersRemoteMcpWhenInitializeDoesNotAdvertiseCapability(t *testin fakePath := filepath.Join(tempDir, "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScriptCapturingRPC())) - backend, err := New("qoder", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{"QODER_RPC_FILE": rpcFile}, - }) + backend, err := New("qoder", acpProviderTestConfig(t, fakePath, map[string]string{"QODER_RPC_FILE": rpcFile})) if err != nil { t.Fatalf("new qoder backend: %v", err) } @@ -620,7 +607,7 @@ func TestQoderPromotesTerminalProviderErrorWithOutput(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPScriptTerminalProviderError())) - backend, err := New("qoder", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("qoder", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new qoder backend: %v", err) } @@ -659,7 +646,7 @@ func TestQoderBackendAttributesUsageToACPDefaultModel(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPUsageWithDefaultModelScript())) - backend, err := New("qoder", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("qoder", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new qoder backend: %v", err) } @@ -707,7 +694,7 @@ func TestQoderBackendClearsSessionIDWhenResumedSessionNotFoundAtPrompt(t *testin fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPStaleResumeScript())) - backend, err := New("qoder", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("qoder", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new qoder backend: %v", err) } @@ -752,7 +739,7 @@ func TestQoderBackendClearsSessionIDWhenResumedSessionNotFoundAtSetModel(t *test fakePath := filepath.Join(t.TempDir(), "qodercli") writeTestExecutable(t, fakePath, []byte(fakeQoderACPStaleResumeSetModelScript())) - backend, err := New("qoder", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("qoder", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new qoder backend: %v", err) } diff --git a/server/pkg/agent/stream_json_final_output_test.go b/server/pkg/agent/stream_json_final_output_test.go index 134723fda85..39d04c53679 100644 --- a/server/pkg/agent/stream_json_final_output_test.go +++ b/server/pkg/agent/stream_json_final_output_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "log/slog" + "os" "path/filepath" "runtime" "strings" @@ -187,14 +188,24 @@ printf '%s\n' '{"type":"user","message":{"role":"user","content":[{"type":"tool_ t.Run(provider+"/"+tt.name, func(t *testing.T) { t.Parallel() - fakePath := filepath.Join(t.TempDir(), provider) + root := t.TempDir() + fakePath := filepath.Join(root, provider) script := "#!/bin/sh\nIFS= read -r _\n" + tt.scriptBody writeTestExecutable(t, fakePath, []byte(script)) backend, err := New(provider, Config{ ExecutablePath: fakePath, - Env: map[string]string{"IS_SANDBOX": "1"}, - Logger: slog.Default(), + Env: map[string]string{ + "IS_SANDBOX": "1", + "PATH": os.Getenv("PATH"), + }, + Logger: slog.Default(), + Launcher: newCommandLauncher(&recordingIsolation{}), + Isolation: &TaskIsolationPolicy{ + WritableRoots: []string{root}, + SystemRoots: existingSystemRootsForTest(t), + }, + TaskTempDir: root, }) if err != nil { t.Fatalf("New(%s): %v", provider, err) @@ -202,7 +213,7 @@ printf '%s\n' '{"type":"user","message":{"role":"user","content":[{"type":"tool_ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - session, err := backend.Execute(ctx, "test prompt", ExecOptions{Timeout: 10 * time.Second}) + session, err := backend.Execute(ctx, "test prompt", ExecOptions{Cwd: root, Timeout: 10 * time.Second}) if err != nil { t.Fatalf("Execute(%s): %v", provider, err) } diff --git a/server/pkg/agent/thinking.go b/server/pkg/agent/thinking.go index 457e86ba6cb..a1faef364e5 100644 --- a/server/pkg/agent/thinking.go +++ b/server/pkg/agent/thinking.go @@ -131,6 +131,15 @@ var claudeStaticEffortFullSuperset = []string{"low", "medium", "high", "xhigh", // picker for that model. func annotateClaudeThinking(ctx context.Context, models []Model, executablePath string) { mapping := loadClaudeThinkingByModel(ctx, executablePath) + annotateThinkingMapping(models, mapping) +} + +func annotateClaudeThinkingWithRunner(ctx context.Context, models []Model, executablePath string, runner modelDiscoveryRunner) { + mapping := loadClaudeThinkingByModelWithRunner(ctx, executablePath, runner, false) + annotateThinkingMapping(models, mapping) +} + +func annotateThinkingMapping(models []Model, mapping map[string]*ModelThinking) { for i := range models { if t, ok := mapping[models[i].ID]; ok && t != nil { models[i].Thinking = t @@ -139,16 +148,22 @@ func annotateClaudeThinking(ctx context.Context, models []Model, executablePath } func loadClaudeThinkingByModel(ctx context.Context, executablePath string) map[string]*ModelThinking { + return loadClaudeThinkingByModelWithRunner(ctx, executablePath, operatorModelDiscoveryRunner(), true) +} + +func loadClaudeThinkingByModelWithRunner(ctx context.Context, executablePath string, runner modelDiscoveryRunner, useCache bool) map[string]*ModelThinking { if executablePath == "" { executablePath = "claude" } - version, _ := DetectVersion(ctx, executablePath) + version, _ := detectCLIVersionWithRunner(ctx, executablePath, runner) key := thinkingCacheKey{provider: "claude", executablePath: executablePath, cliVersion: version} - if cached, ok := thinkingCacheGet(key); ok { - return cached + if useCache { + if cached, ok := thinkingCacheGet(key); ok { + return cached + } } - superset := claudeEffortSuperset(ctx, executablePath) + superset := claudeEffortSupersetWithRunner(ctx, executablePath, runner) result := map[string]*ModelThinking{} for _, m := range claudeStaticModels() { allow := claudeModelEffortAllow[m.ID] @@ -156,12 +171,11 @@ func loadClaudeThinkingByModel(ctx context.Context, executablePath string) map[s if len(levels) == 0 { continue } - result[m.ID] = &ModelThinking{ - SupportedLevels: levels, - DefaultLevel: "medium", - } + result[m.ID] = &ModelThinking{SupportedLevels: levels, DefaultLevel: "medium"} + } + if useCache { + thinkingCachePut(key, result) } - thinkingCachePut(key, result) return result } @@ -170,8 +184,14 @@ func loadClaudeThinkingByModel(ctx context.Context, executablePath string) map[s // fallback rather than nothing so callers can still render a usable // picker. func claudeEffortSuperset(ctx context.Context, executablePath string) []string { - cmd := exec.CommandContext(ctx, executablePath, "--help") - hideAgentWindow(cmd) + return claudeEffortSupersetWithRunner(ctx, executablePath, operatorModelDiscoveryRunner()) +} + +func claudeEffortSupersetWithRunner(ctx context.Context, executablePath string, runner modelDiscoveryRunner) []string { + cmd, err := runner.command(ctx, executablePath, []string{"--help"}, 0) + if err != nil { + return append([]string(nil), claudeStaticEffortFallback...) + } out, err := cmd.CombinedOutput() if err != nil { return append([]string(nil), claudeStaticEffortFallback...) @@ -304,15 +324,19 @@ type codexDebugReasoningLevel struct { // debug command so old binaries do not log a predictable "unknown command" // failure on every cache refresh. func discoverCodexModels(ctx context.Context, executablePath string) []Model { + return discoverCodexModelsWithRunner(ctx, executablePath, operatorModelDiscoveryRunner()) +} + +func discoverCodexModelsWithRunner(ctx context.Context, executablePath string, runner modelDiscoveryRunner) []Model { if executablePath == "" { executablePath = "codex" } - version, err := DetectVersion(ctx, executablePath) + version, err := detectCLIVersionWithRunner(ctx, executablePath, runner) if err != nil || !codexSupportsDebugModels(version) { return codexStaticModels() } - raw, err := runCodexDebugModels(ctx, executablePath) + raw, err := runCodexDebugModelsWithRunner(ctx, executablePath, runner) if err != nil { return codexStaticModels() } @@ -344,8 +368,14 @@ func codexSupportsDebugModels(version string) bool { var codexDebugModelsArgs = []string{"debug", "models", "--bundled"} func runCodexDebugModels(ctx context.Context, executablePath string) ([]byte, error) { - cmd := exec.CommandContext(ctx, executablePath, codexDebugModelsArgs...) - hideAgentWindow(cmd) + return runCodexDebugModelsWithRunner(ctx, executablePath, operatorModelDiscoveryRunner()) +} + +func runCodexDebugModelsWithRunner(ctx context.Context, executablePath string, runner modelDiscoveryRunner) ([]byte, error) { + cmd, err := runner.command(ctx, executablePath, codexDebugModelsArgs, 0) + if err != nil { + return nil, err + } return cmd.Output() } @@ -470,6 +500,38 @@ func codebuddyHelpOutput(ctx context.Context, executablePath string) string { return result } +func codebuddyHelpOutputWithRunner(ctx context.Context, executablePath string, runner modelDiscoveryRunner) string { + if executablePath == "" { + executablePath = "codebuddy" + } + runCtx, cancel := context.WithTimeout(ctx, 35*time.Second) + defer cancel() + cmd, err := runner.command(runCtx, executablePath, []string{"--help"}, 0) + if err != nil { + return "" + } + out, _ := cmd.CombinedOutput() + return string(out) +} + +func annotateCodebuddyThinkingFromHelp(models []Model, helpOutput string) { + levels := parseCodebuddyEffortHelp(helpOutput) + if len(levels) == 0 { + levels = append([]string(nil), codebuddyStaticEffortFallback...) + } + thinkingLevels := make([]ThinkingLevel, 0, len(levels)) + for _, value := range levels { + label, ok := codebuddyEffortLabel[value] + if !ok { + label = strings.Title(value) //nolint:staticcheck + } + thinkingLevels = append(thinkingLevels, ThinkingLevel{Value: value, Label: label}) + } + for i := range models { + models[i].Thinking = &ModelThinking{SupportedLevels: append([]ThinkingLevel(nil), thinkingLevels...), DefaultLevel: "medium"} + } +} + func annotateCodebuddyThinking(ctx context.Context, models []Model, executablePath string) { if executablePath == "" { executablePath = "codebuddy" @@ -544,85 +606,59 @@ func parseCodebuddyEffortHelp(helpText string) []string { // ── Shared validation ──────────────────────────────────────────────── -// ValidateThinkingLevel reports whether `value` is in the supported -// catalog for the given (provider, model) pair. Empty value is always -// valid — it means "use the runtime default". -// -// Empty model means "follow the runtime's own default", resolved at task -// time. How safely we can validate an effort against that depends on the -// provider: -// -// - codex: the effective model comes from the user's local config.toml -// and can be ANY installed model, not necessarily the catalog's flagged -// Default. Borrowing the Default entry (gpt-5.6-sol, the only one -// advertising `ultra`) would green-light levels the actually-configured -// model may not support — Luna tops out at `max`, gpt-5.5/5.4 at `xhigh` -// — and Codex does not reject the mismatch itself. We can't know the -// effective model without parsing config.toml in the task cwd (see this -// file's Codex header for why that's avoided), so an empty codex model -// fails closed: the daemon drops the level rather than injecting one that -// may not fit. Users who need a specific effort must pick an explicit -// model. (MUL-4347 review.) -// - other providers: empty model resolves to the catalog's Default entry -// so a default-model task with a valid thinking_level isn't misjudged as -// "unknown model → reject" (the misjudgement flagged in an earlier -// review). opencode has no single default, so it accepts a level any -// advertised model supports. -// -// The lookup goes through ListModels so it sees the *current* CLI -// catalog (including dynamic discovery for codex), not just a static -// map. The function is intentionally pure of HTTP concerns so the -// daemon's pre-execution guard and the server's UpdateAgent gate can -// share the same source of truth. +// ValidateThinkingLevel is the compatibility entry point for callers that do +// not provide task-scoped discovery authority. Providers with a verified +// static catalog can be validated without process authority; dynamic catalogs +// fail closed. Task callers must use ValidateThinkingLevelForTask. func ValidateThinkingLevel(ctx context.Context, providerType, executablePath, model, value string) (bool, error) { if value == "" { return true, nil } - // Codex empty-model fail-closed (see doc comment). Checked before - // ListModels so the outcome is deterministic even when discovery would - // error — an errored lookup makes the daemon pass the level through, which - // is exactly what we must NOT do for an unresolved codex model. + if providerType == "grok" { + return validateThinkingLevelFromModels(providerType, model, value, grokStaticModels()), nil + } + return false, nil +} + +func ValidateThinkingLevelForTask(ctx context.Context, providerType, executablePath, model, value string, discovery TaskModelDiscovery) (bool, error) { + if value == "" { + return true, nil + } if model == "" && providerType == "codex" { return false, nil } - models, err := ListModels(ctx, providerType, executablePath) + models, err := ListModelsForTask(ctx, providerType, executablePath, discovery) if err != nil { return false, err } + return validateThinkingLevelFromModels(providerType, model, value, models), nil +} + +func validateThinkingLevelFromModels(providerType, model, value string, models []Model) bool { target := model if target == "" { - // Default model = the entry the catalog marks as Default. If no - // entry is flagged, fall through to the no-match return; that - // matches the existing semantics where an unknown model fails - // closed rather than guessing. for _, m := range models { if m.Default { target = m.ID break } } - if target == "" { - if providerType == "opencode" { - return anyModelSupportsThinkingValue(models, value), nil - } - return false, nil + if target == "" && providerType == "opencode" { + return anyModelSupportsThinkingValue(models, value) } } for _, m := range models { - if m.ID != target { + if m.ID != target || m.Thinking == nil { continue } - if m.Thinking == nil { - return false, nil - } - for _, lvl := range m.Thinking.SupportedLevels { - if lvl.Value == value { - return true, nil + for _, level := range m.Thinking.SupportedLevels { + if level.Value == value { + return true } } - return false, nil + return false } - return false, nil + return false } func anyModelSupportsThinkingValue(models []Model, value string) bool { diff --git a/server/pkg/agent/thinking_test.go b/server/pkg/agent/thinking_test.go index e260efdca91..c45db736819 100644 --- a/server/pkg/agent/thinking_test.go +++ b/server/pkg/agent/thinking_test.go @@ -2,14 +2,227 @@ package agent import ( "context" + "encoding/base64" "log/slog" "os" + "os/exec" "path/filepath" "reflect" "runtime" + "strconv" "testing" ) +type recordingTaskDiscoveryLauncher struct { + requests []CommandRequest + responses func(CommandRequest) (string, int) +} + +func newTaskDiscoveryExecution(t *testing.T, provider string, responses func(CommandRequest) (string, int)) (string, TaskModelDiscovery) { + t.Helper() + root := t.TempDir() + launcher := &recordingTaskDiscoveryLauncher{responses: responses} + return filepath.Join(root, provider), TaskModelDiscovery{ + Execution: &ModelDiscoveryExecution{ + Launcher: launcher, + Cwd: root, + Env: map[string]string{ + "PATH": "/usr/bin:/bin", + "TASK_DISCOVERY_SENTINEL": provider, + }, + Isolation: &TaskIsolationPolicy{WritableRoots: []string{root}}, + }, + } +} + +func (l *recordingTaskDiscoveryLauncher) Command(ctx context.Context, req CommandRequest) (TaskCommand, error) { + recorded := req + recorded.Args = append([]string(nil), req.Args...) + recorded.Env = cloneModelDiscoveryEnv(req.Env) + l.requests = append(l.requests, recorded) + + output, exitCode := l.responses(req) + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestTaskDiscoveryLauncherHelperProcess", "--") + cmd.Env = append(os.Environ(), + "GO_WANT_TASK_DISCOVERY_HELPER=1", + "TASK_DISCOVERY_OUTPUT="+base64.StdEncoding.EncodeToString([]byte(output)), + "TASK_DISCOVERY_EXIT_CODE="+strconv.Itoa(exitCode), + ) + return newPreparedCommand(cmd), nil +} + +func TestTaskDiscoveryLauncherHelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_TASK_DISCOVERY_HELPER") != "1" { + return + } + output, err := base64.StdEncoding.DecodeString(os.Getenv("TASK_DISCOVERY_OUTPUT")) + if err != nil { + os.Exit(97) + } + _, _ = os.Stdout.Write(output) + exitCode, err := strconv.Atoi(os.Getenv("TASK_DISCOVERY_EXIT_CODE")) + if err != nil { + os.Exit(98) + } + os.Exit(exitCode) +} + +func TestValidateThinkingLevelForTaskUsesExplicitLauncherForDynamicDiscovery(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("malicious runtime fixture is a POSIX shell script") + } + + tests := []struct { + name string + provider string + model string + level string + responses func(CommandRequest) (string, int) + }{ + { + name: "claude", + provider: "claude", + model: "claude-opus-4-7", + level: "xhigh", + responses: func(req CommandRequest) (string, int) { + if reflect.DeepEqual(req.Args, []string{"--help"}) { + return "--effort Effort level for the current session (low, medium, high, xhigh, max)\n", 0 + } + return "", 96 + }, + }, + { + name: "codex", + provider: "codex", + model: "task-codex-model", + level: "high", + responses: func(req CommandRequest) (string, int) { + switch { + case reflect.DeepEqual(req.Args, []string{"--version"}): + return "codex-cli 0.144.1\n", 0 + case reflect.DeepEqual(req.Args, codexDebugModelsArgs): + return `{"models":[{"slug":"task-codex-model","display_name":"Task Codex","visibility":"list","default_reasoning_level":"medium","supported_reasoning_levels":[{"effort":"high"}]}]}`, 0 + default: + return "", 96 + } + }, + }, + { + name: "codebuddy", + provider: "codebuddy", + model: "claude-sonnet-4-6", + level: "high", + responses: func(req CommandRequest) (string, int) { + if reflect.DeepEqual(req.Args, []string{"--help"}) { + return "--model Currently supported: (claude-sonnet-4-6)\n--effort Effort level (low, medium, high, xhigh)\n", 0 + } + return "", 96 + }, + }, + { + name: "opencode", + provider: "opencode", + model: "openai/task-model", + level: "max", + responses: func(req CommandRequest) (string, int) { + if reflect.DeepEqual(req.Args, []string{"models", "--verbose"}) { + return "openai/task-model\n{\"variants\":{\"high\":{},\"max\":{}}}\n", 0 + } + return "", 96 + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + marker := filepath.Join(root, "direct-exec-marker") + executable := filepath.Join(root, tt.provider) + writeTestExecutable(t, executable, []byte("#!/bin/sh\ntouch '"+marker+"'\nexit 95\n")) + + launcher := &recordingTaskDiscoveryLauncher{responses: tt.responses} + policy := &TaskIsolationPolicy{WritableRoots: []string{root}} + env := map[string]string{"PATH": "/usr/bin:/bin", "TASK_DISCOVERY_SENTINEL": tt.provider} + discovery := TaskModelDiscovery{ + Execution: &ModelDiscoveryExecution{ + Launcher: launcher, + Cwd: root, + Env: env, + Isolation: policy, + }, + } + + ok, err := ValidateThinkingLevelForTask(t.Context(), tt.provider, executable, tt.model, tt.level, discovery) + if err != nil { + t.Fatalf("ValidateThinkingLevelForTask: %v", err) + } + if !ok { + t.Fatal("expected launcher-provided catalog to validate the thinking level") + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("task-selected runtime executed outside launcher: stat error = %v", err) + } + if len(launcher.requests) == 0 { + t.Fatal("task discovery did not use the explicit launcher") + } + for _, req := range launcher.requests { + if req.Executable != executable { + t.Errorf("launcher executable = %q, want %q", req.Executable, executable) + } + if req.Cwd != root { + t.Errorf("launcher cwd = %q, want %q", req.Cwd, root) + } + if !reflect.DeepEqual(req.Env, env) { + t.Errorf("launcher env = %#v, want %#v", req.Env, env) + } + if req.Isolation != policy { + t.Errorf("launcher isolation = %p, want %p", req.Isolation, policy) + } + } + }) + } +} + +func TestValidateThinkingLevelForTaskAcceptsOnlyBoundTrustedSnapshot(t *testing.T) { + models := []Model{{ + ID: "snapshot-model", + Thinking: &ModelThinking{SupportedLevels: []ThinkingLevel{{Value: "high"}}}, + }} + snapshot := &ModelCatalogSnapshot{ + ProviderType: "codex", + ExecutablePath: "/trusted/codex", + Models: models, + } + + ok, err := ValidateThinkingLevelForTask(t.Context(), "codex", "/trusted/codex", "snapshot-model", "high", TaskModelDiscovery{Snapshot: snapshot}) + if err != nil || !ok { + t.Fatalf("bound snapshot validation = (%v, %v), want (true, nil)", ok, err) + } + + ok, err = ValidateThinkingLevelForTask(t.Context(), "codex", "/different/codex", "snapshot-model", "high", TaskModelDiscovery{Snapshot: snapshot}) + if err == nil || ok { + t.Fatalf("mismatched snapshot validation = (%v, %v), want fail closed", ok, err) + } +} + +func TestValidateThinkingLevelLegacyEntryPointFailsClosedWithoutDiscoveryAuthority(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("malicious runtime fixture is a POSIX shell script") + } + root := t.TempDir() + marker := filepath.Join(root, "direct-exec-marker") + executable := filepath.Join(root, "claude") + writeTestExecutable(t, executable, []byte("#!/bin/sh\ntouch '"+marker+"'\necho '--effort (low, medium, high)'\n")) + + ok, err := ValidateThinkingLevel(t.Context(), "claude", executable, "claude-sonnet-4-6", "high") + if err != nil || ok { + t.Fatalf("legacy validation = (%v, %v), want (false, nil)", ok, err) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("legacy task validation directly executed runtime: stat error = %v", err) + } +} + // ── Claude help parsing ────────────────────────────────────────────── func TestParseClaudeEffortHelp_OldFormat(t *testing.T) { @@ -326,8 +539,14 @@ echo '{"models":[{"slug":"runtime-model","display_name":"Runtime Model","visibil }) } -func TestValidateThinkingLevelCodexPerModelFallbackCatalog(t *testing.T) { +func TestValidateThinkingLevelForTaskCodexPerModelFallbackCatalog(t *testing.T) { t.Parallel() + executable := "/trusted/codex" + discovery := TaskModelDiscovery{Snapshot: &ModelCatalogSnapshot{ + ProviderType: "codex", + ExecutablePath: executable, + Models: codexStaticModels(), + }} for _, tc := range []struct { model string level string @@ -340,12 +559,12 @@ func TestValidateThinkingLevelCodexPerModelFallbackCatalog(t *testing.T) { {model: "gpt-5.3-codex", level: "xhigh", want: true}, {model: "gpt-5.3-codex", level: "max", want: false}, } { - got, err := ValidateThinkingLevel(context.Background(), "codex", "/nonexistent/codex", tc.model, tc.level) + got, err := ValidateThinkingLevelForTask(context.Background(), "codex", executable, tc.model, tc.level, discovery) if err != nil { - t.Fatalf("ValidateThinkingLevel(%q, %q): %v", tc.model, tc.level, err) + t.Fatalf("ValidateThinkingLevelForTask(%q, %q): %v", tc.model, tc.level, err) } if got != tc.want { - t.Errorf("ValidateThinkingLevel(%q, %q) = %v, want %v", tc.model, tc.level, got, tc.want) + t.Errorf("ValidateThinkingLevelForTask(%q, %q) = %v, want %v", tc.model, tc.level, got, tc.want) } } } @@ -483,7 +702,7 @@ func TestCodexAdvertisedLevelsArePersistable(t *testing.T) { } } -// ── ValidateThinkingLevel default-model handling ───────────────────── +// ── ValidateThinkingLevelForTask default-model handling ────────────── // // Elon's PR1 review called out that an empty model on a default-model // task must not be misjudged as "unknown model → reject". The fix is to @@ -492,20 +711,18 @@ func TestCodexAdvertisedLevelsArePersistable(t *testing.T) { // layer call this; if it gets default-model wrong, any agent without an // explicit model set would have its thinking_level dropped silently. -func TestValidateThinkingLevel_EmptyModelResolvesToDefault(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("shell-script fake binary requires a POSIX shell") - } +func TestValidateThinkingLevelForTask_EmptyModelResolvesToDefault(t *testing.T) { t.Parallel() - // We need a `claude` whose --help advertises the full superset + // We need task-scoped `claude --help` output advertising the full superset // (low/medium/high/xhigh/max) so per-model projection actually has - // something to filter. A non-existent path falls back to a conservative - // [low,medium,high] which would hide the per-model behaviour we're - // trying to verify. - fakeClaude := writeFakeClaudeHelpBinary(t) - resetThinkingCacheForTests() - defer resetThinkingCacheForTests() + // something to filter. + executable, discovery := newTaskDiscoveryExecution(t, "claude", func(req CommandRequest) (string, int) { + if reflect.DeepEqual(req.Args, []string{"--help"}) { + return "--effort Effort level for the current session (low, medium, high, xhigh, max)\n", 0 + } + return "", 96 + }) ctx := context.Background() @@ -513,7 +730,7 @@ func TestValidateThinkingLevel_EmptyModelResolvesToDefault(t *testing.T) { // Claude's catalog flags Sonnet 4.6 as Default. Sonnet supports // low/medium/high/max (no xhigh) per claudeModelEffortAllow, so // "high" must round-trip when model is left empty. - ok, err := ValidateThinkingLevel(ctx, "claude", fakeClaude, "", "high") + ok, err := ValidateThinkingLevelForTask(ctx, "claude", executable, "", "high", discovery) if err != nil { t.Fatalf("unexpected err: %v", err) } @@ -525,7 +742,7 @@ func TestValidateThinkingLevel_EmptyModelResolvesToDefault(t *testing.T) { t.Run("invalid level on default model fails", func(t *testing.T) { // "xhigh" is opus-only; resolving "" to default (sonnet 4.6) // should reject it, not silently accept. - ok, err := ValidateThinkingLevel(ctx, "claude", fakeClaude, "", "xhigh") + ok, err := ValidateThinkingLevelForTask(ctx, "claude", executable, "", "xhigh", discovery) if err != nil { t.Fatalf("unexpected err: %v", err) } @@ -537,7 +754,7 @@ func TestValidateThinkingLevel_EmptyModelResolvesToDefault(t *testing.T) { t.Run("empty value always valid", func(t *testing.T) { // Empty value means "use runtime default" — should pass // regardless of model resolution. - ok, err := ValidateThinkingLevel(ctx, "claude", fakeClaude, "", "") + ok, err := ValidateThinkingLevelForTask(ctx, "claude", executable, "", "", discovery) if err != nil { t.Fatalf("unexpected err: %v", err) } @@ -547,19 +764,19 @@ func TestValidateThinkingLevel_EmptyModelResolvesToDefault(t *testing.T) { }) } -func TestValidateThinkingLevel_ExplicitModel(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("shell-script fake binary requires a POSIX shell") - } +func TestValidateThinkingLevelForTask_ExplicitModel(t *testing.T) { t.Parallel() - fakeClaude := writeFakeClaudeHelpBinary(t) - resetThinkingCacheForTests() - defer resetThinkingCacheForTests() + executable, discovery := newTaskDiscoveryExecution(t, "claude", func(req CommandRequest) (string, int) { + if reflect.DeepEqual(req.Args, []string{"--help"}) { + return "--effort Effort level for the current session (low, medium, high, xhigh, max)\n", 0 + } + return "", 96 + }) ctx := context.Background() // xhigh IS valid on Opus 4.7. - ok, err := ValidateThinkingLevel(ctx, "claude", fakeClaude, "claude-opus-4-7", "xhigh") + ok, err := ValidateThinkingLevelForTask(ctx, "claude", executable, "claude-opus-4-7", "xhigh", discovery) if err != nil { t.Fatalf("unexpected err: %v", err) } @@ -568,7 +785,7 @@ func TestValidateThinkingLevel_ExplicitModel(t *testing.T) { } // xhigh is NOT valid on Sonnet — should fail. - ok, err = ValidateThinkingLevel(ctx, "claude", fakeClaude, "claude-sonnet-4-6", "xhigh") + ok, err = ValidateThinkingLevelForTask(ctx, "claude", executable, "claude-sonnet-4-6", "xhigh", discovery) if err != nil { t.Fatalf("unexpected err: %v", err) } @@ -577,7 +794,7 @@ func TestValidateThinkingLevel_ExplicitModel(t *testing.T) { } // An unknown model with a valid token still fails closed (no guess). - ok, err = ValidateThinkingLevel(ctx, "claude", fakeClaude, "claude-nonexistent", "high") + ok, err = ValidateThinkingLevelForTask(ctx, "claude", executable, "claude-nonexistent", "high", discovery) if err != nil { t.Fatalf("unexpected err: %v", err) } @@ -586,7 +803,7 @@ func TestValidateThinkingLevel_ExplicitModel(t *testing.T) { } } -// TestValidateThinkingLevel_CodexEmptyModelFailsClosed pins the MUL-4347 +// TestValidateThinkingLevelForTask_CodexEmptyModelFailsClosed pins the MUL-4347 // fix: an explicit codex model is validated against its own per-model // catalog, but an EMPTY model (follow config.toml, which can resolve to any // installed model) must NOT borrow the flagged Default entry's catalog. The @@ -594,22 +811,31 @@ func TestValidateThinkingLevel_ExplicitModel(t *testing.T) { // inherit it would green-light a level Luna / gpt-5.5 don't support and Codex // won't reject. So an empty codex model fails closed for every level and the // daemon drops it — users must pick an explicit model to pin an effort. -func TestValidateThinkingLevel_CodexEmptyModelFailsClosed(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("shell-script fake binary requires a POSIX shell") - } - - fakeCodex := writeFakeCodexModelsBinary(t) +func TestValidateThinkingLevelForTask_CodexEmptyModelFailsClosed(t *testing.T) { + executable, discovery := newTaskDiscoveryExecution(t, "codex", func(req CommandRequest) (string, int) { + switch { + case reflect.DeepEqual(req.Args, []string{"--version"}): + return "codex-cli 0.144.1\n", 0 + case reflect.DeepEqual(req.Args, codexDebugModelsArgs): + return `{"models":[` + + `{"slug":"gpt-5.6-sol","default_reasoning_level":"high","supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"},{"effort":"xhigh"},{"effort":"max"},{"effort":"ultra"}]},` + + `{"slug":"gpt-5.6-terra","default_reasoning_level":"high","supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"},{"effort":"xhigh"},{"effort":"max"},{"effort":"ultra"}]},` + + `{"slug":"gpt-5.6-luna","default_reasoning_level":"medium","supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"},{"effort":"xhigh"},{"effort":"max"}]}` + + `]}`, 0 + default: + return "", 96 + } + }) ctx := context.Background() check := func(model, value string, want bool) { t.Helper() - ok, err := ValidateThinkingLevel(ctx, "codex", fakeCodex, model, value) + ok, err := ValidateThinkingLevelForTask(ctx, "codex", executable, model, value, discovery) if err != nil { - t.Fatalf("ValidateThinkingLevel(codex, %q, %q): unexpected err: %v", model, value, err) + t.Fatalf("ValidateThinkingLevelForTask(codex, %q, %q): unexpected err: %v", model, value, err) } if ok != want { - t.Errorf("ValidateThinkingLevel(codex, %q, %q) = %v, want %v", model, value, ok, want) + t.Errorf("ValidateThinkingLevelForTask(codex, %q, %q) = %v, want %v", model, value, ok, want) } } @@ -633,24 +859,24 @@ func TestValidateThinkingLevel_CodexEmptyModelFailsClosed(t *testing.T) { check("gpt-5.6-luna", "", true) } -func TestValidateThinkingLevel_PreEffortCLIRejectsAllLevels(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("shell-script fake binary requires a POSIX shell") - } +func TestValidateThinkingLevelForTask_PreEffortCLIRejectsAllLevels(t *testing.T) { t.Parallel() // End-to-end guard for the daemon's pre-execution check against a CLI // that predates --effort: the catalog must offer no levels, so any // persisted thinking_level is dropped (with a warning) instead of being // injected as a flag the binary rejects with "unknown option". - fakeClaude := writeFakeClaudePreEffortHelpBinary(t) - resetThinkingCacheForTests() - defer resetThinkingCacheForTests() + executable, discovery := newTaskDiscoveryExecution(t, "claude", func(req CommandRequest) (string, int) { + if reflect.DeepEqual(req.Args, []string{"--help"}) { + return "Usage: claude [options]\n\nOptions:\n --model Model to use\n --verbose\n", 0 + } + return "", 96 + }) ctx := context.Background() for _, level := range []string{"low", "medium", "high", "xhigh", "max"} { - ok, err := ValidateThinkingLevel(ctx, "claude", fakeClaude, "claude-fable-5", level) + ok, err := ValidateThinkingLevelForTask(ctx, "claude", executable, "claude-fable-5", level, discovery) if err != nil { t.Fatalf("unexpected err for %q: %v", level, err) } @@ -660,7 +886,7 @@ func TestValidateThinkingLevel_PreEffortCLIRejectsAllLevels(t *testing.T) { } // Empty value still means "use runtime default" and must stay valid. - ok, err := ValidateThinkingLevel(ctx, "claude", fakeClaude, "claude-fable-5", "") + ok, err := ValidateThinkingLevelForTask(ctx, "claude", executable, "claude-fable-5", "", discovery) if err != nil { t.Fatalf("unexpected err: %v", err) } @@ -669,25 +895,10 @@ func TestValidateThinkingLevel_PreEffortCLIRejectsAllLevels(t *testing.T) { } } -func TestValidateThinkingLevel_OpenCodeEmptyModelUsesAdvertisedVariants(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("shell-script fake binary requires a POSIX shell") - } - - modelCacheMu.Lock() - delete(modelCache, "opencode") - modelCacheMu.Unlock() - defer func() { - modelCacheMu.Lock() - delete(modelCache, "opencode") - modelCacheMu.Unlock() - }() - - dir := t.TempDir() - fake := filepath.Join(dir, "opencode") - script := `#!/bin/sh -if [ "$1" = "models" ]; then - cat <<'EOF' +func TestValidateThinkingLevelForTask_OpenCodeEmptyModelUsesAdvertisedVariants(t *testing.T) { + executable, discovery := newTaskDiscoveryExecution(t, "opencode", func(req CommandRequest) (string, int) { + if reflect.DeepEqual(req.Args, []string{"models", "--verbose"}) { + return ` opencode/deepseek-v4 { "id": "deepseek-v4", @@ -697,15 +908,13 @@ opencode/deepseek-v4 "max": {} } } -EOF - exit 0 -fi -echo "opencode 9.9.9" -` - writeTestExecutable(t, fake, []byte(script)) +`, 0 + } + return "", 96 + }) ctx := context.Background() - ok, err := ValidateThinkingLevel(ctx, "opencode", fake, "", "max") + ok, err := ValidateThinkingLevelForTask(ctx, "opencode", executable, "", "max", discovery) if err != nil { t.Fatalf("unexpected err: %v", err) } @@ -713,7 +922,7 @@ echo "opencode 9.9.9" t.Fatalf("expected empty-model opencode max to pass when any advertised model supports it") } - ok, err = ValidateThinkingLevel(ctx, "opencode", fake, "", "xhigh") + ok, err = ValidateThinkingLevelForTask(ctx, "opencode", executable, "", "xhigh", discovery) if err != nil { t.Fatalf("unexpected err: %v", err) } @@ -722,72 +931,6 @@ echo "opencode 9.9.9" } } -// writeFakeClaudeHelpBinary writes a small shell script that mimics -// `claude --help`, emitting the full effort superset line so per-model -// projection has something to filter. Returns the path to the executable. -func writeFakeClaudeHelpBinary(t *testing.T) string { - t.Helper() - dir := t.TempDir() - path := filepath.Join(dir, "claude") - script := "#!/bin/sh\n" + - "cat <<'EOF'\n" + - "Usage: claude [options]\n" + - "\n" + - "Options:\n" + - " --model Model to use\n" + - " --effort Effort level for the current session (low, medium, high, xhigh, max)\n" + - "EOF\n" - // Same ForkLock rationale as TestRunCodexDebugModels_ArgvSeenByBinary — - // the parser tests that consume this helper exec the script in parallel, - // so a sibling fork can otherwise inherit our write fd and trip ETXTBSY. - writeTestExecutable(t, path, []byte(script)) - return path -} - -// writeFakeClaudePreEffortHelpBinary mimics a Claude Code release from -// before the --effort flag existed (e.g. 2.1.2): --help succeeds but has -// no --effort line at all. -func writeFakeClaudePreEffortHelpBinary(t *testing.T) string { - t.Helper() - dir := t.TempDir() - path := filepath.Join(dir, "claude") - script := "#!/bin/sh\n" + - "cat <<'EOF'\n" + - "Usage: claude [options]\n" + - "\n" + - "Options:\n" + - " --model Model to use\n" + - " --verbose\n" + - "EOF\n" - writeTestExecutable(t, path, []byte(script)) - return path -} - -// writeFakeCodexModelsBinary writes a stand-in `codex` that answers -// `debug models --bundled` with a Codex 0.144.1-shaped gpt-5.6 catalog -// (sol/terra advertise max+ultra, luna tops out at max) and prints a version -// string for any other invocation (DetectVersion's probe). Used to exercise -// ValidateThinkingLevel against a real per-model catalog without a codex install. -func writeFakeCodexModelsBinary(t *testing.T) string { - t.Helper() - dir := t.TempDir() - path := filepath.Join(dir, "codex") - script := "#!/bin/sh\n" + - "if [ \"$1\" = \"debug\" ]; then\n" + - "cat <<'EOF'\n" + - `{"models":[` + - `{"slug":"gpt-5.6-sol","default_reasoning_level":"high","supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"},{"effort":"xhigh"},{"effort":"max"},{"effort":"ultra"}]},` + - `{"slug":"gpt-5.6-terra","default_reasoning_level":"high","supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"},{"effort":"xhigh"},{"effort":"max"},{"effort":"ultra"}]},` + - `{"slug":"gpt-5.6-luna","default_reasoning_level":"medium","supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"},{"effort":"xhigh"},{"effort":"max"}]}` + - `]}` + "\n" + - "EOF\n" + - "exit 0\n" + - "fi\n" + - "echo 'codex-cli 0.144.1'\n" - writeTestExecutable(t, path, []byte(script)) - return path -} - // ── Cache key invalidation ─────────────────────────────────────────── func TestThinkingCacheKeyDistinct(t *testing.T) { diff --git a/server/pkg/agent/traecli.go b/server/pkg/agent/traecli.go index 8e39f5eb8ca..30740b7c2eb 100644 --- a/server/pkg/agent/traecli.go +++ b/server/pkg/agent/traecli.go @@ -5,7 +5,9 @@ import ( "context" "fmt" "io" + "os" "os/exec" + "path/filepath" "strings" "sync" "sync/atomic" @@ -92,9 +94,14 @@ func (b *traecliBackend) Execute(ctx context.Context, prompt string, opts ExecOp if execPath == "" { execPath = "traecli" } - if _, err := exec.LookPath(execPath); err != nil { + resolvedExecPath, err := exec.LookPath(execPath) + if err != nil { return nil, fmt.Errorf("traecli executable not found at %q: %w", execPath, err) } + resolvedExecPath, err = filepath.Abs(resolvedExecPath) + if err != nil { + return nil, fmt.Errorf("resolve traecli executable %q: %w", resolvedExecPath, err) + } // Translate the agent's mcp_config (Claude-style object of objects) into // the array shape ACP session/new and session/load expect. Fail closed on @@ -112,13 +119,20 @@ func (b *traecliBackend) Execute(ctx context.Context, prompt string, opts ExecOp []string{"acp", "serve", "--yolo"}, filterCustomArgs(opts.CustomArgs, traecliBlockedArgs, b.cfg.Logger)..., ) - cmd := exec.CommandContext(runCtx, execPath, traecliArgs...) - hideAgentWindow(cmd) - b.cfg.Logger.Info("agent command", "exec", execPath, "args", traecliArgs) - if opts.Cwd != "" { - cmd.Dir = opts.Cwd + cwd := opts.Cwd + if cwd == "" { + cwd, err = os.Getwd() + if err != nil { + cancel() + return nil, fmt.Errorf("resolve traecli working directory: %w", err) + } + } + cmd, err := b.cfg.command(runCtx, resolvedExecPath, traecliArgs, cwd, 10*time.Second) + if err != nil { + cancel() + return nil, fmt.Errorf("build traecli command: %w", err) } - cmd.Env = buildEnv(b.cfg.Env) + b.cfg.Logger.Info("agent command", "exec", resolvedExecPath, "args", traecliArgs) stdout, err := cmd.StdoutPipe() if err != nil { @@ -153,7 +167,7 @@ func (b *traecliBackend) Execute(ctx context.Context, prompt string, opts ExecOp _, _ = io.Copy(stderrSink, stderr) }() - b.cfg.Logger.Info("traecli acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd) + b.cfg.Logger.Info("traecli acp started", "pid", cmd.Process().Pid, "cwd", opts.Cwd) msgStream := newTraecliMessageStream(256) resCh := make(chan Result, 1) @@ -377,7 +391,7 @@ func (b *traecliBackend) Execute(ctx context.Context, prompt string, opts ExecOp } duration := time.Since(startTime) - b.cfg.Logger.Info("traecli finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) + b.cfg.Logger.Info("traecli finished", "pid", cmd.Process().Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) stdin.Close() cancel() diff --git a/server/pkg/agent/traecli_test.go b/server/pkg/agent/traecli_test.go index 79ec768c55c..1001f60c05d 100644 --- a/server/pkg/agent/traecli_test.go +++ b/server/pkg/agent/traecli_test.go @@ -8,8 +8,6 @@ import ( "strings" "testing" "time" - - "log/slog" ) func TestNewReturnsTraecliBackend(t *testing.T) { @@ -81,7 +79,7 @@ func TestTraecliBackendStreamsAndCompletes(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "traecli") writeTestExecutable(t, fakePath, []byte(fakeTraecliACPScript())) - backend, err := New("traecli", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("traecli", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new traecli backend: %v", err) } @@ -138,11 +136,7 @@ func TestTraecliBlockedArgsFiltering(t *testing.T) { fakePath := filepath.Join(tempDir, "traecli") writeTestExecutable(t, fakePath, []byte(fakeTraecliACPScript())) - backend, err := New("traecli", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{"TRAECLI_ARGS_FILE": argsFile}, - }) + backend, err := New("traecli", acpProviderTestConfig(t, fakePath, map[string]string{"TRAECLI_ARGS_FILE": argsFile})) if err != nil { t.Fatalf("new traecli backend: %v", err) } @@ -213,7 +207,7 @@ func TestTraecliSetModelFailureFailsTask(t *testing.T) { fakePath := filepath.Join(t.TempDir(), "traecli") writeTestExecutable(t, fakePath, []byte(fakeTraecliACPScript())) - backend, err := New("traecli", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + backend, err := New("traecli", acpProviderTestConfig(t, fakePath, nil)) if err != nil { t.Fatalf("new traecli backend: %v", err) } @@ -247,11 +241,7 @@ func TestTraecliUsesSessionLoadForResume(t *testing.T) { fakePath := filepath.Join(tempDir, "traecli") writeTestExecutable(t, fakePath, []byte(fakeTraecliACPScript())) - backend, err := New("traecli", Config{ - ExecutablePath: fakePath, - Logger: slog.Default(), - Env: map[string]string{"TRAECLI_REQUESTS_FILE": requestsFile}, - }) + backend, err := New("traecli", acpProviderTestConfig(t, fakePath, map[string]string{"TRAECLI_REQUESTS_FILE": requestsFile})) if err != nil { t.Fatalf("new traecli backend: %v", err) } @@ -305,8 +295,13 @@ func TestTraecliRealACPSmoke(t *testing.T) { if err != nil { t.Skip("traecli not on PATH; skipping real-binary smoke test") } + path, err = filepath.Abs(path) + if err != nil { + t.Fatalf("resolve traecli path: %v", err) + } + cwd := t.TempDir() - backend, err := New("traecli", Config{ExecutablePath: path, Logger: slog.Default()}) + backend, err := New("traecli", acpProviderTestConfigForCwd(t, path, cwd, inheritedEnvironmentForTest())) if err != nil { t.Fatalf("new traecli backend: %v", err) } @@ -314,7 +309,7 @@ func TestTraecliRealACPSmoke(t *testing.T) { defer cancel() session, err := backend.Execute(ctx, "Reply with exactly one word: pong. Do not use any tools.", ExecOptions{ - Cwd: t.TempDir(), + Cwd: cwd, Timeout: 80 * time.Second, }) if err != nil { diff --git a/server/pkg/protocol/messages.go b/server/pkg/protocol/messages.go index 8378ecc88fa..ad02b859422 100644 --- a/server/pkg/protocol/messages.go +++ b/server/pkg/protocol/messages.go @@ -3,6 +3,12 @@ package protocol import "encoding/json" const ( + // RuntimeCapabilityTaskExecution is persisted on each registered runtime + // whose daemon proved it can execute task commands through the Linux + // bubblewrap FD-bound sandbox. Unlike X-Client-Capabilities, this value is + // claim authority and must be read from the runtime row's metadata. + RuntimeCapabilityTaskExecution = "linux-bubblewrap-fd-v1" + DaemonCapabilitySkillBundlesV1 = "skill-bundles-v1" DaemonCapabilityCoalescedCommentsV1 = "coalesced-comments-v1" // DaemonCapabilityRPCV1 advertises that the daemon can carry