feat(codex): name additional session-catalog homes (#124807)

This commit is contained in:
Peter Steinberger
2026-08-16 13:30:49 -07:00
committed by GitHub
parent 8b9650d42a
commit 2523943ef1
6 changed files with 89 additions and 46 deletions
+10 -5
View File
@@ -85,7 +85,10 @@ outside OpenClaw:
enabled: true,
config: {
sessionCatalog: {
homes: ["/path/to/additional-codex-home"],
homes: [
"/path/to/additional-codex-home",
{ path: "/path/to/review-codex-home", label: "Review sessions" },
],
},
},
},
@@ -95,10 +98,12 @@ outside OpenClaw:
```
Configured stores appear in the sidebar alongside automatically discovered
ones, labeled `Local Codex · configured <n>` and grouped by each session's
working directory. Sessions in these stores support the same view, continue,
and archive actions, and the selected OpenClaw agent still owns the resulting
connection; `homes` only adds catalog sources.
ones, labeled `Local Codex · <label>` and grouped by each session's working
directory. String entries and objects without `label` use the basename of the
canonicalized home directory; an explicit `label` overrides that default.
Sessions in these stores support the same view, continue, and archive actions,
and the selected OpenClaw agent still owns the resulting connection; `homes`
only adds catalog sources.
Only existing directories are included. Equivalent paths are canonicalized and
deduplicated against the automatic homes, and automatic homes keep priority
+15 -2
View File
@@ -84,7 +84,20 @@
"enabled": { "type": "boolean", "default": true },
"homes": {
"type": "array",
"items": { "type": "string", "minLength": 1, "pattern": "\\S" }
"items": {
"anyOf": [
{ "type": "string", "minLength": 1, "pattern": "\\S" },
{
"type": "object",
"additionalProperties": false,
"required": ["path"],
"properties": {
"path": { "type": "string", "minLength": 1, "pattern": "\\S" },
"label": { "type": "string", "minLength": 1, "pattern": "\\S" }
}
}
]
}
}
}
},
@@ -406,7 +419,7 @@
},
"sessionCatalog.homes": {
"label": "Additional Codex Homes",
"help": "Existing local Codex home directories to include in native session discovery. Paths are canonicalized and deduplicated. Requires appServer.transport=stdio and a Gateway restart.",
"help": "Existing local Codex home directories to include in native session discovery. String paths use the canonical directory basename as their label; object entries can override it with label. Paths are canonicalized and deduplicated. Requires appServer.transport=stdio and a Gateway restart.",
"advanced": true
},
"codexDynamicToolsLoading": {
+20 -4
View File
@@ -367,13 +367,29 @@ describe("Codex app-server config", () => {
).toStrictEqual({});
});
it("parses native session discovery options", () => {
it("parses named native session discovery homes and rejects empty labels", () => {
expect(
readCodexPluginConfig({
sessionCatalog: { enabled: false, homes: [" /srv/codex-extra "] },
sessionCatalog: {
enabled: false,
homes: [
" /srv/codex-string ",
{ path: " /srv/codex-named ", label: " Named store " },
{ path: " /srv/codex-default " },
],
},
}).sessionCatalog,
).toEqual({ enabled: false, homes: ["/srv/codex-extra"] });
expect(readCodexPluginConfig({ sessionCatalog: { homes: [" "] } })).toStrictEqual({});
).toEqual({
enabled: false,
homes: [
"/srv/codex-string",
{ path: "/srv/codex-named", label: "Named store" },
{ path: "/srv/codex-default" },
],
});
expect(
readCodexPluginConfig({ sessionCatalog: { homes: [{ path: "/srv/codex", label: " " }] } }),
).toStrictEqual({});
});
it.each([
@@ -1,9 +1,14 @@
import { z } from "zod";
const codexSessionCatalogHomeSchema = z.union([
z.string().trim().min(1),
z.object({ path: z.string().trim().min(1), label: z.string().trim().min(1).optional() }).strict(),
]);
export const codexSessionCatalogConfigSchema = z
.object({
enabled: z.boolean().optional(),
homes: z.array(z.string().trim().min(1)).optional(),
homes: z.array(codexSessionCatalogHomeSchema).optional(),
})
.strict();
+17 -26
View File
@@ -24,7 +24,7 @@ export type { CodexCatalogHome } from "./session-catalog-types.js";
type CatalogHomeCandidate = {
codexHome: string;
label: string;
usesProcessHomeFallback: boolean;
usesProcessHomeFallback?: boolean;
};
function canonicalCatalogHome(value: string): string {
@@ -36,13 +36,16 @@ function canonicalCatalogHome(value: string): string {
}
}
function existingCanonicalCatalogHome(value: string): string | undefined {
function existingCatalogHomeCandidates(value: string, label?: string): CatalogHomeCandidate[] {
const codexHome = canonicalCatalogHome(value);
try {
const codexHome = fs.realpathSync.native(path.resolve(value));
return fs.statSync(codexHome).isDirectory() ? codexHome : undefined;
if (!fs.statSync(codexHome).isDirectory()) {
return [];
}
} catch {
return undefined;
return [];
}
return [{ codexHome, label: `Local Codex · ${label ?? path.basename(codexHome)}` }];
}
function catalogHomeId(codexHome: string): string {
@@ -93,27 +96,15 @@ function resolveCodexCatalogHomes(params: {
left === ownerAgentId ? -1 : right === ownerAgentId ? 1 : left.localeCompare(right),
);
candidates.push(
...agentIds.flatMap((agentId) => {
const codexHome = canonicalCatalogHome(
...agentIds.flatMap((agentId) =>
existingCatalogHomeCandidates(
resolveCodexAppServerHomeDir(resolveAgentDir(config, agentId, env)),
);
return fs.existsSync(codexHome)
? [{ codexHome, label: `Local Codex · ${agentId}`, usesProcessHomeFallback: false }]
: [];
}),
);
candidates.push(
...configuredHomes.flatMap((value, index) => {
const codexHome = existingCanonicalCatalogHome(value);
return codexHome
? [
{
codexHome,
label: `Local Codex · configured ${index + 1}`,
usesProcessHomeFallback: false,
},
]
: [];
agentId,
),
),
...configuredHomes.flatMap((entry) => {
const { path: home, label } = typeof entry === "string" ? { path: entry } : entry;
return existingCatalogHomeCandidates(home, label);
}),
);
}
@@ -144,7 +135,7 @@ function resolveCodexCatalogHomes(params: {
env: { ...base.start.env, CODEX_HOME: candidate.codexHome },
},
},
usesProcessHomeFallback: candidate.usesProcessHomeFallback,
usesProcessHomeFallback: candidate.usesProcessHomeFallback ?? false,
});
if (homes.length >= MAX_HOST_COUNT) {
break;
@@ -293,9 +293,11 @@ describe("Codex supervision catalog", () => {
tempDirs.push(root);
const alphaAgentDir = path.join(root, "agents", "alpha", "agent");
const betaAgentDir = path.join(root, "agents", "beta", "agent");
const fileAgentDir = path.join(root, "agents", "file", "agent");
const processCodexHome = path.join(root, "process-codex-home");
const alphaCodexHome = resolveCodexAppServerHomeDir(alphaAgentDir);
const betaCodexHome = resolveCodexAppServerHomeDir(betaAgentDir);
const fileAgentCodexHome = resolveCodexAppServerHomeDir(fileAgentDir);
const configuredCodexHomes = Array.from({ length: MAX_HOST_COUNT }, (_, index) =>
path.join(root, "configured-codex-home", String(index)),
);
@@ -310,6 +312,9 @@ describe("Codex supervision catalog", () => {
await Promise.all([
fs.symlink(configuredCodexHome, configuredCodexHomeAlias, "dir"),
fs.writeFile(configuredFile, "not a directory"),
fs
.mkdir(fileAgentDir, { recursive: true })
.then(() => fs.writeFile(fileAgentCodexHome, "not a directory")),
]);
const runtimeConfig = {
agents: {
@@ -317,6 +322,7 @@ describe("Codex supervision catalog", () => {
list: [
{ id: "alpha", agentDir: alphaAgentDir },
{ id: "beta", agentDir: betaAgentDir },
{ id: "file", agentDir: fileAgentDir },
],
},
} as OpenClawConfig;
@@ -331,11 +337,13 @@ describe("Codex supervision catalog", () => {
sessionCatalog: {
homes: [
configuredCodexHome,
configuredCodexHomeAlias,
{ path: configuredCodexHomeAlias, label: "Duplicate alias" },
{ path: configuredCodexHomes[1]!, label: "Named store" },
{ path: configuredCodexHomes[2]! },
alphaCodexHome,
path.join(root, "missing-codex-home"),
configuredFile,
...configuredCodexHomes.slice(1),
...configuredCodexHomes.slice(3),
],
},
}),
@@ -350,6 +358,11 @@ describe("Codex supervision catalog", () => {
expect(resolvedHomes.filter((home) => configuredCodexHomes.includes(home))).toHaveLength(
MAX_HOST_COUNT - 3,
);
expect(homes.slice(3, 6).map((home) => home.label)).toEqual([
"Local Codex · 0",
"Local Codex · Named store",
"Local Codex · 2",
]);
expect(homes.map((home) => home.agentDir)).toEqual(Array(MAX_HOST_COUNT).fill(betaAgentDir));
expect(homes[0]?.hostId).toBe(CODEX_LOCAL_SESSION_HOST_ID);
expect(homes.slice(1).every((home) => home.hostId.startsWith("gateway:local:"))).toBe(true);
@@ -423,7 +436,7 @@ describe("Codex supervision catalog", () => {
},
} as OpenClawConfig;
let runtimeConfig = configA;
const existsSync = vi.spyOn(fsSync, "existsSync");
const statSync = vi.spyOn(fsSync, "statSync");
try {
const resolver = createCodexCatalogHomeResolver({
config: configA,
@@ -431,11 +444,11 @@ describe("Codex supervision catalog", () => {
getPluginConfig: () => ({ supervision: { enabled: true } }),
env: { ...process.env, CODEX_HOME: processCodexHome },
});
const seedDiscoveryCount = existsSync.mock.calls.length;
const seedDiscoveryCount = statSync.mock.calls.length;
expect(resolver.forAgent("alpha")).not.toHaveLength(0);
expect(resolver.forAgent("alpha")).not.toHaveLength(0);
expect(existsSync).toHaveBeenCalledTimes(seedDiscoveryCount);
expect(statSync).toHaveBeenCalledTimes(seedDiscoveryCount);
runtimeConfig = configB;
const betaHomes = resolver.forAgent("beta");
@@ -446,12 +459,12 @@ describe("Codex supervision catalog", () => {
betaCodexHome,
),
).toBe(true);
const reloadedDiscoveryCount = existsSync.mock.calls.length;
const reloadedDiscoveryCount = statSync.mock.calls.length;
expect(resolver.forAgent("beta")).toEqual(betaHomes);
expect(existsSync).toHaveBeenCalledTimes(reloadedDiscoveryCount);
expect(statSync).toHaveBeenCalledTimes(reloadedDiscoveryCount);
} finally {
existsSync.mockRestore();
statSync.mockRestore();
}
});