Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
16 changes: 16 additions & 0 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,22 @@ describe("adeRpcServer", () => {
expect(names).not.toContain("spawn_worker");
expect(names).not.toContain("read_mission_status");
expect(names).not.toContain("get_cto_state");
expect(names).not.toContain("get_environment_info");
expect(names).not.toContain("launch_app");
expect(names).not.toContain("interact_gui");
expect(names).not.toContain("screenshot_environment");
expect(names).not.toContain("record_environment");

const denied = await callTool(handler, "screenshot_environment", {});
expect(denied.isError).toBe(true);
expect(JSON.stringify(denied.error ?? denied.structuredContent ?? {})).toContain(
"local computer use is not allowed",
);
const environmentDenied = await callTool(handler, "get_environment_info", {});
expect(environmentDenied.isError).toBe(true);
expect(JSON.stringify(environmentDenied.error ?? environmentDenied.structuredContent ?? {})).toContain(
"local computer use is not allowed",
);
} finally {
if (previousRole == null) delete process.env.ADE_DEFAULT_ROLE;
else process.env.ADE_DEFAULT_ROLE = previousRole;
Expand Down
16 changes: 12 additions & 4 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2843,16 +2843,18 @@ function canCallerAccessCoordinatorTool(name: string, callerCtx: CallerContext):
return false;
}

function isLocalComputerUseAllowed(): boolean {
return true;
function isLocalComputerUseAllowed(callerCtx: CallerContext): boolean {
return callerCtx.role === "cto"
|| callerCtx.role === "orchestrator"
|| callerCtx.role === "agent";
}

async function listToolSpecsForSession(runtime: AdeRuntime, session: SessionState): Promise<ToolSpec[]> {
const callerCtx = await resolveEffectiveCallerContext(runtime, session);
const externalComputerUseAvailable = runtime.computerUseArtifactBrokerService
?.getBackendStatus()
?.backends.some((backend) => backend.available) ?? false;
const localComputerUseAllowed = isLocalComputerUseAllowed();
const localComputerUseAllowed = isLocalComputerUseAllowed(callerCtx);
const shouldHideLocalComputerUse = !localComputerUseAllowed || externalComputerUseAvailable;
const visibleBaseTools = shouldHideLocalComputerUse
? TOOL_SPECS.filter((tool) => !LOCAL_COMPUTER_USE_TOOL_NAMES.has(tool.name))
Expand Down Expand Up @@ -4135,6 +4137,12 @@ async function runTool(args: {
toolName: string,
capabilityKey: "screenshot" | "browser_verification" | "browser_trace" | "video_recording" | "console_logs" | "appLaunch" | "guiInteraction" | "environmentInfo",
) => {
if (!isLocalComputerUseAllowed(callerCtx)) {
Comment thread
arul28 marked this conversation as resolved.
throw new JsonRpcError(
Comment thread
arul28 marked this conversation as resolved.
JsonRpcErrorCode.policyDenied,
`${toolName} is disabled because local computer use is not allowed for this ADE RPC session.`,
);
}
const capabilities = getLocalComputerUseCapabilities();
const capability =
capabilityKey === "appLaunch" || capabilityKey === "guiInteraction" || capabilityKey === "environmentInfo"
Expand Down Expand Up @@ -4786,7 +4794,7 @@ async function runTool(args: {

if (name === "get_environment_info") {
const includeDisplays = asBoolean(toolArgs.includeDisplays, false);
const capabilities = getLocalComputerUseCapabilities();
const capabilities = ensureLocalComputerUse(name, "environmentInfo");

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]

get_environment_info now goes through ensureLocalComputerUse, which throws whenever environmentInfo.available is false: ts // apps/ade-cli/src/adeRpcServer.ts if (name === "get_environment_info") { const includeDisplays = asBoolean(toolArgs.includeDisplays, false); const capabilities = ensureLocalComputerUse(name, "environmentInfo"); const frontmostApp = capabilities.environmentInfo.available That changes this read-only diagnostic tool from returning structured blocked_by_capability / missing state to hard-failing on Linux or on macOS hosts where osascript is unavailable, even though @AGENTS.md says computer-use features should gracefully degrade off macOS. The regression is user-facing because ade proof environment can no longer inspect why local computer use is unavailable for an authorized session. Keep the new policyDenied check for unauthorized callers, but do not treat missing local capability as fatal for this inspection endpoint.

Suggested change
const capabilities = ensureLocalComputerUse(name, "environmentInfo");
const capabilities = isLocalComputerUseAllowed(callerCtx) ? getLocalComputerUseCapabilities() : ensureLocalComputerUse(name, "environmentInfo");

const frontmostApp = capabilities.environmentInfo.available
? tryLocalCommand("osascript", [
"-e",
Expand Down
191 changes: 190 additions & 1 deletion apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,12 @@ import {
toProjectInfo,
upsertProjectRow,
} from "./services/projects/projectService";
import { toRecentProjectSummary } from "./services/projects/recentProjectSummary";
import { createAdeProjectService } from "./services/projects/adeProjectService";
import { createConfigReloadService } from "./services/projects/configReloadService";
import { IPC } from "../shared/ipc";
import { resolveAdeLayout } from "../shared/adeLayout";
import type { PortLease, ProjectInfo } from "../shared/types";
import type { PortLease, ProjectInfo, RecentProjectSummary, SyncMobileProjectSummary, SyncProjectSwitchRequestPayload, SyncProjectSwitchResultPayload } from "../shared/types";
import type { AppContext } from "./services/ipc/registerIpc";
import fs from "node:fs";
import net from "node:net";
Expand Down Expand Up @@ -747,7 +748,10 @@ app.whenReady().then(async () => {
const closeContextPromises = new Map<string, Promise<void>>();
const rpcSocketCleanupByRoot = new Map<string, () => void>();
const projectLastActivatedAt = new Map<string, number>();
const mobileSyncHandoffLeases = new Map<string, number>();
const mobileSyncHandoffLeaseTimers = new Map<string, ReturnType<typeof setTimeout>>();
const MAX_WARM_IDLE_PROJECT_CONTEXTS = 1;
const MOBILE_SYNC_HANDOFF_LEASE_MS = 60_000;
let activeProjectRoot: string | null = null;
let dormantContext!: AppContext;
let projectContextRebalancePromise: Promise<void> = Promise.resolve();
Expand Down Expand Up @@ -880,6 +884,14 @@ app.whenReady().then(async () => {
}

try {
const leaseExpiresAt = mobileSyncHandoffLeases.get(projectRoot) ?? 0;
if (leaseExpiresAt > Date.now()) {
return true;
}
if (leaseExpiresAt > 0) {
mobileSyncHandoffLeases.delete(projectRoot);
}

if ((ctx.syncHostService?.getPeerStates().length ?? 0) > 0) {
return true;
}
Expand Down Expand Up @@ -2454,6 +2466,10 @@ app.whenReady().then(async () => {
processService,
hostStartupEnabled: process.env.ADE_DISABLE_SYNC_HOST !== "1",
notificationEventBus,
projectCatalogProvider: {
listProjects: listMobileSyncProjects,
prepareProjectConnection: prepareMobileSyncProjectConnection,
},
onStatusChanged: (snapshot) =>
emitProjectEvent(projectRoot, IPC.syncEvent, {
type: "sync-status",
Expand Down Expand Up @@ -3450,6 +3466,12 @@ app.whenReady().then(async () => {
await disposeContextResources(ctx);
projectContexts.delete(normalizedRoot);
projectLastActivatedAt.delete(normalizedRoot);
const leaseTimer = mobileSyncHandoffLeaseTimers.get(normalizedRoot);
if (leaseTimer) {
clearTimeout(leaseTimer);
mobileSyncHandoffLeaseTimers.delete(normalizedRoot);
}
mobileSyncHandoffLeases.delete(normalizedRoot);
if (activeProjectRoot === normalizedRoot) {
activeProjectRoot = null;
}
Expand All @@ -3468,6 +3490,173 @@ app.whenReady().then(async () => {
setActiveProject(null);
};

async function mobileProjectSummaryForContext(
ctx: AppContext,
recent?: RecentProjectSummary | null,
options: { useProjectRowId?: boolean } = {},
): Promise<SyncMobileProjectSummary> {
let laneCount = recent?.laneCount ?? 0;
if (!recent?.laneCount) {
try {
laneCount = (await ctx.laneService.list({ includeArchived: false })).length;
} catch {
laneCount = 0;
}
}
return {
id: options.useProjectRowId ? ctx.projectId : `root:${normalizeProjectRoot(ctx.project.rootPath)}`,
displayName: ctx.project.displayName,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
rootPath: ctx.project.rootPath,
defaultBaseRef: ctx.project.baseRef,
lastOpenedAt: recent?.lastOpenedAt ?? null,
laneCount,
isAvailable: fs.existsSync(ctx.project.rootPath),
isCached: false,
};
}

function mobileProjectSummaryForRecent(recent: RecentProjectSummary): SyncMobileProjectSummary {
const normalizedRoot = normalizeProjectRoot(recent.rootPath);
return {
id: `root:${normalizedRoot}`,
displayName: recent.displayName,
rootPath: recent.rootPath,
defaultBaseRef: null,
lastOpenedAt: recent.lastOpenedAt,
laneCount: recent.laneCount ?? 0,
isAvailable: recent.exists,
isCached: false,
};
Comment thread
arul28 marked this conversation as resolved.
}

async function listMobileSyncProjects(): Promise<{ projects: SyncMobileProjectSummary[] }> {
const recentProjects = (readGlobalState(globalStatePath).recentProjects ?? [])
.map(toRecentProjectSummary);
const recentByRoot = new Map(
recentProjects.map((entry) => [normalizeProjectRoot(entry.rootPath), entry] as const),
);
const byRoot = new Map<string, SyncMobileProjectSummary>();
for (const recent of recentProjects) {
byRoot.set(normalizeProjectRoot(recent.rootPath), mobileProjectSummaryForRecent(recent));
}
const contextSummaries = await Promise.all(
[...projectContexts.entries()].map(async ([root, ctx]) =>
[root, await mobileProjectSummaryForContext(ctx, recentByRoot.get(root) ?? null)] as const
),
);
for (const [root, summary] of contextSummaries) {
byRoot.set(root, summary);
}
const projects = [...byRoot.entries()]
.sort(([leftRoot], [rightRoot]) => {
if (leftRoot === activeProjectRoot) return -1;
if (rightRoot === activeProjectRoot) return 1;
return 0;
})
.map(([, project]) => project);
return { projects };
}

async function ensureProjectContextForMobileSync(projectRoot: string): Promise<AppContext> {
const normalizedRoot = normalizeProjectRoot(projectRoot);
const existing = projectContexts.get(normalizedRoot);
if (existing) return existing;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if (!fs.existsSync(normalizedRoot)) {
throw new Error("Project is no longer available on this desktop.");
}

let initPromise = projectInitPromises.get(normalizedRoot);
if (!initPromise) {
initPromise = (async () => {
const baseRef = await detectDefaultBaseRef(normalizedRoot);
const ctx = await initContextForProjectRoot({
projectRoot: normalizedRoot,
baseRef,
ensureExclude: true,
recordLastProject: false,
recordRecent: true,
userSelectedProject: false,
});
projectContexts.set(normalizedRoot, ctx);
return ctx;
})().finally(() => {
projectInitPromises.delete(normalizedRoot);
}) as Promise<AppContext>;
projectInitPromises.set(normalizedRoot, initPromise);
}
return initPromise;
}

async function prepareMobileSyncProjectConnection(
args: SyncProjectSwitchRequestPayload,
): Promise<SyncProjectSwitchResultPayload> {
const catalog = await listMobileSyncProjects();
const requestedRoot = typeof args.rootPath === "string" && args.rootPath.trim()
? normalizeProjectRoot(args.rootPath)
: null;
const requestedProjectId = typeof args.projectId === "string" && args.projectId.trim()
? args.projectId.trim()
: null;
const catalogEntry = catalog.projects.find((entry) => {
const entryRoot = entry.rootPath ? normalizeProjectRoot(entry.rootPath) : null;
return (requestedRoot != null && entryRoot === requestedRoot)
|| (requestedProjectId != null && entry.id === requestedProjectId);
Comment thread
arul28 marked this conversation as resolved.
});
if (!catalogEntry || !catalogEntry.isAvailable) {
return {
ok: false,
message: "That project is not available from this desktop.",
};
}
const targetRoot = catalogEntry.rootPath ? normalizeProjectRoot(catalogEntry.rootPath) : null;
if (!targetRoot) {
return {
ok: false,
message: "Choose a desktop project first.",
};
}

const ctx = await ensureProjectContextForMobileSync(targetRoot);
if (!ctx.syncService) {
throw new Error("Sync is not available for that project.");
}
await ctx.syncService.initialize();
const status = await ctx.syncService.getStatus();
if (!status.bootstrapToken || !status.pairingConnectInfo) {
throw new Error("That project is not ready for phone sync yet.");
}
const recent = (readGlobalState(globalStatePath).recentProjects ?? [])
.map(toRecentProjectSummary)
.find((entry) => normalizeProjectRoot(entry.rootPath) === targetRoot) ?? null;
const project = await mobileProjectSummaryForContext(ctx, recent, { useProjectRowId: true });
const leaseExpiresAt = Date.now() + MOBILE_SYNC_HANDOFF_LEASE_MS;
mobileSyncHandoffLeases.set(targetRoot, leaseExpiresAt);
const existingLeaseTimer = mobileSyncHandoffLeaseTimers.get(targetRoot);
if (existingLeaseTimer) clearTimeout(existingLeaseTimer);
const leaseTimer = setTimeout(() => {
mobileSyncHandoffLeaseTimers.delete(targetRoot);
if (mobileSyncHandoffLeases.get(targetRoot) === leaseExpiresAt) {
mobileSyncHandoffLeases.delete(targetRoot);
}
scheduleProjectContextRebalance();
}, MOBILE_SYNC_HANDOFF_LEASE_MS + 100);
leaseTimer.unref?.();
mobileSyncHandoffLeaseTimers.set(targetRoot, leaseTimer);
projectLastActivatedAt.set(targetRoot, Date.now());
scheduleProjectContextRebalance();
return {
ok: true,
project,
connection: {
authKind: "bootstrap",
token: status.bootstrapToken,
hostIdentity: status.pairingConnectInfo.hostIdentity,
port: status.pairingConnectInfo.port,
addressCandidates: status.pairingConnectInfo.addressCandidates,
},
};
}

const persistRecentProject = (
project: ProjectInfo,
options: { recordLastProject?: boolean; recordRecent?: boolean } = {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
ComputerUseArtifactRouteArgs,
ComputerUseArtifactView,
ComputerUseBackendStatus,
ComputerUseExternalBackendStatus,
ComputerUseArtifactWorkflowState,
ComputerUseEventPayload,
} from "../../../shared/types";
Expand All @@ -38,6 +39,7 @@ import {
toOptionalString,
writeTextAtomic,
} from "../shared/utils";
import { commandExists } from "../ai/utils";
import { createComputerUseArtifactPath, getLocalComputerUseCapabilities, toProjectArtifactUri } from "./localComputerUse";

type StoredArtifactRow = {
Expand Down Expand Up @@ -489,8 +491,40 @@ export function createComputerUseArtifactBrokerService(args: {
if (local.proofRequirements.browser_verification.available) localKinds.push("browser_verification");
if (local.proofRequirements.console_logs.available) localKinds.push("console_logs");

const backends: ComputerUseExternalBackendStatus[] = [];
const ghostInstalled = commandExists("ghost");
Comment thread
arul28 marked this conversation as resolved.
backends.push({
name: "Ghost OS",
available: false,
Comment thread
arul28 marked this conversation as resolved.
state: ghostInstalled ? "installed" : "missing",
detail: ghostInstalled
? "Ghost OS CLI is installed, but ADE Ghost integration readiness is not enabled yet."
: "Ghost OS CLI is not installed on this machine.",
supportedKinds: [
"screenshot",
"browser_verification",
],
});

const agentBrowserInstalled = commandExists("agent-browser");
backends.push({
name: "agent-browser",
available: agentBrowserInstalled,
state: agentBrowserInstalled ? "installed" : "missing",
detail: agentBrowserInstalled
? "agent-browser CLI is installed and can produce artifacts for ADE ingestion."
: "agent-browser CLI is not installed on this machine.",
supportedKinds: [
"screenshot",
"video_recording",
"browser_trace",
"browser_verification",
"console_logs",
],
});

return {
backends: [],
backends,
localFallback: {
available: local.overallState === "present",
detail: local.overallState === "present"
Expand Down
Loading
Loading