diff --git a/src/commands/doctor/shared/pristine-startup-state.test.ts b/src/commands/doctor/shared/pristine-startup-state.test.ts index d9f668bda9a6..7513352f775e 100644 --- a/src/commands/doctor/shared/pristine-startup-state.test.ts +++ b/src/commands/doctor/shared/pristine-startup-state.test.ts @@ -148,6 +148,51 @@ describe("pristine startup state", () => { expect(canSkipPristineStartupStateMigrations(env)).toBe(true); }); + it("accepts canonical internal hook configuration", () => { + const env = createFixture({ + hooks: { + internal: { + enabled: true, + entries: { + "session-memory": { + enabled: true, + env: { OPENCLAW_HOOK_TEST: "enabled" }, + customOption: "value", + }, + }, + }, + }, + }); + + expect(planPristineStartupStateMigrations(env)).toEqual({ + skipAllStateMigrations: true, + skipCoreStateMigrations: true, + }); + }); + + it("retains migrations for legacy, external, and malformed hook configuration", () => { + const unsafeHooks = [ + { gmail: { account: "operator@example.com" } }, + { internal: { installs: { "session-memory": { source: "bundled" } } } }, + { internal: { handlers: [] } }, + { internal: { load: { extraDirs: ["/tmp/hooks"] } } }, + { internal: { enabled: "yes" } }, + { internal: { entries: [] } }, + { internal: { entries: { "session-memory": { enabled: "yes" } } } }, + { internal: { entries: { "session-memory": { env: { INVALID: true } } } } }, + ]; + + for (const hooks of unsafeHooks) { + expect( + planPristineStartupStateMigrations(createFixture({ hooks })), + JSON.stringify(hooks), + ).toEqual({ + skipAllStateMigrations: false, + skipCoreStateMigrations: false, + }); + } + }); + it("retains migrations for bundled plugins with doctor state surfaces", () => { const env = addBundledPlugin( createFixture({ plugins: { entries: { example: { enabled: true } } } }), diff --git a/src/commands/doctor/shared/pristine-startup-state.ts b/src/commands/doctor/shared/pristine-startup-state.ts index 74820aee496b..786088720a30 100644 --- a/src/commands/doctor/shared/pristine-startup-state.ts +++ b/src/commands/doctor/shared/pristine-startup-state.ts @@ -31,7 +31,6 @@ const STATEFUL_CONFIG_KEYS = new Set([ "cron", "discovery", "env", - "hooks", "marketplaces", "mcp", "media", @@ -48,6 +47,51 @@ const STATEFUL_CONFIG_KEYS = new Set([ "web", ]); +// Canonical internal entries have no legacy machine state to import. Keep every +// older or external hook shape on Doctor's full migration path. +function hasOnlyMigrationSafeInternalHooks(config: Record): boolean { + const hooks = config.hooks; + if (hooks === undefined) { + return true; + } + if (!isRecord(hooks) || Object.keys(hooks).some((key) => key !== "internal")) { + return false; + } + + const internal = hooks.internal; + if (internal === undefined) { + return true; + } + if ( + !isRecord(internal) || + Object.keys(internal).some((key) => !["enabled", "entries"].includes(key)) || + (internal.enabled !== undefined && typeof internal.enabled !== "boolean") + ) { + return false; + } + + if (internal.entries === undefined) { + return true; + } + if (!isRecord(internal.entries)) { + return false; + } + return Object.values(internal.entries).every((entry) => { + if (!isRecord(entry)) { + return false; + } + if (entry.enabled !== undefined && typeof entry.enabled !== "boolean") { + return false; + } + if (entry.env === undefined) { + return true; + } + return ( + isRecord(entry.env) && Object.values(entry.env).every((value) => typeof value === "string") + ); + }); +} + function containsObjectKey(value: unknown, targetKey: string): boolean { if (Array.isArray(value)) { return value.some((entry) => containsObjectKey(entry, targetKey)); @@ -142,6 +186,9 @@ function configIsPristineCoreStateSafe(config: Record): boolean if ([...STATEFUL_CONFIG_KEYS].some((key) => Object.hasOwn(config, key))) { return false; } + if (!hasOnlyMigrationSafeInternalHooks(config)) { + return false; + } if (containsObjectKey(config.agents, "memorySearch")) { return false; }