Skip to content

Fix duplicate agent roles in Orca-managed Codex homes #150

Description

@glen-tl

Problem Situation

When Codex runs inside Orca, Orca mirrors global [agents.*] registrations into the account CODEX_HOME. OMO's SessionStart bootstrap also copies the same bundled role TOMLs into CODEX_HOME/agents/. Codex directory discovery then loads the local TOMLs in addition to Orca's mirrored paths and warns that the role names are duplicated.

Reproduction Logs

Focused bootstrap regression before the fix:

not ok - #given a config.toml that already declares [agents.explorer] at a different path (Orca mirror)
AssertionError [ERR_ASSERTION]: Missing expected rejection.
tests 13, pass 12, fail 1

Full log: /tmp/lazycodex-fix-orca-agent-duplicates-repro.log

Root Cause

The bootstrap only filtered foreign registrations while updating config.toml. It still staged and copied every bundled TOML into CODEX_HOME/agents/. Since Codex auto-discovers that directory, filtering the config block alone could not prevent the duplicate role.

Verified Fix

Read the existing account config before staging. For each bundled role already registered at a foreign path, remove the stale OMO-managed account-local TOML and omit it from the staging tree. Other bundled roles continue to install normally. The change is entirely in OMO bootstrap code; no Orca service or app files are modified.

diff --git a/plugins/omo/components/bootstrap/dist/cli.js b/plugins/omo/components/bootstrap/dist/cli.js
index 8a81451..15789b5 100755
--- a/plugins/omo/components/bootstrap/dist/cli.js
+++ b/plugins/omo/components/bootstrap/dist/cli.js
@@ -3543,7 +3543,8 @@ 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 existingConfig = await readConfigIfPresent(join21(options.codexHome, "config.toml"));
+    await stageBundledAgents(options.pluginRoot, stageRoot, agentsTarget, existingConfig);
     const preservedReasoning = await capturePreservedAgentReasoning({ codexHome: options.codexHome });
     const preservedServiceTier = await capturePreservedAgentServiceTier({ codexHome: options.codexHome });
     const linked = await linkCachedPluginAgents({
@@ -3567,7 +3568,7 @@ async function linkBundledAgentsStep(options) {
     };
   }
 }
-async function stageBundledAgents(pluginRoot, stageRoot) {
+async function stageBundledAgents(pluginRoot, stageRoot, agentsTarget, existingConfig) {
   await rm10(stageRoot, { force: true, recursive: true });
   await mkdir7(stageRoot, { recursive: true });
   const componentsRoot = join21(pluginRoot, "components");
@@ -3579,6 +3580,14 @@ 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)) {
+        await rm10(join21(agentsTarget, agentFile), { force: true });
+        continue;
+      }
       await copyFile2(join21(agentsDir, agentFile), join21(stagedAgentsDir, agentFile));
     }
   }
diff --git a/plugins/omo/components/bootstrap/src/agent-stage.ts b/plugins/omo/components/bootstrap/src/agent-stage.ts
new file mode 100644
index 0000000..7cdc660
--- /dev/null
+++ b/plugins/omo/components/bootstrap/src/agent-stage.ts
@@ -0,0 +1,62 @@
+import { copyFile, mkdir, readdir, rm } from "node:fs/promises";
+import { join } from "node:path";
+import { hasForeignAgentRegistration } from "../../../../src/install/codex-config-agents.ts";
+
+export async function stageBundledAgents(
+	pluginRoot: string,
+	stageRoot: string,
+	agentsTarget: string,
+	existingConfig: string,
+): Promise<void> {
+	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) {
+			const agentConfig = {
+				configFile: `./agents/${agentFile}`,
+				name: agentNameFromToml(agentFile),
+			};
+			if (hasForeignAgentRegistration(existingConfig, agentConfig)) {
+				// Codex discovers CODEX_HOME/agents automatically. Leaving an OMO copy
+				// beside Orca's mirrored registration creates the same role twice.
+				await rm(join(agentsTarget, agentFile), { force: true });
+				continue;
+			}
+			await copyFile(join(agentsDir, agentFile), join(stagedAgentsDir, agentFile));
+		}
+	}
+}
+
+async function directoryNames(root: string): Promise<string[]> {
+	return entryNames(root, (entry) => entry.isDirectory());
+}
+
+async function fileNames(root: string): Promise<string[]> {
+	return entryNames(root, (entry) => entry.isFile());
+}
+
+async function entryNames(
+	root: string,
+	keep: (entry: { isDirectory(): boolean; isFile(): boolean }) => boolean,
+): Promise<string[]> {
+	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;
+	}
+}
+
+export function agentNameFromToml(fileName: string): string {
+	return fileName.endsWith(".toml") ? fileName.slice(0, -".toml".length) : fileName;
+}
diff --git a/plugins/omo/components/bootstrap/src/setup.ts b/plugins/omo/components/bootstrap/src/setup.ts
index 1a03421..79efe16 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, stat } from "node:fs/promises";
 import { join } from "node:path";
 
 // These relative imports resolve at BUILD time in the monorepo; esbuild
