Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,12 @@ jobs:
- name: Test Windows desktop, SQLite, and capability contracts
run: cd apps/desktop && npx vitest run src/main/packagedRuntimeSmoke.test.ts src/main/services/computerUse/localComputerUse.test.ts src/renderer/lib/platform.test.ts

# These path-equivalence assertions skip on Linux because case-only path
# changes are meaningful there. Run them on Windows so the worker pools'
# case-insensitive path reuse is exercised by a native filesystem target.
- name: Test Windows SDK worker activity path contracts
run: cd apps/desktop && npx vitest run src/main/services/chat/cursorSdkPool.test.ts src/main/services/chat/piSdkPool.test.ts

# The suites docs/development/windows-port-lane.md names as the Windows
# validation set. Before this step the intersection with this job was
# empty, so the documented gate was never actually run on Windows.
Expand Down
3 changes: 2 additions & 1 deletion apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -623,7 +623,8 @@ ade chat steer session-id --text "active-turn context"
ade chat steer session-id --text "active-turn context" --dispatch interrupt # atomic active-turn delivery: inline | interrupt; omit to stage for the next turn (Claude and Cursor take both; a Cursor cloud run declines inline and stages)
ade chat note "testing desktop auth fallback" # update Work status (aim for 6 words or fewer; truncated past 72 characters); add --session <id> to target explicitly
ade chat ask "Which account should I use?" # escalate a blocking question; add --session <id> to target explicitly
ade session show session-id --text # status + elapsed, live agent pids, settle/snooze state, and why a snoozed row came back
ade chat activity testing # set one fixed detail: planning|implementing|testing|reviewing|debugging|monitoring; `clear` removes it; add --session <id> to target explicitly
ade session show session-id --text # status/activity detail + elapsed, live agent pids, settle/snooze state, and why a snoozed row came back
ade --role cto session move session-id --to done # file the row under a Work-board column: needs-you|working|done ('needs_you' spelling also accepted)
# CTO-only, like every other settle-column writer: a move tells the agent the USER moved it, so a session-bound agent must not move its own card
# 'waiting' is refused — a row sits there because it is snoozed or its PR is mid-CI, so it is derived, never a target
Expand Down
159 changes: 159 additions & 0 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "../../desktop/src/main/services/builtInBrowser/builtInBrowserActorCapabilities";
import { BUILT_IN_BROWSER_ACTOR_CAPABILITY_PARAM } from "./services/builtInBrowser/desktopBridgeMethods";
import { ADE_BUNDLED_AGENT_SKILLS_DIR_ENV } from "../../desktop/src/shared/agentSkillRoots";
import { buildTrackedCliSessionActivityGuidance } from "../../desktop/src/shared/cliLaunch";
import { CTO_VOICE_ACTIONS } from "../../desktop/src/shared/types/ctoVoice";

