diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index bb6f7f8f9b..0b0d57cc11 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -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; const originalPlatform = process.platform; @@ -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", @@ -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"); diff --git a/apps/ade-cli/src/bootstrap.test.ts b/apps/ade-cli/src/bootstrap.test.ts index 108d5103fa..d03216d2ec 100644 --- a/apps/ade-cli/src/bootstrap.test.ts +++ b/apps/ade-cli/src/bootstrap.test.ts @@ -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 () => { diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index cf7f813be7..5febf3db33 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -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, @@ -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, @@ -433,45 +434,102 @@ 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, @@ -479,7 +537,7 @@ function createHeadlessAdeCliAgentEnv(baseEnv: NodeJS.ProcessEnv = process.env): 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) { @@ -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; } diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 461dfce48d..3baae0f74a 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -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"; @@ -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, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 40644d1ec8..d614daabd6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -279,7 +279,8 @@ "from": "resources/agent-skills", "to": "agent-skills", "filter": [ - "**/*" + "**/*", + ".claude-plugin/**/*" ] }, { diff --git a/apps/desktop/resources/agent-skills/.claude-plugin/plugin.json b/apps/desktop/resources/agent-skills/.claude-plugin/plugin.json new file mode 100644 index 0000000000..d2ff6201f7 --- /dev/null +++ b/apps/desktop/resources/agent-skills/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "ade", + "description": "ADE's bundled agent capabilities", + "version": "1.0.0", + "author": { + "name": "ADE" + }, + "skills": "./" +} diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index c8d14f137a..e0f82593dd 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -3302,6 +3302,18 @@ describe("createAgentChatService", () => { }); it("keeps Claude SDK setting sources and skills enabled without output-style plugins", async () => { + const bundledSkillRoot = path.join(tmpRoot, "bundled-agent-skills"); + const repositorySkillRoot = path.join(tmpRoot, "lane-repository-agent-skills"); + fs.mkdirSync(path.join(bundledSkillRoot, ".claude-plugin"), { recursive: true }); + fs.mkdirSync(path.join(repositorySkillRoot, ".claude-plugin"), { recursive: true }); + fs.writeFileSync( + path.join(bundledSkillRoot, ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "ade", skills: "." }), + ); + fs.writeFileSync( + path.join(repositorySkillRoot, ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "shadowed-repository-plugin", hooks: "./hooks.json" }), + ); vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ send: vi.fn(), stream: vi.fn(async function* () { @@ -3311,7 +3323,13 @@ describe("createAgentChatService", () => { sessionId: "sdk-session-skills", } as any); - const { service } = createService(); + const { service } = createService({ + getAdeCliAgentEnv: () => ({ + ...process.env, + ADE_AGENT_SKILLS_DIRS: [repositorySkillRoot, bundledSkillRoot].join(path.delimiter), + ADE_BUNDLED_AGENT_SKILLS_DIR: bundledSkillRoot, + }), + }); await service.createSession({ laneId: "lane-1", provider: "claude", @@ -3332,9 +3350,16 @@ describe("createAgentChatService", () => { fastMode?: boolean; }; skills?: string; + plugins?: Array<{ type?: string; path?: string }>; } | undefined; expect(opts?.settingSources).toEqual(expect.arrayContaining(["user", "project"])); expect(opts?.skills).toBe("all"); + expect(opts?.plugins).toEqual(expect.arrayContaining([ + { type: "local", path: fs.realpathSync(bundledSkillRoot) }, + ])); + expect(opts?.plugins).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ path: repositorySkillRoot }), + ])); expect(opts?.includeHookEvents).toBe(true); expect(opts?.promptSuggestions).toBe(true); expect(opts?.settings).toEqual(expect.objectContaining({ @@ -6873,8 +6898,15 @@ describe("createAgentChatService", () => { it("starts Codex sessions without ADE-owned tool server injection", async () => { const laneRootPath = path.join(tmpRoot, "lane-2"); fs.mkdirSync(laneRootPath, { recursive: true }); + const bundledSkillRoot = path.join(tmpRoot, "codex-agent-skills"); + fs.mkdirSync(bundledSkillRoot, { recursive: true }); - const { service } = createService(); + const { service } = createService({ + getAdeCliAgentEnv: () => ({ + ...process.env, + ADE_AGENT_SKILLS_DIRS: bundledSkillRoot, + }), + }); const session = await service.createSession({ laneId: "lane-2", provider: "codex", @@ -6890,6 +6922,22 @@ describe("createAgentChatService", () => { expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/start")).toBe(true); }); + expect(mockState.codexRequestPayloads).toEqual(expect.arrayContaining([ + expect.objectContaining({ + method: "skills/extraRoots/set", + params: { extraRoots: [bundledSkillRoot] }, + }), + expect.objectContaining({ + method: "skills/list", + params: expect.objectContaining({ + cwds: [expect.stringContaining("lane-2")], + perCwdExtraUserRoots: [{ + cwd: expect.stringContaining("lane-2"), + extraUserRoots: [bundledSkillRoot], + }], + }), + }), + ])); const startPayload = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start"); expect(startPayload?.params).toMatchObject({ cwd: expect.stringContaining("lane-2"), @@ -6908,6 +6956,63 @@ describe("createAgentChatService", () => { expect(textInput).toContain("Inspect the repo and fix the lane launch bug."); }); + it("keeps ADE skill roots and commands out of personal Codex sessions", async () => { + const bundledSkillRoot = path.join(tmpRoot, "codex-agent-skills"); + fs.mkdirSync(bundledSkillRoot, { recursive: true }); + mockState.codexResponseOverrides.set("skills/list", (payload) => { + const params = payload.params as { cwds?: unknown } | undefined; + const cwd = Array.isArray(params?.cwds) ? params.cwds[0] : undefined; + return { + data: [{ + cwd, + skills: [ + { name: "ade-proof-artifacts", description: "Capture ADE proof." }, + { name: "personal-helper", description: "Help with personal tasks." }, + ], + }], + }; + }); + + const { service } = createService({ + getAdeCliAgentEnv: () => ({ + ...process.env, + ADE_AGENT_SKILLS_DIRS: bundledSkillRoot, + }), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + surface: "personal", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Help me organize my week.", + }); + + await vi.waitFor(() => { + expect(service.getSlashCommands({ sessionId: session.id })) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "/personal-helper" }), + ])); + }); + + expect(mockState.codexRequestPayloads.some((payload) => + payload.method === "skills/extraRoots/set" + )).toBe(false); + const skillsListPayload = mockState.codexRequestPayloads.find( + (payload) => payload.method === "skills/list", + ); + expect(skillsListPayload?.params).toEqual({ + cwds: [expect.any(String)], + forceReload: true, + }); + expect(JSON.stringify(skillsListPayload?.params)).not.toContain(bundledSkillRoot); + expect(service.getSlashCommands({ sessionId: session.id }) + .some((command) => /^\/ade(?:-|$)/i.test(command.name))).toBe(false); + }); + it("adds dynamic orchestration tools to Codex orchestrator threads", async () => { const { orchestrationService, created } = await createLoadedOrchestrationRun("S-lead"); try { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 2109055bf6..8ea14232cb 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -435,6 +435,14 @@ import { } from "../../../shared/adeCard"; import { buildAdeCliAgentGuidance } from "../../../shared/adeCliGuidance"; import { getAdeAgentSkillRootsForPrompt } from "../../../shared/agentSkillRoots"; +import { + agentSkillSlashCommands, + claudeAgentSkillPluginRoots, + codexSkillsForCwd, + codexSkillsListParams, + existingAgentSkillRoots, + type CodexSkillsListResponse, +} from "../skills/agentSkillRuntimeService"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { isBackgroundShellCommand, @@ -1176,6 +1184,7 @@ type PendingClaudeApproval = { type CodexRuntime = { kind: "codex"; + agentSkillRoots: string[]; serverVersion: CodexServerVersion | null; process: ChildProcessWithoutNullStreams; reader: readline.Interface; @@ -1588,6 +1597,10 @@ function isVisibleCodexSlashCommand(command: { name: string }): boolean { return slashCommandKey(command.name) !== "/mcp"; } +function isAdeBundledSkillSlashCommand(command: { name: string }): boolean { + return /^\/ade(?:-|$)/i.test(command.name.trim()); +} + type PendingOpenCodeApproval = { category: "bash" | "write"; permissionId: string; @@ -26140,6 +26153,7 @@ export function createAgentChatService(args: { const runtime: CodexRuntime = { kind: "codex", + agentSkillRoots: isPersonalSession(managed.session) ? [] : existingAgentSkillRoots(spawnEnv), serverVersion: null, process: proc, reader, @@ -26415,6 +26429,11 @@ export function createAgentChatService(args: { ]).then(() => undefined); runtime.notify("initialized"); + const bundledSkillRoots = runtime.agentSkillRoots; + if (bundledSkillRoots.length) { + await runtime.request("skills/extraRoots/set", { extraRoots: bundledSkillRoots }) + .catch(() => { /* older app-server versions use the prompt/CLI fallback */ }); + } runtime.acceptedSteersHydrationReady = hydrateAcceptedCodexSteers( managed, runtime, @@ -26451,6 +26470,24 @@ export function createAgentChatService(args: { mode: "recover_from_history" | "explicit_reset"; }; + const refreshCodexSkills = ( + managed: ManagedChatSession, + runtime: CodexRuntime, + ): Promise => { + const extraUserRoots = runtime.agentSkillRoots; + return runtime.request( + "skills/list", + codexSkillsListParams(managed.laneWorktreePath, extraUserRoots), + ).then((response) => { + const commands = agentSkillSlashCommands( + codexSkillsForCwd(response, managed.laneWorktreePath), + ); + runtime.slashCommands = isPersonalSession(managed.session) + ? commands.filter((command) => !isAdeBundledSkillSlashCommand(command)) + : commands; + }).catch(() => { /* skills/list not supported — prompt/CLI fallback remains available */ }); + }; + const resolveCodexThreadParams = (managed: ManagedChatSession): { codexPolicy: CodexPolicy; } => { @@ -26561,15 +26598,7 @@ export function createAgentChatService(args: { await seedCodexThreadGoalFromSessionGoal(managed, runtime); // Fetch available skills and populate slash commands. - runtime.request<{ skills?: Array<{ name?: string; description?: string }> }>("skills/list", {}) - .then((res) => { - if (Array.isArray(res?.skills)) { - runtime.slashCommands = res.skills - .filter((s): s is { name: string; description?: string } => typeof s?.name === "string" && s.name.length > 0) - .map((s) => ({ name: s.name.startsWith("/") ? s.name : `/${s.name}`, description: s.description ?? "" })); - } - }) - .catch(() => { /* skills/list not supported — ignore */ }); + void refreshCodexSkills(managed, runtime); // Fetch initial rate limits. runtime.request<{ rateLimits?: unknown }>("account/rateLimits/read", {}) @@ -26813,7 +26842,10 @@ export function createAgentChatService(args: { }; const claudeExecutable = resolveClaudeCodeExecutable({ env: claudeEnv }); const outputStyle = resolveManagedClaudeOutputStyle(managed); - const pluginPaths = personalSession ? [] : discoverClaudePluginPaths(managed.laneWorktreePath); + const bundledPluginPaths = claudeAgentSkillPluginRoots(claudeEnv); + const pluginPaths = personalSession + ? [] + : [...new Set([...bundledPluginPaths, ...discoverClaudePluginPaths(managed.laneWorktreePath)])]; const claudeDescriptor = resolveSessionModelDescriptor(managed.session); const opts: ClaudeSDKOptions = { cwd: managed.laneWorktreePath, @@ -34637,15 +34669,7 @@ export function createAgentChatService(args: { persistChatState(managed); // Fetch skills after resume if not already fetched if (runtime.slashCommands.length === 0) { - runtime.request<{ skills?: Array<{ name?: string; description?: string }> }>("skills/list", {}) - .then((res) => { - if (Array.isArray(res?.skills)) { - runtime.slashCommands = res.skills - .filter((s): s is { name: string; description?: string } => typeof s?.name === "string" && s.name.length > 0) - .map((s) => ({ name: s.name.startsWith("/") ? s.name : `/${s.name}`, description: s.description ?? "" })); - } - }) - .catch(() => { /* skills/list not supported — ignore */ }); + void refreshCodexSkills(managed, runtime); runtime.request<{ rateLimits?: unknown }>("account/rateLimits/read", {}) .then((res) => { const rateLimits = normalizeCodexRateLimits(res?.rateLimits); @@ -36308,15 +36332,7 @@ export function createAgentChatService(args: { persistChatState(managed); // Fetch skills after resume if not already fetched if (runtime.slashCommands.length === 0) { - runtime.request<{ skills?: Array<{ name?: string; description?: string }> }>("skills/list", {}) - .then((res) => { - if (Array.isArray(res?.skills)) { - runtime.slashCommands = res.skills - .filter((s): s is { name: string; description?: string } => typeof s?.name === "string" && s.name.length > 0) - .map((s) => ({ name: s.name.startsWith("/") ? s.name : `/${s.name}`, description: s.description ?? "" })); - } - }) - .catch(() => { /* skills/list not supported — ignore */ }); + void refreshCodexSkills(managed, runtime); runtime.request<{ rateLimits?: unknown }>("account/rateLimits/read", {}) .then((res) => { const rateLimits = normalizeCodexRateLimits(res?.rateLimits); @@ -40238,13 +40254,20 @@ export function createAgentChatService(args: { const rt = managed?.runtime?.kind === "codex" ? managed.runtime : null; const dynamicCommands: AgentChatSlashCommand[] = (rt?.slashCommands ?? []) .filter(isVisibleCodexSlashCommand) + .filter((command) => + !managed + || !isPersonalSession(managed.session) + || !isAdeBundledSkillSlashCommand(command) + ) .map((cmd: { name: string; description: string; argumentHint?: string }) => ({ name: cmd.name, description: cmd.description, argumentHint: cmd.argumentHint, source: "sdk" as const, })); - const promptCommands = filesystemBackedCommands().filter(isVisibleCodexSlashCommand); + const promptCommands = managed && isPersonalSession(managed.session) + ? [] + : filesystemBackedCommands().filter(isVisibleCodexSlashCommand); return mergeSlashCommands([promptCommands, CODEX_BUILT_IN_SLASH_COMMANDS, dynamicCommands]); } diff --git a/apps/desktop/src/main/services/cli/adeCliService.test.ts b/apps/desktop/src/main/services/cli/adeCliService.test.ts index 8d962d6ce6..d3a2af88c6 100644 --- a/apps/desktop/src/main/services/cli/adeCliService.test.ts +++ b/apps/desktop/src/main/services/cli/adeCliService.test.ts @@ -3,7 +3,10 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createAdeCliService } from "./adeCliService"; -import { ADE_AGENT_SKILLS_DIRS_ENV } from "../../../shared/agentSkillRoots"; +import { + ADE_AGENT_SKILLS_DIRS_ENV, + ADE_BUNDLED_AGENT_SKILLS_DIR_ENV, +} from "../../../shared/agentSkillRoots"; const tmpRoots: string[] = []; const originalPlatform = process.platform; @@ -81,7 +84,56 @@ describe("createAdeCliService", () => { cliJsPath: path.join(resourcesPath, "ade-cli", "cli.cjs"), }); expect(service.agentEnv({ PATH: "/usr/bin:/bin" }).PATH?.split(path.delimiter)[0]).toBe(packagedBinDir); - expect(service.agentEnv({ PATH: "/usr/bin:/bin" })[ADE_AGENT_SKILLS_DIRS_ENV]).toBe(path.join(resourcesPath, "agent-skills")); + expect(service.agentEnv({ PATH: "/usr/bin:/bin" })[ADE_AGENT_SKILLS_DIRS_ENV]) + .toBe(fs.realpathSync(path.join(resourcesPath, "agent-skills"))); + expect(service.agentEnv({ PATH: "/usr/bin:/bin" })[ADE_BUNDLED_AGENT_SKILLS_DIR_ENV]) + .toBe(fs.realpathSync(path.join(resourcesPath, "agent-skills"))); + }); + + it("marks only the canonical ADE source bundle as trusted in development", () => { + const root = makeTempRoot(); + const sourceCli = path.join(root, "apps", "ade-cli", "src", "cli.ts"); + const sourceSkills = path.join(root, "apps", "desktop", "resources", "agent-skills"); + fs.mkdirSync(path.dirname(sourceCli), { recursive: true }); + fs.writeFileSync(sourceCli, "export {};\n"); + fs.mkdirSync(sourceSkills, { recursive: true }); + + const service = createAdeCliService({ + isPackaged: false, + resourcesPath: null, + devRepoRoot: root, + userDataPath: path.join(root, "user-data"), + appExecutablePath: path.join(root, "Electron"), + logger: logger() as any, + }); + + const env = service.agentEnv({ + ADE_AGENT_SKILLS_DIRS: path.join(root, "untrusted-repository-skills"), + }); + expect(env[ADE_BUNDLED_AGENT_SKILLS_DIR_ENV]).toBe(fs.realpathSync(sourceSkills)); + expect(env[ADE_AGENT_SKILLS_DIRS_ENV]?.split(path.delimiter)[0]).toBe(fs.realpathSync(sourceSkills)); + }); + + it("rejects a packaged agent-skills symlink that escapes the app resources boundary", () => { + const root = makeTempRoot(); + const resourcesPath = path.join(root, "Resources"); + const externalSkills = path.join(root, "repository-controlled-skills"); + fs.mkdirSync(resourcesPath, { recursive: true }); + fs.mkdirSync(externalSkills, { recursive: true }); + fs.symlinkSync(externalSkills, path.join(resourcesPath, "agent-skills"), "dir"); + + const service = createAdeCliService({ + isPackaged: true, + resourcesPath, + userDataPath: path.join(root, "user-data"), + appExecutablePath: path.join(root, "ADE.app", "Contents", "MacOS", "ADE"), + logger: logger() as any, + }); + + const env = service.agentEnv({ + ADE_BUNDLED_AGENT_SKILLS_DIR: externalSkills, + }); + expect(env[ADE_BUNDLED_AGENT_SKILLS_DIR_ENV]).toBeUndefined(); }); it("uses channel-specific packaged CLI commands and install targets", async () => { diff --git a/apps/desktop/src/main/services/cli/adeCliService.ts b/apps/desktop/src/main/services/cli/adeCliService.ts index 328e950f3c..1e00c36b9a 100644 --- a/apps/desktop/src/main/services/cli/adeCliService.ts +++ b/apps/desktop/src/main/services/cli/adeCliService.ts @@ -2,8 +2,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { AdeCliInstallResult, AdeCliStatus } from "../../../shared/types/adeCli"; -import { ADE_AGENT_SKILLS_DIRS_ENV, joinAdeAgentSkillRoots, splitAdeAgentSkillRoots } from "../../../shared/agentSkillRoots"; -import { reseedAdeSkills } from "../skills/skillReseedService"; +import { + ADE_AGENT_SKILLS_DIRS_ENV, + ADE_BUNDLED_AGENT_SKILLS_DIR_ENV, + joinAdeAgentSkillRoots, + splitAdeAgentSkillRoots, +} from "../../../shared/agentSkillRoots"; +import { cleanupLegacyAdeSkills } from "../skills/legacySkillCleanupService"; import type { Logger } from "../logging/logger"; import { spawnAsync } from "../shared/utils"; import { @@ -37,6 +42,7 @@ type DevCliEntry = { const PATH_DELIMITER = path.delimiter; const VALID_COMMAND_NAME = /^ade(?:-[a-z0-9][a-z0-9-]*)?$/; +let legacySkillCleanupScheduled = false; function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'`; @@ -81,11 +87,44 @@ function prependAgentSkillsRoot(existing: string | undefined, root: string | nul return joinAdeAgentSkillRoots([root, ...splitAdeAgentSkillRoots(existing)]); } +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; + } +} + function resolveBundledAgentSkillsRoot(args: CreateAdeCliServiceArgs): string | null { - const packagedRoot = args.resourcesPath ? path.join(args.resourcesPath, "agent-skills") : null; - if (pathExistsDirectory(packagedRoot)) return packagedRoot; - const devRoot = args.devRepoRoot ? path.join(args.devRepoRoot, "apps", "desktop", "resources", "agent-skills") : null; - return pathExistsDirectory(devRoot) ? devRoot : null; + if (args.isPackaged && args.resourcesPath) { + const packagedRoot = canonicalDirectoryWithin( + path.join(args.resourcesPath, "agent-skills"), + args.resourcesPath, + ); + if (packagedRoot) return packagedRoot; + } + + if (args.isPackaged) return null; + + const devRepoCandidates = [ + args.devRepoRoot ? path.resolve(args.devRepoRoot) : null, + typeof __dirname === "string" ? findRepoRoot(__dirname) : null, + ]; + for (const repoRoot of devRepoCandidates) { + if (!repoRoot) continue; + const canonicalRoot = canonicalDirectoryWithin( + path.join(repoRoot, "apps", "desktop", "resources", "agent-skills"), + repoRoot, + ); + if (canonicalRoot) return canonicalRoot; + } + return null; } function normalizePackageChannel(value: unknown): "alpha" | "beta" | null { @@ -541,26 +580,30 @@ export function createAdeCliService(args: CreateAdeCliServiceArgs) { const commandName = resolveCommandName(args); const resolved = resolveCliPaths(args, commandName); const bundledAgentSkillsRoot = resolveBundledAgentSkillsRoot(args); - // Seed ADE's bundled skills into the home-level dirs every runtime discovers, so - // desktop-launched agents pick them up via the runtime's own progressive disclosure. + // Migrate away from the old user-global copies. New sessions receive the + // bundled root directly; unrelated harnesses should not see ADE capabilities. if ( bundledAgentSkillsRoot - && process.env.ADE_DISABLE_SKILL_RESEED !== "1" + && process.env.ADE_DISABLE_SKILL_CLEANUP !== "1" && !process.env.VITEST + && !legacySkillCleanupScheduled ) { - try { - reseedAdeSkills({ - bundledRoot: bundledAgentSkillsRoot, - version: process.env.npm_package_version, - }); - } catch (error) { - // best-effort: skill re-seeding must never block desktop startup, but - // surface the failure so it can be debugged. - args.logger.warn("ade_cli.skill_reseed_failed", { - bundledRoot: bundledAgentSkillsRoot, - error: error instanceof Error ? error.message : String(error), - }); - } + legacySkillCleanupScheduled = true; + const cleanupTask = setImmediate(() => { + try { + cleanupLegacyAdeSkills({ + bundledRoot: bundledAgentSkillsRoot, + }); + } catch (error) { + // Best-effort migration: cleanup runs outside the startup critical path + // and must never prevent ADE from launching. + args.logger.warn("ade_cli.legacy_skill_cleanup_failed", { + bundledRoot: bundledAgentSkillsRoot, + error: error instanceof Error ? error.message : String(error), + }); + } + }); + cleanupTask.unref?.(); } const envSnapshot = args.env ?? process.env; const hostPathSnapshot = getPathEnvValue(envSnapshot); @@ -572,6 +615,11 @@ export function createAdeCliService(args: CreateAdeCliServiceArgs) { if (resolved.commandPath) next.ADE_CLI_PATH = resolved.commandPath; if (resolved.binDir) next.ADE_CLI_BIN_DIR = resolved.binDir; next[ADE_AGENT_SKILLS_DIRS_ENV] = prependAgentSkillsRoot(next[ADE_AGENT_SKILLS_DIRS_ENV], bundledAgentSkillsRoot); + if (bundledAgentSkillsRoot) { + next[ADE_BUNDLED_AGENT_SKILLS_DIR_ENV] = bundledAgentSkillsRoot; + } else { + delete next[ADE_BUNDLED_AGENT_SKILLS_DIR_ENV]; + } return next; }; @@ -582,6 +630,11 @@ export function createAdeCliService(args: CreateAdeCliServiceArgs) { if (next.ADE_CLI_PATH) process.env.ADE_CLI_PATH = next.ADE_CLI_PATH; if (next.ADE_CLI_BIN_DIR) process.env.ADE_CLI_BIN_DIR = next.ADE_CLI_BIN_DIR; if (next[ADE_AGENT_SKILLS_DIRS_ENV]) process.env[ADE_AGENT_SKILLS_DIRS_ENV] = next[ADE_AGENT_SKILLS_DIRS_ENV]; + if (next[ADE_BUNDLED_AGENT_SKILLS_DIR_ENV]) { + process.env[ADE_BUNDLED_AGENT_SKILLS_DIR_ENV] = next[ADE_BUNDLED_AGENT_SKILLS_DIR_ENV]; + } else { + delete process.env[ADE_BUNDLED_AGENT_SKILLS_DIR_ENV]; + } }; const getStatus = async (): Promise => { diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 2050007668..5d68669a55 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -54,6 +54,7 @@ const mocks = vi.hoisted(() => { mtimeMs: stat?.mtimeMs ?? 0, mode: stat?.mode ?? 0o040755, isDirectory: () => stat?.isDirectory ?? true, + isFile: () => !(stat?.isDirectory ?? true), }; }), readdirSync: vi.fn((p: string) => dirEntries.get(p) ?? []), @@ -358,6 +359,7 @@ function createHarness(overrides: { diskPressureMonitor?: { canPerform: ReturnType; } | null; + getAdeCliAgentEnv?: (env?: NodeJS.ProcessEnv) => NodeJS.ProcessEnv; } = {}) { const mockPty = createMockPty(); const broadcastData = vi.fn(); @@ -493,6 +495,7 @@ function createHarness(overrides: { ...(overrides.processRegistry !== undefined ? { processRegistry: overrides.processRegistry as any } : {}), ...(overrides.aiIntegrationService ? { aiIntegrationService: overrides.aiIntegrationService as any } : {}), ...(overrides.diskPressureMonitor !== undefined ? { diskPressureMonitor: overrides.diskPressureMonitor as any } : {}), + ...(overrides.getAdeCliAgentEnv ? { getAdeCliAgentEnv: overrides.getAdeCliAgentEnv } : {}), logger: logger as any, broadcastData, broadcastExit, @@ -1304,6 +1307,100 @@ describe("ptyService", () => { expect(mockPty.write).toHaveBeenCalledWith("\x1b[200~ADE session guidance\nUser prompt:\nhello\x1b[201~\r"); }); + it("injects the validated bundled plugin into tracked Claude CLI launches", async () => { + const pluginRoot = "/Applications/ADE.app/Contents/Resources/agent-skills"; + const repositoryPluginRoot = "/tmp/lane/apps/desktop/resources/agent-skills"; + mocks.fileStats.set(path.join(pluginRoot, ".claude-plugin", "plugin.json"), { isDirectory: false }); + mocks.fileStats.set(path.join(repositoryPluginRoot, ".claude-plugin", "plugin.json"), { isDirectory: false }); + const { service, loadPty } = createHarness({ + getAdeCliAgentEnv: (env) => ({ + ...env, + ADE_AGENT_SKILLS_DIRS: [repositoryPluginRoot, pluginRoot].join(path.delimiter), + ADE_BUNDLED_AGENT_SKILLS_DIR: pluginRoot, + }), + }); + + await service.create({ + laneId: "lane-1", + title: "Claude CLI", + cols: 80, + rows: 24, + toolType: "claude", + command: "claude", + args: ["--plugin-dir=/tmp/custom-plugin", "--permission-mode", "default"], + startupCommand: "claude --plugin-dir=/tmp/custom-plugin --permission-mode default", + }); + + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + expect(ptyLib.spawn).toHaveBeenCalledWith( + "claude", + expect.arrayContaining(["--plugin-dir", pluginRoot]), + expect.any(Object), + ); + expect(ptyLib.spawn.mock.calls.at(-1)?.[1]).toEqual(expect.arrayContaining([ + "--plugin-dir=/tmp/custom-plugin", + ])); + expect(ptyLib.spawn.mock.calls.at(-1)?.[1]).not.toEqual(expect.arrayContaining([ + repositoryPluginRoot, + ])); + }); + + it("injects the bundled Claude plugin into env-prefixed shell fallback commands", async () => { + const pluginRoot = "/Applications/ADE Preview.app/Contents/Resources/agent-skills"; + mocks.fileStats.set(path.join(pluginRoot, ".claude-plugin", "plugin.json"), { isDirectory: false }); + const { service, mockPty, loadPty } = createHarness({ + getAdeCliAgentEnv: (env) => ({ + ...env, + ADE_AGENT_SKILLS_DIRS: pluginRoot, + ADE_BUNDLED_AGENT_SKILLS_DIR: pluginRoot, + }), + }); + const spawn = vi.fn((command: string) => { + if (command === "claude") throw new Error("ENOENT"); + return mockPty; + }); + loadPty.mockImplementationOnce(() => ({ spawn: spawn as any })); + + await service.create({ + laneId: "lane-1", + title: "Claude CLI", + cols: 80, + rows: 24, + toolType: "claude", + command: "claude", + args: ["--plugin-dir=/tmp/custom-plugin", "--permission-mode", "default"], + startupCommand: "ADE_RUN_ID='run 1' ADE_DEFAULT_ROLE=agent claude --plugin-dir=/tmp/custom-plugin --permission-mode default", + }); + + expect(mockPty.write).toHaveBeenCalledWith( + "ADE_RUN_ID='run 1' ADE_DEFAULT_ROLE=agent claude --plugin-dir \"/Applications/ADE Preview.app/Contents/Resources/agent-skills\" --plugin-dir=/tmp/custom-plugin --permission-mode default\r", + ); + }); + + it("does not duplicate the bundled Claude plugin in env-prefixed startup commands", async () => { + const pluginRoot = "/Applications/ADE Preview.app/Contents/Resources/agent-skills"; + mocks.fileStats.set(path.join(pluginRoot, ".claude-plugin", "plugin.json"), { isDirectory: false }); + const startupCommand = `ADE_RUN_ID=run-1 claude --plugin-dir "${pluginRoot}" --plugin-dir=/tmp/custom-plugin`; + const { service, mockPty } = createHarness({ + getAdeCliAgentEnv: (env) => ({ + ...env, + ADE_AGENT_SKILLS_DIRS: pluginRoot, + ADE_BUNDLED_AGENT_SKILLS_DIR: pluginRoot, + }), + }); + + await service.create({ + laneId: "lane-1", + title: "Claude CLI", + cols: 80, + rows: 24, + toolType: "claude", + startupCommand, + }); + + expect(mockPty.write).toHaveBeenCalledWith(`${startupCommand}\r`); + }); + it("waits for agent CLI readiness before sending initialInput", async () => { vi.useFakeTimers(); try { @@ -2461,8 +2558,16 @@ describe("ptyService", () => { }); it("backfills a targetless Claude resume command before launching the resumed PTY", async () => { + const pluginRoot = "/Applications/ADE.app/Contents/Resources/agent-skills"; + mocks.fileStats.set(path.join(pluginRoot, ".claude-plugin", "plugin.json"), { isDirectory: false }); (mocks.extractResumeCommandFromOutput as any).mockReturnValueOnce("claude --resume claude-session-123"); - const { service, sessionService, mockPty } = createHarness(); + const { service, sessionService, mockPty } = createHarness({ + getAdeCliAgentEnv: (env) => ({ + ...env, + ADE_AGENT_SKILLS_DIRS: pluginRoot, + ADE_BUNDLED_AGENT_SKILLS_DIR: pluginRoot, + }), + }); sessionService.create({ sessionId: "session-claude-picker", laneId: "lane-1", @@ -2501,7 +2606,9 @@ describe("ptyService", () => { "session-claude-picker", "claude --resume claude-session-123", ); - expect(mockPty.write).toHaveBeenCalledWith("claude --resume claude-session-123\r"); + expect(mockPty.write).toHaveBeenCalledWith( + `claude --plugin-dir "${pluginRoot}" --resume claude-session-123\r`, + ); }); it("backfills a missing Codex storage target before launching the resumed PTY", async () => { diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index e541840a58..ffd3af6f91 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -72,6 +72,8 @@ import { trackedCliTitleFromPromptSeed, withCodexNoAltScreen, } from "../../../shared/cliLaunch"; +import { commandArrayToLine, parseCommandLine } from "../../../shared/shell"; +import { claudeAgentSkillPluginRoots } from "../skills/agentSkillRuntimeService"; import { stripAnsi } from "../../utils/ansiStrip"; import { summarizeTerminalSession } from "../../utils/sessionSummary"; import { derivePreviewFromChunk } from "../../utils/terminalPreview"; @@ -1064,6 +1066,87 @@ function isClaudeTrackedCliToolType(toolType: TerminalToolType | null | undefine return toolType === "claude" || toolType === "claude-orchestrated"; } +function hasClaudePluginRoot(args: string[], pluginRoot: string): boolean { + return args.some((arg, index) => + (arg === "--plugin-dir" && args[index + 1] === pluginRoot) + || arg === `--plugin-dir=${pluginRoot}`, + ); +} + +function shellWordSpans(command: string): Array<{ start: number; end: number }> { + const spans: Array<{ start: number; end: number }> = []; + let index = 0; + while (index < command.length) { + while (index < command.length && /\s/.test(command[index]!)) index += 1; + if (index >= command.length) break; + + const start = index; + let quote: "'" | "\"" | null = null; + let escaped = false; + while (index < command.length) { + const char = command[index]!; + if (escaped) { + escaped = false; + } else if (quote === "'") { + if (char === "'") quote = null; + } else if (quote === "\"") { + if (char === "\"") quote = null; + else if (char === "\\") escaped = true; + } else if (char === "\\") { + escaped = true; + } else if (char === "'" || char === "\"") { + quote = char; + } else if (/\s/.test(char)) { + break; + } + index += 1; + } + spans.push({ start, end: index }); + } + return spans; +} + +function withBundledClaudePlugin( + args: string[], + startupCommand: string, + toolType: TerminalToolType | null, + env: NodeJS.ProcessEnv, +): { args: string[]; startupCommand: string } { + if (!isClaudeTrackedCliToolType(toolType)) { + return { args, startupCommand }; + } + const pluginRoot = claudeAgentSkillPluginRoots(env)[0]; + if (!pluginRoot) return { args, startupCommand }; + + const normalizedArgs = hasClaudePluginRoot(args, pluginRoot) + ? args + : ["--plugin-dir", pluginRoot, ...args]; + let normalizedStartupCommand = startupCommand; + if (startupCommand?.trim()) { + let commandArgs: string[] = []; + try { + commandArgs = parseCommandLine(startupCommand); + } catch { + // Keep malformed or unsupported shell input intact. + } + const claudeIndex = commandArgs.findIndex((arg, index) => + arg === "claude" + && commandArgs.slice(0, index).every((prefix) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(prefix)), + ); + if (claudeIndex >= 0 && !hasClaudePluginRoot(commandArgs.slice(claudeIndex + 1), pluginRoot)) { + const claudeSpan = shellWordSpans(startupCommand)[claudeIndex]; + if (claudeSpan) { + const pluginArgs = commandArrayToLine(["--plugin-dir", pluginRoot]); + normalizedStartupCommand = `${startupCommand.slice(0, claudeSpan.end)} ${pluginArgs}${startupCommand.slice(claudeSpan.end)}`; + } + } + } + return { + args: normalizedArgs, + startupCommand: normalizedStartupCommand, + }; +} + function isPersistedChatToolType(toolType: TerminalToolType | null): boolean { return toolType === "codex-chat" || toolType === "claude-chat" @@ -4386,7 +4469,7 @@ export function createPtyService({ const requestedDirectCommand = typeof args.command === "string" ? args.command.trim() : ""; const directCommand = resolveDirectOpenCodeCommand(requestedDirectCommand, toolTypeHint); - const directArgs = Array.isArray(args.args) ? args.args.filter((value): value is string => typeof value === "string") : []; + let directArgs = Array.isArray(args.args) ? args.args.filter((value): value is string => typeof value === "string") : []; const laneRuntimeEnv = (await getLaneRuntimeEnv?.(laneId)) ?? {}; const sessionLinearEnv = getSessionLinearEnv?.({ sessionId, chatSessionId }) ?? {}; @@ -4449,6 +4532,14 @@ export function createPtyService({ startupCommand = withBundledOpenCodeCommandLine(initialResumeCommand, toolTypeHint); } } + const claudePluginLaunch = withBundledClaudePlugin( + directArgs, + startupCommand, + toolTypeHint, + launchEnv, + ); + directArgs = claudePluginLaunch.args; + startupCommand = claudePluginLaunch.startupCommand; launchEnv = withUserCodexCliPathPriority(launchEnv, { toolType: toolTypeHint, directCommand, diff --git a/apps/desktop/src/main/services/skills/agentSkillRuntimeService.test.ts b/apps/desktop/src/main/services/skills/agentSkillRuntimeService.test.ts new file mode 100644 index 0000000000..30d6c9cc50 --- /dev/null +++ b/apps/desktop/src/main/services/skills/agentSkillRuntimeService.test.ts @@ -0,0 +1,116 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + agentSkillSlashCommands, + claudeAgentSkillPluginRoots, + codexSkillsForCwd, + codexSkillsListParams, + existingAgentSkillRoots, +} from "./agentSkillRuntimeService"; + +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +function temporaryRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-runtime-skills-")); + temporaryRoots.push(root); + return root; +} + +describe("agentSkillRuntimeService", () => { + it("keeps only existing session roots and loads only the trusted Claude plugin root", () => { + const pluginRoot = temporaryRoot(); + const repositoryRoot = temporaryRoot(); + const standaloneRoot = temporaryRoot(); + fs.mkdirSync(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); + fs.writeFileSync(path.join(pluginRoot, ".claude-plugin", "plugin.json"), "{}"); + fs.mkdirSync(path.join(repositoryRoot, ".claude-plugin"), { recursive: true }); + fs.writeFileSync(path.join(repositoryRoot, ".claude-plugin", "plugin.json"), "{}"); + const missingRoot = path.join(pluginRoot, "missing"); + const env = { + ADE_AGENT_SKILLS_DIRS: [repositoryRoot, pluginRoot, standaloneRoot, missingRoot].join(path.delimiter), + ADE_BUNDLED_AGENT_SKILLS_DIR: pluginRoot, + }; + + expect(existingAgentSkillRoots(env)).toEqual([repositoryRoot, pluginRoot, standaloneRoot]); + expect(claudeAgentSkillPluginRoots(env)).toEqual([fs.realpathSync(pluginRoot)]); + }); + + it("fails closed when only an untrusted repository plugin manifest is present", () => { + const repositoryRoot = temporaryRoot(); + fs.mkdirSync(path.join(repositoryRoot, ".claude-plugin"), { recursive: true }); + fs.writeFileSync(path.join(repositoryRoot, ".claude-plugin", "plugin.json"), "{}"); + + expect(claudeAgentSkillPluginRoots({ + ADE_AGENT_SKILLS_DIRS: repositoryRoot, + })).toEqual([]); + }); + + it("canonicalizes trusted roots and rejects symlink escapes from the catalog", () => { + const pluginRoot = temporaryRoot(); + const catalogParent = temporaryRoot(); + const pluginAlias = path.join(catalogParent, "bundle-alias"); + fs.mkdirSync(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); + fs.writeFileSync(path.join(pluginRoot, ".claude-plugin", "plugin.json"), "{}"); + fs.symlinkSync(pluginRoot, pluginAlias, "dir"); + + expect(claudeAgentSkillPluginRoots({ + ADE_AGENT_SKILLS_DIRS: pluginAlias, + ADE_BUNDLED_AGENT_SKILLS_DIR: pluginRoot, + })).toEqual([fs.realpathSync(pluginRoot)]); + + expect(claudeAgentSkillPluginRoots({ + ADE_AGENT_SKILLS_DIRS: catalogParent, + ADE_BUNDLED_AGENT_SKILLS_DIR: pluginRoot, + })).toEqual([]); + }); + + it("builds cwd-scoped Codex discovery params without persisting roots", () => { + expect(codexSkillsListParams("/repo", ["/bundle"])).toEqual({ + cwds: ["/repo"], + forceReload: true, + perCwdExtraUserRoots: [{ cwd: "/repo", extraUserRoots: ["/bundle"] }], + }); + expect(codexSkillsListParams("/repo", [])).toEqual({ + cwds: ["/repo"], + forceReload: true, + }); + }); + + it("normalizes both current and legacy Codex skill-list response shapes", () => { + const current = codexSkillsForCwd({ + data: [ + { cwd: "/other", skills: [{ name: "other" }] }, + { cwd: "/repo", skills: [{ name: "ade-browser", description: "Browser" }] }, + ], + }, "/repo"); + const legacy = codexSkillsForCwd({ skills: [{ name: "ade-search" }] }, "/repo"); + + expect(agentSkillSlashCommands(current)).toEqual([ + { name: "/ade-browser", description: "Browser" }, + ]); + expect(agentSkillSlashCommands(legacy)).toEqual([ + { name: "/ade-search", description: "" }, + ]); + }); + + it("does not borrow Codex skills from another lane", () => { + expect(codexSkillsForCwd({ + data: [ + { cwd: "/lane-a", skills: [{ name: "lane-a-skill" }] }, + { cwd: "/lane-b", skills: [{ name: "lane-b-skill" }] }, + ], + }, "/lane-c")).toEqual([]); + + expect(codexSkillsForCwd({ + data: [{ skills: [{ name: "legacy-single-cwd" }] }], + }, "/lane-c")).toEqual([{ name: "legacy-single-cwd" }]); + }); +}); diff --git a/apps/desktop/src/main/services/skills/agentSkillRuntimeService.ts b/apps/desktop/src/main/services/skills/agentSkillRuntimeService.ts new file mode 100644 index 0000000000..4386b42c44 --- /dev/null +++ b/apps/desktop/src/main/services/skills/agentSkillRuntimeService.ts @@ -0,0 +1,89 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + ADE_AGENT_SKILLS_DIRS_ENV, + ADE_BUNDLED_AGENT_SKILLS_DIR_ENV, + splitAdeAgentSkillRoots, +} from "../../../shared/agentSkillRoots"; + +export type RuntimeAgentSkill = { + name?: string; + description?: string; +}; + +export type CodexSkillsListResponse = { + skills?: RuntimeAgentSkill[]; + data?: Array<{ cwd?: string; skills?: RuntimeAgentSkill[] }>; +}; + +export function existingAgentSkillRoots(env: NodeJS.ProcessEnv): string[] { + return splitAdeAgentSkillRoots(env[ADE_AGENT_SKILLS_DIRS_ENV]).filter((root) => { + try { + return fs.statSync(root).isDirectory(); + } catch { + return false; + } + }); +} + +export function claudeAgentSkillPluginRoots(env: NodeJS.ProcessEnv): string[] { + const trustedRoot = env[ADE_BUNDLED_AGENT_SKILLS_DIR_ENV]?.trim(); + if (!trustedRoot) return []; + + try { + const canonicalTrustedRoot = fs.realpathSync(trustedRoot); + if (!fs.statSync(canonicalTrustedRoot).isDirectory()) return []; + if (!fs.statSync(path.join(canonicalTrustedRoot, ".claude-plugin", "plugin.json")).isFile()) { + return []; + } + + const isCatalogRoot = existingAgentSkillRoots(env).some((root) => { + try { + return fs.realpathSync(root) === canonicalTrustedRoot; + } catch { + return false; + } + }); + return isCatalogRoot ? [canonicalTrustedRoot] : []; + } catch { + return []; + } +} + +export function codexSkillsListParams(cwd: string, extraUserRoots: readonly string[]) { + return { + cwds: [cwd], + forceReload: true, + ...(extraUserRoots.length + ? { perCwdExtraUserRoots: [{ cwd, extraUserRoots }] } + : {}), + }; +} + +export function codexSkillsForCwd( + response: CodexSkillsListResponse, + cwd: string, +): RuntimeAgentSkill[] { + if (!Array.isArray(response.data)) { + return Array.isArray(response.skills) ? response.skills : []; + } + const matchingEntry = response.data.find((entry) => entry.cwd === cwd); + const legacySingleEntry = response.data.length === 1 && response.data[0]?.cwd == null + ? response.data[0] + : undefined; + const skills = matchingEntry?.skills ?? legacySingleEntry?.skills; + return Array.isArray(skills) ? skills : []; +} + +export function agentSkillSlashCommands( + skills: readonly RuntimeAgentSkill[], +): Array<{ name: string; description: string }> { + return skills + .filter((skill): skill is { name: string; description?: string } => + typeof skill?.name === "string" && skill.name.length > 0 + ) + .map((skill) => ({ + name: skill.name.startsWith("/") ? skill.name : `/${skill.name}`, + description: skill.description ?? "", + })); +} diff --git a/apps/desktop/src/main/services/skills/legacySkillCleanupService.test.ts b/apps/desktop/src/main/services/skills/legacySkillCleanupService.test.ts new file mode 100644 index 0000000000..e575bb7cdf --- /dev/null +++ b/apps/desktop/src/main/services/skills/legacySkillCleanupService.test.ts @@ -0,0 +1,237 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanupLegacyAdeSkills } from "./legacySkillCleanupService"; + +function writeSkill(root: string, name: string, body: string): void { + const dir = path.join(root, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "SKILL.md"), body); +} + +function writeLegacyManifest(target: string, names: string[], hash = "legacy-hash"): void { + fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync(path.join(target, ".ade-skills.json"), JSON.stringify({ version: "1", hash, names })); +} + +function legacyManifestHash(name: string, body: string): string { + return crypto.createHash("sha256") + .update(`\0skill:${name}\0`) + .update("SKILL.md") + .update("\0") + .update(body) + .digest("hex"); +} + +describe("cleanupLegacyAdeSkills", () => { + let tmp: string; + let bundled: string; + let target: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ade-skill-cleanup-")); + bundled = path.join(tmp, "bundled"); + target = path.join(tmp, "home", ".claude", "skills"); + fs.mkdirSync(bundled, { recursive: true }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("removes an ADE-recorded global copy that still matches the bundle", () => { + writeSkill(bundled, "ade-browser", "# browser"); + writeSkill(target, "ade-browser", "# browser"); + writeLegacyManifest(target, ["ade-browser"]); + + const result = cleanupLegacyAdeSkills({ bundledRoot: bundled, targetDirs: [target] }); + + expect(result.skillsRemoved).toEqual([path.join(target, "ade-browser")]); + expect(result.skillsPreserved).toEqual([]); + expect(fs.existsSync(path.join(target, "ade-browser"))).toBe(false); + expect(fs.existsSync(path.join(target, ".ade-skills.json"))).toBe(false); + }); + + it("preserves a user-modified ADE copy while retiring the legacy manifest", () => { + writeSkill(bundled, "ade-browser", "# current bundle"); + writeSkill(target, "ade-browser", "# user modified"); + writeLegacyManifest(target, ["ade-browser"]); + + const result = cleanupLegacyAdeSkills({ bundledRoot: bundled, targetDirs: [target] }); + + expect(result.skillsRemoved).toEqual([]); + expect(result.skillsPreserved).toEqual([path.join(target, "ade-browser")]); + expect(fs.readFileSync(path.join(target, "ade-browser", "SKILL.md"), "utf8")).toBe("# user modified"); + expect(fs.existsSync(path.join(target, ".ade-skills.json"))).toBe(false); + }); + + it("removes an unchanged legacy copy after the bundled version has advanced", () => { + writeSkill(bundled, "ade-browser", "# current bundle"); + writeSkill(target, "ade-browser", "# prior bundle"); + writeLegacyManifest( + target, + ["ade-browser"], + legacyManifestHash("ade-browser", "# prior bundle"), + ); + + const result = cleanupLegacyAdeSkills({ bundledRoot: bundled, targetDirs: [target] }); + + expect(result.skillsRemoved).toEqual([path.join(target, "ade-browser")]); + expect(result.skillsPreserved).toEqual([]); + expect(fs.existsSync(path.join(target, "ade-browser"))).toBe(false); + expect(fs.existsSync(path.join(target, ".ade-skills.json"))).toBe(false); + }); + + it("ignores directories without a valid ADE manifest", () => { + writeSkill(target, "ade-browser", "# user owned"); + fs.writeFileSync(path.join(target, ".ade-skills.json"), JSON.stringify({ + hash: "legacy", + names: ["not-ade"], + })); + + const result = cleanupLegacyAdeSkills({ bundledRoot: bundled, targetDirs: [target] }); + + expect(result.targetsCleaned).toEqual([]); + expect(fs.existsSync(path.join(target, "ade-browser", "SKILL.md"))).toBe(true); + expect(fs.existsSync(path.join(target, ".ade-skills.json"))).toBe(true); + }); + + it("rejects manifest names that could escape the provider skill directory", () => { + writeLegacyManifest(target, ["ade-../../outside"]); + const outside = path.join(tmp, "home", "outside"); + fs.mkdirSync(outside, { recursive: true }); + + const result = cleanupLegacyAdeSkills({ bundledRoot: bundled, targetDirs: [target] }); + + expect(result.targetsCleaned).toEqual([]); + expect(fs.existsSync(outside)).toBe(true); + expect(fs.existsSync(path.join(target, ".ade-skills.json"))).toBe(true); + }); + + it("cleans each legacy provider directory independently", () => { + writeSkill(bundled, "ade-browser", "# browser"); + const target2 = path.join(tmp, "home", ".agents", "skills"); + for (const dir of [target, target2]) { + writeSkill(dir, "ade-browser", "# browser"); + writeLegacyManifest(dir, ["ade-browser"]); + } + + const result = cleanupLegacyAdeSkills({ + bundledRoot: bundled, + targetDirs: [target, target2], + }); + + expect(result.targetsCleaned).toEqual([target, target2]); + expect(fs.existsSync(path.join(target, "ade-browser"))).toBe(false); + expect(fs.existsSync(path.join(target2, "ade-browser"))).toBe(false); + }); + + it("preserves a skill whose removal fails and continues cleaning later skills and providers", () => { + writeSkill(bundled, "ade-app-control", "# app"); + writeSkill(bundled, "ade-browser", "# browser"); + const target2 = path.join(tmp, "home", ".agents", "skills"); + for (const dir of [target, target2]) { + writeSkill(dir, "ade-app-control", "# app"); + writeSkill(dir, "ade-browser", "# browser"); + writeLegacyManifest(dir, ["ade-app-control", "ade-browser"]); + } + const failedSkill = path.join(target, "ade-app-control"); + const originalRmSync = fs.rmSync.bind(fs); + vi.spyOn(fs, "rmSync").mockImplementation((pathToRemove, options) => { + if (pathToRemove === failedSkill) throw new Error("permission denied"); + originalRmSync(pathToRemove, options); + }); + + const result = cleanupLegacyAdeSkills({ + bundledRoot: bundled, + targetDirs: [target, target2], + }); + + expect(result.skillsPreserved).toEqual([failedSkill]); + expect(result.skillsRemoved).toEqual([ + path.join(target, "ade-browser"), + path.join(target2, "ade-app-control"), + path.join(target2, "ade-browser"), + ]); + expect(result.targetsCleaned).toEqual([target2]); + expect(fs.existsSync(failedSkill)).toBe(true); + expect(fs.existsSync(path.join(target, ".ade-skills.json"))).toBe(true); + expect(fs.existsSync(path.join(target, "ade-browser"))).toBe(false); + expect(fs.existsSync(path.join(target2, "ade-app-control"))).toBe(false); + }); + + it("leaves a failed manifest uncleaned and continues cleaning later providers", () => { + writeSkill(bundled, "ade-browser", "# browser"); + const target2 = path.join(tmp, "home", ".agents", "skills"); + for (const dir of [target, target2]) { + writeSkill(dir, "ade-browser", "# browser"); + writeLegacyManifest(dir, ["ade-browser"]); + } + const failedManifest = path.join(target, ".ade-skills.json"); + const originalRmSync = fs.rmSync.bind(fs); + vi.spyOn(fs, "rmSync").mockImplementation((pathToRemove, options) => { + if (pathToRemove === failedManifest) throw new Error("permission denied"); + originalRmSync(pathToRemove, options); + }); + + const result = cleanupLegacyAdeSkills({ + bundledRoot: bundled, + targetDirs: [target, target2], + }); + + expect(result.skillsRemoved).toEqual([ + path.join(target, "ade-browser"), + path.join(target2, "ade-browser"), + ]); + expect(result.targetsCleaned).toEqual([target2]); + expect(fs.existsSync(failedManifest)).toBe(true); + expect(fs.existsSync(path.join(target2, ".ade-skills.json"))).toBe(false); + }); + + it.each([ + ["empty directory", (skillDir: string) => fs.mkdirSync(skillDir, { recursive: true })], + ["symbolic link", (skillDir: string) => { + const userOwned = path.join(tmp, "user-owned"); + fs.mkdirSync(userOwned, { recursive: true }); + fs.writeFileSync(path.join(userOwned, "SKILL.md"), "# user owned"); + fs.mkdirSync(path.dirname(skillDir), { recursive: true }); + fs.symlinkSync(userOwned, skillDir, "dir"); + }], + ["unsupported filesystem entry", (skillDir: string) => { + fs.mkdirSync(skillDir, { recursive: true }); + execFileSync("mkfifo", [path.join(skillDir, "pipe")]); + }], + ])("preserves a recorded %s and retires only the manifest", (_label, arrange) => { + writeSkill(bundled, "ade-browser", "# browser"); + const installed = path.join(target, "ade-browser"); + arrange(installed); + writeLegacyManifest(target, ["ade-browser"]); + + const result = cleanupLegacyAdeSkills({ bundledRoot: bundled, targetDirs: [target] }); + + expect(result.skillsRemoved).toEqual([]); + expect(result.skillsPreserved).toEqual([installed]); + expect(fs.existsSync(installed)).toBe(true); + expect(fs.existsSync(path.join(target, ".ade-skills.json"))).toBe(false); + }); + + it("preserves an unreadable recorded directory", () => { + writeSkill(bundled, "ade-browser", "# browser"); + writeSkill(target, "ade-browser", "# browser"); + const installed = path.join(target, "ade-browser"); + writeLegacyManifest(target, ["ade-browser"]); + fs.chmodSync(installed, 0o000); + + const result = cleanupLegacyAdeSkills({ bundledRoot: bundled, targetDirs: [target] }); + + fs.chmodSync(installed, 0o700); + expect(result.skillsRemoved).toEqual([]); + expect(result.skillsPreserved).toEqual([installed]); + expect(fs.existsSync(installed)).toBe(true); + expect(fs.existsSync(path.join(target, ".ade-skills.json"))).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/services/skills/legacySkillCleanupService.ts b/apps/desktop/src/main/services/skills/legacySkillCleanupService.ts new file mode 100644 index 0000000000..2cb74b7084 --- /dev/null +++ b/apps/desktop/src/main/services/skills/legacySkillCleanupService.ts @@ -0,0 +1,244 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** + * ADE used to copy its bundled skills into every provider's user-global skill + * directory. That leaked ADE-only capabilities into unrelated harnesses. + * + * This service is a conservative migration: remove legacy + * copies only when their content is provably unchanged, then remove ADE's + * manifest. Modified or otherwise unverifiable directories are preserved. + */ + +const LEGACY_MANIFEST = ".ade-skills.json"; + +interface BundledSkill { + name: string; + dir: string; +} + +interface LegacyManifest { + hash: string; + names: string[]; +} + +type SkillTreeEntry = + | { kind: "directory"; relative: string } + | { kind: "file"; relative: string; contents: Buffer }; + +export interface LegacySkillCleanupResult { + targetsCleaned: string[]; + skillsRemoved: string[]; + skillsPreserved: string[]; +} + +export function defaultAdeSkillTargetDirs(home: string = os.homedir()): string[] { + return [ + path.join(home, ".claude", "skills"), + path.join(home, ".agents", "skills"), + path.join(home, ".cursor", "skills"), + path.join(home, ".factory", "skills"), + path.join(home, ".config", "opencode", "skills"), + ]; +} + +function isDirectory(target: string): boolean { + try { + return fs.statSync(target).isDirectory(); + } catch { + return false; + } +} + +function listBundledAdeSkills(bundledRoot: string): BundledSkill[] { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(bundledRoot, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((entry) => entry.isDirectory() && entry.name.startsWith("ade-")) + .map((entry) => ({ name: entry.name, dir: path.join(bundledRoot, entry.name) })) + .filter((entry) => { + try { + return fs.lstatSync(path.join(entry.dir, "SKILL.md")).isFile(); + } catch { + return false; + } + }) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +function readSkillTree(dir: string): SkillTreeEntry[] | null { + try { + const rootStat = fs.lstatSync(dir); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return null; + } catch { + return null; + } + + const tree: SkillTreeEntry[] = []; + const stack: Array<{ dir: string; relative: string }> = [{ dir, relative: "" }]; + while (stack.length) { + const current = stack.pop() as { dir: string; relative: string }; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(current.dir, { withFileTypes: true }) + .sort((a, b) => a.name.localeCompare(b.name)); + } catch { + return null; + } + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index] as fs.Dirent; + const relative = current.relative + ? `${current.relative}/${entry.name}` + : entry.name; + const full = path.join(current.dir, entry.name); + if (entry.isSymbolicLink()) return null; + if (entry.isDirectory()) { + tree.push({ kind: "directory", relative }); + stack.push({ dir: full, relative }); + continue; + } + if (!entry.isFile()) return null; + try { + tree.push({ kind: "file", relative, contents: fs.readFileSync(full) }); + } catch { + return null; + } + } + } + return tree.some((entry) => entry.kind === "file") ? tree : null; +} + +function hashSkills(skills: BundledSkill[]): string | null { + const hash = crypto.createHash("sha256"); + for (const skill of skills) { + const tree = readSkillTree(skill.dir); + if (!tree) return null; + hash.update(`skill\0${skill.name}\0`); + for (const entry of tree.sort((a, b) => a.relative.localeCompare(b.relative))) { + hash.update(`${entry.kind}\0${entry.relative}\0`); + if (entry.kind === "file") hash.update(entry.contents); + hash.update("\0"); + } + } + return hash.digest("hex"); +} + +function legacyManifestHash(skills: BundledSkill[]): string | null { + const hash = crypto.createHash("sha256"); + for (const skill of skills) { + const tree = readSkillTree(skill.dir); + if (!tree) return null; + hash.update(`\0skill:${skill.name}\0`); + for (const entry of tree + .filter((item): item is Extract => item.kind === "file") + .sort((a, b) => a.relative.localeCompare(b.relative))) { + hash.update(entry.relative); + hash.update("\0"); + hash.update(entry.contents); + } + } + return hash.digest("hex"); +} + +function readManifest(manifestPath: string): LegacyManifest | null { + try { + const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as LegacyManifest; + if ( + parsed + && typeof parsed.hash === "string" + && Array.isArray(parsed.names) + && parsed.names.every((name) => + typeof name === "string" && /^ade-[a-z0-9][a-z0-9-]*$/.test(name) + ) + ) { + return parsed; + } + } catch { + // A missing or malformed manifest is not authority to remove anything. + } + return null; +} + +function directoryMatches(left: string, right: string): boolean { + const leftHash = hashSkills([{ name: "skill", dir: left }]); + const rightHash = hashSkills([{ name: "skill", dir: right }]); + return leftHash != null && rightHash != null && leftHash === rightHash; +} + +function removePath(target: string, options: fs.RmOptions): boolean { + try { + fs.rmSync(target, options); + return true; + } catch { + // A failed cleanup must not prevent other recorded skills or providers from + // being considered. + return false; + } +} + +export function cleanupLegacyAdeSkills(opts: { + bundledRoot: string; + targetDirs?: string[]; +}): LegacySkillCleanupResult { + const result: LegacySkillCleanupResult = { + targetsCleaned: [], + skillsRemoved: [], + skillsPreserved: [], + }; + const bundledByName = new Map(listBundledAdeSkills(opts.bundledRoot).map((skill) => [skill.name, skill])); + + for (const target of opts.targetDirs ?? defaultAdeSkillTargetDirs()) { + const manifestPath = path.join(target, LEGACY_MANIFEST); + const manifest = readManifest(manifestPath); + if (!manifest) continue; + + const installed = manifest.names + .map((name) => ({ name, dir: path.join(target, name) })) + .filter((skill) => fs.existsSync(skill.dir)) + .sort((a, b) => a.name.localeCompare(b.name)); + const installedHash = legacyManifestHash(installed); + const wholeLegacySetUnchanged = + installed.length === manifest.names.length + && installedHash != null + && installedHash === manifest.hash; + let skillRemovalFailed = false; + + for (const installedSkill of installed) { + const bundled = bundledByName.get(installedSkill.name); + if (wholeLegacySetUnchanged || (bundled && directoryMatches(installedSkill.dir, bundled.dir))) { + if (removePath(installedSkill.dir, { recursive: true, force: true })) { + result.skillsRemoved.push(installedSkill.dir); + } else { + result.skillsPreserved.push(installedSkill.dir); + skillRemovalFailed = true; + } + } else { + result.skillsPreserved.push(installedSkill.dir); + } + } + + // Keep the manifest when an eligible copy could not be removed so a later + // run can retry it. Intentionally preserved user-modified copies do not + // block retiring the manifest. + if (skillRemovalFailed) continue; + + if (removePath(manifestPath, { force: true })) { + result.targetsCleaned.push(target); + } + } + + return result; +} + +export function resolveBundledAgentSkillsRoot(candidates: Array): string | null { + for (const candidate of candidates) { + if (candidate && isDirectory(candidate)) return candidate; + } + return null; +} diff --git a/apps/desktop/src/main/services/skills/skillReseedService.test.ts b/apps/desktop/src/main/services/skills/skillReseedService.test.ts deleted file mode 100644 index 0cb7334abc..0000000000 --- a/apps/desktop/src/main/services/skills/skillReseedService.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { reseedAdeSkills } from "./skillReseedService"; - -function writeSkill(root: string, name: string, body: string): void { - const dir = path.join(root, name); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, "SKILL.md"), body); -} - -describe("reseedAdeSkills", () => { - let tmp: string; - let bundled: string; - let target: string; - - beforeEach(() => { - tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ade-reseed-")); - bundled = path.join(tmp, "bundled"); - target = path.join(tmp, "home", ".claude", "skills"); - fs.mkdirSync(bundled, { recursive: true }); - }); - - afterEach(() => { - fs.rmSync(tmp, { recursive: true, force: true }); - }); - - it("copies only ade-* bundled skills into the target and writes a manifest", () => { - writeSkill(bundled, "ade-browser", "# browser"); - writeSkill(bundled, "ade-proof-artifacts", "# proof"); - writeSkill(bundled, "not-an-ade-skill", "# ignored"); // missing ade- prefix → skipped - - const result = reseedAdeSkills({ bundledRoot: bundled, targetDirs: [target], version: "1" }); - - expect(result.skillNames).toEqual(["ade-browser", "ade-proof-artifacts"]); - expect(result.targetsWritten).toEqual([target]); - expect(fs.existsSync(path.join(target, "ade-browser", "SKILL.md"))).toBe(true); - expect(fs.existsSync(path.join(target, "ade-proof-artifacts", "SKILL.md"))).toBe(true); - expect(fs.existsSync(path.join(target, "not-an-ade-skill"))).toBe(false); - expect(fs.existsSync(path.join(target, ".ade-skills.json"))).toBe(true); - }); - - it("is a no-op on the second run when nothing changed (self-healing, cheap)", () => { - writeSkill(bundled, "ade-browser", "# browser"); - reseedAdeSkills({ bundledRoot: bundled, targetDirs: [target], version: "1" }); - - const second = reseedAdeSkills({ bundledRoot: bundled, targetDirs: [target], version: "1" }); - expect(second.targetsWritten).toEqual([]); - expect(second.targetsUpToDate).toEqual([target]); - }); - - it("never clobbers or prunes a user's own (non-managed) skills", () => { - writeSkill(bundled, "ade-browser", "# browser"); - reseedAdeSkills({ bundledRoot: bundled, targetDirs: [target], version: "1" }); - writeSkill(target, "my-own-skill", "# mine"); // user-authored, not ADE-managed - - // bundle changes → re-seed runs again - writeSkill(bundled, "ade-linear", "# linear"); - reseedAdeSkills({ bundledRoot: bundled, targetDirs: [target], version: "1" }); - - expect(fs.existsSync(path.join(target, "my-own-skill", "SKILL.md"))).toBe(true); - expect(fs.existsSync(path.join(target, "ade-linear", "SKILL.md"))).toBe(true); - }); - - it("materializes real files when the bundle ships a skill as a symlink (dereference)", () => { - // Some bundle layouts (e.g. a plugin) ship skill dirs as symlinks. The seeded - // copy must be real files in the user's home, never a link back into the bundle. - const realSkill = path.join(tmp, "real", "ade-linked"); - fs.mkdirSync(realSkill, { recursive: true }); - fs.writeFileSync(path.join(realSkill, "SKILL.md"), "# linked"); - fs.symlinkSync(realSkill, path.join(bundled, "ade-linked"), "dir"); - - reseedAdeSkills({ bundledRoot: bundled, targetDirs: [target], version: "1" }); - - const dest = path.join(target, "ade-linked"); - expect(fs.lstatSync(dest).isSymbolicLink()).toBe(false); - expect(fs.lstatSync(path.join(dest, "SKILL.md")).isSymbolicLink()).toBe(false); - expect(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8")).toBe("# linked"); - }); - - it("seeds every target dir independently when given multiple targets", () => { - writeSkill(bundled, "ade-browser", "# browser"); - const target1 = path.join(tmp, "home", ".claude", "skills"); - const target2 = path.join(tmp, "home", ".agents", "skills"); - - const result = reseedAdeSkills({ - bundledRoot: bundled, - targetDirs: [target1, target2], - version: "1", - }); - - expect(result.targetsWritten).toEqual([target1, target2]); - expect(fs.existsSync(path.join(target1, "ade-browser", "SKILL.md"))).toBe(true); - expect(fs.existsSync(path.join(target2, "ade-browser", "SKILL.md"))).toBe(true); - }); - - it("prunes ADE-managed skills that are no longer bundled, on a content change", () => { - writeSkill(bundled, "ade-old", "# old"); - reseedAdeSkills({ bundledRoot: bundled, targetDirs: [target], version: "1" }); - expect(fs.existsSync(path.join(target, "ade-old"))).toBe(true); - - fs.rmSync(path.join(bundled, "ade-old"), { recursive: true, force: true }); - writeSkill(bundled, "ade-new", "# new"); - reseedAdeSkills({ bundledRoot: bundled, targetDirs: [target], version: "1" }); - - expect(fs.existsSync(path.join(target, "ade-old"))).toBe(false); - expect(fs.existsSync(path.join(target, "ade-new", "SKILL.md"))).toBe(true); - }); -}); diff --git a/apps/desktop/src/main/services/skills/skillReseedService.ts b/apps/desktop/src/main/services/skills/skillReseedService.ts deleted file mode 100644 index 68b383f05c..0000000000 Binary files a/apps/desktop/src/main/services/skills/skillReseedService.ts and /dev/null differ diff --git a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts index 58f5fade79..9e752dac35 100644 --- a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts +++ b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts @@ -834,7 +834,7 @@ describe("buildTrackedCliStartupCommand", () => { expect(launch.args).toEqual(expect.arrayContaining(["--model", "github-copilot/gpt-5.4", "--prompt"])); expect(launch.env?.OPENCODE_CONFIG_CONTENT).toBe("{\"permission\":\"allow\"}"); expect(launch.env?.[ADE_AGENT_SKILLS_DIRS_ENV]).toContain("agent-skills"); - expect(launch.startupCommand).toContain("OPENCODE_CONFIG_CONTENT=\"{\\\"permission\\\":\\\"allow\\\"}\" opencode"); + expect(launch.startupCommand).toContain("OPENCODE_CONFIG_CONTENT="); expect(launch.startupCommand).toContain("Use OpenCode."); }); diff --git a/apps/desktop/src/shared/adeCliGuidance.test.ts b/apps/desktop/src/shared/adeCliGuidance.test.ts index 7f4070be6a..91fde7af2c 100644 --- a/apps/desktop/src/shared/adeCliGuidance.test.ts +++ b/apps/desktop/src/shared/adeCliGuidance.test.ts @@ -29,6 +29,8 @@ describe("ADE bootstrap guidance", () => { // The fallback: CLI help is ground truth (agents are not trained on `ade`). expect(bootstrap).toContain("ade help "); expect(bootstrap).toContain("ade actions list --text"); + expect(bootstrap).toContain("ade skill list --text"); + expect(bootstrap).toContain("ade skill show --text"); expect(bootstrap).toContain("ade chat scheduled-work create"); expect(bootstrap).toContain("tracked provider CLIs"); expect(bootstrap).toContain('ade chat note "running e2e shard 2/4"'); diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts index bf4c066832..2e95c96113 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -12,6 +12,7 @@ export const adeBundledAgentSkills = [ "ade-proof-artifacts", "ade-deeplinks", "ade-search", + "ade-mosaic", ] as const; /** @@ -34,8 +35,8 @@ export const ADE_SESSION_STATUS_PROTOCOL_GUIDANCE = [ /** * @deprecated Superseded by {@link buildAdeBootstrapGuidance}. Kept as a thin alias so * existing call sites stay wired to the (now minimal) bootstrap. The previous ~1,000-token - * blob is gone: ADE's capabilities are delivered as Agent Skills that each runtime discovers - * natively (progressive disclosure), seeded by `skillReseedService`. Do not re-grow this. + * blob is gone: ADE's capabilities are delivered as session-scoped Agent Skills, with + * `ade skill show` as the runtime-independent activation fallback. Do not re-grow this. */ export function buildAdeCliAgentGuidance(skillRoots: readonly string[] = getAdeAgentSkillRootsForPrompt()): string { return buildAdeBootstrapGuidance(skillRoots); @@ -54,7 +55,7 @@ export const ADE_CLI_INLINE_GUIDANCE = buildAdeCliInlineGuidance(); * blob. It teaches the habit (reach for the matching `ade-*` skill on demand) and the * ground-truth fallback (`ade help` / `ade actions list`) instead of inlining every * socket/browser/proof rule on every turn — those now live in their skills, which each - * runtime discovers natively (progressive disclosure). Keep this short; do not re-grow it. + * runtime discovers natively when it supports extra roots. Keep this short; do not re-grow it. */ export function buildAdeBootstrapGuidance( skillRoots: readonly string[] = getAdeAgentSkillRootsForPrompt(), @@ -65,6 +66,7 @@ export function buildAdeBootstrapGuidance( "Your ADE capabilities ship as Agent Skills. When a task touches an ADE area (lanes/git, PRs, proof & screenshots, the built-in browser, iOS simulator, app control, Linear, deeplinks, or searching across everything in ADE), read the matching `ade-*` skill before acting; otherwise ignore them.", `Skills: ${adeBundledAgentSkills.map((name) => `\`${name}\``).join(", ")}.`, formatAdeAgentSkillRootsForPrompt(skillRoots), + "If your runtime does not expose those skills natively, use `ade skill list --text` to discover them and `ade skill show --text` to load one on demand.", "If the direct `mcp__computer_use` tools are present, use them for Codex Computer Use and honor their per-app approvals; do not initialize `@oai/sky` through `node_repl` as a substitute.", "Ground truth for any `ade` invocation is `ade help ` and `ade actions list --text`; prefer typed commands with `--text`. Project secrets are available through `ade secrets`; read only the named secret the user asks you to use and avoid printing secret values. Track and clean up processes you start.", "`ade chat scheduled-work create` schedules durable self-resume for bound chats and tracked provider CLIs.", diff --git a/apps/desktop/src/shared/agentSkillRoots.ts b/apps/desktop/src/shared/agentSkillRoots.ts index 18f331e480..a3bbb029c7 100644 --- a/apps/desktop/src/shared/agentSkillRoots.ts +++ b/apps/desktop/src/shared/agentSkillRoots.ts @@ -1,4 +1,5 @@ export const ADE_AGENT_SKILLS_DIRS_ENV = "ADE_AGENT_SKILLS_DIRS"; +export const ADE_BUNDLED_AGENT_SKILLS_DIR_ENV = "ADE_BUNDLED_AGENT_SKILLS_DIR"; function processRef(): NodeJS.Process | null { return typeof process !== "undefined" ? process : null; diff --git a/apps/desktop/src/shared/cliLaunch.ts b/apps/desktop/src/shared/cliLaunch.ts index 9fbd6f25ee..07875f286a 100644 --- a/apps/desktop/src/shared/cliLaunch.ts +++ b/apps/desktop/src/shared/cliLaunch.ts @@ -463,7 +463,8 @@ export function buildTrackedCliLaunchCommand(args: { } // Build a shorter startupCommand for the shell-fallback path that excludes // the huge --append-system-prompt blob. The direct-spawn path uses the full - // args array. Claude still discovers ADE skills via ADE_AGENT_SKILLS_DIRS. + // args array. The PTY launch boundary adds the validated bundled + // `--plugin-dir`; the compact prompt remains the compatibility fallback. const shellArgs = commandArgs.filter( (arg, i, arr) => arg !== "--append-system-prompt" && arr[i - 1] !== "--append-system-prompt", ); diff --git a/docs/features/agents/README.md b/docs/features/agents/README.md index 7a581fc7fb..5ce6e4c734 100644 --- a/docs/features/agents/README.md +++ b/docs/features/agents/README.md @@ -19,7 +19,7 @@ The former worker/hiring agents were removed. There is one persistent identity | `apps/desktop/src/main/utils/codexComputerUse.ts` | Security boundary for direct Codex Computer Use: explicit config opt-in, stable/cache candidate resolution, executable check, and strict OpenAI code-signature identity verification. | | `apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md` | Agent-facing ADE CLI control-plane guidance. | | `apps/desktop/src/main/services/ai/tools/systemPrompt.ts` | Provider-runtime prompt assembly, including one shared timezone-safe scheduled-work contract for Claude, Codex, Cursor, Droid, and OpenCode. | -| `apps/desktop/resources/agent-skills/ade-mosaic/SKILL.md` | Agent-facing schema for Mosaic v1 interactive cards: an agent emits a fenced ` ```mosaic ` JSON block to ask the user for structured input (select / multiselect / number / input / approval / table) and the submitted answers return as the next user message. Discovered on demand rather than named in the `adeBundledAgentSkills` bootstrap list; parsing/rendering live in `apps/desktop/src/shared/chatMosaic.ts` (see [chat composer-and-ui.md](../chat/composer-and-ui.md)). | +| `apps/desktop/resources/agent-skills/ade-mosaic/SKILL.md` | Agent-facing schema for Mosaic v1 interactive cards: an agent emits a fenced ` ```mosaic ` JSON block to ask the user for structured input (select / multiselect / number / input / approval / table) and the submitted answers return as the next user message. Parsing/rendering live in `apps/desktop/src/shared/chatMosaic.ts` (see [chat composer-and-ui.md](../chat/composer-and-ui.md)). | | `apps/desktop/src/main/services/cli/adeCliService.ts` | Desktop-side install / status / uninstall surface for the `ade` launcher. | | `apps/desktop/src/shared/adeCliGuidance.ts` | Canonical agent-prompt guidance builder for finding and using `ade`, reading Agent Skills on demand, using socket-backed live surfaces, registering proof, and cleaning up processes. Injected into Work chats, CLI launches, ADE Code/TUI sessions, the CTO, and mobile-started runtime work. | | `apps/desktop/src/shared/agentSkillRoots.ts` | Resolves and formats Agent Skill roots injected into prompts and CLI environments. | @@ -87,6 +87,37 @@ active/ended, never settled. `buildAdeBootstrapGuidance` exposes the surviving commands in the injected agent prompt (`ADE_SESSION_STATUS_PROTOCOL_GUIDANCE`), including the explicit "you cannot settle or unsettle a session" line. + +### Bundled skill distribution + +ADE keeps one canonical bundled skill tree at +`apps/desktop/resources/agent-skills` (packaged as +`Resources/agent-skills`). It does not install those skills into +`~/.claude/skills`, `~/.agents/skills`, `~/.cursor/skills`, +`~/.factory/skills`, or `~/.config/opencode/skills`; doing so leaks +ADE-specific capabilities into unrelated harness sessions. + +Each ADE-launched session receives the canonical root through +`ADE_AGENT_SKILLS_DIRS` and the compact skill catalog in +`buildAdeBootstrapGuidance`. Provider-native integrations are session-scoped: + +- Codex app-server receives `skills/extraRoots/set` and uses + `perCwdExtraUserRoots` when listing skills. +- Claude Agent SDK loads the bundled root as a local plugin through its + `.claude-plugin/plugin.json`; tracked Claude CLI launches receive the same + validated root through `--plugin-dir`. +- OpenCode's currently shipped config schema, Cursor, and Droid do not expose + an arbitrary standalone skill-root option ADE can safely set, so they use + the catalog plus the provider-independent + `ade skill list --text` / `ade skill show --text` activation path. + +The same CLI activation path is available to every provider and remains the +compatibility fallback for older runtime versions. On startup, ADE performs a +one-time conservative migration of the former global installation: it reads +the ADE-owned `.ade-skills.json` manifest, removes only copies whose contents +still match ADE's bundle, preserves modified or unverifiable copies, and then +retires the manifest. + SDK-backed Claude, Codex, Cursor, Droid, and OpenCode chats receive `ADE_CHAT_SESSION_ID` plus `ADE_DEFAULT_ROLE=agent` (or `orchestrator` for an orchestration lead), and their persistent guidance names the concrete