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
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(
"Unsupported tool: screenshot_environment",
);
const environmentDenied = await callTool(handler, "get_environment_info", {});
expect(environmentDenied.isError).toBe(true);
expect(JSON.stringify(environmentDenied.error ?? environmentDenied.structuredContent ?? {})).toContain(
"Unsupported tool: get_environment_info",
);
} finally {
if (previousRole == null) delete process.env.ADE_DEFAULT_ROLE;
else process.env.ADE_DEFAULT_ROLE = previousRole;
Expand Down
17 changes: 14 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.methodNotFound,
`Unsupported tool: ${toolName}`,
);
}
const capabilities = getLocalComputerUseCapabilities();
const capability =
capabilityKey === "appLaunch" || capabilityKey === "guiInteraction" || capabilityKey === "environmentInfo"
Expand Down Expand Up @@ -4786,6 +4794,9 @@ async function runTool(args: {

if (name === "get_environment_info") {
const includeDisplays = asBoolean(toolArgs.includeDisplays, false);
if (!isLocalComputerUseAllowed(callerCtx)) {
ensureLocalComputerUse(name, "environmentInfo");
}
const capabilities = getLocalComputerUseCapabilities();
const frontmostApp = capabilities.environmentInfo.available
? tryLocalCommand("osascript", [
Expand Down
243 changes: 242 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,11 @@ 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 mobileSyncPreparationPromises = new Map<string, Promise<SyncProjectSwitchResultPayload>>();
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 +885,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 +2467,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 +3467,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 +3491,224 @@ app.whenReady().then(async () => {
setActiveProject(null);
};

async function mobileProjectSummaryForContext(
ctx: AppContext,
recent?: RecentProjectSummary | null,
): Promise<SyncMobileProjectSummary> {
let laneCount = recent?.laneCount ?? 0;
if (!recent?.laneCount) {
try {
laneCount = (await ctx.laneService.list({ includeArchived: false })).length;
} catch {
laneCount = 0;
}
}
return {
id: `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;
let catalogEntry = catalog.projects.find((entry) => {
const entryRoot = entry.rootPath ? normalizeProjectRoot(entry.rootPath) : null;
if (requestedRoot != null && requestedProjectId != null) {
if (entryRoot !== requestedRoot) return false;
return entry.id === requestedProjectId || !requestedProjectId.startsWith("root:");
}
return (requestedRoot != null && entryRoot === requestedRoot)
|| (requestedProjectId != null && entry.id === requestedProjectId);
Comment thread
arul28 marked this conversation as resolved.
});
if (!catalogEntry && requestedProjectId) {
for (const [root, ctx] of projectContexts) {
if (ctx.projectId === requestedProjectId) {
catalogEntry = catalog.projects.find((entry) =>
entry.rootPath != null && normalizeProjectRoot(entry.rootPath) === root
) ?? await mobileProjectSummaryForContext(ctx, null);
break;
}
}
}
Comment thread
coderabbitai[bot] 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 existingPreparation = mobileSyncPreparationPromises.get(targetRoot);
if (existingPreparation) return existingPreparation;

const preparationPromise = (async (): Promise<SyncProjectSwitchResultPayload> => {
const hadExistingContext = projectContexts.has(targetRoot);
let createdLeaseExpiresAt: number | null = null;
let createdLeaseTimer: ReturnType<typeof setTimeout> | null = null;
try {
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);
const leaseExpiresAt = Date.now() + MOBILE_SYNC_HANDOFF_LEASE_MS;
createdLeaseExpiresAt = leaseExpiresAt;
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?.();
createdLeaseTimer = leaseTimer;
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,
},
};
} catch (error) {
const currentLeaseTimer = mobileSyncHandoffLeaseTimers.get(targetRoot);
if (createdLeaseTimer != null && currentLeaseTimer === createdLeaseTimer) {
clearTimeout(createdLeaseTimer);
mobileSyncHandoffLeaseTimers.delete(targetRoot);
}
if (createdLeaseExpiresAt != null && mobileSyncHandoffLeases.get(targetRoot) === createdLeaseExpiresAt) {
mobileSyncHandoffLeases.delete(targetRoot);
}
if (!hadExistingContext && projectContexts.has(targetRoot) && !mobileSyncHandoffLeases.has(targetRoot)) {
await closeProjectContext(targetRoot);
} else {
scheduleProjectContextRebalance();
}
return {
ok: false,
message: error instanceof Error ? error.message : "Unable to prepare phone sync for that project.",
};
}
})();
mobileSyncPreparationPromises.set(targetRoot, preparationPromise);
try {
return await preparationPromise;
} finally {
if (mobileSyncPreparationPromises.get(targetRoot) === preparationPromise) {
mobileSyncPreparationPromises.delete(targetRoot);
}
}
}

const persistRecentProject = (
project: ProjectInfo,
options: { recordLastProject?: boolean; recordRecent?: boolean } = {},
Expand Down
Loading
Loading