diff --git a/extensions/codex/doctor-contract-api.test.ts b/extensions/codex/doctor-contract-api.test.ts index 7e33d250957f..4bfea48cf5a1 100644 --- a/extensions/codex/doctor-contract-api.test.ts +++ b/extensions/codex/doctor-contract-api.test.ts @@ -353,6 +353,156 @@ describe("codex doctor contract", () => { 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("matches an owner through the contained fallback for a stale session file locator", async () => { const sessionKey = "agent:main:stale-locator"; const fixture = await createBindingMigrationFixture({ diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index 86b7a47c1976..2cb8b1a95618 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -418,6 +418,21 @@ function normalizeLegacyBindingFingerprints( return normalized; } +export function normalizeStoredCodexAppServerBindingFingerprints( + value: unknown, +): StoredCodexAppServerBinding | undefined { + const stored = readStoredCodexAppServerBinding(value); + if (!stored || stored.state !== "active") { + return stored; + } + const binding = normalizeLegacyBindingFingerprints( + stored.binding as unknown as Record, + ); + return binding === stored.binding + ? stored + : readStoredCodexAppServerBinding({ ...stored, binding }); +} + /** Encodes a migrated sidecar binding as one canonical plugin-state row. */ export function createStoredCodexAppServerBinding( value: unknown, diff --git a/extensions/codex/src/migration/session-binding-sidecars.ts b/extensions/codex/src/migration/session-binding-sidecars.ts index 836014241dec..32087fec712c 100644 --- a/extensions/codex/src/migration/session-binding-sidecars.ts +++ b/extensions/codex/src/migration/session-binding-sidecars.ts @@ -415,7 +415,12 @@ async function migrateSource( ]); const raw = JSON.parse(contents) as Record; const [ - { bindingStoreKey, createStoredCodexAppServerBinding, readStoredCodexAppServerBinding }, + { + bindingStoreKey, + createStoredCodexAppServerBinding, + normalizeStoredCodexAppServerBindingFingerprints, + readStoredCodexAppServerBinding, + }, { legacyCodexConversationBindingId }, ] = await Promise.all([ import("../app-server/session-binding.js"), @@ -458,17 +463,53 @@ async function migrateSource( bindingId: legacyCodexConversationBindingId(sessionFile), }), ); + const normalizeStoredRow = async ( + key: string, + current: MigratedBindingRow, + ): Promise<{ value?: MigratedBindingRow; warning?: string }> => { + const parsed = readStoredCodexAppServerBinding(current); + if (!parsed) { + return { warning: `canonical plugin state is invalid at ${key}` }; + } + const normalized = normalizeStoredCodexAppServerBindingFingerprints(parsed); + if (!normalized) { + return { warning: `canonical plugin state is invalid at ${key}` }; + } + if (isDeepStrictEqual(parsed, normalized)) { + return { value: parsed }; + } + if (parsed.lease && parsed.lease.expiresAt > Date.now()) { + return { warning: `canonical plugin state is leased at ${key}` }; + } + const update = store.update; + if (!update) { + return { warning: `canonical plugin state could not be normalized at ${key}` }; + } + await update(key, (candidate) => { + const candidateParsed = readStoredCodexAppServerBinding(candidate); + if (!candidateParsed || !isDeepStrictEqual(candidateParsed, parsed)) { + return undefined; + } + return normalized; + }); + const persisted = readStoredCodexAppServerBinding(await store.lookup(key)); + if (!persisted || !isDeepStrictEqual(persisted, normalized)) { + return { warning: `canonical plugin state changed at ${key}` }; + } + importedKeys++; + return { value: normalized }; + }; let currentConversation: MigratedBindingRow | undefined; for (const key of conversationKeys) { const current = await store.lookup(key); if (current === undefined) { continue; } - const parsed = readStoredCodexAppServerBinding(current); - if (!parsed) { - return retain(`canonical plugin state is invalid at ${key}`); + const result = await normalizeStoredRow(key, current); + if (result.warning || !result.value) { + return retain(result.warning ?? `canonical plugin state is invalid at ${key}`); } - currentConversation ??= parsed; + currentConversation ??= result.value; } const stored = currentConversation ?? baseStored; const sessionKey = owner @@ -500,7 +541,14 @@ async function migrateSource( }; for (const entry of entries) { const current = await store.lookup(entry.key); - if (current !== undefined && !hasExpected(current, entry.value)) { + if (current === undefined) { + continue; + } + const result = await normalizeStoredRow(entry.key, current); + if (result.warning || !result.value) { + return retain(result.warning ?? `canonical plugin state is invalid at ${entry.key}`); + } + if (!hasExpected(result.value, entry.value)) { return retain(`canonical plugin state changed at ${entry.key}`); } }