@@ -17,6 +17,7 @@ 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, stageBundledAgents } from "./agent-stage.ts";
 import { appendBootstrapLog, BOOTSTRAP_DOCTOR_HINT } from "./worker.ts";
 import type { BootstrapDegradedEntry, BootstrapStepOutcome } from "./worker.ts";
 
@@ -91,7 +92,8 @@ async function linkBundledAgentsStep(options: WorkerSetupOptions): Promise<Agent
 		// first: bootstrap must never persist anything under PLUGIN_ROOT (the
 		// Codex-managed marketplace cache).
 		const stageRoot = join(options.pluginData, "bootstrap", "agents-stage");
-		await stageBundledAgents(options.pluginRoot, stageRoot);
+		const existingConfig = await readConfigIfPresent(join(options.codexHome, "config.toml"));
+		await stageBundledAgents(options.pluginRoot, stageRoot, agentsTarget, existingConfig);
 		const preservedReasoning = await capturePreservedAgentReasoning({ codexHome: options.codexHome });
 		const preservedServiceTier = await capturePreservedAgentServiceTier({ codexHome: options.codexHome });
 		const linked = await linkCachedPluginAgents({
@@ -118,22 +120,6 @@ async function linkBundledAgentsStep(options: WorkerSetupOptions): Promise<Agent
 	}
 }
 
-async function stageBundledAgents(pluginRoot: string, stageRoot: string): Promise<void> {
-	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 },
@@ -268,31 +254,6 @@ async function stampGitBashEnvStep(options: WorkerSetupOptions, degraded: Bootst
 	}
 }
 
-async function directoryNames(root: string): Promise<string[]> {
-	return entryNames(root, (entry) => entry.isDirectory());
-}
-
-async function fileNames(root: string): Promise<string[]> {
-	return entryNames(root, (entry) => entry.isFile());
-}
-
-async function entryNames(root: string, keep: (entry: { isDirectory(): boolean; isFile(): boolean }) => boolean): Promise<string[]> {
-	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..a5dcf88 100644
--- a/plugins/omo/test/bootstrap-setup.test.mjs
+++ b/plugins/omo/test/bootstrap-setup.test.mjs
@@ -130,6 +130,8 @@ 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 () => {
 	await withSetupFixture(async (fixture) => {
 		const orcaMirrorPath = "/orca-mirrored-home/.codex/agents/explorer.toml";
+		await mkdir(join(fixture.codexHome, "agents"), { recursive: true });
+		await writeFile(join(fixture.codexHome, "agents", "explorer.toml"), BUNDLED_EXPLORER_TOML);
 		await writeFile(
 			join(fixture.codexHome, "config.toml"),
 			`[marketplaces.sisyphuslabs]\n${MARKETPLACE_SOURCE_LINE}\n\n[agents.explorer]\nconfig_file = "${orcaMirrorPath}"\n`,
@@ -149,10 +151,10 @@ 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")),
+			(error) => error?.code === "ENOENT",
+			"the stale local toml is removed because Codex directory discovery would register it again",
 		);
 	});
 });

Verification

  • RED: focused regression test failed before the fix (12 passed, 1 failed).
  • GREEN: node --test plugins/omo/test/bootstrap-setup.test.mjs passed 13/13.
  • Adjacent: setup, binlinks, hooks, and orchestration bootstrap suites passed with 0 failures.
  • Generated runtime bundle and TypeScript source were reviewed for behavioral parity.
  • Independent LazyCodex code review: APPROVE; gate review: APPROVE.

This fix was debugged, implemented, and verified by LazyCodex.
Tag: lazycodex-generated

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions