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
15 changes: 15 additions & 0 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
resetBuiltInBrowserActorCapabilitiesForTest,
} from "../../desktop/src/main/services/builtInBrowser/builtInBrowserActorCapabilities";
import { BUILT_IN_BROWSER_ACTOR_CAPABILITY_PARAM } from "./services/builtInBrowser/desktopBridgeMethods";
import { ADE_BUNDLED_AGENT_SKILLS_DIR_ENV } from "../../desktop/src/shared/agentSkillRoots";

type RuntimeFixture = ReturnType<typeof createRuntime>;
const originalPlatform = process.platform;
Expand Down Expand Up @@ -2273,6 +2274,19 @@ describe("adeRpcServer", () => {

it("routes start_cli_session through shared provider launch helpers", async () => {
const fixture = createRuntime();
const repositorySkills = path.join(
fixture.runtime.paths.worktreesDir,
"lane-1",
"apps",
"desktop",
"resources",
"agent-skills",
);
fs.mkdirSync(path.join(repositorySkills, ".claude-plugin"), { recursive: true });
fs.writeFileSync(
path.join(repositorySkills, ".claude-plugin", "plugin.json"),
JSON.stringify({ name: "malicious-repository-plugin", skills: "./" }),
);
fixture.runtime.sessionService.get.mockReturnValue({
id: "session-1",
laneId: "lane-1",
Expand Down Expand Up @@ -2315,6 +2329,7 @@ describe("adeRpcServer", () => {
}),
);
const createCall = fixture.runtime.ptyService.create.mock.calls.at(-1)?.[0];
expect(createCall?.env).not.toHaveProperty(ADE_BUNDLED_AGENT_SKILLS_DIR_ENV);
expect(createCall?.args).toEqual(expect.arrayContaining(["--model", "gpt-5.5", "-c", "model_reasoning_effort=\"xhigh\"", "-c", "service_tier=\"default\""]));
expect(createCall?.args).not.toContain(expect.stringContaining("fix failing tests"));
expect(createCall?.initialInput).toContain("fix failing tests");
Expand Down
134 changes: 132 additions & 2 deletions apps/ade-cli/src/bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,139 @@
import { describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createEventBuffer, type BufferedEvent } from "./eventBuffer";
import { emitRuntimePrCardsForChanges } from "./bootstrap";
import {
createHeadlessAdeCliAgentEnv,
emitRuntimePrCardsForChanges,
inferAgentSkillsRootForCliEntry,
} from "./bootstrap";
import { createPrEventFanout } from "./prEventFanout";
import { isSourceCheckoutRuntimeModule } from "./runtimePackaging";
import type { PrCardChange } from "../../desktop/src/main/services/prs/prChatCards";
import {
ADE_AGENT_SKILLS_DIRS_ENV,
ADE_BUNDLED_AGENT_SKILLS_DIR_ENV,
splitAdeAgentSkillRoots,
} from "../../desktop/src/shared/agentSkillRoots";

const tempRoots: string[] = [];

function makeTempRoot(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bootstrap-skills-"));
tempRoots.push(root);
return root;
}

function writeFile(filePath: string, contents = ""): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, contents);
}

function writeSkillsManifest(skillsRoot: string): void {
writeFile(
path.join(skillsRoot, ".claude-plugin", "plugin.json"),
JSON.stringify({ name: "ade", skills: "./" }),
);
}

afterEach(() => {
vi.restoreAllMocks();
for (const root of tempRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});

describe("headless ADE CLI agent skill roots", () => {
it("keeps a cwd-discovered catalog root untrusted and clears an inherited bundle marker", () => {
const root = makeTempRoot();
const repositorySkills = path.join(root, "apps", "desktop", "resources", "agent-skills");
writeSkillsManifest(repositorySkills);

const inferred = inferAgentSkillsRootForCliEntry(null, {
cwd: root,
resourcesPath: null,
});
const env = createHeadlessAdeCliAgentEnv({
ADE_BUNDLED_AGENT_SKILLS_DIR: repositorySkills,
}, {
cliEntry: null,
cwd: root,
resourcesPath: null,
});

expect(inferred).toEqual({
catalogRoot: repositorySkills,
bundledRoot: null,
});
expect(splitAdeAgentSkillRoots(env[ADE_AGENT_SKILLS_DIRS_ENV])).toContain(repositorySkills);
expect(env[ADE_BUNDLED_AGENT_SKILLS_DIR_ENV]).toBeUndefined();
});

it("trusts canonical source-checkout and CLI-adjacent bundles", () => {
const sourceRoot = makeTempRoot();
const sourceCli = path.join(sourceRoot, "apps", "ade-cli", "src", "cli.ts");
const sourceSkills = path.join(sourceRoot, "apps", "desktop", "resources", "agent-skills");
writeFile(sourceCli, "export {};\n");
writeSkillsManifest(sourceSkills);

const packagedRoot = makeTempRoot();
const packagedCli = path.join(packagedRoot, "Resources", "ade-cli", "cli.cjs");
const packagedSkills = path.join(packagedRoot, "Resources", "agent-skills");
writeFile(packagedCli, "module.exports = {};\n");
writeSkillsManifest(packagedSkills);

expect(inferAgentSkillsRootForCliEntry(sourceCli, {
cwd: path.join(sourceRoot, "elsewhere"),
resourcesPath: null,
})).toEqual({
catalogRoot: fs.realpathSync(sourceSkills),
bundledRoot: fs.realpathSync(sourceSkills),
});
expect(inferAgentSkillsRootForCliEntry(packagedCli, {
cwd: path.join(packagedRoot, "elsewhere"),
resourcesPath: null,
})).toEqual({
catalogRoot: fs.realpathSync(packagedSkills),
bundledRoot: fs.realpathSync(packagedSkills),
});
expect(createHeadlessAdeCliAgentEnv({
ADE_BUNDLED_AGENT_SKILLS_DIR: "/inherited/untrusted-skills",
}, {
cliEntry: null,
cwd: path.join(packagedRoot, "elsewhere"),
resourcesPath: path.join(packagedRoot, "Resources"),
})[ADE_BUNDLED_AGENT_SKILLS_DIR_ENV]).toBe(fs.realpathSync(packagedSkills));
});

it("rejects package-resource and source bundle symlinks that escape their boundaries", () => {
const packagedRoot = makeTempRoot();
const resourcesPath = path.join(packagedRoot, "Resources");
const externalSkills = path.join(packagedRoot, "external-skills");
fs.mkdirSync(resourcesPath, { recursive: true });
writeSkillsManifest(externalSkills);
fs.symlinkSync(externalSkills, path.join(resourcesPath, "agent-skills"), "dir");

const sourceRoot = makeTempRoot();
const sourceExternalRoot = makeTempRoot();
const sourceCli = path.join(sourceRoot, "apps", "ade-cli", "dist", "cli.cjs");
const sourceSkills = path.join(sourceRoot, "apps", "desktop", "resources", "agent-skills");
const sourceExternalSkills = path.join(sourceExternalRoot, "external-skills");
writeFile(sourceCli, "module.exports = {};\n");
writeSkillsManifest(sourceExternalSkills);
fs.mkdirSync(path.dirname(sourceSkills), { recursive: true });
fs.symlinkSync(sourceExternalSkills, sourceSkills, "dir");

expect(inferAgentSkillsRootForCliEntry(null, {
cwd: path.join(packagedRoot, "elsewhere"),
resourcesPath,
}).bundledRoot).toBeNull();
expect(inferAgentSkillsRootForCliEntry(sourceCli, {
cwd: path.join(sourceRoot, "elsewhere"),
resourcesPath: null,
}).bundledRoot).toBeNull();
});
});

describe("emitRuntimePrCardsForChanges", () => {
it("emits PR cards through the daemon-owned chat service", async () => {
Expand Down
124 changes: 94 additions & 30 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from "../../desktop/src/main/services/runtime/lastFailureStore";
import { mapKvDbOpenErrorCode } from "../../desktop/src/shared/types/recovery";
import { detectDefaultBaseRef, toProjectInfo, upsertProjectRow } from "../../desktop/src/main/services/projects/projectService";
import { reseedAdeSkills } from "../../desktop/src/main/services/skills/skillReseedService";
import { cleanupLegacyAdeSkills } from "../../desktop/src/main/services/skills/legacySkillCleanupService";
import {
createAdeProjectService,
initializeOrRepairAdeProject,
Expand Down Expand Up @@ -91,6 +91,7 @@ import type { createGithubService } from "../../desktop/src/main/services/github
import { createFeedbackReporterService } from "../../desktop/src/main/services/feedback/feedbackReporterService";
import {
ADE_AGENT_SKILLS_DIRS_ENV,
ADE_BUNDLED_AGENT_SKILLS_DIR_ENV,
getAdeAgentSkillRootsForPrompt,
joinAdeAgentSkillRoots,
splitAdeAgentSkillRoots,
Expand Down Expand Up @@ -433,53 +434,110 @@ function prependAgentSkillsRoot(existing: string | undefined, root: string | nul
return joinAdeAgentSkillRoots([root, ...splitAdeAgentSkillRoots(existing)]);
}

function inferAgentSkillsRootForCliEntry(cliEntry: string | null): string | null {
const candidates: string[] = [];
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
if (resourcesPath) candidates.push(path.join(resourcesPath, "agent-skills"));
if (cliEntry) {
const cliDir = path.dirname(cliEntry);
candidates.push(path.resolve(cliDir, "..", "agent-skills"));
candidates.push(path.resolve(cliDir, "..", "..", "desktop", "resources", "agent-skills"));
candidates.push(path.resolve(cliDir, "..", "..", "..", "apps", "desktop", "resources", "agent-skills"));
function canonicalDirectoryWithin(root: string | null, boundary: string | null): string | null {
if (!root || !boundary) return null;
try {
const canonicalRoot = fs.realpathSync(root);
const canonicalBoundary = fs.realpathSync(boundary);
if (!fs.statSync(canonicalRoot).isDirectory()) return null;
const relative = path.relative(canonicalBoundary, canonicalRoot);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return null;
return canonicalRoot;
} catch {
return null;
}
candidates.push(path.resolve(process.cwd(), "apps", "desktop", "resources", "agent-skills"));
for (const candidate of candidates) {
if (pathExistsDirectory(candidate)) return candidate;
}

function trustedAgentSkillsRootForCliEntry(
cliEntry: string | null,
resourcesPath: string | null,
): string | null {
const packagedRoot = canonicalDirectoryWithin(
resourcesPath ? path.join(resourcesPath, "agent-skills") : null,
resourcesPath,
);
if (packagedRoot) return packagedRoot;
if (!cliEntry) return null;

let canonicalCliEntry: string;
try {
canonicalCliEntry = fs.realpathSync(cliEntry);
if (!fs.statSync(canonicalCliEntry).isFile()) return null;
} catch {
return null;
}

let current = path.dirname(canonicalCliEntry);
for (let depth = 0; depth < 8; depth += 1) {
if (path.basename(current) === "ade-cli") {
const parent = path.dirname(current);
if (path.basename(parent) === "apps") {
const repoRoot = path.dirname(parent);
return canonicalDirectoryWithin(
path.join(repoRoot, "apps", "desktop", "resources", "agent-skills"),
repoRoot,
);
}
return canonicalDirectoryWithin(path.join(parent, "agent-skills"), parent);
}
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return null;
}

let adeSkillsReseededForCli = false;
export function inferAgentSkillsRootForCliEntry(
cliEntry: string | null,
options: { resourcesPath?: string | null; cwd?: string | null } = {},
): { catalogRoot: string | null; bundledRoot: string | null } {
const resourcesPath = options.resourcesPath
?? (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
?? null;
const bundledRoot = trustedAgentSkillsRootForCliEntry(cliEntry, resourcesPath);
if (bundledRoot) return { catalogRoot: bundledRoot, bundledRoot };

const cwd = options.cwd ?? process.cwd();
const cwdRoot = cwd
? path.resolve(cwd, "apps", "desktop", "resources", "agent-skills")
: null;
return {
catalogRoot: pathExistsDirectory(cwdRoot) ? cwdRoot : null,
bundledRoot: null,
};
}

let legacyAdeSkillsCleanedForCli = false;

/**
* Materialize ADE's bundled `ade-*` skills into the home-level skill dirs every
* runtime natively discovers, so agents ADE spawns pick them up via the runtime's
* own progressive disclosure. Cheap no-op once on-disk copies are current;
* best-effort so an unwritable home dir never blocks the CLI.
* Remove legacy ADE-managed user-global copies when they are provably unchanged.
* Session-scoped discovery now uses ADE_AGENT_SKILLS_DIRS instead.
*/
export function reseedBundledAdeSkillsForCli(): void {
if (adeSkillsReseededForCli) return;
if (process.env.ADE_DISABLE_SKILL_RESEED === "1" || process.env.VITEST) return;
adeSkillsReseededForCli = true;
export function cleanupLegacyBundledAdeSkillsForCli(): void {
if (legacyAdeSkillsCleanedForCli) return;
if (process.env.ADE_DISABLE_SKILL_CLEANUP === "1" || process.env.VITEST) return;
legacyAdeSkillsCleanedForCli = true;
try {
const bundledRoot = inferAgentSkillsRootForCliEntry(resolveCurrentAdeCliEntry());
if (bundledRoot) reseedAdeSkills({ bundledRoot });
const { bundledRoot } = inferAgentSkillsRootForCliEntry(resolveCurrentAdeCliEntry());
if (bundledRoot) cleanupLegacyAdeSkills({ bundledRoot });
} catch {
/* best-effort: skill re-seeding must never break agent launch */
/* best-effort: legacy cleanup must never break agent launch */
}
}

function createHeadlessAdeCliAgentEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
reseedBundledAdeSkillsForCli();
export function createHeadlessAdeCliAgentEnv(
baseEnv: NodeJS.ProcessEnv = process.env,
options: { cliEntry?: string | null; resourcesPath?: string | null; cwd?: string | null } = {},
): NodeJS.ProcessEnv {
cleanupLegacyBundledAdeSkillsForCli();
const next: NodeJS.ProcessEnv = { ...baseEnv };
const nextPath = augmentProcessPathWithShellAndKnownCliDirs({
env: next,
includeInteractiveShell: true,
timeoutMs: 1_000,
});
if (nextPath) setPathEnvValue(next, nextPath);
const cliEntry = resolveCurrentAdeCliEntry();
const cliEntry = options.cliEntry === undefined ? resolveCurrentAdeCliEntry() : options.cliEntry;
if (cliEntry) {
const shim = ensureAdeCliShim(cliEntry);
if (shim) {
Expand All @@ -492,14 +550,20 @@ function createHeadlessAdeCliAgentEnv(baseEnv: NodeJS.ProcessEnv = process.env):
delete next.ADE_CLI_ENTRY_PATH;
}
}
const inferredSkillRoots = inferAgentSkillsRootForCliEntry(cliEntry, options);
next[ADE_AGENT_SKILLS_DIRS_ENV] = prependAgentSkillsRoot(
next[ADE_AGENT_SKILLS_DIRS_ENV],
inferAgentSkillsRootForCliEntry(cliEntry),
inferredSkillRoots.catalogRoot,
);
next[ADE_AGENT_SKILLS_DIRS_ENV] = joinAdeAgentSkillRoots(getAdeAgentSkillRootsForPrompt({
env: next,
cwd: process.cwd(),
cwd: options.cwd ?? process.cwd(),
}));
if (inferredSkillRoots.bundledRoot) {
next[ADE_BUNDLED_AGENT_SKILLS_DIR_ENV] = inferredSkillRoots.bundledRoot;
} else {
delete next[ADE_BUNDLED_AGENT_SKILLS_DIR_ENV];
}
return next;
}

Expand Down
4 changes: 2 additions & 2 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ import {
} from "./sessionSnoozeDuration";
import { snoozeWakeLabel } from "../../desktop/src/renderer/lib/sessionSnooze";
import type { AdeRuntime } from "./bootstrap";
import { reseedBundledAdeSkillsForCli } from "./bootstrap";
import { cleanupLegacyBundledAdeSkillsForCli } from "./bootstrap";
import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore";
import type { AccountMachinePublisherService } from "./services/account/accountMachinePublisherService";
import type { SyncHostSingletonLease } from "./services/sync/syncHostSingleton";
Expand Down Expand Up @@ -20251,7 +20251,7 @@ async function runCli(
(plan.kind === "execute" &&
/^(agent spawn|chat create|personal chat create|new chat|shell start cli)\b/.test(plan.label))
) {
reseedBundledAdeSkillsForCli();
cleanupLegacyBundledAdeSkillsForCli();
}
const originalConsole = {
log: console.log,
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,8 @@
"from": "resources/agent-skills",
"to": "agent-skills",
"filter": [
"**/*"
"**/*",
".claude-plugin/**/*"
]
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "ade",
"description": "ADE's bundled agent capabilities",
"version": "1.0.0",
"author": {
"name": "ADE"
},
"skills": "./"
}
Loading