type RuntimeFixture = ReturnType<typeof createRuntime>;
Expand Down Expand Up @@ -94,6 +95,7 @@ function createRuntime() {
projectRoot,
workspaceRoot: projectRoot,
projectId: "project-1",
sessionActivityReportingEnabled: true,
project: { rootPath: projectRoot, displayName: "project", baseRef: "main" },
paths: {
adeDir: path.join(projectRoot, ".ade"),
Expand Down Expand Up @@ -219,6 +221,7 @@ function createRuntime() {
updateMeta: vi.fn(),
readTranscriptTail: vi.fn(() => ""),
requestAttention: vi.fn(() => true),
setSessionActivity: vi.fn(() => true),
setStatusNote: vi.fn(() => true),
settleSession: vi.fn(() => true),
unsettleSession: vi.fn(() => true),
Expand Down Expand Up @@ -1641,6 +1644,20 @@ describe("adeRpcServer", () => {
attentionRequestedAt: null,
lastTurnFailedAt: null,
} as any);
runtime.sessionService.get.mockImplementation((sessionId: string) => {
if (sessionId === "attached-terminal") {
return { id: "attached-terminal", chatSessionId: "chat-1" } as any;
}
if (sessionId === "chat-1") {
return {
id: "chat-1",
toolType: "codex-chat",
attentionRequestedAt: null,
lastTurnFailedAt: null,
} as any;
}
return null;
});

const lifecycleCalls = [
{
Expand All @@ -1651,6 +1668,14 @@ describe("adeRpcServer", () => {
"Choose a release channel.",
),
},
{
action: "setSessionActivity",
args: { value: "testing" },
assert: () => expect(runtime.sessionService.setSessionActivity).toHaveBeenCalledWith(
"chat-1",
"testing",
),
},
{
action: "setSessionStatusNote",
args: { note: "Waiting for release choice" },
Expand All @@ -1670,6 +1695,14 @@ describe("adeRpcServer", () => {
lifecycle.assert();
}

const attachedTerminalActivity = await callTool(handler, "run_ade_action", {
domain: "session",
action: "setSessionActivity",
args: { sessionId: "attached-terminal", value: "testing" },
});
expect(attachedTerminalActivity?.isError).toBeUndefined();
expect(runtime.sessionService.setSessionActivity).toHaveBeenCalledWith("attached-terminal", "testing");

const denied = await callTool(handler, "run_ade_action", {
domain: "session",
action: "setSessionStatusNote",
Expand All @@ -1680,6 +1713,13 @@ describe("adeRpcServer", () => {
"chat-2",
"Cross-session write",
);
const activityDenied = await callTool(handler, "run_ade_action", {
domain: "session",
action: "setSessionActivity",
args: { sessionId: "chat-2", value: "testing" },
});
expect(activityDenied.isError).toBe(true);
expect(runtime.sessionService.setSessionActivity).not.toHaveBeenCalledWith("chat-2", "testing");

// Settlement is user- and PR-merge-driven only (2026-07). A session-bound
// caller gets no settle writer at all: the caller-scoped `*SelfSession`
Expand Down Expand Up @@ -1726,6 +1766,14 @@ describe("adeRpcServer", () => {
"Running CLI checks",
);

const activity = await callTool(handler, "run_ade_action", {
domain: "session",
action: "setSessionActivity",
args: { value: "monitoring" },
});
expect(activity?.isError).toBeUndefined();
expect(runtime.sessionService.setSessionActivity).toHaveBeenCalledWith("chat-from-env", "monitoring");

const denied = await callTool(handler, "run_ade_action", {
domain: "session",
action: "setSessionStatusNote",
Expand Down Expand Up @@ -2502,6 +2550,102 @@ describe("adeRpcServer", () => {
});
});

it("omits activity guidance when the runtime cannot accept activity reports", async () => {
const fixture = createRuntime();
fixture.runtime.sessionActivityReportingEnabled = false;
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });

await initialize(handler, { role: "agent" });
const response = await callTool(handler, "start_cli_session", {
laneId: "lane-1",
provider: "codex",
permissionMode: "edit",
initialInput: "run the checks",
});

expect(response?.isError).toBeUndefined();
const createCall = fixture.runtime.ptyService.create.mock.calls.at(-1)?.[0];
expect(createCall).toBeDefined();
expect(JSON.stringify(createCall)).not.toContain("Activity detail for this tracked ADE CLI session");
});

it.each([
{
name: "Claude",
args: { provider: "claude", permissionMode: "default" },
available: false,
},
{
name: "Codex",
args: { provider: "codex", permissionMode: "edit" },
available: true,
},
{
name: "Cursor with an initial prompt",
args: { provider: "cursor", permissionMode: "edit", hasInitialPrompt: true },
available: true,
},
{
name: "blank Cursor",
args: { provider: "cursor", permissionMode: "edit", hasInitialPrompt: false },
available: false,
},
{
name: "write-capable Droid",
args: { provider: "droid", permissionMode: "default", droidPermissionMode: "auto-medium" },
available: true,
},
{
name: "AGI Droid",
args: { provider: "droid", permissionMode: "default", droidPermissionMode: "agi" },
available: false,
},
{
name: "OpenCode",
args: { provider: "opencode", permissionMode: "edit" },
available: true,
},
{
name: "OpenCode with external config",
args: { provider: "opencode", permissionMode: "config-toml" },
available: false,
},
{
name: "full-auto Pi",
args: { provider: "pi", permissionMode: "full-auto" },
available: true,
},
{
name: "non-full-auto Pi",
args: { provider: "pi", permissionMode: "edit" },
available: false,
},
{ name: "Qwen", args: { provider: "qwen", permissionMode: "edit" }, available: false },
{ name: "Kimi", args: { provider: "kimi", permissionMode: "edit" }, available: false },
{ name: "Grok", args: { provider: "grok", permissionMode: "edit" }, available: false },
{ name: "Copilot", args: { provider: "copilot", permissionMode: "edit" }, available: false },
{
name: "Codex in Plan mode",
args: { provider: "codex", permissionMode: "plan" },
available: false,
},
] satisfies Array<{
name: string;
args: Parameters<typeof buildTrackedCliSessionActivityGuidance>[0];
available: boolean;
}>)("gates tracked CLI activity guidance for $name", ({ args, available }) => {
const guidance = buildTrackedCliSessionActivityGuidance({
...args,
sessionActivityReportingEnabled: true,
});

expect(guidance !== null).toBe(available);
if (available) {
expect(guidance).toContain("ADE_ACTIVITY_SESSION_ID");
expect(guidance).toContain("chat activity testing");
}
});

it("persists a requested preset id in resume metadata even when resolution is unavailable", async () => {
const fixture = createRuntime();
const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-rpc-preset-metadata-"));
Expand Down Expand Up @@ -7014,6 +7158,21 @@ describe("run_ade_action search scope", () => {
expect(args.callerScope).toBeUndefined();
});

it("denies unbound agent CLI activity writes to arbitrary sessions", async () => {
const { runtime } = createRuntime();
const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" });
await initialize(handler, { callerId: "ade-cli:4242", role: "agent" });

const response = await callTool(handler, "run_ade_action", {
domain: "session",
action: "setSessionActivity",
args: { sessionId: "another-session", value: "testing" },
});

expect(response?.isError).toBe(true);
expect(runtime.sessionService.setSessionActivity).not.toHaveBeenCalled();
});

it("leaves an unbound external caller unscoped", async () => {
const fixture = createRuntime();
const search = searchServiceMock();
Expand Down
27 changes: 25 additions & 2 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2747,6 +2747,7 @@ const SCOPED_CHAT_ACTIONS = new Set([
// session-bound agent may only aim it at its own row.
"continueUsageLimitOnAlternate",
"requestSessionAttention",
"setSessionActivity",
"setSessionStatusNote",
// `settleSelfSession` / `unsettleSelfSession` used to be scoped here so a
// bound agent could only settle its OWN row. Both actions were removed in
Expand All @@ -2769,6 +2770,7 @@ function chatUpdateSessionMutatesSpawnKind(chatArgs: Record<string, unknown>): b
}

function scopeChatAdeActionArgs(
runtime: AdeRuntime,
session: SessionState,
action: string,
chatArgs: Record<string, unknown>,
Expand All @@ -2777,7 +2779,18 @@ function scopeChatAdeActionArgs(
const method = `run_ade_action:${domain}.${action}`;
const spawnKindUpdate = action === "updateSession" && chatUpdateSessionMutatesSpawnKind(chatArgs);
if (!SCOPED_CHAT_ACTIONS.has(action) && !spawnKindUpdate) return chatArgs;
if (isUnboundAdeCliCaller(session)) return chatArgs;
if (isUnboundAdeCliCaller(session)) {
// A direct, unbound `ade` CLI keeps project-wide access for read actions,
// but agent-reported activity is a write and must stay attached to the
// caller's own chat or tracked terminal. The CTO/user path is unaffected.
if (action === "setSessionActivity") {
Comment thread
arul28 marked this conversation as resolved.
chatAccessDenied(method, {
callerChatSessionId: null,
requestedSessionId: asOptionalTrimmedString(chatArgs.sessionId),
});
}
return chatArgs;
}

const scopedArgs = { ...chatArgs };
const callerChatSessionId = asOptionalTrimmedString(session.identity.chatSessionId);
Expand All @@ -2787,7 +2800,14 @@ function scopeChatAdeActionArgs(
chatAccessDenied(method, { callerChatSessionId, requestedSessionId });
}

if (!callerChatSessionId || (requestedSessionId && requestedSessionId !== callerChatSessionId)) {
const requestedOwnedTerminal = action === "setSessionActivity"
&& requestedSessionId != null
&& callerChatSessionId != null
&& runtime.sessionService.get(requestedSessionId)?.chatSessionId === callerChatSessionId;
if (
!callerChatSessionId
|| (requestedSessionId && requestedSessionId !== callerChatSessionId && !requestedOwnedTerminal)
) {
chatAccessDenied(method, { callerChatSessionId, requestedSessionId });
}
if (!requestedSessionId) scopedArgs.sessionId = callerChatSessionId;
Expand Down Expand Up @@ -4119,6 +4139,7 @@ async function runTool(args: {
};
} else {
scopedObjectArgs = scopeChatAdeActionArgs(
runtime,
session,
action,
chatArgs,
Expand All @@ -4130,6 +4151,7 @@ async function runTool(args: {
&& SCOPED_CHAT_ACTIONS.has(action)
) {
scopedObjectArgs = scopeChatAdeActionArgs(
runtime,
session,
action,
requireObjectArgsForScopedAdeAction(domain, action, argsList, hasScalarArg, rawObjectArgs),
Expand Down Expand Up @@ -4427,6 +4449,7 @@ async function runTool(args: {
return buildTrackedCliLaunchCommand({
provider,
permissionMode,
sessionActivityReportingEnabled: runtime.sessionActivityReportingEnabled,
...(droidPermissionMode ? { droidPermissionMode } : {}),
sessionId: preassignedSessionId,
model,
Expand Down
8 changes: 8 additions & 0 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,8 @@ export type AdeRuntime = {
projectId: string;
project: { rootPath: string; displayName: string; baseRef: string };
paths: AdeRuntimePaths;
/** Whether this runtime serves the RPC endpoint needed by activity reports. */
sessionActivityReportingEnabled: boolean;
logger: Logger;
db: AdeDb;
keybindingsService?: ReturnType<typeof createKeybindingsService> | null;
Expand Down Expand Up @@ -744,6 +746,7 @@ export async function createAdeRuntime(args: {
const runtimeSocketPath = typeof resolvedArgs.runtimeSocketPath === "string"
? resolvedArgs.runtimeSocketPath.trim() || paths.socketPath
: paths.socketPath;
const sessionActivityReportingEnabled = !embeddedRuntime && Boolean(runtimeSocketPath);
const logger = createFileLogger(path.join(paths.logsDir, "ade-cli.jsonl"));
const diskPressureMonitor = createDiskPressureMonitor({
roots: [projectRoot, resolveMachineAdeLayout().adeDir],
Expand Down Expand Up @@ -1304,6 +1307,8 @@ export async function createAdeRuntime(args: {

const ptyService = createPtyService({
projectRoot,
runtimeSocketPath,
sessionActivityReportingEnabled,
transcriptsDir: paths.transcriptsDir,
laneService,
sessionService,
Expand Down Expand Up @@ -1640,6 +1645,7 @@ export async function createAdeRuntime(args: {
browserActorCapabilityIssuer,
projectRoot,
runtimeSocketPath,
sessionActivityReportingEnabled,
adeDir: paths.adeDir,
transcriptsDir: paths.transcriptsDir,
fileService: headlessLinearServices.fileService,
Expand Down Expand Up @@ -2390,6 +2396,7 @@ export async function createAdeRuntime(args: {
projectRoot,
appVersion: syncRuntimeOptions.appVersion ?? "ade-cli",
runtimeKind: syncRuntimeOptions.runtimeKind ?? "headless",
sessionActivityReportingEnabled,
localDeviceIdPath: syncRuntimeOptions.localDeviceIdPath,
phonePairingStateDir: syncRuntimeOptions.phonePairingStateDir,
fileService: headlessLinearServices.fileService,
Expand Down Expand Up @@ -2552,6 +2559,7 @@ export async function createAdeRuntime(args: {
projectId,
project,
paths,
sessionActivityReportingEnabled,
logger,
db,
keybindingsService,
Expand Down
Loading
Loading