diff --git a/.ade/cto/openclaw-history.json b/.ade/cto/openclaw-history.json deleted file mode 100644 index fe51488c7..000000000 --- a/.ade/cto/openclaw-history.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/.ade/cto/openclaw-idempotency.json b/.ade/cto/openclaw-idempotency.json deleted file mode 100644 index 0967ef424..000000000 --- a/.ade/cto/openclaw-idempotency.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/.ade/cto/openclaw-outbox.json b/.ade/cto/openclaw-outbox.json deleted file mode 100644 index fe51488c7..000000000 --- a/.ade/cto/openclaw-outbox.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/.ade/cto/openclaw-routes.json b/.ade/cto/openclaw-routes.json deleted file mode 100644 index e52cc3a0c..000000000 --- a/.ade/cto/openclaw-routes.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "byAgentId": {} -} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 3b2fde49c..5b9285af4 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1,5 +1,5 @@ import { app, BrowserWindow, nativeImage, shell } from "electron"; -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import path from "node:path"; type NodePtyType = typeof import("node-pty"); import { registerIpc } from "./services/ipc/registerIpc"; @@ -123,8 +123,8 @@ function fixElectronShellPath(): void { try { const loginShell = process.env.SHELL || "/bin/zsh"; - // Use login (-l) shell to source profile, printf to avoid trailing newline. - const resolved = execSync(`${loginShell} -lc 'printf "%s" "$PATH"'`, { + // Use execFileSync so SHELL is treated as a path, not interpolated shell text. + const resolved = execFileSync(loginShell, ["-lc", 'printf "%s" "$PATH"'], { encoding: "utf-8", timeout: 5_000, }).trim(); @@ -570,6 +570,7 @@ app.whenReady().then(async () => { let conflictServiceRef: ReturnType | null = null; let prServiceRef: ReturnType | null = null; let prPollingServiceRef: ReturnType | null = null; + let testServiceRef: ReturnType | null = null; const lastHeadByLaneId = new Map(); @@ -1253,6 +1254,9 @@ app.whenReady().then(async () => { linearCredentials: linearCredentialService, prService, processService, + getTestService: () => testServiceRef, + ptyService, + getAutomationService: () => automationService, episodicSummaryService, laneService, sessionService, @@ -1331,6 +1335,7 @@ app.whenReady().then(async () => { emitProjectEvent(projectRoot, IPC.testsEvent, ev); } }); + testServiceRef = testService; automationService = createAutomationService({ db, diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts index 4b14c7f57..706ae47dc 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts @@ -139,6 +139,40 @@ describe("createCtoOperatorTools", () => { expect(toolKeys).toContain("listFileWorkspaces"); expect(toolKeys).toContain("readWorkspaceFile"); expect(toolKeys).toContain("searchWorkspaceText"); + + // PR creation & management tools + expect(toolKeys).toContain("createPrFromLane"); + expect(toolKeys).toContain("landPullRequest"); + expect(toolKeys).toContain("closePullRequest"); + expect(toolKeys).toContain("requestPrReviewers"); + + // Lane management tools + expect(toolKeys).toContain("deleteLane"); + + // Worker management tools + expect(toolKeys).toContain("removeWorker"); + expect(toolKeys).toContain("updateWorker"); + + // Test management tools + expect(toolKeys).toContain("listTestSuites"); + expect(toolKeys).toContain("runTests"); + expect(toolKeys).toContain("stopTestRun"); + expect(toolKeys).toContain("listTestRuns"); + expect(toolKeys).toContain("getTestLog"); + + // Terminal management tools + expect(toolKeys).toContain("createTerminal"); + + // Linear issue discovery tools + expect(toolKeys).toContain("listLinearIssues"); + expect(toolKeys).toContain("getLinearIssue"); + expect(toolKeys).toContain("updateLinearIssueAssignee"); + expect(toolKeys).toContain("addLinearIssueLabel"); + + // Automation management tools + expect(toolKeys).toContain("listAutomations"); + expect(toolKeys).toContain("triggerAutomation"); + expect(toolKeys).toContain("listAutomationRuns"); }); // ── Chat tools ────────────────────────────────────────────────── @@ -791,7 +825,7 @@ describe("createCtoOperatorTools", () => { // ── Linear workflow tools ─────────────────────────────────────── describe("Linear workflow tools", () => { - it.each(["approve", "reject", "retry", "complete"] as const)( + it.each(["approve", "reject", "retry", "resume", "complete"] as const)( "resolves Linear run actions for %s", async (action) => { const deps = buildDeps({ @@ -813,6 +847,8 @@ describe("createCtoOperatorTools", () => { action, "operator note", { workflows: [] }, + undefined, + undefined, ); expect(result).toMatchObject({ success: true, run: { id: "run-1" } }); }, diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 4e0cf1134..8fabd13c2 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -9,9 +9,14 @@ import type { AgentChatSessionSummary, AgentStatus, AgentUpsertInput, + AutomationRuleSummary, + AutomationRun, + AutomationRunListArgs, CtoTriggerAgentWakeupArgs, LinearWorkflowConfig, OperatorNavigationSuggestion, + TestRunSummary, + TestSuiteDefinition, } from "../../../../shared/types"; import type { IssueTracker } from "../../cto/issueTracker"; import type { createLinearDispatcherService } from "../../cto/linearDispatcherService"; @@ -47,6 +52,21 @@ export interface CtoOperatorToolDeps { prService?: ReturnType | null; fileService?: ReturnType | null; processService?: ReturnType | null; + testService?: { + listSuites: () => TestSuiteDefinition[]; + run: (args: { laneId: string; suiteId: string }) => Promise; + stop: (args: { runId: string }) => void; + listRuns: (args?: { laneId?: string; suiteId?: string; limit?: number }) => TestRunSummary[]; + getLogTail: (args: { runId: string; maxBytes?: number }) => string; + } | null; + ptyService?: { + create: (args: { laneId: string; title?: string; cols?: number; rows?: number; tracked?: boolean; startupCommand?: string }) => Promise<{ ptyId: string; sessionId: string }>; + } | null; + automationService?: { + list: () => AutomationRuleSummary[]; + triggerManually: (args: { id: string; dryRun?: boolean }) => Promise; + listRuns: (args?: AutomationRunListArgs) => AutomationRun[]; + } | null; issueTracker?: IssueTracker | null; listChats: (laneId?: string, options?: { includeIdentity?: boolean; includeAutomation?: boolean }) => Promise; getChatStatus: (sessionId: string) => Promise; @@ -1304,13 +1324,14 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { + execute: async ({ runId, action, note, laneId }) => { if (!deps.linearDispatcherService || !deps.flowPolicyService) { return { success: false, error: "Linear workflow services are not available." }; } @@ -1320,6 +1341,8 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { + if (!deps.prService) return { success: false, error: "PR service is not available." }; + try { + const pr = await deps.prService.createFromLane({ laneId, title, body: body ?? "", draft }); + return { success: true, pr }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.landPullRequest = tool({ + description: "Land (merge) an ADE-managed pull request.", + inputSchema: z.object({ + prId: z.string().trim().min(1), + method: z.enum(["merge", "squash", "rebase"]).optional().default("squash"), + archiveLane: z.boolean().optional().default(true), + }), + execute: async ({ prId, method, archiveLane }) => { + if (!deps.prService) return { success: false, error: "PR service is not available." }; + try { + const result = await deps.prService.land({ prId, method, archiveLane }); + return result; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.closePullRequest = tool({ + description: "Close an ADE-managed pull request without merging.", + inputSchema: z.object({ + prId: z.string().trim().min(1), + }), + execute: async ({ prId }) => { + if (!deps.prService) return { success: false, error: "PR service is not available." }; + try { + await deps.prService.closePr({ prId }); + return { success: true, prId }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.requestPrReviewers = tool({ + description: "Request reviewers on an ADE-managed pull request.", + inputSchema: z.object({ + prId: z.string().trim().min(1), + reviewers: z.array(z.string().trim().min(1)).min(1), + }), + execute: async ({ prId, reviewers }) => { + if (!deps.prService) return { success: false, error: "PR service is not available." }; + try { + await deps.prService.requestReviewers({ prId, reviewers }); + return { success: true, prId, reviewers }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + // --------------------------------------------------------------------------- + // Lane Management + // --------------------------------------------------------------------------- + + tools.deleteLane = tool({ + description: "Delete an ADE lane and its associated worktree.", + inputSchema: z.object({ + laneId: z.string().trim().min(1), + }), + execute: async ({ laneId }) => { + try { + await deps.laneService.delete({ laneId }); + return { success: true, laneId }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + // --------------------------------------------------------------------------- + // Worker Management + // --------------------------------------------------------------------------- + + tools.removeWorker = tool({ + description: "Remove a worker agent from the CTO org.", + inputSchema: z.object({ + agentId: z.string().trim().min(1), + }), + execute: async ({ agentId }) => { + if (!deps.workerAgentService) return { success: false, error: "Worker service is not available." }; + try { + deps.workerAgentService.removeAgent(agentId); + return { success: true, agentId }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.updateWorker = tool({ + description: "Update a worker agent configuration.", + inputSchema: z.object({ + agentId: z.string().trim().min(1), + name: z.string().optional(), + role: z.enum(["engineer", "qa", "designer", "devops", "researcher", "general"]).optional(), + title: z.string().nullable().optional(), + reportsTo: z.string().nullable().optional(), + capabilities: z.array(z.string()).optional(), + modelId: z.string().nullable().optional(), + budgetMonthlyCents: z.number().int().nonnegative().optional(), + }), + execute: async ({ agentId, name, role, title, reportsTo, capabilities, modelId, budgetMonthlyCents }) => { + if (!deps.workerAgentService) return { success: false, error: "Worker service is not available." }; + const existing = deps.workerAgentService.getAgent(agentId); + if (!existing) return { success: false, error: `Worker not found: ${agentId}` }; + try { + const worker = deps.workerAgentService.saveAgent({ + id: agentId, + name: name ?? existing.name, + role: role ?? existing.role, + ...(title !== undefined ? { title: title ?? undefined } : {}), + ...(reportsTo !== undefined ? { reportsTo } : {}), + ...(capabilities ? { capabilities } : {}), + adapterType: existing.adapterType, + adapterConfig: modelId !== undefined ? { ...existing.adapterConfig, modelId: modelId ?? undefined } : existing.adapterConfig, + ...(budgetMonthlyCents !== undefined ? { budgetMonthlyCents } : {}), + }); + return { success: true, worker }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + // --------------------------------------------------------------------------- + // Test Management + // --------------------------------------------------------------------------- + + tools.listTestSuites = tool({ + description: "List available test suites that can be run in ADE.", + inputSchema: z.object({}), + execute: async () => { + if (!deps.testService) return { success: false, error: "Test service is not available." }; + const suites = deps.testService.listSuites(); + return { success: true, count: suites.length, suites }; + }, + }); + + tools.runTests = tool({ + description: "Run a test suite in a specific ADE lane.", + inputSchema: z.object({ + laneId: z.string().trim().min(1), + suiteId: z.string().trim().min(1), + }), + execute: async ({ laneId, suiteId }) => { + if (!deps.testService) return { success: false, error: "Test service is not available." }; + try { + const run = await deps.testService.run({ laneId, suiteId }); + return { success: true, run }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.stopTestRun = tool({ + description: "Stop a running test execution.", + inputSchema: z.object({ + runId: z.string().trim().min(1), + }), + execute: async ({ runId }) => { + if (!deps.testService) return { success: false, error: "Test service is not available." }; + try { + deps.testService.stop({ runId }); + return { success: true, runId }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.listTestRuns = tool({ + description: "List recent test runs, optionally filtered by lane or suite.", + inputSchema: z.object({ + laneId: z.string().optional(), + suiteId: z.string().optional(), + limit: z.number().int().positive().max(100).optional().default(20), + }), + execute: async ({ laneId, suiteId, limit }) => { + if (!deps.testService) return { success: false, error: "Test service is not available." }; + const runs = deps.testService.listRuns({ + ...(laneId?.trim() ? { laneId: laneId.trim() } : {}), + ...(suiteId?.trim() ? { suiteId: suiteId.trim() } : {}), + limit, + }); + return { success: true, count: runs.length, runs }; + }, + }); + + tools.getTestLog = tool({ + description: "Read the tail of a test run log.", + inputSchema: z.object({ + runId: z.string().trim().min(1), + maxBytes: z.number().int().positive().max(500_000).optional().default(40_000), + }), + execute: async ({ runId, maxBytes }) => { + if (!deps.testService) return { success: false, error: "Test service is not available." }; + try { + const content = deps.testService.getLogTail({ runId, maxBytes }); + return { success: true, runId, content }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + // --------------------------------------------------------------------------- + // Terminal Management + // --------------------------------------------------------------------------- + + tools.createTerminal = tool({ + description: "Create a new terminal session in an ADE lane.", + inputSchema: z.object({ + laneId: z.string().trim().min(1), + title: z.string().optional(), + startupCommand: z.string().optional(), + }), + execute: async ({ laneId, title, startupCommand }) => { + if (!deps.ptyService) return { success: false, error: "Terminal service is not available." }; + try { + const result = await deps.ptyService.create({ + laneId, + ...(title?.trim() ? { title: title.trim() } : {}), + ...(startupCommand?.trim() ? { startupCommand: startupCommand.trim() } : {}), + tracked: true, + }); + return { success: true, ...result }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + // --------------------------------------------------------------------------- + // Linear Issue Discovery + // --------------------------------------------------------------------------- + + tools.listLinearIssues = tool({ + description: "Search Linear issues by project slug and state.", + inputSchema: z.object({ + projectSlugs: z.array(z.string()).optional(), + stateTypes: z.array(z.string()).optional(), + limit: z.number().int().positive().max(100).optional().default(25), + }), + execute: async ({ projectSlugs, stateTypes, limit }) => { + if (!deps.issueTracker) return { success: false, error: "Linear issue tracker is not available." }; + try { + const issues = await deps.issueTracker.fetchCandidateIssues({ + projectSlugs: projectSlugs ?? [], + stateTypes: stateTypes ?? ["started", "unstarted"], + }); + const limited = issues.slice(0, limit); + return { + success: true, + count: limited.length, + totalAvailable: issues.length, + issues: limited.map((issue) => ({ + id: issue.id, + identifier: issue.identifier, + title: issue.title, + stateName: issue.stateName, + priorityLabel: issue.priorityLabel, + assigneeName: issue.assigneeName, + labels: issue.labels, + projectSlug: issue.projectSlug, + url: issue.url, + })), + }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.getLinearIssue = tool({ + description: "Fetch a single Linear issue by ID or identifier.", + inputSchema: z.object({ + issueId: z.string().trim().min(1), + }), + execute: async ({ issueId }) => { + if (!deps.issueTracker) return { success: false, error: "Linear issue tracker is not available." }; + try { + const issue = await deps.issueTracker.fetchIssueById(issueId); + if (!issue) return { success: false, error: `Issue not found: ${issueId}` }; + return { success: true, issue: buildIssueBrief(issue), raw: issue }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.updateLinearIssueAssignee = tool({ + description: "Assign or unassign a Linear issue.", + inputSchema: z.object({ + issueId: z.string().trim().min(1), + assigneeId: z.string().nullable(), + }), + execute: async ({ issueId, assigneeId }) => { + if (!deps.issueTracker) return { success: false, error: "Linear issue tracker is not available." }; + try { + await deps.issueTracker.updateIssueAssignee(issueId, assigneeId); + return { success: true, issueId, assigneeId }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.addLinearIssueLabel = tool({ + description: "Add a label to a Linear issue.", + inputSchema: z.object({ + issueId: z.string().trim().min(1), + label: z.string().trim().min(1), + }), + execute: async ({ issueId, label }) => { + if (!deps.issueTracker) return { success: false, error: "Linear issue tracker is not available." }; + try { + await deps.issueTracker.addLabel(issueId, label); + return { success: true, issueId, label }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + // --------------------------------------------------------------------------- + // Automation Management + // --------------------------------------------------------------------------- + + tools.listAutomations = tool({ + description: "List automation rules configured in ADE.", + inputSchema: z.object({}), + execute: async () => { + if (!deps.automationService) return { success: false, error: "Automation service is not available." }; + const rules = deps.automationService.list(); + return { success: true, count: rules.length, rules }; + }, + }); + + tools.triggerAutomation = tool({ + description: "Manually trigger an ADE automation rule.", + inputSchema: z.object({ + automationId: z.string().trim().min(1), + dryRun: z.boolean().optional().default(false), + }), + execute: async ({ automationId, dryRun }) => { + if (!deps.automationService) return { success: false, error: "Automation service is not available." }; + try { + const run = await deps.automationService.triggerManually({ id: automationId, dryRun }); + return { success: true, run }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.listAutomationRuns = tool({ + description: "List recent automation run history.", + inputSchema: z.object({ + limit: z.number().int().positive().max(100).optional().default(20), + }), + execute: async ({ limit }) => { + if (!deps.automationService) return { success: false, error: "Automation service is not available." }; + const runs = deps.automationService.listRuns({ limit }); + return { success: true, count: runs.length, runs }; + }, + }); + return tools; } diff --git a/apps/desktop/src/main/services/ai/tools/universalTools.test.ts b/apps/desktop/src/main/services/ai/tools/universalTools.test.ts index 2b7e39fa3..cfe3e192a 100644 --- a/apps/desktop/src/main/services/ai/tools/universalTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/universalTools.test.ts @@ -115,6 +115,24 @@ describe("checkWorkerSandbox", () => { expect(result.reason).toContain("Path outside sandbox"); }); + it("rejects symlinked paths that resolve outside the sandbox root", () => { + const cwd = makeTmpDir("ade-sandbox-symlink-root-"); + const outsideDir = makeTmpDir("ade-sandbox-symlink-outside-"); + const linkedDir = path.join(cwd, "linked-outside"); + const outsideFile = path.join(outsideDir, "secret.txt"); + fs.writeFileSync(outsideFile, "secret\n", "utf-8"); + fs.symlinkSync(outsideDir, linkedDir, "dir"); + + const result = checkWorkerSandbox( + `cat ${path.join(linkedDir, "secret.txt")}`, + sandboxWith({ allowedPaths: ["./"] }), + cwd, + ); + + expect(result.allowed).toBe(false); + expect(result.reason).toContain("Path outside sandbox"); + }); + it("detects redirect target paths for write-like commands", () => { const cwd = "/tmp/project"; const config = sandboxWith({ @@ -146,6 +164,18 @@ describe("checkWorkerSandbox", () => { expect(result.allowed).toBe(false); expect(result.reason).toContain("protected file pattern"); }); + + it("blocks symlinked paths that escape the sandbox root", () => { + const projectRoot = makeTmpDir("ade-sandbox-root-"); + const outsideDir = makeTmpDir("ade-sandbox-outside-"); + const linkPath = path.join(projectRoot, "linked-outside"); + fs.symlinkSync(outsideDir, linkPath, "dir"); + fs.writeFileSync(path.join(outsideDir, "secret.txt"), "secret", "utf8"); + + const result = checkWorkerSandbox("cat linked-outside/secret.txt", DEFAULT_WORKER_SANDBOX_CONFIG, projectRoot); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("Path outside sandbox"); + }); }); // ============================================================================ diff --git a/apps/desktop/src/main/services/ai/tools/universalTools.ts b/apps/desktop/src/main/services/ai/tools/universalTools.ts index 1b9c2f444..cba2026fd 100644 --- a/apps/desktop/src/main/services/ai/tools/universalTools.ts +++ b/apps/desktop/src/main/services/ai/tools/universalTools.ts @@ -287,21 +287,19 @@ function tokenizeCommand(command: string): string[] { function looksLikePathToken(value: string): boolean { return ( - value.startsWith("/") || - value.startsWith("./") || - value.startsWith("../") || - value.startsWith("~") || value.startsWith(".") || + value.startsWith("~") || value.includes("/") ); } +const COMMAND_SEPARATORS = new Set(["|", "||", "&&", ";", "&"]); + function splitCommandSegments(tokens: string[]): string[][] { const segments: string[][] = []; let current: string[] = []; for (const token of tokens) { - const normalized = normalizePathToken(token); - if (normalized === "|" || normalized === "||" || normalized === "&&" || normalized === ";" || normalized === "&") { + if (COMMAND_SEPARATORS.has(normalizePathToken(token))) { if (current.length > 0) segments.push(current); current = []; continue; @@ -381,8 +379,6 @@ function collectPathReferences(command: string, cwd: string): PathReference[] { case "chown": case "patch": case "truncate": - pathOperands.forEach((value) => addPath(value, "write")); - return; case "tee": pathOperands.forEach((value) => addPath(value, "write")); return; @@ -442,17 +438,17 @@ export function checkWorkerSandbox( const commandMutates = bashCommandLikelyMutates(command); // 2. Validate file paths against allowedPaths (absolute + relative) - const rootResolved = path.resolve(projectRoot); + const rootResolved = canonicalizePathForContainment(projectRoot); const pathRefs = collectPathReferences(command, projectRoot); for (const entry of pathRefs) { const p = entry.raw; - const resolved = entry.resolved; + const resolved = canonicalizePathForContainment(entry.resolved); const isSystemExecutablePath = resolved.startsWith("/usr/bin/") || resolved.startsWith("/usr/local/bin/"); if (resolved === "/dev/null") continue; if (isSystemExecutablePath && (entry.access === "read" || (!commandMutates && entry.access !== "write"))) continue; const withinAllowed = config.allowedPaths.some((allowed) => { - const allowedAbs = path.resolve(projectRoot, allowed); + const allowedAbs = canonicalizePathForContainment(path.resolve(projectRoot, allowed)); return isWithinDir(allowedAbs, resolved); }); if (!withinAllowed && !isWithinDir(rootResolved, resolved)) { diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.test.ts b/apps/desktop/src/main/services/automations/automationPlannerService.test.ts index 4ebd1cfe2..9871bb666 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.test.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.test.ts @@ -1,14 +1,22 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { createAutomationPlannerService } from "./automationPlannerService"; import type { AutomationRuleDraft } from "../../../shared/types"; -function createPlannerForTests(args: { suites: Array<{ id: string; name: string }> }) { +function createPlannerForTests(args: { + suites: Array<{ id: string; name: string }>; + projectRoot?: string; + laneWorktrees?: Record; +}) { const logger = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} } as any; + const projectRoot = args.projectRoot ?? "/tmp"; let snapshot = { shared: {}, @@ -42,13 +50,15 @@ function createPlannerForTests(args: { suites: Array<{ id: string; name: string }, } as any; - const laneService = {} as any; + const laneService = { + getLaneWorktreePath: (laneId: string) => args.laneWorktrees?.[laneId] ?? projectRoot, + } as any; const automationService = { list: () => [], syncFromConfig: () => {} } as any; return { planner: createAutomationPlannerService({ logger, - projectRoot: "/tmp", + projectRoot, projectConfigService, laneService, automationService @@ -57,7 +67,11 @@ function createPlannerForTests(args: { suites: Array<{ id: string; name: string }; } -function getPlanner(args: { suites: Array<{ id: string; name: string }> }) { +function getPlanner(args: { + suites: Array<{ id: string; name: string }>; + projectRoot?: string; + laneWorktrees?: Record; +}) { const harness = createPlannerForTests(args); return harness; } @@ -93,6 +107,86 @@ describe("automationPlannerService.validateDraft", () => { expect(withConfirm.ok).toBe(true); }); + it("rejects run-command cwd values that resolve through symlinks outside the project root", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-planner-root-")); + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-planner-outside-")); + const linkPath = path.join(projectRoot, "linked-outside"); + fs.symlinkSync(outsideDir, linkPath); + + try { + const { planner } = getPlanner({ suites: [], projectRoot }); + const draft = createDraft({ + name: "Escape", + actions: [{ type: "run-command", command: "echo hello", cwd: "linked-outside" }] + }); + + const result = planner.validateDraft({ draft, confirmations: ["confirm.run-command"] }); + expect(result.ok).toBe(false); + expect(result.issues.some((issue) => issue.path === "actions[0].cwd")).toBe(true); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + it("rejects run-command cwd values that escape through symlinks", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-planner-root-")); + const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-planner-outside-")); + const linkedPath = path.join(projectRoot, "outside-link"); + fs.symlinkSync(outsideRoot, linkedPath); + + try { + const { planner } = getPlanner({ suites: [], projectRoot }); + const draft = createDraft({ + name: "Symlink escape", + actions: [{ type: "run-command", command: "pwd", cwd: "outside-link" }] + }); + + const result = planner.validateDraft({ draft, confirmations: ["confirm.run-command"] }); + expect(result.ok).toBe(false); + expect(result.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "actions[0].cwd", + message: expect.stringContaining("project root"), + }), + ]), + ); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + fs.rmSync(outsideRoot, { recursive: true, force: true }); + } + }); + + it("validates run-command cwd against the target lane worktree", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-planner-root-")); + const laneWorktree = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-planner-worktree-")); + + try { + fs.mkdirSync(path.join(laneWorktree, "nested"), { recursive: true }); + const { planner } = getPlanner({ + suites: [], + projectRoot, + laneWorktrees: { "lane-1": laneWorktree }, + }); + const draft = createDraft({ + name: "Lane cwd", + execution: { kind: "built-in", targetLaneId: "lane-1" } as any, + actions: [{ type: "run-command", command: "pwd", cwd: "nested" }], + }); + + const result = planner.validateDraft({ draft, confirmations: ["confirm.run-command"] }); + expect(result.ok).toBe(true); + expect(result.normalized?.actions[0]).toMatchObject({ + type: "run-command", + cwd: "nested", + }); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + fs.rmSync(laneWorktree, { recursive: true, force: true }); + } + }); + it("validates schedule cron", () => { const { planner } = getPlanner({ suites: [] }); diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.ts b/apps/desktop/src/main/services/automations/automationPlannerService.ts index fab25f52e..a749163e5 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.ts @@ -29,7 +29,33 @@ import { resolveAdeLayout } from "../../../shared/adeLayout"; import type { Logger } from "../logging/logger"; import type { createProjectConfigService } from "../config/projectConfigService"; import type { createLaneService } from "../lanes/laneService"; -import { isWithinDir } from "../shared/utils"; +import { resolvePathWithinRoot } from "../shared/utils"; + +function resolveAutomationCwdBase( + projectRoot: string, + laneService: ReturnType, + laneId: string | null | undefined, +): string { + return laneId ? laneService.getLaneWorktreePath(laneId) : projectRoot; +} + +function validateAutomationCwd(baseCwd: string, cwdRaw: string): string | null { + const candidate = path.isAbsolute(cwdRaw) ? cwdRaw : path.resolve(baseCwd, cwdRaw); + let resolved: string; + try { + resolved = resolvePathWithinRoot(baseCwd, candidate, { allowMissing: true }); + } catch { + return "cwd must stay within the target lane worktree or project root."; + } + try { + if (!fs.statSync(resolved).isDirectory()) { + return "cwd must point to an existing directory within the target lane worktree or project root."; + } + } catch { + return "cwd must point to an existing directory within the target lane worktree or project root."; + } + return null; +} function slugify(input: string): string { const s = input @@ -486,6 +512,7 @@ function normalizeDraft(args: { draft: AutomationRuleDraft; suites: TestSuiteDefinition[]; projectRoot: string; + laneService: ReturnType; }): { normalized: AutomationRuleDraftNormalized | null; issues: AutomationDraftIssue[]; @@ -642,28 +669,17 @@ function normalizeDraft(args: { const cwdRaw = safeTrim(action?.cwd); if (cwdRaw) { - if (path.isAbsolute(cwdRaw)) { - if (!isWithinDir(args.projectRoot, cwdRaw)) { - issues.push({ - level: "error", - path: `actions[${idx}].cwd`, - message: "Absolute cwd must be within the project root." - }); - } else { - next.cwd = cwdRaw; - } + const executionLaneId = safeTrim(args.draft.execution?.targetLaneId) || null; + const baseCwd = resolveAutomationCwdBase(args.projectRoot, args.laneService, executionLaneId); + const cwdIssue = validateAutomationCwd(baseCwd, cwdRaw); + if (cwdIssue) { + issues.push({ + level: "error", + path: `actions[${idx}].cwd`, + message: cwdIssue + }); } else { - // Require relative cwd to stay within the project root (runtime also enforces lane/base cwd bounds). - const resolved = path.resolve(args.projectRoot, cwdRaw); - if (!isWithinDir(args.projectRoot, resolved)) { - issues.push({ - level: "error", - path: `actions[${idx}].cwd`, - message: "cwd must not escape the project root." - }); - } else { - next.cwd = cwdRaw; - } + next.cwd = cwdRaw; } } @@ -1036,7 +1052,7 @@ export function createAutomationPlannerService({ ? { kind: "built-in", builtIn: { actions: [] } } : { kind: "agent-session", session: {} }; - const normalizedRes = normalizeDraft({ draft, suites, projectRoot }); + const normalizedRes = normalizeDraft({ draft, suites, projectRoot, laneService }); const confidence = clampNumber(1 - normalizedRes.ambiguities.length * 0.18 - normalizedRes.issues.filter((i) => i.level === "warning").length * 0.08, 0, 1); return { @@ -1052,7 +1068,7 @@ export function createAutomationPlannerService({ validateDraft(req: AutomationValidateDraftRequest): AutomationValidateDraftResult { const suites = readSuites(); - const { normalized, issues } = normalizeDraft({ draft: req.draft, suites, projectRoot }); + const { normalized, issues } = normalizeDraft({ draft: req.draft, suites, projectRoot, laneService }); const required = normalized ? requiredConfirmationsForDraft(normalized) : []; const missing = normalized ? missingConfirmations(required, req.confirmations) : []; @@ -1143,7 +1159,7 @@ export function createAutomationPlannerService({ simulate(req: AutomationSimulateRequest): AutomationSimulateResult { const suites = readSuites(); - const { normalized, issues } = normalizeDraft({ draft: req.draft, suites, projectRoot }); + const { normalized, issues } = normalizeDraft({ draft: req.draft, suites, projectRoot, laneService }); if (!normalized) { return { normalized: null, actions: [], notes: [], issues }; } diff --git a/apps/desktop/src/main/services/automations/automationService.test.ts b/apps/desktop/src/main/services/automations/automationService.test.ts index 97fa14373..ee984e390 100644 --- a/apps/desktop/src/main/services/automations/automationService.test.ts +++ b/apps/desktop/src/main/services/automations/automationService.test.ts @@ -1,4 +1,6 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { createRequire } from "node:module"; import initSqlJs from "sql.js"; @@ -46,14 +48,30 @@ function createInMemoryAdeDb(): { db: AdeDb; raw: Database } { id text primary key, project_id text not null, automation_id text not null, + chat_session_id text, + mission_id text, + worker_run_id text, + worker_agent_id text, + queue_item_id text, + ingress_event_id text, trigger_type text not null, started_at text not null, ended_at text, status text not null, + execution_kind text, + queue_status text, + executor_mode text, actions_completed integer not null, actions_total integer not null, error_message text, - trigger_metadata text + verification_required integer not null default 0, + spend_usd real not null default 0, + trigger_metadata text, + summary text, + confidence_json text, + billing_code text, + linked_procedure_ids_json text, + procedure_feedback_json text ) `); raw.run(` @@ -189,6 +207,55 @@ describe("automationService integration", () => { expect(String(mapped[0]?.output ?? "")).toContain("hello"); }); + it("rejects run-command cwd values that escape through symlinks", async () => { + const { db } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-runtime-root-")); + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-runtime-outside-")); + const symlinkPath = path.join(projectRoot, "linked-outside"); + fs.symlinkSync(outsideDir, symlinkPath); + + const rule = { + id: "escape", + name: "Escape", + trigger: { type: "manual" as const }, + actions: [{ type: "run-command" as const, command: "echo hello", cwd: "linked-outside", timeoutMs: 10_000 }], + enabled: true + }; + + const projectConfigService = { + get: () => ({ + trust: { requiresSharedTrust: false }, + effective: { automations: [rule], providerMode: "guest" } + }) + } as any; + + const laneService = { + list: async () => [], + getLaneWorktreePath: () => projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, + logger, + projectId, + projectRoot, + laneService, + projectConfigService + }); + + try { + const run = await service.triggerManually({ id: "escape" }); + expect(run.status).toBe("failed"); + expect(run.errorMessage).toContain("Unsafe cwd"); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + it("computes nextRunAt for scheduled rules", async () => { const { db } = createInMemoryAdeDb(); const logger = createLogger(); @@ -345,4 +412,441 @@ describe("automationService integration", () => { await expect(service.triggerManually({ id: "echo" })).rejects.toThrow(/untrusted/i); }); + + it("runs agent-session automations in plan mode when publish verification is required", async () => { + const { db, raw } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-agent-session-")); + const createSession = vi.fn(async () => ({ id: "session-1" })); + const runSessionTurn = vi.fn(async () => ({ outputText: "Prepared a review summary." })); + + const rule = { + id: "agent-review", + name: "Agent review", + enabled: true, + mode: "review", + reviewProfile: "quick", + trigger: { type: "manual" as const }, + triggers: [{ type: "manual" as const }], + executor: { mode: "automation-bot", targetId: null }, + toolPalette: ["github"] as const, + contextSources: [], + memory: { mode: "project" as const }, + guardrails: { maxDurationMin: 5 }, + outputs: { disposition: "comment-only" as const, createArtifact: true }, + verification: { verifyBeforePublish: true, mode: "intervention" as const }, + billingCode: "auto:test", + execution: { + kind: "agent-session" as const, + session: { title: "Review output" }, + }, + modelConfig: { + orchestratorModel: { + modelId: "openai/gpt-5.4-codex", + thinkingLevel: "medium", + }, + }, + prompt: "Review the latest PR status.", + }; + + const projectConfigService = { + get: () => ({ + trust: { requiresSharedTrust: false }, + effective: { automations: [rule], providerMode: "guest" } + }) + } as any; + + const laneService = { + list: async () => [{ id: "lane-1", laneType: "primary" }], + getLaneWorktreePath: () => projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, + logger, + projectId, + projectRoot, + laneService, + projectConfigService, + agentChatService: { + createSession, + runSessionTurn, + } as any, + }); + + try { + const run = await service.triggerManually({ id: "agent-review" }); + expect(run.status).toBe("succeeded"); + expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ + permissionMode: "plan", + })); + const row = mapExecRows(raw.exec("select queue_status from automation_runs where automation_id = 'agent-review'"))[0]; + expect(String(row?.queue_status)).toBe("verification-required"); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("does not attach codex sandbox for agent-session automations in dry-run mode", async () => { + const { db } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-dry-run-")); + const createSession = vi.fn(async () => ({ id: "session-1" })); + const runSessionTurn = vi.fn(async () => ({ outputText: "Prepared a dry-run summary." })); + + const rule = { + id: "agent-dry-run", + name: "Agent dry run", + enabled: true, + mode: "review", + reviewProfile: "quick", + trigger: { type: "manual" as const }, + triggers: [{ type: "manual" as const }], + executor: { mode: "automation-bot", targetId: null }, + toolPalette: ["github"] as const, + contextSources: [], + memory: { mode: "project" as const }, + guardrails: { maxDurationMin: 5 }, + outputs: { disposition: "comment-only" as const, createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "dry-run" as const }, + billingCode: "auto:test", + execution: { + kind: "agent-session" as const, + session: { title: "Dry run output" }, + }, + modelConfig: { + orchestratorModel: { + modelId: "openai/gpt-5.4-codex", + thinkingLevel: "medium", + }, + }, + prompt: "Dry-run the automation.", + }; + + const projectConfigService = { + get: () => ({ + trust: { requiresSharedTrust: false }, + effective: { automations: [rule], providerMode: "guest" } + }) + } as any; + + const laneService = { + list: async () => [{ id: "lane-1", laneType: "primary" }], + getLaneWorktreePath: () => projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, + logger, + projectId, + projectRoot, + laneService, + projectConfigService, + agentChatService: { + createSession, + runSessionTurn, + } as any, + }); + + try { + const run = await service.triggerManually({ id: "agent-dry-run" }); + expect(run.status).toBe("succeeded"); + expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ + permissionMode: "plan", + })); + const sessionArgs = (createSession as any).mock.calls[0][0] as Record; + expect(sessionArgs).not.toHaveProperty("codexSandbox"); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("simulates manual dry runs without starting automation side effects", async () => { + const { db } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-manual-dry-run-")); + const createSession = vi.fn(async () => ({ id: "session-1" })); + + const rule = { + id: "agent-manual-dry-run", + name: "Agent manual dry run", + enabled: true, + mode: "review", + reviewProfile: "quick", + trigger: { type: "manual" as const }, + triggers: [{ type: "manual" as const }], + executor: { mode: "automation-bot", targetId: null }, + toolPalette: [] as const, + contextSources: [], + memory: { mode: "project" as const }, + guardrails: { maxDurationMin: 5 }, + outputs: { disposition: "comment-only" as const, createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" as const }, + billingCode: "auto:test", + execution: { + kind: "agent-session" as const, + }, + prompt: "Summarize the current state.", + }; + + const projectConfigService = { + get: () => ({ + trust: { requiresSharedTrust: false }, + effective: { automations: [rule], providerMode: "guest" } + }) + } as any; + + const laneService = { + list: async () => [{ id: "lane-1", laneType: "primary" }], + getLaneWorktreePath: () => projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, + logger, + projectId, + projectRoot, + laneService, + projectConfigService, + agentChatService: { + createSession, + } as any, + }); + + try { + const run = await service.triggerManually({ id: "agent-manual-dry-run", dryRun: true }); + expect(run.status).toBe("succeeded"); + const row = db.get<{ queue_status: string }>("select queue_status from automation_runs where automation_id = 'agent-manual-dry-run'"); + expect(row?.queue_status).toBe("completed-clean"); + expect(createSession).not.toHaveBeenCalled(); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("blocks agent-session automations when the budget cap rejects the run", async () => { + const { db } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-budget-")); + const createSession = vi.fn(async () => ({ id: "session-1" })); + + const rule = { + id: "agent-budget", + name: "Agent budget", + enabled: true, + mode: "review", + reviewProfile: "quick", + trigger: { type: "manual" as const }, + triggers: [{ type: "manual" as const }], + executor: { mode: "automation-bot", targetId: null }, + toolPalette: [] as const, + contextSources: [], + memory: { mode: "project" as const }, + guardrails: { maxDurationMin: 5 }, + outputs: { disposition: "comment-only" as const, createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" as const }, + billingCode: "auto:test", + execution: { + kind: "agent-session" as const, + }, + prompt: "Summarize the current state.", + }; + + const projectConfigService = { + get: () => ({ + trust: { requiresSharedTrust: false }, + effective: { automations: [rule], providerMode: "guest" } + }) + } as any; + + const laneService = { + list: async () => [{ id: "lane-1", laneType: "primary" }], + getLaneWorktreePath: () => projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, + logger, + projectId, + projectRoot, + laneService, + projectConfigService, + agentChatService: { + createSession, + } as any, + budgetCapService: { + checkBudget: vi.fn(() => ({ allowed: false, reason: "Budget exceeded" })), + } as any, + }); + + try { + await expect(service.triggerManually({ id: "agent-budget" })).rejects.toThrow("Budget exceeded"); + expect(createSession).not.toHaveBeenCalled(); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("checks the budget cap against the resolved provider group", async () => { + const { db } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-budget-provider-")); + const createSession = vi.fn(async () => ({ id: "session-1" })); + const checkBudget = vi.fn(() => ({ allowed: false, reason: "Budget exceeded" })); + + const rule = { + id: "agent-budget-provider", + name: "Agent budget provider", + enabled: true, + mode: "review", + reviewProfile: "quick", + trigger: { type: "manual" as const }, + triggers: [{ type: "manual" as const }], + executor: { mode: "automation-bot", targetId: null }, + toolPalette: [] as const, + contextSources: [], + memory: { mode: "project" as const }, + guardrails: { maxDurationMin: 5 }, + outputs: { disposition: "comment-only" as const, createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" as const }, + billingCode: "auto:test", + execution: { + kind: "agent-session" as const, + }, + modelConfig: { + orchestratorModel: { + modelId: "openai/gpt-5.4-codex", + thinkingLevel: "medium", + }, + }, + prompt: "Summarize the current state.", + }; + + const projectConfigService = { + get: () => ({ + trust: { requiresSharedTrust: false }, + effective: { automations: [rule], providerMode: "guest" } + }) + } as any; + + const laneService = { + list: async () => [{ id: "lane-1", laneType: "primary" }], + getLaneWorktreePath: () => projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, + logger, + projectId, + projectRoot, + laneService, + projectConfigService, + agentChatService: { + createSession, + } as any, + budgetCapService: { + checkBudget, + } as any, + }); + + try { + await expect(service.triggerManually({ id: "agent-budget-provider" })).rejects.toThrow("Budget exceeded"); + expect(checkBudget).toHaveBeenCalledWith("automation-rule", "agent-budget-provider", "codex"); + expect(createSession).not.toHaveBeenCalled(); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("does not attach codexSandbox when a codex automation runs in dry-run mode", async () => { + const { db } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-codex-dryrun-")); + const createSession = vi.fn(async () => ({ id: "session-1" })); + const runSessionTurn = vi.fn(async () => ({ outputText: "Planned changes only." })); + + const rule = { + id: "agent-codex-dryrun", + name: "Agent codex dry run", + enabled: true, + mode: "review", + reviewProfile: "quick", + trigger: { type: "manual" as const }, + triggers: [{ type: "manual" as const }], + executor: { mode: "automation-bot", targetId: null }, + toolPalette: ["github"] as const, + contextSources: [], + memory: { mode: "project" as const }, + guardrails: { maxDurationMin: 5 }, + outputs: { disposition: "comment-only" as const, createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "dry-run" as const }, + billingCode: "auto:test", + execution: { + kind: "agent-session" as const, + }, + permissionConfig: { + providers: { + codexSandbox: "workspace-write" as const, + }, + }, + modelConfig: { + orchestratorModel: { + modelId: "openai/gpt-5.4-codex", + thinkingLevel: "medium", + }, + }, + prompt: "Plan the latest changes.", + }; + + const projectConfigService = { + get: () => ({ + trust: { requiresSharedTrust: false }, + effective: { automations: [rule], providerMode: "guest" } + }) + } as any; + + const laneService = { + list: async () => [{ id: "lane-1", laneType: "primary" }], + getLaneWorktreePath: () => projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, + logger, + projectId, + projectRoot, + laneService, + projectConfigService, + agentChatService: { + createSession, + runSessionTurn, + } as any, + }); + + try { + const run = await service.triggerManually({ id: "agent-codex-dryrun" }); + expect(run.status).toBe("succeeded"); + expect(createSession).toHaveBeenCalledWith(expect.not.objectContaining({ + codexSandbox: "workspace-write", + })); + expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ + permissionMode: "plan", + })); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + }); diff --git a/apps/desktop/src/main/services/automations/automationService.ts b/apps/desktop/src/main/services/automations/automationService.ts index 4956dc673..38c99df4f 100644 --- a/apps/desktop/src/main/services/automations/automationService.ts +++ b/apps/desktop/src/main/services/automations/automationService.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; +import fs from "node:fs"; import path from "node:path"; import cron from "node-cron"; import chokidar, { type FSWatcher } from "chokidar"; @@ -39,10 +40,11 @@ import type { createAgentChatService } from "../chat/agentChatService"; import type { createMemoryBriefingService } from "../memory/memoryBriefingService"; import type { createProceduralLearningService } from "../memory/proceduralLearningService"; import type { createBudgetCapService } from "../usage/budgetCapService"; +import type { BudgetCapProvider } from "../../../shared/types/usage"; import { buildClaudeReadOnlyWorkerAllowedTools } from "../orchestrator/unifiedOrchestratorAdapter"; import type { createWorkerHeartbeatService } from "../cto/workerHeartbeatService"; -import { escapeRegExp, globToRegExp, isRecord, isWithinDir, matchesGlob, normalizeSet, nowIso, safeJsonParse } from "../shared/utils"; -import { getDefaultModelDescriptor } from "../../../shared/modelRegistry"; +import { escapeRegExp, globToRegExp, isRecord, matchesGlob, normalizeSet, nowIso, resolvePathWithinRoot, safeJsonParse } from "../shared/utils"; +import { getDefaultModelDescriptor, getModelById, resolveProviderGroupForModel } from "../../../shared/modelRegistry"; type CronTask = { stop: () => void; @@ -455,7 +457,10 @@ function normalizeRuntimeRule(rule: AutomationRule): AutomationRule { createArtifact: rule.outputs?.createArtifact ?? true, ...(rule.outputs?.notificationChannel ? { notificationChannel: rule.outputs.notificationChannel } : {}), }, - verification: { verifyBeforePublish: false, mode: "intervention" }, + verification: { + verifyBeforePublish: rule.verification?.verifyBeforePublish === true, + mode: rule.verification?.mode ?? "intervention", + }, billingCode: rule.billingCode?.trim() || `auto:${rule.id}`, execution: normalizedExecution, legacy: { @@ -929,8 +934,8 @@ export function createAutomationService({ ...(rule.permissionConfig?.inProcess ? { inProcess: rule.permissionConfig.inProcess } : {}), ...(rule.permissionConfig?.externalMcp ? { externalMcp: rule.permissionConfig.externalMcp } : {}), providers: { - claude: providers?.claude ?? "edit", - codex: providers?.codex ?? "edit", + claude: rule.verification.mode === "dry-run" ? "plan" : (providers?.claude ?? "edit"), + codex: rule.verification.mode === "dry-run" ? "plan" : (providers?.codex ?? "edit"), unified: rule.verification.mode === "dry-run" ? "plan" : (providers?.unified ?? "edit"), codexSandbox: providers?.codexSandbox ?? "workspace-write", ...(providers?.writablePaths?.length ? { writablePaths: providers.writablePaths } : {}), @@ -1421,14 +1426,24 @@ export function createAutomationService({ const laneId = trigger.laneId ?? null; const baseCwd = laneId ? laneService.getLaneWorktreePath(laneId) : projectRoot; const configuredCwd = (action.cwd ?? "").trim(); - const cwd = configuredCwd.length + const cwdCandidate = configuredCwd.length ? path.isAbsolute(configuredCwd) ? configuredCwd : path.resolve(baseCwd, configuredCwd) : baseCwd; - if (!isWithinDir(baseCwd, cwd)) { + let cwd: string; + try { + cwd = resolvePathWithinRoot(baseCwd, cwdCandidate, { allowMissing: true }); + } catch { throw new Error("Unsafe cwd: must stay inside the lane worktree or project root."); } + try { + if (!fs.statSync(cwd).isDirectory()) { + throw new Error("Configured cwd is not a directory."); + } + } catch { + throw new Error(`Configured cwd does not exist: ${configuredCwd || cwd}`); + } const { output, exitCode } = await runCommand({ command, cwd, timeoutMs: action.timeoutMs ?? 5 * 60_000 }); if (exitCode !== 0) return { status: "failed", output: output.length ? output : `Command exited with code ${exitCode}` }; return { status: "succeeded", output }; @@ -1572,6 +1587,25 @@ export function createAutomationService({ } }; + const resolveAutomationModelDescriptor = (rule: AutomationRule) => { + const requestedModelId = rule.modelConfig?.orchestratorModel?.modelId; + if (requestedModelId && !getModelById(requestedModelId)) { + throw new Error(`Unknown model '${requestedModelId}'.`); + } + const modelId = requestedModelId ?? DEFAULT_AUTOMATION_CHAT_MODEL_ID; + const modelDescriptor = getModelById(modelId) ?? getDefaultModelDescriptor("unified"); + if (!modelDescriptor) { + throw new Error(`Unknown model '${modelId}'.`); + } + const providerGroup = resolveProviderGroupForModel(modelDescriptor); + return { + modelId, + modelDescriptor, + providerGroup, + budgetProvider: (providerGroup === "claude" || providerGroup === "codex" ? providerGroup : "any") as BudgetCapProvider, + }; + }; + const dispatchAgentSessionRun = async (args: { rule: AutomationRule; trigger: TriggerContext; @@ -1586,6 +1620,16 @@ export function createAutomationService({ throw new Error("No lane is available for this automation run."); } + const { modelId, providerGroup, budgetProvider } = resolveAutomationModelDescriptor(args.rule); + const budgetCheck = budgetCapServiceRef?.checkBudget( + AUTOMATION_SCOPE as Parameters["checkBudget"]>[0], + args.rule.id, + budgetProvider, + ); + if (budgetCheck && !budgetCheck.allowed) { + throw new Error(budgetCheck.reason ?? "Budget cap blocked automation run."); + } + const briefing = await buildBriefing(args.rule, args.trigger); const linkedProcedureIds = briefing?.usedProcedureIds ?? []; const confidence = computeConfidence(args.rule, linkedProcedureIds.length); @@ -1604,7 +1648,16 @@ export function createAutomationService({ }); const actionId = insertAction(run.id, 0, "agent-session"); - const modelId = args.rule.modelConfig?.orchestratorModel?.modelId ?? DEFAULT_AUTOMATION_CHAT_MODEL_ID; + const permissionConfig = buildPermissionConfig(args.rule, { publishPhase: false }); + const verificationRequired = requiresPublishGate(args.rule); + const dryRun = args.rule.verification.mode === "dry-run"; + const permissionMode = verificationRequired || dryRun + ? "plan" + : providerGroup === "claude" + ? permissionConfig.providers?.claude ?? "edit" + : providerGroup === "codex" + ? permissionConfig.providers?.codex ?? "edit" + : permissionConfig.providers?.unified ?? "edit"; const reasoningEffort = args.rule.execution?.session?.reasoningEffort ?? args.rule.modelConfig?.orchestratorModel?.thinkingLevel ?? null; const timeoutMs = Math.max( 15_000, @@ -1621,7 +1674,10 @@ export function createAutomationService({ modelId, sessionProfile: "workflow", reasoningEffort, - unifiedPermissionMode: "full-auto", + permissionMode, + ...(providerGroup === "codex" && !verificationRequired && !dryRun && permissionConfig.providers?.codexSandbox + ? { codexSandbox: permissionConfig.providers.codexSandbox } + : {}), surface: "automation", automationId: args.rule.id, automationRunId: run.id, @@ -1654,7 +1710,7 @@ export function createAutomationService({ queue_status: deriveQueueStatus({ current: "pending-review", runStatus: "succeeded", - verificationRequired: false, + verificationRequired, mode: args.rule.mode, summary: result.outputText, }), @@ -1677,7 +1733,7 @@ export function createAutomationService({ queue_status: deriveQueueStatus({ current: "pending-review", runStatus: "failed", - verificationRequired: false, + verificationRequired, mode: args.rule.mode, summary: message, }), @@ -1736,7 +1792,12 @@ export function createAutomationService({ const confidence = computeConfidence(args.rule, linkedProcedureIds.length); const permissionConfig = buildPermissionConfig(args.rule, { publishPhase: Boolean(args.publishPhase) }); const budgetScope = AUTOMATION_SCOPE; - const budgetCheck = budgetCapServiceRef?.checkBudget(budgetScope as Parameters["checkBudget"]>[0], args.rule.id, "any"); + const { budgetProvider } = resolveAutomationModelDescriptor(args.rule); + const budgetCheck = budgetCapServiceRef?.checkBudget( + budgetScope as Parameters["checkBudget"]>[0], + args.rule.id, + budgetProvider, + ); if (budgetCheck && !budgetCheck.allowed) { throw new Error(budgetCheck.reason ?? "Budget cap blocked automation run."); } @@ -1893,7 +1954,74 @@ export function createAutomationService({ }); }; - const runRule = async (rule: AutomationRule, trigger: TriggerContext): Promise => { + const simulateDryRun = async (rule: AutomationRule, trigger: TriggerContext): Promise => { + const briefing = await buildBriefing(rule, trigger); + const linkedProcedureIds = briefing?.usedProcedureIds ?? []; + const confidence = computeConfidence(rule, linkedProcedureIds.length); + const summary = `${rule.prompt?.trim() || rule.name} (dry run)`; + const run = insertRun({ + rule, + trigger, + status: "succeeded", + queueStatus: "completed-clean", + actionsTotal: 1, + confidence, + linkedProcedureIds, + summary, + ingressEventId: trigger.ingressEventId ?? null, + }); + const actionId = insertAction(run.id, 0, "dry-run"); + finishAction({ + id: actionId, + status: "succeeded", + output: "Dry run completed. No automation side effects were executed.", + }); + updateRun(run.id, { + ended_at: nowIso(), + status: "succeeded", + queue_status: "completed-clean", + actions_completed: 1, + error_message: null, + summary, + confidence_json: JSON.stringify(confidence), + linked_procedure_ids_json: JSON.stringify(linkedProcedureIds), + }); + emit({ type: "runs-updated", automationId: rule.id, runId: run.id }); + return toRun(loadRunRow(run.id) ?? { + id: run.id, + automation_id: rule.id, + chat_session_id: null, + mission_id: null, + worker_run_id: null, + worker_agent_id: null, + queue_item_id: null, + ingress_event_id: trigger.ingressEventId ?? null, + trigger_type: trigger.triggerType, + started_at: run.startedAt, + ended_at: nowIso(), + status: "succeeded", + execution_kind: resolveExecutionKind(rule), + queue_status: "completed-clean", + executor_mode: rule.executor.mode, + actions_completed: 1, + actions_total: 1, + error_message: null, + verification_required: 0, + spend_usd: 0, + trigger_metadata: JSON.stringify(buildTriggerMetadata(trigger)), + summary, + confidence_json: JSON.stringify(confidence), + billing_code: rule.billingCode, + linked_procedure_ids_json: JSON.stringify(linkedProcedureIds), + procedure_feedback_json: JSON.stringify([]), + }); + }; + + const runRule = async ( + rule: AutomationRule, + trigger: TriggerContext, + options: { dryRun?: boolean } = {}, + ): Promise => { if (projectConfigService.get().trust.requiresSharedTrust) { throw new Error("Shared config is untrusted. Confirm trust to run automations."); } @@ -1913,6 +2041,9 @@ export function createAutomationService({ } inFlightByAutomationId.add(rule.id); try { + if (options.dryRun) { + return await simulateDryRun(rule, trigger); + } const executionKind = resolveExecutionKind(rule); if (executionKind === "agent-session") return await dispatchAgentSessionRun({ rule, trigger }); if (executionKind === "built-in") return await runLegacyRule(rule, trigger); @@ -2572,7 +2703,7 @@ export function createAutomationService({ scheduledAt: nowIso(), reviewProfileOverride: args.reviewProfileOverride ?? null, verboseTrace: Boolean(args.verboseTrace), - }); + }, { dryRun: Boolean(args.dryRun) }); }, getHistory(args: { id: string; limit?: number }): AutomationRun[] { diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 8d17ef313..2de353cf8 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -1836,6 +1836,125 @@ describe("createAgentChatService", () => { expect(toolResults).toHaveLength(1); }); + it("rejects attachments outside the project root before dispatch", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + const threadsBefore = mockState.codexThreadCounter; + const turnsBefore = mockState.codexTurnCounter; + const outsidePath = path.join(process.cwd(), `.ade-agent-chat-outside-${Date.now()}.txt`); + fs.writeFileSync(outsidePath, "secret", "utf8"); + try { + await expect(service.sendMessage({ + sessionId: session.id, + text: "Review this file", + attachments: [{ path: outsidePath, type: "file" }], + })).rejects.toThrow(/project root/); + } finally { + fs.rmSync(outsidePath, { force: true }); + } + expect(mockState.codexThreadCounter).toBe(threadsBefore); + expect(mockState.codexTurnCounter).toBe(turnsBefore); + }); + + it("keeps public attachment paths trimmed without exposing resolved filesystem paths", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => { + events.push(event); + }, + }); + fs.writeFileSync(path.join(tmpRoot, "note.txt"), "hello", "utf8"); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + const attachments = [{ path: " note.txt ", type: "file" as const }]; + await service.sendMessage({ + sessionId: session.id, + text: "Review this file", + attachments, + }); + + const userMessage = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { event: { type: "user_message"; attachments?: Array<{ path: string; type: "file" | "image" }> } } => + event.event.type === "user_message", + ); + + expect(attachments[0]?.path).toBe(" note.txt "); + expect(userMessage.event.attachments).toEqual([{ path: "note.txt", type: "file" }]); + }); + + it("logs attachment read failures and keeps the fallback text generic", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service, logger } = createService({ + onEvent: (event: AgentChatEventEnvelope) => { + events.push(event); + }, + }); + const attachmentDir = path.join(tmpRoot, "attachment-dir"); + fs.mkdirSync(attachmentDir, { recursive: true }); + vi.mocked(generateText).mockResolvedValue({ text: "Attachment fallback test" } as any); + let streamArgs: Record | null = null; + vi.mocked(streamText).mockImplementation((args: Record) => { + streamArgs = args; + return { + fullStream: (async function* () { + yield { type: "finish", usage: {} }; + })(), + } as any; + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "unified", + model: "", + modelId: "anthropic/claude-sonnet-4-6-api", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Check this attachment", + attachments: [{ path: "attachment-dir", type: "file" }], + }); + + const rawMessages = streamArgs && Array.isArray((streamArgs as { messages?: unknown }).messages) + ? (streamArgs as { messages: Array<{ role: string; content: unknown }> }).messages + : []; + const messages = rawMessages; + const currentUserMessageText = JSON.stringify(messages.at(-1)?.content); + const userMessageEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => event.event.type === "user_message", + ); + const rendererPayload = JSON.stringify(userMessageEvent.event); + + expect(currentUserMessageText).toContain("Attachment unavailable: attachment-dir"); + expect(currentUserMessageText).not.toContain("Path is not a regular file"); + expect(currentUserMessageText).not.toContain("EISDIR"); + expect(rendererPayload).not.toContain("Path is not a regular file"); + expect(rendererPayload).not.toContain("EISDIR"); + expect(rendererPayload).not.toContain(attachmentDir); + expect(rendererPayload).not.toContain(tmpRoot); + expect(logger.warn).toHaveBeenCalledWith( + "agent_chat.streaming_attachment_unavailable", + expect.objectContaining({ + attachmentPath: "attachment-dir", + error: expect.any(Error), + }), + ); + }); + it("prefers the canonical turn-scoped Codex text stream when item-scoped deltas also arrive", async () => { const textEvents: Array<{ text: string; itemId?: string; turnId?: string }> = []; const { service } = createService({ diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 08a37da77..8e078e184 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -13,7 +13,7 @@ import { type ModelMessage, type UserContent, } from "ai"; -import { query as claudeQuery, unstable_v2_createSession, unstable_v2_resumeSession } from "@anthropic-ai/claude-agent-sdk"; +import { unstable_v2_createSession, unstable_v2_resumeSession } from "@anthropic-ai/claude-agent-sdk"; import type { Query as ClaudeSDKQuery, SDKMessage, SDKUserMessage, Options as ClaudeSDKOptions, PermissionResult as ClaudePermissionResult } from "@anthropic-ai/claude-agent-sdk"; type ClaudeV2Session = { @@ -39,7 +39,7 @@ import type { createProcessService } from "../processes/processService"; import { runGit } from "../git/git"; import { CLAUDE_RUNTIME_AUTH_ERROR, isClaudeRuntimeAuthError } from "../ai/claudeRuntimeProbe"; import { resolveClaudeCodeExecutable } from "../ai/claudeCodeExecutable"; -import { nowIso, fileSizeOrZero } from "../shared/utils"; +import { fileSizeOrZero, isEnoentError, nowIso, readFileWithinRootSecure, resolvePathWithinRoot } from "../shared/utils"; import type { EpisodicSummaryService } from "../memory/episodicSummaryService"; import { createDefaultComputerUsePolicy, @@ -70,6 +70,8 @@ import type { AgentChatSessionCapabilities, AgentChatSessionCapabilitiesArgs, AgentChatSessionSummary, + AgentChatSlashCommand, + AgentChatSlashCommandsArgs, AgentChatSubagentListArgs, AgentChatSubagentSnapshot, AgentChatSurface, @@ -81,6 +83,7 @@ import type { AgentChatUpdateSessionArgs, ComputerUseBackendStatus, ComputerUsePolicy, + ThinkingLevel, TerminalSessionStatus, TerminalToolType, CtoCapabilityMode, @@ -102,7 +105,7 @@ import { createUniversalToolSet, type PermissionMode } from "../ai/tools/univers import { createWorkflowTools } from "../ai/tools/workflowTools"; import { createLinearTools } from "../ai/tools/linearTools"; import { createCtoOperatorTools } from "../ai/tools/ctoOperatorTools"; -import { buildCodingAgentSystemPrompt, composeSystemPrompt } from "../ai/tools/systemPrompt"; +import { buildCodingAgentSystemPrompt } from "../ai/tools/systemPrompt"; import { resolveClaudeCliModel } from "../ai/claudeModelUtils"; import { getProviderRuntimeHealth, @@ -119,6 +122,8 @@ import type { createWorkerHeartbeatService } from "../cto/workerHeartbeatService import type { IssueTracker } from "../cto/issueTracker"; import type { createFlowPolicyService } from "../cto/flowPolicyService"; import type { createLinearDispatcherService } from "../cto/linearDispatcherService"; +import type { LinearClient } from "../cto/linearClient"; +import type { LinearCredentialService } from "../cto/linearCredentialService"; import type { createPrService } from "../prs/prService"; import type { ComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; import { createProofObserver } from "../computerUse/proofObserver"; @@ -140,12 +145,6 @@ type JsonRpcEnvelope = { }; }; -type ClaudeToolPermissionResult = { - behavior: "allow" | "deny"; - message?: string; - interrupt?: boolean; -}; - type PersistedClaudeMessage = { role: "user" | "assistant"; content: string; @@ -240,7 +239,7 @@ type CodexRuntime = { type ClaudeRuntime = { kind: "claude"; sdkSessionId: string | null; - activeQuery: import("@anthropic-ai/claude-agent-sdk").Query | null; + activeQuery: ClaudeSDKQuery | null; v2Session: ClaudeV2Session | null; /** Single stream generator kept alive across turns (never closed by for-await). */ v2StreamGen: AsyncGenerator | null; @@ -316,22 +315,25 @@ function codexNotificationDedupKey(payload: JsonRpcEnvelope): string | null { const method = typeof payload.method === "string" ? payload.method : ""; const params = asRecord(payload.params) ?? {}; - if (method === "item/started" || method === "codex/event/item_started") { - const itemId = readCodexNotificationItemId(params); - return itemId ? `item_started:${itemId}` : null; - } - - if (method === "item/completed" || method === "codex/event/item_completed") { - const itemId = readCodexNotificationItemId(params); - return itemId ? `item_completed:${itemId}` : null; - } - - if (method === "turn/aborted" || method === "codex/event/turn_aborted") { - const turnId = extractCodexTurnId(params); - return turnId ? `turn_aborted:${turnId}` : null; + switch (method) { + case "item/started": + case "codex/event/item_started": { + const itemId = readCodexNotificationItemId(params); + return itemId ? `item_started:${itemId}` : null; + } + case "item/completed": + case "codex/event/item_completed": { + const itemId = readCodexNotificationItemId(params); + return itemId ? `item_completed:${itemId}` : null; + } + case "turn/aborted": + case "codex/event/turn_aborted": { + const turnId = extractCodexTurnId(params); + return turnId ? `turn_aborted:${turnId}` : null; + } + default: + return null; } - - return null; } function shouldSkipDuplicateCodexNotification(runtime: CodexRuntime, payload: JsonRpcEnvelope): boolean { @@ -553,11 +555,17 @@ type PreparedSendMessage = { promptText: string; visibleText: string; attachments: AgentChatFileRef[]; + resolvedAttachments: ResolvedAgentChatFileRef[]; reasoningEffort?: string | null; interactionMode?: AgentChatInteractionMode | null; onDispatched?: () => void; }; +type ResolvedAgentChatFileRef = AgentChatFileRef & { + _resolvedPath: string; + _rootPath: string; +}; + type ResolvedChatConfig = { codexApprovalPolicy: AgentChatCodexApprovalPolicy; codexSandboxMode: AgentChatCodexSandbox; @@ -939,9 +947,10 @@ function readProviderParentItemId(value: unknown): string | undefined { function buildStreamingUserContent( args: { baseText: string; - attachments: AgentChatFileRef[]; + attachments: ResolvedAgentChatFileRef[]; runtimeKind: "claude" | "unified"; modelDescriptor?: ModelDescriptor; + logger?: Logger; }, ): UserContent { if (!args.attachments.length) { @@ -953,14 +962,8 @@ function buildStreamingUserContent( ]; for (const attachment of args.attachments) { - const resolvedPath = path.resolve(attachment.path); - if (!fs.existsSync(resolvedPath)) { - parts.push({ type: "text", text: `\nAttachment missing: ${attachment.path}` }); - continue; - } - try { - const data = fs.readFileSync(resolvedPath); + const data = readFileWithinRootSecure(attachment._rootPath, attachment._resolvedPath); const mediaType = inferAttachmentMediaType(attachment); if (attachment.type === "image") { @@ -983,7 +986,7 @@ function buildStreamingUserContent( parts.push({ type: "file", data, - filename: path.basename(resolvedPath) || undefined, + filename: path.basename(attachment._resolvedPath) || undefined, mediaType, }); continue; @@ -994,9 +997,19 @@ function buildStreamingUserContent( text: `\nAttached file: ${attachment.path}`, }); } catch (error) { + if (isEnoentError(error)) { + parts.push({ type: "text", text: `\nAttachment missing: ${attachment.path}` }); + continue; + } + args.logger?.warn("agent_chat.streaming_attachment_unavailable", { + attachmentPath: attachment.path, + resolvedPath: attachment._resolvedPath, + rootPath: attachment._rootPath, + error, + }); parts.push({ type: "text", - text: `\nAttachment unavailable: ${attachment.path}${error instanceof Error ? ` (${error.message})` : ""}`, + text: `\nAttachment unavailable: ${attachment.path}`, }); } } @@ -1541,7 +1554,7 @@ function inferCapabilityMode(provider: AgentChatProvider): CtoCapabilityMode { return provider === "codex" || provider === "claude" ? "full_mcp" : "fallback"; } -function guardedIdentityPermissionModeForProvider(provider: AgentChatProvider): AgentChatSession["permissionMode"] { +function guardedIdentityPermissionModeForProvider(_provider: AgentChatProvider): AgentChatSession["permissionMode"] { return "plan"; } @@ -1579,10 +1592,13 @@ export function createAgentChatService(args: { getMissionService?: () => ReturnType | null; getAiOrchestratorService?: () => ReturnType | null; getLinearDispatcherService?: () => ReturnType | null; - linearClient?: import("../cto/linearClient").LinearClient | null; - linearCredentials?: import("../cto/linearCredentialService").LinearCredentialService | null; + linearClient?: LinearClient | null; + linearCredentials?: LinearCredentialService | null; prService?: ReturnType | null; processService?: ReturnType | null; + getTestService?: () => { listSuites: () => any[]; run: (args: any) => Promise; stop: (args: any) => void; listRuns: (args?: any) => any[]; getLogTail: (args: any) => string } | null; + ptyService?: { create: (args: any) => Promise<{ ptyId: string; sessionId: string }> } | null; + getAutomationService?: () => { list: () => any[]; triggerManually: (args: any) => Promise; listRuns: (args?: any) => any[] } | null; computerUseArtifactBrokerService?: ComputerUseArtifactBrokerService | null; laneService: ReturnType; sessionService: ReturnType; @@ -1611,6 +1627,9 @@ export function createAgentChatService(args: { linearCredentials: linearCredentialsRef, prService, processService, + getTestService, + ptyService, + getAutomationService, computerUseArtifactBrokerService, laneService, sessionService, @@ -1634,6 +1653,18 @@ export function createAgentChatService(args: { fs.mkdirSync(transcriptsDir, { recursive: true }); fs.mkdirSync(chatTranscriptsDir, { recursive: true }); + const stageAttachmentForCodexInput = (attachment: ResolvedAgentChatFileRef): string => { + const content = readFileWithinRootSecure(attachment._rootPath, attachment._resolvedPath); + const stagedDir = path.join(layout.tmpDir, "agent-chat-attachments"); + fs.mkdirSync(stagedDir, { recursive: true }); + const baseName = path.basename(attachment.path) || path.basename(attachment._resolvedPath) || "attachment"; + const stagedPath = path.join(stagedDir, `${randomUUID()}-${baseName}`); + const tempPath = `${stagedPath}.tmp`; + fs.writeFileSync(tempPath, content); + fs.renameSync(tempPath, stagedPath); + return stagedPath; + }; + const managedSessions = new Map(); const sessionTurnCollectors = new Map(); const subagentStates = new Map>(); @@ -3946,6 +3977,7 @@ export function createAgentChatService(args: { promptText: string; displayText?: string; attachments?: AgentChatFileRef[]; + resolvedAttachments?: ResolvedAgentChatFileRef[]; onDispatched?: () => void; }, ): Promise => { @@ -3960,6 +3992,11 @@ export function createAgentChatService(args: { } const runtime = managed.runtime; const attachments = args.attachments ?? []; + const resolvedAttachments = args.resolvedAttachments ?? attachments.map((attachment) => ({ + ...attachment, + _resolvedPath: attachment.path, + _rootPath: managed.laneWorktreePath, + })); const displayText = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; const autoMemoryPlan = await buildAutoMemoryTurnPlan(managed, displayText, attachments); const autoMemoryNotice = buildAutoMemorySystemNotice(autoMemoryPlan); @@ -4009,13 +4046,14 @@ export function createAgentChatService(args: { text_elements: [] }); - for (const attachment of attachments) { + for (const attachment of resolvedAttachments) { + const stagedPath = stageAttachmentForCodexInput(attachment); if (attachment.type === "image") { - input.push({ type: "localImage", path: attachment.path }); + input.push({ type: "localImage", path: stagedPath }); continue; } const name = path.basename(attachment.path) || attachment.path; - input.push({ type: "mention", name, path: attachment.path }); + input.push({ type: "mention", name, path: stagedPath }); } managed.session.status = "active"; @@ -4064,9 +4102,9 @@ export function createAgentChatService(args: { // ── Helpers for unified turn logic ── - const mapReasoningEffortToThinking = (effort: string | null | undefined): import("../../../shared/types").ThinkingLevel | null => { + const mapReasoningEffortToThinking = (effort: string | null | undefined): ThinkingLevel | null => { if (!effort) return null; - const map: Record = { + const map: Record = { none: "none", minimal: "minimal", low: "low", @@ -4145,6 +4183,7 @@ export function createAgentChatService(args: { promptText: string; displayText?: string; attachments?: AgentChatFileRef[]; + resolvedAttachments?: ResolvedAgentChatFileRef[]; onDispatched?: () => void; }, ): Promise => { @@ -4165,6 +4204,11 @@ export function createAgentChatService(args: { managed.session.status = "active"; const attachments = args.attachments ?? []; + const resolvedAttachments = args.resolvedAttachments ?? attachments.map((attachment) => ({ + ...attachment, + _resolvedPath: attachment.path, + _rootPath: managed.laneWorktreePath, + })); const displayText = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; emitPreparedUserMessage(managed, { text: displayText, @@ -4269,7 +4313,9 @@ export function createAgentChatService(args: { // Build the message — plain string for text-only, or SDKUserMessage with // image content blocks (streaming input format per SDK docs). - const messageToSend = buildClaudeV2Message(basePromptText, attachments); + const messageToSend = buildClaudeV2Message(basePromptText, resolvedAttachments, { + baseDir: managed.laneWorktreePath, + }); const turnPermissionMode = resolveClaudeTurnPermissionMode(managed); if (typeof runtime.v2Session.setPermissionMode === "function") { @@ -4859,6 +4905,7 @@ export function createAgentChatService(args: { promptText: string; displayText?: string; attachments?: AgentChatFileRef[]; + resolvedAttachments?: ResolvedAgentChatFileRef[]; onDispatched?: () => void; }, ): Promise => { @@ -4882,6 +4929,11 @@ export function createAgentChatService(args: { runtime.interrupted = false; managed.session.status = "active"; const attachments = args.attachments ?? []; + const resolvedAttachments = args.resolvedAttachments ?? attachments.map((attachment) => ({ + ...attachment, + _resolvedPath: attachment.path, + _rootPath: managed.laneWorktreePath, + })); const displayText = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; emitPreparedUserMessage(managed, { text: displayText, @@ -4978,9 +5030,10 @@ export function createAgentChatService(args: { role: "user", content: buildStreamingUserContent({ baseText: streamingBaseText, - attachments, + attachments: resolvedAttachments, runtimeKind: "unified", modelDescriptor: runtime.modelDescriptor, + logger, }), }; }); @@ -5191,6 +5244,9 @@ export function createAgentChatService(args: { prService: prService ?? null, fileService: fileService ?? null, processService: processService ?? null, + testService: getTestService?.() ?? null, + ptyService: ptyService ?? null, + automationService: getAutomationService?.() ?? null, issueTracker: linearIssueTracker ?? null, listChats: listSessions, getChatStatus: getSessionSummary, @@ -7583,6 +7639,33 @@ export function createAgentChatService(args: { const visibleText = displayText?.trim().length ? displayText.trim() : trimmed; const managed = ensureManagedSession(sessionId); + const publicAttachments = attachments.map((attachment) => ({ + ...attachment, + path: attachment.path.trim(), + })); + const resolvedAttachments = publicAttachments.map((attachment): ResolvedAgentChatFileRef => { + const rawPath = attachment.path; + if (!rawPath.length) { + throw new Error("Attachment path is required."); + } + const isAbsolute = path.isAbsolute(rawPath); + const root = isAbsolute ? projectRoot : managed.laneWorktreePath; + try { + const safePath = resolvePathWithinRoot(root, rawPath, { allowMissing: true }); + return { + ...attachment, + path: rawPath, + _resolvedPath: safePath, + _rootPath: root, + }; + } catch { + throw new Error( + isAbsolute + ? `Attachment path must stay within the project root: ${rawPath}` + : `Attachment path must stay within the active lane: ${rawPath}`, + ); + } + }); const allowClaudeLoginCommand = managed.session.provider === "claude" && slashCommand === "/login"; const claudeRuntimeHealth = managed.session.provider === "claude" ? getProviderRuntimeHealth("claude") @@ -7636,7 +7719,8 @@ export function createAgentChatService(args: { managed, promptText, visibleText, - attachments, + attachments: publicAttachments, + resolvedAttachments, reasoningEffort, interactionMode: managed.session.provider === "claude" ? managed.session.interactionMode ?? "default" : null, }; @@ -7711,6 +7795,7 @@ export function createAgentChatService(args: { promptText, visibleText, attachments, + resolvedAttachments, reasoningEffort, onDispatched, } = prepared; @@ -7726,7 +7811,7 @@ export function createAgentChatService(args: { if (reasoningEffort) { managed.session.reasoningEffort = normalizeReasoningEffort(reasoningEffort); } - await runTurn(managed, { promptText, displayText: visibleText, attachments, onDispatched }); + await runTurn(managed, { promptText, displayText: visibleText, attachments, resolvedAttachments, onDispatched }); return; } @@ -7793,7 +7878,7 @@ export function createAgentChatService(args: { } } - await sendCodexMessage(managed, { promptText, displayText: visibleText, attachments, onDispatched }); + await sendCodexMessage(managed, { promptText, displayText: visibleText, attachments, resolvedAttachments, onDispatched }); return; } @@ -7803,7 +7888,7 @@ export function createAgentChatService(args: { } ensureClaudeSessionRuntime(managed); - await runClaudeTurn(managed, { promptText, displayText: visibleText, attachments, onDispatched }); + await runClaudeTurn(managed, { promptText, displayText: visibleText, attachments, resolvedAttachments, onDispatched }); }; const sendMessage = async ( @@ -8778,13 +8863,13 @@ export function createAgentChatService(args: { return deriveSessionCapabilities(managed); }; - const getSlashCommands = ({ sessionId }: import("../../../shared/types").AgentChatSlashCommandsArgs): import("../../../shared/types").AgentChatSlashCommand[] => { + const getSlashCommands = ({ sessionId }: AgentChatSlashCommandsArgs): AgentChatSlashCommand[] => { const managed = managedSessions.get(sessionId); if (!managed) return []; const provider = managed.session.provider; // Local commands available to all providers - const localCommands: import("../../../shared/types").AgentChatSlashCommand[] = [ + const localCommands: AgentChatSlashCommand[] = [ { name: "/clear", description: "Clear chat history", source: "local" }, ]; if (provider === "claude") { @@ -8798,21 +8883,21 @@ export function createAgentChatService(args: { // Claude SDK commands if (provider === "claude" && managed.runtime?.kind === "claude") { const rt = managed.runtime; - const sdkCmds: import("../../../shared/types").AgentChatSlashCommand[] = rt.slashCommands.map((cmd: { name: string; description: string; argumentHint?: string }) => ({ + const sdkCmds: AgentChatSlashCommand[] = rt.slashCommands.map((cmd: { name: string; description: string; argumentHint?: string }) => ({ name: cmd.name, description: cmd.description, argumentHint: cmd.argumentHint, source: "sdk" as const, })); // Merge: SDK commands first, then local commands that don't conflict - const sdkNames = new Set(sdkCmds.map((c: import("../../../shared/types").AgentChatSlashCommand) => c.name)); + const sdkNames = new Set(sdkCmds.map((c: AgentChatSlashCommand) => c.name)); return [...sdkCmds, ...localCommands.filter((c) => !sdkNames.has(c.name))]; } // Codex SDK commands if (provider === "codex" && managed.runtime?.kind === "codex") { const rt = managed.runtime; - const sdkCmds: import("../../../shared/types").AgentChatSlashCommand[] = rt.slashCommands.map((cmd: { name: string; description: string; argumentHint?: string }) => ({ + const sdkCmds: AgentChatSlashCommand[] = rt.slashCommands.map((cmd: { name: string; description: string; argumentHint?: string }) => ({ name: cmd.name, description: cmd.description, argumentHint: cmd.argumentHint, @@ -8822,7 +8907,7 @@ export function createAgentChatService(args: { if (!sdkCmds.some((c) => c.name === "/review")) { sdkCmds.push({ name: "/review", description: "Review uncommitted changes", source: "sdk" as const }); } - const sdkNames = new Set(sdkCmds.map((c: import("../../../shared/types").AgentChatSlashCommand) => c.name)); + const sdkNames = new Set(sdkCmds.map((c: AgentChatSlashCommand) => c.name)); return [...sdkCmds, ...localCommands.filter((c) => !sdkNames.has(c.name))]; } diff --git a/apps/desktop/src/main/services/chat/buildClaudeV2Message.test.ts b/apps/desktop/src/main/services/chat/buildClaudeV2Message.test.ts index 3ec7e5c3c..f412e6b93 100644 --- a/apps/desktop/src/main/services/chat/buildClaudeV2Message.test.ts +++ b/apps/desktop/src/main/services/chat/buildClaudeV2Message.test.ts @@ -84,6 +84,20 @@ describe("buildClaudeV2Message", () => { expect(Buffer.from(source.data as string, "base64").toString()).toBe("fake-image-bytes"); }); + it("resolves relative image attachments against the provided base directory", () => { + writeFakeImage("relative-photo.png"); + const attachments: AgentChatFileRef[] = [ + { path: "relative-photo.png", type: "image" }, + ]; + + const result = buildClaudeV2Message("Describe this image", attachments, { baseDir: tmpDir }); + const msg = result as SDKUserMessagePartial; + const imgBlock = msg.message.content[1] as Record; + const source = imgBlock.source as Record; + + expect(Buffer.from(source.data as string, "base64").toString()).toBe("fake-image-bytes"); + }); + // ───────────────────────────────────────────────────────────────────────── // 4. Missing image file -> text fallback // ───────────────────────────────────────────────────────────────────────── diff --git a/apps/desktop/src/main/services/chat/buildClaudeV2Message.ts b/apps/desktop/src/main/services/chat/buildClaudeV2Message.ts index 31bfc084f..15fe11137 100644 --- a/apps/desktop/src/main/services/chat/buildClaudeV2Message.ts +++ b/apps/desktop/src/main/services/chat/buildClaudeV2Message.ts @@ -1,6 +1,12 @@ import fs from "node:fs"; import path from "node:path"; import type { AgentChatFileRef } from "../../../shared/types/chat"; +import { readFileWithinRootSecure } from "../shared/utils"; + +type ResolvedAgentChatFileRef = AgentChatFileRef & { + _resolvedPath?: string; + _rootPath?: string; +}; /** MIME types the Anthropic API accepts for inline image content blocks. */ export const ANTHROPIC_IMAGE_MEDIA_TYPES = new Set([ @@ -74,7 +80,8 @@ export type SDKUserMessagePartial = { */ export function buildClaudeV2Message( promptText: string, - attachments: AgentChatFileRef[], + attachments: ResolvedAgentChatFileRef[], + options: { baseDir?: string } = {}, ): string | SDKUserMessagePartial { const imageAttachments = attachments.filter((a) => a.type === "image"); if (!imageAttachments.length) { @@ -97,13 +104,16 @@ export function buildClaudeV2Message( } try { - const resolvedPath = path.resolve(attachment.path); const mediaType = inferAttachmentMediaType(attachment); if (!ANTHROPIC_IMAGE_MEDIA_TYPES.has(mediaType)) { content.push({ type: "text", text: `\n[Image attached (${mediaType}): ${attachment.path}]` }); continue; } - const data = fs.readFileSync(resolvedPath); + const secureRoot = attachment._rootPath ?? options.baseDir; + const resolvedPath = attachment._resolvedPath ?? attachment.path; + const data = secureRoot + ? readFileWithinRootSecure(secureRoot, resolvedPath) + : fs.readFileSync(resolvedPath); content.push({ type: "image", source: { type: "base64", media_type: mediaType, data: data.toString("base64") }, diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts index 045d25623..22de3540a 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts @@ -114,4 +114,80 @@ describe("computerUseArtifactBrokerService", () => { "artifact-reviewed", ]); }); + + it("rejects local file imports outside allowed artifact roots", () => { + const missionService = { addArtifact: vi.fn() } as any; + const orchestratorService = { registerArtifact: vi.fn() } as any; + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + missionService, + orchestratorService, + logger: createLogger(), + }); + + const blockedPath = path.join(process.cwd(), `.ade-broker-blocked-${Date.now()}.txt`); + fs.writeFileSync(blockedPath, "secret", "utf8"); + try { + expect(() => + broker.ingest({ + backend: { + style: "external_cli", + name: "agent-browser", + }, + inputs: [ + { + kind: "console_logs", + title: "Blocked import", + path: blockedPath, + }, + ], + }), + ).toThrow(/outside allowed import roots/); + } finally { + fs.rmSync(blockedPath, { force: true }); + } + }); + + it("rejects symlinked artifact paths that escape the project artifact directory", () => { + const missionService = { addArtifact: vi.fn() } as any; + const orchestratorService = { registerArtifact: vi.fn() } as any; + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + missionService, + orchestratorService, + logger: createLogger(), + }); + + const outsideDir = fs.mkdtempSync(path.join(process.cwd(), ".ade-computer-use-broker-outside-")); + const outsideFile = path.join(outsideDir, "secret.txt"); + const artifactDir = path.join(projectRoot, ".ade", "artifacts"); + const symlinkPath = path.join(artifactDir, "linked-secret.txt"); + fs.mkdirSync(artifactDir, { recursive: true }); + fs.writeFileSync(outsideFile, "secret", "utf8"); + fs.symlinkSync(outsideFile, symlinkPath); + + try { + expect(() => + broker.ingest({ + backend: { + style: "external_cli", + name: "agent-browser", + }, + inputs: [ + { + kind: "console_logs", + title: "Linked artifact", + path: symlinkPath, + }, + ], + }), + ).toThrow(/outside allowed import roots/); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts index ca1fdc6a5..4f7b4cebb 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import type { ComputerUseArtifactIngestionRequest, @@ -33,8 +34,8 @@ import type { createExternalMcpService } from "../externalMcp/externalMcpService import { fileExists, isRecord, - isWithinDir, nowIso, + resolvePathWithinRoot, safeJsonParse, toOptionalString, writeTextAtomic, @@ -75,6 +76,66 @@ function isHttpUrl(value: string): boolean { return /^https?:\/\//i.test(value); } +function isAllowedExternalArtifactSource( + absolutePath: string, + roots: string[], +): boolean { + return roots.some((root) => { + try { + resolvePathWithinRoot(root, absolutePath); + return true; + } catch { + return false; + } + }); +} + +function secureCopyFromDescriptor(sourcePath: string, targetPath: string): void { + const sourceFlags = fs.constants.O_RDONLY | (typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0); + const sourceFd = fs.openSync(sourcePath, sourceFlags); + const tempPath = `${targetPath}.tmp-${randomUUID()}`; + let tempCreated = false; + + try { + const sourceStat = fs.fstatSync(sourceFd); + if (!sourceStat.isFile()) { + throw new Error("Artifact source must be a regular file."); + } + + const targetFd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC, sourceStat.mode & 0o777); + tempCreated = true; + try { + const buffer = Buffer.allocUnsafe(64 * 1024); + let position = 0; + for (;;) { + const bytesRead = fs.readSync(sourceFd, buffer, 0, buffer.length, position); + if (bytesRead === 0) break; + + let offset = 0; + while (offset < bytesRead) { + offset += fs.writeSync(targetFd, buffer, offset, bytesRead - offset); + } + position += bytesRead; + } + fs.fsyncSync(targetFd); + } finally { + fs.closeSync(targetFd); + } + + fs.renameSync(tempPath, targetPath); + tempCreated = false; + } finally { + if (tempCreated) { + try { + fs.rmSync(tempPath, { force: true }); + } catch { + // Best-effort cleanup only. + } + } + fs.closeSync(sourceFd); + } +} + function dedupeOwners(owners: ComputerUseArtifactOwner[]): ComputerUseArtifactOwner[] { const seen = new Set(); const result: ComputerUseArtifactOwner[] = []; @@ -134,8 +195,14 @@ export function createComputerUseArtifactBrokerService(args: { logger?: Logger | null; onEvent?: (payload: ComputerUseEventPayload) => void; }) { - const { db, projectId, projectRoot, missionService, orchestratorService, externalMcpService, logger, onEvent } = args; + const { db, projectId, projectRoot, missionService, orchestratorService, externalMcpService, onEvent } = args; const layout = resolveAdeLayout(projectRoot); + const allowedImportRoots = Array.from(new Set([ + layout.artifactsDir, + layout.tmpDir, + os.tmpdir(), + path.join(os.homedir(), ".agent-browser"), + ])); const emit = (payload: ComputerUseEventPayload): void => { try { @@ -164,18 +231,26 @@ export function createComputerUseArtifactBrokerService(args: { const pathLike = toOptionalString(input.path) ?? (directUri && !isHttpUrl(directUri) ? directUri : null); if (pathLike) { - const absolutePath = path.isAbsolute(pathLike) ? pathLike : path.resolve(projectRoot, pathLike); + const absolutePath = path.isAbsolute(pathLike) + ? pathLike + : resolvePathWithinRoot(projectRoot, pathLike, { allowMissing: true }); if (fileExists(absolutePath)) { - if (isWithinDir(layout.artifactsDir, absolutePath)) { + try { + const existingArtifactPath = resolvePathWithinRoot(layout.artifactsDir, absolutePath); return { - uri: toProjectArtifactUri(projectRoot, absolutePath), + uri: toProjectArtifactUri(projectRoot, existingArtifactPath), storageKind: "file", mimeType: toOptionalString(input.mimeType), }; + } catch { + // Fall through to external import handling. + } + if (!isAllowedExternalArtifactSource(absolutePath, allowedImportRoots)) { + throw new Error(`Artifact path is outside allowed import roots: ${absolutePath}`); } const extension = inferArtifactExtension({ ...input, path: absolutePath }, kind); const targetPath = createComputerUseArtifactPath(projectRoot, title, extension); - fs.copyFileSync(absolutePath, targetPath); + secureCopyFromDescriptor(absolutePath, targetPath); return { uri: toProjectArtifactUri(projectRoot, targetPath), storageKind: "file", diff --git a/apps/desktop/src/main/services/config/projectConfigService.laneEnvInit.test.ts b/apps/desktop/src/main/services/config/projectConfigService.laneEnvInit.test.ts index 9f65fa369..b5d509b6a 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.laneEnvInit.test.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.laneEnvInit.test.ts @@ -180,6 +180,75 @@ describe("projectConfigService lane env init", () => { ); }); + it("rejects process, suite, and overlay cwd values outside the project root", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-cwd-")); + tempDirs.push(root); + + const adeDir = path.join(root, ".ade"); + fs.mkdirSync(adeDir, { recursive: true }); + + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-cwd-outside-")); + tempDirs.push(outsideDir); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-1", + db: makeDb(), + logger: makeLogger() + }); + + const validation = service.validate({ + shared: { + version: 1, + processes: [ + { + id: "proc-1", + name: "Proc 1", + command: ["echo", "ok"], + cwd: outsideDir, + }, + ], + stackButtons: [], + testSuites: [ + { + id: "suite-1", + name: "Suite 1", + command: ["echo", "ok"], + cwd: outsideDir, + }, + ], + laneOverlayPolicies: [ + { + id: "overlay-1", + name: "Overlay 1", + overrides: { + cwd: outsideDir, + }, + }, + ], + automations: [] + }, + local: { + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [] + } + }); + + expect(validation.ok).toBe(false); + expect(validation.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "effective.processes[0].cwd", message: expect.stringContaining("project root") }), + expect.objectContaining({ path: "effective.testSuites[0].cwd", message: expect.stringContaining("project root") }), + expect.objectContaining({ path: "effective.laneOverlayPolicies[0].overrides.cwd", message: expect.stringContaining("project root") }), + ]), + ); + }); + it("deep merges nested docker config across shared and local lane env init", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-lane-init-docker-merge-")); tempDirs.push(root); diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index 0ca49efb8..1e4a91cc5 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -65,7 +65,7 @@ import type { import { NO_DEFAULT_LANE_TEMPLATE } from "../../../shared/types"; import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; -import { isRecord } from "../shared/utils"; +import { isRecord, resolvePathWithinRoot } from "../shared/utils"; const TRUSTED_SHARED_HASH_KEY = "project_config:trusted_shared_hash"; const VERSION = 1; @@ -83,6 +83,15 @@ const AUTOMATION_TOOL_FAMILIES: AutomationToolFamily[] = [ "external-mcp", ]; +function isPathWithinProjectRoot(projectRoot: string, candidate: string, opts: { allowMissing?: boolean } = {}): boolean { + try { + resolvePathWithinRoot(projectRoot, candidate, opts); + return true; + } catch { + return false; + } +} + function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } @@ -2179,6 +2188,8 @@ function validateEffectiveConfig( const absCwd = path.isAbsolute(proc.cwd) ? proc.cwd : path.join(projectRoot, proc.cwd); if (proc.cwd && !isDirectory(absCwd)) { issues.push({ path: `${p}.cwd`, message: `cwd does not exist: ${proc.cwd}` }); + } else if (proc.cwd && !isPathWithinProjectRoot(projectRoot, absCwd)) { + issues.push({ path: `${p}.cwd`, message: `cwd must stay within the project root: ${proc.cwd}` }); } if (proc.readiness.type === "port") { @@ -2252,6 +2263,8 @@ function validateEffectiveConfig( const absCwd = path.isAbsolute(suite.cwd) ? suite.cwd : path.join(projectRoot, suite.cwd); if (suite.cwd && !isDirectory(absCwd)) { issues.push({ path: `${p}.cwd`, message: `cwd does not exist: ${suite.cwd}` }); + } else if (suite.cwd && !isPathWithinProjectRoot(projectRoot, absCwd)) { + issues.push({ path: `${p}.cwd`, message: `cwd must stay within the project root: ${suite.cwd}` }); } if (suite.timeoutMs != null && (!Number.isFinite(suite.timeoutMs) || suite.timeoutMs <= 0)) { @@ -2279,6 +2292,8 @@ function validateEffectiveConfig( const absCwd = path.isAbsolute(overrideCwd) ? overrideCwd : path.join(projectRoot, overrideCwd); if (!isDirectory(absCwd)) { issues.push({ path: `${p}.overrides.cwd`, message: `cwd override does not exist: ${overrideCwd}` }); + } else if (!isPathWithinProjectRoot(projectRoot, absCwd)) { + issues.push({ path: `${p}.overrides.cwd`, message: `cwd override must stay within the project root: ${overrideCwd}` }); } } for (const processId of policy.overrides.processIds ?? []) { diff --git a/apps/desktop/src/main/services/cto/flowPolicyService.ts b/apps/desktop/src/main/services/cto/flowPolicyService.ts index 0083e441d..df109c55c 100644 --- a/apps/desktop/src/main/services/cto/flowPolicyService.ts +++ b/apps/desktop/src/main/services/cto/flowPolicyService.ts @@ -78,6 +78,44 @@ function normalizePolicy(input?: LinearWorkflowConfig | null): LinearWorkflowCon .map((entry) => { const target = entry.target ?? ({} as LinearWorkflowDefinition["target"]); const triggers = entry.triggers ?? ({} as LinearWorkflowDefinition["triggers"]); + + const routeTags = entry.routing ? uniqueStrings(entry.routing.metadataTags) : []; + const routing = entry.routing + ? { + routing: { + ...(routeTags.length ? { metadataTags: routeTags } : {}), + ...(entry.routing.watchOnly === true ? { watchOnly: true } : {}), + }, + } + : {}; + + const closeoutApplyLabels = entry.closeout ? uniqueStrings(entry.closeout.applyLabels) : []; + const closeoutLabels = entry.closeout ? uniqueStrings(entry.closeout.labels) : []; + const closeout = entry.closeout + ? { + closeout: { + ...entry.closeout, + ...(closeoutApplyLabels.length ? { applyLabels: closeoutApplyLabels } : {}), + ...(closeoutLabels.length ? { labels: closeoutLabels } : {}), + }, + } + : {}; + + const reviewers = entry.humanReview ? uniqueStrings(entry.humanReview.reviewers) : []; + const humanReview = entry.humanReview + ? { + humanReview: { + ...entry.humanReview, + ...(reviewers.length ? { reviewers } : {}), + }, + } + : {}; + + const steps = (entry.steps ?? []).map((step, index) => ({ + ...step, + id: step.id?.trim() || `step-${index + 1}`, + })); + return { ...entry, id: entry.id?.trim() || "", @@ -93,52 +131,23 @@ function normalizePolicy(input?: LinearWorkflowConfig | null): LinearWorkflowCon teamKeys: uniqueStrings(triggers.teamKeys), priority: uniqueStrings(triggers.priority) as LinearWorkflowDefinition["triggers"]["priority"], stateTransitions: (triggers.stateTransitions ?? []) - .map((transition) => ({ - ...(uniqueStrings(transition?.from).length ? { from: uniqueStrings(transition.from) } : {}), - ...(uniqueStrings(transition?.to).length ? { to: uniqueStrings(transition.to) } : {}), - })) + .map((transition) => { + const from = uniqueStrings(transition?.from); + const to = uniqueStrings(transition?.to); + return { + ...(from.length ? { from } : {}), + ...(to.length ? { to } : {}), + }; + }) .filter((transition) => (transition.to?.length ?? 0) > 0), owner: uniqueStrings(triggers.owner), creator: uniqueStrings(triggers.creator), metadataTags: uniqueStrings(triggers.metadataTags), }, - ...(entry.routing - ? { - routing: { - ...(uniqueStrings(entry.routing.metadataTags).length - ? { metadataTags: uniqueStrings(entry.routing.metadataTags) } - : {}), - ...(entry.routing.watchOnly === true ? { watchOnly: true } : {}), - }, - } - : {}), - steps: (entry.steps ?? []).map((step, index) => ({ - ...step, - id: step.id?.trim() || `step-${index + 1}`, - })), - ...(entry.closeout - ? { - closeout: { - ...entry.closeout, - ...(uniqueStrings(entry.closeout.applyLabels).length - ? { applyLabels: uniqueStrings(entry.closeout.applyLabels) } - : {}), - ...(uniqueStrings(entry.closeout.labels).length - ? { labels: uniqueStrings(entry.closeout.labels) } - : {}), - }, - } - : {}), - ...(entry.humanReview - ? { - humanReview: { - ...entry.humanReview, - ...(uniqueStrings(entry.humanReview.reviewers).length - ? { reviewers: uniqueStrings(entry.humanReview.reviewers) } - : {}), - }, - } - : {}), + ...routing, + steps, + ...closeout, + ...humanReview, ...(entry.retry ? { retry: { diff --git a/apps/desktop/src/main/services/cto/linearCloseoutService.ts b/apps/desktop/src/main/services/cto/linearCloseoutService.ts index 40d76defa..9ab38d07e 100644 --- a/apps/desktop/src/main/services/cto/linearCloseoutService.ts +++ b/apps/desktop/src/main/services/cto/linearCloseoutService.ts @@ -208,30 +208,33 @@ export function createLinearCloseoutService(args: { if (comment?.trim()) { await args.issueTracker.createComment(input.issue.id, comment.trim()); } + const outboundStatus = input.outcome === "cancelled" ? "canceled" as const : input.outcome; + const outboundTemplateValues = { + ...templateValues, + pr: { + ...(templateValues.pr as Record), + links: closeoutArtifacts.prLinks, + }, + }; + if (input.workflow.target.type === "mission" && input.run.linkedMissionId) { await args.outboundService.publishMissionCloseout({ issue: input.issue, missionId: input.run.linkedMissionId, - status: input.outcome === "cancelled" ? "canceled" : input.outcome, + status: outboundStatus, summary: input.summary, prLinks: closeoutArtifacts.prLinks, artifactPaths: closeoutArtifacts.artifactPaths, artifactMode: closeout?.artifactMode ?? "links", commentTemplate: closeout?.commentTemplate ?? null, - templateValues: { - ...templateValues, - pr: { - ...(templateValues.pr as Record), - links: closeoutArtifacts.prLinks, - }, - }, + templateValues: outboundTemplateValues, }); return; } await args.outboundService.publishWorkflowCloseout({ issue: input.issue, - status: input.outcome === "cancelled" ? "canceled" : input.outcome, + status: outboundStatus, summary: input.summary, targetLabel: input.workflow.target.type.replace(/_/g, " "), targetId: @@ -245,13 +248,7 @@ export function createLinearCloseoutService(args: { artifactPaths: closeoutArtifacts.artifactPaths, artifactMode: closeout?.artifactMode ?? "links", commentTemplate: closeout?.commentTemplate ?? null, - templateValues: { - ...templateValues, - pr: { - ...(templateValues.pr as Record), - links: closeoutArtifacts.prLinks, - }, - }, + templateValues: outboundTemplateValues, }); }; diff --git a/apps/desktop/src/main/services/cto/linearDispatcherService.test.ts b/apps/desktop/src/main/services/cto/linearDispatcherService.test.ts index 113754c95..ce1a700d9 100644 --- a/apps/desktop/src/main/services/cto/linearDispatcherService.test.ts +++ b/apps/desktop/src/main/services/cto/linearDispatcherService.test.ts @@ -706,6 +706,67 @@ describe("linearDispatcherService", () => { db.close(); }); + it("resumes awaiting-delegation runs after an operator picks an override", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-resume-awaiting-delegation-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildEmployeeSessionPolicy(); + const employeeIssue = { + ...issueFixture, + assigneeId: "unknown-agent", + assigneeName: "Unknown Agent", + labels: ["workflow:backend"], + }; + const ensureIdentitySession = vi.fn(async () => ({ id: "session-override-1" })); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => employeeIssue), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => []) } as any, + workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { + ensureIdentitySession, + sendMessage: vi.fn(async () => ({ id: "message-1" })), + listSessions: vi.fn(async () => []), + } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun(employeeIssue, buildMatch(policy)); + const awaitingDelegation = await dispatcher.advanceRun(run.id, policy); + expect(awaitingDelegation?.status).toBe("awaiting_delegation"); + + const queued = await dispatcher.resolveRunAction(run.id, "resume", "Use CTO.", policy, "cto"); + expect(queued?.status).toBe("queued"); + + const resumed = await dispatcher.advanceRun(run.id, policy); + expect(resumed?.status).toBe("waiting_for_target"); + expect(ensureIdentitySession).toHaveBeenCalledWith(expect.objectContaining({ + identityKey: "cto", + laneId: "lane-1", + })); + db.close(); + }); + it("launches a direct CTO employee session when the workflow targets CTO", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-cto-session-")); const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); @@ -756,6 +817,75 @@ describe("linearDispatcherService", () => { db.close(); }); + it("resumes awaiting-lane-choice runs after an operator picks a lane", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-lane-choice-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildDirectCtoSessionPolicy({ + laneSelection: "operator_prompt", + sessionReuse: "reuse_existing", + }); + const ensureIdentitySession = vi.fn(async () => ({ id: "session-cto-2" })); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => []) } as any, + workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { + ensureIdentitySession, + sendMessage: vi.fn(async () => ({ id: "message-1" })), + listSessions: vi.fn(async () => []), + } as any, + laneService: { + ensurePrimaryLane: vi.fn(async () => {}), + list: vi.fn(async () => [ + { id: "lane-1", laneType: "primary", name: "Primary" }, + { id: "lane-2", laneType: "worktree", name: "Existing lane" }, + ]), + } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend"], assigneeName: "CTO" }, buildMatch(policy)); + const awaitingLaneChoice = await dispatcher.advanceRun(run.id, policy); + + expect(awaitingLaneChoice?.status).toBe("awaiting_lane_choice"); + + const queued = await dispatcher.resolveRunAction(run.id, "resume", "Use the existing lane.", policy, undefined, "lane-2"); + expect(queued?.executionLaneId).toBe("lane-2"); + + await dispatcher.advanceRun(run.id, policy); + + expect(ensureIdentitySession).toHaveBeenCalledWith(expect.objectContaining({ + identityKey: "cto", + laneId: "lane-2", + })); + expect(dispatcher.listQueue()[0]).toEqual(expect.objectContaining({ + laneId: "lane-2", + sessionId: "session-cto-2", + })); + db.close(); + }); + it("keeps employee-session workflows waiting when a chat runtime ends and relinks to the active identity session", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-session-relink-")); const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); diff --git a/apps/desktop/src/main/services/cto/linearDispatcherService.ts b/apps/desktop/src/main/services/cto/linearDispatcherService.ts index a6be4542f..934df1c70 100644 --- a/apps/desktop/src/main/services/cto/linearDispatcherService.ts +++ b/apps/desktop/src/main/services/cto/linearDispatcherService.ts @@ -13,6 +13,7 @@ import type { LinearWorkflowRunDetail, LinearWorkflowRun, LinearWorkflowRunEvent, + LinearSyncResolutionAction, LinearWorkflowRunStatus, LinearWorkflowRunStep, LinearWorkflowStep, @@ -597,19 +598,14 @@ export function createLinearDispatcherService(args: { const selector = target.workerSelector; const workers = listWorkers(); if (!selector || selector.mode === "none") return null; - if (selector.mode === "id") { - const match = workers.find((entry) => entry.id === selector.value); - return match ? toResolvedWorker({ id: match.id, slug: match.slug, adapterType: match.adapterType as AdapterType, name: match.name }) : null; + let match: typeof workers[number] | undefined; + switch (selector.mode) { + case "id": match = workers.find((entry) => entry.id === selector.value); break; + case "slug": match = workers.find((entry) => entry.slug === selector.value); break; + case "capability": match = workers.find((entry) => entry.capabilities.includes(selector.value)); break; + default: return null; } - if (selector.mode === "slug") { - const match = workers.find((entry) => entry.slug === selector.value); - return match ? toResolvedWorker({ id: match.id, slug: match.slug, adapterType: match.adapterType as AdapterType, name: match.name }) : null; - } - if (selector.mode === "capability") { - const match = workers.find((entry) => entry.capabilities.includes(selector.value)); - return match ? toResolvedWorker({ id: match.id, slug: match.slug, adapterType: match.adapterType as AdapterType, name: match.name }) : null; - } - return null; + return match ? toResolvedWorker({ id: match.id, slug: match.slug, adapterType: match.adapterType as AdapterType, name: match.name }) : null; }; const resolveWorkerFromAssignee = (issue: NormalizedLinearIssue): ResolvedWorkerTarget | null => { @@ -750,6 +746,17 @@ export function createLinearDispatcherService(args: { return preferred; }; + const resolveOperatorLane = async (laneId: string): Promise => { + const trimmed = laneId.trim(); + if (!trimmed) throw new Error("Choose an execution lane before resuming this workflow."); + const lanes = await args.laneService.list({ includeArchived: false, includeStatus: false }); + const lane = lanes.find((entry) => entry.id === trimmed) ?? null; + if (!lane) { + throw new Error(`Execution lane not found: ${trimmed}`); + } + return lane.id; + }; + const buildDelegationPrompt = ( run: LinearWorkflowRun, workflow: LinearWorkflowDefinition, @@ -2341,10 +2348,11 @@ export function createLinearDispatcherService(args: { const resolveRunAction = async ( queueItemId: string, - action: "approve" | "reject" | "retry" | "complete", + action: LinearSyncResolutionAction, note: string | undefined, policy: LinearWorkflowConfig, employeeOverride?: string, + laneId?: string, ): Promise => { const row = getRunRow(queueItemId); const run = row ? toRun(row) : null; @@ -2360,6 +2368,7 @@ export function createLinearDispatcherService(args: { ? buildReviewContext(workflow, currentStep) : null; const trimmedOverride = employeeOverride?.trim(); + const trimmedLaneId = laneId?.trim(); const resolvedOverride = trimmedOverride?.length ? resolveOverrideWorker(policy, trimmedOverride) : null; if (trimmedOverride !== undefined) { if ( @@ -2376,8 +2385,65 @@ export function createLinearDispatcherService(args: { employeeOverride: trimmedOverride && trimmedOverride.length ? trimmedOverride : null, }); } + if (trimmedLaneId !== undefined) { + if (!activeTarget || activeTarget.type === "mission" || activeTarget.type === "review_gate") { + throw new Error("This workflow target does not use an execution lane."); + } + const resolvedLaneId = await resolveOperatorLane(trimmedLaneId); + if (run.executionLaneId !== resolvedLaneId) { + updateRun(run.id, { executionLaneId: resolvedLaneId }); + updateExecutionState(run.id, { + waitingFor: null, + stalledReason: null, + }); + appendEvent(run.id, "run.lane_selected", "queued", `Operator selected execution lane ${resolvedLaneId}.`, { + laneId: resolvedLaneId, + }); + } + } - if (action === "complete") { + if (action === "resume") { + if (run.status !== "awaiting_delegation" && run.status !== "awaiting_lane_choice") { + throw new Error("This workflow run is not paused for operator routing."); + } + const resolvedExecutionLaneId = getRunRow(run.id)?.execution_lane_id ?? null; + if (run.status === "awaiting_lane_choice" && !resolvedExecutionLaneId) { + throw new Error("Choose an execution lane before resuming this workflow."); + } + const launchTargetStepRow = + currentStepRow?.type === "launch_target" + ? currentStepRow + : getStepRows(run.id).find((entry) => entry.type === "launch_target") ?? null; + if (launchTargetStepRow) { + updateStep(launchTargetStepRow.id, { + status: "pending", + startedAt: null, + completedAt: null, + payload: null, + }); + } + updateRun(run.id, { + status: "queued", + retryAfter: null, + currentStepIndex: 0, + currentStepId: null, + latestReviewNote: note ?? run.latestReviewNote, + linkedMissionId: null, + linkedSessionId: null, + linkedWorkerRunId: null, + }); + updateExecutionState(run.id, { + activeStageIndex: 0, + activeTargetType: null, + downstreamPending: false, + waitingFor: null, + stalledReason: null, + }); + appendEvent(run.id, "run.resumed", "queued", note ?? "Queued with operator routing input.", { + employeeOverride: trimmedOverride && trimmedOverride.length ? trimmedOverride : null, + laneId: resolvedExecutionLaneId, + }); + } else if (action === "complete") { if (!workflow || !currentStep || !currentStepRow || currentStep.type !== "wait_for_target_status" || currentTargetStatus !== "explicit_completion") { throw new Error("This workflow run is not waiting on an explicit ADE completion signal."); } diff --git a/apps/desktop/src/main/services/cto/linearSyncService.test.ts b/apps/desktop/src/main/services/cto/linearSyncService.test.ts index bbd044b4a..c8a5230e1 100644 --- a/apps/desktop/src/main/services/cto/linearSyncService.test.ts +++ b/apps/desktop/src/main/services/cto/linearSyncService.test.ts @@ -519,7 +519,52 @@ describe("linearSyncService", () => { }); await service.resolveQueueItem({ queueItemId: "run-1", action: "retry", employeeOverride: "agent:worker-1" }); - expect(resolveRunAction).toHaveBeenCalledWith("run-1", "retry", undefined, policy, "agent:worker-1"); + expect(resolveRunAction).toHaveBeenCalledWith("run-1", "retry", undefined, policy, "agent:worker-1", undefined); + db.close(); + }); + + it("forwards laneId through queue resolution resumes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-lane-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const resolveRunAction = vi.fn(async () => ({ id: "run-1", status: "queued" })); + + const service = createLinearSyncService({ + db, + projectId: "project-1", + flowPolicyService: { getPolicy: () => policy } as any, + routingService: { + routeIssue: vi.fn(async () => ({ + workflowId: null, + workflowName: null, + workflow: null, + target: null, + reason: "No match", + candidates: [], + nextStepsPreview: [], + })), + } as any, + intakeService: { + fetchCandidates: vi.fn(async () => []), + persistSnapshot: vi.fn(() => {}), + } as any, + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + } as any, + dispatcherService: { + hasActiveRuns: vi.fn(() => false), + findActiveRunForIssue: vi.fn(() => null), + createRun: vi.fn(), + advanceRun: vi.fn(async () => null), + listActiveRuns: vi.fn(() => []), + listQueue: vi.fn(() => [{ id: "run-1", laneId: "lane-2" }]), + resolveRunAction, + getRunDetail: vi.fn(async () => null), + } as any, + autoStart: false, + }); + + await service.resolveQueueItem({ queueItemId: "run-1", action: "resume", laneId: "lane-2" }); + expect(resolveRunAction).toHaveBeenCalledWith("run-1", "resume", undefined, policy, undefined, "lane-2"); db.close(); }); }); diff --git a/apps/desktop/src/main/services/cto/linearSyncService.ts b/apps/desktop/src/main/services/cto/linearSyncService.ts index a13143fe7..c2a06ca07 100644 --- a/apps/desktop/src/main/services/cto/linearSyncService.ts +++ b/apps/desktop/src/main/services/cto/linearSyncService.ts @@ -515,6 +515,7 @@ export function createLinearSyncService(args: { input.note, policy, input.employeeOverride, + input.laneId, ); if (!run) return null; if (run.status !== "completed" && run.status !== "failed" && run.status !== "cancelled") { diff --git a/apps/desktop/src/main/services/cto/linearWorkflowFileService.ts b/apps/desktop/src/main/services/cto/linearWorkflowFileService.ts index 7ec437699..5e3bc5bad 100644 --- a/apps/desktop/src/main/services/cto/linearWorkflowFileService.ts +++ b/apps/desktop/src/main/services/cto/linearWorkflowFileService.ts @@ -43,16 +43,13 @@ function slugify(value: string): string { function normalizeIntake(input: unknown): LinearWorkflowIntake { const source = isRecord(input) ? input : {}; + const projectSlugs = ensureStringArray(source.projectSlugs); + const activeStateTypes = ensureStringArray(source.activeStateTypes); + const terminalStateTypes = ensureStringArray(source.terminalStateTypes); return { - ...(ensureStringArray(source.projectSlugs).length - ? { projectSlugs: ensureStringArray(source.projectSlugs) } - : {}), - ...(ensureStringArray(source.activeStateTypes).length - ? { activeStateTypes: ensureStringArray(source.activeStateTypes) } - : { activeStateTypes: [...DEFAULT_ACTIVE_STATE_TYPES] }), - ...(ensureStringArray(source.terminalStateTypes).length - ? { terminalStateTypes: ensureStringArray(source.terminalStateTypes) } - : { terminalStateTypes: [...DEFAULT_TERMINAL_STATE_TYPES] }), + ...(projectSlugs.length ? { projectSlugs } : {}), + activeStateTypes: activeStateTypes.length ? activeStateTypes : [...DEFAULT_ACTIVE_STATE_TYPES], + terminalStateTypes: terminalStateTypes.length ? terminalStateTypes : [...DEFAULT_TERMINAL_STATE_TYPES], }; } diff --git a/apps/desktop/src/main/services/cto/workerHeartbeatService.ts b/apps/desktop/src/main/services/cto/workerHeartbeatService.ts index ca0931056..18c7aa2a0 100644 --- a/apps/desktop/src/main/services/cto/workerHeartbeatService.ts +++ b/apps/desktop/src/main/services/cto/workerHeartbeatService.ts @@ -570,6 +570,7 @@ export function createWorkerHeartbeatService(args: WorkerHeartbeatServiceArgs) { }, }); const sanitizedOutput = redactSecrets(runtimeResult.outputText); + const outputPreview = clipText(sanitizedOutput, 600); updateRunFields(run.id, { status: runStatus, finished_at: finishedAt, @@ -581,7 +582,7 @@ export function createWorkerHeartbeatService(args: WorkerHeartbeatServiceArgs) { ok: runtimeResult.ok, statusCode: runtimeResult.statusCode ?? null, heartbeatOk, - outputPreview: clipText(sanitizedOutput, 600), + outputPreview, provider: runtimeResult.provider ?? null, sessionId: runtimeResult.sessionId ?? null, }), @@ -598,7 +599,7 @@ export function createWorkerHeartbeatService(args: WorkerHeartbeatServiceArgs) { runtimeResult.effectiveSurface !== "process" && runtimeResult.effectiveSurface !== "openclaw_webhook" ? `Resumed via ${runtimeResult.effectiveSurface}.` : "", - heartbeatOk ? "No action required." : clipText(sanitizedOutput, 600) || "No output.", + heartbeatOk ? "No action required." : outputPreview || "No output.", ] .filter((entry) => entry.length > 0) .join(" "), @@ -620,7 +621,7 @@ export function createWorkerHeartbeatService(args: WorkerHeartbeatServiceArgs) { summary: clipText( [ runtimeResult.ok ? "Worker run completed." : "Worker run failed.", - heartbeatOk ? "No action required." : clipText(sanitizedOutput, 600) || "No output.", + heartbeatOk ? "No action required." : outputPreview || "No output.", ].join(" "), 360 ), diff --git a/apps/desktop/src/main/services/files/fileService.test.ts b/apps/desktop/src/main/services/files/fileService.test.ts new file mode 100644 index 000000000..e78067379 --- /dev/null +++ b/apps/desktop/src/main/services/files/fileService.test.ts @@ -0,0 +1,48 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createFileService } from "./fileService"; + +describe("fileService", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("preserves non-escape filesystem errors while resolving workspace paths", () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-file-service-")); + const rootReal = fs.realpathSync(rootPath); + const blockedPath = path.join(rootReal, "blocked"); + const permissionError = Object.assign(new Error("permission denied"), { code: "EACCES" as const }); + const originalLstatSync = fs.lstatSync.bind(fs); + + const laneService = { + resolveWorkspaceById: vi.fn(() => ({ + id: "workspace-1", + laneId: "lane-1", + rootPath, + })), + getFilesWorkspaces: vi.fn(() => []), + } as any; + + const service = createFileService({ laneService }); + const spy = vi.spyOn(fs, "lstatSync").mockImplementation(((filePath: fs.PathLike) => { + if (String(filePath) === blockedPath) { + throw permissionError; + } + return originalLstatSync(filePath); + }) as typeof fs.lstatSync); + + try { + expect(() => + service.readFile({ + workspaceId: "workspace-1", + path: "blocked/child.txt", + }) + ).toThrow(permissionError); + } finally { + spy.mockRestore(); + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/services/files/fileService.ts b/apps/desktop/src/main/services/files/fileService.ts index 875742db9..9c99867f4 100644 --- a/apps/desktop/src/main/services/files/fileService.ts +++ b/apps/desktop/src/main/services/files/fileService.ts @@ -1,6 +1,5 @@ import fs from "node:fs"; import path from "node:path"; -import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; import type { FileChangeEvent, @@ -24,7 +23,15 @@ import type { } from "../../../shared/types"; import type { createLaneService } from "../lanes/laneService"; import { runGit } from "../git/git"; -import { hasNullByte, isWithinDir, normalizeRelative } from "../shared/utils"; +import { + hasNullByte, + normalizeRelative, + resolvePathWithinRoot, + secureMkdirWithinRoot, + secureRenameWithinRoot, + secureWriteFileWithinRoot, + secureWriteTextAtomicWithinRoot, +} from "../shared/utils"; import { createFileWatcherService } from "./fileWatcherService"; import { createFileSearchIndexService } from "./fileSearchIndexService"; @@ -122,11 +129,21 @@ async function runGitCheckIgnoreBatch(args: { cwd: string; paths: string[]; time }); } -function ensureSafePath(rootPath: string, relPath: string): { absPath: string; normalizedRel: string } { +function ensureSafePath( + rootPath: string, + relPath: string, + opts: { allowMissing?: boolean } = {}, +): { absPath: string; normalizedRel: string } { const normalizedRel = normalizeRelative(relPath); - const absPath = path.normalize(path.join(rootPath, normalizedRel)); - if (!isWithinDir(rootPath, absPath)) { - throw new Error("Refusing to access path outside workspace"); + const joinedPath = path.normalize(path.join(rootPath, normalizedRel)); + let absPath: string; + try { + absPath = resolvePathWithinRoot(rootPath, joinedPath, { allowMissing: opts.allowMissing }); + } catch (error) { + if (error instanceof Error && error.message === "Path escapes root") { + throw new Error("Refusing to access path outside workspace"); + } + throw error; } if (containsDotGit(absPath)) { throw new Error("Refusing to access .git internals"); @@ -134,29 +151,17 @@ function ensureSafePath(rootPath: string, relPath: string): { absPath: string; n return { absPath, normalizedRel }; } -function isWorkspaceRootRelativePath(normalizedRel: string): boolean { - return normalizedRel === "" || normalizedRel === "."; +function assertMutablePathAllowed(rootPath: string, relPath: string): string { + const normalizedRel = normalizeRelative(relPath); + const candidatePath = path.join(rootPath, normalizedRel); + if (containsDotGit(candidatePath)) { + throw new Error("Refusing to access .git internals"); + } + return normalizedRel; } -function writeTextAtomicAbs(absPath: string, text: string): void { - fs.mkdirSync(path.dirname(absPath), { recursive: true }); - const tmp = `${absPath}.tmp-${randomUUID()}`; - fs.writeFileSync(tmp, text, "utf8"); - try { - fs.renameSync(tmp, absPath); - } catch (err: any) { - try { - fs.copyFileSync(tmp, absPath); - fs.unlinkSync(tmp); - } catch { - try { - fs.unlinkSync(tmp); - } catch { - // ignore - } - throw err; - } - } +function isWorkspaceRootRelativePath(normalizedRel: string): boolean { + return normalizedRel === "" || normalizedRel === "."; } function inferDirectoryStatus(statusMap: Map, relPath: string): FileTreeChangeStatus { @@ -401,8 +406,8 @@ export function createFileService({ return { writeTextAtomic({ laneId, relPath, text }: { laneId: string; relPath: string; text: string }): void { const { worktreePath } = laneService.getLaneBaseAndBranch(laneId); - const { absPath } = ensureSafePath(worktreePath, relPath); - writeTextAtomicAbs(absPath, text); + assertMutablePathAllowed(worktreePath, relPath); + secureWriteTextAtomicWithinRoot(worktreePath, relPath, text); invalidateGitStatusCache(worktreePath); if (onLaneWorktreeMutation) { onLaneWorktreeMutation({ @@ -455,8 +460,8 @@ export function createFileService({ writeWorkspaceText(args: FilesWriteTextArgs): void { const workspace = resolveWorkspace(args.workspaceId); - const { absPath, normalizedRel } = ensureSafePath(workspace.rootPath, args.path); - writeTextAtomicAbs(absPath, args.text); + const normalizedRel = assertMutablePathAllowed(workspace.rootPath, args.path); + secureWriteTextAtomicWithinRoot(workspace.rootPath, args.path, args.text); invalidateGitStatusCache(workspace.rootPath); if (normalizedRel === ".gitignore") { clearIgnoreCacheForRoot(workspace.rootPath); @@ -473,10 +478,10 @@ export function createFileService({ createFile(args: FilesCreateFileArgs): void { const workspace = resolveWorkspace(args.workspaceId); - const { absPath, normalizedRel } = ensureSafePath(workspace.rootPath, args.path); - fs.mkdirSync(path.dirname(absPath), { recursive: true }); + const normalizedRel = assertMutablePathAllowed(workspace.rootPath, args.path); + const { absPath } = ensureSafePath(workspace.rootPath, args.path, { allowMissing: true }); if (!fs.existsSync(absPath)) { - fs.writeFileSync(absPath, args.content ?? "", "utf8"); + secureWriteFileWithinRoot(workspace.rootPath, args.path, args.content ?? "", "utf8"); } invalidateGitStatusCache(workspace.rootPath); indexService.onFileChanged({ @@ -491,8 +496,8 @@ export function createFileService({ createDirectory(args: FilesCreateDirectoryArgs): void { const workspace = resolveWorkspace(args.workspaceId); - const { absPath } = ensureSafePath(workspace.rootPath, args.path); - fs.mkdirSync(absPath, { recursive: true }); + assertMutablePathAllowed(workspace.rootPath, args.path); + secureMkdirWithinRoot(workspace.rootPath, args.path); invalidateGitStatusCache(workspace.rootPath); indexService.invalidateWorkspace(args.workspaceId); emitLaneMutation(args.workspaceId, "directory_create"); @@ -500,10 +505,9 @@ export function createFileService({ rename(args: FilesRenameArgs): void { const workspace = resolveWorkspace(args.workspaceId); - const { absPath: oldAbs, normalizedRel: oldRel } = ensureSafePath(workspace.rootPath, args.oldPath); - const { absPath: newAbs, normalizedRel: newRel } = ensureSafePath(workspace.rootPath, args.newPath); - fs.mkdirSync(path.dirname(newAbs), { recursive: true }); - fs.renameSync(oldAbs, newAbs); + const oldRel = assertMutablePathAllowed(workspace.rootPath, args.oldPath); + const newRel = assertMutablePathAllowed(workspace.rootPath, args.newPath); + secureRenameWithinRoot(workspace.rootPath, args.oldPath, args.newPath); invalidateGitStatusCache(workspace.rootPath); if (oldRel === ".gitignore" || newRel === ".gitignore") { clearIgnoreCacheForRoot(workspace.rootPath); @@ -521,7 +525,7 @@ export function createFileService({ deletePath(args: FilesDeleteArgs): void { const workspace = resolveWorkspace(args.workspaceId); - const { absPath, normalizedRel } = ensureSafePath(workspace.rootPath, args.path); + const { absPath, normalizedRel } = ensureSafePath(workspace.rootPath, args.path, { allowMissing: true }); if (isWorkspaceRootRelativePath(normalizedRel)) { throw new Error("Refusing to delete workspace root."); } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 8cec19b8d..7b6d3e76f 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -3,6 +3,7 @@ import type { createAutoUpdateService } from "../updates/autoUpdateService"; import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; +import type { Server as NetServer } from "node:net"; import path from "node:path"; import { IPC } from "../../../shared/ipc"; import { getModelById } from "../../../shared/modelRegistry"; @@ -92,6 +93,7 @@ import type { CreateIntegrationPrResult, CreateQueuePrsArgs, CreateQueuePrsResult, + ReorderQueuePrsArgs, CommitIntegrationArgs, CleanupIntegrationWorkflowArgs, CleanupIntegrationWorkflowResult, @@ -198,26 +200,10 @@ import type { MergeSimulationArgs, MergeSimulationResult, OperationRecord, - PackExport, - PackDeltaDigestArgs, - PackDeltaDigestV1, - PackEvent, - PackHeadVersion, - PackSummary, - PackVersion, - PackVersionSummary, - Checkpoint, - GetLaneExportArgs, - GetProjectExportArgs, - GetConflictExportArgs, - ContextExportLevel, - GetMissionPackArgs, - RefreshMissionPackArgs, ContextGenerateDocsArgs, ContextGenerateDocsResult, ContextOpenDocArgs, ContextStatus, - ListPackEventsSinceArgs, ProcessActionArgs, ProcessDefinition, ProcessRuntime, @@ -241,7 +227,6 @@ import type { RebaseStartArgs, RebaseStartResult, RebaseSuggestion, - RebaseRunEventPayload, AutoRebaseLaneStatus, RiskMatrixEntry, PrepareConflictProposalArgs, @@ -272,7 +257,6 @@ import type { MissionIntervention, MissionArtifact, MissionStep, - MissionStatus, MissionSummary, PhaseCard, PhaseProfile, @@ -291,6 +275,8 @@ import type { ImportPhaseProfileArgs, MissionPhaseConfiguration, MissionDashboardSnapshot, + GetFullMissionViewArgs, + FullMissionViewResult, MissionPreflightRequest, MissionPreflightResult, GetMissionRunViewArgs, @@ -325,6 +311,12 @@ import type { AiApiKeyVerificationResult, AiConfig, AiSettingsStatus, + SyncDesktopConnectionDraft, + SyncDeviceRecord, + SyncDeviceRuntimeState, + SyncPeerDeviceType, + SyncRoleSnapshot, + SyncTransferReadiness, CtoGetStateArgs, CtoEnsureSessionArgs, CtoUpdateIdentityArgs, @@ -422,6 +414,7 @@ import type { CtoStartLinearOAuthResult, LinearConnectionStatus, CtoSetLinearTokenArgs, + CtoSetLinearOAuthClientArgs, CtoFlowPolicyRevision, CtoSaveFlowPolicyArgs, CtoRollbackFlowPolicyRevisionArgs, @@ -457,7 +450,6 @@ import type { ComputerUseArtifactReviewArgs, ComputerUseArtifactRouteArgs, ComputerUseArtifactView, - ComputerUseEventPayload, ComputerUseOwnerSnapshot, ComputerUseOwnerSnapshotArgs, ComputerUseSettingsSnapshot, @@ -465,6 +457,10 @@ import type { LaneOverlayOverrides, LaneTemplate, PortLease, + UpdateOAuthRedirectConfigArgs, + GenerateRedirectUrisArgs, + EncodeOAuthStateArgs, + DecodeOAuthStateArgs, } from "../../../shared/types"; import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; @@ -550,7 +546,7 @@ import type { createSyncHostService } from "../sync/syncHostService"; import type { createSyncService } from "../sync/syncService"; import type { AdeProjectService } from "../projects/adeProjectService"; import type { ConfigReloadService } from "../projects/configReloadService"; -import { getErrorMessage, isRecord, isWithinDir, nowIso, toMemoryEntryDto, toOptionalString } from "../shared/utils"; +import { getErrorMessage, isRecord, nowIso, resolvePathWithinRoot, toMemoryEntryDto } from "../shared/utils"; export type AppContext = { db: AdeDb; @@ -633,7 +629,7 @@ export type AppContext = { configReloadService?: ConfigReloadService | null; syncHostService?: ReturnType | null; syncService?: ReturnType | null; - mcpSocketServer?: import("node:net").Server; + mcpSocketServer?: NetServer; mcpSocketPath?: string; autoUpdateService?: ReturnType | null; }; @@ -1342,6 +1338,16 @@ function buildPrAiDisplayText(context: PrAiResolutionContext): string { return "Resolve this PR with AI."; } +function getAllowedDirs(getCtx: () => AppContext): string[] { + const projectRoot = getCtx().project.rootPath; + return [ + projectRoot, + app.getPath("downloads"), + app.getPath("documents"), + app.getPath("temp"), + ]; +} + export function registerIpc({ getCtx, switchProjectFromDialog, @@ -1688,14 +1694,16 @@ export function registerIpc({ const normalized = path.resolve(raw); // Validate the path is within known safe directories only. // Reject requests to reveal arbitrary paths (e.g. ~/.ssh, /etc, /System). - const projectRoot = getCtx().project.rootPath; - const allowedDirs = [ - projectRoot, - app.getPath("downloads"), - app.getPath("documents"), - app.getPath("temp"), - ]; - if (!allowedDirs.some((dir) => isWithinDir(dir, normalized))) { + const allowedDirs = getAllowedDirs(getCtx); + const allowed = allowedDirs.some((dir) => { + try { + resolvePathWithinRoot(dir, normalized); + return true; + } catch { + return false; + } + }); + if (!allowed) { throw new Error("Path is outside allowed directories."); } shell.showItemInFolder(normalized); @@ -1720,10 +1728,32 @@ export function registerIpc({ throw new Error("Unsupported editor target."); } const rootPath = path.resolve(rootRaw); - const targetPath = relRaw ? path.resolve(rootPath, relRaw) : rootPath; - const relToRoot = path.relative(rootPath, targetPath); - if (relToRoot.startsWith("..") || path.isAbsolute(relToRoot)) { - throw new Error("relativePath escapes rootPath."); + + // Validate the renderer-supplied root is a known workspace root + // (same pattern as appRevealPath). + const allowedRoots = getAllowedDirs(getCtx); + const rootAllowed = allowedRoots.some((dir) => { + try { + resolvePathWithinRoot(dir, rootPath); + return true; + } catch { + return false; + } + }); + if (!rootAllowed) { + throw new Error("rootPath is outside allowed directories."); + } + + let targetPath: string; + try { + const candidatePath = relRaw ? path.resolve(rootPath, relRaw) : rootPath; + targetPath = resolvePathWithinRoot(rootPath, candidatePath, { allowMissing: true }); + } catch (resolveError: unknown) { + // Only translate containment errors; rethrow unexpected failures. + if (resolveError instanceof Error && resolveError.message === "Path escapes root") { + throw new Error("relativePath escapes rootPath."); + } + throw resolveError; } if (target === "finder") { @@ -2006,7 +2036,7 @@ export function registerIpc({ }); }); - ipcMain.handle(IPC.syncGetStatus, async (): Promise => { + ipcMain.handle(IPC.syncGetStatus, async (): Promise => { const ctx = getCtx(); if (!ctx.syncService) { throw new Error("Sync service is not available."); @@ -2014,7 +2044,7 @@ export function registerIpc({ return await ctx.syncService.getStatus(); }); - ipcMain.handle(IPC.syncListDevices, async (): Promise => { + ipcMain.handle(IPC.syncListDevices, async (): Promise => { const ctx = getCtx(); if (!ctx.syncService) { throw new Error("Sync service is not available."); @@ -2026,8 +2056,8 @@ export function registerIpc({ IPC.syncUpdateLocalDevice, async ( _event, - arg: { name?: string; deviceType?: import("../../../shared/types").SyncPeerDeviceType }, - ): Promise => { + arg: { name?: string; deviceType?: SyncPeerDeviceType }, + ): Promise => { const ctx = getCtx(); if (!ctx.syncService) { throw new Error("Sync service is not available."); @@ -2041,7 +2071,7 @@ export function registerIpc({ ipcMain.handle( IPC.syncConnectToBrain, - async (_event, arg: import("../../../shared/types").SyncDesktopConnectionDraft): Promise => { + async (_event, arg: SyncDesktopConnectionDraft): Promise => { const ctx = getCtx(); if (!ctx.syncService) { throw new Error("Sync service is not available."); @@ -2050,7 +2080,7 @@ export function registerIpc({ }, ); - ipcMain.handle(IPC.syncDisconnectFromBrain, async (): Promise => { + ipcMain.handle(IPC.syncDisconnectFromBrain, async (): Promise => { const ctx = getCtx(); if (!ctx.syncService) { throw new Error("Sync service is not available."); @@ -2058,7 +2088,7 @@ export function registerIpc({ return await ctx.syncService.disconnectFromBrain(); }); - ipcMain.handle(IPC.syncForgetDevice, async (_event, arg: { deviceId: string }): Promise => { + ipcMain.handle(IPC.syncForgetDevice, async (_event, arg: { deviceId: string }): Promise => { const ctx = getCtx(); if (!ctx.syncService) { throw new Error("Sync service is not available."); @@ -2066,7 +2096,7 @@ export function registerIpc({ return await ctx.syncService.forgetDevice(typeof arg?.deviceId === "string" ? arg.deviceId : ""); }); - ipcMain.handle(IPC.syncGetTransferReadiness, async (): Promise => { + ipcMain.handle(IPC.syncGetTransferReadiness, async (): Promise => { const ctx = getCtx(); if (!ctx.syncService) { throw new Error("Sync service is not available."); @@ -2074,7 +2104,7 @@ export function registerIpc({ return await ctx.syncService.getTransferReadiness(); }); - ipcMain.handle(IPC.syncTransferBrainToLocal, async (): Promise => { + ipcMain.handle(IPC.syncTransferBrainToLocal, async (): Promise => { const ctx = getCtx(); if (!ctx.syncService) { throw new Error("Sync service is not available."); @@ -2261,6 +2291,7 @@ export function registerIpc({ laneId: arg?.laneId ?? null, reviewProfileOverride: arg?.reviewProfileOverride ?? null, verboseTrace: Boolean(arg?.verboseTrace), + dryRun: Boolean(arg?.dryRun), }); }); @@ -2386,19 +2417,19 @@ export function registerIpc({ ipcMain.handle( IPC.missionsGetFullMissionView, - async (_event, arg: import("../../../shared/types").GetFullMissionViewArgs): Promise => { + async (_event, arg: GetFullMissionViewArgs): Promise => { const ctx = getCtx(); const missionId = typeof arg?.missionId === "string" ? arg.missionId.trim() : ""; if (!missionId) return { mission: null, runGraph: null, artifacts: [], checkpoints: [], dashboard: null }; - let dashboard: import("../../../shared/types").MissionDashboardSnapshot | null = null; + let dashboard: MissionDashboardSnapshot | null = null; try { dashboard = ctx.missionService.getDashboard(); } catch { /* best-effort */ } const mission = await ctx.missionService.get(missionId); - let runGraph: import("../../../shared/types").OrchestratorRunGraph | null = null; - let artifacts: import("../../../shared/types").OrchestratorArtifact[] = []; - let checkpoints: import("../../../shared/types").OrchestratorWorkerCheckpoint[] = []; + let runGraph: OrchestratorRunGraph | null = null; + let artifacts: OrchestratorArtifact[] = []; + let checkpoints: OrchestratorWorkerCheckpoint[] = []; const runs = await ctx.orchestratorService.listRuns({ missionId, limit: 20 }); const activeStatuses = new Set(["active", "bootstrapping", "queued", "paused"]); @@ -2432,14 +2463,7 @@ export function registerIpc({ const ctx = getCtx(); const prompt = typeof arg?.prompt === "string" ? arg.prompt.trim() : ""; if (!prompt.length) throw new Error("Mission prompt is required."); - const title = - typeof arg?.title === "string" && arg.title.trim().length > 0 - ? arg.title.trim() - : prompt.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0) ?? "Mission"; const plannerEngine = arg?.plannerEngine ?? "auto"; - const laneId = typeof arg?.laneId === "string" && arg.laneId.trim().length > 0 ? arg.laneId.trim() : null; - const executionMode = arg?.executionMode ?? "local"; - const targetMachineId = typeof arg?.targetMachineId === "string" ? arg.targetMachineId.trim() || null : null; const autostart = arg?.autostart !== false; const runMode = arg?.launchMode === "manual" ? "manual" : "autopilot"; const defaultExecutorKind: OrchestratorExecutorKind = runMode === "manual" @@ -2462,7 +2486,7 @@ export function registerIpc({ event: "mission_start", reason: `missions_create_autostart:${created.id}` }); - const launch = await ctx.aiOrchestratorService.startMissionRun({ + await ctx.aiOrchestratorService.startMissionRun({ missionId: created.id, runMode, autopilotOwnerId: "missions-autopilot", @@ -3479,9 +3503,9 @@ export function registerIpc({ const parseOAuthUpdateConfigArgs = ( value: unknown, - ): import("../../../shared/types").UpdateOAuthRedirectConfigArgs => { + ): UpdateOAuthRedirectConfigArgs => { const record = requireRecord(value, "OAuth config update"); - const updates: import("../../../shared/types").UpdateOAuthRedirectConfigArgs = {}; + const updates: UpdateOAuthRedirectConfigArgs = {}; if ("enabled" in record) { if (typeof record.enabled !== "boolean") { @@ -3515,7 +3539,7 @@ export function registerIpc({ const parseGenerateRedirectUrisArgs = ( value: unknown, - ): import("../../../shared/types").GenerateRedirectUrisArgs => { + ): GenerateRedirectUrisArgs => { if (value === undefined) return {}; const record = requireRecord(value, "OAuth redirect URI request"); if (record.provider === undefined) return {}; @@ -3527,7 +3551,7 @@ export function registerIpc({ const parseEncodeOAuthStateArgs = ( value: unknown, - ): import("../../../shared/types").EncodeOAuthStateArgs => { + ): EncodeOAuthStateArgs => { const record = requireRecord(value, "OAuth state encode request"); if (typeof record.laneId !== "string" || !record.laneId.trim()) { throw new Error("OAuth state encode laneId must be a non-empty string"); @@ -3540,7 +3564,7 @@ export function registerIpc({ const parseDecodeOAuthStateArgs = ( value: unknown, - ): import("../../../shared/types").DecodeOAuthStateArgs => { + ): DecodeOAuthStateArgs => { const record = requireRecord(value, "OAuth state decode request"); if (typeof record.encodedState !== "string" || !record.encodedState) { throw new Error("OAuth state decode encodedState must be a non-empty string"); @@ -4587,7 +4611,7 @@ export function registerIpc({ ipcMain.handle(IPC.prsCancelQueueAutomation, async (_event, arg) => getCtx().queueLandingService.cancelQueue(arg.queueId)); - ipcMain.handle(IPC.prsReorderQueue, async (_event, arg: import("../../../shared/types").ReorderQueuePrsArgs): Promise => { + ipcMain.handle(IPC.prsReorderQueue, async (_event, arg: ReorderQueuePrsArgs): Promise => { await getCtx().prService.reorderQueuePrs(arg); }); @@ -5661,7 +5685,7 @@ export function registerIpc({ }; }); - ipcMain.handle(IPC.ctoSetLinearOAuthClient, async (_event, arg: import("../../../shared/types").CtoSetLinearOAuthClientArgs): Promise => { + ipcMain.handle(IPC.ctoSetLinearOAuthClient, async (_event, arg: CtoSetLinearOAuthClientArgs): Promise => { const ctx = getCtx(); if (!ctx.linearCredentialService) throw new Error("Linear credential service is not available."); ctx.linearCredentialService.setOAuthClientCredentials({ diff --git a/apps/desktop/src/main/services/lanes/laneEnvironmentService.test.ts b/apps/desktop/src/main/services/lanes/laneEnvironmentService.test.ts index 2790a849f..5fb27aba3 100644 --- a/apps/desktop/src/main/services/lanes/laneEnvironmentService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneEnvironmentService.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { createLaneEnvironmentService } from "./laneEnvironmentService"; import type { LaneEnvInitConfig, LaneOverlayOverrides, LaneSummary } from "../../../shared/types"; @@ -113,6 +113,27 @@ describe("laneEnvironmentService", () => { expect(result.overallStatus).toBe("completed"); }); + + it("fails when an env file source escapes the project root", async () => { + const outsidePath = path.join(path.dirname(projectRoot), `lane-env-outside-${Date.now()}.env`); + fs.writeFileSync(outsidePath, "SECRET=1\n", "utf8"); + + const worktreePath = path.join(projectRoot, "worktree-escape"); + fs.mkdirSync(worktreePath, { recursive: true }); + + const lane = makeLane({ id: "lane-escape", name: "escape-lane", worktreePath }); + const config: LaneEnvInitConfig = { + envFiles: [{ source: `../${path.basename(outsidePath)}`, dest: ".env" }] + }; + + try { + const service = createService(); + const result = await service.initLaneEnvironment(lane, config, {}); + expect(result.overallStatus).toBe("failed"); + } finally { + fs.rmSync(outsidePath, { force: true }); + } + }); }); describe("multi-lane collision", () => { @@ -200,6 +221,24 @@ describe("laneEnvironmentService", () => { }); }); + describe("dependency installs", () => { + it("fails when a dependency cwd escapes the worktree", async () => { + const worktreePath = path.join(projectRoot, "wt-deps"); + fs.mkdirSync(worktreePath, { recursive: true }); + fs.mkdirSync(path.join(path.dirname(worktreePath), "outside-deps"), { recursive: true }); + + const lane = makeLane({ id: "l-deps", name: "deps-test", worktreePath }); + const config: LaneEnvInitConfig = { + dependencies: [{ command: ["npm", "--version"], cwd: "../outside-deps" }] + }; + + const service = createService(); + const result = await service.initLaneEnvironment(lane, config, {}); + + expect(result.overallStatus).toBe("failed"); + }); + }); + describe("resolveEnvInitConfig", () => { it("returns undefined when both inputs are undefined", () => { const service = createService(); @@ -278,13 +317,94 @@ describe("laneEnvironmentService", () => { expect(fs.readFileSync(dockerLogPath, "utf-8").trim().split("\n")).toEqual([ "compose", "-f", - path.join(projectRoot, "infra/compose.yaml"), + fs.realpathSync(path.join(projectRoot, "infra/compose.yaml")), "-p", "lane-lane-clean", "down", "--remove-orphans" ]); }); + + it("skips docker teardown when the compose path escapes the project root", async () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-env-bin-")); + const dockerLogPath = path.join(projectRoot, "docker-args.log"); + const dockerPath = path.join(binDir, "docker"); + fs.writeFileSync( + dockerPath, + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$ADE_TEST_DOCKER_LOG\"\n", + { mode: 0o755 } + ); + process.env.PATH = `${binDir}:${originalPath ?? ""}`; + process.env.ADE_TEST_DOCKER_LOG = dockerLogPath; + + const outsideCompose = path.join(path.dirname(projectRoot), `compose-${Date.now()}.yaml`); + fs.writeFileSync(outsideCompose, "services: {}\n"); + + const worktreePath = path.join(projectRoot, "wt-cleanup-escape"); + fs.mkdirSync(worktreePath, { recursive: true }); + + try { + const lane = makeLane({ id: "lane-clean-escape", name: "cleanup lane", worktreePath }); + const service = createService(); + await service.cleanupLaneEnvironment(lane, { + docker: { composePath: `../${path.basename(outsideCompose)}`, projectPrefix: "lane" } + }); + + expect(fs.existsSync(dockerLogPath)).toBe(false); + } finally { + fs.rmSync(outsideCompose, { force: true }); + } + }); + + it("still runs docker teardown when compose path validation hits a non-escape error", async () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-env-bin-")); + const dockerLogPath = path.join(projectRoot, "docker-args.log"); + const dockerPath = path.join(binDir, "docker"); + fs.writeFileSync( + dockerPath, + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$ADE_TEST_DOCKER_LOG\"\n", + { mode: 0o755 } + ); + process.env.PATH = `${binDir}:${originalPath ?? ""}`; + process.env.ADE_TEST_DOCKER_LOG = dockerLogPath; + + const composeDir = path.join(projectRoot, "infra"); + fs.mkdirSync(composeDir, { recursive: true }); + const composePath = path.join(composeDir, "compose.yaml"); + fs.writeFileSync(composePath, "services: {}\n"); + + const worktreePath = path.join(projectRoot, "wt-cleanup-permission"); + fs.mkdirSync(worktreePath, { recursive: true }); + + const originalLstatSync = fs.lstatSync.bind(fs); + const permissionError = Object.assign(new Error("permission denied"), { code: "EACCES" as const }); + const spy = vi.spyOn(fs, "lstatSync").mockImplementation(((filePath: fs.PathLike) => { + if (String(filePath) === composePath) { + throw permissionError; + } + return originalLstatSync(filePath); + }) as typeof fs.lstatSync); + + try { + const lane = makeLane({ id: "lane-clean-permission", name: "cleanup lane", worktreePath }); + const service = createService(); + await service.cleanupLaneEnvironment(lane, { + docker: { composePath: "infra/compose.yaml", projectPrefix: "lane" } + }); + + expect(fs.readFileSync(dockerLogPath, "utf-8").trim().split("\n")).toEqual([ + "compose", + "-f", + fs.realpathSync(composePath), + "-p", + "lane-lane-clean-permission", + "down", + "--remove-orphans" + ]); + } finally { + spy.mockRestore(); + } + }); }); describe("progress events", () => { diff --git a/apps/desktop/src/main/services/lanes/laneEnvironmentService.ts b/apps/desktop/src/main/services/lanes/laneEnvironmentService.ts index f10d88cd4..3803cb8c6 100644 --- a/apps/desktop/src/main/services/lanes/laneEnvironmentService.ts +++ b/apps/desktop/src/main/services/lanes/laneEnvironmentService.ts @@ -17,7 +17,80 @@ import type { } from "../../../shared/types"; import type { Logger } from "../logging/logger"; -import { isWithinDir } from "../shared/utils"; +import { + resolvePathWithinRoot, + secureCopyPathIntoRoot, + secureWriteFileWithinRoot, +} from "../shared/utils"; + +/** Resolve a relative path against `root` and throw if it escapes. Logs a warning on escape. */ +function resolveCheckedPath( + root: string, + relative: string, + logger: Logger, + logTag: string, + logContext: Record, + opts: { allowMissing?: boolean } = {}, +): string { + try { + return resolvePathWithinRoot(root, relative, opts); + } catch (err) { + if (err instanceof Error && err.message === "Path escapes root") { + logger.warn(logTag, logContext); + throw new Error("Path escapes allowed directory"); + } + throw err; + } +} + +function isPathEscapeError(error: unknown): boolean { + return error instanceof Error && error.message === "Path escapes allowed directory"; +} + +function secureWriteTextFile( + root: string, + relative: string, + content: string, + logger: Logger, + logTag: string, + logContext: Record, +): void { + try { + secureWriteFileWithinRoot(root, relative, content, "utf8"); + } catch (error) { + if (error instanceof Error && error.message === "Path escapes root") { + logger.warn(logTag, logContext); + throw new Error("Path escapes allowed directory"); + } + throw error; + } +} + +function secureCopyPath( + sourceRoot: string, + sourceRelative: string, + destRoot: string, + destRelative: string, + logger: Logger, + sourceLogTag: string, + sourceLogContext: Record, + destLogTag: string, + destLogContext: Record, +): void { + const sourcePath = resolveCheckedPath(sourceRoot, sourceRelative, logger, sourceLogTag, sourceLogContext, { allowMissing: true }); + if (!fs.existsSync(sourcePath)) { + return; + } + try { + secureCopyPathIntoRoot(destRoot, destRelative, sourcePath); + } catch (error) { + if (error instanceof Error && error.message === "Path escapes root") { + logger.warn(destLogTag, destLogContext); + throw new Error("Path escapes allowed directory"); + } + throw error; + } +} function cloneDockerConfig(config: LaneDockerConfig): LaneDockerConfig { return config.services @@ -173,19 +246,7 @@ export function createLaneEnvironmentService({ laneVars: Record ): Promise { for (const file of envFiles) { - const sourcePath = path.resolve(projectRoot, file.source); - const destPath = path.resolve(worktreePath, file.dest); - - if (!isWithinDir(worktreePath, destPath)) { - logger.warn("lane_env_init.env_file_path_escape", { dest: file.dest, worktreePath }); - throw new Error("Path escapes allowed directory"); - } - - // Ensure destination directory exists - const destDir = path.dirname(destPath); - if (!fs.existsSync(destDir)) { - fs.mkdirSync(destDir, { recursive: true }); - } + const sourcePath = resolveCheckedPath(projectRoot, file.source, logger, "lane_env_init.env_file_source_escape", { source: file.source, projectRoot }, { allowMissing: true }); if (!fs.existsSync(sourcePath)) { logger.warn("lane_env_init.env_file_missing", { source: file.source }); @@ -201,7 +262,14 @@ export function createLaneEnvironmentService({ content = content.replace(new RegExp(`\\{\\{${escapeRegExp(key)}\\}\\}`, "g"), value); } - fs.writeFileSync(destPath, content, "utf-8"); + secureWriteTextFile( + worktreePath, + file.dest, + content, + logger, + "lane_env_init.env_file_path_escape", + { dest: file.dest, worktreePath }, + ); logger.debug("lane_env_init.env_file_copied", { source: file.source, dest: file.dest }); } } @@ -215,7 +283,7 @@ export function createLaneEnvironmentService({ logger.warn("lane_env_init.docker_compose_missing", { path: docker.composePath ?? "" }); return { exitCode: 0, stderr: "" }; } - const composePath = path.resolve(projectRoot, docker.composePath); + const composePath = resolveCheckedPath(projectRoot, docker.composePath, logger, "lane_env_init.docker_compose_escape", { path: docker.composePath, projectRoot }, { allowMissing: true }); if (!fs.existsSync(composePath)) { logger.warn("lane_env_init.docker_compose_missing", { path: docker.composePath }); return { exitCode: 0, stderr: "" }; @@ -251,7 +319,7 @@ export function createLaneEnvironmentService({ logger.warn("lane_env_init.dependency_command_not_allowed", { command: baseCommand }); continue; } - const cwd = dep.cwd ? path.resolve(worktreePath, dep.cwd) : worktreePath; + const cwd = resolveCheckedPath(worktreePath, dep.cwd ?? ".", logger, "lane_env_init.dependency_cwd_escape", { cwd: dep.cwd ?? ".", worktreePath }); const result = await execCommand(dep.command, cwd); if (result.exitCode !== 0) { failures.push(`${dep.command.join(" ")}: ${result.stderr.slice(0, 500)}`); @@ -270,22 +338,7 @@ export function createLaneEnvironmentService({ mountPoints: LaneMountPointConfig[] ): void { for (const mp of mountPoints) { - const sourcePath = path.resolve(adeDir, mp.source); - const destPath = path.resolve(worktreePath, mp.dest); - - if (!isWithinDir(adeDir, sourcePath)) { - logger.warn("lane_env_init.mount_source_path_escape", { source: mp.source, adeDir }); - throw new Error("Path escapes allowed directory"); - } - if (!isWithinDir(worktreePath, destPath)) { - logger.warn("lane_env_init.mount_dest_path_escape", { dest: mp.dest, worktreePath }); - throw new Error("Path escapes allowed directory"); - } - - const destDir = path.dirname(destPath); - if (!fs.existsSync(destDir)) { - fs.mkdirSync(destDir, { recursive: true }); - } + const sourcePath = resolveCheckedPath(adeDir, mp.source, logger, "lane_env_init.mount_source_path_escape", { source: mp.source, adeDir }, { allowMissing: true }); if (!fs.existsSync(sourcePath)) { logger.warn("lane_env_init.mount_source_missing", { source: mp.source }); @@ -293,7 +346,17 @@ export function createLaneEnvironmentService({ } // Copy file (not symlink, to avoid cross-worktree issues) - fs.copyFileSync(sourcePath, destPath); + secureCopyPath( + adeDir, + mp.source, + worktreePath, + mp.dest, + logger, + "lane_env_init.mount_source_path_escape", + { source: mp.source, adeDir }, + "lane_env_init.mount_dest_path_escape", + { dest: mp.dest, worktreePath }, + ); logger.debug("lane_env_init.mount_point_setup", { source: mp.source, dest: mp.dest }); } } @@ -303,34 +366,32 @@ export function createLaneEnvironmentService({ copyPaths: LaneCopyPathConfig[] ): void { for (const cp of copyPaths) { - const sourcePath = path.resolve(projectRoot, cp.source); + const sourcePath = resolveCheckedPath(projectRoot, cp.source, logger, "lane_env_init.copy_source_path_escape", { source: cp.source, projectRoot }, { allowMissing: true }); const dest = cp.dest ?? cp.source; - const destPath = path.resolve(worktreePath, dest); - - if (!isWithinDir(worktreePath, destPath)) { - logger.warn("lane_env_init.copy_dest_path_escape", { dest, worktreePath }); - throw new Error("Path escapes allowed directory"); - } if (!fs.existsSync(sourcePath)) { logger.warn("lane_env_init.copy_path_missing", { source: cp.source }); continue; } - const stat = fs.statSync(sourcePath); - if (stat.isDirectory()) { - // Recursive directory copy - fs.cpSync(sourcePath, destPath, { recursive: true, force: true }); - logger.debug("lane_env_init.copy_path_dir", { source: cp.source, dest }); - } else { - // Single file copy - const destDir = path.dirname(destPath); - if (!fs.existsSync(destDir)) { - fs.mkdirSync(destDir, { recursive: true }); - } - fs.copyFileSync(sourcePath, destPath); - logger.debug("lane_env_init.copy_path_file", { source: cp.source, dest }); + secureCopyPath( + projectRoot, + cp.source, + worktreePath, + dest, + logger, + "lane_env_init.copy_source_path_escape", + { source: cp.source, projectRoot }, + "lane_env_init.copy_dest_path_escape", + { dest, worktreePath }, + ); + let sourceIsDirectory = false; + try { + sourceIsDirectory = fs.statSync(sourcePath).isDirectory(); + } catch { + sourceIsDirectory = false; } + logger.debug(sourceIsDirectory ? "lane_env_init.copy_path_dir" : "lane_env_init.copy_path_file", { source: cp.source, dest }); } } @@ -495,8 +556,33 @@ export function createLaneEnvironmentService({ return; } const projectName = buildDockerProjectName(lane.id, config.docker.projectPrefix); - const composePath = path.resolve(projectRoot, config.docker.composePath); - if (!fs.existsSync(composePath)) { + let composePath: string; + let skipExistsCheck = false; + try { + composePath = resolveCheckedPath( + projectRoot, + config.docker.composePath, + logger, + "lane_env_cleanup.docker_compose_escape", + { laneId: lane.id, path: config.docker.composePath, projectRoot }, + { allowMissing: true }, + ); + } catch (error) { + if (isPathEscapeError(error)) { + progressMap.delete(lane.id); + return; + } + logger.warn("lane_env_cleanup.docker_compose_path_validation_failed", { + laneId: lane.id, + path: config.docker.composePath, + error: error instanceof Error ? error.message : String(error), + }); + composePath = path.isAbsolute(config.docker.composePath) + ? config.docker.composePath + : path.resolve(projectRoot, config.docker.composePath); + skipExistsCheck = true; + } + if (!skipExistsCheck && !fs.existsSync(composePath)) { logger.warn("lane_env_cleanup.docker_compose_missing", { laneId: lane.id, path: config.docker.composePath }); progressMap.delete(lane.id); return; diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index b95fb7135..586804a1d 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -3383,6 +3383,90 @@ describe("aiOrchestratorService", () => { } }); + it("keeps coordinator availability interventions open when no live coordinator exists", async () => { + const fixture = await createFixture(); + try { + const mission = fixture.missionService.create({ + prompt: "Do not clear coordinator blockers without a live coordinator.", + laneId: fixture.laneId, + }); + const started = fixture.orchestratorService.startRun({ + missionId: mission.id, + steps: [], + }); + const runId = started.run.id; + fixture.missionService.update({ + missionId: mission.id, + status: "in_progress", + }); + fixture.orchestratorService.addSteps({ + runId, + steps: [ + { + stepKey: "manual-check", + title: "Manual check", + stepIndex: 0, + dependencyStepKeys: [], + executorKind: "manual", + }, + ], + }); + fixture.orchestratorService.tick({ runId }); + const graph = fixture.orchestratorService.getRunGraph({ runId }); + const readyStep = graph.steps.find((step) => step.stepKey === "manual-check" && step.status === "ready"); + if (!readyStep) throw new Error("Expected manual-check step to be ready"); + const attempt = await fixture.orchestratorService.startAttempt({ + runId, + stepId: readyStep.id, + ownerId: "test-owner", + executorKind: "manual", + }); + await fixture.orchestratorService.completeAttempt({ + attemptId: attempt.id, + status: "succeeded", + }); + const intervention = fixture.missionService.addIntervention({ + missionId: mission.id, + interventionType: "failed_step", + title: "Coordinator unavailable", + body: "Coordinator agent is not available for this run.", + requestedAction: "Resume after coordinator runtime is healthy.", + metadata: { + runId, + stepId: readyStep.id, + attemptId: attempt.id, + reasonCode: "coordinator_unavailable", + }, + }); + + fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ + type: "orchestrator-step-updated", + runId, + stepId: readyStep.id, + attemptId: attempt.id, + at: new Date().toISOString(), + reason: "attempt_completed", + } as any); + + const refreshedMission = fixture.missionService.get(mission.id); + const refreshedIntervention = refreshedMission?.interventions.find((entry) => entry.id === intervention.id); + expect(refreshedIntervention?.status).toBe("open"); + + const resolvedEvents = fixture.orchestratorService.listRuntimeEvents({ + runId, + eventTypes: ["intervention_resolved"], + limit: 10, + }); + expect( + resolvedEvents.some((entry) => + String((entry.payload as Record | null)?.interventionId ?? "") === intervention.id + ) + ).toBe(false); + } finally { + fixture.dispose(); + } + }); + it("prefers mission launch failures over later coordinator-unavailable interventions in run view", async () => { const fixture = await createFixture(); try { diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts index b229364ea..d943c056a 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts @@ -1,9 +1,8 @@ -import { createHash, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; import fs from "node:fs"; import nodePath from "node:path"; import type { MissionDetail, - MissionExecutionPolicy, MissionStepStatus, MissionStatus, CancelOrchestratorRunArgs, @@ -23,13 +22,15 @@ import type { CleanupOrchestratorTeamResourcesResult, UserSteeringDirective, OrchestratorChatMessage, - OrchestratorChatThread, OrchestratorChatTarget, SendOrchestratorChatArgs, GetOrchestratorChatArgs, + GetGlobalChatArgs, ListOrchestratorChatThreadsArgs, GetOrchestratorThreadMessagesArgs, SendOrchestratorThreadMessageArgs, + GetActiveAgentsArgs, + SendAgentMessageArgs, OrchestratorWorkerDigest, ListOrchestratorWorkerDigestsArgs, GetOrchestratorWorkerDigestArgs, @@ -50,18 +51,19 @@ import type { ExecutionPlanPreview, ExecutionPlanPhase, ExecutionPlanStepPreview, + PhaseCard, OrchestratorWorkerRole, RecoveryLoopPolicy, AggregatedUsageStats, GetAggregatedUsageArgs, RecoveryLoopState, IntegrationPrPolicy, + QueueLandingState, PrStrategy, - StartOrchestratorRunStepInput, OrchestratorRunStatus, OrchestratorTeamMember, OrchestratorTeamRuntimeState, - TeamRuntimeConfig, + MissionAgentRuntimeConfig, FinalizeRunArgs, FinalizeRunResult, GetMissionStateDocumentArgs, @@ -117,13 +119,23 @@ import type { createPrService } from "../prs/prService"; import type { createConflictService } from "../conflicts/conflictService"; import type { createQueueLandingService } from "../prs/queueLandingService"; import type { ComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; +import type { HumanWorkDigestService } from "../memory/humanWorkDigestService"; +import type { MissionMemoryLifecycleService } from "../memory/missionMemoryLifecycleService"; +import type { MissionBudgetService } from "./missionBudgetService"; import { buildComputerUseOwnerSnapshot, collectRequiredComputerUseKindsFromPhases, getComputerUseArtifactKinds, } from "../computerUse/controlPlane"; import { createMemoryService } from "../memory/memoryService"; -import { CoordinatorAgent, type CoordinatorPlanningStartupFailure } from "./coordinatorAgent"; +import { + CoordinatorAgent, + type CoordinatorAvailableProvider, + type CoordinatorPlanningStartupFailure, + type CoordinatorProjectContext, + type CoordinatorRuntimeFailure, + type CoordinatorUserRules, +} from "./coordinatorAgent"; import { routeEventToCoordinator } from "./runtimeEventRouter"; import { deleteCoordinatorCheckpoint, @@ -158,6 +170,7 @@ import type { MissionRuntimeProfile, SessionRuntimeSignal, AttemptRuntimeTracker, + OrchestratorContext, OrchestratorChatSessionState, WorkerDeliveryContext, ParallelMissionStepDescriptor, @@ -205,11 +218,9 @@ import { workerStateFromRuntimeSignal, parseTerminalRuntimeState, toOptionalString, - readConfig, mapOrchestratorStepStatus, deriveMissionStatusFromRun, buildOutcomeSummary, - buildConflictResolutionInstructions, extractRunFailureMessage, filterExecutionSteps, runOrchestratorHookCommand, @@ -570,8 +581,6 @@ export function normalizeCoordinatorUpdateForChat(message: string): string | nul // Import from team runtime config module import { - resolveMissionTeamRuntime as resolveMissionTeamRuntimeCtx, - normalizeTeamRuntimeConfig as normalizeTeamRuntimeConfigFn, normalizeAgentRuntimeFlags, } from "./teamRuntimeConfig"; @@ -590,10 +599,8 @@ import { getRunMetadata as getRunMetadataCtx, updateRunMetadata as updateRunMetadataCtx, loadSteeringDirectivesFromMetadata as loadSteeringDirectivesCtx, - loadChatSessionStateFromMetadata as loadChatSessionStateCtx, emitOrchestratorMessage as emitOrchestratorMessageCtx, upsertThread as upsertThreadCtx, - summarizeRunForChat as summarizeRunForChatCtx, appendChatMessageCtx, updateChatMessage as updateChatMessageCtx, listChatThreadsCtx, @@ -764,9 +771,9 @@ export function createAiOrchestratorService(args: { prService?: ReturnType | null; conflictService?: ReturnType | null; queueLandingService?: ReturnType | null; - missionBudgetService?: import("./missionBudgetService").MissionBudgetService | null; - humanWorkDigestService?: import("../memory/humanWorkDigestService").HumanWorkDigestService | null; - missionMemoryLifecycleService?: import("../memory/missionMemoryLifecycleService").MissionMemoryLifecycleService | null; + missionBudgetService?: MissionBudgetService | null; + humanWorkDigestService?: HumanWorkDigestService | null; + missionMemoryLifecycleService?: MissionMemoryLifecycleService | null; computerUseArtifactBrokerService?: ComputerUseArtifactBrokerService | null; projectRoot?: string; onThreadEvent?: (event: OrchestratorThreadEvent) => void; @@ -844,7 +851,7 @@ export function createAiOrchestratorService(args: { let healthSweepTimer: NodeJS.Timeout | null = null; // ── OrchestratorContext — shared state for extracted modules ── - const ctx: import("./orchestratorContext").OrchestratorContext = { + const ctx: OrchestratorContext = { db, logger, missionService, @@ -951,10 +958,6 @@ export function createAiOrchestratorService(args: { const resolveCallTypeConfig = (missionId: string, callType: OrchestratorCallType) => resolveCallTypeConfigCtx(ctx, missionId, callType); - // ── Team runtime config (delegated to teamRuntimeConfig module) ── - const resolveMissionTeamRuntime = (missionId: string) => resolveMissionTeamRuntimeCtx(ctx, missionId); - const normalizeTeamRuntimeConfig = (missionId: string, config: TeamRuntimeConfig) => normalizeTeamRuntimeConfigFn(missionId, config); - const getMissionMetadata = (missionId: string): Record => getMissionMetadataCtx(ctx, missionId); const updateMissionMetadata = (missionId: string, mutate: (metadata: Record) => void): void => updateMissionMetadataCtx(ctx, missionId, mutate); const getMissionIdForRun = (runId: string): string | null => getMissionIdForRunCtx(ctx, runId); @@ -966,8 +969,6 @@ export function createAiOrchestratorService(args: { const loadSteeringDirectivesFromMetadata = (missionId: string) => loadSteeringDirectivesCtx(ctx, missionId); - const loadChatSessionStateFromMetadata = (missionId: string) => loadChatSessionStateCtx(ctx, missionId); - const appendChatMessage = (message: OrchestratorChatMessage): OrchestratorChatMessage => appendChatMessageCtx(ctx, message); const updateChatMessage = ( @@ -1185,15 +1186,6 @@ export function createAiOrchestratorService(args: { const clipStructuredText = (value: string, maxChars = 8_000): string => value.length <= maxChars ? value : `${value.slice(0, Math.max(0, maxChars - 16))}\n[truncated]`; - const stringifyStructuredDetail = (value: unknown): string => { - if (typeof value === "string") return clipStructuredText(value, 12_000); - try { - return clipStructuredText(JSON.stringify(value, null, 2), 12_000); - } catch { - return String(value); - } - }; - const structuredThreadKeyForEvent = (scopeKey: string, event: AgentChatEvent): string | null => { switch (event.type) { case "text": @@ -1938,10 +1930,10 @@ export function createAiOrchestratorService(args: { missionGoal: string, modelConfig: ModelConfig, opts?: { - userRules?: import("./coordinatorAgent").CoordinatorUserRules; - projectContext?: import("./coordinatorAgent").CoordinatorProjectContext; - availableProviders?: import("./coordinatorAgent").CoordinatorAvailableProvider[]; - phases?: import("../../../shared/types").PhaseCard[]; + userRules?: CoordinatorUserRules; + projectContext?: CoordinatorProjectContext; + availableProviders?: CoordinatorAvailableProvider[]; + phases?: PhaseCard[]; skipInitialActivationMessage?: boolean; missionLaneId?: string; }, @@ -2067,7 +2059,7 @@ export function createAiOrchestratorService(args: { }, }); }, - onCoordinatorRuntimeFailure: (failure: import("./coordinatorAgent").CoordinatorRuntimeFailure) => { + onCoordinatorRuntimeFailure: (failure: CoordinatorRuntimeFailure) => { const lifecycleMessage = failure.reasonCode === "coordinator_runtime_provider_auth_failed" ? "The orchestrator could not authenticate with the selected provider, so I paused the run." : failure.category === "provider_unreachable" @@ -2100,6 +2092,7 @@ export function createAiOrchestratorService(args: { turnId: failure.turnId, modelId, error: failure.message, + coordinatorFailureHandled: true, }, }); endCoordinatorAgentV2(runId); @@ -2899,7 +2892,7 @@ Check all worker statuses and continue managing the mission from here. Read work updateMissionStateDoc(runId, { coordinatorAvailability: availability }, options); }; - const onQueueLandingStateChanged = async (queueState: import("../../../shared/types").QueueLandingState): Promise => { + const onQueueLandingStateChanged = async (queueState: QueueLandingState): Promise => { const runId = queueState.config.originRunId ?? null; const missionId = queueState.config.originMissionId ?? (runId ? getMissionIdForRun(runId) : null); if (!runId || !missionId) return; @@ -4664,55 +4657,6 @@ Check all worker statuses and continue managing the mission from here. Read work appendChatMessage, }); - const summarizeRunForChat = (missionId: string): string => summarizeRunForChatCtx(ctx, missionId); - - const resolveChatProvider = (missionId: string): "claude" | "codex" | null => { - const existingSession = activeChatSessions.get(missionId) ?? loadChatSessionStateFromMetadata(missionId); - if (existingSession) { - return existingSession.provider; - } - - const runs = orchestratorService.listRuns({ missionId }); - const activeRun = runs.find((entry) => entry.status === "active" || entry.status === "bootstrapping" || entry.status === "queued" || entry.status === "paused"); - if (activeRun) { - try { - const graph = orchestratorService.getRunGraph({ runId: activeRun.id, timelineLimit: 0 }); - const runningAttempt = graph.attempts - .filter((attempt) => attempt.status === "running") - .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0]; - if (runningAttempt) { - const rawMeta = isRecord((runningAttempt as any).metadata) ? ((runningAttempt as any).metadata as Record) : null; - const attemptModel = typeof rawMeta?.model === "string" - ? rawMeta.model - : typeof rawMeta?.modelId === "string" - ? rawMeta.modelId - : null; - if (attemptModel) { - const desc = getModelById(attemptModel); - if (desc?.family === "openai") return "codex"; - if (desc?.family === "anthropic") return "claude"; - } - } - } catch { - // ignore - } - } - - const config = readConfig(projectConfigService); - if (config.defaultOrchestratorModelId === "claude" || config.defaultOrchestratorModelId === "codex") { - return config.defaultOrchestratorModelId as "claude" | "codex"; - } - if (config.defaultOrchestratorModelId) { - const desc = getModelById(config.defaultOrchestratorModelId); - if (desc?.family === "anthropic") return "claude"; - if (desc?.family === "openai") return "codex"; - } - const availability = aiIntegrationService?.getAvailability?.(); - if (availability?.claude) return "claude"; - if (availability?.codex) return "codex"; - return null; - }; - const resolveMissionProjectId = (missionId: string): string => { const row = db.get<{ project_id: string | null }>( `select project_id from missions where id = ? limit 1`, @@ -6456,7 +6400,7 @@ Check all worker statuses and continue managing the mission from here. Read work // TERMINAL_PHASE_STEP_STATUSES — imported from missionLifecycle - const syncMissionPhaseFromRun = (graph: OrchestratorRunGraph, reason: string) => { + const syncMissionPhaseFromRun = (graph: OrchestratorRunGraph, _reason: string) => { if (!graph.run.missionId) return; const target = deriveMissionPhaseSyncTarget(graph); if (!target) return; @@ -7526,13 +7470,13 @@ Check all worker statuses and continue managing the mission from here. Read work const missionMeta = getMissionMetadata(missionId); const launch = isRecord(missionMeta?.launch) ? missionMeta.launch as Record : {}; - const userRules: import("./coordinatorAgent").CoordinatorUserRules = {}; + const userRules: CoordinatorUserRules = {}; if (typeof launch.providerPreference === "string") userRules.providerPreference = launch.providerPreference; if (typeof launch.costMode === "string") userRules.costMode = launch.costMode; if (typeof launch.maxParallelWorkers === "number") userRules.maxParallelWorkers = launch.maxParallelWorkers; const agentRuntime = isRecord(launch.agentRuntime) ? launch.agentRuntime : null; if (agentRuntime) { - const flags = normalizeAgentRuntimeFlags(agentRuntime as Partial); + const flags = normalizeAgentRuntimeFlags(agentRuntime as Partial); userRules.allowParallelAgents = flags.allowParallelAgents; userRules.allowSubAgents = flags.allowSubAgents; userRules.allowClaudeAgentTeams = flags.allowClaudeAgentTeams; @@ -7595,7 +7539,7 @@ Check all worker statuses and continue managing the mission from here. Read work } } - const projectCtx: import("./coordinatorAgent").CoordinatorProjectContext | undefined = + const projectCtx: CoordinatorProjectContext | undefined = projectRoot ? { projectRoot, projectDocPaths: projectDocsContext.found ? projectDocsContext.paths : undefined, @@ -7604,7 +7548,7 @@ Check all worker statuses and continue managing the mission from here. Read work } : undefined; // Detect available providers - const availableProviders: import("./coordinatorAgent").CoordinatorAvailableProvider[] = []; + const availableProviders: CoordinatorAvailableProvider[] = []; const availability = aiIntegrationService?.getAvailability?.(); if (availability) { if (availability.claude) availableProviders.push({ name: "claude", available: true }); @@ -8048,7 +7992,7 @@ Check all worker statuses and continue managing the mission from here. Read work const runId = typeof meta?.runId === "string" ? meta.runId.trim() : ""; const attemptId = typeof meta?.attemptId === "string" ? meta.attemptId.trim() : ""; const stepId = typeof meta?.stepId === "string" ? meta.stepId.trim() : ""; - const sessionId = typeof meta?.sessionId === "string" ? meta.sessionId.trim() : ""; + const _sessionId = typeof meta?.sessionId === "string" ? meta.sessionId.trim() : ""; const ownerGraph = runId ? loadRunGraph(runId) : null; const ownerAttempt = attemptId.length > 0 ? ownerGraph?.attempts.find((entry) => entry.id === attemptId) ?? null : null; const ownerStep = stepId.length > 0 ? ownerGraph?.steps.find((entry) => entry.id === stepId) ?? null : null; @@ -8397,13 +8341,13 @@ Check all worker statuses and continue managing the mission from here. Read work const deliverMessageToAgent = (args: Parameters[1]) => deliverMessageToAgentCtx(ctx, args, { sendWorkerMessageToSession }); - const getGlobalChat = (args: import("../../../shared/types").GetGlobalChatArgs) => + const getGlobalChat = (args: GetGlobalChatArgs) => getGlobalChatCtx(ctx, args); - const getActiveAgents = (args: import("../../../shared/types").GetActiveAgentsArgs) => + const getActiveAgents = (args: GetActiveAgentsArgs) => getActiveAgentsCtx(ctx, args); - const sendAgentMessageWithMentions = (agentMsgArgs: import("../../../shared/types").SendAgentMessageArgs) => + const sendAgentMessageWithMentions = (agentMsgArgs: SendAgentMessageArgs) => sendAgentMessageWithMentionsCtx(ctx, agentMsgArgs, { deliverMessageToAgent }); const maybeForwardSubagentCompletionRollup = (args: { @@ -10169,8 +10113,10 @@ Check all worker statuses and continue managing the mission from here. Read work if (event.reason !== "finalized") { const missionId = getMissionIdForRun(runId); if (missionId) { + let suppressGenericIntervention = false; const launchFailure = getRunLaunchFailureMetadata(runId); if (launchFailure && hasOpenMissionLaunchFailureIntervention({ missionId, runId })) { + suppressGenericIntervention = true; logger.info("ai_orchestrator.coordinator_unavailable_suppressed", { runId, missionId, @@ -10178,25 +10124,46 @@ Check all worker statuses and continue managing the mission from here. Read work reason: event.reason, failureStage: launchFailure.failureStage ?? null, }); - return; } - pauseRunWithIntervention({ - runId, - missionId, - stepId: event.stepId ?? null, - source: "transition_decision", - reasonCode: existingCoordinator ? "coordinator_recovery_failed" : "coordinator_unavailable", - title: existingCoordinator ? "Coordinator recovery failed" : "Coordinator unavailable", - body: existingCoordinator - ? "Coordinator agent terminated and could not be recovered. Mission paused to prevent non-autonomous fallback logic." - : "Coordinator agent is not available for this run. Mission paused to prevent non-autonomous fallback logic.", - requestedAction: "Resume after coordinator runtime is healthy, or restart the mission run.", - metadata: { - runtimeEventType: event.type, - runtimeEventReason: event.reason, - attemptId: event.attemptId ?? null - } - }); + + // Skip creating a generic coordinator_unavailable intervention if + // onCoordinatorRuntimeFailure already handled this failure and + // created a more specific intervention with full failure context. + const mission = missionService.get(missionId); + const alreadyHandledByRuntimeFailure = mission?.interventions.some((entry) => { + if (entry.status !== "open") return false; + const meta = isRecord(entry.metadata) ? entry.metadata : null; + return meta?.coordinatorFailureHandled === true && meta?.runId === runId; + }) ?? false; + if (alreadyHandledByRuntimeFailure) { + suppressGenericIntervention = true; + logger.info("ai_orchestrator.coordinator_unavailable_suppressed_runtime_failure", { + runId, + missionId, + eventType: event.type, + reason: event.reason, + }); + } + + if (!suppressGenericIntervention) { + pauseRunWithIntervention({ + runId, + missionId, + stepId: event.stepId ?? null, + source: "transition_decision", + reasonCode: existingCoordinator ? "coordinator_recovery_failed" : "coordinator_unavailable", + title: existingCoordinator ? "Coordinator recovery failed" : "Coordinator unavailable", + body: existingCoordinator + ? "Coordinator agent terminated and could not be recovered. Mission paused to prevent non-autonomous fallback logic." + : "Coordinator agent is not available for this run. Mission paused to prevent non-autonomous fallback logic.", + requestedAction: "Resume after coordinator runtime is healthy, or restart the mission run.", + metadata: { + runtimeEventType: event.type, + runtimeEventReason: event.reason, + attemptId: event.attemptId ?? null + } + }); + } } } else if (runStatus === "succeeded" || runStatus === "failed" || runStatus === "canceled") { resolveCoordinatorHealthInterventions({ @@ -10211,15 +10178,14 @@ Check all worker statuses and continue managing the mission from here. Read work reason: event.reason, recovered: false }); - return; + } else { + resolveCoordinatorHealthInterventions({ + runId, + note: "Coordinator runtime is healthy again; closed stale coordinator availability intervention.", + resolutionReason: "coordinator_recovered", + }); } - resolveCoordinatorHealthInterventions({ - runId, - note: "Coordinator runtime is healthy again; closed stale coordinator availability intervention.", - resolutionReason: "coordinator_recovered", - }); - // Run finalized — coordinator's job is done, shut it down if (event.reason === "finalized") { const terminalRunStatus = getEventGraph().run.status; @@ -10330,14 +10296,16 @@ Check all worker statuses and continue managing the mission from here. Read work // transition handlers, quality gates, retry decisions, failure diagnosis, // fan-out analysis, or intervention auto-resolution. // ──────────────────────────────────────────────────────────────────── - try { - routeEventToCoordinator(coordinator, event, { graph: getEventGraph() }); - } catch (routeError) { - logger.debug("ai_orchestrator.coordinator_v2_route_failed", { - runId, - reason: event.reason, - error: routeError instanceof Error ? routeError.message : String(routeError), - }); + if (coordinator) { + try { + routeEventToCoordinator(coordinator, event, { graph: getEventGraph() }); + } catch (routeError) { + logger.debug("ai_orchestrator.coordinator_v2_route_failed", { + runId, + reason: event.reason, + error: routeError instanceof Error ? routeError.message : String(routeError), + }); + } } return; }, diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts b/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts index a9fe9c47e..5514af49c 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts @@ -15,6 +15,8 @@ function createTestDeps(args: { text: string; priority?: "normal" | "urgent"; }) => Promise; + projectRoot?: string; + workspaceRoot?: string; memoryService?: { search: (opts: Record) => Promise; searchAcrossScopeOwners?: (opts: Record) => Promise; @@ -55,8 +57,8 @@ function createTestDeps(args: { missionId: "mission-1", logger, db: {} as any, - projectRoot: "/tmp", - workspaceRoot: "/tmp/worktree", + projectRoot: args.projectRoot ?? "/tmp", + workspaceRoot: args.workspaceRoot ?? "/tmp/worktree", onDagMutation: vi.fn(), sendWorkerMessageToSession: args.sendWorkerMessageToSession, }); @@ -64,6 +66,31 @@ function createTestDeps(args: { return { tools, orchestratorService }; } +describe("coordinator project context", () => { + it("redacts the workspace root from get_project_context", async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-coordinator-tools-")); + try { + fs.writeFileSync(path.join(workspaceRoot, "README.md"), "# Test workspace\n", "utf8"); + const { tools } = createTestDeps({ + graph: { run: { id: "run-1", metadata: {} }, steps: [], attempts: [] }, + workspaceRoot, + }); + + const result = await (tools.get_project_context as any).execute({}); + + expect(result).toMatchObject({ + ok: true, + projectRoot: "./", + projectRootRedacted: true, + rootLabel: "./", + }); + expect(JSON.stringify(result)).not.toContain(workspaceRoot); + } finally { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); +}); + describe("coordinator memory tools", () => { it("memory_search queries project memory with mission scope defaults", async () => { const memoryService = { @@ -4279,6 +4306,165 @@ describe("coordinatorTools file path containment", () => { }); }); + it("read_file rejects symlinked files that point outside the workspace", async () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-read-symlink-root-")); + const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-read-symlink-outside-")); + const outsideFile = path.join(outsideRoot, "secret.txt"); + fs.writeFileSync(outsideFile, "leak", "utf-8"); + fs.symlinkSync(outsideFile, path.join(projectRoot, "linked-secret.txt")); + const { tools } = createCoordinatorHarness({ + graph: { run: { metadata: {} }, steps: [], attempts: [] }, + projectRoot, + }); + + const result = await (tools.read_file as any).execute({ + filePath: "linked-secret.txt", + }); + + expect(result).toMatchObject({ + ok: false, + error: "Path is outside mission workspace root", + }); + }); + + it("read_file sanitizes missing in-workspace files", async () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-read-missing-root-")); + try { + const { tools } = createCoordinatorHarness({ + graph: { run: { metadata: {} }, steps: [], attempts: [] }, + projectRoot, + }); + + const result = await (tools.read_file as any).execute({ + filePath: "missing.txt", + }); + + expect(result.ok).toBe(false); + expect(result.error).toBe("file not found: missing.txt"); + expect(String(result.error)).not.toContain(projectRoot); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("search_files skips symlinked files that point outside the workspace", async () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-search-symlink-root-")); + const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-search-symlink-outside-")); + const outsideFile = path.join(outsideRoot, "secret.txt"); + fs.writeFileSync(outsideFile, "leak me", "utf-8"); + fs.symlinkSync(outsideFile, path.join(projectRoot, "linked-secret.txt")); + const { tools } = createCoordinatorHarness({ + graph: { run: { metadata: {} }, steps: [], attempts: [] }, + projectRoot, + }); + + const result = await (tools.search_files as any).execute({ + pattern: "leak me", + searchType: "content", + maxResults: 10, + }); + + expect(result).toMatchObject({ + ok: true, + total: 0, + }); + }); + + it("search_files matches filename globs against the full relative path", async () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-search-glob-root-")); + fs.mkdirSync(path.join(projectRoot, "src", "nested"), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, "tests"), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, "src", "nested", "match.ts"), "export const value = 1;\n", "utf-8"); + fs.writeFileSync(path.join(projectRoot, "tests", "match.ts"), "export const value = 2;\n", "utf-8"); + fs.writeFileSync(path.join(projectRoot, "src", "match.js"), "export const value = 3;\n", "utf-8"); + + try { + const { tools } = createCoordinatorHarness({ + graph: { run: { metadata: {} }, steps: [], attempts: [] }, + projectRoot, + }); + + const result = await (tools.search_files as any).execute({ + pattern: "src/**/*.ts", + searchType: "filename", + maxResults: 10, + }); + + expect(result).toMatchObject({ + ok: true, + total: 1, + results: ["src/nested/match.ts"], + }); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("search_files treats unsafe content regex patterns as literals", async () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-search-regex-root-")); + fs.mkdirSync(path.join(projectRoot, "docs"), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, "docs", "notes.txt"), "literal (a+)+$ pattern\n", "utf-8"); + + try { + const { tools } = createCoordinatorHarness({ + graph: { run: { metadata: {} }, steps: [], attempts: [] }, + projectRoot, + }); + + const result = await (tools.search_files as any).execute({ + pattern: "(a+)+$", + searchType: "content", + maxResults: 10, + }); + + expect(result).toMatchObject({ + ok: true, + total: 1, + }); + expect(result.results).toEqual([ + expect.objectContaining({ + file: "docs/notes.txt", + line: 1, + }), + ]); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("search_files supports explicit regex mode for safe patterns", async () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-search-explicit-regex-root-")); + fs.mkdirSync(path.join(projectRoot, "docs"), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, "docs", "notes.txt"), "hello world\n", "utf-8"); + + try { + const { tools } = createCoordinatorHarness({ + graph: { run: { metadata: {} }, steps: [], attempts: [] }, + projectRoot, + }); + + const result = await (tools.search_files as any).execute({ + pattern: "^hello\\s+world$", + searchType: "content", + explicitRegexMode: true, + maxResults: 10, + }); + + expect(result).toMatchObject({ + ok: true, + total: 1, + }); + expect(result.results).toEqual([ + expect.objectContaining({ + file: "docs/notes.txt", + line: 1, + }), + ]); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + it("read_step_output sanitizes traversal-like keys and reads only project-scoped output", async () => { const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-step-output-root-")); const maliciousKey = "../../sensitive"; @@ -4303,6 +4489,27 @@ describe("coordinatorTools file path containment", () => { content: "scoped output", }); }); + + it("read_step_output reports missing files without leaking resolved paths", async () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-step-output-missing-root-")); + + try { + const { tools } = createCoordinatorHarness({ + graph: { run: { metadata: {} }, steps: [], attempts: [] }, + projectRoot, + }); + + const result = await (tools.read_step_output as any).execute({ + stepKey: "missing-step", + }); + + expect(result.ok).toBe(false); + expect(result.error).toBe("file not found: missing-step"); + expect(String(result.error)).not.toContain(projectRoot); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); }); describe("coordinatorTools completion DAG events", () => { diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts b/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts index 69701267f..f8f098c48 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts @@ -44,7 +44,7 @@ import { TERMINAL_STEP_STATUSES, } from "./orchestratorContext"; import { readMissionStateDocument, updateMissionStateDocument } from "./missionStateDoc"; -import { isWithinDir } from "../shared/utils"; +import { escapeRegExp, resolvePathWithinRoot } from "../shared/utils"; import { normalizeAgentRuntimeFlags } from "./teamRuntimeConfig"; import { registerTeamMember } from "./teamRuntimeState"; import type { createMemoryService } from "../memory/memoryService"; @@ -675,6 +675,85 @@ export function createCoordinatorToolSet(deps: { : null; const resolvedProjectRoot = path.resolve(projectRoot); const resolvedWorkspaceRoot = path.resolve(workspaceRoot); + const resolvedWorkspaceRootReal = fs.existsSync(resolvedWorkspaceRoot) + ? fs.realpathSync(resolvedWorkspaceRoot) + : resolvedWorkspaceRoot; + const resolveWorkspacePath = (candidatePath: string, allowMissing: boolean = false): string | null => { + try { + return resolvePathWithinRoot(resolvedWorkspaceRoot, candidatePath, { allowMissing }); + } catch { + return null; + } + }; + const getFsErrorCode = (error: unknown): string | null => { + if (!error || typeof error !== "object" || !("code" in error)) return null; + const code = (error as { code?: unknown }).code; + return typeof code === "string" ? code : null; + }; + const formatWorkspaceReadError = (kind: "file" | "step output", reference: string, error: unknown): string => { + const code = getFsErrorCode(error); + if (code === "ENOENT" || code === "ENOTDIR") { + return `file not found: ${reference}`; + } + if (code === "EACCES" || code === "EPERM") { + return `permission denied: ${reference}`; + } + return `failed to read ${kind}: ${reference}`; + }; + const MAX_CONTENT_SEARCH_PATTERN_LENGTH = 200; + const filenamePatternToRegExp = (pattern: string): RegExp => { + const normalized = pattern.replace(/\\/g, "/").trim(); + if (!normalized.length) return /^$/i; + let source = "^"; + for (let index = 0; index < normalized.length; index += 1) { + const char = normalized[index]!; + if (char === "*") { + const next = normalized[index + 1]; + if (next === "*") { + if (normalized[index + 2] === "/") { + source += "(?:.*/)?"; + index += 2; + } else { + source += ".*"; + index += 1; + } + } else { + source += "[^/]*"; + } + continue; + } + source += escapeRegExp(char); + } + source += "$"; + return new RegExp(source, "i"); + }; + const isUnsafeContentSearchPattern = (pattern: string): boolean => { + if (pattern.length > MAX_CONTENT_SEARCH_PATTERN_LENGTH) return true; + return /\\[1-9]/.test(pattern) + || /\(\?[:!=<]/.test(pattern) + || /\((?:[^()\\]|\\.)*[+*{](?:[^()\\]|\\.)*\)[+*{]/.test(pattern) + || /(?:^|[^\\])(?:\*|\+|\{[^}]+\})(?:\s*)(?:\*|\+|\{[^}]+\})/.test(pattern); + }; + const compileContentSearchRegex = (pattern: string, explicitRegexMode: boolean = false): RegExp => { + const normalized = pattern.trim(); + if (!normalized.length) { + throw new Error("pattern is required"); + } + if (!explicitRegexMode) { + return new RegExp(escapeRegExp(normalized), "i"); + } + try { + if (isUnsafeContentSearchPattern(normalized)) { + throw new Error("Unsafe regular expression pattern."); + } + return new RegExp(normalized, "i"); + } catch (error) { + if (error instanceof Error && error.message === "Unsafe regular expression pattern.") { + throw error; + } + throw new Error(`Invalid regular expression pattern: ${normalized}`); + } + }; const normalizeLaneId = (value: string | null | undefined): string | null => { if (typeof value !== "string") return null; @@ -6030,34 +6109,32 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act if (planningReadBlockReason) { return { ok: false, error: planningReadBlockReason }; } - const fullPath = path.resolve(resolvedWorkspaceRoot, filePath); - // Security: ensure path is within mission workspace root - if (!isWithinDir(resolvedWorkspaceRoot, fullPath)) { + const fullPath = resolveWorkspacePath(filePath, true); + if (!fullPath) { return { ok: false, error: "Path is outside mission workspace root" }; } - let stat: fs.Stats; try { - stat = fs.statSync(fullPath); - } catch { - return { ok: false, error: `File not found: ${filePath}` }; - } - if (stat.isDirectory()) { - const entries = fs.readdirSync(fullPath).slice(0, 100); - return { ok: true, type: "directory", entries }; + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + const entries = fs.readdirSync(fullPath).slice(0, 100); + return { ok: true, type: "directory", entries }; + } + const content = fs.readFileSync(fullPath, "utf-8"); + const lines = content.split("\n"); + const limit = maxLines ?? 200; + const truncated = lines.length > limit; + const result = truncated ? lines.slice(0, limit).join("\n") : content; + return { + ok: true, + type: "file", + filePath, + content: result, + totalLines: lines.length, + truncated, + }; + } catch (error) { + return { ok: false, error: formatWorkspaceReadError("file", filePath, error) }; } - const content = fs.readFileSync(fullPath, "utf-8"); - const lines = content.split("\n"); - const limit = maxLines ?? 200; - const truncated = lines.length > limit; - const result = truncated ? lines.slice(0, limit).join("\n") : content; - return { - ok: true, - type: "file", - filePath, - content: result, - totalLines: lines.length, - truncated, - }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { ok: false, error: msg }; @@ -6074,17 +6151,16 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act execute: async ({ stepKey }) => { try { const sanitized = stepKey.replace(/[^a-zA-Z0-9_-]/g, "_"); - const filePath = path.resolve(resolvedWorkspaceRoot, `.ade/step-output-${sanitized}.md`); - if (!isWithinDir(resolvedWorkspaceRoot, filePath)) { + const filePath = resolveWorkspacePath(`.ade/step-output-${sanitized}.md`, true); + if (!filePath) { return { ok: false, error: "Path is outside mission workspace root" }; } - let content: string; try { - content = fs.readFileSync(filePath, "utf-8"); - } catch { - return { ok: false, error: `Step output file not found for step: ${stepKey}` }; + const content = fs.readFileSync(filePath, "utf-8"); + return { ok: true, stepKey, content }; + } catch (error) { + return { ok: false, error: formatWorkspaceReadError("step output", stepKey, error) }; } - return { ok: true, stepKey, content }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { ok: false, error: msg }; @@ -6096,11 +6172,12 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act description: "Search project files by name pattern or content. Use to find relevant code or files.", inputSchema: z.object({ - pattern: z.string().describe("Search pattern — a filename glob (e.g. '**/*.ts') or content regex"), + pattern: z.string().describe("Search pattern — a filename glob (e.g. '**/*.ts') or literal content text"), searchType: z.enum(["filename", "content"]).default("content").describe("Whether to search file names or file content"), maxResults: z.number().optional().describe("Maximum results to return (default: 20)"), + explicitRegexMode: z.boolean().default(false).describe("Treat content patterns as regular expressions instead of literal text."), }), - execute: async ({ pattern, searchType, maxResults }) => { + execute: async ({ pattern, searchType, maxResults, explicitRegexMode }) => { try { const g = graph(); const planningReadBlockReason = getPlanningRepoReadBlockReason(g); @@ -6111,65 +6188,81 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act if (searchType === "filename") { // Simple recursive file listing with glob matching const results: string[] = []; + const filenameRegex = filenamePatternToRegExp(pattern); + const visited = new Set(); const walkDir = (dir: string, depth = 0) => { if (depth > 6 || results.length >= limit) return; try { + const realDir = fs.realpathSync(dir); + if (visited.has(realDir)) return; + visited.add(realDir); const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { if (results.length >= limit) break; if (entry.name.startsWith(".") || entry.name === "node_modules") continue; - const rel = path.relative(workspaceRoot, path.join(dir, entry.name)); - if (entry.isDirectory()) { - walkDir(path.join(dir, entry.name), depth + 1); - } else if (new RegExp(pattern.replace(/\*/g, ".*")).test(entry.name)) { - results.push(rel); + const fullPath = resolveWorkspacePath(path.join(dir, entry.name)); + if (!fullPath) continue; + const rel = path.relative(resolvedWorkspaceRootReal, fullPath); + try { + if (fs.statSync(fullPath).isDirectory()) { + walkDir(fullPath, depth + 1); + } else if (filenameRegex.test(rel.replace(/\\/g, "/"))) { + results.push(rel); + } + } catch { + // Skip entries whose stat fails (broken symlinks, etc.) } } } catch { // Skip unreadable dirs } }; - walkDir(workspaceRoot); + walkDir(resolvedWorkspaceRoot); return { ok: true, searchType, pattern, results, total: results.length }; } // Content search using a simple line-by-line grep const results: Array<{ file: string; line: number; text: string }> = []; - const regex = new RegExp(pattern, "i"); + const regex = compileContentSearchRegex(pattern, explicitRegexMode); + const visited = new Set(); const walkDir = (dir: string, depth = 0) => { if (depth > 6 || results.length >= limit) return; try { + const realDir = fs.realpathSync(dir); + if (visited.has(realDir)) return; + visited.add(realDir); const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { if (results.length >= limit) break; if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist") continue; - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - walkDir(fullPath, depth + 1); - } else { - try { - const stat = fs.statSync(fullPath); + const fullPath = resolveWorkspacePath(path.join(dir, entry.name)); + if (!fullPath) continue; + try { + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + walkDir(fullPath, depth + 1); + } else { if (stat.size > 500_000) continue; // Skip large files const content = fs.readFileSync(fullPath, "utf-8"); const lines = content.split("\n"); for (let i = 0; i < lines.length && results.length < limit; i++) { if (regex.test(lines[i]!)) { results.push({ - file: path.relative(workspaceRoot, fullPath), + file: path.relative(resolvedWorkspaceRootReal, fullPath), line: i + 1, text: lines[i]!.slice(0, 200), }); } } - } catch { - // Skip unreadable files } + } catch { + // Skip entries whose stat/read fails (broken symlinks, etc.) } } } catch { // Skip unreadable dirs } }; - walkDir(workspaceRoot); + walkDir(resolvedWorkspaceRoot); return { ok: true, searchType, pattern, results, total: results.length }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -6188,7 +6281,8 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act const keyFiles = ["package.json", "tsconfig.json", "README.md", "CLAUDE.md"]; const docs: Record = {}; for (const f of keyFiles) { - const fp = path.resolve(workspaceRoot, f); + const fp = resolveWorkspacePath(f, true); + if (!fp) continue; try { const content = fs.readFileSync(fp, "utf-8"); docs[f] = content.slice(0, 4_000); @@ -6205,9 +6299,12 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act } catch { // Ignore } + const workspaceRootLabel = "./"; return { ok: true, - projectRoot: workspaceRoot, + projectRoot: workspaceRootLabel, + projectRootRedacted: true, + rootLabel: workspaceRootLabel, topLevelEntries: topLevel, docs, }; diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts index 447895405..2a227b427 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts @@ -39,7 +39,6 @@ import type { OrchestratorRuntimeEventType, OrchestratorTeamRuntimeState, OrchestratorTimelineEvent, - PackExport, PrepareResolverSessionArgs, PtyCreateArgs, RunCompletionEvaluation, @@ -83,6 +82,12 @@ import type { createProjectConfigService } from "../config/projectConfigService" import type { createPrService } from "../prs/prService"; import type { createMemoryService } from "../memory/memoryService"; import type { createExternalMcpService } from "../externalMcp/externalMcpService"; +import type { AiTaskType } from "../ai/aiIntegrationService"; +import type { EpisodicSummaryService } from "../memory/episodicSummaryService"; +import type { KnowledgeCaptureService } from "../memory/knowledgeCaptureService"; +import type { MemoryBriefing, MemoryBriefingService } from "../memory/memoryBriefingService"; +import type { MissionMemoryLifecycleService } from "../memory/missionMemoryLifecycleService"; +import type { ProceduralLearningService } from "../memory/proceduralLearningService"; import { asRecord, nowIso, parseJsonRecord, TERMINAL_STEP_STATUSES, filterExecutionSteps } from "./orchestratorContext"; import { parseNumericDependencyIndices } from "./missionLifecycle"; import { getMissionStateDocumentPath } from "./missionStateDoc"; @@ -101,7 +106,7 @@ import { type RunRow, type StepRow, type AttemptRow, type ClaimRow, type ContextSnapshotRow, type HandoffRow, type TimelineRow, type RuntimeEventRow, type GateReportRow, type ArtifactRow, - DEFAULT_CONTEXT_POLICY, DEFAULT_CONTEXT_PROFILE_ID, + DEFAULT_CONTEXT_POLICY, TERMINAL_RUN_STATUSES, RETRYABLE_ERROR_CLASSES, MAX_TIMELINE_LIMIT, GATE_THRESHOLDS, normalizeIsoTimestamp, normalizeRunStatus, @@ -130,7 +135,7 @@ import { } from "./stepPolicyResolver"; import { normalizeAgentRuntimeFlags } from "./teamRuntimeConfig"; import { normalizeMissionPermissions, providerPermissionsToLegacyConfig, mapPermissionToInProcess } from "./permissionMapping"; -import type { MissionPermissionConfig } from "../../../shared/types/missions"; +import type { MissionPermissionConfig, MissionProviderPermissions } from "../../../shared/types/missions"; import { resolveAdeLayout } from "../../../shared/adeLayout"; // Row types, StepPolicy, and other extracted types are imported from @@ -212,7 +217,7 @@ export type OrchestratorExecutorStartArgs = { }; /** Per-provider permission modes (new unified shape). When present, adapters * should prefer this over the legacy cli/inProcess fields. */ - _providers?: import("../../../shared/types/missions").MissionProviderPermissions; + _providers?: MissionProviderPermissions; }; /** Checkpoint content from a previous interrupted attempt's worker checkpoint file. */ previousCheckpoint?: string; @@ -223,7 +228,7 @@ export type OrchestratorExecutorStartArgs = { /** Project ID for memory service scoping. */ memoryProjectId?: string; /** Precomputed shared memory briefing for prompt assembly. */ - memoryBriefing?: import("../memory/memoryBriefingService").MemoryBriefing | null; + memoryBriefing?: MemoryBriefing | null; }; export type OrchestratorExecutorAdapter = { @@ -818,11 +823,11 @@ export function createOrchestratorService({ projectConfigService?: ReturnType | null; aiIntegrationService?: ReturnType | null; memoryService?: ReturnType | null; - memoryBriefingService?: import("../memory/memoryBriefingService").MemoryBriefingService | null; - missionMemoryLifecycleService?: import("../memory/missionMemoryLifecycleService").MissionMemoryLifecycleService | null; - episodicSummaryService?: import("../memory/episodicSummaryService").EpisodicSummaryService | null; - proceduralLearningService?: import("../memory/proceduralLearningService").ProceduralLearningService | null; - knowledgeCaptureService?: import("../memory/knowledgeCaptureService").KnowledgeCaptureService | null; + memoryBriefingService?: MemoryBriefingService | null; + missionMemoryLifecycleService?: MissionMemoryLifecycleService | null; + episodicSummaryService?: EpisodicSummaryService | null; + proceduralLearningService?: ProceduralLearningService | null; + knowledgeCaptureService?: KnowledgeCaptureService | null; externalMcpService?: ReturnType | null; onEvent?: (event: OrchestratorEvent) => void; }) { @@ -6839,7 +6844,7 @@ export function createOrchestratorService({ ); const stepType = String(step.metadata?.stepType ?? step.metadata?.taskType ?? "").trim().toLowerCase(); - const taskType: import("../ai/aiIntegrationService").AiTaskType = + const taskType: AiTaskType = stepType === "analysis" || stepType === "planning" ? "planning" : stepType === "review" || stepType === "test_review" || stepType === "review_test" diff --git a/apps/desktop/src/main/services/processes/processService.test.ts b/apps/desktop/src/main/services/processes/processService.test.ts index 65f555a48..349f23d2b 100644 --- a/apps/desktop/src/main/services/processes/processService.test.ts +++ b/apps/desktop/src/main/services/processes/processService.test.ts @@ -217,4 +217,57 @@ describe("processService start logging", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + + it("rejects process cwd values that escape the lane workspace", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-process-cwd-")); + const dbPath = path.join(tmpDir, "kv.sqlite"); + const logsDir = path.join(tmpDir, "logs"); + const projectId = "proj-cwd"; + const logger = createLogger(); + + const db = await openKvDb(dbPath, createLogger()); + const now = "2026-03-24T12:00:00.000Z"; + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, tmpDir, "test", "main", now, now], + ); + db.run( + `insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ["lane-cwd", projectId, "Lane Cwd", null, "worktree", "main", "feature/cwd", tmpDir, null, 0, null, null, null, null, "active", now, null], + ); + + const config = makeMinimalConfig([ + { id: "escape-proc", command: ["echo", "hello"], cwd: ".." }, + ]); + + const service = createProcessService({ + db, + projectId, + processLogsDir: logsDir, + logger, + laneService: { + getLaneWorktreePath: () => tmpDir, + list: async () => [makeLaneSummary(tmpDir, "lane-cwd")], + } as any, + projectConfigService: { + get: () => config, + getEffective: () => config.effective, + getExecutableConfig: () => config.effective, + } as any, + broadcastEvent: () => {}, + }); + + try { + await expect(service.start({ laneId: "lane-cwd", processId: "escape-proc" })).rejects.toThrow( + /cwd escapes lane workspace/, + ); + } finally { + service.disposeAll(); + db.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/desktop/src/main/services/processes/processService.ts b/apps/desktop/src/main/services/processes/processService.ts index 2a0f2594f..b33a826a5 100644 --- a/apps/desktop/src/main/services/processes/processService.ts +++ b/apps/desktop/src/main/services/processes/processService.ts @@ -23,7 +23,7 @@ import type { AdeDb } from "../state/kvDb"; import type { createProjectConfigService } from "../config/projectConfigService"; import type { createLaneService } from "../lanes/laneService"; import { matchLaneOverlayPolicies } from "../config/laneOverlayMatcher"; -import { isWithinDir, fileSizeOrZero, nowIso } from "../shared/utils"; +import { isWithinDir, fileSizeOrZero, nowIso, resolvePathWithinRoot } from "../shared/utils"; type ManagedTerminationReason = "stopped" | "killed" | "crashed" | "restart"; @@ -758,7 +758,13 @@ export function createProcessService({ const laneRoot = laneService.getLaneWorktreePath(laneId); const configuredCwd = opts.overlay?.cwd?.trim() ? opts.overlay.cwd : definition.cwd; - const cwd = path.isAbsolute(configuredCwd) ? configuredCwd : path.join(laneRoot, configuredCwd); + const cwdCandidate = path.isAbsolute(configuredCwd) ? configuredCwd : path.join(laneRoot, configuredCwd); + let cwd: string; + try { + cwd = resolvePathWithinRoot(laneRoot, cwdCandidate); + } catch { + throw new Error(`Process '${definition.id}' cwd escapes lane workspace`); + } const env = { ...process.env, // Inject color-friendly defaults for processes running without a PTY. diff --git a/apps/desktop/src/main/services/prs/prIssueResolver.test.ts b/apps/desktop/src/main/services/prs/prIssueResolver.test.ts index bc5c41377..0d7352139 100644 --- a/apps/desktop/src/main/services/prs/prIssueResolver.test.ts +++ b/apps/desktop/src/main/services/prs/prIssueResolver.test.ts @@ -338,7 +338,7 @@ describe("launchPrIssueResolutionChat", () => { modelId: "openai/gpt-5.4-codex", surface: "work", sessionProfile: "workflow", - unifiedPermissionMode: "edit", + permissionMode: "edit", })); expect(updateMeta).toHaveBeenCalledWith({ sessionId: "session-1", title: "Resolve PR #80 issues" }); expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/apps/desktop/src/main/services/prs/prIssueResolver.ts b/apps/desktop/src/main/services/prs/prIssueResolver.ts index 41f12cdb2..e50ba2203 100644 --- a/apps/desktop/src/main/services/prs/prIssueResolver.ts +++ b/apps/desktop/src/main/services/prs/prIssueResolver.ts @@ -409,7 +409,7 @@ export async function launchPrIssueResolutionChat( model: descriptor.id, modelId: descriptor.id, ...(reasoningEffort ? { reasoningEffort } : {}), - unifiedPermissionMode: mapPermissionMode(args.permissionMode) as import("../../../shared/types").AgentChatUnifiedPermissionMode, + permissionMode: mapPermissionMode(args.permissionMode), surface: "work", sessionProfile: "workflow", }); diff --git a/apps/desktop/src/main/services/prs/prService.integrationCommit.test.ts b/apps/desktop/src/main/services/prs/prService.integrationCommit.test.ts index 8032bcc29..5974ab5f1 100644 --- a/apps/desktop/src/main/services/prs/prService.integrationCommit.test.ts +++ b/apps/desktop/src/main/services/prs/prService.integrationCommit.test.ts @@ -333,4 +333,200 @@ describe("prService.commitIntegration", () => { expect(proposal.steps.find((step) => step.laneId === secondLane.id)?.conflictingFiles[0]?.path).toBe("src/conflicted.ts"); expect(runGitMergeTreeMock).toHaveBeenCalledOnce(); }); + + it("does not read conflict previews through symlinked worktree escapes during simulation", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-integration-symlink-preview-")); + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-integration-symlink-outside-")); + const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); + const projectId = "proj-integration-symlink-preview"; + + try { + const baseLane = makeLane("lane-main", "main", "refs/heads/main", root, { + laneType: "primary", + }); + const conflictLane = makeLane("lane-conflict", "computer-use", "refs/heads/feature/computer-use", path.join(root, "conflict")); + + await seedProject(db, projectId, root); + await seedLane(db, projectId, baseLane); + await seedLane(db, projectId, conflictLane); + + runGitOrThrowMock.mockImplementation(async (args: string[]) => { + if (args[0] === "rev-parse" && args[1] === "main") return "base-sha"; + if (args[0] === "rev-parse" && args[1] === "feature/computer-use") return "computer-sha"; + return ""; + }); + + runGitMergeTreeMock.mockResolvedValue({ + exitCode: 0, + stdout: "", + stderr: "", + mergeBase: "base-sha", + branchA: "base-sha", + branchB: "computer-sha", + conflicts: [], + treeOid: null, + usedMergeBaseFlag: true, + usedWriteTree: true, + }); + + runGitMock.mockImplementation(async (args: string[], options?: { cwd?: string }) => { + if (args[0] === "rev-list" || args[0] === "diff") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "rev-parse" && args[1] === "--short" && args[2] === "computer-sha") { + return { exitCode: 0, stdout: "computer", stderr: "" }; + } + if (args[0] === "merge" && args[1] === "--abort") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "worktree" && args[1] === "remove") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "merge") { + fs.writeFileSync(path.join(outsideDir, "secret.ts"), "<<<<<<< ours\nleft\n=======\nright\n>>>>>>> theirs\n", "utf8"); + fs.mkdirSync(options!.cwd!, { recursive: true }); + fs.symlinkSync(outsideDir, path.join(options!.cwd!, "linked")); + return { exitCode: 1, stdout: "", stderr: "merge conflict" }; + } + if (args[0] === "status" && options?.cwd?.includes(`${path.sep}worktree`)) { + return { exitCode: 0, stdout: "UU linked/secret.ts\n", stderr: "" }; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }); + + const { createPrService } = await createServiceModule(); + const service = createPrService({ + db, + logger: createLogger() as any, + projectId, + projectRoot: root, + laneService: { + list: async () => [baseLane, conflictLane], + } as any, + operationService: {} as any, + githubService: { + getRepoOrThrow: vi.fn(), + apiRequest: vi.fn(), + } as any, + aiIntegrationService: undefined, + projectConfigService: { + get: () => ({ effective: { providerMode: "guest" } }), + } as any, + conflictService: undefined, + openExternal: async () => {}, + }); + + const proposal = await service.simulateIntegration({ + sourceLaneIds: [conflictLane.id], + baseBranch: "main", + }); + + expect(proposal.steps[0]?.conflictingFiles[0]).toMatchObject({ + path: "linked/secret.ts", + conflictType: null, + conflictMarkers: "", + }); + } finally { + db.close(); + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + it("ignores symlinked conflict marker files that escape the integration lane during recheck", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-integration-symlink-recheck-")); + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-integration-recheck-outside-")); + let db: Awaited> | null = null; + try { + db = await openKvDb(path.join(root, ".ade.db"), createLogger()); + const projectId = "proj-integration-symlink-recheck"; + const now = "2026-03-12T00:00:00.000Z"; + + const baseLane = makeLane("lane-main", "main", "refs/heads/main", root, { + laneType: "primary", + }); + const sourceLane = makeLane("lane-source", "source", "refs/heads/feature/source", path.join(root, "source")); + const integrationLane = makeLane("lane-int", "integration", "refs/heads/integration/test", path.join(root, "integration")); + + fs.mkdirSync(integrationLane.worktreePath, { recursive: true }); + fs.writeFileSync(path.join(outsideDir, "secret.ts"), "<<<<<<< ours\nleft\n=======\nright\n>>>>>>> theirs\n", "utf8"); + fs.symlinkSync(outsideDir, path.join(integrationLane.worktreePath, "linked")); + + await seedProject(db, projectId, root); + await seedLane(db, projectId, baseLane); + await seedLane(db, projectId, sourceLane); + await seedLane(db, projectId, integrationLane); + + const proposalId = "proposal-symlink-recheck"; + db.run( + `insert into integration_proposals( + id, project_id, source_lane_ids_json, base_branch, steps_json, pairwise_results_json, lane_summaries_json, overall_outcome, created_at, status, integration_lane_id, resolution_state_json + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + proposalId, + projectId, + JSON.stringify([sourceLane.id]), + "main", + JSON.stringify([ + { laneId: sourceLane.id, laneName: sourceLane.name, position: 0, outcome: "conflict", conflictingFiles: [{ path: "linked/secret.ts" }], diffStat: { insertions: 0, deletions: 0, filesChanged: 1 } }, + ]), + JSON.stringify([]), + JSON.stringify([]), + "conflict", + now, + "committed", + integrationLane.id, + JSON.stringify({ + integrationLaneId: integrationLane.id, + stepResolutions: { [sourceLane.id]: "pending" }, + activeWorkerStepId: null, + activeLaneId: null, + updatedAt: now, + }), + ], + ); + + runGitMock.mockImplementation(async (args: string[]) => { + if (args[0] === "status") { + return { exitCode: 0, stdout: " M linked/secret.ts\n", stderr: "" }; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }); + + const { createPrService } = await createServiceModule(); + const service = createPrService({ + db, + logger: createLogger() as any, + projectId, + projectRoot: root, + laneService: { + list: async () => [baseLane, sourceLane, integrationLane], + } as any, + operationService: {} as any, + githubService: { + getRepoOrThrow: vi.fn(), + apiRequest: vi.fn(), + } as any, + aiIntegrationService: undefined, + projectConfigService: { + get: () => ({ effective: { providerMode: "guest" } }), + } as any, + conflictService: undefined, + openExternal: async () => {}, + }); + + const result = await service.recheckIntegrationStep({ proposalId, laneId: sourceLane.id }); + + expect(result).toMatchObject({ + resolution: "resolved", + remainingConflictFiles: [], + allResolved: true, + message: null, + }); + } finally { + db?.close(); + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index c2a587ca7..00232f7de 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -101,7 +101,7 @@ import { extractFirstJsonObject } from "../ai/utils"; import { buildIntegrationPreflight } from "./integrationPlanning"; import { hasMergeConflictMarkers, parseGitStatusPorcelain } from "./integrationValidation"; import { fetchRemoteTrackingBranch } from "../shared/queueRebase"; -import { asNumber, asString, getErrorMessage, normalizeBranchName, nowIso } from "../shared/utils"; +import { asNumber, asString, getErrorMessage, normalizeBranchName, nowIso, resolvePathWithinRoot } from "../shared/utils"; type PullRequestRow = { id: string; @@ -261,8 +261,10 @@ const EMPTY_CONFLICT_EXCERPTS: ConflictExcerpts = { function readConflictFilePreviewFromWorktree(worktreePath: string, filePath: string): IntegrationProposalStep["conflictingFiles"][number] { const root = path.resolve(worktreePath); - const absPath = path.resolve(root, filePath); - if (absPath !== root && !absPath.startsWith(`${root}${path.sep}`)) { + let absPath: string; + try { + absPath = resolvePathWithinRoot(root, filePath); + } catch { return { path: filePath, ...EMPTY_CONFLICT_EXCERPTS }; } @@ -2488,8 +2490,10 @@ export function createPrService({ for (const rawPath of filePaths) { const filePath = rawPath.trim(); if (!filePath) continue; - const absPath = path.resolve(worktreeRoot, filePath); - if (absPath !== worktreeRoot && !absPath.startsWith(`${worktreeRoot}${path.sep}`)) { + let absPath: string; + try { + absPath = resolvePathWithinRoot(worktreeRoot, filePath); + } catch { continue; } try { diff --git a/apps/desktop/src/main/services/prs/queueLandingService.test.ts b/apps/desktop/src/main/services/prs/queueLandingService.test.ts index 3be084d0e..2c29cf451 100644 --- a/apps/desktop/src/main/services/prs/queueLandingService.test.ts +++ b/apps/desktop/src/main/services/prs/queueLandingService.test.ts @@ -257,4 +257,235 @@ describe("queueLandingService", () => { expect(completed.entries[0]?.resolvedByAi).toBe(true); expect(land).toHaveBeenCalledTimes(2); }); + + it("rejects an invalid state transition and keeps the entry in its current state", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-queue-guard-")); + const db = await openKvDb(path.join(repoRoot, ".ade.db"), createLogger()); + const projectId = "proj-queue-guard"; + await seedProject(db, projectId, repoRoot); + db.run( + `insert into pr_groups(id, project_id, group_type, name, auto_rebase, ci_gating, target_branch, created_at) + values (?, ?, 'queue', ?, 1, 0, ?, ?)`, + ["group-guard", projectId, "Queue Guard", "main", "2026-03-09T00:00:00.000Z"], + ); + + // First land call fails with a non-merge-conflict error (entry goes to "failed"). + // Second land call succeeds (for the second entry if it gets reached). + const land = vi.fn() + .mockResolvedValueOnce({ + prId: "pr-fail", + prNumber: 1, + success: false, + mergeCommitSha: null, + branchDeleted: false, + laneArchived: false, + error: "Branch protection rule violations found.", + }) + .mockResolvedValueOnce({ + prId: "pr-ok", + prNumber: 2, + success: true, + mergeCommitSha: "sha-ok", + branchDeleted: true, + laneArchived: false, + error: null, + }); + + const service = createQueueLandingService({ + db, + logger: createLogger(), + projectId, + prService: { + land, + listGroupPrs: async () => [ + { id: "pr-fail", laneId: "lane-1", title: "Will Fail", headBranch: "feature/lane-1", githubPrNumber: 1, githubUrl: "https://example.com/pr/1", createdAt: "2026-03-09T10:00:00.000Z" }, + { id: "pr-ok", laneId: "lane-1", title: "Should Skip", headBranch: "feature/lane-1", githubPrNumber: 2, githubUrl: "https://example.com/pr/2", createdAt: "2026-03-09T11:00:00.000Z" }, + ] as any, + getStatus: async (prId: string) => ({ + prId, + state: "open", + checksStatus: "passing", + reviewStatus: "approved", + isMergeable: true, + mergeConflicts: false, + behindBaseBy: 0, + }), + }, + laneService: { + list: async () => [{ id: "lane-main", branchRef: "main", baseRef: "main" }], + getLaneBaseAndBranch: () => ({ worktreePath: repoRoot, branchRef: "feature/lane-1", baseRef: "main" }), + } as any, + conflictService: null, + emitEvent: () => {}, + }); + + // Start the queue; first entry will fail, queue pauses. + await service.startQueue({ groupId: "group-guard", method: "squash" }); + const paused = await waitFor( + () => service.getQueueStateByGroup("group-guard"), + (state) => state.state === "paused", + ); + + expect(paused.entries[0]!.state).toBe("failed"); + expect(paused.entries[1]!.state).toBe("pending"); + + // Set up entries so the loop will encounter a "failed" entry it cannot + // transition to "landing". entry[0] is "landed" (the loop skips it), + // entry[1] is "failed" (guardTransition rejects failed→landing). + // Put currentPosition at 0 so resumeQueue sees entry[0] = "landed" and + // does NOT reset it (resumeQueue only resets failed/paused/resolving/landing). + const entriesForGuardTest = paused.entries.map((e, i) => ({ + ...e, + state: i === 0 ? "landed" : "failed", + })); + db.run( + "update queue_landing_state set state = 'paused', entries_json = ?, current_position = 0 where id = ?", + [JSON.stringify(entriesForGuardTest), paused.queueId], + ); + + // Create a second service instance pointing at the same DB, then resume. + const service2 = createQueueLandingService({ + db, + logger: createLogger(), + projectId, + prService: { + land, + listGroupPrs: async () => [] as any, + getStatus: async (prId: string) => ({ + prId, + state: "open", + checksStatus: "passing", + reviewStatus: "approved", + isMergeable: true, + mergeConflicts: false, + behindBaseBy: 0, + }), + }, + laneService: { + list: async () => [{ id: "lane-main", branchRef: "main", baseRef: "main" }], + getLaneBaseAndBranch: () => ({ worktreePath: repoRoot, branchRef: "feature/lane-1", baseRef: "main" }), + } as any, + conflictService: null, + emitEvent: () => {}, + }); + + // resumeQueue sets the queue to "landing" and launches the loop. + // The loop skips entry[0] (landed), hits entry[1] (failed), and + // guardTransition rejects failed→landing so the loop exits immediately. + const resumed = service2.resumeQueue({ queueId: paused.queueId }); + expect(resumed).not.toBeNull(); + expect(resumed!.state).toBe("landing"); + + // The landing loop runs asynchronously. When guardTransition rejects the + // failed→landing transition, the loop returns silently without updating DB + // state — there is no observable state change to poll for. Yield to the + // event loop so the async loop body executes, then verify the invariants. + await new Promise((resolve) => setTimeout(resolve, 100)); + + const finalState = service2.getQueueState(paused.queueId); + expect(finalState).not.toBeNull(); + // entry[1] must still be "failed" — guardTransition prevented it from becoming "landing" + expect(finalState!.entries[1]!.state).toBe("failed"); + // land was called exactly once (the original failure) — no additional calls + expect(land).toHaveBeenCalledTimes(1); + }); + + it("stops the landing loop when the queue is cancelled externally", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-queue-cancel-")); + const db = await openKvDb(path.join(repoRoot, ".ade.db"), createLogger()); + const projectId = "proj-queue-cancel"; + await seedProject(db, projectId, repoRoot); + db.run( + `insert into pr_groups(id, project_id, group_type, name, auto_rebase, ci_gating, target_branch, created_at) + values (?, ?, 'queue', ?, 1, 0, ?, ?)`, + ["group-cancel", projectId, "Queue Cancel", "main", "2026-03-09T00:00:00.000Z"], + ); + + // Controllable deferred promises so the test drives timing explicitly + let resolveSlowLand!: () => void; + const slowLandStarted = new Promise((resolve) => { resolveSlowLand = resolve; }); + let releaseSlowLand!: () => void; + const slowLandGate = new Promise((resolve) => { releaseSlowLand = resolve; }); + + const land = vi.fn().mockImplementation(async ({ prId }: { prId: string }) => { + if (prId === "pr-slow") { + // Signal that the slow land has started, then wait for the test to release + resolveSlowLand(); + await slowLandGate; + return { + prId: "pr-slow", + prNumber: 1, + success: true, + mergeCommitSha: "sha-slow", + branchDeleted: true, + laneArchived: false, + error: null, + }; + } + return { + prId, + prNumber: 2, + success: true, + mergeCommitSha: "sha-fast", + branchDeleted: true, + laneArchived: false, + error: null, + }; + }); + + const service = createQueueLandingService({ + db, + logger: createLogger(), + projectId, + prService: { + land, + listGroupPrs: async () => [ + { id: "pr-slow", laneId: "lane-1", title: "Slow PR", headBranch: "feature/lane-1", githubPrNumber: 1, githubUrl: "https://example.com/pr/1", createdAt: "2026-03-09T10:00:00.000Z" }, + { id: "pr-fast", laneId: "lane-1", title: "Fast PR", headBranch: "feature/lane-1", githubPrNumber: 2, githubUrl: "https://example.com/pr/2", createdAt: "2026-03-09T11:00:00.000Z" }, + ] as any, + getStatus: async (prId: string) => ({ + prId, + state: "open", + checksStatus: "passing", + reviewStatus: "approved", + isMergeable: true, + mergeConflicts: false, + behindBaseBy: 0, + }), + }, + laneService: { + list: async () => [{ id: "lane-main", branchRef: "main", baseRef: "main" }], + getLaneBaseAndBranch: () => ({ worktreePath: repoRoot, branchRef: "feature/lane-1", baseRef: "main" }), + } as any, + conflictService: null, + emitEvent: () => {}, + }); + + const queueState = await service.startQueue({ groupId: "group-cancel", method: "squash" }); + const cancelQueueId = queueState.queueId; + + // Wait for the land mock to actually be entered before cancelling + await slowLandStarted; + db.run( + "update queue_landing_state set state = 'cancelled', completed_at = ? where id = ?", + [new Date().toISOString(), cancelQueueId], + ); + + // Release the slow land so the loop can proceed and notice the cancellation + releaseSlowLand(); + + // Poll until the service reflects the cancelled state + const finalState = await waitFor( + () => service.getQueueStateByGroup("group-cancel"), + (state) => state.state === "cancelled", + ); + expect(finalState).not.toBeNull(); + // The queue should be cancelled (as we set it externally). + expect(finalState!.state).toBe("cancelled"); + // The second entry (pr-fast) should never have been processed. + // land was called once for pr-slow, but the loop should have bailed + // after noticing the cancellation via isQueueCancelledOrDone(). + expect(land).toHaveBeenCalledTimes(1); + expect(land.mock.calls[0]![0].prId).toBe("pr-slow"); + }); }); diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 54fa417bc..c20655f0d 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -439,6 +439,22 @@ describe("ptyService", () => { expect(() => service.dispose({ ptyId })).not.toThrow(); }); + it("removes per-session MCP config artifacts when a tool session is manually closed", async () => { + const { service } = createHarness(); + const { ptyId, sessionId } = await service.create({ + laneId: "lane-1", + title: "Claude session", + cols: 80, + rows: 24, + toolType: "claude", + startupCommand: "claude", + }); + + service.dispose({ ptyId }); + + expect(mocks.unlinkSync).toHaveBeenCalledWith(`/tmp/test-project/.ade/mcp-configs/terminal-${sessionId}.json`); + }); + it("handles orphaned sessions (PTY not in map but session exists)", async () => { const { service, sessionService, broadcastExit, logger } = createHarness(); sessionService.get.mockReturnValue({ diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 656b42924..c9f1251ea 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -369,13 +369,7 @@ export function createPtyService({ } catch { // ignore } - for (const cleanupPath of entry.cleanupPaths) { - try { - fs.unlinkSync(cleanupPath); - } catch { - // best effort - } - } + cleanupEntryPaths(entry); flushPreview(entry); const endedAt = new Date().toISOString(); @@ -493,6 +487,16 @@ export function createPtyService({ } }; + const cleanupEntryPaths = (entry: PtyEntry) => { + for (const cleanupPath of entry.cleanupPaths) { + try { + fs.unlinkSync(cleanupPath); + } catch { + // best effort + } + } + }; + return { async create(args: PtyCreateArgs): Promise { const { laneId, title } = args; @@ -881,6 +885,7 @@ export function createPtyService({ } catch { // ignore } + cleanupEntryPaths(entry); try { entry.pty.kill(); } catch { diff --git a/apps/desktop/src/main/services/shared/utils.test.ts b/apps/desktop/src/main/services/shared/utils.test.ts index 266df0216..da086a5b6 100644 --- a/apps/desktop/src/main/services/shared/utils.test.ts +++ b/apps/desktop/src/main/services/shared/utils.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; import { isRecord, asString, @@ -14,6 +17,7 @@ import { parseDiffNameOnly, safeJsonParse, isWithinDir, + resolvePathWithinRoot, toOptionalString, normalizeRelative, normalizeBranchName, @@ -218,6 +222,96 @@ describe("isWithinDir", () => { }); }); +describe("resolvePathWithinRoot", () => { + it("rejects symlink escapes for existing paths", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-utils-root-")); + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-utils-outside-")); + const linkPath = path.join(root, "linked-outside"); + try { + fs.symlinkSync(outsideDir, linkPath); + expect(() => resolvePathWithinRoot(root, path.join(linkPath, "secret.txt"), { allowMissing: true })).toThrow( + /Path escapes root/, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + it("allows a normal child path when intermediate segments do not exist yet", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-utils-root-")); + try { + const target = "nested/new-file.txt"; + const resolved = resolvePathWithinRoot(root, target, { allowMissing: true }); + expect(path.basename(resolved)).toBe("new-file.txt"); + expect(resolved.endsWith(`${path.sep}nested${path.sep}new-file.txt`)).toBe(true); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("resolves relative child paths against the provided root", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-utils-root-")); + try { + const resolved = resolvePathWithinRoot(root, "nested/new-file.txt", { allowMissing: true }); + expect(path.basename(resolved)).toBe("new-file.txt"); + expect(resolved.endsWith(`${path.sep}nested${path.sep}new-file.txt`)).toBe(true); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects symlink escapes that point outside the root", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-utils-root-")); + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-utils-outside-")); + try { + const linkPath = path.join(tempRoot, "linked"); + const outsideFile = path.join(outsideDir, "secret.txt"); + fs.writeFileSync(outsideFile, "secret", "utf8"); + fs.symlinkSync(outsideDir, linkPath, "dir"); + + expect(() => resolvePathWithinRoot(tempRoot, path.join(linkPath, "secret.txt"))).toThrow("Path escapes root"); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + it("rejects relative symlink traversals hidden by .. segments", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-utils-root-")); + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-utils-outside-")); + try { + fs.symlinkSync(outsideDir, path.join(tempRoot, "linked"), "dir"); + expect(() => resolvePathWithinRoot(tempRoot, "linked/../secret.txt", { allowMissing: true })).toThrow("Path escapes root"); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + it("rethrows non-missing lstat errors while resolving paths", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-utils-root-")); + const rootReal = fs.realpathSync(root); + const originalLstatSync = fs.lstatSync.bind(fs); + const blockedPath = path.join(rootReal, "blocked"); + const permissionError = Object.assign(new Error("permission denied"), { code: "EACCES" as const }); + + const spy = vi.spyOn(fs, "lstatSync").mockImplementation(((filePath: fs.PathLike) => { + if (String(filePath) === blockedPath) { + throw permissionError; + } + return originalLstatSync(filePath); + }) as typeof fs.lstatSync); + + try { + expect(() => resolvePathWithinRoot(root, "blocked/child.txt", { allowMissing: true })).toThrow(permissionError); + } finally { + spy.mockRestore(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("toOptionalString", () => { it("returns trimmed string for non-empty values", () => { expect(toOptionalString(" hello ")).toBe("hello"); diff --git a/apps/desktop/src/main/services/shared/utils.ts b/apps/desktop/src/main/services/shared/utils.ts index 57bd3820f..7da46af39 100644 --- a/apps/desktop/src/main/services/shared/utils.ts +++ b/apps/desktop/src/main/services/shared/utils.ts @@ -149,6 +149,309 @@ export function isWithinDir(root: string, candidate: string): boolean { return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); } +function realpathExisting(filePath: string): string { + return typeof fs.realpathSync.native === "function" + ? fs.realpathSync.native(filePath) + : fs.realpathSync(filePath); +} + +function candidateExpressionFromRoot(root: string, candidate: string): string { + const normalizedRoot = path.resolve(root); + if (path.isAbsolute(candidate)) return candidate; + if (!candidate.length) return normalizedRoot; + return normalizedRoot.endsWith(path.sep) + ? `${normalizedRoot}${candidate}` + : `${normalizedRoot}${path.sep}${candidate}`; +} + +function splitPathSegments(filePath: string): { root: string; segments: string[] } { + const parsed = path.parse(filePath); + const remainder = filePath.slice(parsed.root.length); + return { + root: parsed.root, + segments: remainder.split(/[\\/]+/).filter((segment) => segment.length > 0), + }; +} + +function pathEntryExists(filePath: string): boolean { + try { + fs.lstatSync(filePath); + return true; + } catch (error) { + const code = error && typeof error === "object" + ? ("code" in error ? (error as NodeJS.ErrnoException).code : undefined) + : undefined; + if (code === "ENOENT" || code === "ENOTDIR") { + return false; + } + throw error; + } +} + +function resolveCandidatePath( + root: string, + candidate: string, + opts: { allowMissing?: boolean } = {}, +): string { + const expression = candidateExpressionFromRoot(root, candidate); + const { root: candidateRoot, segments } = splitPathSegments(expression); + let cursor = candidateRoot; + + for (const segment of segments) { + if (segment === "." || segment === "") continue; + if (segment === "..") { + cursor = path.dirname(cursor); + continue; + } + const nextPath = path.join(cursor, segment); + if (pathEntryExists(nextPath)) { + cursor = realpathExisting(nextPath); + continue; + } + if (!opts.allowMissing) { + throw new Error(`Path does not exist: ${candidate}`); + } + cursor = nextPath; + } + + return cursor; +} + +/** + * Resolve `candidate` against the real filesystem layout and ensure it stays + * inside `root`, even when symlinks are involved. + */ +export function resolvePathWithinRoot( + root: string, + candidate: string, + opts: { allowMissing?: boolean } = {}, +): string { + const rootReal = realpathExisting(path.resolve(root)); + const candidateReal = resolveCandidatePath(root, candidate, opts); + if (!isWithinDir(rootReal, candidateReal)) { + throw new Error("Path escapes root"); + } + return candidateReal; +} + +function openReadOnlyNoFollow(filePath: string): number { + const noFollowFlag = typeof fs.constants.O_NOFOLLOW === "number" + ? fs.constants.O_NOFOLLOW + : 0; + return fs.openSync(filePath, fs.constants.O_RDONLY | noFollowFlag); +} + +function openWriteNoFollow(filePath: string, flags: number, mode: number): number { + const noFollowFlag = typeof fs.constants.O_NOFOLLOW === "number" + ? fs.constants.O_NOFOLLOW + : 0; + return fs.openSync(filePath, flags | noFollowFlag, mode); +} + +function isPathAlignedWithRoot(rootReal: string, candidatePath: string): boolean { + return isWithinDir(candidatePath, rootReal) || isWithinDir(rootReal, candidatePath); +} + +function ensureDirectoryChainWithinRoot( + rootReal: string, + candidate: string, + opts: { createMissing?: boolean } = {}, +): string { + const { root: candidateRoot, segments } = splitPathSegments(candidate); + let cursor = realpathExisting(candidateRoot); + + if (!isPathAlignedWithRoot(rootReal, cursor)) { + throw new Error("Path escapes root"); + } + + for (const segment of segments) { + if (segment === "." || segment === "") continue; + if (segment === "..") { + cursor = path.dirname(cursor); + if (!isPathAlignedWithRoot(rootReal, cursor)) { + throw new Error("Path escapes root"); + } + continue; + } + + const nextPath = path.join(cursor, segment); + try { + fs.lstatSync(nextPath); + const resolvedPath = realpathExisting(nextPath); + if (!isPathAlignedWithRoot(rootReal, resolvedPath)) { + throw new Error("Path escapes root"); + } + if (!fs.statSync(resolvedPath).isDirectory()) { + throw new Error(`Path is not a directory: ${nextPath}`); + } + cursor = resolvedPath; + } catch (error) { + const code = error && typeof error === "object" + ? ("code" in error ? (error as NodeJS.ErrnoException).code : undefined) + : undefined; + if (code !== "ENOENT" || !opts.createMissing) { + throw error; + } + if (!isPathAlignedWithRoot(rootReal, nextPath)) { + throw new Error("Path escapes root"); + } + fs.mkdirSync(nextPath); + const createdPath = realpathExisting(nextPath); + if (!isPathAlignedWithRoot(rootReal, createdPath)) { + throw new Error("Path escapes root"); + } + cursor = createdPath; + } + } + + return cursor; +} + +function prepareMutationTargetWithinRoot( + root: string, + candidate: string, +): { rootReal: string; parentPath: string; targetPath: string } { + const rootReal = realpathExisting(path.resolve(root)); + const expression = candidateExpressionFromRoot(root, candidate); + const parentExpression = path.dirname(expression); + const parentPath = ensureDirectoryChainWithinRoot(rootReal, parentExpression, { createMissing: true }); + const targetPath = path.join(parentPath, path.basename(expression)); + if (!isWithinDir(rootReal, targetPath)) { + throw new Error("Path escapes root"); + } + return { rootReal, parentPath, targetPath }; +} + +function writeFileByDescriptor( + filePath: string, + data: string | NodeJS.ArrayBufferView, + options?: fs.WriteFileOptions | BufferEncoding, +): void { + const mode = typeof options === "object" && options != null && typeof options.mode === "number" + ? options.mode + : 0o666; + const fd = openWriteNoFollow(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC, mode); + try { + fs.writeFileSync(fd, data, options as fs.WriteFileOptions | BufferEncoding | undefined); + } finally { + fs.closeSync(fd); + } +} + +/** + * Re-resolve and validate a file at open time, then read it through the file + * descriptor so callers do not rely on a previously checked path string. + */ +export function readFileWithinRootSecure(root: string, candidate: string): Buffer { + let expectedPath: string; + try { + expectedPath = resolvePathWithinRoot(root, candidate, { allowMissing: false }); + } catch (error) { + if (error instanceof Error && error.message.startsWith("Path does not exist:")) { + const missing = new Error(error.message) as NodeJS.ErrnoException; + missing.code = "ENOENT"; + throw missing; + } + throw error; + } + const fd = openReadOnlyNoFollow(expectedPath); + try { + const openStat = fs.fstatSync(fd); + if (!openStat.isFile()) { + throw new Error("Path is not a regular file"); + } + const currentPath = resolvePathWithinRoot(root, expectedPath, { allowMissing: false }); + const currentStat = fs.statSync(currentPath); + if (openStat.dev !== currentStat.dev || openStat.ino !== currentStat.ino) { + throw new Error("Path changed during open"); + } + return fs.readFileSync(fd); + } finally { + fs.closeSync(fd); + } +} + +export function secureMkdirWithinRoot(root: string, candidate: string): string { + const rootReal = realpathExisting(path.resolve(root)); + const expression = candidateExpressionFromRoot(root, candidate); + return ensureDirectoryChainWithinRoot(rootReal, expression, { createMissing: true }); +} + +export function secureWriteFileWithinRoot( + root: string, + candidate: string, + data: string | NodeJS.ArrayBufferView, + options?: fs.WriteFileOptions | BufferEncoding, +): string { + const { targetPath } = prepareMutationTargetWithinRoot(root, candidate); + writeFileByDescriptor(targetPath, data, options); + return targetPath; +} + +export function secureWriteTextAtomicWithinRoot(root: string, candidate: string, text: string): string { + const initialTarget = prepareMutationTargetWithinRoot(root, candidate); + const tmpPath = path.join( + initialTarget.parentPath, + `.${path.basename(initialTarget.targetPath) || "tmp"}.${randomUUID()}.tmp`, + ); + writeFileByDescriptor(tmpPath, text, "utf8"); + try { + const { targetPath } = prepareMutationTargetWithinRoot(root, candidate); + fs.renameSync(tmpPath, targetPath); + return targetPath; + } catch (error) { + try { + fs.unlinkSync(tmpPath); + } catch { + // ignore cleanup errors + } + throw error; + } +} + +export function secureCopyFileIntoRoot(root: string, candidate: string, sourcePath: string): string { + const initialTarget = prepareMutationTargetWithinRoot(root, candidate); + const tmpPath = path.join( + initialTarget.parentPath, + `.${path.basename(initialTarget.targetPath) || "tmp"}.${randomUUID()}.tmp`, + ); + fs.copyFileSync(sourcePath, tmpPath); + try { + const { targetPath } = prepareMutationTargetWithinRoot(root, candidate); + fs.renameSync(tmpPath, targetPath); + return targetPath; + } catch (error) { + try { + fs.unlinkSync(tmpPath); + } catch { + // ignore cleanup errors + } + throw error; + } +} + +export function secureCopyPathIntoRoot(root: string, candidate: string, sourcePath: string): string { + const stat = fs.statSync(sourcePath); + if (stat.isDirectory()) { + const targetPath = secureMkdirWithinRoot(root, candidate); + for (const entry of fs.readdirSync(sourcePath, { withFileTypes: true })) { + secureCopyPathIntoRoot(root, path.join(candidate, entry.name), path.join(sourcePath, entry.name)); + } + return targetPath; + } + return secureCopyFileIntoRoot(root, candidate, sourcePath); +} + +export function secureRenameWithinRoot(root: string, sourceCandidate: string, targetCandidate: string): { + sourcePath: string; + targetPath: string; +} { + const sourcePath = resolvePathWithinRoot(root, sourceCandidate, { allowMissing: false }); + const { targetPath } = prepareMutationTargetWithinRoot(root, targetCandidate); + fs.renameSync(sourcePath, targetPath); + return { sourcePath, targetPath }; +} + // ── String helpers ────────────────────────────────────────────────── /** Return trimmed string or null if empty/non-string. */ diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index 3a8af5936..1c146daa7 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -359,6 +359,8 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { fs.writeFileSync(path.join(workspaceRoot, "notes.txt"), "initial", "utf8"); fs.writeFileSync(path.join(workspaceRoot, ".git", "config"), "[core]\n", "utf8"); const artifactPath = path.join(projectRoot, ".ade", "artifacts", "computer-use", "shot.png"); + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-sync-artifact-outside-")); + const outsideArtifact = path.join(outsideDir, "outside-artifact.txt"); fs.writeFileSync(artifactPath, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00])); const host = createSyncHostService({ @@ -459,6 +461,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { activeDisposers.push(async () => { await host.dispose(); brainDb.close(); + fs.rmSync(outsideDir, { recursive: true, force: true }); }); const client = await connectClient({ @@ -503,6 +506,25 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { expect(artifactPayload.result.encoding).toBe("base64"); expect(Buffer.from(artifactPayload.result.content, "base64").length).toBeGreaterThan(0); + const artifactLinkPath = path.join(projectRoot, ".ade", "artifacts", "linked-secret.txt"); + fs.writeFileSync(outsideArtifact, "secret", "utf8"); + fs.symlinkSync(outsideArtifact, artifactLinkPath); + + client.ws.send(encodeSyncEnvelope({ + type: "file_request", + requestId: "artifact-link-read", + payload: { + action: "readArtifact", + args: { + path: path.relative(projectRoot, artifactLinkPath), + }, + }, + })); + const linkedArtifactResponse = await client.queue.next("file_response"); + const linkedArtifactPayload = linkedArtifactResponse.payload as { ok: boolean; error?: { message: string } }; + expect(linkedArtifactPayload.ok).toBe(false); + expect(linkedArtifactPayload.error?.message).toMatch(/\.ade\/artifacts/i); + client.ws.send(encodeSyncEnvelope({ type: "file_request", requestId: "git-blocked", @@ -518,6 +540,9 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const blockedPayload = blockedResponse.payload as { ok: boolean; error?: { message: string } }; expect(blockedPayload.ok).toBe(false); expect(blockedPayload.error?.message).toMatch(/\.git/i); + + fs.rmSync(artifactLinkPath, { force: true }); + fs.rmSync(outsideArtifact, { force: true }); }); it("streams terminal snapshots, live output, exit events, and supports the quick-run seed command", async () => { diff --git a/apps/desktop/src/main/services/sync/syncHostService.ts b/apps/desktop/src/main/services/sync/syncHostService.ts index b027ee21d..eff56ab6e 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.ts @@ -15,7 +15,6 @@ import type { FilesWorkspace, PtyDataEvent, PtyExitEvent, - SyncChatEventPayload, SyncBrainStatusPayload, SyncChangesetBatchPayload, SyncCommandPayload, @@ -52,7 +51,7 @@ import type { createPrService } from "../prs/prService"; import type { createSessionService } from "../sessions/sessionService"; import type { createComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; import type { AdeDb } from "../state/kvDb"; -import { hasNullByte, isWithinDir, normalizeRelative, nowIso, toOptionalString } from "../shared/utils"; +import { hasNullByte, normalizeRelative, nowIso, resolvePathWithinRoot, toOptionalString } from "../shared/utils"; import type { DeviceRegistryService } from "./deviceRegistryService"; import { createSyncPairingStore } from "./syncPairingStore"; import { DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, DEFAULT_SYNC_HOST_PORT, encodeSyncEnvelope, mapPlatform, parseSyncEnvelope, wsDataToText } from "./syncProtocol"; @@ -527,8 +526,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { peer.chatTranscriptOffsets.set(sessionId, nextOffset); } for (const event of events) { - const chatEventPayload: SyncChatEventPayload = event; - send(peer.ws, "chat_event", chatEventPayload); + send(peer.ws, "chat_event", event); } } } @@ -574,13 +572,16 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const absolute = path.isAbsolute(candidate) ? candidate : path.resolve(args.projectRoot, candidate); - if (!isWithinDir(layout.artifactsDir, absolute)) { + let resolvedArtifactPath: string; + try { + resolvedArtifactPath = resolvePathWithinRoot(layout.artifactsDir, absolute); + } catch { throw new Error("Artifact path must resolve within .ade/artifacts."); } - if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) { + if (!fs.existsSync(resolvedArtifactPath) || !fs.statSync(resolvedArtifactPath).isFile()) { throw new Error("Artifact file does not exist."); } - return absolute; + return resolvedArtifactPath; } async function handleFileRequest(peer: PeerState, requestId: string | null, payload: SyncFileRequest): Promise { diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts new file mode 100644 index 000000000..aadd63331 --- /dev/null +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -0,0 +1,1085 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { createSyncRemoteCommandService } from "./syncRemoteCommandService"; +import type { SyncCommandPayload } from "../../../shared/types"; + +function createLogger() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +function createMockLaneService() { + return { + list: vi.fn().mockResolvedValue([]), + refreshSnapshots: vi.fn().mockResolvedValue({ lanes: [] }), + create: vi.fn().mockResolvedValue({ id: "lane-1" }), + createChild: vi.fn().mockResolvedValue({ id: "child-1" }), + createFromUnstaged: vi.fn().mockResolvedValue({ id: "unstaged-1" }), + attach: vi.fn().mockResolvedValue({ id: "attached-1" }), + adoptAttached: vi.fn().mockResolvedValue({ ok: true }), + rename: vi.fn(), + reparent: vi.fn().mockResolvedValue({ ok: true }), + updateAppearance: vi.fn(), + archive: vi.fn().mockResolvedValue(undefined), + unarchive: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + getStackChain: vi.fn().mockResolvedValue([]), + getChildren: vi.fn().mockResolvedValue([]), + rebaseStart: vi.fn().mockResolvedValue({ runId: "run-1" }), + rebasePush: vi.fn().mockResolvedValue({ ok: true }), + rebaseRollback: vi.fn().mockResolvedValue({ ok: true }), + rebaseAbort: vi.fn().mockResolvedValue({ ok: true }), + listStateSnapshots: vi.fn().mockResolvedValue([]), + getStateSnapshot: vi.fn().mockResolvedValue(null), + } as any; +} + +function createMockPrService() { + return { + listAll: vi.fn().mockResolvedValue([]), + refresh: vi.fn().mockResolvedValue(undefined), + listSnapshots: vi.fn().mockReturnValue([]), + getDetail: vi.fn().mockResolvedValue({}), + getStatus: vi.fn().mockResolvedValue({}), + getChecks: vi.fn().mockResolvedValue([]), + getReviews: vi.fn().mockResolvedValue([]), + getComments: vi.fn().mockResolvedValue([]), + getFiles: vi.fn().mockResolvedValue([]), + createFromLane: vi.fn().mockResolvedValue({ prId: "pr-1" }), + land: vi.fn().mockResolvedValue({ ok: true }), + closePr: vi.fn().mockResolvedValue(undefined), + reopenPr: vi.fn().mockResolvedValue(undefined), + requestReviewers: vi.fn().mockResolvedValue(undefined), + } as any; +} + +function createMockPtyService() { + return { + create: vi.fn().mockResolvedValue({ sessionId: "pty-1" }), + dispose: vi.fn().mockResolvedValue(undefined), + } as any; +} + +function createMockSessionService() { + return { + list: vi.fn().mockReturnValue([]), + get: vi.fn().mockReturnValue(null), + } as any; +} + +function createMockFileService() { + return { + writeTextAtomic: vi.fn(), + } as any; +} + +function createMockGitService() { + return { + stageFile: vi.fn().mockResolvedValue(undefined), + stageAll: vi.fn().mockResolvedValue(undefined), + unstageFile: vi.fn().mockResolvedValue(undefined), + unstageAll: vi.fn().mockResolvedValue(undefined), + discardFile: vi.fn().mockResolvedValue(undefined), + restoreStagedFile: vi.fn().mockResolvedValue(undefined), + commit: vi.fn().mockResolvedValue({ sha: "abc123" }), + generateCommitMessage: vi.fn().mockResolvedValue({ message: "feat: auto" }), + listRecentCommits: vi.fn().mockResolvedValue([]), + listCommitFiles: vi.fn().mockResolvedValue([]), + getCommitMessage: vi.fn().mockResolvedValue({ message: "msg" }), + revertCommit: vi.fn().mockResolvedValue(undefined), + cherryPickCommit: vi.fn().mockResolvedValue(undefined), + stashPush: vi.fn().mockResolvedValue(undefined), + listStashes: vi.fn().mockResolvedValue([]), + stashApply: vi.fn().mockResolvedValue(undefined), + stashPop: vi.fn().mockResolvedValue(undefined), + stashDrop: vi.fn().mockResolvedValue(undefined), + fetch: vi.fn().mockResolvedValue(undefined), + pull: vi.fn().mockResolvedValue(undefined), + getSyncStatus: vi.fn().mockResolvedValue(null), + sync: vi.fn().mockResolvedValue(undefined), + push: vi.fn().mockResolvedValue(undefined), + getConflictState: vi.fn().mockResolvedValue(null), + rebaseContinue: vi.fn().mockResolvedValue(undefined), + rebaseAbort: vi.fn().mockResolvedValue(undefined), + listBranches: vi.fn().mockResolvedValue([]), + checkoutBranch: vi.fn().mockResolvedValue(undefined), + } as any; +} + +function createMockDiffService() { + return { + getChanges: vi.fn().mockResolvedValue([]), + getFileDiff: vi.fn().mockResolvedValue({}), + } as any; +} + +function createMockAgentChatService() { + return { + listSessions: vi.fn().mockResolvedValue([]), + getSessionSummary: vi.fn().mockResolvedValue({}), + getChatTranscript: vi.fn().mockResolvedValue([]), + createSession: vi.fn().mockResolvedValue({ sessionId: "chat-1" }), + sendMessage: vi.fn().mockResolvedValue(undefined), + interrupt: vi.fn().mockResolvedValue(undefined), + steer: vi.fn().mockResolvedValue(undefined), + approveToolUse: vi.fn().mockResolvedValue(undefined), + respondToInput: vi.fn().mockResolvedValue(undefined), + resumeSession: vi.fn().mockResolvedValue(undefined), + updateSession: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn().mockResolvedValue(undefined), + getAvailableModels: vi.fn().mockResolvedValue([{ id: "model-1", modelId: "m1" }]), + } as any; +} + +function createMockConflictService() { + return { + getLaneStatus: vi.fn().mockResolvedValue(null), + listOverlaps: vi.fn().mockResolvedValue([]), + getBatchAssessment: vi.fn().mockResolvedValue({ lanes: [] }), + } as any; +} + +function makePayload(action: string, args: Record = {}): SyncCommandPayload { + return { commandId: `cmd-${Date.now()}`, action: action as any, args }; +} + +describe("createSyncRemoteCommandService", () => { + let laneService: ReturnType; + let prService: ReturnType; + let ptyService: ReturnType; + let sessionService: ReturnType; + let fileService: ReturnType; + let gitService: ReturnType; + let diffService: ReturnType; + let agentChatService: ReturnType; + let conflictService: ReturnType; + let service: ReturnType; + + beforeEach(() => { + laneService = createMockLaneService(); + prService = createMockPrService(); + ptyService = createMockPtyService(); + sessionService = createMockSessionService(); + fileService = createMockFileService(); + gitService = createMockGitService(); + diffService = createMockDiffService(); + agentChatService = createMockAgentChatService(); + conflictService = createMockConflictService(); + service = createSyncRemoteCommandService({ + laneService, + prService, + ptyService, + sessionService, + fileService, + gitService, + diffService, + agentChatService, + conflictService, + logger: createLogger() as any, + }); + }); + + // --------------------------------------------------------------- + // Introspection: getSupportedActions / getDescriptors / getPolicy + // --------------------------------------------------------------- + + describe("getSupportedActions", () => { + it("returns a non-empty array of action strings", () => { + const actions = service.getSupportedActions(); + expect(actions.length).toBeGreaterThan(0); + for (const action of actions) { + expect(typeof action).toBe("string"); + expect(action.length).toBeGreaterThan(0); + } + }); + + it("includes known representative actions from each category", () => { + const actions = service.getSupportedActions(); + expect(actions).toContain("lanes.list"); + expect(actions).toContain("lanes.create"); + expect(actions).toContain("prs.list"); + expect(actions).toContain("prs.createFromLane"); + expect(actions).toContain("git.commit"); + expect(actions).toContain("git.push"); + expect(actions).toContain("chat.create"); + expect(actions).toContain("chat.send"); + expect(actions).toContain("files.writeTextAtomic"); + expect(actions).toContain("work.listSessions"); + expect(actions).toContain("conflicts.getLaneStatus"); + }); + }); + + describe("getDescriptors", () => { + it("returns descriptors with action and policy for every registered command", () => { + const descriptors = service.getDescriptors(); + const actions = service.getSupportedActions(); + expect(descriptors).toHaveLength(actions.length); + for (const desc of descriptors) { + expect(desc).toHaveProperty("action"); + expect(desc).toHaveProperty("policy"); + expect(desc.policy).toHaveProperty("viewerAllowed"); + } + }); + + it("each descriptor action matches a supported action", () => { + const actions = new Set(service.getSupportedActions()); + for (const desc of service.getDescriptors()) { + expect(actions.has(desc.action as any)).toBe(true); + } + }); + }); + + describe("getPolicy", () => { + it("returns policy for a known action", () => { + const policy = service.getPolicy("lanes.list"); + expect(policy).not.toBeNull(); + expect(policy!.viewerAllowed).toBe(true); + }); + + it("returns policy with queueable flag for mutating actions", () => { + const policy = service.getPolicy("lanes.create"); + expect(policy).not.toBeNull(); + expect(policy!.queueable).toBe(true); + }); + + it("returns null for an unknown action", () => { + const policy = service.getPolicy("totally.unknown.action"); + expect(policy).toBeNull(); + }); + }); + + // --------------------------------------------------------------- + // execute: unknown action + // --------------------------------------------------------------- + + describe("execute — unknown action", () => { + it("throws for an unregistered action", async () => { + await expect(service.execute(makePayload("bogus.action"))) + .rejects.toThrow("Unsupported remote command: bogus.action"); + }); + }); + + // --------------------------------------------------------------- + // execute: lane commands + // --------------------------------------------------------------- + + describe("execute — lane commands", () => { + it("lanes.list routes to laneService.list", async () => { + await service.execute(makePayload("lanes.list", { includeArchived: true })); + expect(laneService.list).toHaveBeenCalledWith({ + includeArchived: true, + includeStatus: undefined, + }); + }); + + it("lanes.create parses name and routes to laneService.create", async () => { + await service.execute(makePayload("lanes.create", { + name: "my-lane", + description: "desc", + baseBranch: "main", + })); + expect(laneService.create).toHaveBeenCalledWith({ + name: "my-lane", + description: "desc", + baseBranch: "main", + }); + }); + + it("lanes.create throws when name is missing", async () => { + await expect(service.execute(makePayload("lanes.create", {}))) + .rejects.toThrow("lanes.create requires name."); + }); + + it("lanes.createChild parses name + parentLaneId", async () => { + await service.execute(makePayload("lanes.createChild", { + name: "child-lane", + parentLaneId: "parent-1", + })); + expect(laneService.createChild).toHaveBeenCalledWith({ + name: "child-lane", + parentLaneId: "parent-1", + }); + }); + + it("lanes.createChild throws when parentLaneId is missing", async () => { + await expect(service.execute(makePayload("lanes.createChild", { name: "child" }))) + .rejects.toThrow("lanes.createChild requires parentLaneId."); + }); + + it("lanes.rename parses laneId and name", async () => { + await service.execute(makePayload("lanes.rename", { + laneId: "lane-1", + name: "new-name", + })); + expect(laneService.rename).toHaveBeenCalledWith({ + laneId: "lane-1", + name: "new-name", + }); + }); + + it("lanes.archive routes to laneService.archive", async () => { + const result = await service.execute(makePayload("lanes.archive", { laneId: "lane-1" })); + expect(laneService.archive).toHaveBeenCalledWith({ laneId: "lane-1" }); + expect(result).toEqual({ ok: true }); + }); + + it("lanes.delete parses all optional flags", async () => { + await service.execute(makePayload("lanes.delete", { + laneId: "lane-1", + deleteBranch: true, + deleteRemoteBranch: false, + force: true, + })); + expect(laneService.delete).toHaveBeenCalledWith({ + laneId: "lane-1", + deleteBranch: true, + deleteRemoteBranch: false, + force: true, + }); + }); + + it("lanes.getStackChain requires laneId", async () => { + await expect(service.execute(makePayload("lanes.getStackChain", {}))) + .rejects.toThrow("lanes.getStackChain requires laneId."); + }); + }); + + // --------------------------------------------------------------- + // execute: PR commands + // --------------------------------------------------------------- + + describe("execute — PR commands", () => { + it("prs.list routes to prService.listAll", async () => { + const result = await service.execute(makePayload("prs.list")); + expect(prService.listAll).toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it("prs.getDetail requires prId", async () => { + await expect(service.execute(makePayload("prs.getDetail", {}))) + .rejects.toThrow("prs.getDetail requires prId."); + }); + + it("prs.getDetail routes to prService.getDetail", async () => { + const result = await service.execute(makePayload("prs.getDetail", { prId: "pr-42" })); + expect(prService.getDetail).toHaveBeenCalledWith("pr-42"); + expect(result).toEqual({}); + }); + + it("prs.createFromLane parses laneId + title + draft", async () => { + await service.execute(makePayload("prs.createFromLane", { + laneId: "lane-1", + title: "My PR", + body: "Description", + draft: true, + })); + expect(prService.createFromLane).toHaveBeenCalledWith({ + laneId: "lane-1", + title: "My PR", + body: "Description", + draft: true, + }); + }); + + it("prs.createFromLane throws when laneId or title is missing", async () => { + await expect(service.execute(makePayload("prs.createFromLane", { laneId: "lane-1" }))) + .rejects.toThrow("prs.createFromLane requires laneId and title."); + }); + + it("prs.land validates method enum", async () => { + await expect(service.execute(makePayload("prs.land", { + prId: "pr-1", + method: "invalid-method", + }))).rejects.toThrow("prs.land requires method to be merge, squash, or rebase."); + }); + + it("prs.land routes with valid method", async () => { + await service.execute(makePayload("prs.land", { + prId: "pr-1", + method: "squash", + })); + expect(prService.land).toHaveBeenCalledWith({ + prId: "pr-1", + method: "squash", + }); + }); + + it("prs.close routes to prService.closePr", async () => { + const result = await service.execute(makePayload("prs.close", { + prId: "pr-1", + comment: "closing", + })); + expect(prService.closePr).toHaveBeenCalledWith({ + prId: "pr-1", + comment: "closing", + }); + expect(result).toEqual({ ok: true }); + }); + + it("prs.requestReviewers throws when reviewers array is empty", async () => { + await expect(service.execute(makePayload("prs.requestReviewers", { + prId: "pr-1", + reviewers: [], + }))).rejects.toThrow("prs.requestReviewers requires at least one reviewer."); + }); + + it("prs.requestReviewers routes with valid reviewers", async () => { + const result = await service.execute(makePayload("prs.requestReviewers", { + prId: "pr-1", + reviewers: ["alice", "bob"], + })); + expect(prService.requestReviewers).toHaveBeenCalledWith({ + prId: "pr-1", + reviewers: ["alice", "bob"], + }); + expect(result).toEqual({ ok: true }); + }); + }); + + // --------------------------------------------------------------- + // execute: git commands + // --------------------------------------------------------------- + + describe("execute — git commands", () => { + it("git.commit parses laneId + message", async () => { + await service.execute(makePayload("git.commit", { + laneId: "lane-1", + message: "fix: bug", + })); + expect(gitService.commit).toHaveBeenCalledWith({ + laneId: "lane-1", + message: "fix: bug", + amend: undefined, + }); + }); + + it("git.commit throws when message is missing", async () => { + await expect(service.execute(makePayload("git.commit", { laneId: "lane-1" }))) + .rejects.toThrow("git.commit requires message."); + }); + + it("git.commit throws when laneId is missing", async () => { + await expect(service.execute(makePayload("git.commit", { message: "fix" }))) + .rejects.toThrow("git.commit requires laneId."); + }); + + it("git.push parses forceWithLease flag", async () => { + await service.execute(makePayload("git.push", { + laneId: "lane-1", + forceWithLease: true, + })); + expect(gitService.push).toHaveBeenCalledWith({ + laneId: "lane-1", + forceWithLease: true, + }); + }); + + it("git.stageFile requires laneId and path", async () => { + await service.execute(makePayload("git.stageFile", { + laneId: "lane-1", + path: "src/index.ts", + })); + expect(gitService.stageFile).toHaveBeenCalledWith({ + laneId: "lane-1", + path: "src/index.ts", + }); + }); + + it("git.stageFile throws when path is missing", async () => { + await expect(service.execute(makePayload("git.stageFile", { laneId: "lane-1" }))) + .rejects.toThrow("git.stageFile requires path."); + }); + + it("git.stageAll requires laneId and paths", async () => { + await service.execute(makePayload("git.stageAll", { + laneId: "lane-1", + paths: ["a.ts", "b.ts"], + })); + expect(gitService.stageAll).toHaveBeenCalledWith({ + laneId: "lane-1", + paths: ["a.ts", "b.ts"], + }); + }); + + it("git.listRecentCommits passes laneId and optional limit", async () => { + await service.execute(makePayload("git.listRecentCommits", { + laneId: "lane-1", + limit: 5, + })); + expect(gitService.listRecentCommits).toHaveBeenCalledWith({ + laneId: "lane-1", + limit: 5, + }); + }); + + it("git.revertCommit requires laneId and commitSha", async () => { + await service.execute(makePayload("git.revertCommit", { + laneId: "lane-1", + commitSha: "abc123", + })); + expect(gitService.revertCommit).toHaveBeenCalledWith({ + laneId: "lane-1", + commitSha: "abc123", + }); + }); + + it("git.revertCommit throws when commitSha is missing", async () => { + await expect(service.execute(makePayload("git.revertCommit", { laneId: "lane-1" }))) + .rejects.toThrow("git.revertCommit requires commitSha."); + }); + + it("git.sync parses optional mode and baseRef", async () => { + await service.execute(makePayload("git.sync", { + laneId: "lane-1", + mode: "rebase", + baseRef: "main", + })); + expect(gitService.sync).toHaveBeenCalledWith({ + laneId: "lane-1", + mode: "rebase", + baseRef: "main", + }); + }); + + it("git.checkoutBranch requires laneId and branchName", async () => { + await service.execute(makePayload("git.checkoutBranch", { + laneId: "lane-1", + branchName: "feature/new", + })); + expect(gitService.checkoutBranch).toHaveBeenCalledWith({ + laneId: "lane-1", + branchName: "feature/new", + }); + }); + + it("git.checkoutBranch throws when branchName is missing", async () => { + await expect(service.execute(makePayload("git.checkoutBranch", { laneId: "lane-1" }))) + .rejects.toThrow("git.checkoutBranch requires branchName."); + }); + }); + + // --------------------------------------------------------------- + // execute: git commands (when gitService is not provided) + // --------------------------------------------------------------- + + describe("execute — git commands without gitService", () => { + it("throws when gitService is not available", async () => { + const svcNoGit = createSyncRemoteCommandService({ + laneService, + prService, + ptyService, + sessionService, + fileService, + logger: createLogger() as any, + }); + await expect(svcNoGit.execute(makePayload("git.commit", { + laneId: "lane-1", + message: "fix", + }))).rejects.toThrow("Git service not available."); + }); + }); + + // --------------------------------------------------------------- + // execute: diff / file commands + // --------------------------------------------------------------- + + describe("execute — diff and file commands", () => { + it("git.getChanges routes to diffService.getChanges", async () => { + await service.execute(makePayload("git.getChanges", { laneId: "lane-1" })); + expect(diffService.getChanges).toHaveBeenCalledWith("lane-1"); + }); + + it("git.getChanges throws when diffService is not available", async () => { + const svcNoDiff = createSyncRemoteCommandService({ + laneService, + prService, + ptyService, + sessionService, + fileService, + logger: createLogger() as any, + }); + await expect(svcNoDiff.execute(makePayload("git.getChanges", { laneId: "lane-1" }))) + .rejects.toThrow("Diff service not available."); + }); + + it("files.writeTextAtomic parses laneId + path + text", async () => { + const result = await service.execute(makePayload("files.writeTextAtomic", { + laneId: "lane-1", + path: "readme.md", + text: "hello world", + })); + expect(fileService.writeTextAtomic).toHaveBeenCalledWith({ + laneId: "lane-1", + relPath: "readme.md", + text: "hello world", + }); + expect(result).toEqual({ ok: true }); + }); + + it("files.writeTextAtomic throws when text is not a string", async () => { + await expect(service.execute(makePayload("files.writeTextAtomic", { + laneId: "lane-1", + path: "readme.md", + text: 42, + }))).rejects.toThrow("files.writeTextAtomic requires text."); + }); + + it("files.writeTextAtomic allows empty string text", async () => { + await service.execute(makePayload("files.writeTextAtomic", { + laneId: "lane-1", + path: "empty.txt", + text: "", + })); + expect(fileService.writeTextAtomic).toHaveBeenCalledWith({ + laneId: "lane-1", + relPath: "empty.txt", + text: "", + }); + }); + }); + + // --------------------------------------------------------------- + // execute: chat commands + // --------------------------------------------------------------- + + describe("execute — chat commands", () => { + it("chat.create parses laneId + provider + model", async () => { + await service.execute(makePayload("chat.create", { + laneId: "lane-1", + provider: "codex", + model: "gpt-4", + })); + expect(agentChatService.createSession).toHaveBeenCalledWith({ + laneId: "lane-1", + provider: "codex", + model: "gpt-4", + }); + }); + + it("chat.create resolves model from available models when model is empty", async () => { + await service.execute(makePayload("chat.create", { + laneId: "lane-1", + provider: "codex", + model: "", + })); + expect(agentChatService.getAvailableModels).toHaveBeenCalledWith({ provider: "codex" }); + expect(agentChatService.createSession).toHaveBeenCalledWith( + expect.objectContaining({ model: "model-1", modelId: "m1" }), + ); + }); + + it("chat.send requires sessionId and text", async () => { + const result = await service.execute(makePayload("chat.send", { + sessionId: "sess-1", + text: "hello", + })); + expect(agentChatService.sendMessage).toHaveBeenCalledWith({ + sessionId: "sess-1", + text: "hello", + }); + expect(result).toEqual({ ok: true }); + }); + + it("chat.send throws when text is missing", async () => { + await expect(service.execute(makePayload("chat.send", { sessionId: "sess-1" }))) + .rejects.toThrow("chat.send requires text."); + }); + + it("chat.dispose routes to agentChatService.dispose", async () => { + const result = await service.execute(makePayload("chat.dispose", { + sessionId: "sess-1", + })); + expect(agentChatService.dispose).toHaveBeenCalledWith({ sessionId: "sess-1" }); + expect(result).toEqual({ ok: true }); + }); + + it("chat.interrupt routes to agentChatService.interrupt", async () => { + const result = await service.execute(makePayload("chat.interrupt", { + sessionId: "sess-1", + })); + expect(agentChatService.interrupt).toHaveBeenCalledWith({ sessionId: "sess-1" }); + expect(result).toEqual({ ok: true }); + }); + + it("chat.interrupt throws when sessionId is missing", async () => { + await expect(service.execute(makePayload("chat.interrupt", {}))) + .rejects.toThrow("chat.interrupt requires sessionId."); + }); + + it("chat.steer routes to agentChatService.steer", async () => { + const result = await service.execute(makePayload("chat.steer", { + sessionId: "sess-1", + text: "change direction", + })); + expect(agentChatService.steer).toHaveBeenCalledWith({ + sessionId: "sess-1", + text: "change direction", + }); + expect(result).toEqual({ ok: true }); + }); + + it("chat.steer throws when text is missing", async () => { + await expect(service.execute(makePayload("chat.steer", { sessionId: "sess-1" }))) + .rejects.toThrow("chat.steer requires text."); + }); + + it("chat.resume routes to agentChatService.resumeSession", async () => { + await service.execute(makePayload("chat.resume", { + sessionId: "sess-1", + })); + expect(agentChatService.resumeSession).toHaveBeenCalledWith({ sessionId: "sess-1" }); + }); + + it("chat.resume throws when sessionId is missing", async () => { + await expect(service.execute(makePayload("chat.resume", {}))) + .rejects.toThrow("chat.resume requires sessionId."); + }); + + it("chat.models returns available models for a provider", async () => { + await service.execute(makePayload("chat.models", { provider: "codex" })); + expect(agentChatService.getAvailableModels).toHaveBeenCalledWith({ provider: "codex" }); + }); + + it("chat commands throw when agentChatService is not available", async () => { + const svcNoChat = createSyncRemoteCommandService({ + laneService, + prService, + ptyService, + sessionService, + fileService, + logger: createLogger() as any, + }); + await expect(svcNoChat.execute(makePayload("chat.send", { + sessionId: "s1", + text: "hi", + }))).rejects.toThrow("Agent chat service not available."); + }); + }); + + // --------------------------------------------------------------- + // execute: work (session) commands + // --------------------------------------------------------------- + + describe("execute — work commands", () => { + it("work.listSessions routes to sessionService.list", async () => { + await service.execute(makePayload("work.listSessions", { laneId: "lane-1" })); + expect(sessionService.list).toHaveBeenCalledWith( + expect.objectContaining({ laneId: "lane-1" }), + ); + }); + + it("work.runQuickCommand parses laneId + title + startupCommand", async () => { + await service.execute(makePayload("work.runQuickCommand", { + laneId: "lane-1", + title: "test run", + startupCommand: "npm test", + toolType: "run-shell", + })); + expect(ptyService.create).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + title: "test run", + startupCommand: "npm test", + toolType: "run-shell", + }), + ); + }); + + it("work.runQuickCommand throws when startupCommand is missing and toolType is not shell", async () => { + await expect(service.execute(makePayload("work.runQuickCommand", { + laneId: "lane-1", + title: "test", + toolType: "run-shell", + }))).rejects.toThrow("work.runQuickCommand requires startupCommand unless toolType is shell."); + }); + + it("work.runQuickCommand allows missing startupCommand when toolType is shell", async () => { + await service.execute(makePayload("work.runQuickCommand", { + laneId: "lane-1", + title: "shell session", + toolType: "shell", + })); + expect(ptyService.create).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + title: "shell session", + toolType: "shell", + }), + ); + }); + + it("work.closeSession disposes pty if session has a ptyId", async () => { + sessionService.get.mockReturnValue({ ptyId: "pty-42" }); + const result = await service.execute(makePayload("work.closeSession", { + sessionId: "sess-1", + })); + expect(sessionService.get).toHaveBeenCalledWith("sess-1"); + expect(ptyService.dispose).toHaveBeenCalledWith({ ptyId: "pty-42", sessionId: "sess-1" }); + expect(result).toEqual({ ok: true }); + }); + + it("work.closeSession skips pty disposal when the session has no ptyId", async () => { + sessionService.get.mockReturnValue(null); + const result = await service.execute(makePayload("work.closeSession", { + sessionId: "sess-1", + })); + expect(sessionService.get).toHaveBeenCalledWith("sess-1"); + expect(ptyService.dispose).not.toHaveBeenCalled(); + expect(result).toEqual({ ok: true }); + }); + }); + + // --------------------------------------------------------------- + // execute: conflict commands + // --------------------------------------------------------------- + + describe("execute — conflict commands", () => { + it("conflicts.getLaneStatus routes to conflictService", async () => { + await service.execute(makePayload("conflicts.getLaneStatus", { laneId: "lane-1" })); + expect(conflictService.getLaneStatus).toHaveBeenCalledWith({ laneId: "lane-1" }); + }); + + it("conflicts.getBatchAssessment routes with no args", async () => { + await service.execute(makePayload("conflicts.getBatchAssessment")); + expect(conflictService.getBatchAssessment).toHaveBeenCalled(); + }); + + it("conflicts commands throw when conflictService is not available", async () => { + const svcNoConflict = createSyncRemoteCommandService({ + laneService, + prService, + ptyService, + sessionService, + fileService, + logger: createLogger() as any, + }); + await expect(svcNoConflict.execute(makePayload("conflicts.getLaneStatus", { laneId: "lane-1" }))) + .rejects.toThrow("Conflict service not available."); + }); + }); + + // --------------------------------------------------------------- + // execute: args edge cases / parse helpers via execute + // --------------------------------------------------------------- + + describe("execute — argument parsing edge cases", () => { + it("trims whitespace from string args", async () => { + await service.execute(makePayload("lanes.create", { name: " my-lane " })); + expect(laneService.create).toHaveBeenCalledWith( + expect.objectContaining({ name: "my-lane" }), + ); + }); + + it("rejects empty-after-trim string for required fields", async () => { + await expect(service.execute(makePayload("lanes.create", { name: " " }))) + .rejects.toThrow("lanes.create requires name."); + }); + + it("ignores non-boolean values for optional boolean fields", async () => { + await service.execute(makePayload("lanes.list", { + includeArchived: "yes" as any, + })); + expect(laneService.list).toHaveBeenCalledWith({ + includeArchived: undefined, + includeStatus: undefined, + }); + }); + + it("ignores non-number values for optional number fields", async () => { + await service.execute(makePayload("work.listSessions", { + limit: "ten" as any, + })); + expect(sessionService.list).toHaveBeenCalledWith({}); + }); + + it("handles payload.args being non-object by defaulting to empty record", async () => { + const result = await service.execute({ + commandId: "cmd-1", + action: "prs.list", + args: "not-an-object" as any, + }); + expect(prService.listAll).toHaveBeenCalled(); + }); + + it("filters non-string entries from string arrays", async () => { + await service.execute(makePayload("prs.requestReviewers", { + prId: "pr-1", + reviewers: ["alice", 42, null, "bob", ""], + })); + expect(prService.requestReviewers).toHaveBeenCalledWith({ + prId: "pr-1", + reviewers: ["alice", "bob"], + }); + }); + }); + + // --------------------------------------------------------------- + // execute: git.getFile (compound parse) + // --------------------------------------------------------------- + + describe("execute — git.getFile", () => { + it("parses all required and optional fields", async () => { + await service.execute(makePayload("git.getFile", { + laneId: "lane-1", + path: "src/app.ts", + mode: "staged", + compareRef: "abc123", + compareTo: "head", + })); + expect(diffService.getFileDiff).toHaveBeenCalledWith({ + laneId: "lane-1", + filePath: "src/app.ts", + mode: "staged", + compareRef: "abc123", + compareTo: "head", + }); + }); + + it("throws when mode is missing", async () => { + await expect(service.execute(makePayload("git.getFile", { + laneId: "lane-1", + path: "src/app.ts", + }))).rejects.toThrow("git.getFile requires mode."); + }); + }); + + // --------------------------------------------------------------- + // execute: prs.refresh + // --------------------------------------------------------------- + + describe("execute — prs.refresh", () => { + it("refreshes single PR by prId", async () => { + prService.listAll.mockResolvedValue([{ id: "pr-1" }]); + const result = await service.execute(makePayload("prs.refresh", { prId: "pr-1" })); + expect(prService.refresh).toHaveBeenCalledWith({ prId: "pr-1" }); + expect(result).toEqual(expect.objectContaining({ refreshedCount: 1 })); + }); + + it("refreshes all PRs when no prId or prIds given", async () => { + prService.listAll.mockResolvedValue([{ id: "pr-1" }, { id: "pr-2" }]); + const result = await service.execute(makePayload("prs.refresh", {})); + expect(prService.refresh).toHaveBeenCalledWith({}); + expect(result).toEqual(expect.objectContaining({ refreshedCount: 2 })); + }); + }); + + // --------------------------------------------------------------- + // execute: lanes.rebase* commands + // --------------------------------------------------------------- + + describe("execute — lanes rebase commands", () => { + it("lanes.rebaseStart parses laneId and optional fields", async () => { + await service.execute(makePayload("lanes.rebaseStart", { + laneId: "lane-1", + scope: "chain", + pushMode: "force", + })); + expect(laneService.rebaseStart).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + scope: "chain", + pushMode: "force", + }), + ); + }); + + it("lanes.rebasePush parses runId and laneIds", async () => { + await service.execute(makePayload("lanes.rebasePush", { + runId: "run-1", + laneIds: ["lane-1", "lane-2"], + })); + expect(laneService.rebasePush).toHaveBeenCalledWith({ + runId: "run-1", + laneIds: ["lane-1", "lane-2"], + }); + }); + + it("lanes.rebasePush throws when laneIds is empty", async () => { + await expect(service.execute(makePayload("lanes.rebasePush", { + runId: "run-1", + laneIds: [], + }))).rejects.toThrow("lanes.rebasePush requires laneIds."); + }); + }); + + // --------------------------------------------------------------- + // execute: git stash commands + // --------------------------------------------------------------- + + describe("execute — git stash commands", () => { + it("git.stashPush parses optional message and includeUntracked", async () => { + await service.execute(makePayload("git.stashPush", { + laneId: "lane-1", + message: "wip", + includeUntracked: true, + })); + expect(gitService.stashPush).toHaveBeenCalledWith({ + laneId: "lane-1", + message: "wip", + includeUntracked: true, + }); + }); + + it("git.stashApply requires laneId and stashRef", async () => { + await service.execute(makePayload("git.stashApply", { + laneId: "lane-1", + stashRef: "stash@{0}", + })); + expect(gitService.stashApply).toHaveBeenCalledWith({ + laneId: "lane-1", + stashRef: "stash@{0}", + }); + }); + + it("git.stashApply throws when stashRef is missing", async () => { + await expect(service.execute(makePayload("git.stashApply", { laneId: "lane-1" }))) + .rejects.toThrow("git.stashApply requires stashRef."); + }); + }); + + // --------------------------------------------------------------- + // execute: chat.approve / chat.respondToInput + // --------------------------------------------------------------- + + describe("execute — chat approval and input commands", () => { + it("chat.approve parses sessionId + itemId + decision", async () => { + const result = await service.execute(makePayload("chat.approve", { + sessionId: "s1", + itemId: "item-1", + decision: "allow", + })); + expect(agentChatService.approveToolUse).toHaveBeenCalledWith({ + sessionId: "s1", + itemId: "item-1", + decision: "allow", + }); + expect(result).toEqual({ ok: true }); + }); + + it("chat.approve throws when decision is missing", async () => { + await expect(service.execute(makePayload("chat.approve", { + sessionId: "s1", + itemId: "item-1", + }))).rejects.toThrow("chat.approve requires decision."); + }); + + it("chat.respondToInput parses sessionId + itemId + answers", async () => { + const result = await service.execute(makePayload("chat.respondToInput", { + sessionId: "s1", + itemId: "item-1", + answers: { key1: "val1" }, + decision: "submit", + })); + expect(agentChatService.respondToInput).toHaveBeenCalledWith({ + sessionId: "s1", + itemId: "item-1", + answers: { key1: "val1" }, + decision: "submit", + }); + expect(result).toEqual({ ok: true }); + }); + }); +}); diff --git a/apps/desktop/src/main/services/tests/testService.ts b/apps/desktop/src/main/services/tests/testService.ts index 9f05f686d..e156f4770 100644 --- a/apps/desktop/src/main/services/tests/testService.ts +++ b/apps/desktop/src/main/services/tests/testService.ts @@ -21,7 +21,7 @@ import type { AdeDb } from "../state/kvDb"; import type { createProjectConfigService } from "../config/projectConfigService"; import type { createLaneService } from "../lanes/laneService"; import { matchLaneOverlayPolicies } from "../config/laneOverlayMatcher"; -import { nowIso } from "../shared/utils"; +import { nowIso, resolvePathWithinRoot } from "../shared/utils"; type ActiveRunEntry = { laneId: string; @@ -273,7 +273,13 @@ export function createTestService({ const startedAt = nowIso(); const laneRoot = laneService.getLaneWorktreePath(laneId); const configuredCwd = overlay.cwd?.trim() ? overlay.cwd : suite.cwd; - const cwd = path.isAbsolute(configuredCwd) ? configuredCwd : path.join(laneRoot, configuredCwd); + const cwdCandidate = path.isAbsolute(configuredCwd) ? configuredCwd : path.join(laneRoot, configuredCwd); + let cwd: string; + try { + cwd = resolvePathWithinRoot(laneRoot, cwdCandidate); + } catch { + throw new Error(`Test suite '${suite.id}' cwd escapes lane workspace`); + } if (!suite.command.length) throw new Error(`Suite '${suite.id}' has an empty command`); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index df9d54152..27734342e 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -16,6 +16,7 @@ import type { ConflictProposal, ConflictExternalResolverRunSummary, ConflictProposalPreview, + ContextDocPrefs, ContextGenerateDocsArgs, ContextGenerateDocsResult, ContextOpenDocArgs, @@ -72,9 +73,17 @@ import type { AgentChatRespondToInputArgs, AgentChatResumeArgs, AgentChatSendArgs, + AgentChatSlashCommand, + AgentChatSlashCommandsArgs, + AgentChatFileSearchArgs, + AgentChatFileSearchResult, AgentChatSession, + AgentChatSessionCapabilities, + AgentChatSessionCapabilitiesArgs, AgentChatSessionSummary, AgentChatSteerArgs, + AgentChatSubagentSnapshot, + AgentChatSubagentListArgs, AgentChatUpdateSessionArgs, AutomationsEventPayload, AutomationManualTriggerRequest, @@ -122,7 +131,6 @@ import type { CtoListSessionLogsArgs, CtoSnapshot, CtoSessionLogEntry, - CtoIdentity, AgentIdentity, AgentCoreMemory, AgentSessionLogEntry, @@ -200,6 +208,8 @@ import type { OnboardingExistingLaneCandidate, OnboardingStatus, GitActionResult, + GitBranchSummary, + GitCheckoutBranchArgs, GitCherryPickArgs, GitCommitArgs, GitCommitSummary, @@ -207,6 +217,7 @@ import type { GitGetCommitMessageArgs, GitGenerateCommitMessageArgs, GitGenerateCommitMessageResult, + GitListBranchesArgs, GitListCommitFilesArgs, GitFileActionArgs, GitBatchFileActionArgs, @@ -284,14 +295,12 @@ import type { PrIssueResolutionStartResult, RebaseResolutionStartArgs, RebaseResolutionStartResult, - PrLabel, PrMergeContext, PrReview, PrReviewThread, PrReviewThreadComment, PrStatus, PrSummary, - PrUser, PrWithConflicts, ReplyToPrReviewThreadArgs, ResolvePrReviewThreadArgs, @@ -321,22 +330,6 @@ import type { ListSessionsArgs, ListTestRunsArgs, OperationRecord, - PackExport, - PackEvent, - PackHeadVersion, - PackSummary, - PackVersion, - PackVersionSummary, - Checkpoint, - GetLaneExportArgs, - GetProjectExportArgs, - GetConflictExportArgs, - GetFeatureExportArgs, - GetPlanExportArgs, - GetMissionExportArgs, - GetMissionPackArgs, - RefreshMissionPackArgs, - ListPackEventsSinceArgs, ProcessActionArgs, ProcessDefinition, ProcessEvent, @@ -370,6 +363,13 @@ import type { CommitExternalConflictResolverRunArgs, CommitExternalConflictResolverRunResult, RunConflictPredictionArgs, + PrepareResolverSessionArgs, + PrepareResolverSessionResult, + AttachResolverSessionArgs, + FinalizeResolverSessionArgs, + CancelResolverSessionArgs, + SuggestResolverTargetArgs, + SuggestResolverTargetResult, SessionDeltaSummary, StackChainItem, StopTestRunArgs, @@ -418,6 +418,8 @@ import type { GetMissionLogsResult, ExportMissionLogsArgs, ExportMissionLogsResult, + GetAggregatedUsageArgs, + AggregatedUsageStats, GetOrchestratorGateReportArgs, GetOrchestratorRunGraphArgs, ListOrchestratorRunsArgs, @@ -736,7 +738,7 @@ declare global { sendAgentMessage: (args: SendAgentMessageArgs) => Promise; getGlobalChat: (args: GetGlobalChatArgs) => Promise; getActiveAgents: (args: GetActiveAgentsArgs) => Promise; - getAggregatedUsage: (args: import("../shared/types").GetAggregatedUsageArgs) => Promise; + getAggregatedUsage: (args: GetAggregatedUsageArgs) => Promise; onEvent: (cb: (ev: OrchestratorRuntimeEvent) => void) => () => void; onThreadEvent: (cb: (ev: OrchestratorThreadEvent) => void) => () => void; onDagMutation: (cb: (ev: DagMutationEvent) => void) => () => void; @@ -832,10 +834,10 @@ declare global { updateSession: (args: AgentChatUpdateSessionArgs) => Promise; warmupModel: (args: { sessionId: string; modelId: string }) => Promise; onEvent: (cb: (ev: AgentChatEventEnvelope) => void) => () => void; - slashCommands: (args: import("../shared/types").AgentChatSlashCommandsArgs) => Promise; - fileSearch: (args: import("../shared/types").AgentChatFileSearchArgs) => Promise; - listSubagents: (args: import("../shared/types").AgentChatSubagentListArgs) => Promise; - getSessionCapabilities: (args: import("../shared/types").AgentChatSessionCapabilitiesArgs) => Promise; + slashCommands: (args: AgentChatSlashCommandsArgs) => Promise; + fileSearch: (args: AgentChatFileSearchArgs) => Promise; + listSubagents: (args: AgentChatSubagentListArgs) => Promise; + getSessionCapabilities: (args: AgentChatSessionCapabilitiesArgs) => Promise; saveTempAttachment: (args: { data: string; filename: string }) => Promise<{ path: string }>; }; computerUse: { @@ -903,8 +905,8 @@ declare global { rebaseAbort: (laneId: string) => Promise; mergeContinue: (laneId: string) => Promise; mergeAbort: (laneId: string) => Promise; - listBranches: (args: import("../shared/types").GitListBranchesArgs) => Promise; - checkoutBranch: (args: import("../shared/types").GitCheckoutBranchArgs) => Promise; + listBranches: (args: GitListBranchesArgs) => Promise; + checkoutBranch: (args: GitCheckoutBranchArgs) => Promise; }; conflicts: { getLaneStatus: (args: GetLaneConflictStatusArgs) => Promise; @@ -921,19 +923,19 @@ declare global { runExternalResolver: (args: RunExternalConflictResolverArgs) => Promise; listExternalResolverRuns: (args?: ListExternalConflictResolverRunsArgs) => Promise; commitExternalResolverRun: (args: CommitExternalConflictResolverRunArgs) => Promise; - prepareResolverSession: (args: import("../shared/types").PrepareResolverSessionArgs) => Promise; - attachResolverSession: (args: import("../shared/types").AttachResolverSessionArgs) => Promise; - finalizeResolverSession: (args: import("../shared/types").FinalizeResolverSessionArgs) => Promise; - cancelResolverSession: (args: import("../shared/types").CancelResolverSessionArgs) => Promise; - suggestResolverTarget: (args: import("../shared/types").SuggestResolverTargetArgs) => Promise; + prepareResolverSession: (args: PrepareResolverSessionArgs) => Promise; + attachResolverSession: (args: AttachResolverSessionArgs) => Promise; + finalizeResolverSession: (args: FinalizeResolverSessionArgs) => Promise; + cancelResolverSession: (args: CancelResolverSessionArgs) => Promise; + suggestResolverTarget: (args: SuggestResolverTargetArgs) => Promise; onEvent: (cb: (ev: ConflictEventPayload) => void) => () => void; }; context: { getStatus: () => Promise; generateDocs: (args: ContextGenerateDocsArgs) => Promise; openDoc: (args: ContextOpenDocArgs) => Promise; - getPrefs: () => Promise; - savePrefs: (prefs: import("../shared/types").ContextDocPrefs) => Promise; + getPrefs: () => Promise; + savePrefs: (prefs: ContextDocPrefs) => Promise; onStatusChanged: (cb: (status: ContextStatus) => void) => () => void; }; github: { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 057ca531c..e405e75ce 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -82,6 +82,7 @@ import type { CtoListOpenclawMessagesResult, CtoSendOpenclawMessageArgs, LinearConnectionStatus, + CtoSetLinearOAuthClientArgs, LinearIngressEventRecord, LinearIngressStatus, CtoSetLinearTokenArgs, @@ -106,6 +107,8 @@ import type { ExternalConnectionAuthStatus, ExternalConnectionOAuthSessionResult, ExternalConnectionOAuthSessionStartResult, + ExternalMcpEventPayload, + ExternalMcpManagedAuthConfig, ExternalMcpServerConfig, ExternalMcpServerSnapshot, ExternalMcpUsageEvent, @@ -115,6 +118,7 @@ import type { ConflictExternalResolverRunSummary, ConflictProposal, ConflictProposalPreview, + ContextDocPrefs, ContextGenerateDocsArgs, ContextGenerateDocsResult, ContextOpenDocArgs, @@ -188,8 +192,6 @@ import type { PrFile, PrActionRun, PrActivityEvent, - PrLabel, - PrUser, AddPrCommentArgs, ReplyToPrReviewThreadArgs, ResolvePrReviewThreadArgs, @@ -230,9 +232,17 @@ import type { AgentChatRespondToInputArgs, AgentChatResumeArgs, AgentChatSendArgs, + AgentChatSlashCommand, + AgentChatSlashCommandsArgs, + AgentChatFileSearchArgs, + AgentChatFileSearchResult, AgentChatSession, + AgentChatSessionCapabilities, + AgentChatSessionCapabilitiesArgs, AgentChatSessionSummary, AgentChatSteerArgs, + AgentChatSubagentSnapshot, + AgentChatSubagentListArgs, AgentChatUpdateSessionArgs, KeybindingOverride, KeybindingsSnapshot, @@ -250,24 +260,6 @@ import type { MergeSimulationArgs, MergeSimulationResult, OperationRecord, - PackEvent, - PackExport, - PackHeadVersion, - PackSummary, - PackDeltaDigestArgs, - PackDeltaDigestV1, - PackVersion, - PackVersionSummary, - Checkpoint, - GetLaneExportArgs, - GetProjectExportArgs, - GetConflictExportArgs, - GetFeatureExportArgs, - GetPlanExportArgs, - GetMissionExportArgs, - GetMissionPackArgs, - RefreshMissionPackArgs, - ListPackEventsSinceArgs, ProcessActionArgs, ProcessDefinition, ProcessEvent, @@ -422,6 +414,8 @@ import type { ImportPhaseProfileArgs, MissionPhaseConfiguration, MissionDashboardSnapshot, + GetFullMissionViewArgs, + FullMissionViewResult, MissionPreflightRequest, MissionPreflightResult, GetMissionRunViewArgs, @@ -639,8 +633,8 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.externalMcpGetUsageEvents, args), listAuthRecords: async (): Promise => ipcRenderer.invoke(IPC.externalMcpListAuthRecords), - onEvent: (cb: (event: import("../shared/types").ExternalMcpEventPayload) => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: import("../shared/types").ExternalMcpEventPayload) => cb(payload); + onEvent: (cb: (event: ExternalMcpEventPayload) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: ExternalMcpEventPayload) => cb(payload); ipcRenderer.on(IPC.externalMcpEvent, listener); return () => ipcRenderer.removeListener(IPC.externalMcpEvent, listener); }, @@ -658,7 +652,7 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.externalMcpSaveAuthRecord, { record }), removeAuthRecord: async (authId: string): Promise => ipcRenderer.invoke(IPC.externalMcpRemoveAuthRecord, { authId }), - getAuthStatus: async (binding?: import("../shared/types").ExternalMcpManagedAuthConfig | null): Promise => + getAuthStatus: async (binding?: ExternalMcpManagedAuthConfig | null): Promise => ipcRenderer.invoke(IPC.externalMcpGetAuthStatus, { binding: binding ?? null }), startOAuthSession: async (authId: string): Promise => ipcRenderer.invoke(IPC.externalMcpStartOAuthSession, { authId }), @@ -771,7 +765,7 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.missionsGetPhaseConfiguration, { missionId }), getDashboard: async (): Promise => ipcRenderer.invoke(IPC.missionsGetDashboard), - getFullMissionView: async (args: import("../shared/types").GetFullMissionViewArgs): Promise => + getFullMissionView: async (args: GetFullMissionViewArgs): Promise => ipcRenderer.invoke(IPC.missionsGetFullMissionView, args), preflight: async (args: MissionPreflightRequest): Promise => ipcRenderer.invoke(IPC.missionsPreflight, args), @@ -1147,13 +1141,13 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.on(IPC.agentChatEvent, listener); return () => ipcRenderer.removeListener(IPC.agentChatEvent, listener); }, - slashCommands: async (args: import("../shared/types").AgentChatSlashCommandsArgs): Promise => + slashCommands: async (args: AgentChatSlashCommandsArgs): Promise => ipcRenderer.invoke(IPC.agentChatSlashCommands, args), - fileSearch: async (args: import("../shared/types").AgentChatFileSearchArgs): Promise => + fileSearch: async (args: AgentChatFileSearchArgs): Promise => ipcRenderer.invoke(IPC.agentChatFileSearch, args), - listSubagents: async (args: import("../shared/types").AgentChatSubagentListArgs): Promise => + listSubagents: async (args: AgentChatSubagentListArgs): Promise => ipcRenderer.invoke(IPC.agentChatListSubagents, args), - getSessionCapabilities: async (args: import("../shared/types").AgentChatSessionCapabilitiesArgs): Promise => + getSessionCapabilities: async (args: AgentChatSessionCapabilitiesArgs): Promise => ipcRenderer.invoke(IPC.agentChatGetSessionCapabilities, args), saveTempAttachment: async (args: { data: string; filename: string }): Promise<{ path: string }> => ipcRenderer.invoke(IPC.agentChatSaveTempAttachment, args), @@ -1312,8 +1306,8 @@ contextBridge.exposeInMainWorld("ade", { generateDocs: async (args: ContextGenerateDocsArgs): Promise => ipcRenderer.invoke(IPC.contextGenerateDocs, args), openDoc: async (args: ContextOpenDocArgs): Promise => ipcRenderer.invoke(IPC.contextOpenDoc, args), - getPrefs: async (): Promise => ipcRenderer.invoke(IPC.contextGetPrefs), - savePrefs: async (prefs: import("../shared/types").ContextDocPrefs): Promise => + getPrefs: async (): Promise => ipcRenderer.invoke(IPC.contextGetPrefs), + savePrefs: async (prefs: ContextDocPrefs): Promise => ipcRenderer.invoke(IPC.contextSavePrefs, prefs), onStatusChanged: (cb: (status: ContextStatus) => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: ContextStatus) => cb(payload); @@ -1719,7 +1713,7 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.ctoPreviewSystemPrompt, args), getLinearProjects: async (): Promise => ipcRenderer.invoke(IPC.ctoGetLinearProjects), - setLinearOAuthClient: async (args: import("../shared/types").CtoSetLinearOAuthClientArgs): Promise => + setLinearOAuthClient: async (args: CtoSetLinearOAuthClientArgs): Promise => ipcRenderer.invoke(IPC.ctoSetLinearOAuthClient, args), clearLinearOAuthClient: async (): Promise => ipcRenderer.invoke(IPC.ctoClearLinearOAuthClient), diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 9ae0594fa..468c286e6 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -247,26 +247,20 @@ function toolSourceChip(toolName: string): { label: string; tone: ChatSurfaceChi return null; } -function messageCardStyle(): React.CSSProperties { - return { - borderColor: "rgba(245, 158, 11, 0.16)", - background: "#171412", - }; -} +const MESSAGE_CARD_STYLE: React.CSSProperties = { + borderColor: "rgba(245, 158, 11, 0.16)", + background: "#171412", +}; -function surfaceInlineCardStyle(): React.CSSProperties { - return { - borderColor: "rgba(255, 255, 255, 0.08)", - background: "#14161a", - }; -} +const SURFACE_INLINE_CARD_STYLE: React.CSSProperties = { + borderColor: "rgba(255, 255, 255, 0.08)", + background: "#14161a", +}; -function assistantMessageCardStyle(): React.CSSProperties { - return { - borderColor: "rgba(148, 163, 184, 0.14)", - background: "#101318", - }; -} +const ASSISTANT_MESSAGE_CARD_STYLE: React.CSSProperties = { + borderColor: "rgba(148, 163, 184, 0.14)", + background: "#101318", +}; function describeUserDeliveryState(event: Extract): { label: string; className: string } | null { if (event.deliveryState === "failed") { @@ -376,8 +370,6 @@ function todoItemStatusClass(status: string): string { function statusColorClass(status: string | undefined): string { switch (status) { - case "completed": - return "text-emerald-400/70"; case "failed": return "text-red-400/70"; case "running": @@ -657,7 +649,7 @@ function CollapsibleCard({ const isOpen = forceOpen === true ? true : open; return ( -
+
); @@ -788,10 +788,10 @@ export function CtoPage() {
{/* Chat tab */}
- {loading &&
Connecting persistent session...
} - {error &&
{error}
} + {loading &&
Connecting persistent session...
} + {error &&
{error}
} {!laneId && ( -
+
Create a lane to start the persistent CTO session.
)} @@ -895,7 +895,7 @@ export function CtoPage() { { label: "Paused", value: String(teamStats.paused) }, ].map((item) => (
-
{item.label}
+
{item.label}
{item.value}
))} @@ -946,7 +946,7 @@ export function CtoPage() { )} {/* Linear tab */} - {activeTab === "linear" && } + {activeTab === "linear" && } {/* Settings tab */} {activeTab === "settings" && ( diff --git a/apps/desktop/src/renderer/components/cto/LinearSyncPanel.test.ts b/apps/desktop/src/renderer/components/cto/LinearSyncPanel.test.ts index f3797a56d..4e3845747 100644 --- a/apps/desktop/src/renderer/components/cto/LinearSyncPanel.test.ts +++ b/apps/desktop/src/renderer/components/cto/LinearSyncPanel.test.ts @@ -166,6 +166,14 @@ describe("LinearSyncPanel", () => { ) ).toContain("No employee could be resolved"); + expect( + deriveRunStallSummary( + makeRunDetail({ + run: { status: "awaiting_lane_choice" } as unknown as LinearWorkflowRunDetail["run"], + }) + ) + ).toContain("Pick an execution lane"); + expect( deriveRunStallSummary( makeRunDetail({ diff --git a/apps/desktop/src/renderer/components/cto/LinearSyncPanel.tsx b/apps/desktop/src/renderer/components/cto/LinearSyncPanel.tsx index 7d24898c0..b4bf67ca4 100644 --- a/apps/desktop/src/renderer/components/cto/LinearSyncPanel.tsx +++ b/apps/desktop/src/renderer/components/cto/LinearSyncPanel.tsx @@ -3,9 +3,11 @@ import { useNavigate } from "react-router-dom"; import type { AgentIdentity, CtoFlowPolicyRevision, + LaneSummary, LinearConnectionStatus, LinearIngressEventRecord, LinearIngressStatus, + LinearSyncResolutionAction, LinearWorkflowMatchCandidate, LinearSyncDashboard, LinearSyncQueueItem, @@ -125,6 +127,10 @@ export function deriveRunStallSummary(detail: LinearWorkflowRunDetail, queueItem return "No employee could be resolved yet. Pick a delegation override, or update the workflow target."; } + if (detail.run.status === "awaiting_lane_choice") { + return "Pick an execution lane to resume this workflow."; + } + if (stalledReason) { return stalledReason; } @@ -238,7 +244,7 @@ function buildRunTimeline(detail: LinearWorkflowRunDetail): Array<{ return entries.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); } -export function LinearSyncPanel() { +export function LinearSyncPanel({ lanes, selectedLaneId }: { lanes: LaneSummary[]; selectedLaneId: string | null }) { const navigate = useNavigate(); const [connection, setConnection] = useState(null); const [dashboard, setDashboard] = useState(null); @@ -249,7 +255,7 @@ export function LinearSyncPanel() { const [selectedRunDetail, setSelectedRunDetail] = useState(null); const [runDetailLoading, setRunDetailLoading] = useState(false); const [reviewNote, setReviewNote] = useState(""); - const [queueActionLoading, setQueueActionLoading] = useState<"approve" | "reject" | "retry" | "complete" | null>(null); + const [queueActionLoading, setQueueActionLoading] = useState(null); const [revisions, setRevisions] = useState([]); const [agents, setAgents] = useState([]); const [ingressStatus, setIngressStatus] = useState(null); @@ -259,6 +265,7 @@ export function LinearSyncPanel() { const [error, setError] = useState(null); const [statusNote, setStatusNote] = useState(null); const [delegationOverrides, setDelegationOverrides] = useState>({}); + const [laneChoices, setLaneChoices] = useState>({}); const runtimeRefreshTimerRef = useRef(null); const selectedWorkflow = useMemo( @@ -401,8 +408,13 @@ export function LinearSyncPanel() { ); const actOnRun = useCallback( - async (action: "approve" | "reject" | "retry" | "complete") => { + async (action: LinearSyncResolutionAction) => { if (!window.ade?.cto || !selectedRunId) return; + const laneId = laneChoices[selectedRunId]?.trim() || undefined; + if (selectedRunDetail?.run.status === "awaiting_lane_choice" && action === "resume" && !laneId) { + setError("Choose an execution lane before continuing this workflow."); + return; + } setQueueActionLoading(action); setError(null); try { @@ -412,6 +424,7 @@ export function LinearSyncPanel() { action, note: reviewNote.trim() || undefined, employeeOverride: override || undefined, + laneId, }); setDelegationOverrides((prev) => { if (!prev[selectedRunId]) return prev; @@ -419,6 +432,12 @@ export function LinearSyncPanel() { delete next[selectedRunId]; return next; }); + setLaneChoices((prev) => { + if (!prev[selectedRunId]) return prev; + const next = { ...prev }; + delete next[selectedRunId]; + return next; + }); await loadRuntimeState(); await loadRunDetail(selectedRunId); const statusMessages: Record = { @@ -426,6 +445,7 @@ export function LinearSyncPanel() { reject: "Supervisor decision recorded.", complete: "Delegated work marked complete.", retry: "Workflow queued to retry.", + resume: "Workflow resumed with operator routing input.", }; setStatusNote(statusMessages[action] ?? "Workflow updated."); } catch (err) { @@ -434,7 +454,7 @@ export function LinearSyncPanel() { setQueueActionLoading(null); } }, - [delegationOverrides, loadRunDetail, loadRuntimeState, reviewNote, selectedRunId] + [delegationOverrides, laneChoices, loadRunDetail, loadRuntimeState, reviewNote, selectedRunDetail?.run.status, selectedRunId] ); const savePolicy = useCallback(async () => { @@ -509,6 +529,15 @@ export function LinearSyncPanel() { ); const selectedRunDelegationStatus = selectedRunQueueItem?.status ?? (selectedRunDetail?.run.status === "awaiting_delegation" ? "awaiting_delegation" : null); const showDelegationOverride = selectedRunDelegationStatus ? shouldShowDelegationOverride(selectedRunDelegationStatus) : false; + const selectedRunLaneId = useMemo( + () => laneChoices[selectedRunId ?? ""] ?? selectedRunDetail?.run.executionLaneId ?? selectedRunQueueItem?.laneId ?? selectedLaneId ?? lanes[0]?.id ?? "", + [laneChoices, lanes, selectedLaneId, selectedRunDetail, selectedRunId, selectedRunQueueItem] + ); + const showLaneChoice = selectedRunDetail?.run.status === "awaiting_lane_choice"; + const canResumeRun = Boolean( + selectedRunDetail + && (selectedRunDetail.run.status === "awaiting_delegation" || selectedRunDetail.run.status === "awaiting_lane_choice") + ); const canMarkRunComplete = Boolean( selectedRunDetail && selectedRunDetail.run.status === "waiting_for_target" @@ -523,9 +552,9 @@ export function LinearSyncPanel() { return (
{(statusNote || error) && ( -
- {statusNote ?
{statusNote}
: null} - {error ?
{error}
: null} +
+ {statusNote ?
{statusNote}
: null} + {error ?
{error}
: null}
)} @@ -544,7 +573,10 @@ export function LinearSyncPanel() {
{!selectedWorkflow ? ( -
Select or create a workflow
+
+
No workflow selected
+
Select a workflow from the sidebar or create one from the preset templates.
+
) : ( { + setLaneChoices((current) => { + const next = { ...current }; + if (value === null) { + delete next[runId]; + } else { + next[runId] = value; + } + return next; + }); + }} policySource={policy.source} connection={connection} revisions={revisions} diff --git a/apps/desktop/src/renderer/components/cto/pipeline/OperationsSidebar.tsx b/apps/desktop/src/renderer/components/cto/pipeline/OperationsSidebar.tsx index d4f3a597f..9a05c1eda 100644 --- a/apps/desktop/src/renderer/components/cto/pipeline/OperationsSidebar.tsx +++ b/apps/desktop/src/renderer/components/cto/pipeline/OperationsSidebar.tsx @@ -2,8 +2,10 @@ import React from "react"; import { Lightning } from "@phosphor-icons/react"; import type { CtoFlowPolicyRevision, + LaneSummary, LinearConnectionStatus, LinearIngressStatus, + LinearSyncResolutionAction, LinearSyncDashboard, LinearSyncQueueItem, LinearWorkflowMatchCandidate, @@ -32,6 +34,15 @@ function formatQueueRunStatus(item: LinearSyncQueueItem): string { return item.status.replace(/_/g, " "); } +function queueItemStatusColor(status: string): string { + switch (status) { + case "resolved": return "#34D399"; + case "escalated": return "#F472B6"; + case "failed": return "#EF4444"; + default: return "#60A5FA"; + } +} + type TimelineItem = { id: string; timestamp: string; @@ -65,17 +76,22 @@ type Props = { selectedRunStallSummary: string | null; selectedRunDelegationOverride: string; showDelegationOverride: boolean; + selectedRunLaneId: string; + showLaneChoice: boolean; + availableLanes: LaneSummary[]; + canResumeRun: boolean; canMarkRunComplete: boolean; selectedRunTimeline: TimelineItem[]; reviewNote: string; onReviewNoteChange: (note: string) => void; - queueActionLoading: "approve" | "reject" | "retry" | "complete" | null; + queueActionLoading: LinearSyncResolutionAction | null; delegatedEmployeeOptions: DelegatedEmployeeOption[]; onDelegationOverrideChange: (runId: string, value: string | null) => void; + onLaneChoiceChange: (runId: string, value: string | null) => void; policySource: LinearWorkflowSource; connection: LinearConnectionStatus | null; revisions: CtoFlowPolicyRevision[]; - onActOnRun: (action: "approve" | "reject" | "retry" | "complete") => void; + onActOnRun: (action: LinearSyncResolutionAction) => void; onEnsureWebhook: () => void; }; @@ -92,6 +108,10 @@ export function OperationsSidebar({ selectedRunStallSummary, selectedRunDelegationOverride, showDelegationOverride, + selectedRunLaneId, + showLaneChoice, + availableLanes, + canResumeRun, canMarkRunComplete, selectedRunTimeline, reviewNote, @@ -99,6 +119,7 @@ export function OperationsSidebar({ queueActionLoading, delegatedEmployeeOptions, onDelegationOverrideChange, + onLaneChoiceChange, policySource, connection, revisions, @@ -110,23 +131,23 @@ export function OperationsSidebar({ className="min-h-0 overflow-auto p-3" style={{ borderLeft: "1px solid rgba(167,139,250,0.08)", background: "linear-gradient(180deg, rgba(12,10,20,0.4), rgba(8,6,16,0.5))" }} > -
+
{/* Operations */}
-
-
- +
+
+ Operations
-
+
{dashboard ? ( <> {[ @@ -137,12 +158,12 @@ export function OperationsSidebar({ ].map((stat) => (
0 ? `${stat.accent}06` : "rgba(255,255,255,0.02)" }} + className="rounded-lg border border-white/[0.06] px-3 py-2.5 transition-all duration-200 hover:border-white/[0.12] hover:bg-white/[0.02]" + style={{ background: stat.value > 0 ? `${stat.accent}08` : "rgba(255,255,255,0.02)" }} > -
{stat.label}
+
{stat.label}
0 ? stat.accent : "rgba(255,255,255,0.5)" }} > {stat.value} @@ -151,80 +172,83 @@ export function OperationsSidebar({ ))} ) : ( -
Loading queue summary...
+
Loading queue summary...
)}
-
- Watch-only hits - {dashboard?.watchOnlyHits ?? 0} +
+ Watch-only hits + {dashboard?.watchOnlyHits ?? 0}
-
-
+
+
Recent sync events
{dashboard?.recentEvents?.length ? ( -
+
{dashboard.recentEvents.slice(0, 4).map((event) => (
- + {event.message ?? event.eventType} - {event.status ?? "sync"} + {event.status ?? "sync"}
))}
) : ( -
No recent sync events
+
+ No sync events recorded yet +
)}
{/* Ingress */}
-
+
-
- +
+ Ingress
-
+
- Relay - + Relay + {ingressStatus ? formatEndpoint(ingressStatus.relay) : "..."}
- Local - + Local + {ingressStatus ? formatEndpoint(ingressStatus.localWebhook) : "..."}
- Reconcile - + Reconcile + {ingressStatus?.reconciliation.enabled ? `${ingressStatus.reconciliation.intervalSec}s` : "off"} @@ -235,22 +259,22 @@ export function OperationsSidebar({ {/* Queue */}
-
+
-
- +
+ Queue
{dashboard && ( -
+
{[ { n: dashboard.queue.queued, color: "#60A5FA", label: "Q" }, { n: dashboard.queue.dispatched, color: "#FBBF24", label: "W" }, @@ -259,7 +283,7 @@ export function OperationsSidebar({ ].map((s) => ( 0 ? s.color : "rgba(255,255,255,0.12)" }} title={s.label} > @@ -270,16 +294,9 @@ export function OperationsSidebar({ )}
{queue.length ? ( -
+
{queue.slice(0, 6).map((item) => { - const statusColor = - item.status === "resolved" - ? "#34D399" - : item.status === "escalated" - ? "#F472B6" - : item.status === "failed" - ? "#EF4444" - : "#60A5FA"; + const statusColor = queueItemStatusColor(item.status); const isSelected = selectedRunId === item.id; return ( ); })}
) : ( -
No runs yet
+
+
No workflow runs in the queue
+
Runs appear here when Linear events match a workflow trigger.
+
)}
{/* Run detail */} {selectedRunId && (
-
-
- +
+
+ Run Detail {selectedRunQueueItem && {formatQueueRunStatus(selectedRunQueueItem)}}
{runDetailLoading ? ( -
Loading...
+
Loading run detail...
) : !selectedRunDetail ? ( -
Unavailable
+
Run detail unavailable
) : ( -
-
+
+
{selectedRunDetail.run.identifier} {selectedRunDetail.run.targetType}
{selectedRunMatchSummary ? (
-
+
Why this matched
{selectedRunMatchSummary.matchedCandidate ? route : null}
-
{selectedRunMatchSummary.reason}
+
{selectedRunMatchSummary.reason}
{selectedRunMatchSummary.matchedSignals.length ? ( -
+
Signals: {selectedRunMatchSummary.matchedSignals.join(" \u00b7 ")}
) : null} {selectedRunMatchSummary.routeTags.length ? ( -
+
Route tags: {selectedRunMatchSummary.routeTags.join(" \u00b7 ")}
) : null} {selectedRunMatchSummary.nextStepsPreview.length ? ( -
+
Next steps: {selectedRunMatchSummary.nextStepsPreview.join(" \u00b7 ")}
) : null} @@ -377,29 +397,74 @@ export function OperationsSidebar({ ) : null}
-
+
Why this is stalled
-
+
{selectedRunStallSummary ?? "Waiting for the next workflow event."}
{selectedRunDetail.run.routeContext?.matchedSignals?.length ? ( -
+
Matched signals: {selectedRunDetail.run.routeContext.matchedSignals.join(" \u00b7 ")}
) : null}
+ {showLaneChoice ? ( +
+
+ + required +
+ +
+ Pick the lane this workflow should use, then continue the run. +
+
+ +
+
+ ) : null} + {showDelegationOverride ? (
-
+
Delegation override
{selectedRunDetail.run.status === "awaiting_delegation" ? ( @@ -409,7 +474,7 @@ export function OperationsSidebar({ )}
-
- Applies to approve, reject, retry, and complete actions on this run. +
+ Applies to resume, approve, reject, retry, and complete actions on this run.
+ {selectedRunDetail.run.status === "awaiting_delegation" ? ( +
+ +
+ ) : null}
) : null} {selectedRunDetail.reviewContext && selectedRunDetail.run.status === "awaiting_human_review" ? (
-
+
Supervisor action needed
-
+