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 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
23 changes: 23 additions & 0 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
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 @@ -1385,6 +1404,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
92 changes: 87 additions & 5 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ import {
} from "./cursorCloud";
import {
CliDeeplinkUsageError,
runDeeplinkCommand,
runDeeplinkCommandAsync,
type LinkEnvelopeContext,
} from "./commands/deeplinks";
import {
CliSkillUsageError,
runSkillCommand,
} from "./commands/skill";
import { buildDeeplink } from "../../desktop/src/shared/deeplinks";
import { buildDeeplink, type DeeplinkEnvelope } from "../../desktop/src/shared/deeplinks";
import { SEARCH_DOC_KINDS } from "../../desktop/src/shared/types/search";
import { deriveDeterministicLaneNameFromPrompt } from "../../desktop/src/shared/laneNameFallback";
import {
Expand Down Expand Up @@ -467,7 +468,7 @@ const TOP_LEVEL_HELP = `${ADE_BANNER}
$ ade new chat --mode chat|cli --prompt "fix" Start an ADE Work chat or tracked CLI session
$ ade desktop Launch the installed desktop app
$ ade open <url> Open an ade:// or ade-app.dev deeplink via the OS
$ ade link lane | session | branch | pr | linear-issue
$ ade link lane | session | file | commit | artifact | branch | pr | linear-issue
Build a shareable deeplink (copies to clipboard)
$ ade linear install Register ADE as Linear's "Open in coding tool" target
$ ade skill list | show <name> Browse ADE's bundled agent skills (local)
Expand Down Expand Up @@ -1068,18 +1069,22 @@ const HELP_BY_COMMAND: Record<string, string> = {
link: `${ADE_BANNER}
ADE Link

Build a shareable deeplink URL for a lane, Work session, branch, PR, or Linear issue.
Build a shareable deeplink URL for a lane, Work session, file, commit, artifact, branch, PR, or Linear issue.
The URL is printed and (unless --no-clipboard) copied to the clipboard.

$ ade link lane <lane-uuid>
$ ade link session <session-id> [--lane <lane-uuid>]
$ ade link file <path> [--line <number>] [--lane <lane-uuid>]
$ ade link commit <sha> [--lane <lane-uuid>]
$ ade link artifact <id>
$ ade link branch <owner/repo> <branch> [--pr <number>]
$ ade link pr <owner/repo> <number>
$ ade link linear-issue <ADE-123> [--branch <branch>]
$ ade link <url> Round-trip parse + re-emit a deeplink

Flags:
--ade Emit the custom "ade://" form. Defaults to the https mirror.
--no-envelope Skip best-effort repo/branch/PR envelope lookup.
--no-clipboard Print the URL but do not copy it to the system clipboard.
`,
skill: `${ADE_BANNER}
Expand Down Expand Up @@ -16157,6 +16162,81 @@ async function runGithubAppLogin(
}
}

function createLinkEnvelopeResolver(
options: GlobalOptions,
): (context: LinkEnvelopeContext) => Promise<DeeplinkEnvelope | null> {
const action = async (
connection: CliConnection,
domain: string,
name: string,
args: JsonObject = {},
): Promise<unknown> => {
const raw = await connection.request("ade/actions/call", {
name: "run_ade_action",
arguments: { domain, action: name, args },
});
return unwrapActionEnvelope(unwrapToolResult(raw));
};

const directAction = async (
connection: CliConnection,
name: string,
args: JsonObject = {},
): Promise<unknown> => {
const raw = await connection.request("ade/actions/call", { name, arguments: args });
return unwrapToolResult(raw);
};

const records = (value: unknown, keys: string[]): Record<string, unknown>[] => {
const unwrapped = unwrapActionEnvelope(value);
if (Array.isArray(unwrapped)) return unwrapped.filter(isRecord);
if (!isRecord(unwrapped)) return [];
for (const key of keys) {
const nested = unwrapped[key];
if (Array.isArray(nested)) return nested.filter(isRecord);
}
return [];
};

return async (context) => {
let connection: CliConnection | null = null;
try {
connection = await createConnection(
{ ...options, headless: false, requireSocket: true },
{ autoRegisterProject: false },
);
const lanesValue = await directAction(connection, "list_lanes", { includeArchived: false }).catch(() => null);
const lane = records(lanesValue, ["lanes", "items", "result"])
.find((candidate) => asString(candidate.id) === context.laneId) ?? null;
const githubValue = await action(connection, "github", "getRemoteStatus").catch(() => null);
const repo = isRecord(githubValue) && isRecord(githubValue.repo) ? githubValue.repo : null;
const prsValue = await action(connection, "pr", "listAll", { laneId: context.laneId }).catch(() => null);
const pr = records(prsValue, ["prs", "items", "result"])
.find((candidate) => asString(candidate.laneId) === context.laneId) ?? null;

const branch = asString(lane?.branchRef)?.replace(/^refs\/heads\//, "") ?? null;
const laneIssue = isRecord(lane?.linearIssue) ? lane.linearIssue : null;
const linearIssue = asString(laneIssue?.identifier);
const prNumber = typeof pr?.githubPrNumber === "number" && Number.isSafeInteger(pr.githubPrNumber)
? pr.githubPrNumber
: null;
const envelope: DeeplinkEnvelope = {};
const owner = asString(repo?.owner);
const name = asString(repo?.name);
if (owner) envelope.repoOwner = owner;
if (name) envelope.repoName = name;
if (branch) envelope.branch = branch;
if (prNumber && prNumber > 0) envelope.prNumber = prNumber;
if (linearIssue) envelope.linearIssue = linearIssue;
return Object.keys(envelope).length > 0 ? envelope : null;
} catch {
return null;
} finally {
if (connection) await Promise.resolve(connection.close()).catch(() => undefined);
}
};
}

async function executePlan(
plan: CliPlan & { kind: "execute" },
options: GlobalOptions,
Expand Down Expand Up @@ -16338,7 +16418,9 @@ async function runCli(
}
if (plan.kind === "deeplink") {
try {
const result = runDeeplinkCommand(plan.rest);
const result = await runDeeplinkCommandAsync(plan.rest, {
resolveEnvelope: createLinkEnvelopeResolver(parsed.options),
});
return { output: result.output, exitCode: result.exitCode };
} catch (error) {
if (error instanceof CliDeeplinkUsageError) {
Expand Down
Loading