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
3 changes: 3 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Intentional secret-shaped redaction fixtures introduced in ADE-116.
7958e05ac0cb5cf2a3d4ba111962e5c2c232199d:apps/desktop/src/main/services/chat/agentChatService.test.ts:generic-api-key:4533
7958e05ac0cb5cf2a3d4ba111962e5c2c232199d:apps/desktop/src/main/services/chat/agentChatService.test.ts:generic-api-key:4571
4 changes: 4 additions & 0 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ The runtime exposes two layers of JSON-RPC methods (`src/multiProjectRpcServer.t
ade/initialize ade/initialized ping shutdown exit
runtime/info machineInfo.get
projects.list projects.add projects.remove projects.touch
projects.browseDirectories projects.getDetail
projects.getWorkSummary projects.getDefaultParentDir
projects.getHandoffStoragePreflight
projects.create projects.clone projects.listMyGitHubRepos
personalChats.call personalChats.streamEvents
runtimeEvents.subscribe runtimeEvents.unsubscribe
sync.getStatus sync.refreshDiscovery
Expand Down
33 changes: 33 additions & 0 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2574,6 +2574,16 @@ describe("adeRpcServer", () => {

it("lists ADE actions across runtime domains", async () => {
const fixture = createRuntime();
const crossMachineActions = [
"prepareCrossMachineHandoff",
"validateCrossMachineSource",
"preflightCrossMachineDestination",
"acceptCrossMachineHandoff",
"markCrossMachineHandoff",
] as const;
for (const action of crossMachineActions) {
(fixture.runtime.agentChatService as any)[action] = vi.fn(async (args: unknown) => ({ action, args }));
}
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });
await initialize(handler, { callerId: "agent-1", role: "agent" });

Expand All @@ -2595,6 +2605,29 @@ describe("adeRpcServer", () => {
expect(getSessionSummary).toMatchObject({
input: expect.stringContaining("scalar sessionId"),
});
const listedChatActionNames = chatActions.structuredContent.actions.map((entry: { name: string }) => entry.name);
expect(listedChatActionNames).toEqual(expect.arrayContaining(
crossMachineActions.map((action) => `chat.${action}`),
));

const prepared = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "prepareCrossMachineHandoff",
args: {
sourceSessionId: "chat-1",
handoffId: "handoff-1",
targetModelId: "openai/gpt-5.5",
},
});
expect(prepared?.isError).toBeUndefined();
expect(prepared.structuredContent.result).toEqual({
action: "prepareCrossMachineHandoff",
args: {
sourceSessionId: "chat-1",
handoffId: "handoff-1",
targetModelId: "openai/gpt-5.5",
},
});

const usageActions = await callTool(handler, "list_ade_actions", { domain: "usage" });
expect(usageActions?.isError).toBeUndefined();
Expand Down
1 change: 1 addition & 0 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1026,6 +1026,7 @@ export async function createAdeRuntime(args: {
logger,
appVersion: "ade-cli",
getAdeCliAgentEnv: createHeadlessAdeCliAgentEnv,
getLocalGitHubToken: () => headlessLinearServices.githubService.getTokenOrThrow(),
onLinearIssueChatLinked: publishLinearChatLink,
onEvent: (event) => {
pushEvent("runtime", event as unknown as Record<string, unknown>);
Expand Down
142 changes: 142 additions & 0 deletions apps/ade-cli/src/multiProjectRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createEventBuffer } from "./eventBuffer";
import { createMultiProjectRpcRequestHandler } from "./multiProjectRpcServer";
import * as gitModule from "../../desktop/src/main/services/git/git";
import { ProjectRegistry } from "./services/projects/projectRegistry";
import { ProjectScopeRegistry } from "./services/projects/projectScope";

Expand Down Expand Up @@ -193,6 +194,147 @@ describe("multi-project RPC server", () => {
handler.dispose();
});

it("preflights destination storage without creating the clone folder", async () => {
const { root, registry } = createRegistry();
const parentDir = path.join(root, "projects");
fs.mkdirSync(parentDir, { recursive: true });
const handler = createMultiProjectRpcRequestHandler({
serverVersion: "test",
projectRegistry: registry,
});

const initialized = await handler({
jsonrpc: "2.0",
id: 1,
method: "ade/initialize",
params: {},
});
expect(initialized).toMatchObject({
capabilities: {
machineProjects: { handoffStoragePreflight: true },
},
});

const preflight = await handler({
jsonrpc: "2.0",
id: 2,
method: "projects.getHandoffStoragePreflight",
params: { parentDir, repoName: "ade-handoff" },
}) as {
targetPath: string;
requiredBytes: number;
freeBytes: number;
targetExists: boolean;
blockingErrors: string[];
};

expect(preflight.targetPath).toBe(path.join(parentDir, "ade-handoff"));
expect(preflight.requiredBytes).toBeGreaterThanOrEqual(1024 * 1024 * 1024);
expect(preflight.freeBytes).toBeGreaterThan(0);
expect(preflight.targetExists).toBe(false);
expect(preflight.blockingErrors).toEqual([]);
expect(fs.existsSync(preflight.targetPath)).toBe(false);

fs.mkdirSync(preflight.targetPath);
const occupied = await handler({
jsonrpc: "2.0",
id: 3,
method: "projects.getHandoffStoragePreflight",
params: { parentDir, repoName: "ade-handoff" },
}) as { targetExists: boolean; blockingErrors: string[] };
expect(occupied.targetExists).toBe(true);
expect(occupied.blockingErrors.join(" ")).toMatch(/already exists/i);

const destinationAuthFailure = await handler({
jsonrpc: "2.0",
id: 4,
method: "projects.getHandoffStoragePreflight",
params: {
parentDir,
repoName: "private-repo",
originUrl: `file://${path.join(root, "missing-private.git")}`,
branchRef: "feature/handoff",
sourceHeadSha: "1".repeat(40),
},
}) as { blockingErrors: string[] };
expect(destinationAuthFailure.blockingErrors.join(" ")).toMatch(
/destination cannot read the published repository with its own Git credentials/i,
);
handler.dispose();
});

