Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import { createAutomationService } from "./services/automations/automationServic
import { createAutomationPlannerService } from "./services/automations/automationPlannerService";
import { createAutomationSecretService } from "./services/automations/automationSecretService";
import { createAutomationIngressService } from "./services/automations/automationIngressService";
import { createReviewService } from "./services/review/reviewService";
import { createGithubPollingService } from "./services/automations/githubPollingService";
import type { AutomationAdeActionRegistry } from "./services/automations/automationService";
import {
Expand Down Expand Up @@ -2334,6 +2335,23 @@ app.whenReady().then(async () => {
onEvent: (event) =>
emitProjectEvent(projectRoot, IPC.automationsEvent, event),
});
const reviewService = createReviewService({
db,
logger,
projectId,
projectRoot,
projectDefaultBranch: baseRef,
laneService,
gitService,
agentChatService,
sessionService,
sessionDeltaService,
testService,
issueInventoryService,
prService,
embeddingService,
onEvent: (event) => emitProjectEvent(projectRoot, IPC.reviewEvent, event),
});
const automationIngressService = createAutomationIngressService({
logger,
automationService,
Expand Down Expand Up @@ -3464,6 +3482,7 @@ app.whenReady().then(async () => {
queueLandingService,
issueInventoryService,
prSummaryService,
reviewService,
jobEngine,
automationService,
automationPlannerService,
Expand Down Expand Up @@ -3572,6 +3591,7 @@ app.whenReady().then(async () => {
queueLandingService: null,
issueInventoryService: null,
prSummaryService: null,
reviewService: null,
jobEngine: null,
automationService: null,
automationPlannerService: null,
Expand Down Expand Up @@ -3684,6 +3704,11 @@ app.whenReady().then(async () => {
} catch {
// ignore
}
try {
ctx.reviewService?.dispose?.();
} catch {
// ignore
}
Comment on lines +3707 to +3711
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Dispose reviewService in the fast-shutdown path too.

Lines 3707-3709 cover normal context teardown, but Line 4233's runImmediateProcessCleanup() still skips the new ctx.reviewService. On uncaughtException, SIGTERM, or forced exit, an in-flight review can keep running/publishing while the rest of the project context is already being torn down.

Suggested follow-up
     for (const ctx of contexts) {
       try {
+        ctx.reviewService?.dispose?.();
+      } catch {
+        // ignore
+      }
+      try {
         ctx.aiOrchestratorService?.dispose?.();
       } catch {
         // ignore
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/main.ts` around lines 3707 - 3711, The fast-shutdown
path (runImmediateProcessCleanup) currently skips disposing ctx.reviewService
causing in-flight reviews to continue; update the fast-shutdown/cleanup routine
(runImmediateProcessCleanup) to call and await ctx.reviewService?.dispose?.()
(or invoke the same disposal logic used in normal teardown) and wrap it in a
try/catch similar to the normal context teardown block to ensure it runs safely
on uncaughtException/SIGTERM/forced exit so the reviewService is properly
stopped before process exit.

try {
ctx.usageTrackingService?.dispose();
} catch {
Expand Down
57 changes: 57 additions & 0 deletions apps/desktop/src/main/services/ipc/registerIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ import type {
AutomationSaveDraftResult,
AutomationSimulateRequest,
AutomationSimulateResult,
ReviewLaunchContext,
ReviewListRunsArgs,
ReviewRun,
ReviewRunDetail,
ReviewStartRunArgs,
AdeActionRegistryEntry,
AddMissionArtifactArgs,
AddMissionInterventionArgs,
Expand Down Expand Up @@ -534,6 +539,7 @@ import type { createPrPollingService } from "../prs/prPollingService";
import type { createQueueLandingService } from "../prs/queueLandingService";
import type { createIssueInventoryService } from "../prs/issueInventoryService";
import type { createPrSummaryService } from "../prs/prSummaryService";
import type { createReviewService } from "../review/reviewService";
import type { createAgentChatService } from "../chat/agentChatService";
import type { createComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService";
import { buildComputerUseOwnerSnapshot } from "../computerUse/controlPlane";
Expand Down Expand Up @@ -631,6 +637,7 @@ export type AppContext = {
queueLandingService: ReturnType<typeof createQueueLandingService>;
issueInventoryService: ReturnType<typeof createIssueInventoryService>;
prSummaryService: ReturnType<typeof createPrSummaryService>;
reviewService: ReturnType<typeof createReviewService>;
jobEngine: ReturnType<typeof createJobEngine>;
automationService: ReturnType<typeof createAutomationService>;
automationPlannerService: ReturnType<typeof createAutomationPlannerService>;
Expand Down Expand Up @@ -2785,6 +2792,56 @@ export function registerIpc({
return ctx.automationPlannerService.simulate(arg);
});

ipcMain.handle(IPC.reviewListLaunchContext, async (): Promise<ReviewLaunchContext> => {
const ctx = getCtx();
return ctx.reviewService.listLaunchContext();
});

ipcMain.handle(IPC.reviewListRuns, async (_event, arg: ReviewListRunsArgs = {}): Promise<ReviewRun[]> => {
const ctx = getCtx();
return ctx.reviewService.listRuns(arg);
});

ipcMain.handle(IPC.reviewGetRunDetail, async (_event, arg: { runId: string }): Promise<ReviewRunDetail | null> => {
const ctx = getCtx();
return ctx.reviewService.getRunDetail({ runId: arg?.runId ?? "" });
});

ipcMain.handle(IPC.reviewStartRun, async (_event, arg: ReviewStartRunArgs): Promise<ReviewRun> => {
const ctx = getCtx();
return ctx.reviewService.startRun(arg);
});

ipcMain.handle(IPC.reviewRerun, async (_event, arg: { runId: string }): Promise<ReviewRun> => {
const ctx = getCtx();
return ctx.reviewService.rerun(arg?.runId ?? "");
});

ipcMain.handle(IPC.reviewCancelRun, async (_event, arg: { runId: string }) => {
const ctx = getCtx();
return ctx.reviewService.cancelRun({ runId: arg?.runId ?? "" });
});

ipcMain.handle(IPC.reviewRecordFeedback, async (_event, arg: import("../../../shared/types").ReviewRecordFeedbackArgs) => {
const ctx = getCtx();
return ctx.reviewService.recordFeedback(arg);
});

ipcMain.handle(IPC.reviewListSuppressions, async (_event, arg: import("../../../shared/types").ReviewListSuppressionsArgs | undefined) => {
const ctx = getCtx();
return ctx.reviewService.listSuppressions(arg ?? {});
});

ipcMain.handle(IPC.reviewDeleteSuppression, async (_event, arg: { suppressionId: string }) => {
const ctx = getCtx();
return ctx.reviewService.deleteSuppression({ suppressionId: arg?.suppressionId ?? "" });
});

ipcMain.handle(IPC.reviewQualityReport, async () => {
const ctx = getCtx();
return ctx.reviewService.qualityReport();
});

ipcMain.handle(IPC.adeActionsListRegistry, async (): Promise<AdeActionRegistryEntry[]> => {
const ctx = getCtx();
const services = getAdeActionDomainServices(ctx as unknown as AdeRuntime);
Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/src/main/services/lanes/laneLaunchContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,29 @@ describe("resolveLaneLaunchContext", () => {
});
});

describe("happy path: explicit absolute cwd outside worktree", () => {
it("allows an external absolute cwd when the caller opts in", () => {
mocks.statSync.mockReturnValue({ isDirectory: () => true });
mocks.realpathSync
.mockReturnValueOnce("/real/lane/root")
.mockReturnValueOnce("/real/project/root");

const result = resolveLaneLaunchContext({
laneService: makeLaneService("/projects/my-lane"),
laneId: "lane-1",
requestedCwd: "/real/project/root",
allowExternalCwd: true,
purpose: "start agent",
});

expect(result).toEqual({
laneWorktreePath: "/real/lane/root",
cwd: "/real/project/root",
});
expect(mocks.resolvePathWithinRoot).not.toHaveBeenCalled();
});
});

describe("error: lane has no worktree configured", () => {
it("throws when worktreePath is empty string", () => {
expect(() =>
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/main/services/lanes/laneLaunchContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function resolveLaneLaunchContext(args: {
laneService: ReturnType<typeof createLaneService>;
laneId: string;
requestedCwd?: string | null;
allowExternalCwd?: boolean;
purpose: string;
}): LaneLaunchContext {
const laneId = String(args.laneId ?? "").trim();
Expand All @@ -51,6 +52,16 @@ export function resolveLaneLaunchContext(args: {
};
}

if (args.allowExternalCwd === true && path.isAbsolute(requestedCwd)) {
return {
laneWorktreePath: laneRoot,
cwd: ensureDirectoryExists(
path.resolve(requestedCwd),
`Requested cwd '${requestedCwd}' is not an existing directory.`,
),
};
}
Comment on lines +55 to +63
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reintroduce a boundary for external CWDs.

This branch now skips resolvePathWithinRoot() for any absolute path and only checks that the directory exists. apps/desktop/src/main/services/pty/ptyService.ts:1001-1030 then uses the returned cwd directly, so any caller that can set allowExternalCwd can launch work anywhere on the host filesystem, not just inside the selected lane or project. If this escape hatch is required, constrain it to an explicit trusted root set instead of treating every absolute path as valid.

As per coding guidelines apps/desktop/src/**: Electron desktop app — check for IPC security, proper main/renderer process separation, and React best practices.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/lanes/laneLaunchContext.ts` around lines 55 -
63, The current early-return for allowExternalCwd in laneLaunchContext.ts allows
any absolute requestedCwd to bypass resolvePathWithinRoot and be used directly
(propagated to ptyService.ts), creating a security escape; change the branch so
that when args.allowExternalCwd is true you validate requestedCwd against an
explicit trusted roots whitelist (e.g., configured trustedRootPaths) and only
accept it if path.resolve(requestedCwd) is inside one of those trusted roots,
otherwise fall back to resolvePathWithinRoot(requestedCwd) or reject; keep the
use of ensureDirectoryExists for existence checks and preserve laneWorktreePath
behavior, updating references to allowExternalCwd, resolvePathWithinRoot, and
ensureDirectoryExists in laneLaunchContext.ts accordingly so callers like
ptyService.ts receive only vetted paths.


const requestedTarget = path.isAbsolute(requestedCwd)
? requestedCwd
: path.resolve(laneRoot, requestedCwd);
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/main/services/lanes/laneService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2532,6 +2532,11 @@ export function createLaneService({
db.run("delete from pr_pipeline_settings where pr_id in (select id from pull_requests where lane_id = ? and project_id = ?)", [laneId, projectId]);
db.run("delete from pr_issue_inventory where pr_id in (select id from pull_requests where lane_id = ? and project_id = ?)", [laneId, projectId]);
db.run("delete from pull_requests where lane_id = ? and project_id = ?", [laneId, projectId]);
db.run("delete from review_run_publications where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]);
db.run("delete from review_finding_feedback where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]);
db.run("delete from review_findings where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]);
Comment on lines +2535 to +2537
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The lane deletion cleanup explicitly deletes review_run_publications, review_findings, review_run_artifacts, and review_runs to avoid relying on CASCADE (the comment at line 2529 explains CRR conversion can strip checked foreign keys). However, review_finding_feedback — which references review_findings(id) with ON DELETE CASCADE — is not explicitly deleted. Following the same reasoning documented in the code, orphaned review_finding_feedback rows will accumulate when CRR is active.

// laneService.ts — missing line before review_findings deletion:
db.run("delete from review_run_publications where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]);
db.run("delete from review_findings where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]);
// ↑ review_finding_feedback should be deleted BEFORE review_findings

Add: db.run("delete from review_finding_feedback where finding_id in (select id from review_findings where run_id in (select id from review_runs where lane_id = ? and project_id = ?))", [laneId, projectId]); before the review_findings deletion.

Suggested change
db.run("delete from review_run_publications where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]);
db.run("delete from review_findings where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]);
db.run("delete from review_run_publications where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]);
db.run("delete from review_finding_feedback where finding_id in (select id from review_findings where run_id in (select id from review_runs where lane_id = ? and project_id = ?))", [laneId, projectId]);
db.run("delete from review_findings where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]);

db.run("delete from review_run_artifacts where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]);
db.run("delete from review_runs where lane_id = ? and project_id = ?", [laneId, projectId]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
db.run("delete from session_deltas where lane_id = ?", [laneId]);
db.run("delete from terminal_sessions where lane_id = ?", [laneId]);
db.run("delete from operations where lane_id = ?", [laneId]);
Expand Down
60 changes: 60 additions & 0 deletions apps/desktop/src/main/services/processes/processService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ describe("processService PTY-backed run commands", () => {
try {
await service.start({ laneId: "lane-env", processId: "print-env" });
expect(ptyService.create).toHaveBeenCalledWith(expect.objectContaining({
allowNewSessionId: true,
env: expect.objectContaining({
PORT: "3001",
PORT_RANGE_START: "3001",
Expand All @@ -227,6 +228,65 @@ describe("processService PTY-backed run commands", () => {
}
});

it("allows a managed process to use an explicit absolute cwd outside the lane worktree", async () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-process-absolute-cwd-"));
const laneRoot = path.join(projectRoot, ".ade", "worktrees", "lane-absolute");
fs.mkdirSync(laneRoot, { recursive: true });
const dbPath = path.join(projectRoot, "kv.sqlite");
const projectId = "proj-absolute-cwd";
const logger = createLogger();
const db = await openKvDb(dbPath, createLogger());
const now = "2026-03-24T12:00:00.000Z";
const { ptyService, sessionService } = createPtyHarness(projectRoot);

db.run(
"insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)",
[projectId, projectRoot, "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-absolute", projectId, "Lane Absolute", null, "worktree", "main", "feature/absolute", laneRoot, null, 0, null, null, null, null, "active", now, null],
);

const config = makeMinimalConfig([
{ id: "absolute-proc", command: ["scripts/dogfood.sh", "code-review"], cwd: projectRoot },
]);

const service = createProcessService({
db,
projectId,
logger,
laneService: {
getLaneWorktreePath: () => laneRoot,
list: async () => [makeLaneSummary(laneRoot, "lane-absolute")],
} as any,
projectConfigService: {
get: () => config,
getEffective: () => config.effective,
getExecutableConfig: () => config.effective,
} as any,
sessionService,
ptyService,
broadcastEvent: () => {},
});

try {
const resolvedProjectRoot = fs.realpathSync(projectRoot);
await service.start({ laneId: "lane-absolute", processId: "absolute-proc" });
expect(ptyService.create).toHaveBeenCalledWith(expect.objectContaining({
allowExternalCwd: true,
cwd: resolvedProjectRoot,
}));
} finally {
service.disposeAll();
db.close();
fs.rmSync(projectRoot, { recursive: true, force: true });
}
});

it("includes envPath and envShell in the process.start log entry", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-process-startlog-"));
const dbPath = path.join(tmpDir, "kv.sqlite");
Expand Down
33 changes: 25 additions & 8 deletions apps/desktop/src/main/services/processes/processService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,17 +582,32 @@ export function createProcessService({
}

const laneRoot = laneService.getLaneWorktreePath(laneId);
const configuredCwd = opts.overlay?.cwd?.trim() ? opts.overlay.cwd : definition.cwd;
const cwdCandidate = path.isAbsolute(configuredCwd) ? configuredCwd : path.join(laneRoot, configuredCwd);
const configuredCwd = opts.overlay?.cwd?.trim() ? opts.overlay.cwd.trim() : definition.cwd.trim();
// Absolute cwd is intentionally allowed: ProcessDefinition.cwd and LaneOverlayOverrides.cwd
// originate from the trusted project config (same file that already declares the command
// to execute), not from UI/IPC input. Relative paths remain sandboxed to the lane worktree.
const allowExternalCwd = path.isAbsolute(configuredCwd);
let cwd: string;
try {
cwd = resolvePathWithinRoot(laneRoot, cwdCandidate);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("Path does not exist")) {
if (allowExternalCwd) {
try {
const resolved = path.resolve(configuredCwd);
const stat = fs.statSync(resolved);
if (!stat.isDirectory()) throw new Error("Path is not a directory");
cwd = fs.realpathSync(resolved);
} catch {
throw new Error(`Process '${definition.id}' cwd does not exist: ${configuredCwd}`);
}
throw new Error(`Process '${definition.id}' cwd must stay within the lane workspace`);
} else {
const cwdCandidate = path.join(laneRoot, configuredCwd);
try {
cwd = resolvePathWithinRoot(laneRoot, cwdCandidate);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("Path does not exist")) {
throw new Error(`Process '${definition.id}' cwd does not exist: ${configuredCwd}`);
}
throw new Error(`Process '${definition.id}' cwd must stay within the lane workspace`);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const laneRuntimeEnv = (await getLaneRuntimeEnv?.(laneId)) ?? {};
Expand All @@ -614,6 +629,8 @@ export function createProcessService({
try {
const result = await ptyService.create({
sessionId,
allowNewSessionId: true,
allowExternalCwd,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
laneId,
cwd,
cols: 120,
Expand Down
Loading
Loading