Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 11 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,17 @@ 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",
);
} finally {
if (previousRole == null) delete process.env.ADE_DEFAULT_ROLE;
else process.env.ADE_DEFAULT_ROLE = previousRole;
Expand Down
14 changes: 11 additions & 3 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
167 changes: 166 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,9 @@ 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 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 +883,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 +2465,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 @@ -3468,6 +3483,156 @@ 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));
}
for (const [root, ctx] of projectContexts) {
byRoot.set(root, await mobileProjectSummaryForContext(ctx, recentByRoot.get(root) ?? null));
}
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 });
mobileSyncHandoffLeases.set(targetRoot, Date.now() + MOBILE_SYNC_HANDOFF_LEASE_MS);

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]

This handoff lease is only consulted inside hasActiveProjectWorkloads() during a rebalance pass, but the new switch path only queues a rebalance immediately after setting the lease. If the phone cancels the switch or never reconnects, nothing schedules another rebalance when the 60s lease expires, so the inactive project context can stay resident indefinitely with its sync host, DB, and watchers still alive until some unrelated project switch happens. ts // apps/desktop/src/main/main.ts const project = await mobileProjectSummaryForContext(ctx, recent, { useProjectRowId: true }); mobileSyncHandoffLeases.set(targetRoot, Date.now() + MOBILE_SYNC_HANDOFF_LEASE_MS); projectLastActivatedAt.set(targetRoot, Date.now()); scheduleProjectContextRebalance(); Schedule a follow-up rebalance for leaseExpiresAt (or explicitly clear/rebalance when the handoff finishes/fails) so abandoned mobile switches do not pin warm contexts forever.

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,41 @@ 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: ghostInstalled,
state: ghostInstalled ? "installed" : "missing",
detail: ghostInstalled
? "Ghost OS CLI is installed and can produce artifacts for ADE ingestion."
: "Ghost OS CLI is not installed on this machine.",
supportedKinds: [
"screenshot",
"video_recording",
"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
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ vi.mock("node:child_process", () => ({
})),
}));

import { buildComputerUseOwnerSnapshot } from "./controlPlane";
import {
buildComputerUseOwnerSnapshot,
collectRequiredComputerUseKindsFromPhases,
} from "./controlPlane";

function createBackendStatus(): ComputerUseBackendStatus {
return {
Expand Down Expand Up @@ -47,4 +50,33 @@ describe("computer use control plane", () => {
expect(snapshot.summary).toContain("Ghost OS is available and ready to capture proof");
expect(snapshot.activity.some((item) => item.kind === "backend_available")).toBe(true);
});

it("collects only supported proof kinds from required phases", () => {
const phases = [
{
validationGate: {
required: true,
evidenceRequirements: ["screenshot", "browser_verification", "unsupported-evidence"],
},
},
{
validationGate: {
required: false,
evidenceRequirements: ["video_recording"],
},
},
{
validationGate: {
required: true,
evidenceRequirements: ["screenshot", "console_logs"],
},
},
] as any;

expect(collectRequiredComputerUseKindsFromPhases(phases)).toEqual([
"screenshot",
"browser_verification",
"console_logs",
]);
});
});
17 changes: 17 additions & 0 deletions apps/desktop/src/main/services/computerUse/controlPlane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
ComputerUseArtifactView,
ComputerUseBackendStatus,
ComputerUseOwnerSnapshot,
PhaseCard,
} from "../../../shared/types";
import type { ComputerUseArtifactBrokerService } from "./computerUseArtifactBrokerService";

Expand All @@ -20,6 +21,22 @@ export function getComputerUseArtifactKinds(): ComputerUseArtifactKind[] {
return [...COMPUTER_USE_KINDS];
}

export function collectRequiredComputerUseKindsFromPhases(
phases: PhaseCard[],
): ComputerUseArtifactKind[] {
const required = new Set<ComputerUseArtifactKind>();
const supported = new Set<ComputerUseArtifactKind>(COMPUTER_USE_KINDS);
for (const phase of phases) {
if (!phase.validationGate.required) continue;
for (const requirement of phase.validationGate.evidenceRequirements ?? []) {
if (supported.has(requirement as ComputerUseArtifactKind)) {
required.add(requirement as ComputerUseArtifactKind);
}
}
}
return Array.from(required);
}

function buildActivity(
artifacts: ComputerUseArtifactView[],
backendStatus: ComputerUseBackendStatus,
Expand Down
Loading
Loading