From 05f4056e95a6e616687066b356bfe1146f76ab86 Mon Sep 17 00:00:00 2001 From: cat0825 <1759138827@qq.com> Date: Mon, 24 Aug 2026 18:02:28 +0800 Subject: [PATCH 1/2] fix(core,storage): unify the Codex thread source gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The foreign-session scanner and the Codex Session adapter each owned a private set of eligible `source` tokens, so the same Codex thread could be visible through one surface and invisible through the other: - bare `exec` was accepted by the adapter but dropped by the scanner; - bare `atlas`/`chatgpt` and wrapped `{"custom":"cli"}` / `{"custom":"vscode"}` were accepted by the scanner but dropped by the adapter; - a NULL `source` column was admitted by the adapter but dropped by the scanner, hiding threads written by older Codex schemas. Make `CODEX_SUPPORTED_THREAD_SOURCES` in `@maka/core/foreign-session` the single authority (`cli`, `exec`, `vscode`, `atlas`, `chatgpt`) and delete the adapter's duplicate gate. `codexSourceToken` now also accepts an already-parsed object, which is the shape rollout `session_meta` payloads arrive in, and the new `isSupportedCodexThreadSource` states the absent-is-eligible rule once instead of at each call site. Internal subagent threads (`{"subagent":{…}}`) still resolve to no token and stay out of both surfaces. Closes #3693 --- .../src/__tests__/foreign-session.test.ts | Bin 21168 -> 23726 bytes packages/core/src/foreign-session.ts | 72 +++++++++++++----- .../__tests__/codex-session-adapter.test.ts | 58 ++++++++++++++ packages/storage/src/codex-session-adapter.ts | 22 +----- 4 files changed, 112 insertions(+), 40 deletions(-) diff --git a/packages/core/src/__tests__/foreign-session.test.ts b/packages/core/src/__tests__/foreign-session.test.ts index 89398d4791201e569e14bf95a8f4d11d8475e392..199fe6d47ebed21119f91a0ff17e2fe09816e4d6 100644 GIT binary patch delta 1822 zcmaJ>&u<$=6qcn1QD{{K5}_2gyt=K|A)7?4TVf2Q2d<@3NPDVEJ>Gq`hm2=7GqZLr z%W{#pBQa+rE^q@y3KuS@*Zw#C2atHPKboW=@xfm2ym{|?-}~OT|6AqHzbpGcE>yYM zNs~lri}BYg#^;X)8dD~kS zY;Jg$0|F~36grVdpP}ne8s7wrnSl}CHoTjq)++4}e2Fqy#4Ho44@^=< zIpQY*GuRthc%-DUdGq(&_S$9lQ~g2(-7obO_t&TQ&-o_YUQo>+>T5Tj_&4`AcpO52 z!Ri;3WJcfg!)+rt0nC1GZ7v^qoyuLQ#*z-)53O5Qr(n`9>l2G@ zm==O~W)e1*^nd35t-_W@U)c-03@tHn30-9exj?--#+oGwVeTnCBuv-ci(3os_pM8J z%bT_Kw3e$bFXI1GpH%)?@tViAb}gyRX0yO8eiVA{&X>0XSGlt6UbQaG&sxVIgvKh| z`wFI|isB*n_=(zFF9oHv83Ow(nvi}6IP`j=9tuhg7|SNK7n$0R15^#i?P-bodX223}0n8rS1gC&LLKp2A zQTpWtJ66zV7EKV&j}VgCgZG?fI}0lv$G7gU9d{>Wq;--D&6JTJ?NV-oxv5aFd1DbV zMm(mVj4m#?oj}ZcJ@k_mlR3ngpp3l(l2_#$WK!K@5z@x&Vw{_YZ?%y-NU8cBJP9Jf ziT_|^ym$GhY=!7ImpDB{L`FZc!PC`j=-vdGF{teZ?xjq){pPtlr_Ug-RZPJfrZ@F$ z8t&KT(iy|+Ol$eNkz{X7^Lw+}tn+`V$k&d0o40lY32i=cwBAka`Vs3YK+x>KErlyL v&(vB_?6mu)2`i_z={8!I-FCD3@u?Awz-PE|`uUyP)LD-Sg9k5LuUGy9u$poY delta 28 kcmZ3tlX1gR#tkj3o0qV9sck-}_nT*Ow;liHYfd*L0k_QzlmGw# diff --git a/packages/core/src/foreign-session.ts b/packages/core/src/foreign-session.ts index 2813daf350..fdc091fcc6 100644 --- a/packages/core/src/foreign-session.ts +++ b/packages/core/src/foreign-session.ts @@ -392,8 +392,21 @@ export function claudeToolFilePaths(record: Record): string[] { * content. * ------------------------------------------------------------------ */ -/** Codex thread sources eligible for import (per issue #1057). */ -export const CODEX_SUPPORTED_THREAD_SOURCES = ['cli', 'vscode', 'atlas', 'chatgpt'] as const; +/** + * Codex thread sources eligible for import — the single authority for both the + * foreign-session scanner below and the Codex Session adapter in `@maka/storage`. + * #1057 defined the original list; `exec` covers headless `codex exec` runs, + * which #2502 listed as root Sessions from the adapter's first commit. The two + * gates drifted apart while each owned its own token set, so the same thread was + * visible through one surface and invisible through the other. + */ +export const CODEX_SUPPORTED_THREAD_SOURCES = [ + 'cli', + 'exec', + 'vscode', + 'atlas', + 'chatgpt', +] as const; /** * Timestamps below this (2020-01-01 UTC in ms) are treated as seconds and @@ -421,34 +434,51 @@ function normalizeEpochMs(value: unknown): number | undefined { } /** - * Codex persists `source` either as a bare token (`cli`, `vscode`) or as a - * JSON object string (`{"custom":"atlas"}`, `{"custom":"chatgpt"}`). Return - * the canonical token, or undefined when it isn't a supported source — a - * bare-string equality check would silently drop every atlas/chatgpt thread. + * Codex persists `source` as a bare token (`cli`, `exec`, `vscode`), as a JSON + * object string (`{"custom":"atlas"}`), or — in rollout `session_meta` payloads, + * which arrive already parsed — as the object itself. Return the canonical + * token, or undefined when it isn't a supported source: a bare-string equality + * check would silently drop every atlas/chatgpt thread, and a token set that + * only knows the wrapped form would drop the bare one. + * + * Unsupported shapes (notably `{"subagent":{…}}`) resolve to undefined, which is + * what keeps internal subagent threads out of both surfaces. */ export function codexSourceToken(value: unknown): string | undefined { - if (typeof value !== 'string' || value.length === 0) return undefined; - if ((CODEX_SUPPORTED_THREAD_SOURCES as readonly string[]).includes(value)) return value; - if (value.startsWith('{')) { + if (typeof value === 'string') { + if (value.length === 0) return undefined; + if (isSupportedCodexSourceToken(value)) return value; + // Only an object string can carry a `custom` token; anything else is a + // plain unsupported source and must not reach JSON.parse. + if (!value.startsWith('{')) return undefined; try { - const parsed = JSON.parse(value) as unknown; - const custom = - typeof parsed === 'object' && parsed !== null - ? (parsed as Record).custom - : undefined; - if ( - typeof custom === 'string' && - (CODEX_SUPPORTED_THREAD_SOURCES as readonly string[]).includes(custom) - ) { - return custom; - } + return codexSourceToken(JSON.parse(value) as unknown); } catch { return undefined; } } + if (typeof value === 'object' && value !== null) { + const custom = (value as Record).custom; + return typeof custom === 'string' && isSupportedCodexSourceToken(custom) ? custom : undefined; + } return undefined; } +/** + * Whether a recorded `source` makes a Codex thread eligible. An absent source is + * eligible: older Codex schemas have no `source` column and older rollout + * `session_meta` payloads omit the field, so dropping those would hide every + * legacy thread. A present-but-unsupported source is a hard drop. + */ +export function isSupportedCodexThreadSource(value: unknown): boolean { + if (value === undefined || value === null) return true; + return codexSourceToken(value) !== undefined; +} + +function isSupportedCodexSourceToken(value: string): boolean { + return (CODEX_SUPPORTED_THREAD_SOURCES as readonly string[]).includes(value); +} + export interface CodexThreadRow { id?: unknown; rollout_path?: unknown; @@ -476,7 +506,7 @@ export function normalizeCodexThreadRow( if (row.archived === 1 || row.archived === true) return undefined; // A present-but-unsupported source is a hard drop; an absent source column // (older schema) is allowed through — the SELECT simply didn't project it. - if (row.source !== undefined && codexSourceToken(row.source) === undefined) return undefined; + if (!isSupportedCodexThreadSource(row.source)) return undefined; const updatedAtMs = normalizeEpochMs(row.updated_at_ms) ?? normalizeEpochMs(row.updated_at) ?? 0; const title = sanitizeForeignTitle(row.title) || sanitizeForeignTitle(row.first_user_message) || row.id; diff --git a/packages/storage/src/__tests__/codex-session-adapter.test.ts b/packages/storage/src/__tests__/codex-session-adapter.test.ts index e9d92a7951..7add9b40ca 100644 --- a/packages/storage/src/__tests__/codex-session-adapter.test.ts +++ b/packages/storage/src/__tests__/codex-session-adapter.test.ts @@ -129,6 +129,64 @@ describe('CodexSessionAdapter', () => { }); }); + test('lists every thread source the foreign-session scanner accepts (#3693)', async () => { + // The adapter owned its own token set, so bare `atlas`/`chatgpt` and a + // wrapped `{"custom":"cli"}` were dropped here while the scanner in + // `@maka/core/foreign-session` listed them. Both gates now share one + // authority, so the catalog and the scan agree on every shape. + await withCodexHome(async (codexHome) => { + const sources = ['cli', 'exec', 'vscode', 'atlas', 'chatgpt'] as const; + const rows: StateRow[] = []; + for (const [index, source] of sources.entries()) { + const bareId = `codex-bare-${source}`; + const wrappedId = `codex-wrapped-${source}`; + rows.push({ + id: bareId, + rolloutPath: await seedMinimalRollout(codexHome, bareId, false, '/workspace', 'Task'), + cwd: '/workspace', + name: `bare ${source}`, + createdAtMs: 1000 + index, + updatedAtMs: 3000 + index, + archived: false, + source, + }); + rows.push({ + id: wrappedId, + rolloutPath: await seedMinimalRollout(codexHome, wrappedId, false, '/workspace', 'Task'), + cwd: '/workspace', + name: `wrapped ${source}`, + createdAtMs: 1100 + index, + updatedAtMs: 3100 + index, + archived: false, + source: JSON.stringify({ custom: source }), + }); + } + const subagentId = 'codex-subagent-drop'; + rows.push({ + id: subagentId, + rolloutPath: await seedMinimalRollout(codexHome, subagentId, false, '/workspace', 'Task'), + cwd: '/workspace', + name: 'internal child', + createdAtMs: 2000, + updatedAtMs: 4000, + archived: false, + source: '{"subagent":{"thread_spawn":{"parent_thread_id":"parent"}}}', + }); + await seedStateDatabase(codexHome, rows); + + const listed = new Set( + (await new CodexSessionAdapter({ codexHome }).listSessions()).map((session) => session.id), + ); + for (const source of sources) { + assert.ok(listed.has(`codex-bare-${source}`), `bare ${source} was dropped`); + assert.ok(listed.has(`codex-wrapped-${source}`), `wrapped ${source} was dropped`); + } + // Internal subagent threads stay out of the catalog. + assert.equal(listed.has(subagentId), false); + assert.equal(listed.size, sources.length * 2); + }); + }); + test('a Windows path spelling reaches the matcher instead of being lost in SQL', async () => { // The SQL used to prefilter with `cwd IN ()`, and // SQLite compares those exactly — a row stored `C:\\Repo\\App` was diff --git a/packages/storage/src/codex-session-adapter.ts b/packages/storage/src/codex-session-adapter.ts index be3c48c4bc..4c3164db69 100644 --- a/packages/storage/src/codex-session-adapter.ts +++ b/packages/storage/src/codex-session-adapter.ts @@ -22,7 +22,7 @@ import { open, readdir, realpath, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { basename, join, resolve, sep } from 'node:path'; import type { StoredMessage } from '@maka/core/session'; -import { sanitizeForeignTitle } from '@maka/core/foreign-session'; +import { isSupportedCodexThreadSource, sanitizeForeignTitle } from '@maka/core/foreign-session'; import { externalSessionMatchesQuery } from '@maka/core/external-session'; import type { ExternalMakaSession, @@ -38,7 +38,6 @@ const CODEX_ROLLOUT_HEAD_BYTES = 512 * 1024; const CODEX_SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; const CODEX_UNSAFE_PATH_CHARS = /[\u0000-\u001F\u007F\u0080-\u009F\u061C\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/; -const CODEX_ROOT_SOURCE_TOKENS = new Set(['cli', 'exec', 'vscode']); export interface CodexSessionAdapterOptions { /** Codex's state root. Defaults to `$CODEX_HOME`, then `~/.codex`. */ @@ -156,7 +155,7 @@ export class CodexSessionAdapter implements ExternalSessionAdapter { private async entryFromRow(row: CodexThreadRow): Promise { if (!isSafeCodexSessionId(row.id)) return undefined; if (typeof row.rollout_path !== 'string' || row.rollout_path.length === 0) return undefined; - if (!isRootCodexSource(row.source)) return undefined; + if (!isSupportedCodexThreadSource(row.source)) return undefined; const rolloutPath = await this.resolveRolloutPath(row.rollout_path, row.id); if (!rolloutPath) return undefined; @@ -575,7 +574,7 @@ function catalogEntryFromRolloutHead( const payload = asRecord(record.payload); if (!payload) continue; if (record.type === 'session_meta') { - if (!isRootCodexSource(payload.source)) return undefined; + if (!isSupportedCodexThreadSource(payload.source)) return undefined; id = stringField(payload, 'session_id') ?? stringField(payload, 'id') ?? id; cwd = safeCodexCwd(payload.cwd) || cwd; createdAt = @@ -794,21 +793,6 @@ function firstNonEmptyTitle(...values: unknown[]): string | undefined { return undefined; } -function isRootCodexSource(value: unknown): boolean { - if (value === undefined || value === null) return true; - if (typeof value === 'string') { - if (CODEX_ROOT_SOURCE_TOKENS.has(value)) return true; - if (!value.startsWith('{')) return false; - try { - return isRootCodexSource(JSON.parse(value) as unknown); - } catch { - return false; - } - } - if (!isRecord(value)) return false; - return value.custom === 'atlas' || value.custom === 'chatgpt'; -} - function codexErrorAffectsTurnStatus(payload: JsonRecord): boolean { const info = payload.codex_error_info; if (info === 'thread_rollback_failed' || info === 'active_turn_not_steerable') return false; From dd591d148b7d7a4a64c24d8503a7630a998840c1 Mon Sep 17 00:00:00 2001 From: cat0825 <1759138827@qq.com> Date: Sun, 30 Aug 2026 16:56:09 +0800 Subject: [PATCH 2/2] fix(storage): route sqlite sources through shared gate Generated-by: OpenAI Codex --- .../__tests__/foreign-session-store.test.ts | 26 +++++++++++++++++-- packages/storage/src/foreign-session-store.ts | 25 +++++++++++------- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/packages/storage/src/__tests__/foreign-session-store.test.ts b/packages/storage/src/__tests__/foreign-session-store.test.ts index 81e268ead4..72b8f6a86c 100644 --- a/packages/storage/src/__tests__/foreign-session-store.test.ts +++ b/packages/storage/src/__tests__/foreign-session-store.test.ts @@ -112,7 +112,7 @@ type CodexThreadSeed = { title?: string; updatedAtMs?: number; archived?: number; - source?: string; + source?: string | null; rolloutRelPath?: string; }; @@ -179,7 +179,7 @@ async function seedCodexSqliteGen( t.title ?? null, t.updatedAtMs ?? NOW - 60_000, t.archived ?? 0, - t.source ?? 'cli', + t.source === undefined ? 'cli' : t.source, ); } db.close(); @@ -351,6 +351,28 @@ describe('foreign session store — Codex scan', () => { assert.deepEqual((await store.listSessions()).map((s) => s.id).sort(), ['atl', 'gpt']); }); + it('routes every supported sqlite source shape through the shared gate', async () => { + const home = await tempHome(); + await seedCodexSqlite(home, [ + { id: 'bare-exec', cwd: '/repo', source: 'exec' }, + { id: 'bare-atlas', cwd: '/repo', source: 'atlas' }, + { id: 'bare-chatgpt', cwd: '/repo', source: 'chatgpt' }, + { id: 'wrapped-cli', cwd: '/repo', source: '{ "custom": "cli" }' }, + { id: 'wrapped-vscode', cwd: '/repo', source: '{"custom":"vscode"}' }, + { id: 'legacy-null', cwd: '/repo', source: null }, + { id: 'unsupported', cwd: '/repo', source: '{"custom":"other"}' }, + ]); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + assert.deepEqual((await store.listSessions()).map((session) => session.id).sort(), [ + 'bare-atlas', + 'bare-chatgpt', + 'bare-exec', + 'legacy-null', + 'wrapped-cli', + 'wrapped-vscode', + ]); + }); + it('rejects rollout paths that escape ~/.codex', async () => { const home = await tempHome(); const outside = join(home, 'outside.jsonl'); diff --git a/packages/storage/src/foreign-session-store.ts b/packages/storage/src/foreign-session-store.ts index d130110634..e74db57c9b 100644 --- a/packages/storage/src/foreign-session-store.ts +++ b/packages/storage/src/foreign-session-store.ts @@ -47,6 +47,7 @@ import { open, readdir, realpath, stat, type FileHandle } from 'node:fs/promises import { homedir } from 'node:os'; import { basename, join, resolve, sep } from 'node:path'; import { + CODEX_SUPPORTED_THREAD_SOURCES, FOREIGN_SESSION_DIGEST_MAX_READ_BYTES, FOREIGN_SESSION_HEAD_BYTES, FOREIGN_SESSION_SCAN_MAX_AGE_MS, @@ -537,13 +538,6 @@ async function codexStateDbsNewestFirst(codexRoot: string): Promise { } } -/** - * Codex source tokens as stored in the DB — bare for cli/vscode, JSON-wrapped - * for the `custom` variants. Used as bound `source IN (…)` params so archived - * / foreign-source rows are excluded IN SQL (before LIMIT), not after. - */ -const CODEX_SOURCE_SQL_VALUES = ['cli', 'vscode', '{"custom":"atlas"}', '{"custom":"chatgpt"}']; - /** * Read candidate thread rows from one state DB, filtered and ordered in SQL. * undefined = DB unusable (cannot open, or lacks the id/rollout_path columns) @@ -582,8 +576,21 @@ async function readCodexThreadRows( const params: string[] = []; if (columns.has('archived')) where.push('(archived IS NULL OR archived = 0)'); if (columns.has('source')) { - where.push(`source IN (${CODEX_SOURCE_SQL_VALUES.map(() => '?').join(', ')})`); - params.push(...CODEX_SOURCE_SQL_VALUES); + const sourceTokens = [...CODEX_SUPPORTED_THREAD_SOURCES]; + const placeholders = sourceTokens.map(() => '?').join(', '); + // Keep unsupported rows from consuming the bounded SQL window, but + // derive this coarse prefilter from the same token authority as + // normalizeCodexThreadRow(). The JS gate remains authoritative over + // exact shapes after bare, wrapped-custom, and legacy NULL sources + // have survived the query. + where.push(`( + source IS NULL + OR source IN (${placeholders}) + OR CASE WHEN json_valid(source) + THEN json_extract(source, '$.custom') + END IN (${placeholders}) + )`); + params.push(...sourceTokens, ...sourceTokens); } // Filter cwd IN SQL, before LIMIT: otherwise a multi-project store with // many newer threads from other directories fills the LIMIT window and