Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified packages/core/src/__tests__/foreign-session.test.ts
Binary file not shown.
72 changes: 51 additions & 21 deletions packages/core/src/foreign-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,8 +391,21 @@ export function claudeToolFilePaths(record: Record<string, unknown>): 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
Expand All @@ -413,34 +426,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<string, unknown>).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<string, unknown>).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;
Expand Down Expand Up @@ -468,7 +498,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;
Expand Down
58 changes: 58 additions & 0 deletions packages/storage/src/__tests__/codex-session-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<spelling variants>)`, and
// SQLite compares those exactly — a row stored `C:\\Repo\\App` was
Expand Down
22 changes: 3 additions & 19 deletions packages/storage/src/codex-session-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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`. */
Expand Down Expand Up @@ -156,7 +155,7 @@ export class CodexSessionAdapter implements ExternalSessionAdapter {
private async entryFromRow(row: CodexThreadRow): Promise<CodexCatalogEntry | undefined> {
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;
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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;
Expand Down