Skip to content
Merged
2 changes: 2 additions & 0 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ ade prs pipeline pr-id save --conflict-strategy rebase --no-early-merge-on-green
ade run defs --text
ade run start web --lane lane-id
ade shell start --lane lane-id -- npm test
ade shell start-cli codex --lane lane-id --permission-mode edit --message "fix failing tests"
ade shell start --provider claude --lane lane-id --permission-mode default
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
ade chat create --lane lane-id --model gpt-5.5
ade tests run --lane lane-id --suite unit --wait
ade proof list --arg ownerKind=chat --arg ownerId=session-id
Expand Down
108 changes: 107 additions & 1 deletion apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,9 @@ function createRuntime() {
},
ptyService: {
create: vi.fn(async () => ({ ptyId: "pty-1", sessionId: "session-1" })),
dispose: vi.fn()
dispose: vi.fn(),
writeBySessionId: vi.fn(() => true),
enrichSessions: vi.fn((sessions: unknown[]) => sessions),
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
testService: {
run: vi.fn(async () => ({ id: "test-run-1", status: "running" })),
Expand Down Expand Up @@ -1085,6 +1087,7 @@ describe("adeRpcServer", () => {
"screenshot_environment",
"record_environment",
"run_tests",
"start_cli_session",
"get_lane_status",
"list_lanes",
"commit_changes",
Expand Down Expand Up @@ -1918,6 +1921,109 @@ describe("adeRpcServer", () => {
expect(response.structuredContent.contextRef?.path).toBeNull();
});

it("routes start_cli_session through shared provider launch helpers", async () => {
const fixture = createRuntime();
fixture.runtime.sessionService.get.mockReturnValue({
id: "session-1",
laneId: "lane-1",
ptyId: "pty-1",
tracked: true,
toolType: "codex",
title: "Codex",
status: "running",
resumeCommand: null,
resumeMetadata: null,
});
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });

await initialize(handler, { role: "orchestrator" });
const response = await callTool(handler, "start_cli_session", {
laneId: "lane-1",
provider: "codex",
permissionMode: "edit",
initialInput: "fix failing tests",
cols: 90,
rows: 24,
});

expect(response?.isError).toBeUndefined();
expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith(
expect.objectContaining({
laneId: "lane-1",
title: "Codex",
toolType: "codex",
cols: 90,
rows: 24,
command: "codex",
startupCommand: expect.stringContaining("codex --no-alt-screen"),
}),
);
expect(fixture.runtime.ptyService.writeBySessionId).toHaveBeenCalledWith("session-1", "fix failing tests\r");
expect(response.structuredContent).toMatchObject({
provider: "codex",
laneId: "lane-1",
ptyId: "pty-1",
sessionId: "session-1",
initialInputWritten: true,
});
});

it("preassigns Claude session ids for start_cli_session launches", async () => {
const fixture = createRuntime();
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });

await initialize(handler, { role: "orchestrator" });
const response = await callTool(handler, "start_cli_session", {
laneId: "lane-1",
provider: "claude",
permissionMode: "default",
});

expect(response?.isError).toBeUndefined();
const createCall = fixture.runtime.ptyService.create.mock.calls.at(-1)?.[0];
expect(createCall.sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
expect(createCall.allowNewSessionId).toBe(true);
expect(createCall.startupCommand).toContain("--session-id");
expect(createCall.startupCommand).toContain(createCall.sessionId);
expect(createCall.toolType).toBe("claude");
});

it("resumes start_cli_session from stored terminal metadata", async () => {
const fixture = createRuntime();
fixture.runtime.sessionService.get.mockReturnValue({
id: "session-existing",
laneId: "lane-1",
ptyId: "pty-existing",
tracked: true,
toolType: "codex",
title: "Codex",
status: "exited",
resumeCommand: "codex resume picker",
resumeMetadata: {
provider: "codex",
targetKind: "thread",
targetId: "thread-77",
launch: { permissionMode: "edit" },
},
});
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });

await initialize(handler, { role: "orchestrator" });
const response = await callTool(handler, "start_cli_session", {
laneId: "lane-1",
provider: "codex",
resumeSessionId: "session-existing",
});

expect(response?.isError).toBeUndefined();
expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "session-existing",
startupCommand: "codex --no-alt-screen --sandbox workspace-write --ask-for-approval untrusted resume thread-77",
}),
);
});