it("keeps destination GitHub authorization out of preflight command arguments", async () => {
const { root, registry } = createRegistry();
const parentDir = path.join(root, "projects");
fs.mkdirSync(parentDir, { recursive: true });
const sourceHeadSha = "2".repeat(40);
const destinationToken = "destination-private-token";
const originalAdeHome = process.env.ADE_HOME;
const originalGitHubToken = process.env.ADE_GITHUB_TOKEN;
process.env.ADE_HOME = path.join(root, "machine-home");
process.env.ADE_GITHUB_TOKEN = destinationToken;
const runGitSpy = vi.spyOn(gitModule, "runGit").mockResolvedValue({
exitCode: 0,
stdout: `${sourceHeadSha}\trefs/heads/feature/handoff\n`,
stderr: "",
});
const handler = createMultiProjectRpcRequestHandler({
serverVersion: "test",
projectRegistry: registry,
});

try {
await handler({
jsonrpc: "2.0",
id: 1,
method: "ade/initialize",
params: {},
});
const preflight = await handler({
jsonrpc: "2.0",
id: 2,
method: "projects.getHandoffStoragePreflight",
params: {
parentDir,
repoName: "private-repo",
originUrl: "https://github.com/example/private-repo.git",
branchRef: "feature/handoff",
sourceHeadSha,
},
}) as { blockingErrors: string[] };

expect(preflight.blockingErrors).toEqual([]);
expect(runGitSpy).toHaveBeenCalledOnce();
const [args, options] = runGitSpy.mock.calls[0] ?? [];
const expectedAuthorization = `AUTHORIZATION: basic ${Buffer.from(
`x-access-token:${destinationToken}`,
"utf8",
).toString("base64")}`;
expect(args).toEqual([
"ls-remote",
"--heads",
"https://github.com/example/private-repo.git",
"refs/heads/feature/handoff",
]);
expect(args?.join(" ")).not.toContain(destinationToken);
expect(args?.join(" ")).not.toContain(expectedAuthorization);
expect(options?.env).toMatchObject({
GIT_TERMINAL_PROMPT: "0",
GCM_INTERACTIVE: "Never",
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader",
GIT_CONFIG_VALUE_0: expectedAuthorization,
});
} finally {
handler.dispose();
runGitSpy.mockRestore();
if (originalAdeHome === undefined) delete process.env.ADE_HOME;
else process.env.ADE_HOME = originalAdeHome;
if (originalGitHubToken === undefined) delete process.env.ADE_GITHUB_TOKEN;
else process.env.ADE_GITHUB_TOKEN = originalGitHubToken;
}
});

