diff --git a/plugins/omo/components/bootstrap/dist/cli.js b/plugins/omo/components/bootstrap/dist/cli.js index 8a81451..27a2884 100755 --- a/plugins/omo/components/bootstrap/dist/cli.js +++ b/plugins/omo/components/bootstrap/dist/cli.js @@ -3543,7 +3543,15 @@ async function linkBundledAgentsStep(options) { const agentsTarget = join21(options.codexHome, "agents"); try { const stageRoot = join21(options.pluginData, "bootstrap", "agents-stage"); - await stageBundledAgents(options.pluginRoot, stageRoot); + const previouslyInstalledAgents = await readInstalledAgentPaths(stageRoot); + const previouslyStagedAgentContents = await readStagedAgentContents(stageRoot); + const existingConfig = await readConfigIfPresent(join21(options.codexHome, "config.toml")); + const foreignAgentFiles = await stageBundledAgents(options.pluginRoot, stageRoot, existingConfig); + for (const agentFile of foreignAgentFiles) { + const agentPath = join21(agentsTarget, agentFile); + if (previouslyInstalledAgents.has(agentPath) && await matchesAgentContent(agentPath, previouslyStagedAgentContents.get(agentFile))) + await rm10(agentPath, { force: true }); + } const preservedReasoning = await capturePreservedAgentReasoning({ codexHome: options.codexHome }); const preservedServiceTier = await capturePreservedAgentServiceTier({ codexHome: options.codexHome }); const linked = await linkCachedPluginAgents({ @@ -3567,9 +3575,43 @@ async function linkBundledAgentsStep(options) { }; } } -async function stageBundledAgents(pluginRoot, stageRoot) { +async function readInstalledAgentPaths(stageRoot) { + try { + const parsed = JSON.parse(await readFile(join21(stageRoot, ".installed-agents.json"), "utf8")); + if (!isRecord(parsed) || !Array.isArray(parsed["agents"])) + return new Set(); + return new Set(parsed["agents"].filter((path) => typeof path === "string")); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return new Set(); + throw error; + } +} +async function readStagedAgentContents(stageRoot) { + const contents = new Map(); + const componentsRoot = join21(stageRoot, "components"); + for (const componentName of await directoryNames(componentsRoot)) { + const agentsDir = join21(componentsRoot, componentName, "agents"); + for (const agentFile of await fileNames(agentsDir)) + contents.set(agentFile, await readFile(join21(agentsDir, agentFile), "utf8")); + } + return contents; +} +async function matchesAgentContent(path, expectedContent) { + if (expectedContent === void 0) + return false; + try { + return await readFile(path, "utf8") === expectedContent; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return false; + throw error; + } +} +async function stageBundledAgents(pluginRoot, stageRoot, existingConfig) { await rm10(stageRoot, { force: true, recursive: true }); await mkdir7(stageRoot, { recursive: true }); + const foreignAgentFiles = []; const componentsRoot = join21(pluginRoot, "components"); for (const componentName of await directoryNames(componentsRoot)) { const agentsDir = join21(componentsRoot, componentName, "agents"); @@ -3579,9 +3621,15 @@ async function stageBundledAgents(pluginRoot, stageRoot) { const stagedAgentsDir = join21(stageRoot, "components", componentName, "agents"); await mkdir7(stagedAgentsDir, { recursive: true }); for (const agentFile of agentFiles) { + const agentConfig = { configFile: `./agents/${agentFile}`, name: agentNameFromToml3(agentFile) }; + if (hasForeignAgentRegistration(existingConfig, agentConfig)) { + foreignAgentFiles.push(agentFile); + continue; + } await copyFile2(join21(agentsDir, agentFile), join21(stagedAgentsDir, agentFile)); } } + return foreignAgentFiles; } async function updateConfigStep(options, inputs, degraded) { const configPath = join21(options.codexHome, "config.toml"); diff --git a/plugins/omo/components/bootstrap/src/agent-staging.ts b/plugins/omo/components/bootstrap/src/agent-staging.ts new file mode 100644 index 0000000..53e56af --- /dev/null +++ b/plugins/omo/components/bootstrap/src/agent-staging.ts @@ -0,0 +1,97 @@ +import { copyFile, mkdir, readFile, readdir, rm } from "node:fs/promises"; +import { join } from "node:path"; + +import { hasForeignAgentRegistration } from "../../../../src/install/codex-config-agents.ts"; + +const AGENT_MANIFEST = ".installed-agents.json"; + +export async function readInstalledAgentPaths(stageRoot: string): Promise> { + try { + const parsed: unknown = JSON.parse(await readFile(join(stageRoot, AGENT_MANIFEST), "utf8")); + if (!isRecord(parsed) || !Array.isArray(parsed["agents"])) return new Set(); + return new Set(parsed["agents"].filter((path): path is string => typeof path === "string")); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return new Set(); + throw error; + } +} + +export async function readStagedAgentContents(stageRoot: string): Promise> { + const contents = new Map(); + const componentsRoot = join(stageRoot, "components"); + for (const componentName of await directoryNames(componentsRoot)) { + const agentsDir = join(componentsRoot, componentName, "agents"); + for (const agentFile of await fileNames(agentsDir)) { + contents.set(agentFile, await readFile(join(agentsDir, agentFile), "utf8")); + } + } + return contents; +} + +export async function matchesAgentContent(path: string, expectedContent: string | undefined): Promise { + if (expectedContent === undefined) return false; + try { + return (await readFile(path, "utf8")) === expectedContent; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return false; + throw error; + } +} + +export async function stageBundledAgents( + pluginRoot: string, + stageRoot: string, + existingConfig: string, +): Promise { + await rm(stageRoot, { force: true, recursive: true }); + await mkdir(stageRoot, { recursive: true }); + const foreignAgentFiles: string[] = []; + const componentsRoot = join(pluginRoot, "components"); + for (const componentName of await directoryNames(componentsRoot)) { + const agentsDir = join(componentsRoot, componentName, "agents"); + const agentFiles = (await fileNames(agentsDir)).filter((name) => name.endsWith(".toml")); + if (agentFiles.length === 0) continue; + const stagedAgentsDir = join(stageRoot, "components", componentName, "agents"); + await mkdir(stagedAgentsDir, { recursive: true }); + for (const agentFile of agentFiles) { + const agentConfig = { configFile: `./agents/${agentFile}`, name: agentNameFromToml(agentFile) }; + if (hasForeignAgentRegistration(existingConfig, agentConfig)) { + foreignAgentFiles.push(agentFile); + continue; + } + await copyFile(join(agentsDir, agentFile), join(stagedAgentsDir, agentFile)); + } + } + return foreignAgentFiles; +} + +export function agentNameFromToml(fileName: string): string { + return fileName.endsWith(".toml") ? fileName.slice(0, -".toml".length) : fileName; +} + +async function directoryNames(root: string): Promise { + return entryNames(root, (entry) => entry.isDirectory()); +} + +async function fileNames(root: string): Promise { + return entryNames(root, (entry) => entry.isFile()); +} + +async function entryNames(root: string, keep: (entry: { isDirectory(): boolean; isFile(): boolean }) => boolean): Promise { + try { + const entries: readonly { isDirectory(): boolean; isFile(): boolean; name: string }[] = await readdir(root, { + withFileTypes: true, + }); + return entries + .filter((entry) => keep(entry)) + .map((entry) => entry.name) + .sort(); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return []; + throw error; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/omo/components/bootstrap/src/setup.ts b/plugins/omo/components/bootstrap/src/setup.ts index 1a03421..13f320f 100644 --- a/plugins/omo/components/bootstrap/src/setup.ts +++ b/plugins/omo/components/bootstrap/src/setup.ts @@ -1,4 +1,4 @@ -import { copyFile, mkdir, readFile, readdir, rm, stat } from "node:fs/promises"; +import { readFile, rm, stat } from "node:fs/promises"; import { join } from "node:path"; // These relative imports resolve at BUILD time in the monorepo; esbuild @@ -17,6 +17,13 @@ import { trustedHookStatesForPlugin } from "../../../../src/install/codex-hook-t import { resolveCodexInstallerBinDir } from "../../../../src/install/codex-installer-bin-dir.ts"; import { prepareGitBashForInstall } from "../../../../src/install/git-bash.ts"; import type { CodexAgentConfig, GitBashResolution } from "../../../../src/install/types.ts"; +import { + agentNameFromToml, + matchesAgentContent, + readInstalledAgentPaths, + readStagedAgentContents, + stageBundledAgents, +} from "./agent-staging.ts"; import { appendBootstrapLog, BOOTSTRAP_DOCTOR_HINT } from "./worker.ts"; import type { BootstrapDegradedEntry, BootstrapStepOutcome } from "./worker.ts"; @@ -91,7 +98,19 @@ async function linkBundledAgentsStep(options: WorkerSetupOptions): Promise { - await rm(stageRoot, { force: true, recursive: true }); - await mkdir(stageRoot, { recursive: true }); - const componentsRoot = join(pluginRoot, "components"); - for (const componentName of await directoryNames(componentsRoot)) { - const agentsDir = join(componentsRoot, componentName, "agents"); - const agentFiles = (await fileNames(agentsDir)).filter((name) => name.endsWith(".toml")); - if (agentFiles.length === 0) continue; - const stagedAgentsDir = join(stageRoot, "components", componentName, "agents"); - await mkdir(stagedAgentsDir, { recursive: true }); - for (const agentFile of agentFiles) { - await copyFile(join(agentsDir, agentFile), join(stagedAgentsDir, agentFile)); - } - } -} - async function updateConfigStep( options: WorkerSetupOptions, inputs: { agentConfigs: readonly CodexAgentConfig[]; gitBashEnabled: boolean }, @@ -147,7 +150,7 @@ async function updateConfigStep( // for such a role would collide with the mirrored entry once Codex // discovers /agents/.toml (two different file paths for // one role name -> upstream warning), so foreign pre-existing blocks are - // left untouched; directory discovery still loads the linked toml. + // left untouched and their runtime-local copies are excluded from staging. const existingConfig = await readConfigIfPresent(configPath); const agentConfigs = inputs.agentConfigs.filter( (agentConfig) => !hasForeignAgentRegistration(existingConfig, agentConfig), @@ -268,31 +271,6 @@ async function stampGitBashEnvStep(options: WorkerSetupOptions, degraded: Bootst } } -async function directoryNames(root: string): Promise { - return entryNames(root, (entry) => entry.isDirectory()); -} - -async function fileNames(root: string): Promise { - return entryNames(root, (entry) => entry.isFile()); -} - -async function entryNames(root: string, keep: (entry: { isDirectory(): boolean; isFile(): boolean }) => boolean): Promise { - try { - const entries = await readdir(root, { withFileTypes: true }); - return entries - .filter((entry) => keep(entry)) - .map((entry) => entry.name) - .sort(); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") return []; - throw error; - } -} - -function agentNameFromToml(fileName: string): string { - return fileName.endsWith(".toml") ? fileName.slice(0, -".toml".length) : fileName; -} - function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/plugins/omo/test/bootstrap-setup.test.mjs b/plugins/omo/test/bootstrap-setup.test.mjs index 4ef3311..dacaac0 100644 --- a/plugins/omo/test/bootstrap-setup.test.mjs +++ b/plugins/omo/test/bootstrap-setup.test.mjs @@ -127,9 +127,10 @@ test("#given a completed first run #when the worker setup runs again #then confi }); }); -test("#given a config.toml that already declares [agents.explorer] at a different path (Orca mirror) #when the worker setup runs #then no second colliding registration is added for that role", async () => { +test("#given a config.toml that already declares [agents.explorer] at a different path (Orca mirror) #when the worker setup runs #then the mirrored role is not also linked into the runtime agents directory", async () => { await withSetupFixture(async (fixture) => { const orcaMirrorPath = "/orca-mirrored-home/.codex/agents/explorer.toml"; + await runWorkerSetup(setupOptions(fixture)); await writeFile( join(fixture.codexHome, "config.toml"), `[marketplaces.sisyphuslabs]\n${MARKETPLACE_SOURCE_LINE}\n\n[agents.explorer]\nconfig_file = "${orcaMirrorPath}"\n`, @@ -149,11 +150,15 @@ test("#given a config.toml that already declares [agents.explorer] at a differen "no colliding ./agents registration for the mirrored role", ); assert.match(config, /\[agents\.metis\]\nconfig_file = "\.\/agents\/metis\.toml"/); - assert.equal( - await readFile(join(fixture.codexHome, "agents", "explorer.toml"), "utf8"), - BUNDLED_EXPLORER_TOML, - "the linked toml is still staged for Codex directory discovery", + await assert.rejects(() => stat(join(fixture.codexHome, "agents", "explorer.toml")), { code: "ENOENT" }); + assert.equal(await readFile(join(fixture.codexHome, "agents", "metis.toml"), "utf8"), BUNDLED_METIS_TOML); + const manifest = JSON.parse( + await readFile(join(fixture.pluginData, "bootstrap", "agents-stage", ".installed-agents.json"), "utf8"), ); + assert.deepEqual(manifest.agents, [join(fixture.codexHome, "agents", "metis.toml")]); + await runWorkerSetup(setupOptions(fixture)); + assert.equal(await readConfig(fixture), config); + await assert.rejects(() => stat(join(fixture.codexHome, "agents", "explorer.toml")), { code: "ENOENT" }); }); }); @@ -168,6 +173,23 @@ test("#given a config.toml with no pre-existing agent entries #when the worker s }); }); +test("#given an unmanaged local role with the same name as an Orca registration #when the worker setup runs #then the local role is preserved", async () => { + await withSetupFixture(async (fixture) => { + const userAgent = 'description = "User-owned explorer"\nmodel = "gpt-5.6"\n'; + await runWorkerSetup(setupOptions(fixture)); + await mkdir(join(fixture.codexHome, "agents"), { recursive: true }); + await writeFile(join(fixture.codexHome, "agents", "explorer.toml"), userAgent); + await writeFile( + join(fixture.codexHome, "config.toml"), + `[marketplaces.sisyphuslabs]\n${MARKETPLACE_SOURCE_LINE}\n\n[agents.explorer]\nconfig_file = "/orca-mirrored-home/.codex/agents/explorer.toml"\n`, + ); + + await runWorkerSetup(setupOptions(fixture)); + + assert.equal(await readFile(join(fixture.codexHome, "agents", "explorer.toml"), "utf8"), userAgent); + }); +}); + test("#given a package-relative CodeGraph MCP path #when worker setup runs #then the path is stamped absolute", async () => { await withSetupFixture(async (fixture) => { await writeFile(