Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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 apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,9 @@ ade --role cto github app-auth clear # remove the stored GitHub App
ade open ade://lane/<lane-uuid>
ade open --linear-issue ADE-123 --branch arul/ade-123-fix
ade link lane <lane-uuid>
ade link file src/index.ts --line 42 --lane <lane-uuid>
ade link commit abc1234 --lane <lane-uuid> --no-envelope
ade link artifact proof-artifact-id
ade link branch owner/repo my-branch --pr 42
ade link pr owner/repo 42 --ade
ade link linear-issue ADE-123 --branch arul/ade-123-fix
Expand Down
65 changes: 65 additions & 0 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1037,6 +1037,71 @@ describe("adeRpcServer", () => {
expect(navigate).not.toHaveBeenCalled();
});

it("rejects app/navigate file targets that are not repo-relative", async () => {
const { runtime } = createRuntime();
const navigate = vi.fn(async () => ({ ok: true, mode: "desktop", windowId: 7 }));
runtime.appNavigationService = { navigate };
const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" });
await initialize(handler, { role: "cto" });

// Traversal, absolute paths, and drive letters must never reach the
// renderer's path composition — the RPC path bypasses parseDeeplink.
for (const path of ["../../.ssh/config", "/etc/passwd", "C:/windows/system32", "src/../../secret"]) {
await expect(handler({
jsonrpc: "2.0",
id: 3,
method: "app/navigate",
params: { source: "ade-code", target: { kind: "file", path } },
})).rejects.toMatchObject({
code: JsonRpcErrorCode.invalidParams,
message: "app/navigate target 'file' requires a repo-relative path.",
});
}
expect(navigate).not.toHaveBeenCalled();

// A valid repo-relative path still routes through.
const ok = await handler({
jsonrpc: "2.0",
id: 4,
method: "app/navigate",
params: { source: "ade-code", target: { kind: "file", path: "src/app.ts", line: 3 } },
});
expect(ok).toBeTruthy();
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith({
source: "ade-code",
target: { kind: "file", path: "src/app.ts", line: 3 },
});
});

it("rejects app/navigate commit targets with malformed shas", async () => {
const { runtime } = createRuntime();
const navigate = vi.fn(async () => ({ ok: true, mode: "desktop", windowId: 7 }));
runtime.appNavigationService = { navigate };
const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" });
await initialize(handler, { role: "cto" });

await expect(handler({
jsonrpc: "2.0",
id: 5,
method: "app/navigate",
params: { source: "ade-code", target: { kind: "commit", sha: "not-a-sha" } },
})).rejects.toMatchObject({ code: JsonRpcErrorCode.invalidParams });
expect(navigate).not.toHaveBeenCalled();

const ok = await handler({
jsonrpc: "2.0",
id: 6,
method: "app/navigate",
params: { source: "ade-code", target: { kind: "commit", sha: "ABC1234" } },
});
expect(ok).toBeTruthy();
expect(navigate).toHaveBeenCalledWith({
source: "ade-code",
target: { kind: "commit", sha: "abc1234" },
});
});