it("requires projectId for project-scoped methods", async () => {
const { registry } = createRegistry();
const handler = createMultiProjectRpcRequestHandler({
Expand Down
135 changes: 135 additions & 0 deletions apps/ade-cli/src/multiProjectRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getProjectWorkSummary,
} from "../../desktop/src/main/services/projects/projectDetailService";
import { createProjectScaffoldService } from "../../desktop/src/main/services/projects/projectScaffoldService";
import { runGit } from "../../desktop/src/main/services/git/git";
import type { Logger } from "../../desktop/src/main/services/logging/logger";
import type {
CloneProjectInput,
Expand Down Expand Up @@ -74,6 +75,7 @@ const RUNTIME_METHODS = new Set([
"projects.getDetail",
"projects.getWorkSummary",
"projects.getDefaultParentDir",
"projects.getHandoffStoragePreflight",
"projects.create",
"projects.clone",
"projects.listMyGitHubRepos",
Expand Down Expand Up @@ -258,6 +260,134 @@ function defaultParentDir(projectRegistry: ProjectRegistry): string {
return path.join(os.homedir(), "Projects");
}

async function inspectHandoffStorage(params: Record<string, unknown>) {
const parentDir = readOptionalString(params.parentDir);
const repoName = readOptionalString(params.repoName);
if (!parentDir) {
throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "projects.getHandoffStoragePreflight requires parentDir.");
}
if (
!repoName
|| repoName === "."
|| repoName === ".."
|| repoName.includes("\0")
|| repoName.includes("/")
|| repoName.includes("\\")
) {
throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "projects.getHandoffStoragePreflight requires a valid repoName.");
}
const normalizedParent = path.resolve(parentDir);
const targetPath = path.join(normalizedParent, repoName);
const blockingErrors: string[] = [];
const warnings: string[] = [];
if (!fs.existsSync(normalizedParent)) {
blockingErrors.push(`Destination folder does not exist: ${normalizedParent}`);
} else if (!fs.statSync(normalizedParent).isDirectory()) {
blockingErrors.push(`Destination path is not a folder: ${normalizedParent}`);
} else {
try {
fs.accessSync(normalizedParent, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);
} catch {
blockingErrors.push(`ADE cannot write to destination folder: ${normalizedParent}`);
}
}
const targetExists = fs.existsSync(targetPath);
if (targetExists) {
blockingErrors.push(`A file or folder already exists at ${targetPath}. Add that repository to ADE or choose another destination.`);
}
const requiredBytes = 1_073_741_824;
let freeBytes = 0;
if (fs.existsSync(normalizedParent)) {
try {
const stats = fs.statfsSync(normalizedParent, { bigint: true });
freeBytes = Number(stats.bavail * stats.bsize);
} catch (error) {
warnings.push(`ADE could not read free disk space: ${error instanceof Error ? error.message : String(error)}`);
}
}
const hasEnoughSpace = freeBytes >= requiredBytes;
if (freeBytes > 0 && !hasEnoughSpace) {
blockingErrors.push("The destination does not have enough free space for a safe clone and lane worktree.");
} else if (freeBytes > 0 && freeBytes < requiredBytes * 2) {
warnings.push("Disk space is above the minimum, but there is limited room for dependencies and build artifacts.");
}
const originUrl = readOptionalString(params.originUrl);
const branchRef = readOptionalString(params.branchRef)?.replace(/^refs\/heads\//, "") ?? null;
const sourceHeadSha = readOptionalString(params.sourceHeadSha);
if (originUrl || branchRef || sourceHeadSha) {
if (!originUrl || !branchRef || !sourceHeadSha) {
blockingErrors.push("Destination Git authentication preflight is missing repository, branch, or commit details.");
} else if (
originUrl.includes("\0")
|| branchRef.includes("\0")
|| branchRef.startsWith("-")
|| branchRef.length > 255
|| !/^[0-9a-f]{40,64}$/i.test(sourceHeadSha)
) {
blockingErrors.push("Destination Git authentication preflight received invalid repository details.");
} else if (fs.existsSync(normalizedParent) && fs.statSync(normalizedParent).isDirectory()) {
const githubService = createHeadlessGitHubService(normalizedParent, machineProjectLogger);
let destinationAuthHeader = "";
if (githubService.parseGitHubRepoFromRemoteUrl(originUrl) && /^https:\/\//i.test(originUrl)) {
try {
const token = githubService.getTokenOrThrow();
const basic = Buffer.from(`x-access-token:${token}`, "utf8").toString("base64");
destinationAuthHeader = `AUTHORIZATION: basic ${basic}`;
} catch {
// The destination may still have a system credential helper. Let
// Git try it with terminal prompting disabled below.
}
}
const remoteArgs = [
"ls-remote",
"--heads",
originUrl,
`refs/heads/${branchRef}`,
];
const remote = await runGit(
remoteArgs,
{
cwd: normalizedParent,
timeoutMs: 30_000,
env: {
GIT_TERMINAL_PROMPT: "0",
GCM_INTERACTIVE: "Never",
...(destinationAuthHeader
? {
// Keep destination-owned credentials out of command-line
// arguments, which may be visible to other local processes.
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader",
GIT_CONFIG_VALUE_0: destinationAuthHeader,
}
: {}),
},
maxOutputBytes: 64_000,
},
);
Comment thread
arul28 marked this conversation as resolved.
if (remote.exitCode !== 0) {
const detail = (remote.stderr.trim() || remote.stdout.trim() || "check the destination Git credential manager").slice(0, 1_000);
blockingErrors.push(`The destination cannot read the published repository with its own Git credentials: ${detail}`);
} else {
const remoteHeadSha = remote.stdout.trim().split(/\s+/)[0] ?? "";
if (remoteHeadSha !== sourceHeadSha) {
blockingErrors.push("The destination sees a different published branch commit than the source machine.");
}
}
}
}
return {
parentDir: normalizedParent,
targetPath,
freeBytes,
requiredBytes,
hasEnoughSpace,
targetExists,
blockingErrors,
warnings,
};
}

function readProjectId(params: Record<string, unknown>): ProjectId | null {
const value = params.projectId;
return typeof value === "string" && value.trim().length > 0
Expand Down Expand Up @@ -535,6 +665,7 @@ export function createMultiProjectRpcRequestHandler(
getDetail: true,
getWorkSummary: true,
getDefaultParentDir: true,
handoffStoragePreflight: true,
create: true,
clone: true,
listMyGitHubRepos: true,
Expand Down Expand Up @@ -658,6 +789,10 @@ export function createMultiProjectRpcRequestHandler(
return defaultParentDir(projectRegistry);
}

if (method === "projects.getHandoffStoragePreflight") {
return await inspectHandoffStorage(params);
}

if (method === "projects.create") {
const result =
await createMachineProjectScaffoldService().createLocalProject(
Expand Down
Loading
Loading