mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 11:06:04 +00:00
1175 lines
39 KiB
TypeScript
1175 lines
39 KiB
TypeScript
// Codex tests cover doctor contract api plugin behavior.
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import {
|
|
createPluginStateKeyedStoreForTests,
|
|
resetPluginStateStoreForTests,
|
|
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
|
import type {
|
|
OpenKeyedStoreOptions,
|
|
PluginDoctorStateMigrationContext,
|
|
} from "openclaw/plugin-sdk/runtime-doctor";
|
|
import { getSessionEntry, upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
legacyConfigRules,
|
|
normalizeCompatibilityConfig,
|
|
stateMigrations,
|
|
} from "./doctor-contract-api.js";
|
|
import {
|
|
bindingStoreKey,
|
|
CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
|
|
CODEX_APP_SERVER_BINDING_NAMESPACE,
|
|
createStoredCodexAppServerBinding,
|
|
hashCodexAppServerBindingFingerprint,
|
|
type StoredCodexAppServerBinding,
|
|
} from "./src/app-server/session-binding.js";
|
|
import { legacyCodexConversationBindingId } from "./src/conversation-binding-data.js";
|
|
|
|
function createDoctorContext(
|
|
env: NodeJS.ProcessEnv,
|
|
afterRegister?: () => Promise<void>,
|
|
): PluginDoctorStateMigrationContext {
|
|
return {
|
|
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
|
|
const store = createPluginStateKeyedStoreForTests<T>("codex", {
|
|
...options,
|
|
env: options.env ?? env,
|
|
});
|
|
return afterRegister
|
|
? {
|
|
...store,
|
|
async registerIfAbsent(...args: Parameters<typeof store.registerIfAbsent>) {
|
|
const registered = await store.registerIfAbsent(...args);
|
|
await afterRegister();
|
|
return registered;
|
|
},
|
|
}
|
|
: store;
|
|
},
|
|
};
|
|
}
|
|
|
|
function openBindingStore(env: NodeJS.ProcessEnv) {
|
|
return createDoctorContext(env).openPluginStateKeyedStore<StoredCodexAppServerBinding>({
|
|
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
|
|
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
|
|
overflowPolicy: "reject-new",
|
|
});
|
|
}
|
|
|
|
async function createBindingMigrationFixture(options: {
|
|
binding?: Record<string, unknown>;
|
|
name: string;
|
|
sessionIndex?: Record<string, unknown>;
|
|
threadId: string;
|
|
}) {
|
|
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-"));
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
|
const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
|
|
const storePath = path.join(sessionsDir, "sessions.json");
|
|
const transcriptPath = path.join(sessionsDir, `${options.name}.jsonl`);
|
|
const sidecarPath = `${transcriptPath}.codex-app-server.json`;
|
|
await fs.mkdir(sessionsDir, { recursive: true });
|
|
await fs.writeFile(
|
|
transcriptPath,
|
|
`${JSON.stringify({ type: "session", id: options.name })}\n`,
|
|
"utf8",
|
|
);
|
|
if (options.sessionIndex !== undefined) {
|
|
await fs.writeFile(storePath, JSON.stringify(options.sessionIndex), "utf8");
|
|
}
|
|
await fs.writeFile(
|
|
sidecarPath,
|
|
JSON.stringify({
|
|
schemaVersion: 2,
|
|
threadId: options.threadId,
|
|
sessionFile: transcriptPath,
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
pluginAppPolicyContext: {
|
|
fingerprint: "policy-1",
|
|
apps: {},
|
|
pluginAppIds: {},
|
|
},
|
|
...options.binding,
|
|
}),
|
|
"utf8",
|
|
);
|
|
const migration = stateMigrations[0];
|
|
if (!migration) {
|
|
throw new Error("missing Codex binding migration");
|
|
}
|
|
return {
|
|
env,
|
|
migration,
|
|
params: {
|
|
config: {},
|
|
env,
|
|
stateDir,
|
|
oauthDir: path.join(stateDir, "oauth"),
|
|
context: createDoctorContext(env),
|
|
},
|
|
sessionsDir,
|
|
sidecarPath,
|
|
stateDir,
|
|
storePath,
|
|
transcriptPath,
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
resetPluginStateStoreForTests();
|
|
});
|
|
|
|
describe("codex doctor contract", () => {
|
|
it("reports the retired dynamic tools profile config key", () => {
|
|
expect(
|
|
legacyConfigRules[0]?.match({
|
|
codexDynamicToolsProfile: "openclaw-compat",
|
|
codexDynamicToolsLoading: "direct",
|
|
}),
|
|
).toBe(true);
|
|
expect(legacyConfigRules[0]?.match({ codexDynamicToolsLoading: "direct" })).toBe(false);
|
|
});
|
|
|
|
it("reports old approval-routed destructive plugin policy values", () => {
|
|
expect(
|
|
legacyConfigRules[1]?.match({
|
|
allow_destructive_actions: "on-request",
|
|
plugins: {},
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
legacyConfigRules[1]?.match({
|
|
allow_destructive_actions: true,
|
|
plugins: {
|
|
"google-calendar": { allow_destructive_actions: "on-request" },
|
|
},
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
legacyConfigRules[1]?.match({
|
|
allow_destructive_actions: "auto",
|
|
plugins: {
|
|
"google-calendar": { allow_destructive_actions: true },
|
|
},
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
legacyConfigRules[1]?.match({
|
|
allow_destructive_actions: "ask",
|
|
plugins: {
|
|
"google-calendar": { allow_destructive_actions: "ask" },
|
|
},
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
legacyConfigRules[1]?.match({
|
|
allow_destructive_actions: "always",
|
|
plugins: {
|
|
"google-calendar": { allow_destructive_actions: "always" },
|
|
},
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("reports the retired on-failure app-server approval policy", () => {
|
|
expect(legacyConfigRules[2]?.match({ approvalPolicy: "on-failure" })).toBe(true);
|
|
expect(legacyConfigRules[2]?.match({ approvalPolicy: "on-request" })).toBe(false);
|
|
});
|
|
|
|
it("removes the retired dynamic tools profile without dropping other Codex config", () => {
|
|
const original = {
|
|
plugins: {
|
|
entries: {
|
|
codex: {
|
|
enabled: true,
|
|
config: {
|
|
codexDynamicToolsProfile: "openclaw-compat",
|
|
codexDynamicToolsLoading: "direct",
|
|
codexDynamicToolsExclude: ["custom_tool"],
|
|
appServer: { mode: "guardian" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = normalizeCompatibilityConfig({ cfg: original });
|
|
|
|
expect(result.changes).toEqual([
|
|
"Removed retired plugins.entries.codex.config.codexDynamicToolsProfile; Codex app-server always keeps Codex-native workspace tools native.",
|
|
]);
|
|
expect(result.config.plugins?.entries?.codex?.config).toEqual({
|
|
codexDynamicToolsLoading: "direct",
|
|
codexDynamicToolsExclude: ["custom_tool"],
|
|
appServer: { mode: "guardian" },
|
|
});
|
|
expect(original.plugins.entries.codex.config).toHaveProperty("codexDynamicToolsProfile");
|
|
});
|
|
|
|
it("imports and archives shipped binding sidecars", async () => {
|
|
const fixture = await createBindingMigrationFixture({
|
|
name: "session-current",
|
|
sessionIndex: {
|
|
"agent:main:session-1": {
|
|
sessionId: "session-current",
|
|
sessionFile: "session-current.jsonl",
|
|
updatedAt: 1,
|
|
},
|
|
},
|
|
threadId: "thread-1",
|
|
binding: {
|
|
pluginAppPolicyContext: {
|
|
fingerprint: "policy-1",
|
|
apps: {
|
|
app: {
|
|
configKey: "app",
|
|
marketplaceName: "openai-curated",
|
|
pluginName: "plugin",
|
|
allowDestructiveActions: true,
|
|
destructiveApprovalMode: "ask",
|
|
mcpServerNames: [],
|
|
},
|
|
},
|
|
pluginAppIds: {},
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(fixture.migration.detectLegacyState(fixture.params)).resolves.toMatchObject({
|
|
preview: [expect.stringContaining("legacy sidecar")],
|
|
});
|
|
await expect(fixture.migration.migrateLegacyState(fixture.params)).resolves.toMatchObject({
|
|
changes: [expect.stringContaining("Migrated 1")],
|
|
warnings: [],
|
|
});
|
|
|
|
const store = openBindingStore(fixture.env);
|
|
await expect(
|
|
store.lookup(
|
|
bindingStoreKey({
|
|
kind: "session",
|
|
agentId: "main",
|
|
sessionId: "session-current",
|
|
sessionKey: "agent:main:session-1",
|
|
}),
|
|
),
|
|
).resolves.toMatchObject({
|
|
state: "active",
|
|
sessionId: "session-current",
|
|
binding: {
|
|
threadId: "thread-1",
|
|
pluginAppPolicyContext: {
|
|
apps: { app: { destructiveApprovalMode: "ask" } },
|
|
},
|
|
},
|
|
});
|
|
await expect(
|
|
store.lookup(
|
|
bindingStoreKey({
|
|
kind: "conversation",
|
|
bindingId: legacyCodexConversationBindingId(fixture.transcriptPath),
|
|
}),
|
|
),
|
|
).resolves.toMatchObject({ state: "active", binding: { threadId: "thread-1" } });
|
|
await expect(fs.access(`${fixture.sidecarPath}.migrated`)).resolves.toBeUndefined();
|
|
expect(
|
|
getSessionEntry({
|
|
agentId: "main",
|
|
env: fixture.env,
|
|
sessionKey: "agent:main:session-1",
|
|
storePath: fixture.storePath,
|
|
}),
|
|
).toMatchObject({
|
|
sessionId: "session-current",
|
|
agentHarnessId: "codex",
|
|
});
|
|
await expect(
|
|
fs.readFile(fixture.storePath, "utf8").then(JSON.parse),
|
|
).resolves.not.toHaveProperty("agent:main:session-1.agentHarnessId");
|
|
|
|
await fs.rm(fixture.stateDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("bounds oversized legacy fingerprints before plugin-state import", async () => {
|
|
const rawDynamicToolsFingerprint = JSON.stringify([
|
|
{ name: "legacy_large_tool", inputSchema: { description: "dynamic-marker".repeat(8_000) } },
|
|
]);
|
|
const rawUserMcpServersFingerprint = JSON.stringify({
|
|
mcp_servers: {
|
|
legacy: {
|
|
command: "node",
|
|
args: ["user-mcp-marker".repeat(8_000)],
|
|
http_headers: { authorization: "Bearer legacy-secret" },
|
|
},
|
|
},
|
|
});
|
|
const sessionKey = "agent:main:oversized";
|
|
const fixture = await createBindingMigrationFixture({
|
|
name: "oversized",
|
|
sessionIndex: {
|
|
[sessionKey]: {
|
|
sessionId: "oversized",
|
|
sessionFile: "oversized.jsonl",
|
|
},
|
|
},
|
|
threadId: "thread-oversized",
|
|
binding: {
|
|
dynamicToolsFingerprint: rawDynamicToolsFingerprint,
|
|
userMcpServersFingerprint: rawUserMcpServersFingerprint,
|
|
},
|
|
});
|
|
expect((await fs.stat(fixture.sidecarPath)).size).toBeGreaterThan(65_536);
|
|
|
|
await expect(fixture.migration.migrateLegacyState(fixture.params)).resolves.toEqual({
|
|
changes: [
|
|
"Migrated 1 Codex app-server binding sidecar(s) to plugin state and archived the legacy sources",
|
|
],
|
|
warnings: [],
|
|
});
|
|
|
|
const stored = await openBindingStore(fixture.env).lookup(
|
|
bindingStoreKey({
|
|
kind: "session",
|
|
agentId: "main",
|
|
sessionId: "oversized",
|
|
sessionKey,
|
|
}),
|
|
);
|
|
expect(stored).toMatchObject({
|
|
state: "active",
|
|
binding: {
|
|
dynamicToolsFingerprint: hashCodexAppServerBindingFingerprint(rawDynamicToolsFingerprint),
|
|
userMcpServersFingerprint: hashCodexAppServerBindingFingerprint(
|
|
rawUserMcpServersFingerprint,
|
|
),
|
|
},
|
|
});
|
|
expect(Buffer.byteLength(JSON.stringify(stored))).toBeLessThan(65_536);
|
|
expect(JSON.stringify(stored)).not.toContain("dynamic-marker");
|
|
expect(JSON.stringify(stored)).not.toContain("user-mcp-marker");
|
|
expect(JSON.stringify(stored)).not.toContain("legacy-secret");
|
|
await expect(fs.access(fixture.sidecarPath)).rejects.toThrow();
|
|
await expect(fs.access(`${fixture.sidecarPath}.migrated`)).resolves.toBeUndefined();
|
|
await expect(fixture.migration.migrateLegacyState(fixture.params)).resolves.toEqual({
|
|
changes: [],
|
|
warnings: [],
|
|
});
|
|
|
|
await fs.rm(fixture.stateDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("normalizes a partial raw conversation import before copying the session row", async () => {
|
|
const threadId = "thread-partial-import";
|
|
const sessionId = "partial-import";
|
|
const sessionKey = "agent:main:partial-import";
|
|
const emptyConversation: StoredCodexAppServerBinding = {
|
|
version: 1,
|
|
state: "active",
|
|
binding: {
|
|
threadId,
|
|
cwd: "",
|
|
dynamicToolsFingerprint: "",
|
|
},
|
|
};
|
|
const rawFingerprint = "x".repeat(
|
|
65_535 - Buffer.byteLength(JSON.stringify(emptyConversation)),
|
|
);
|
|
const rawConversation: StoredCodexAppServerBinding = {
|
|
...emptyConversation,
|
|
binding: {
|
|
...emptyConversation.binding,
|
|
dynamicToolsFingerprint: rawFingerprint,
|
|
},
|
|
};
|
|
const rawSession = { ...rawConversation, sessionId };
|
|
expect(Buffer.byteLength(JSON.stringify(rawConversation))).toBe(65_535);
|
|
expect(Buffer.byteLength(JSON.stringify(rawSession))).toBeGreaterThan(65_536);
|
|
|
|
const fixture = await createBindingMigrationFixture({
|
|
name: sessionId,
|
|
sessionIndex: {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
sessionFile: `${sessionId}.jsonl`,
|
|
},
|
|
},
|
|
threadId,
|
|
binding: { dynamicToolsFingerprint: rawFingerprint },
|
|
});
|
|
const conversationKey = bindingStoreKey({
|
|
kind: "conversation",
|
|
bindingId: legacyCodexConversationBindingId(fixture.transcriptPath),
|
|
});
|
|
const sessionBindingKey = bindingStoreKey({
|
|
kind: "session",
|
|
agentId: "main",
|
|
sessionId,
|
|
sessionKey,
|
|
});
|
|
const store = openBindingStore(fixture.env);
|
|
await store.register(conversationKey, rawConversation);
|
|
|
|
await expect(fixture.migration.migrateLegacyState(fixture.params)).resolves.toEqual({
|
|
changes: [
|
|
"Migrated 1 Codex app-server binding sidecar(s) to plugin state and archived the legacy sources",
|
|
],
|
|
warnings: [],
|
|
});
|
|
|
|
const expectedFingerprint = hashCodexAppServerBindingFingerprint(rawFingerprint);
|
|
await expect(store.lookup(conversationKey)).resolves.toMatchObject({
|
|
state: "active",
|
|
binding: { dynamicToolsFingerprint: expectedFingerprint },
|
|
});
|
|
await expect(store.lookup(sessionBindingKey)).resolves.toMatchObject({
|
|
state: "active",
|
|
sessionId,
|
|
binding: { dynamicToolsFingerprint: expectedFingerprint },
|
|
});
|
|
await expect(fs.access(fixture.sidecarPath)).rejects.toThrow();
|
|
await expect(fs.access(`${fixture.sidecarPath}.migrated`)).resolves.toBeUndefined();
|
|
await expect(fixture.migration.migrateLegacyState(fixture.params)).resolves.toEqual({
|
|
changes: [],
|
|
warnings: [],
|
|
});
|
|
|
|
await fs.rm(fixture.stateDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("normalizes retained raw conversation and session rows before comparison", async () => {
|
|
const threadId = "thread-retained-import";
|
|
const sessionId = "retained-import";
|
|
const sessionKey = "agent:main:retained-import";
|
|
const rawFingerprint = "x".repeat(60_000);
|
|
const rawConversation: StoredCodexAppServerBinding = {
|
|
version: 1,
|
|
state: "active",
|
|
binding: {
|
|
threadId,
|
|
cwd: "",
|
|
dynamicToolsFingerprint: rawFingerprint,
|
|
},
|
|
};
|
|
const rawSession: StoredCodexAppServerBinding = {
|
|
...rawConversation,
|
|
sessionId,
|
|
};
|
|
expect(Buffer.byteLength(JSON.stringify(rawSession))).toBeLessThan(65_536);
|
|
|
|
const fixture = await createBindingMigrationFixture({
|
|
name: sessionId,
|
|
sessionIndex: {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
sessionFile: `${sessionId}.jsonl`,
|
|
},
|
|
},
|
|
threadId,
|
|
binding: { dynamicToolsFingerprint: rawFingerprint },
|
|
});
|
|
const conversationKey = bindingStoreKey({
|
|
kind: "conversation",
|
|
bindingId: legacyCodexConversationBindingId(fixture.transcriptPath),
|
|
});
|
|
const sessionBindingKey = bindingStoreKey({
|
|
kind: "session",
|
|
agentId: "main",
|
|
sessionId,
|
|
sessionKey,
|
|
});
|
|
const store = openBindingStore(fixture.env);
|
|
await store.register(conversationKey, rawConversation);
|
|
await store.register(sessionBindingKey, rawSession);
|
|
|
|
await expect(fixture.migration.migrateLegacyState(fixture.params)).resolves.toEqual({
|
|
changes: [
|
|
"Migrated 1 Codex app-server binding sidecar(s) to plugin state and archived the legacy sources",
|
|
],
|
|
warnings: [],
|
|
});
|
|
|
|
const expectedFingerprint = hashCodexAppServerBindingFingerprint(rawFingerprint);
|
|
await expect(store.lookup(conversationKey)).resolves.toMatchObject({
|
|
state: "active",
|
|
binding: { dynamicToolsFingerprint: expectedFingerprint },
|
|
});
|
|
await expect(store.lookup(sessionBindingKey)).resolves.toMatchObject({
|
|
state: "active",
|
|
sessionId,
|
|
binding: { dynamicToolsFingerprint: expectedFingerprint },
|
|
});
|
|
await expect(fs.access(fixture.sidecarPath)).rejects.toThrow();
|
|
await expect(fs.access(`${fixture.sidecarPath}.migrated`)).resolves.toBeUndefined();
|
|
await expect(fixture.migration.migrateLegacyState(fixture.params)).resolves.toEqual({
|
|
changes: [],
|
|
warnings: [],
|
|
});
|
|
|
|
await fs.rm(fixture.stateDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("rejects an explicit session file locator outside the session directory", async () => {
|
|
const sessionKey = "agent:main:stale-locator";
|
|
const fixture = await createBindingMigrationFixture({
|
|
name: "stale-locator",
|
|
sessionIndex: {
|
|
[sessionKey]: {
|
|
sessionId: "stale-locator",
|
|
sessionFile: "../outside.jsonl",
|
|
},
|
|
},
|
|
threadId: "thread-stale-locator",
|
|
});
|
|
|
|
const result = await fixture.migration.migrateLegacyState(fixture.params);
|
|
|
|
expect(result.changes).toEqual([]);
|
|
expect(result.warnings).toHaveLength(1);
|
|
expect(result.warnings[0]).toContain("invalid locator");
|
|
await expect(fs.access(fixture.sidecarPath)).resolves.toBeUndefined();
|
|
await expect(fs.access(`${fixture.sidecarPath}.migrated`)).rejects.toThrow();
|
|
expect(
|
|
getSessionEntry({
|
|
agentId: "main",
|
|
env: fixture.env,
|
|
sessionKey,
|
|
storePath: fixture.storePath,
|
|
}),
|
|
).toBeUndefined();
|
|
await expect(openBindingStore(fixture.env).entries()).resolves.toEqual([]);
|
|
|
|
await fs.rm(fixture.stateDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("deduplicates session-store aliases before classifying binding ownership", async () => {
|
|
const fixture = await createBindingMigrationFixture({
|
|
name: "aliased-store",
|
|
sessionIndex: {
|
|
"agent:main:aliased-store": {
|
|
sessionId: "aliased-store",
|
|
sessionFile: "aliased-store.jsonl",
|
|
},
|
|
},
|
|
threadId: "thread-aliased-store",
|
|
});
|
|
await fs.writeFile(
|
|
path.join(fixture.sessionsDir, "sessions.json"),
|
|
JSON.stringify({
|
|
"agent:main:aliased-store": {
|
|
sessionId: "aliased-store",
|
|
sessionFile: fixture.transcriptPath,
|
|
},
|
|
}),
|
|
"utf8",
|
|
);
|
|
const storeAlias = path.join(fixture.stateDir, "sessions-alias.json");
|
|
await fs.symlink(path.join(fixture.sessionsDir, "sessions.json"), storeAlias);
|
|
|
|
const result = await fixture.migration.migrateLegacyState({
|
|
...fixture.params,
|
|
config: { session: { store: storeAlias } },
|
|
});
|
|
|
|
expect(result.warnings).toEqual([]);
|
|
await expect(fs.access(`${fixture.sidecarPath}.migrated`)).resolves.toBeUndefined();
|
|
const configuredIndex = JSON.parse(await fs.readFile(storeAlias, "utf8")) as Record<
|
|
string,
|
|
Record<string, unknown>
|
|
>;
|
|
const targetIndex = JSON.parse(
|
|
await fs.readFile(path.join(fixture.sessionsDir, "sessions.json"), "utf8"),
|
|
) as Record<string, Record<string, unknown>>;
|
|
expect(
|
|
getSessionEntry({
|
|
agentId: "main",
|
|
env: fixture.env,
|
|
sessionKey: "agent:main:aliased-store",
|
|
storePath: storeAlias,
|
|
}),
|
|
).toMatchObject({ agentHarnessId: "codex" });
|
|
expect(configuredIndex["agent:main:aliased-store"]).not.toHaveProperty("agentHarnessId");
|
|
expect(targetIndex["agent:main:aliased-store"]).not.toHaveProperty("agentHarnessId");
|
|
|
|
await fs.rm(fixture.stateDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("resolves relative session files from a symlinked store path", async () => {
|
|
const sessionKey = "agent:main:symlinked-store";
|
|
const fixture = await createBindingMigrationFixture({
|
|
name: "symlinked-store",
|
|
sessionIndex: {
|
|
[sessionKey]: {
|
|
sessionId: "symlinked-store",
|
|
sessionFile: "symlinked-store.jsonl",
|
|
},
|
|
},
|
|
threadId: "thread-symlinked-store",
|
|
});
|
|
const configuredDir = path.join(fixture.stateDir, "configured-sessions");
|
|
const configuredStore = path.join(configuredDir, "sessions.json");
|
|
const configuredTranscript = path.join(configuredDir, "symlinked-store.jsonl");
|
|
const configuredSidecar = `${configuredTranscript}.codex-app-server.json`;
|
|
await fs.mkdir(configuredDir, { recursive: true });
|
|
await fs.rename(fixture.transcriptPath, configuredTranscript);
|
|
await fs.rename(fixture.sidecarPath, configuredSidecar);
|
|
const sidecar = JSON.parse(await fs.readFile(configuredSidecar, "utf8")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
await fs.writeFile(
|
|
configuredSidecar,
|
|
JSON.stringify({ ...sidecar, sessionFile: configuredTranscript }),
|
|
"utf8",
|
|
);
|
|
await fs.symlink(path.join(fixture.sessionsDir, "sessions.json"), configuredStore);
|
|
|
|
const result = await fixture.migration.migrateLegacyState({
|
|
...fixture.params,
|
|
config: { session: { store: configuredStore } },
|
|
});
|
|
|
|
expect(result.warnings).toEqual([]);
|
|
await expect(fs.access(`${configuredSidecar}.migrated`)).resolves.toBeUndefined();
|
|
expect(
|
|
getSessionEntry({
|
|
agentId: "main",
|
|
env: fixture.env,
|
|
sessionKey,
|
|
storePath: configuredStore,
|
|
}),
|
|
).toMatchObject({ agentHarnessId: "codex" });
|
|
await expect(fs.readFile(configuredStore, "utf8").then(JSON.parse)).resolves.not.toHaveProperty(
|
|
`${sessionKey}.agentHarnessId`,
|
|
);
|
|
|
|
await fs.rm(fixture.stateDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it.each([
|
|
{ label: "new", preexisting: false },
|
|
{ label: "pre-existing", preexisting: true },
|
|
])(
|
|
"retires a $label session row when its owner rebinds during migration",
|
|
async ({ preexisting }) => {
|
|
const sessionKey = "agent:main:session-1";
|
|
const fixture = await createBindingMigrationFixture({
|
|
name: "session-current",
|
|
sessionIndex: {
|
|
[sessionKey]: {
|
|
sessionId: "session-current",
|
|
sessionFile: "session-current.jsonl",
|
|
lifecycleRevision: "rev-1",
|
|
},
|
|
},
|
|
threadId: "thread-1",
|
|
});
|
|
const sessionBindingKey = bindingStoreKey({
|
|
kind: "session",
|
|
agentId: "main",
|
|
sessionId: "session-current",
|
|
sessionKey,
|
|
});
|
|
const imported = createStoredCodexAppServerBinding(
|
|
JSON.parse(await fs.readFile(fixture.sidecarPath, "utf8")),
|
|
);
|
|
if (!imported) {
|
|
throw new Error("missing imported Codex binding");
|
|
}
|
|
const store = openBindingStore(fixture.env);
|
|
if (preexisting) {
|
|
await store.register(sessionBindingKey, { ...imported, sessionId: "session-current" });
|
|
}
|
|
let rebound = false;
|
|
const context = createDoctorContext(fixture.env, async () => {
|
|
if (rebound) {
|
|
return;
|
|
}
|
|
rebound = true;
|
|
await upsertSessionEntry({
|
|
agentId: "main",
|
|
env: fixture.env,
|
|
sessionKey,
|
|
storePath: fixture.storePath,
|
|
entry: {
|
|
sessionId: "session-current",
|
|
lifecycleRevision: "rev-2",
|
|
updatedAt: Date.now(),
|
|
},
|
|
});
|
|
});
|
|
|
|
const result = await fixture.migration.migrateLegacyState({ ...fixture.params, context });
|
|
|
|
expect(result.warnings).toEqual([
|
|
expect.stringContaining("session owner changed before Codex ownership could be recorded"),
|
|
]);
|
|
await expect(fs.access(fixture.sidecarPath)).resolves.toBeUndefined();
|
|
await expect(fs.access(`${fixture.sidecarPath}.migrated`)).rejects.toThrow();
|
|
await expect(
|
|
fs.readFile(path.join(fixture.sessionsDir, "sessions.json"), "utf8").then(JSON.parse),
|
|
).resolves.not.toHaveProperty(`${sessionKey}.agentHarnessId`);
|
|
expect(
|
|
getSessionEntry({
|
|
agentId: "main",
|
|
env: fixture.env,
|
|
sessionKey,
|
|
storePath: fixture.storePath,
|
|
}),
|
|
).toMatchObject({ lifecycleRevision: "rev-2" });
|
|
await expect(store.lookup(sessionBindingKey)).resolves.toMatchObject({
|
|
version: 1,
|
|
state: "cleared",
|
|
sessionId: "session-current",
|
|
retired: true,
|
|
});
|
|
|
|
await fs.rm(fixture.stateDir, { recursive: true, force: true });
|
|
},
|
|
);
|
|
|
|
it("retires an imported row when its locator escapes during owner revalidation", async () => {
|
|
const sessionKey = "agent:main:locator-race";
|
|
const fixture = await createBindingMigrationFixture({
|
|
name: "locator-race",
|
|
sessionIndex: {
|
|
[sessionKey]: {
|
|
sessionId: "locator-race",
|
|
sessionFile: "locator-race.jsonl",
|
|
},
|
|
},
|
|
threadId: "thread-locator-race",
|
|
});
|
|
let rebound = false;
|
|
const context = createDoctorContext(fixture.env, async () => {
|
|
if (rebound) {
|
|
return;
|
|
}
|
|
rebound = true;
|
|
await fs.writeFile(
|
|
fixture.storePath,
|
|
JSON.stringify({
|
|
[sessionKey]: {
|
|
sessionId: "locator-race",
|
|
sessionFile: "../outside.jsonl",
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
const result = await fixture.migration.migrateLegacyState({ ...fixture.params, context });
|
|
|
|
expect(result.warnings).toEqual([
|
|
expect.stringContaining("session owner changed before Codex ownership could be recorded"),
|
|
]);
|
|
await expect(fs.access(fixture.sidecarPath)).resolves.toBeUndefined();
|
|
await expect(fs.access(`${fixture.sidecarPath}.migrated`)).rejects.toThrow();
|
|
await expect(
|
|
openBindingStore(fixture.env).lookup(
|
|
bindingStoreKey({
|
|
kind: "session",
|
|
agentId: "main",
|
|
sessionId: "locator-race",
|
|
sessionKey,
|
|
}),
|
|
),
|
|
).resolves.toMatchObject({
|
|
version: 1,
|
|
state: "cleared",
|
|
sessionId: "locator-race",
|
|
retired: true,
|
|
});
|
|
|
|
await fs.rm(fixture.stateDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("does not resurrect a retired session generation from its legacy sidecar", async () => {
|
|
const sessionKey = "agent:main:retired";
|
|
const fixture = await createBindingMigrationFixture({
|
|
name: "retired",
|
|
sessionIndex: {
|
|
[sessionKey]: {
|
|
sessionId: "retired",
|
|
sessionFile: "retired.jsonl",
|
|
},
|
|
},
|
|
threadId: "thread-retired",
|
|
});
|
|
const store = openBindingStore(fixture.env);
|
|
const active = createStoredCodexAppServerBinding(
|
|
JSON.parse(await fs.readFile(fixture.sidecarPath, "utf8")),
|
|
);
|
|
if (!active) {
|
|
throw new Error("missing imported Codex binding");
|
|
}
|
|
await store.register(
|
|
bindingStoreKey({
|
|
kind: "conversation",
|
|
bindingId: legacyCodexConversationBindingId(fixture.transcriptPath),
|
|
}),
|
|
active,
|
|
);
|
|
const sessionBindingKey = bindingStoreKey({
|
|
kind: "session",
|
|
agentId: "main",
|
|
sessionId: "retired",
|
|
sessionKey,
|
|
});
|
|
const retired: StoredCodexAppServerBinding = {
|
|
version: 1,
|
|
state: "cleared",
|
|
sessionId: "retired",
|
|
retired: true,
|
|
};
|
|
await store.register(sessionBindingKey, retired);
|
|
|
|
const result = await fixture.migration.migrateLegacyState(fixture.params);
|
|
|
|
expect(result.changes).toEqual([]);
|
|
expect(result.warnings).toEqual([
|
|
expect.stringContaining(`canonical plugin state changed at ${sessionBindingKey}`),
|
|
]);
|
|
await expect(fs.access(fixture.sidecarPath)).resolves.toBeUndefined();
|
|
await expect(store.lookup(sessionBindingKey)).resolves.toEqual(retired);
|
|
await expect(
|
|
fs.readFile(path.join(fixture.sessionsDir, "sessions.json"), "utf8").then(JSON.parse),
|
|
).resolves.not.toHaveProperty(`${sessionKey}.agentHarnessId`);
|
|
|
|
await fs.rm(fixture.stateDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it.each(["active", "cleared"] as const)(
|
|
"archives zero-owner sidecars without changing imported $state conversation state",
|
|
async (state) => {
|
|
const fixture = await createBindingMigrationFixture({
|
|
name: `orphan-${state}`,
|
|
threadId: "thread-orphan",
|
|
});
|
|
const bindingKey = bindingStoreKey({
|
|
kind: "conversation",
|
|
bindingId: legacyCodexConversationBindingId(fixture.transcriptPath),
|
|
});
|
|
const active = createStoredCodexAppServerBinding(
|
|
JSON.parse(await fs.readFile(fixture.sidecarPath, "utf8")),
|
|
);
|
|
if (!active) {
|
|
throw new Error("missing imported Codex binding");
|
|
}
|
|
const existing: StoredCodexAppServerBinding =
|
|
state === "active" ? active : { version: 1, state: "cleared", retired: true };
|
|
const store = openBindingStore(fixture.env);
|
|
await store.register(bindingKey, existing);
|
|
|
|
await expect(fixture.migration.migrateLegacyState(fixture.params)).resolves.toEqual({
|
|
changes: [
|
|
"Migrated 1 Codex app-server binding sidecar(s) to plugin state and archived the legacy sources",
|
|
],
|
|
warnings: [],
|
|
});
|
|
await expect(fs.access(fixture.sidecarPath)).rejects.toThrow();
|
|
await expect(fs.access(`${fixture.sidecarPath}.migrated`)).resolves.toBeUndefined();
|
|
await expect(store.lookup(bindingKey)).resolves.toEqual(existing);
|
|
await expect(fixture.migration.detectLegacyState(fixture.params)).resolves.toBeNull();
|
|
await expect(fixture.migration.migrateLegacyState(fixture.params)).resolves.toEqual({
|
|
changes: [],
|
|
warnings: [],
|
|
});
|
|
|
|
await fs.rm(fixture.stateDir, { recursive: true, force: true });
|
|
},
|
|
);
|
|
|
|
it("ignores metadata-only session rows when proving zero ownership", async () => {
|
|
const fixture = await createBindingMigrationFixture({
|
|
name: "orphan-metadata-row",
|
|
sessionIndex: {
|
|
"agent:main:metadata-only": {
|
|
label: "Waiting for first turn",
|
|
updatedAt: 1,
|
|
},
|
|
},
|
|
threadId: "thread-orphan",
|
|
});
|
|
|
|
await expect(fixture.migration.migrateLegacyState(fixture.params)).resolves.toEqual({
|
|
changes: [
|
|
"Migrated 1 Codex app-server binding sidecar(s) to plugin state and archived the legacy sources",
|
|
],
|
|
warnings: [],
|
|
});
|
|
await expect(fs.access(fixture.sidecarPath)).rejects.toThrow();
|
|
await expect(fs.access(`${fixture.sidecarPath}.migrated`)).resolves.toBeUndefined();
|
|
|
|
await fs.rm(fixture.stateDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("retains a zero-owner sidecar when canonical plugin state is malformed", async () => {
|
|
const fixture = await createBindingMigrationFixture({
|
|
name: "orphan-invalid-state",
|
|
threadId: "thread-orphan",
|
|
});
|
|
const bindingKey = bindingStoreKey({
|
|
kind: "conversation",
|
|
bindingId: legacyCodexConversationBindingId(fixture.transcriptPath),
|
|
});
|
|
const store = createDoctorContext(fixture.env).openPluginStateKeyedStore<unknown>({
|
|
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
|
|
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
|
|
overflowPolicy: "reject-new",
|
|
});
|
|
const malformed = { version: 1, state: "active" };
|
|
await store.register(bindingKey, malformed);
|
|
|
|
const result = await fixture.migration.migrateLegacyState(fixture.params);
|
|
|
|
expect(result.changes).toEqual([]);
|
|
expect(result.warnings).toEqual([
|
|
expect.stringContaining(`canonical plugin state is invalid at ${bindingKey}`),
|
|
]);
|
|
await expect(fs.access(fixture.sidecarPath)).resolves.toBeUndefined();
|
|
await expect(store.lookup(bindingKey)).resolves.toEqual(malformed);
|
|
|
|
await fs.rm(fixture.stateDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("retains mixed Codex and foreign ambiguous binding owners", async () => {
|
|
const fixture = await createBindingMigrationFixture({
|
|
name: "shared",
|
|
sessionIndex: {
|
|
"agent:main:first": {
|
|
sessionId: "first",
|
|
sessionFile: "shared.jsonl",
|
|
agentHarnessId: "codex",
|
|
},
|
|
"agent:main:second": {
|
|
sessionId: "second",
|
|
sessionFile: "shared.jsonl",
|
|
agentHarnessId: "pi",
|
|
},
|
|
},
|
|
threadId: "thread-shared",
|
|
});
|
|
|
|
const result = await fixture.migration.migrateLegacyState(fixture.params);
|
|
|
|
expect(result.changes).toEqual([]);
|
|
expect(result.warnings).toHaveLength(1);
|
|
expect(result.warnings[0]).toContain("2 matching session owners make ownership ambiguous");
|
|
await expect(fs.access(fixture.sidecarPath)).resolves.toBeUndefined();
|
|
await expect(openBindingStore(fixture.env).entries()).resolves.toEqual([]);
|
|
|
|
await fs.rm(fixture.stateDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("retains a sidecar owned by a foreign harness without importing plugin state", async () => {
|
|
const fixture = await createBindingMigrationFixture({
|
|
name: "foreign",
|
|
sessionIndex: {
|
|
"agent:main:foreign": {
|
|
sessionId: "foreign",
|
|
sessionFile: "foreign.jsonl",
|
|
agentHarnessId: "pi",
|
|
},
|
|
},
|
|
threadId: "thread-foreign",
|
|
});
|
|
|
|
const result = await fixture.migration.migrateLegacyState(fixture.params);
|
|
|
|
expect(result.changes).toEqual([]);
|
|
expect(result.warnings).toEqual([expect.stringContaining("owned by agent harness pi")]);
|
|
await expect(fs.access(fixture.sidecarPath)).resolves.toBeUndefined();
|
|
await expect(openBindingStore(fixture.env).entries()).resolves.toEqual([]);
|
|
|
|
await fs.rm(fixture.stateDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it.each([
|
|
{ contents: "{", detail: "invalid JSON", label: "invalid JSON" },
|
|
{
|
|
contents: JSON.stringify({
|
|
"agent:main:invalid": { sessionId: "invalid", agentHarnessId: 42 },
|
|
}),
|
|
detail: "invalid entries",
|
|
label: "malformed harness metadata",
|
|
},
|
|
{
|
|
contents: JSON.stringify({
|
|
"agent:main:unsafe": { sessionId: "../unsafe", sessionFile: "unsafe.jsonl" },
|
|
}),
|
|
detail: "invalid entries",
|
|
label: "unsafe session id",
|
|
},
|
|
])("retains binding sidecars for an indeterminate $label index", async ({ contents, detail }) => {
|
|
const fixture = await createBindingMigrationFixture({
|
|
name: "unknown-owner",
|
|
threadId: "thread-unknown-owner",
|
|
});
|
|
const externalDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-store-"));
|
|
const externalStore = path.join(externalDir, "sessions.json");
|
|
await fs.writeFile(externalStore, contents, "utf8");
|
|
const params = {
|
|
...fixture.params,
|
|
config: { session: { store: externalStore } },
|
|
};
|
|
|
|
const result = await fixture.migration.migrateLegacyState(params);
|
|
|
|
expect(result.changes).toEqual([]);
|
|
expect(result.warnings).toHaveLength(1);
|
|
expect(result.warnings[0]).toContain("session index");
|
|
expect(result.warnings[0]).toContain(detail);
|
|
await expect(fs.access(fixture.sidecarPath)).resolves.toBeUndefined();
|
|
await expect(fs.access(`${fixture.sidecarPath}.migrated`)).rejects.toThrow();
|
|
await expect(openBindingStore(fixture.env).entries()).resolves.toEqual([]);
|
|
|
|
await Promise.all([
|
|
fs.rm(fixture.stateDir, { recursive: true, force: true }),
|
|
fs.rm(externalDir, { recursive: true, force: true }),
|
|
]);
|
|
});
|
|
|
|
it("does not scan above stateDir or follow escaped external store locators", async () => {
|
|
const outerDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-outer-"));
|
|
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-outside-"));
|
|
const stateDir = path.join(outerDir, "state");
|
|
await fs.mkdir(stateDir, { recursive: true });
|
|
const strayDir = path.join(outerDir, "unrelated");
|
|
await fs.mkdir(strayDir, { recursive: true });
|
|
const externalStore = path.join(outerDir, "sessions.json");
|
|
await fs.writeFile(
|
|
path.join(strayDir, "foreign.jsonl.codex-app-server.json"),
|
|
JSON.stringify({ schemaVersion: 2, threadId: "thread-foreign" }),
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(outsideDir, "foreign.jsonl.codex-app-server.json"),
|
|
JSON.stringify({ schemaVersion: 2, threadId: "thread-escaped" }),
|
|
"utf8",
|
|
);
|
|
await fs.symlink(outsideDir, path.join(outerDir, "escaped"));
|
|
await fs.writeFile(
|
|
externalStore,
|
|
JSON.stringify({
|
|
"agent:main:foreign": {
|
|
sessionId: "foreign",
|
|
// The transcript is missing, but the sidecar exists through an
|
|
// escaping symlink. Containment must resolve the existing ancestor.
|
|
sessionFile: "escaped/foreign.jsonl",
|
|
},
|
|
}),
|
|
"utf8",
|
|
);
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
|
const params = {
|
|
// The store directory is exactly stateDir's parent. It stays indexed-only,
|
|
// and its explicit locator cannot escape that directory.
|
|
config: { session: { store: externalStore } },
|
|
env,
|
|
stateDir,
|
|
oauthDir: path.join(stateDir, "oauth"),
|
|
context: createDoctorContext(env),
|
|
};
|
|
const migration = stateMigrations[0];
|
|
if (!migration) {
|
|
throw new Error("missing Codex binding migration");
|
|
}
|
|
|
|
await expect(migration.detectLegacyState(params)).resolves.toBeNull();
|
|
|
|
await Promise.all([
|
|
fs.rm(outerDir, { recursive: true, force: true }),
|
|
fs.rm(outsideDir, { recursive: true, force: true }),
|
|
]);
|
|
});
|
|
|
|
it("renames old approval-routed destructive plugin policy values", () => {
|
|
const original = {
|
|
plugins: {
|
|
entries: {
|
|
codex: {
|
|
enabled: true,
|
|
config: {
|
|
codexDynamicToolsProfile: "openclaw-compat",
|
|
codexPlugins: {
|
|
enabled: true,
|
|
allow_destructive_actions: "on-request",
|
|
plugins: {
|
|
"google-calendar": {
|
|
enabled: true,
|
|
allow_destructive_actions: "on-request",
|
|
},
|
|
slack: {
|
|
enabled: true,
|
|
allow_destructive_actions: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = normalizeCompatibilityConfig({ cfg: original });
|
|
|
|
expect(result.changes).toEqual([
|
|
"Removed retired plugins.entries.codex.config.codexDynamicToolsProfile; Codex app-server always keeps Codex-native workspace tools native.",
|
|
'Renamed plugins.entries.codex.config.codexPlugins allow_destructive_actions="on-request" values to "auto".',
|
|
]);
|
|
expect(result.config.plugins?.entries?.codex?.config).toEqual({
|
|
codexPlugins: {
|
|
enabled: true,
|
|
allow_destructive_actions: "auto",
|
|
plugins: {
|
|
"google-calendar": {
|
|
enabled: true,
|
|
allow_destructive_actions: "auto",
|
|
},
|
|
slack: {
|
|
enabled: true,
|
|
allow_destructive_actions: false,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(
|
|
original.plugins.entries.codex.config.codexPlugins.plugins["google-calendar"]
|
|
.allow_destructive_actions,
|
|
).toBe("on-request");
|
|
});
|
|
|
|
it("renames the retired app-server on-failure approval policy", () => {
|
|
const original = {
|
|
plugins: {
|
|
entries: {
|
|
codex: {
|
|
enabled: true,
|
|
config: {
|
|
appServer: {
|
|
approvalPolicy: "on-failure",
|
|
sandbox: "workspace-write",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = normalizeCompatibilityConfig({ cfg: original });
|
|
|
|
expect(result.changes).toEqual([
|
|
'Renamed plugins.entries.codex.config.appServer.approvalPolicy="on-failure" to "on-request".',
|
|
]);
|
|
expect(result.config.plugins?.entries?.codex?.config).toEqual({
|
|
appServer: {
|
|
approvalPolicy: "on-request",
|
|
sandbox: "workspace-write",
|
|
},
|
|
});
|
|
expect(original.plugins.entries.codex.config.appServer.approvalPolicy).toBe("on-failure");
|
|
});
|
|
});
|