it("treats requested privileged roles as external without trusted env identity", async () => {
const { runtime } = createRuntime();
const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" });
Expand Down
56 changes: 54 additions & 2 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { runGit } from "../../desktop/src/main/services/git/git";
import { resolvePathWithinRoot } from "../../desktop/src/main/services/shared/utils";
import { getDefaultModelDescriptor } from "../../desktop/src/shared/modelRegistry";
import { buildAdeCliInlineGuidance } from "../../desktop/src/shared/adeCliGuidance";
import { buildDeeplink } from "../../desktop/src/shared/deeplinks";
import { buildDeeplink, isValidCommitSha, isValidRepoRelativePath } from "../../desktop/src/shared/deeplinks";
import {
ADE_AGENT_SKILLS_DIRS_ENV,
getAdeAgentSkillRootsForPrompt,
Expand Down Expand Up @@ -4691,6 +4691,9 @@ async function readResource(runtime: AdeRuntime, uri: string): Promise<Record<st
const APP_NAVIGATE_SUPPORTED_KINDS = new Set([
"work",
"chat",
"file",
"commit",
"artifact",
"lane",
"pr",
"route",
Expand Down Expand Up @@ -4999,6 +5002,15 @@ export function createAdeRpcRequestHandler(args: {
if (kind === "lane" && !asOptionalTrimmedString(target.laneId)) {
throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "app/navigate target 'lane' requires laneId.");
}
if (kind === "file" && !asOptionalTrimmedString(target.path)) {
throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "app/navigate target 'file' requires path.");
}
if (kind === "commit" && !asOptionalTrimmedString(target.sha)) {
throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "app/navigate target 'commit' requires sha.");
}
if (kind === "artifact" && !asOptionalTrimmedString(target.artifactId)) {
throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "app/navigate target 'artifact' requires artifactId.");
}
if (kind === "route" && !asOptionalTrimmedString(target.route)) {
throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "app/navigate target 'route' requires route.");
}
Expand All @@ -5023,7 +5035,47 @@ export function createAdeRpcRequestHandler(args: {
const sessionId = asOptionalTrimmedString(target.sessionId);
const laneId = asOptionalTrimmedString(target.laneId);
if ((kind === "work" || kind === "chat" || kind === "lane") && sessionId) normalizedTarget.sessionId = sessionId;
if ((kind === "work" || kind === "chat" || kind === "lane" || kind === "pr") && laneId) normalizedTarget.laneId = laneId;
if ((kind === "work" || kind === "chat" || kind === "lane" || kind === "pr" || kind === "file" || kind === "commit") && laneId) normalizedTarget.laneId = laneId;
if (kind === "work" || kind === "chat") {
if (typeof target.event === "number" && Number.isSafeInteger(target.event) && target.event >= 0) normalizedTarget.event = target.event;
if (typeof target.offset === "number" && Number.isSafeInteger(target.offset) && target.offset >= 0) normalizedTarget.offset = target.offset;
}
if (kind === "file") {
// Same repo-relative rules as parseDeeplink: RPC callers must not be
// able to smuggle traversal/absolute paths past the URL parser.
const filePath = asOptionalTrimmedString(target.path) ?? "";
if (!isValidRepoRelativePath(filePath)) {
throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "app/navigate target 'file' requires a repo-relative path.");
}
normalizedTarget.path = filePath;
if (typeof target.line === "number" && Number.isSafeInteger(target.line) && target.line > 0) normalizedTarget.line = target.line;
}
if (kind === "commit") {
const sha = asOptionalTrimmedString(target.sha) ?? "";
if (!isValidCommitSha(sha)) {
throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "app/navigate target 'commit' requires a 7-40 hex sha.");
}
normalizedTarget.sha = sha.toLowerCase();
}
if (kind === "artifact") {
normalizedTarget.artifactId = asOptionalTrimmedString(target.artifactId);
}
if (kind === "work" || kind === "chat" || kind === "lane" || kind === "commit" || kind === "artifact") {
const envelope = safeObject(target.envelope);
const repoOwner = asOptionalTrimmedString(envelope.repoOwner);
const repoName = asOptionalTrimmedString(envelope.repoName);
const branch = asOptionalTrimmedString(envelope.branch);
const linearIssue = asOptionalTrimmedString(envelope.linearIssue);
const normalizedEnvelope: Record<string, unknown> = {};
if (repoOwner) normalizedEnvelope.repoOwner = repoOwner;
if (repoName) normalizedEnvelope.repoName = repoName;
if (branch) normalizedEnvelope.branch = branch;
if (typeof envelope.prNumber === "number" && Number.isSafeInteger(envelope.prNumber) && envelope.prNumber > 0) {
normalizedEnvelope.prNumber = envelope.prNumber;
}
if (linearIssue) normalizedEnvelope.linearIssue = linearIssue;
if (Object.keys(normalizedEnvelope).length > 0) normalizedTarget.envelope = normalizedEnvelope;
}
if (kind === "pr") {
const prId = asOptionalTrimmedString(target.prId);
if (prId) normalizedTarget.prId = prId;
Expand Down
77 changes: 43 additions & 34 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ import type { BuiltInBrowserDesktopBridgeClient } from "./services/builtInBrowse
import { resolveMachineAdeLayout } from "./services/projects/machineLayout";
import { createPushRegistrationStore } from "./services/push/pushRegistrationStore";
import { createPushRelayClient } from "./services/push/pushRelayClient";
import { getSharedPushPublisherService, resolvePushRelayStateFile, type PushPrNotification, type PushPublisherService } from "./services/push/pushPublisherService";
import { getSharedPushPublisherService, type PushPrNotification, type PushPublisherService } from "./services/push/pushPublisherService";
import type { createFileService } from "../../desktop/src/main/services/files/fileService";
import type { AppNavigationRequest, AppNavigationResult, PortLease } from "../../desktop/src/shared/types";
import type { PrEventPayload } from "../../desktop/src/shared/types/prs";
Expand Down Expand Up @@ -462,8 +462,24 @@ export async function createAdeRuntime(args: {
const searchServiceHolder: { current: SearchService | null } = { current: null };
let linearIssueTrackerRef: ReturnType<typeof createLinearIssueTracker> | null = null;
let githubServiceRef: ReturnType<typeof createGithubService> | null = null;
let laneServiceRef: ReturnType<typeof createLaneService> | null = null;
let prServiceRef: ReturnType<typeof createPrService> | null = null;
const publishLinearChatLink = createLinearChatLinkPublisher({
getIssueTracker: () => linearIssueTrackerRef,
resolveEnvelope: async ({ laneId }) => {
const repo = await githubServiceRef?.getRepoOrThrow().catch(() => null);
if (!repo) return null;
const lanes = await laneServiceRef?.list({ includeArchived: false, includeStatus: false }).catch(() => []);
const lane = lanes?.find((candidate) => candidate.id === laneId) ?? null;
const branch = lane?.branchRef?.replace(/^refs\/heads\//, "") ?? null;
const pr = prServiceRef?.getForLane(laneId) ?? null;
return {
repoOwner: repo.owner,
repoName: repo.name,
branch,
prNumber: pr?.githubPrNumber ?? null,
};
},
log: (event, fields) => logger.warn(event, fields),
});
const laneTeardownDeps: LaneDeleteTeardownDeps = {};
Expand Down Expand Up @@ -505,6 +521,7 @@ export async function createAdeRuntime(args: {
linkedAt,
repoOwner: repo?.owner ?? null,
repoName: repo?.name ?? null,
prNumber: prServiceRef?.getForLane(lane.id)?.githubPrNumber ?? null,
postInitialComment: true,
log: (event, fields) => logger.warn(event, fields),
}))
Expand All @@ -521,6 +538,7 @@ export async function createAdeRuntime(args: {
teardownDeps: laneTeardownDeps,
logger,
});
laneServiceRef = laneService;
await laneService.ensurePrimaryLane();

const sessionService = createSessionService({ db });
Expand Down Expand Up @@ -927,6 +945,7 @@ export async function createAdeRuntime(args: {
});
linearIssueTrackerRef = headlessLinearServices.linearIssueTracker;
githubServiceRef = headlessLinearServices.githubService as ReturnType<typeof createGithubService>;
prServiceRef = headlessLinearServices.prService;
laneTeardownDeps.fileWatcherService = {
countActiveForWorkspace: (id) => headlessLinearServices.fileService.countActiveWatchersForWorkspace(id),
stopAllForWorkspace: (id) => headlessLinearServices.fileService.stopAllWatchersForWorkspace(id),
Expand Down Expand Up @@ -1203,7 +1222,7 @@ export async function createAdeRuntime(args: {
// push-identity file), so a run in one project doesn't clobber the phone's
// single "agent-runs" Live Activity for another. Each scope wires its own
// chat/pty/PR signals via attachSources; the aggregate merges runs across all.
const pushRelayFilePath = resolvePushRelayStateFile(resolveMachineAdeLayout().secretsDir);
const pushRelayFilePath = path.join(resolveMachineAdeLayout().secretsDir, "push-relay.json");
const pushPublisherService = getSharedPushPublisherService(pushRelayFilePath, () => {
const store = createPushRegistrationStore({ filePath: pushRelayFilePath });
return {
Expand Down Expand Up @@ -1262,33 +1281,24 @@ export async function createAdeRuntime(args: {
projectConfigService,
usageTrackingService,
});
// Cloud tunnel relay (phone → Cloudflare DO → this brain). On by default
// the Settings kill-switch flips the shared store and the client follows.
// The store instance is shared with the sync service so the relay candidate
// in pairingConnectInfo and the tunnel client always agree on one config file.
// Cloud tunnel relay (phone → Cloudflare DO → this brain). Off by default;
// the Settings toggle flips the shared store and the client follows. The
// store instance is shared with the sync service so the relay candidate in
// pairingConnectInfo and the tunnel client always agree on one config file.
const { createSyncCloudRelayStore } = await import("./services/sync/syncCloudRelayStore");
const { createSyncTunnelClientService, getSharedSyncTunnelClientService } = await import("./services/sync/syncTunnelClientService");
const cloudRelayFilePath = path.join(
resolvedArgs.syncRuntime?.phonePairingStateDir ?? resolveMachineAdeLayout().secretsDir,
"sync-cloud-relay.json",
);
const cloudRelayStore = createSyncCloudRelayStore({ filePath: cloudRelayFilePath });
// ONE tunnel client per machine (keyed by the config file): per-scope
// instances would re-register the same machineKey with the relay on every
// project open and churn the connection paired phones dial through.
const syncTunnelClientService = getSharedSyncTunnelClientService(cloudRelayFilePath, () =>
createSyncTunnelClientService({
logger,
configStore: cloudRelayStore,
getSyncPort: () => resolvedArgs.syncRuntime?.sharedSyncListener?.getPort() ?? null,
}));
// Only the runtime that actually hosts phone sync (owns the brain-level
// shared listener) may register the relay tunnel. The relay DO keeps ONE
// host socket per machineKey (last wins), so a headless one-shot CLI
// runtime or embedded fallback starting the tunnel would steal the relay
// from `ade serve` and then fail every phone /connect (no sync port).
const canHostRelayTunnel = resolvedArgs.syncRuntime?.sharedSyncListener != null;
if (canHostRelayTunnel && cloudRelayStore.isEnabled()) {
const { createSyncTunnelClientService } = await import("./services/sync/syncTunnelClientService");
const cloudRelayStore = createSyncCloudRelayStore({
filePath: path.join(
resolvedArgs.syncRuntime?.phonePairingStateDir ?? resolveMachineAdeLayout().secretsDir,
"sync-cloud-relay.json",
),
});
const syncTunnelClientService = createSyncTunnelClientService({
logger,
configStore: cloudRelayStore,
getSyncPort: () => resolvedArgs.syncRuntime?.sharedSyncListener?.getPort() ?? null,
});
if (cloudRelayStore.isEnabled()) {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
void syncTunnelClientService.start().catch((error) => {
logger.warn("sync.tunnel_start_failed", {
error: error instanceof Error ? error.message : String(error),
Expand Down Expand Up @@ -1342,9 +1352,6 @@ export async function createAdeRuntime(args: {
getModelPickerStore: () => getSharedModelPickerStore(db),
cloudRelayStore,
onCloudRelayEnabledChanged: (enabled) => {
// Same gate as startup: only the sync-hosting runtime may register
// the relay tunnel (see canHostRelayTunnel above).
if (enabled && !canHostRelayTunnel) return;
const action = enabled ? syncTunnelClientService.start() : syncTunnelClientService.stop();
void action.catch((error) => {
logger.warn("sync.tunnel_toggle_failed", {
Expand Down Expand Up @@ -1385,6 +1392,10 @@ export async function createAdeRuntime(args: {
agentChatService,
prService: headlessLinearServices.prService ?? null,
gitService,
repoSlug: async () => {
const status = await headlessLinearServices.githubService.getRemoteStatus().catch(() => ({ repo: null }));
return status.repo ?? null;
},
fileService: headlessLinearServices.fileService ?? null,
artifactBroker: computerUseArtifactBrokerService,
linearIssueTracker: headlessLinearServices.linearIssueTracker ?? null,
Expand Down Expand Up @@ -1468,9 +1479,7 @@ export async function createAdeRuntime(args: {
swallow(() => prPollingService.dispose());
// Detach only this scope's signals; the shared publisher outlives the scope.
swallow(() => detachPushSources());
// The tunnel client is machine-level and shared across scopes — closing
// one project must not sever the relay for the others. The daemon's
// shutdown path (disposeServeResources) stops it.
void syncTunnelClientService.dispose().catch(() => {});
swallow(() => automationIngressService?.dispose());
swallow(() => automationService?.dispose());
swallow(() => usageTrackingService.dispose());
Expand Down
Loading