it("starts spawn_agent without writing an attached ADE server config", async () => {
const fixture = createRuntime();
fixture.runtime.workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-spawn-workspace-"));
Expand Down
135 changes: 135 additions & 0 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ import {
} from "../../desktop/src/shared/types";
import type { PrActionRun, PrCheck, PrComment, PrReviewThread } from "../../desktop/src/shared/types/prs";
import { resolveAdeLayout } from "../../desktop/src/shared/adeLayout";
import {
buildTrackedCliLaunchCommand,
buildTrackedCliResumeCommand,
LAUNCH_PROFILE_TITLE,
LAUNCH_PROFILE_TOOL_TYPE,
type CliProvider,
type LaunchProfile,
} from "../../desktop/src/shared/cliLaunch";
import type { AgentChatPermissionMode, TerminalSessionSummary } from "../../desktop/src/shared/types";
import type { AdeRuntime } from "./bootstrap";
import { JsonRpcError, JsonRpcErrorCode, type JsonRpcHandler, type JsonRpcRequest } from "./jsonrpc";

Expand Down Expand Up @@ -217,6 +226,29 @@ const TOOL_SPECS: ToolSpec[] = [
}
}
},
{
name: "start_cli_session",
description: "Start or resume a tracked ADE Work CLI terminal for an allowlisted provider, using the same launch helpers as desktop and mobile.",
inputSchema: {
type: "object",
required: ["laneId", "provider"],
additionalProperties: false,
properties: {
laneId: { type: "string", minLength: 1 },
provider: { type: "string", enum: ["claude", "codex", "cursor", "droid", "opencode", "shell"] },
permissionMode: { type: "string", enum: ["default", "plan", "edit", "full-auto", "config-toml"], default: "default" },
title: { type: "string" },
initialInput: { type: "string" },
cols: { type: "number", minimum: 20, maximum: 240, default: 120 },
rows: { type: "number", minimum: 4, maximum: 120, default: 36 },
cwd: { type: "string" },
chatSessionId: { type: "string" },
resumeSessionId: { type: "string" },
resumeTargetId: { type: "string" },
tracked: { type: "boolean", default: true }
}
}
},
{
name: "get_ade_action_status",
description: "Check status/progress for long-running ADE actions by operation/test/chat/run/mission identifiers.",
Expand Down Expand Up @@ -1912,6 +1944,7 @@ const READ_ONLY_TOOLS = new Set([
const MUTATION_TOOLS = new Set([
"create_lane",
"run_ade_action",
"start_cli_session",
"import_lane",
"merge_lane",
"git_fetch",
Expand Down Expand Up @@ -2092,6 +2125,35 @@ function assertNonEmptyString(value: unknown, field: string): string {
return text;
}

const CLI_SESSION_PROVIDERS: readonly LaunchProfile[] = ["claude", "codex", "cursor", "droid", "opencode", "shell"];
const CLI_SESSION_PERMISSION_MODES: readonly AgentChatPermissionMode[] = ["default", "plan", "edit", "full-auto", "config-toml"];

function parseCliSessionProvider(value: unknown): LaunchProfile {
const provider = asTrimmedString(value).toLowerCase();
const match = CLI_SESSION_PROVIDERS.find((entry) => entry === provider);
if (!match) {
throw new JsonRpcError(
JsonRpcErrorCode.invalidParams,
"provider must be one of claude, codex, cursor, droid, opencode, or shell",
);
}
return match;
}

function parseCliSessionPermissionMode(value: unknown): AgentChatPermissionMode {
const mode = asTrimmedString(value);
return CLI_SESSION_PERMISSION_MODES.find((entry) => entry === mode) ?? "default";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function clampInteger(value: unknown, fallback: number, min: number, max: number): number {
const raw = typeof value === "number" && Number.isFinite(value) ? value : fallback;
return Math.max(min, Math.min(max, Math.floor(raw)));
}

function isCliProvider(provider: LaunchProfile): provider is CliProvider {
return provider !== "shell";
}

export function resolveComputerUseOwners(session: SessionState, toolArgs: Record<string, unknown>): ComputerUseArtifactOwner[] {
const owners: ComputerUseArtifactOwner[] = [];
const add = (
Expand Down Expand Up @@ -4258,6 +4320,79 @@ async function runTool(args: {
};
}

if (name === "start_cli_session") {
const laneId = assertNonEmptyString(toolArgs.laneId, "laneId");
const provider = parseCliSessionProvider(toolArgs.provider);
const permissionMode = parseCliSessionPermissionMode(toolArgs.permissionMode);
const cols = clampInteger(toolArgs.cols, DEFAULT_PTY_COLS, 20, 240);
const rows = clampInteger(toolArgs.rows, DEFAULT_PTY_ROWS, 4, 120);
const title = asOptionalTrimmedString(toolArgs.title) ?? LAUNCH_PROFILE_TITLE[provider];
const resumeSessionId = asOptionalTrimmedString(toolArgs.resumeSessionId);
const resumeTargetId = asOptionalTrimmedString(toolArgs.resumeTargetId);
const initialInput = asOptionalTrimmedString(toolArgs.initialInput)?.slice(0, 20_000) ?? null;
const ptyService = runtime.ptyService as typeof runtime.ptyService & {
writeBySessionId?: (sessionId: string, data: string) => boolean;
enrichSessions?: (sessions: TerminalSessionSummary[]) => TerminalSessionSummary[];
};
const preassignedSessionId = provider === "claude" && !resumeSessionId ? randomUUID() : undefined;

const launchFields: { startupCommand?: string; command?: string; args?: string[]; env?: Record<string, string> } = (() => {
if (!isCliProvider(provider)) return {};
if (resumeSessionId || resumeTargetId) {
const resumeSession = resumeSessionId ? runtime.sessionService.get(resumeSessionId) : null;
const startupCommand = resumeSession?.resumeMetadata
? buildTrackedCliResumeCommand(resumeSession.resumeMetadata)
: resumeSession?.resumeCommand?.trim()
|| buildTrackedCliResumeCommand({
provider,
targetKind: "session",
targetId: resumeTargetId,
launch: { permissionMode },
});
return { startupCommand };
}
return buildTrackedCliLaunchCommand({ provider, permissionMode, sessionId: preassignedSessionId });
})();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caller-controlled resumeTargetId not sanitized before shell command

Medium Severity

In the start_cli_session handler, resumeTargetId from caller-controlled RPC input is passed directly into buildTrackedCliResumeCommand without applying stripInjectionChars first. This value is interpolated into a shell startup command. While commandArrayToLine provides quoting, the project rule requires stripInjectionChars() on each value individually before assembling the string, as a defense-in-depth measure against injection via control characters.

Fix in Cursor Fix in Web

Triggered by learned rule: Apply stripInjectionChars to all caller-controlled values before prompt/shell interpolation

Reviewed by Cursor Bugbot for commit 1a185b2. Configure here.


const created = await ptyService.create({
...(resumeSessionId || preassignedSessionId ? { sessionId: resumeSessionId ?? preassignedSessionId } : {}),
...(preassignedSessionId ? { allowNewSessionId: true } : {}),
laneId,
cols,
rows,
title,
tracked: toolArgs.tracked !== false,
toolType: LAUNCH_PROFILE_TOOL_TYPE[provider],
...(asOptionalTrimmedString(toolArgs.cwd) ? { cwd: asOptionalTrimmedString(toolArgs.cwd)! } : {}),
...(asOptionalTrimmedString(toolArgs.chatSessionId) ? { chatSessionId: asOptionalTrimmedString(toolArgs.chatSessionId) } : {}),
...launchFields,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

let initialInputWritten = false;
if (initialInput && isCliProvider(provider)) {
if (typeof ptyService.writeBySessionId !== "function") {
throw new JsonRpcError(JsonRpcErrorCode.internalError, "PTY service does not support session-scoped writes.");
}
initialInputWritten = ptyService.writeBySessionId(created.sessionId, `${initialInput}\r`);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

const session = runtime.sessionService.get(created.sessionId) as TerminalSessionSummary | null;
const enrichedSession = session && typeof ptyService.enrichSessions === "function"
? ptyService.enrichSessions([session])[0] ?? session
: session;
return {
provider,
laneId,
title,
permissionMode,
ptyId: created.ptyId,
sessionId: created.sessionId,
startupCommand: launchFields.startupCommand ?? null,
initialInputWritten,
session: enrichedSession ?? null,
};
}

if (name === "get_ade_action_status") {
const operationId = asOptionalTrimmedString(toolArgs.operationId);
const testRunId = asOptionalTrimmedString(toolArgs.testRunId);
Expand Down
52 changes: 52 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,58 @@ describe("ADE CLI", () => {
});
});

it("maps provider shell launches to start_cli_session", () => {
const plan = buildCliPlan([
"shell",
"start-cli",
"codex",
"--lane",
"lane-1",
"--permission-mode",
"edit",
"--message",
"fix the tests",
]);
expect(plan.kind).toBe("execute");
if (plan.kind !== "execute") return;
expect(plan.steps[0]?.params).toEqual({
name: "start_cli_session",
arguments: expect.objectContaining({
laneId: "lane-1",
provider: "codex",
permissionMode: "edit",
initialInput: "fix the tests",
title: "Codex",
cols: 120,
rows: 36,
tracked: true,
}),
});
});

it("accepts --provider on shell start as the CLI-session launcher", () => {
const plan = buildCliPlan([
"shell",
"start",
"--provider",
"claude",
"--lane",
"lane-1",
"--resume-session",
"session-1",
]);
expect(plan.kind).toBe("execute");
if (plan.kind !== "execute") return;
expect(plan.steps[0]?.params).toMatchObject({
name: "start_cli_session",
arguments: {
laneId: "lane-1",
provider: "claude",
resumeSessionId: "session-1",
},
});
});

it("renders an empty lane graph placeholder when no lanes are returned", () => {
expect(renderLaneGraph({ lanes: [] })).toBe("ADE lanes\n(no lanes)");
expect(renderLaneGraph(null)).toBe("ADE lanes\n(no lanes)");
Expand Down
Loading
Loading