Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
3 changes: 2 additions & 1 deletion apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,8 @@ ade --socket ios-sim preview-render --source apps/ios/ADE/Views/Home.swift --ind
ade --socket app-control launch --command "npm run dev" --text
ade --socket app-control focus --text
ade --socket app-control minimize --text
ade --socket browser open http://localhost:5173 --new-tab --text
ade --socket browser open http://localhost:5173 --new-tab --text # ADE-launched chat/terminal capability required
ade --socket browser authorize --tab tab-id --text # native human grant for the current agent + origin
ade --socket update status --text
ade --socket update check --text
ade --socket update install --text
Expand Down
167 changes: 166 additions & 1 deletion apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import {
resolveComputerUseOwners,
} from "./adeRpcServer";
import { JsonRpcError, JsonRpcErrorCode } from "./jsonrpc";
import {
issueBuiltInBrowserActorCapability,
resetBuiltInBrowserActorCapabilitiesForTest,
} from "../../desktop/src/main/services/builtInBrowser/builtInBrowserActorCapabilities";

type RuntimeFixture = ReturnType<typeof createRuntime>;
const originalPlatform = process.platform;
Expand All @@ -18,6 +22,7 @@ const ADE_ENV_KEYS = [
"ADE_STEP_ID",
"ADE_ATTEMPT_ID",
"ADE_OWNER_ID",
"ADE_BROWSER_ACTOR_TOKEN",
] as const;
const originalAdeEnv = new Map<string, string | undefined>(
ADE_ENV_KEYS.map((key) => [key, process.env[key]]),
Expand All @@ -31,6 +36,7 @@ function setPlatform(value: NodeJS.Platform): void {
}

beforeEach(() => {
resetBuiltInBrowserActorCapabilitiesForTest();
for (const key of ADE_ENV_KEYS) {
delete process.env[key];
}
Expand Down Expand Up @@ -2930,7 +2936,11 @@ describe("adeRpcServer", () => {
});
fixture.runtime.ptyService.list.mockReturnValue([ownTerminal, otherTerminal]);
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });
await initialize(handler, { callerId: "agent-1", role: "agent", chatSessionId: "chat-1" });
await initialize(handler, {
callerId: "agent-1",
role: "agent",
chatSessionId: "chat-1",
});

const listed = await callTool(handler, "run_ade_action", {
domain: "pty",
Expand Down Expand Up @@ -3018,6 +3028,161 @@ describe("adeRpcServer", () => {
});
});

it("injects the caller lease identity into built-in browser actions and blocks agent takeovers", async () => {
const fixture = createRuntime();
fixture.runtime.sessionService.get.mockImplementation((sessionId: string) => (
sessionId === "chat-1" ? { id: "chat-1", laneId: "lane-1" } : null
));
const captureScreenshot = vi.fn(async (args: unknown) => args);
fixture.runtime.builtInBrowserService = { captureScreenshot };
const actorToken = issueBuiltInBrowserActorCapability({
chatSessionId: "chat-1",
laneId: "lane-1",
projectRoot: fixture.runtime.projectRoot,
tabCollection: null,
});
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });
await initialize(handler, {
callerId: "agent-1",
role: "agent",
chatSessionId: "chat-1",
browserActorToken: actorToken,
});

const captured = await callTool(handler, "run_ade_action", {
domain: "built_in_browser",
action: "captureScreenshot",
args: { tabId: "tab-1" },
});
expect(captured?.isError).toBeUndefined();
expect(captureScreenshot).toHaveBeenCalledWith({
tabId: "tab-1",
laneId: "lane-1",
chatSessionId: "chat-1",
force: false,
projectRoot: fixture.runtime.projectRoot,
});

const forced = await callTool(handler, "run_ade_action", {
domain: "built_in_browser",
action: "captureScreenshot",
args: { tabId: "tab-1", force: true },
});
expect(forced.isError).toBe(true);

const impersonated = await callTool(handler, "run_ade_action", {
domain: "built_in_browser",
action: "captureScreenshot",
args: { tabId: "tab-1", chatSessionId: "chat-2" },
});
expect(impersonated.isError).toBe(true);
const diagnostics = await callTool(handler, "run_ade_action", {
domain: "built_in_browser",
action: "getProfileDiagnostics",
args: {},
});
expect(diagnostics.isError).toBe(true);
const permissionClear = await callTool(handler, "run_ade_action", {
domain: "built_in_browser",
action: "clearPermissions",
args: {},
});
expect(permissionClear.isError).toBe(true);
expect(captureScreenshot).toHaveBeenCalledTimes(1);
});

