From de7b8f136e21e48ebccd690420b189e8109c4380 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:36:12 -0400 Subject: [PATCH 1/5] Release 10: CTO pipeline, chat, sync, and cross-cutting improvements Major areas: Linear dispatcher/intake/closeout hardening, chat text batching and transcript merging, sync remote command surface for agent chat, symlink-aware path containment, MCP headless linear services, pipeline builder UI, and code simplification pass across all services. Co-Authored-By: Claude Opus 4.6 (1M context) --- .ade/cto/openclaw-history.json | 1 - .ade/cto/openclaw-idempotency.json | 1 - .ade/cto/openclaw-outbox.json | 1 - .ade/cto/openclaw-routes.json | 3 - apps/desktop/src/main/main.ts | 11 +- .../ai/tools/ctoOperatorTools.test.ts | 36 + .../services/ai/tools/ctoOperatorTools.ts | 412 ++++++- .../services/ai/tools/universalTools.test.ts | 30 + .../main/services/ai/tools/universalTools.ts | 18 +- .../automationPlannerService.test.ts | 61 +- .../automations/automationPlannerService.ts | 49 +- .../automations/automationService.test.ts | 195 +++- .../services/automations/automationService.ts | 56 +- .../services/chat/agentChatService.test.ts | 21 + .../main/services/chat/agentChatService.ts | 111 +- .../chat/buildClaudeV2Message.test.ts | 14 + .../services/chat/buildClaudeV2Message.ts | 5 +- .../computerUseArtifactBrokerService.test.ts | 76 ++ .../computerUseArtifactBrokerService.ts | 33 +- .../projectConfigService.laneEnvInit.test.ts | 69 ++ .../services/config/projectConfigService.ts | 17 +- .../main/services/cto/flowPolicyService.ts | 68 +- .../services/cto/linearCloseoutService.ts | 29 +- .../cto/linearDispatcherService.test.ts | 130 +++ .../services/cto/linearDispatcherService.ts | 94 +- .../services/cto/linearSyncService.test.ts | 47 +- .../main/services/cto/linearSyncService.ts | 1 + .../services/cto/linearWorkflowFileService.ts | 15 +- .../services/cto/workerHeartbeatService.ts | 7 +- .../src/main/services/files/fileService.ts | 25 +- .../src/main/services/ipc/registerIpc.ts | 107 +- .../lanes/laneEnvironmentService.test.ts | 72 +- .../services/lanes/laneEnvironmentService.ts | 63 +- .../orchestrator/aiOrchestratorService.ts | 133 +-- .../orchestrator/coordinatorTools.test.ts | 44 + .../services/orchestrator/coordinatorTools.ts | 36 +- .../orchestrator/orchestratorService.ts | 27 +- .../services/processes/processService.test.ts | 53 + .../main/services/processes/processService.ts | 10 +- .../main/services/prs/prIssueResolver.test.ts | 2 +- .../src/main/services/prs/prIssueResolver.ts | 2 +- .../prs/prService.integrationCommit.test.ts | 185 +++ .../src/main/services/prs/prService.ts | 14 +- .../services/prs/queueLandingService.test.ts | 220 ++++ .../src/main/services/pty/ptyService.test.ts | 16 + .../src/main/services/pty/ptyService.ts | 19 +- .../src/main/services/shared/utils.test.ts | 42 + .../desktop/src/main/services/shared/utils.ts | 42 + .../services/sync/syncHostService.test.ts | 25 + .../src/main/services/sync/syncHostService.ts | 15 +- .../sync/syncRemoteCommandService.test.ts | 1031 +++++++++++++++++ .../src/main/services/tests/testService.ts | 10 +- apps/desktop/src/preload/global.d.ts | 68 +- apps/desktop/src/preload/preload.ts | 56 +- .../components/chat/AgentChatMessageList.tsx | 46 +- .../components/chat/AgentChatPane.tsx | 7 +- .../components/chat/chatStatusVisuals.tsx | 18 +- .../components/chat/chatTranscriptRows.ts | 31 +- .../src/renderer/components/cto/CtoPage.tsx | 30 +- .../components/cto/LinearSyncPanel.test.ts | 8 + .../components/cto/LinearSyncPanel.tsx | 63 +- .../cto/pipeline/OperationsSidebar.tsx | 339 ++++-- .../cto/pipeline/WorkflowListSidebar.tsx | 54 +- .../components/cto/shared/TimelineEntry.tsx | 18 +- .../components/cto/shared/designTokens.ts | 2 +- .../components/lanes/MonacoDiffView.tsx | 7 +- .../components/prs/detail/PrDetailPane.tsx | 3 +- .../components/settings/MemoryHealthTab.tsx | 26 +- apps/desktop/src/shared/types/linearSync.ts | 9 +- apps/mcp-server/src/bootstrap.ts | 2 +- apps/mcp-server/src/mcpServer.test.ts | 119 +- apps/mcp-server/src/mcpServer.ts | 52 +- docs/architecture/AI_INTEGRATION.md | 1 + docs/architecture/IOS_APP.md | 1 + docs/architecture/MULTI_DEVICE_SYNC.md | 3 + docs/architecture/SECURITY_AND_PRIVACY.md | 13 +- docs/features/LANES.md | 10 +- docs/features/PULL_REQUESTS.md | 2 + 78 files changed, 4005 insertions(+), 787 deletions(-) delete mode 100644 .ade/cto/openclaw-history.json delete mode 100644 .ade/cto/openclaw-idempotency.json delete mode 100644 .ade/cto/openclaw-outbox.json delete mode 100644 .ade/cto/openclaw-routes.json create mode 100644 apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts 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..c462b93b6 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 ────────────────────────────────────────────────── @@ -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..be199ce3f 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -47,6 +47,21 @@ export interface CtoOperatorToolDeps { prService?: ReturnType | null; fileService?: ReturnType | null; processService?: ReturnType | null; + testService?: { + listSuites: () => any[]; + run: (args: { laneId: string; suiteId: string }) => Promise; + stop: (args: { runId: string }) => void; + listRuns: (args?: { laneId?: string; suiteId?: string; limit?: number }) => any[]; + 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: () => any[]; + triggerManually: (args: { id: string; dryRun?: boolean }) => Promise; + listRuns: (args?: any) => any[]; + } | null; issueTracker?: IssueTracker | null; listChats: (laneId?: string, options?: { includeIdentity?: boolean; includeAutomation?: boolean }) => Promise; getChatStatus: (sessionId: string) => Promise; @@ -1309,8 +1324,9 @@ 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 +1336,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..187121a02 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); + 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..01db94a0e 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.test.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.test.ts @@ -1,14 +1,18 @@ +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 }) { const logger = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} } as any; + const projectRoot = args.projectRoot ?? "/tmp"; let snapshot = { shared: {}, @@ -48,7 +52,7 @@ function createPlannerForTests(args: { suites: Array<{ id: string; name: string return { planner: createAutomationPlannerService({ logger, - projectRoot: "/tmp", + projectRoot, projectConfigService, laneService, automationService @@ -57,7 +61,7 @@ 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 }) { const harness = createPlannerForTests(args); return harness; } @@ -93,6 +97,57 @@ 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 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..814ad6f85 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.ts @@ -29,7 +29,25 @@ 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 validateAutomationCwd(projectRoot: string, cwdRaw: string): string | null { + const candidate = path.isAbsolute(cwdRaw) ? cwdRaw : path.resolve(projectRoot, cwdRaw); + let resolved: string; + try { + resolved = resolvePathWithinRoot(projectRoot, candidate, { allowMissing: true }); + } catch { + return "cwd must stay within the project root."; + } + try { + if (!fs.statSync(resolved).isDirectory()) { + return "cwd must point to an existing directory within the project root."; + } + } catch { + return "cwd must point to an existing directory within the project root."; + } + return null; +} function slugify(input: string): string { const s = input @@ -642,28 +660,15 @@ 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 cwdIssue = validateAutomationCwd(args.projectRoot, 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; } } diff --git a/apps/desktop/src/main/services/automations/automationService.test.ts b/apps/desktop/src/main/services/automations/automationService.test.ts index 97fa14373..c0bb03416 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"; @@ -189,6 +191,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 +396,146 @@ 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("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 }); + } + }); + }); diff --git a/apps/desktop/src/main/services/automations/automationService.ts b/apps/desktop/src/main/services/automations/automationService.ts index 4956dc673..7ddb93b24 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"; @@ -41,8 +42,8 @@ import type { createProceduralLearningService } from "../memory/proceduralLearni import type { createBudgetCapService } from "../usage/budgetCapService"; 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 +456,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: { @@ -1421,14 +1425,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 }; @@ -1586,6 +1600,15 @@ export function createAutomationService({ throw new Error("No lane is available for this automation run."); } + const budgetCheck = budgetCapServiceRef?.checkBudget( + AUTOMATION_SCOPE as Parameters["checkBudget"]>[0], + args.rule.id, + "any", + ); + 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); @@ -1605,6 +1628,20 @@ export function createAutomationService({ const actionId = insertAction(run.id, 0, "agent-session"); const modelId = args.rule.modelConfig?.orchestratorModel?.modelId ?? DEFAULT_AUTOMATION_CHAT_MODEL_ID; + const modelDescriptor = getModelById(modelId) ?? getDefaultModelDescriptor("unified"); + if (!modelDescriptor) { + throw new Error(`Unknown model '${modelId}'.`); + } + const providerGroup = resolveProviderGroupForModel(modelDescriptor); + const permissionConfig = buildPermissionConfig(args.rule, { publishPhase: false }); + const verificationRequired = requiresPublishGate(args.rule); + const permissionMode = verificationRequired + ? "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 +1658,10 @@ export function createAutomationService({ modelId, sessionProfile: "workflow", reasoningEffort, - unifiedPermissionMode: "full-auto", + permissionMode, + ...(providerGroup === "codex" && !verificationRequired && permissionConfig.providers?.codexSandbox + ? { codexSandbox: permissionConfig.providers.codexSandbox } + : {}), surface: "automation", automationId: args.rule.id, automationRunId: run.id, @@ -1654,7 +1694,7 @@ export function createAutomationService({ queue_status: deriveQueueStatus({ current: "pending-review", runStatus: "succeeded", - verificationRequired: false, + verificationRequired, mode: args.rule.mode, summary: result.outputText, }), @@ -1677,7 +1717,7 @@ export function createAutomationService({ queue_status: deriveQueueStatus({ current: "pending-review", runStatus: "failed", - verificationRequired: false, + verificationRequired, mode: args.rule.mode, summary: message, }), diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 8d17ef313..9ea0a392a 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -1836,6 +1836,27 @@ 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 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 }); + } + }); + 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..1a8cd5bfa 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 { nowIso, fileSizeOrZero, 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 { @@ -942,6 +944,7 @@ function buildStreamingUserContent( attachments: AgentChatFileRef[]; runtimeKind: "claude" | "unified"; modelDescriptor?: ModelDescriptor; + baseDir?: string; }, ): UserContent { if (!args.attachments.length) { @@ -953,7 +956,9 @@ function buildStreamingUserContent( ]; for (const attachment of args.attachments) { - const resolvedPath = path.resolve(attachment.path); + const resolvedPath = args.baseDir + ? path.resolve(args.baseDir, attachment.path) + : path.resolve(attachment.path); if (!fs.existsSync(resolvedPath)) { parts.push({ type: "text", text: `\nAttachment missing: ${attachment.path}` }); continue; @@ -1541,7 +1546,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 +1584,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 +1619,9 @@ export function createAgentChatService(args: { linearCredentials: linearCredentialsRef, prService, processService, + getTestService, + ptyService, + getAutomationService, computerUseArtifactBrokerService, laneService, sessionService, @@ -4064,9 +4075,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", @@ -4269,7 +4280,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, attachments, { + baseDir: managed.laneWorktreePath, + }); const turnPermissionMode = resolveClaudeTurnPermissionMode(managed); if (typeof runtime.v2Session.setPermissionMode === "function") { @@ -4981,6 +4994,7 @@ export function createAgentChatService(args: { attachments, runtimeKind: "unified", modelDescriptor: runtime.modelDescriptor, + baseDir: managed.laneWorktreePath, }), }; }); @@ -5191,6 +5205,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 +7600,24 @@ export function createAgentChatService(args: { const visibleText = displayText?.trim().length ? displayText.trim() : trimmed; const managed = ensureManagedSession(sessionId); + for (const attachment of attachments) { + const rawPath = attachment.path.trim(); + if (!rawPath.length) { + throw new Error("Attachment path is required."); + } + const isAbsolute = path.isAbsolute(rawPath); + const root = isAbsolute ? projectRoot : managed.laneWorktreePath; + const candidate = isAbsolute ? rawPath : path.resolve(managed.laneWorktreePath, rawPath); + try { + resolvePathWithinRoot(root, candidate, { allowMissing: true }); + } 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") @@ -8778,13 +8813,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 +8833,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 +8857,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..ee0618658 100644 --- a/apps/desktop/src/main/services/chat/buildClaudeV2Message.ts +++ b/apps/desktop/src/main/services/chat/buildClaudeV2Message.ts @@ -75,6 +75,7 @@ export type SDKUserMessagePartial = { export function buildClaudeV2Message( promptText: string, attachments: AgentChatFileRef[], + options: { baseDir?: string } = {}, ): string | SDKUserMessagePartial { const imageAttachments = attachments.filter((a) => a.type === "image"); if (!imageAttachments.length) { @@ -97,7 +98,9 @@ export function buildClaudeV2Message( } try { - const resolvedPath = path.resolve(attachment.path); + const resolvedPath = options.baseDir + ? path.resolve(options.baseDir, attachment.path) + : 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}]` }); 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..1e2759c43 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,20 @@ 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 dedupeOwners(owners: ComputerUseArtifactOwner[]): ComputerUseArtifactOwner[] { const seen = new Set(); const result: ComputerUseArtifactOwner[] = []; @@ -136,6 +151,12 @@ export function createComputerUseArtifactBrokerService(args: { }) { const { db, projectId, projectRoot, missionService, orchestratorService, externalMcpService, logger, 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 { @@ -166,12 +187,18 @@ export function createComputerUseArtifactBrokerService(args: { if (pathLike) { const absolutePath = path.isAbsolute(pathLike) ? pathLike : path.resolve(projectRoot, pathLike); 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); 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..120cb2e36 100644 --- a/apps/desktop/src/main/services/cto/flowPolicyService.ts +++ b/apps/desktop/src/main/services/cto/flowPolicyService.ts @@ -93,51 +93,57 @@ 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 } : {}), - }, - } + ? (() => { + const routeTags = uniqueStrings(entry.routing.metadataTags); + return { + routing: { + ...(routeTags.length ? { metadataTags: routeTags } : {}), + ...(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) } - : {}), - }, - } + ? (() => { + const applyLabels = uniqueStrings(entry.closeout.applyLabels); + const labels = uniqueStrings(entry.closeout.labels); + return { + closeout: { + ...entry.closeout, + ...(applyLabels.length ? { applyLabels } : {}), + ...(labels.length ? { labels } : {}), + }, + }; + })() : {}), ...(entry.humanReview - ? { - humanReview: { - ...entry.humanReview, - ...(uniqueStrings(entry.humanReview.reviewers).length - ? { reviewers: uniqueStrings(entry.humanReview.reviewers) } - : {}), - }, - } + ? (() => { + const reviewers = uniqueStrings(entry.humanReview.reviewers); + return { + humanReview: { + ...entry.humanReview, + ...(reviewers.length ? { reviewers } : {}), + }, + }; + })() : {}), ...(entry.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.ts b/apps/desktop/src/main/services/files/fileService.ts index 875742db9..3417a4ecc 100644 --- a/apps/desktop/src/main/services/files/fileService.ts +++ b/apps/desktop/src/main/services/files/fileService.ts @@ -24,7 +24,7 @@ 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, isWithinDir, normalizeRelative, resolvePathWithinRoot } from "../shared/utils"; import { createFileWatcherService } from "./fileWatcherService"; import { createFileSearchIndexService } from "./fileSearchIndexService"; @@ -122,10 +122,17 @@ 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)) { + const joinedPath = path.normalize(path.join(rootPath, normalizedRel)); + let absPath: string; + try { + absPath = resolvePathWithinRoot(rootPath, joinedPath, { allowMissing: opts.allowMissing }); + } catch { throw new Error("Refusing to access path outside workspace"); } if (containsDotGit(absPath)) { @@ -401,7 +408,7 @@ 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); + const { absPath } = ensureSafePath(worktreePath, relPath, { allowMissing: true }); writeTextAtomicAbs(absPath, text); invalidateGitStatusCache(worktreePath); if (onLaneWorktreeMutation) { @@ -455,7 +462,7 @@ export function createFileService({ writeWorkspaceText(args: FilesWriteTextArgs): void { const workspace = resolveWorkspace(args.workspaceId); - const { absPath, normalizedRel } = ensureSafePath(workspace.rootPath, args.path); + const { absPath, normalizedRel } = ensureSafePath(workspace.rootPath, args.path, { allowMissing: true }); writeTextAtomicAbs(absPath, args.text); invalidateGitStatusCache(workspace.rootPath); if (normalizedRel === ".gitignore") { @@ -473,7 +480,7 @@ export function createFileService({ createFile(args: FilesCreateFileArgs): void { const workspace = resolveWorkspace(args.workspaceId); - const { absPath, normalizedRel } = ensureSafePath(workspace.rootPath, args.path); + const { absPath, normalizedRel } = ensureSafePath(workspace.rootPath, args.path, { allowMissing: true }); fs.mkdirSync(path.dirname(absPath), { recursive: true }); if (!fs.existsSync(absPath)) { fs.writeFileSync(absPath, args.content ?? "", "utf8"); @@ -491,7 +498,7 @@ export function createFileService({ createDirectory(args: FilesCreateDirectoryArgs): void { const workspace = resolveWorkspace(args.workspaceId); - const { absPath } = ensureSafePath(workspace.rootPath, args.path); + const { absPath } = ensureSafePath(workspace.rootPath, args.path, { allowMissing: true }); fs.mkdirSync(absPath, { recursive: true }); invalidateGitStatusCache(workspace.rootPath); indexService.invalidateWorkspace(args.workspaceId); @@ -501,7 +508,7 @@ 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); + const { absPath: newAbs, normalizedRel: newRel } = ensureSafePath(workspace.rootPath, args.newPath, { allowMissing: true }); fs.mkdirSync(path.dirname(newAbs), { recursive: true }); fs.renameSync(oldAbs, newAbs); invalidateGitStatusCache(workspace.rootPath); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 8cec19b8d..0d7d82aa6 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; }; @@ -1695,7 +1691,15 @@ export function registerIpc({ app.getPath("documents"), app.getPath("temp"), ]; - if (!allowedDirs.some((dir) => isWithinDir(dir, normalized))) { + 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,9 +1724,11 @@ 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)) { + let targetPath: string; + try { + const candidatePath = relRaw ? path.resolve(rootPath, relRaw) : rootPath; + targetPath = resolvePathWithinRoot(rootPath, candidatePath, { allowMissing: true }); + } catch { throw new Error("relativePath escapes rootPath."); } @@ -2006,7 +2012,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 +2020,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 +2032,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 +2047,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 +2056,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 +2064,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 +2072,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 +2080,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."); @@ -2386,19 +2392,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 +2438,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 +2461,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 +3478,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 +3514,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 +3526,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 +3539,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 +4586,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 +5660,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..000884fa7 100644 --- a/apps/desktop/src/main/services/lanes/laneEnvironmentService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneEnvironmentService.test.ts @@ -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,44 @@ 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 }); + } + }); }); 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..9a7c6fe95 100644 --- a/apps/desktop/src/main/services/lanes/laneEnvironmentService.ts +++ b/apps/desktop/src/main/services/lanes/laneEnvironmentService.ts @@ -17,7 +17,24 @@ import type { } from "../../../shared/types"; import type { Logger } from "../logging/logger"; -import { isWithinDir } from "../shared/utils"; +import { resolvePathWithinRoot } 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, path.resolve(root, relative), opts); + } catch { + logger.warn(logTag, logContext); + throw new Error("Path escapes allowed directory"); + } +} function cloneDockerConfig(config: LaneDockerConfig): LaneDockerConfig { return config.services @@ -173,13 +190,8 @@ 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"); - } + const sourcePath = resolveCheckedPath(projectRoot, file.source, logger, "lane_env_init.env_file_source_escape", { source: file.source, projectRoot }, { allowMissing: true }); + const destPath = resolveCheckedPath(worktreePath, file.dest, logger, "lane_env_init.env_file_path_escape", { dest: file.dest, worktreePath }, { allowMissing: true }); // Ensure destination directory exists const destDir = path.dirname(destPath); @@ -215,7 +227,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 +263,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,17 +282,8 @@ 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 sourcePath = resolveCheckedPath(adeDir, mp.source, logger, "lane_env_init.mount_source_path_escape", { source: mp.source, adeDir }, { allowMissing: true }); + const destPath = resolveCheckedPath(worktreePath, mp.dest, logger, "lane_env_init.mount_dest_path_escape", { dest: mp.dest, worktreePath }, { allowMissing: true }); const destDir = path.dirname(destPath); if (!fs.existsSync(destDir)) { @@ -303,14 +306,9 @@ 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"); - } + const destPath = resolveCheckedPath(worktreePath, dest, logger, "lane_env_init.copy_dest_path_escape", { dest, worktreePath }, { allowMissing: true }); if (!fs.existsSync(sourcePath)) { logger.warn("lane_env_init.copy_path_missing", { source: cp.source }); @@ -495,7 +493,14 @@ export function createLaneEnvironmentService({ return; } const projectName = buildDockerProjectName(lane.id, config.docker.projectPrefix); - const composePath = path.resolve(projectRoot, config.docker.composePath); + let composePath: string; + try { + composePath = resolvePathWithinRoot(projectRoot, path.resolve(projectRoot, config.docker.composePath), { allowMissing: true }); + } catch { + logger.warn("lane_env_cleanup.docker_compose_escape", { laneId: lane.id, path: config.docker.composePath, projectRoot }); + progressMap.delete(lane.id); + return; + } if (!fs.existsSync(composePath)) { logger.warn("lane_env_cleanup.docker_compose_missing", { laneId: lane.id, path: config.docker.composePath }); progressMap.delete(lane.id); diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts index b229364ea..f042ccd95 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" @@ -2899,7 +2891,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 +4656,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 +6399,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 +7469,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 +7538,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 +7547,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 +7991,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 +8340,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: { diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts b/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts index a9fe9c47e..363025e48 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts @@ -4279,6 +4279,50 @@ 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("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("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"; diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts b/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts index 69701267f..6d38f7624 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 { resolvePathWithinRoot } from "../shared/utils"; import { normalizeAgentRuntimeFlags } from "./teamRuntimeConfig"; import { registerTeamMember } from "./teamRuntimeState"; import type { createMemoryService } from "../memory/memoryService"; @@ -675,6 +675,13 @@ export function createCoordinatorToolSet(deps: { : null; const resolvedProjectRoot = path.resolve(projectRoot); const resolvedWorkspaceRoot = path.resolve(workspaceRoot); + const resolveWorkspacePath = (candidatePath: string): string | null => { + try { + return resolvePathWithinRoot(resolvedWorkspaceRoot, candidatePath); + } catch { + return null; + } + }; const normalizeLaneId = (value: string | null | undefined): string | null => { if (typeof value !== "string") return null; @@ -6030,9 +6037,8 @@ 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(path.resolve(resolvedWorkspaceRoot, filePath)); + if (!fullPath) { return { ok: false, error: "Path is outside mission workspace root" }; } let stat: fs.Stats; @@ -6074,8 +6080,8 @@ 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(path.resolve(resolvedWorkspaceRoot, `.ade/step-output-${sanitized}.md`)); + if (!filePath) { return { ok: false, error: "Path is outside mission workspace root" }; } let content: string; @@ -6118,9 +6124,11 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act 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)); + const fullPath = resolveWorkspacePath(path.join(dir, entry.name)); + if (!fullPath) continue; + const rel = path.relative(resolvedWorkspaceRoot, fullPath); if (entry.isDirectory()) { - walkDir(path.join(dir, entry.name), depth + 1); + walkDir(fullPath, depth + 1); } else if (new RegExp(pattern.replace(/\*/g, ".*")).test(entry.name)) { results.push(rel); } @@ -6129,7 +6137,7 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act // 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 @@ -6142,7 +6150,8 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act 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); + const fullPath = resolveWorkspacePath(path.join(dir, entry.name)); + if (!fullPath) continue; if (entry.isDirectory()) { walkDir(fullPath, depth + 1); } else { @@ -6154,7 +6163,7 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act 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(resolvedWorkspaceRoot, fullPath), line: i + 1, text: lines[i]!.slice(0, 200), }); @@ -6169,7 +6178,7 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act // 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 +6197,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(path.resolve(resolvedWorkspaceRoot, f)); + if (!fp) continue; try { const content = fs.readFileSync(fp, "utf-8"); docs[f] = content.slice(0, 4_000); 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..c095cef5d 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,189 @@ 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"; + + 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: "", + }); + db.close(); + }); + + 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-")); + const 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, + }); + db.close(); + }); }); diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index c2a587ca7..308f83979 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, path.resolve(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, path.resolve(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..bed9b9b41 100644 --- a/apps/desktop/src/main/services/prs/queueLandingService.test.ts +++ b/apps/desktop/src/main/services/prs/queueLandingService.test.ts @@ -257,4 +257,224 @@ 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"); + + // Give the loop time to run and exit. + await sleep(300); + + 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"], + ); + + let cancelQueueId: string | null = null; + // land mock: first call delays long enough for external cancellation to happen + const land = vi.fn().mockImplementation(async ({ prId }: { prId: string }) => { + if (prId === "pr-slow") { + // During this delay, the test will cancel the queue via direct DB update + await sleep(200); + 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" }); + cancelQueueId = queueState.queueId; + + // Wait just enough for land to be called (the first entry starts landing + // immediately), then cancel the queue externally via DB update before land resolves. + await sleep(50); + db.run( + "update queue_landing_state set state = 'cancelled', completed_at = ? where id = ?", + [new Date().toISOString(), cancelQueueId], + ); + + // Wait for the landing loop to notice the cancellation and exit. + await sleep(500); + + const finalState = service.getQueueStateByGroup("group-cancel"); + 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..d804a3c5b 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 } = 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-uuid-2.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..9560a79fc 100644 --- a/apps/desktop/src/main/services/shared/utils.test.ts +++ b/apps/desktop/src/main/services/shared/utils.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { isRecord, @@ -14,6 +17,7 @@ import { parseDiffNameOnly, safeJsonParse, isWithinDir, + resolvePathWithinRoot, toOptionalString, normalizeRelative, normalizeBranchName, @@ -218,6 +222,44 @@ 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 }); + } + }); +}); + +describe("resolvePathWithinRoot", () => { + it("allows a normal child path when intermediate segments do not exist yet", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-utils-root-")); + const target = path.join(root, "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); + }); + + 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-")); + const linkPath = path.join(tempRoot, "linked"); + const outsideFile = path.join(outsideDir, "secret.txt"); + fs.writeFileSync(outsideFile, "secret", "utf8"); + fs.symlinkSync(outsideDir, linkPath); + + expect(() => resolvePathWithinRoot(tempRoot, path.join(linkPath, "secret.txt"))).toThrow("Path escapes root"); + }); +}); + 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..4276cba63 100644 --- a/apps/desktop/src/main/services/shared/utils.ts +++ b/apps/desktop/src/main/services/shared/utils.ts @@ -149,6 +149,48 @@ 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 resolveRealPathAllowMissing(filePath: string): string { + const resolved = path.resolve(filePath); + const missingSegments: string[] = []; + let cursor = resolved; + + while (!fs.existsSync(cursor)) { + const parent = path.dirname(cursor); + if (parent === cursor) { + throw new Error(`Path does not exist: ${filePath}`); + } + missingSegments.unshift(path.basename(cursor)); + cursor = parent; + } + + return path.resolve(realpathExisting(cursor), ...missingSegments); +} + +/** + * 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 = opts.allowMissing + ? resolveRealPathAllowMissing(candidate) + : realpathExisting(path.resolve(candidate)); + if (!isWithinDir(rootReal, candidateReal)) { + throw new Error("Path escapes root"); + } + return candidateReal; +} + // ── 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..b0d213e09 --- /dev/null +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -0,0 +1,1031 @@ +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 () => { + await service.execute(makePayload("prs.list")); + expect(prService.listAll).toHaveBeenCalled(); + }); + + 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 () => { + await service.execute(makePayload("prs.getDetail", { prId: "pr-42" })); + expect(prService.getDetail).toHaveBeenCalledWith("pr-42"); + }); + + 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.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 }); + }); + }); + + // --------------------------------------------------------------- + // 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..8087d174b 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,82 @@ 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 +258,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 +282,7 @@ export function OperationsSidebar({ ].map((s) => ( 0 ? s.color : "rgba(255,255,255,0.12)" }} title={s.label} > @@ -270,16 +293,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 +396,73 @@ 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 ? ( +
+
+
+ Execution lane +
+ required +
+ +
+ Pick the lane this workflow should use, then continue the run. +
+
+ +
+
+ ) : null} + {showDelegationOverride ? (
-
+
Delegation override
{selectedRunDetail.run.status === "awaiting_delegation" ? ( @@ -409,7 +472,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
-
+