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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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