it("does not let the runtime daemon environment override the connecting browser actor", async () => {
const fixture = createRuntime();
const getStatus = vi.fn(async (args: unknown) => args);
fixture.runtime.builtInBrowserService = { getStatus };
process.env.ADE_BROWSER_ACTOR_TOKEN = issueBuiltInBrowserActorCapability({
chatSessionId: "chat-daemon",
laneId: "lane-daemon",
projectRoot: fixture.runtime.projectRoot,
tabCollection: null,
});
const clientActorToken = issueBuiltInBrowserActorCapability({
chatSessionId: "chat-client",
laneId: "lane-client",
projectRoot: fixture.runtime.projectRoot,
tabCollection: null,
});
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });
await initialize(handler, {
callerId: "agent-client",
role: "agent",
chatSessionId: "chat-client",
browserActorToken: clientActorToken,
});

const status = await callTool(handler, "run_ade_action", {
domain: "built_in_browser",
action: "getStatus",
args: {},
});
expect(status?.isError).toBeUndefined();
expect(getStatus).toHaveBeenCalledTimes(1);
expect(getStatus).toHaveBeenCalledWith({
laneId: "lane-client",
chatSessionId: "chat-client",
force: false,
projectRoot: fixture.runtime.projectRoot,
});
});

it("denies unbound and elevated local callers without a browser actor capability", async () => {
const fixture = createRuntime();
const getStatus = vi.fn(async () => ({ ok: true }));
fixture.runtime.builtInBrowserService = { getStatus };

const unboundHandler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });
await initialize(unboundHandler, { callerId: "ade-cli:123", role: "agent" });
const unbound = await callTool(unboundHandler, "run_ade_action", {
domain: "built_in_browser",
action: "getStatus",
args: {},
});
expect(unbound.isError).toBe(true);

const elevatedHandler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });
await initialize(elevatedHandler, { callerId: "local-cto", role: "cto" });
const elevated = await callTool(elevatedHandler, "run_ade_action", {
domain: "built_in_browser",
action: "getStatus",
args: {},
});
expect(elevated.isError).toBe(true);
expect(getStatus).not.toHaveBeenCalled();
});

it("accepts a bridge credential only from the local desktop client", async () => {
const trustedFixture = createRuntime();
const configureTrusted = vi.fn(async () => true);
trustedFixture.runtime.configureBuiltInBrowserDesktopBridgeAuth = configureTrusted;
const trustedHandler = createAdeRpcRequestHandler({
runtime: trustedFixture.runtime,
serverVersion: "test",
});
await initialize(trustedHandler, { callerId: "desktop", role: "cto" }, {
clientInfo: { name: "ade-desktop-local", version: "test" },
desktopBridgeAuthToken: "ephemeral-desktop-token",
});
expect(configureTrusted).toHaveBeenCalledWith("ephemeral-desktop-token");

const untrustedFixture = createRuntime();
const configureUntrusted = vi.fn(async () => true);
untrustedFixture.runtime.configureBuiltInBrowserDesktopBridgeAuth = configureUntrusted;
const untrustedHandler = createAdeRpcRequestHandler({
runtime: untrustedFixture.runtime,
serverVersion: "test",
});
await initialize(untrustedHandler, { callerId: "raw-cli", role: "cto" }, {
clientInfo: { name: "ade-cli", version: "test" },
desktopBridgeAuthToken: "spoofed-token",
});
expect(configureUntrusted).not.toHaveBeenCalled();
});

it("scopes external-sessions ADE actions to the caller's lane", async () => {
const fixture = createRuntime();
const ownChat = { id: "chat-1", laneId: "lane-1", chatSessionId: "chat-1" };
Expand Down
74 changes: 74 additions & 0 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import { JsonRpcError, JsonRpcErrorCode, type JsonRpcHandler, type JsonRpcReques
import { normalizeAdeRuntimeRole } from "./runtimeRoles";
import { getSharedModelPickerStore } from "./services/modelPickerStore";
import { resolveLaneCreateRemoteBase } from "./services/laneCreateRemoteBase";
import { resolveBuiltInBrowserActorCapability } from "../../desktop/src/main/services/builtInBrowser/builtInBrowserActorCapabilities";
import { resolveCodexComputerUseMcpConfig } from "../../desktop/src/main/utils/codexComputerUse";

// Cross-surface (desktop + TUI + iOS) model picker favorites & recents.
Expand Down Expand Up @@ -161,6 +162,7 @@ type SessionIdentity = {
stepId: string | null;
attemptId: string | null;
ownerId: string | null;
browserActorToken: string | null;
};

type SessionState = {
Expand Down Expand Up @@ -2112,6 +2114,10 @@ function chatAccessDenied(method: string): never {
throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported chat method: ${method}`);
}

function builtInBrowserAccessDenied(method: string): never {
throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported built-in browser method: ${method}`);
}

function externalSessionsAccessDenied(method: string): never {
throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported external sessions method: ${method}`);
}
Expand Down Expand Up @@ -2413,6 +2419,49 @@ function scopeSearchAdeActionArgs(
return { ...searchArgs, callerScope: { chatSessionId: callerChatSessionId } };
}

function scopeBuiltInBrowserAdeActionArgs(
session: SessionState,
action: string,
browserArgs: Record<string, unknown>,
): Record<string, unknown> {
const callerChatSessionId = asOptionalTrimmedString(session.identity.chatSessionId);
const method = `run_ade_action:built_in_browser.${action}`;
const browserActorToken = asOptionalTrimmedString(session.identity.browserActorToken);
const actor = resolveBuiltInBrowserActorCapability(browserActorToken);
if (!callerChatSessionId || !actor || actor.chatSessionId !== callerChatSessionId) {
builtInBrowserAccessDenied(method);
}
if (
action === "getProfileDiagnostics"
|| action === "listPermissions"
|| action === "clearPermissions"
) {
builtInBrowserAccessDenied(method);
}
const callerLaneId = actor.laneId;
const requestedChatSessionId = asOptionalTrimmedString(browserArgs.chatSessionId);
const requestedLaneId = asOptionalTrimmedString(browserArgs.laneId);
if (requestedChatSessionId && requestedChatSessionId !== callerChatSessionId) {
builtInBrowserAccessDenied(method);
}
if (requestedLaneId && requestedLaneId !== callerLaneId) {
builtInBrowserAccessDenied(method);
}
if (browserArgs.force === true) {
builtInBrowserAccessDenied(method);
}

return {
...browserArgs,
chatSessionId: actor.chatSessionId,
...(callerLaneId ? { laneId: callerLaneId } : {}),
...(actor.projectRoot
? { projectRoot: actor.projectRoot, tabCollection: undefined }
: { projectRoot: undefined, tabCollection: actor.tabCollection }),
force: false,
};
}

const EXTERNAL_SESSION_AUTH_FIND_LIMIT = 500;
const EXTERNAL_SESSION_PROVIDER_NAMES = new Set<string>(["claude", "codex", "cursor", "droid", "opencode"]);

Expand Down Expand Up @@ -2944,6 +2993,10 @@ function parseInitializeIdentity(_runtime: AdeRuntime, params: unknown): Session
const resolvedRunId = envContext.runId ?? asOptionalTrimmedString(identity.runId);
const resolvedStepId = envContext.stepId ?? asOptionalTrimmedString(identity.stepId);
const resolvedAttemptId = envContext.attemptId ?? asOptionalTrimmedString(identity.attemptId);
// Browser actor capabilities belong to the connecting CLI process. The
// long-lived runtime daemon must never lend an inherited token to another
// client, even if it was accidentally launched from an agent-owned shell.
const browserActorToken = asOptionalTrimmedString(identity.browserActorToken);

const standaloneChatSession = Boolean(resolvedChatSessionId)
&& !resolvedRunId
Expand All @@ -2959,6 +3012,7 @@ function parseInitializeIdentity(_runtime: AdeRuntime, params: unknown): Session
stepId: resolvedStepId,
attemptId: resolvedAttemptId,
ownerId: asOptionalTrimmedString(identity.ownerId) ?? envContext.ownerId,
browserActorToken,
};
}

Expand Down Expand Up @@ -3416,6 +3470,12 @@ async function runTool(args: {
session,
requireObjectArgsForScopedAdeAction(domain, action, argsList, hasScalarArg, rawObjectArgs),
);
} else if (domain === "built_in_browser") {
scopedObjectArgs = scopeBuiltInBrowserAdeActionArgs(
session,
action,
requireObjectArgsForScopedAdeAction(domain, action, argsList, hasScalarArg, rawObjectArgs),
);
} else if (!callerIsCto && domain === "external-sessions" && !isUnboundAdeCliCaller(session)) {
const externalArgs = requireObjectArgsForScopedAdeAction(domain, action, argsList, hasScalarArg, rawObjectArgs);
if (action === "list") {
Expand Down Expand Up @@ -4960,6 +5020,7 @@ export function createAdeRpcRequestHandler(args: {
stepId: null,
attemptId: null,
ownerId: null,
browserActorToken: null,
},
askUserEvents: [],
askUserRateLimit: {
Expand Down Expand Up @@ -5002,6 +5063,19 @@ export function createAdeRpcRequestHandler(args: {
?? asOptionalTrimmedString(clientInfo.name)
?? "unknown";
session.identity = parseInitializeIdentity(runtime, params);
const desktopBridgeAuthToken = asOptionalTrimmedString(params.desktopBridgeAuthToken);
if (
session.clientName === "ade-desktop-local"
&& desktopBridgeAuthToken
&& runtime.configureBuiltInBrowserDesktopBridgeAuth
) {
const configured = await runtime.configureBuiltInBrowserDesktopBridgeAuth(desktopBridgeAuthToken);
if (!configured) {
runtime.logger.warn("built_in_browser_bridge.runtime_auth_rejected", {
clientName: session.clientName,
});
}
}
const resourcesEnabled = session.identity.role !== "orchestrator";
return {
protocolVersion: session.protocolVersion,
Expand Down
20 changes: 17 additions & 3 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ import {
import type { BuiltInBrowserService } from "../../desktop/src/main/services/builtInBrowser/builtInBrowserService";
import {
createBuiltInBrowserDesktopBridgeClient,
verifyBuiltInBrowserDesktopBridgeAuth,
} from "./services/builtInBrowser/desktopBridgeClient";
import type { BuiltInBrowserDesktopBridgeClient } from "./services/builtInBrowser/desktopBridgeMethods";
import { resolveMachineAdeLayout } from "./services/projects/machineLayout";
Expand Down Expand Up @@ -230,6 +231,7 @@ export type AdeRuntime = {
iosSimulatorService?: IosSimulatorService | null;
appControlService?: AppControlService | null;
builtInBrowserService?: BuiltInBrowserService | BuiltInBrowserDesktopBridgeClient | null;
configureBuiltInBrowserDesktopBridgeAuth?: (authToken: string) => Promise<boolean>;
syncHostService?: ReturnType<typeof createSyncHostService> | null;
syncService?: ReturnType<typeof createSyncService> | null;
pushPublisherService?: PushPublisherService | null;
Expand Down Expand Up @@ -965,12 +967,15 @@ export async function createAdeRuntime(args: {
// individual calls fail clearly. Override the socket path with
// `ADE_DESKTOP_BRIDGE_SOCKET_PATH` for dev launches that use a non-default
// ADE home.
let builtInBrowserBridgeAuthToken: string | null = null;
const builtInBrowserBridgeSocketPath =
process.env.ADE_DESKTOP_BRIDGE_SOCKET_PATH?.trim()
|| resolveMachineAdeLayout().desktopBridgeSocketPath;
const builtInBrowserBridge: BuiltInBrowserDesktopBridgeClient | null = chatOnlyRuntime
? null
: createBuiltInBrowserDesktopBridgeClient({
socketPath:
process.env.ADE_DESKTOP_BRIDGE_SOCKET_PATH?.trim()
|| resolveMachineAdeLayout().desktopBridgeSocketPath,
socketPath: builtInBrowserBridgeSocketPath,
getAuthToken: () => builtInBrowserBridgeAuthToken,
projectRoot,
logger,
});
Expand Down Expand Up @@ -1640,6 +1645,15 @@ export async function createAdeRuntime(args: {
iosSimulatorService,
appControlService,
builtInBrowserService: builtInBrowserBridge,
configureBuiltInBrowserDesktopBridgeAuth: async (authToken: string) => {
if (!builtInBrowserBridge) return false;
const verified = await verifyBuiltInBrowserDesktopBridgeAuth({
socketPath: builtInBrowserBridgeSocketPath,
authToken,
});
if (verified) builtInBrowserBridgeAuthToken = authToken.trim();
return verified;
},
eventBuffer,
isPackaged: !isSourceCheckoutRuntimeModule(currentModulePath),
dispose: () => {
Expand Down
Loading