diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index 59652bbaef..d87aa4895a 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -53,6 +53,10 @@ Récupération explicite destinée aux anciennes versions de développement qui Il s'agit d'un réétiquetage large et destructif : chaque fil contenant un message utilisateur et actuellement marqué `opencodex` passe à `openai`, `exec` est normalisé en `cli` et l'indicateur d'événement est activé. L'historique légitime d'un fournisseur dédié est également concerné. Sauvegardez l'état et n'exécutez la commande que si vous souhaitez cette portée complète. +### `ocx recover-history --ocx-compaction --yes` + +Réparez l'historique d'une tâche compactée par un fournisseur routé avant de la reprendre avec Codex natif. La commande sélectionne exactement une tâche par UUID, enregistre d'abord une sauvegarde privée octet par octet, puis convertit uniquement l'état de compaction `ocx1:` propre à OpenCodeX en résumé ordinaire relisible par Codex natif. Le contenu chiffré natif et les autres tâches restent inchangés. Fermez la tâche sélectionnée avant d'exécuter la commande ; toute modification simultanée du rollout interrompt la récupération sans remplacer le fichier. + ### `ocx uninstall` · `ocx remove` Arrête le service et le proxy, supprime le service et le shim Codex, rétablit le fonctionnement natif de Codex, puis supprime la configuration locale d’opencodex uniquement si toutes les étapes de restauration ont réussi. `remove` est un alias de `uninstall`. Le nettoyage de la configuration exige les métadonnées de propriété créées par une installation récente ; les répertoires anciens ou partagés sont conservés. diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index b187ff7fd3..f889c721bb 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -53,6 +53,10 @@ ocx eject back これは広範囲で破壊的な再ラベル付けです。ユーザーメッセージを持ち、現在 `opencodex` とタグ付けされているすべてのスレッドを `openai` に変更し、`exec` を `cli` に正規化してイベントマーカーを設定します。正当な専用プロバイダー履歴も対象です。状態をバックアップし、この全範囲を意図する場合にのみ実行してください。 +### `ocx recover-history --ocx-compaction --yes` + +ルーティングされたプロバイダーで圧縮されたタスクをネイティブ Codex で再開する前に、その履歴を修復します。このコマンドは UUID で 1 つのタスクだけを選択し、非公開のバイト単位バックアップを保存してから、OpenCodeX 所有の `ocx1:` 圧縮状態だけをネイティブ Codex が再生できる通常の要約に変換します。ネイティブの暗号化コンテンツと他のタスクは変更しません。実行前に対象タスクを閉じてください。処理中に rollout が変更された場合、ファイルを置き換えずに修復を中止します。 + ### `ocx uninstall`・`ocx remove` すべての復元手順が成功した場合にのみ、サービスとプロキシを停止し、サービスと Codex シムを削除し、ネイティブ Codex を復元してから、opencodex ローカル設定を削除します。 `remove` は `uninstall` の別名です。設定のクリーンアップには、新規インストールによって作成された所有権メタデータが必要です。従来のディレクトリまたは共有ディレクトリはそのまま残ります。 diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 068807025b..21bed30b3e 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -71,6 +71,10 @@ ocx eject back thread를 `openai`로 바꾸고, `exec`를 `cli`로 정규화하며 event marker를 설정합니다. 정상적인 dedicated-provider history도 포함됩니다. 상태를 백업하고 이 전체 범위를 의도한 경우에만 실행하세요. +### `ocx recover-history --ocx-compaction --yes` + +라우팅된 provider를 통해 압축된 작업을 native Codex에서 다시 열기 전에 해당 기록을 복구합니다. 이 명령은 UUID로 정확히 하나의 작업을 선택하고 비공개 바이트 단위 백업을 저장한 뒤, OpenCodeX가 소유한 `ocx1:` 압축 상태만 native Codex가 재생할 수 있는 일반 요약으로 변환합니다. native 암호화 콘텐츠와 다른 작업은 변경하지 않습니다. 실행 전에 선택한 작업을 닫으십시오. 처리 중 rollout이 변경되면 파일을 교체하지 않고 복구를 중단합니다. + ### `ocx uninstall` · `ocx remove` 서비스와 프록시를 중지하고, 서비스와 Codex shim을 제거한 뒤, 기본 Codex를 복원합니다. 그 다음 diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 0dda487b3a..b3ecd894b3 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -77,6 +77,15 @@ changed to `openai`, `exec` is normalized to `cli`, and the event marker is set. legitimate dedicated-provider history. Back up the state and run it only when that full scope is intended. +### `ocx recover-history --ocx-compaction --yes` + +Repair one thread that was compacted through a routed provider before resuming it through native +Codex. The command reads the exact thread selected by UUID, saves a private byte-for-byte backup, +then converts only OpenCodeX-owned `ocx1:` compaction state into a plain summary that native Codex +can replay. Native encrypted content and other threads are left unchanged. Close the selected +thread before running the command; a concurrent rollout change makes recovery stop without +replacing the file. + ### `ocx uninstall` · `ocx remove` Stop the service and proxy, remove the service and Codex shim, restore native Codex, then remove diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 7be5d5ad77..2f6b9e227b 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -78,6 +78,10 @@ ocx eject back устанавливается. Корректная история выделенного провайдера тоже входит в охват. Сначала сделайте резервную копию и запускайте команду только если нужен весь этот охват. +### `ocx recover-history --ocx-compaction --yes` + +Исправьте историю одной задачи, сжатой через маршрутизируемого провайдера, перед её возобновлением в нативном Codex. Команда выбирает ровно одну задачу по UUID, сначала сохраняет приватную побайтовую резервную копию, а затем преобразует только принадлежащее OpenCodeX состояние сжатия `ocx1:` в обычную сводку, которую может воспроизвести нативный Codex. Нативное зашифрованное содержимое и другие задачи не изменяются. Перед запуском закройте выбранную задачу; если rollout изменится во время обработки, восстановление остановится без замены файла. + ### `ocx uninstall` · `ocx remove` Остановить службу и прокси, удалить службу и Codex shim, восстановить native Codex, а затем diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index 6a7a565139..0aa50bfebf 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -84,6 +84,10 @@ Bu, geniş kapsamlı ve yıkıcı bir yeniden etiketlemedir: kullanıcı iletisi olarak normalleştirilir ve event marker ayarlanır. Geçerli dedicated-provider geçmişi de kapsama dahildir. Durumu yedekleyin ve yalnızca bu kapsamın tamamını istiyorsanız çalıştırın. +### `ocx recover-history --ocx-compaction --yes` + +Yönlendirilmiş bir sağlayıcı üzerinden sıkıştırılmış bir görevi yerel Codex ile sürdürmeden önce geçmişini onarın. Komut UUID ile yalnızca bir görevi seçer, önce özel ve bayt bayt bir yedek kaydeder, ardından yalnızca OpenCodeX'e ait `ocx1:` sıkıştırma durumunu yerel Codex'in yeniden oynatabileceği düz bir özete dönüştürür. Yerel şifreli içerik ve diğer görevler değişmeden kalır. Komutu çalıştırmadan önce seçili görevi kapatın; işlem sırasında rollout değişirse kurtarma dosyayı değiştirmeden durur. + ### `ocx uninstall` · `ocx remove` Servisi ve proxy'yi durdurun, servisi ve Codex dolgusunu kaldırın, yerel Codex'i diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index f0c6ee5a59..0335593a11 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -53,6 +53,10 @@ ocx eject back 这是范围很广且具有破坏性的重标记:所有包含用户消息且当前标记为 `opencodex` 的线程都会改标为 `openai`,`exec` 会规范化为 `cli`,并设置事件标记。正常的专用提供方历史记录也在范围内。请先备份状态,并且仅在确实需要这一完整范围时执行。 +### `ocx recover-history --ocx-compaction --yes` + +在通过原生 Codex 恢复某个曾由路由提供方压缩的任务前,修复该任务的历史记录。此命令按 UUID 精确选择一个任务,先保存私有的逐字节备份,然后仅将 OpenCodeX 自有的 `ocx1:` 压缩状态转换为原生 Codex 可重放的普通摘要。原生加密内容和其他任务保持不变。运行前请关闭所选任务;如果 rollout 在处理期间发生变化,恢复会停止且不会替换原文件。 + ### `ocx uninstall` · `ocx remove` 停止服务和代理,移除服务和 Codex shim,恢复原生 Codex,然后仅在所有恢复步骤都成功时才删除 opencodex 本地配置。`remove` 是 `uninstall` 的别名。配置清理需要由全新安装创建的所有权元数据;旧版或共享目录会保留原样。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 71d575e774..0eaf6c75f9 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -52,6 +52,10 @@ ocx eject back 這是範圍很廣且具破壞性的重新標記:所有含有使用者訊息且目前標記為 `opencodex` 的 thread 都會改標為 `openai`,`exec` 會正規化為 `cli`,並設定 event marker。正常的專用 provider 歷史也包含在內。請先備份狀態,而且只有在確實需要這個完整範圍時才執行。 +### `ocx recover-history --ocx-compaction --yes` + +在透過原生 Codex 恢復曾由路由提供方壓縮的工作前,修復該工作的歷史記錄。此命令依 UUID 精確選取一個工作,先儲存私有的逐位元組備份,然後只把 OpenCodeX 自有的 `ocx1:` 壓縮狀態轉換成原生 Codex 可重播的普通摘要。原生加密內容與其他工作不會變更。執行前請關閉所選工作;若 rollout 在處理期間發生變化,復原會停止且不會取代原始檔案。 + ### `ocx uninstall` · `ocx remove` 停止服務與代理、移除服務與 Codex shim、還原原生 Codex,然後僅在所有還原步驟成功時移除 opencodex 本機設定。`remove` 是 `uninstall` 的別名。設定清理需要由全新安裝建立的擁有權中繼資料;舊版或共享目錄會被原樣保留。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index a1b6e1d46e..5090595e35 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -686,6 +686,7 @@ "gui-static.test.ts": "gui", "health-scoring.test.ts": "server", "history-migration-guardian.test.ts": "codex-integration", + "history-ocx-compaction-recovery.test.ts": "codex-integration", "hyperbolic-provider.test.ts": "providers", "identity-neutralize.test.ts": "adapters", "init-backup-cleanup.test.ts": "service", diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index c884bb2e5d..e85de1c05f 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -25,6 +25,7 @@ import { afterCatalogWriteHandleAppServers } from "../codex/app-server-processes import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job"; import { isJsonOption, takeFlag } from "./runtime-api"; import type { ClientConnectionState } from "../client/state"; +import { OCX_NATIVE_REPLAY_RECOVERY_NOTE } from "../responses/compaction"; export interface CliDispatchDeps { args: string[]; @@ -199,6 +200,7 @@ const commandRunners: Record = { } if (r.success) { console.log("Codex integration is OFF and plain `codex` now runs natively. Switch back with: ocx restore back"); + console.log(`Note: ${OCX_NATIVE_REPLAY_RECOVERY_NOTE}`); } else { console.error("Plain `codex` was not fully restored. Inspect $CODEX_HOME/config.toml before using native Codex."); } diff --git a/src/cli/help.ts b/src/cli/help.ts index 0cd6bec4dc..43916695b6 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -33,6 +33,8 @@ Usage: ocx restore back Re-point codex at the running proxy (undo restore) ocx recover-history --legacy-openai --yes Force all user-message opencodex rows to OpenAI (legacy recovery) + ocx recover-history --ocx-compaction --yes + Back up and make one ocx1-compacted thread replayable by native Codex ocx uninstall Remove service/shim/config and restore native Codex (alias: remove) ocx service [sub] Run as a background service (default: install/update/start) ocx codex-shim Auto-start proxy when \`codex\` launches (install|status|uninstall|remove) diff --git a/src/cli/index.ts b/src/cli/index.ts index 663514a120..309eea763a 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1442,8 +1442,31 @@ async function handleStatus() { } async function handleRecoverHistory() { + if (args[1] === "--ocx-compaction") { + const threadId = args[2]; + if (args.length !== 4 || !threadId || args[3] !== "--yes") { + console.error("Usage: ocx recover-history --ocx-compaction --yes"); + console.error("This rewrites one rollout after saving a private byte-for-byte backup. Close that Codex thread before retrying."); + process.exit(1); + } + console.error("WARNING: this converts OpenCodeX-owned ocx1 compaction state into a plain summary for native Codex replay."); + try { + const { recoverOcxCompactionHistory } = await import("../codex/ocx-compaction-history"); + const result = recoverOcxCompactionHistory({ threadId }); + if (result.replaced === 0) { + console.log(`Thread ${threadId} has no repairable ocx1 compaction history; no files changed.`); + return; + } + console.log(`Recovered ${result.replaced} ocx1 compaction item(s) in thread ${threadId}.`); + console.log(`Backup: ${result.backupPath}`); + return; + } catch (error) { + console.error(`Recovery failed: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + } if (args[1] !== "--legacy-openai") { - console.error("Usage: ocx recover-history --legacy-openai --yes"); + console.error("Usage: ocx recover-history (--legacy-openai | --ocx-compaction ) --yes"); console.error("This force-relabels every user-message opencodex row to OpenAI, including legitimate dedicated-provider history. Back up first and use it only for pre-backup legacy recovery."); process.exit(1); } diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 467a0f7971..73bbd68e31 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -36,8 +36,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "recover-history", - usage: "ocx recover-history --legacy-openai --yes", - summary: "Force all user-message opencodex rows to OpenAI for legacy recovery.", + usage: "ocx recover-history (--legacy-openai | --ocx-compaction ) --yes", + summary: "Recover legacy provider metadata or one OpenCodeX-compacted thread for native replay.", }, { name: "uninstall", diff --git a/src/codex/ocx-compaction-history.ts b/src/codex/ocx-compaction-history.ts new file mode 100644 index 0000000000..7cab765c4f --- /dev/null +++ b/src/codex/ocx-compaction-history.ts @@ -0,0 +1,226 @@ +import { createHash } from "node:crypto"; +import { + chmodSync, + closeSync, + constants, + existsSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + realpathSync, + truncateSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { basename, isAbsolute, join, relative, resolve } from "node:path"; + +import { Database } from "bun:sqlite"; + +import { getConfigDir } from "../config"; +import { hardenSecretPath } from "../lib/windows-secret-acl"; +import { renameAtomicFile } from "../lib/windows-atomic-replace"; +import { + decodeCompactionSummary, + isCompactionItemType, + SUMMARY_PREFIX, +} from "../responses/compaction"; +import { resolveCodexHomeDir } from "./home"; +import { resolveCodexStateDbPath } from "./paths"; + +export interface OcxCompactionRewriteResult { + content: string; + replaced: number; +} + +export interface OcxCompactionHistoryRecoveryResult { + rolloutPath: string; + backupPath: string | null; + replaced: number; +} + +export interface OcxCompactionHistoryRecoveryOptions { + threadId: string; + codexHome?: string; + stateDbPath?: string; + backupRoot?: string; + now?: () => Date; +} + +const THREAD_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function digest(content: string | Buffer): string { + return createHash("sha256").update(content).digest("hex"); +} + +function pathInside(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +function resolveOwnedRolloutPath(codexHome: string, rawPath: string): string { + const candidate = resolve(isAbsolute(rawPath) ? rawPath : join(codexHome, rawPath)); + const roots = [join(codexHome, "sessions"), join(codexHome, "archived_sessions")] + .filter(existsSync) + .map(root => realpathSync.native(root)); + const entry = lstatSync(candidate); + if (!entry.isFile() || entry.isSymbolicLink()) { + throw new Error("the referenced rollout is not a regular file"); + } + const canonical = realpathSync.native(candidate); + if (!roots.some(root => pathInside(root, canonical))) { + throw new Error("the referenced rollout is outside Codex session storage"); + } + return canonical; +} + +function writePrivateFile(path: string, content: string): void { + const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); + try { + if (process.platform !== "win32") chmodSync(path, 0o600); + else hardenSecretPath(path, { required: true, timeoutMemoKey: path }); + writeFileSync(fd, content, "utf8"); + fsyncSync(fd); + } finally { + closeSync(fd); + } +} + +function safeRemovePrivateFile(path: string): void { + try { truncateSync(path, 0); } catch { /* best effort before unlink */ } + try { unlinkSync(path); } catch { /* caller reports the original failure */ } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function lowerCompactionItem(item: unknown): { item: unknown; changed: boolean } { + if (!isRecord(item) || !isCompactionItemType(item.type)) { + return { item, changed: false }; + } + if (typeof item.encrypted_content !== "string") { + return { item, changed: false }; + } + const summary = decodeCompactionSummary(item.encrypted_content); + if (summary === null) return { item, changed: false }; + return { + item: { + type: "message", + role: "user", + content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\n${summary}` }], + }, + changed: true, + }; +} + +function rewriteJsonlLine(line: string): { line: string; replaced: number } { + let record: unknown; + try { + record = JSON.parse(line); + } catch { + return { line, replaced: 0 }; + } + if (!isRecord(record) || record.type !== "compacted" || !isRecord(record.payload)) { + return { line, replaced: 0 }; + } + const history = record.payload.replacement_history; + if (!Array.isArray(history)) return { line, replaced: 0 }; + + let replaced = 0; + const replacementHistory = history.map(item => { + const lowered = lowerCompactionItem(item); + if (lowered.changed) replaced += 1; + return lowered.item; + }); + if (replaced === 0) return { line, replaced: 0 }; + + return { + line: JSON.stringify({ + ...record, + payload: { ...record.payload, replacement_history: replacementHistory }, + }), + replaced, + }; +} + +/** + * Convert OpenCodeX-owned `ocx1:` compaction items into ordinary replayable user messages. + * + * Only the authoritative `compacted.payload.replacement_history` snapshot is changed. Earlier + * response-item events are historical output and are deliberately preserved byte-for-byte. + * Native opaque compactions are also untouched because OpenCodeX cannot decode them safely. + */ +export function rewriteOcxCompactionsForNativeReplay(content: string): OcxCompactionRewriteResult { + const parts = content.split(/(\r?\n)/); + let replaced = 0; + for (let index = 0; index < parts.length; index += 2) { + const line = parts[index]; + if (!line) continue; + const rewritten = rewriteJsonlLine(line); + if (rewritten.replaced === 0) continue; + parts[index] = rewritten.line; + replaced += rewritten.replaced; + } + return replaced === 0 + ? { content, replaced: 0 } + : { content: parts.join(""), replaced }; +} + +/** + * Repair one explicitly selected Codex rollout for direct native replay. + * + * The original bytes are copied to an owner-private backup before the rollout is atomically + * replaced. A last-moment digest check refuses a concurrent Codex append instead of losing it. + */ +export function recoverOcxCompactionHistory( + options: OcxCompactionHistoryRecoveryOptions, +): OcxCompactionHistoryRecoveryResult { + if (!THREAD_ID_RE.test(options.threadId)) throw new Error("thread id must be a UUID"); + const codexHome = realpathSync.native(options.codexHome ?? resolveCodexHomeDir()); + const stateDbPath = options.stateDbPath ?? resolveCodexStateDbPath({ codexHome }); + if (!existsSync(stateDbPath)) throw new Error("Codex state database was not found"); + + const db = new Database(stateDbPath, { readonly: true }); + let rawRolloutPath: string | undefined; + try { + db.exec("PRAGMA busy_timeout = 1000"); + rawRolloutPath = db.query<{ rollout_path: string }, [string]>( + "SELECT rollout_path FROM threads WHERE id = ? LIMIT 1", + ).get(options.threadId)?.rollout_path; + } finally { + db.close(); + } + if (!rawRolloutPath) throw new Error("thread was not found in the Codex state database"); + + const rolloutPath = resolveOwnedRolloutPath(codexHome, rawRolloutPath); + const originalBytes = readFileSync(rolloutPath); + const original = originalBytes.toString("utf8"); + if (!Buffer.from(original, "utf8").equals(originalBytes)) { + throw new Error("the rollout is not valid UTF-8 and cannot be repaired safely"); + } + const rewritten = rewriteOcxCompactionsForNativeReplay(original); + if (rewritten.replaced === 0) { + return { rolloutPath, backupPath: null, replaced: 0 }; + } + + const stamp = (options.now ?? (() => new Date()))().toISOString().replace(/[:.]/g, "-"); + const backupDir = resolve(options.backupRoot ?? join(getConfigDir(), "history-recovery-backups", options.threadId)); + mkdirSync(backupDir, { recursive: true, mode: 0o700 }); + const backupPath = join(backupDir, `${basename(rolloutPath)}.${stamp}.bak`); + writePrivateFile(backupPath, original); + + const tempPath = `${rolloutPath}.ocx-repair-${process.pid}-${crypto.randomUUID()}.tmp`; + try { + writePrivateFile(tempPath, rewritten.content); + if (digest(readFileSync(rolloutPath)) !== digest(originalBytes)) { + throw new Error("the rollout changed while it was being repaired; close Codex and retry"); + } + renameAtomicFile(tempPath, rolloutPath); + } catch (error) { + safeRemovePrivateFile(tempPath); + throw error; + } + return { rolloutPath, backupPath, replaced: rewritten.replaced }; +} diff --git a/src/responses/compaction.ts b/src/responses/compaction.ts index f3fba7a033..b067e7c858 100644 --- a/src/responses/compaction.ts +++ b/src/responses/compaction.ts @@ -17,6 +17,10 @@ export const OCX_COMPACTION_PREFIX = "ocx1:"; +export const OCX_NATIVE_REPLAY_RECOVERY_NOTE = + "Threads compacted through a routed provider can contain OpenCodeX-owned ocx1 state. " + + "Before resuming one through native Codex, run `ocx recover-history --ocx-compaction --yes`."; + /** Mirrors codex-rs core/templates/compact/prompt.md (the local-compaction instruction). */ export const COMPACT_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task. diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index e63001c980..56158bd162 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -366,13 +366,14 @@ async function handleCodexToggle(ctx: ManagementContext): Promise { } } const { restoreNativeCodexAsync } = await import("../../codex/inject"); + const { OCX_NATIVE_REPLAY_RECOVERY_NOTE } = await import("../../responses/compaction"); const restored = await restoreNativeCodexAsync({ revalidateDesiredState: true }); return jsonResponse({ ok: true, clientId: "codex", changed: durable && persisted.status === "committed", state: restored.success ? "absent" : "unsafe", desiredEnabled: enabled, message: restored.success - ? "Codex restored to its native path; the proxy is still serving other clients" + ? `Codex restored to its native path; the proxy is still serving other clients. ${OCX_NATIVE_REPLAY_RECOVERY_NOTE}` : `Codex intent saved, but restoring the native path did not complete: ${restored.message}`, ...(restored.success ? (durable ? {} : { reason: "not_durable" }) diff --git a/tests/cli/cli-help.test.ts b/tests/cli/cli-help.test.ts index d101b75bc8..1020439e49 100644 --- a/tests/cli/cli-help.test.ts +++ b/tests/cli/cli-help.test.ts @@ -1,6 +1,6 @@ import { describe, expect, setDefaultTimeout, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -288,8 +288,8 @@ describe("CLI subcommand help", () => { expectSpawnFinished(result, "ocx recover-history --help"); expect(result.status).toBe(0); - expect(result.stdout).toContain("Usage: ocx recover-history --legacy-openai --yes"); - expect(result.stdout).toContain("Force all user-message opencodex rows to OpenAI"); + expect(result.stdout).toContain("Usage: ocx recover-history (--legacy-openai | --ocx-compaction ) --yes"); + expect(result.stdout).toContain("Recover legacy provider metadata or one OpenCodeX-compacted thread"); expect(result.stdout).not.toContain("Recovered"); expect(result.stderr).toBe(""); expect(existsSync(statePath)).toBe(false); @@ -345,6 +345,48 @@ describe("CLI subcommand help", () => { } }); + test("recover-history repairs one explicitly selected ocx1-compacted thread", () => { + const codexHome = mkdtempSync(join(tmpdir(), "ocx-recover-compaction-")); + const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-recover-compaction-state-")); + try { + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5"\n', "utf8"); + const threadId = "01a018e6-242f-7801-81b8-ffc0a5c6d589"; + const rolloutDir = join(codexHome, "sessions", "2026", "09", "07"); + mkdirSync(rolloutDir, { recursive: true }); + const rollout = join(rolloutDir, `rollout-fixture-${threadId}.jsonl`); + const summary = `ocx1:${Buffer.from("portable summary", "utf8").toString("base64")}`; + writeFileSync(rollout, `${JSON.stringify({ + type: "compacted", + payload: { + replacement_history: [{ type: "compaction", id: "cmp_fixture", encrypted_content: summary }], + }, + })}\n`, "utf8"); + const statePath = join(codexHome, "state_5.sqlite"); + const db = new Database(statePath, { create: true }); + db.exec("CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL)"); + db.query("INSERT INTO threads (id, rollout_path) VALUES (?, ?)").run(threadId, rollout); + db.close(); + + const result = runCli( + ["recover-history", "--ocx-compaction", threadId, "--yes"], + { CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome, CI: "1" }, + ); + + expectSpawnFinished(result, "ocx recover-history --ocx-compaction"); + expect(result.status).toBe(0); + expect(result.stdout).toContain("Recovered 1 ocx1 compaction item(s)"); + expect(readFileSync(rollout, "utf8")).toContain("portable summary"); + expect(readFileSync(rollout, "utf8")).not.toContain("ocx1:"); + const backupDir = join(opencodexHome, "history-recovery-backups", threadId); + const backups = readdirSync(backupDir); + expect(backups).toHaveLength(1); + expect(readFileSync(join(backupDir, backups[0]), "utf8")).toContain("ocx1:"); + } finally { + removeTreeWithRetry(opencodexHome); + removeTreeWithRetry(codexHome); + } + }); + test("start rejects unknown and partially numeric port arguments", () => { const cases = [ { args: ["start", "--port", "123abc"], expected: "Invalid port number" }, diff --git a/tests/cli/cli-restore-back.test.ts b/tests/cli/cli-restore-back.test.ts index 04051a4b10..a750ae8ee9 100644 --- a/tests/cli/cli-restore-back.test.ts +++ b/tests/cli/cli-restore-back.test.ts @@ -45,6 +45,7 @@ describe("ocx restore back", () => { expect(result.status).toBe(0); expect(JSON.parse(readFileSync(join(ocxHome, "config.json"), "utf8")).clientIntegrations.codex).toBe(false); expect(`${result.stdout}\n${result.stderr}`).toContain("Codex integration is OFF and plain `codex` now runs natively."); + expect(result.stdout).toContain("ocx recover-history --ocx-compaction --yes"); } finally { removeTreeWithRetry(codexHome); removeTreeWithRetry(ocxHome); diff --git a/tests/codex-integration/codex-composed-acceptance.test.ts b/tests/codex-integration/codex-composed-acceptance.test.ts index b333fa3c65..44730e5453 100644 --- a/tests/codex-integration/codex-composed-acceptance.test.ts +++ b/tests/codex-integration/codex-composed-acceptance.test.ts @@ -484,9 +484,11 @@ describe("WP13 composed toggle acceptance", () => { // The CLI's own output is the assertion message: a bare "expected 0, got 1" sent two // Windows CI rounds chasing a timeout that was never the cause. expect(`exit=${back.exitCode}\nstderr: ${back.stderr}\nstdout: ${back.stdout}`).toContain("exit=0"); - expect((await fx.request(server.runtime, "/api/native-integrations/codex", { + const disabledAgain = await fx.request(server.runtime, "/api/native-integrations/codex", { method: "PUT", body: JSON.stringify({ enabled: false }), - })).body).toMatchObject({ desiredEnabled: false }); + }); + expect(disabledAgain.body).toMatchObject({ desiredEnabled: false }); + expect(String(disabledAgain.body.message)).toContain("ocx recover-history --ocx-compaction --yes"); } finally { await fx.stop(server); } diff --git a/tests/codex-integration/history-ocx-compaction-recovery.test.ts b/tests/codex-integration/history-ocx-compaction-recovery.test.ts new file mode 100644 index 0000000000..325a83c93d --- /dev/null +++ b/tests/codex-integration/history-ocx-compaction-recovery.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + recoverOcxCompactionHistory, + rewriteOcxCompactionsForNativeReplay, +} from "../../src/codex/ocx-compaction-history"; +import { encodeCompactionSummary, SUMMARY_PREFIX } from "../../src/responses/compaction"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +describe("OpenCodeX compaction history recovery", () => { + test("lowers only proxy-owned compactions in compacted replacement history", () => { + const source = [ + JSON.stringify({ type: "session_meta", payload: { id: "thread-fixture" } }), + JSON.stringify({ + type: "compacted", + payload: { + replacement_history: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "keep" }] }, + { + type: "compaction", + id: "cmp_fixture", + encrypted_content: encodeCompactionSummary("fixture summary"), + }, + { type: "compaction", id: "cmp_native", encrypted_content: "native-opaque" }, + ], + }, + }), + JSON.stringify({ + type: "response_item", + payload: { type: "compaction", encrypted_content: encodeCompactionSummary("historical output") }, + }), + "", + ].join("\n"); + + const result = rewriteOcxCompactionsForNativeReplay(source); + + expect(result.replaced).toBe(1); + const lines = result.content.trimEnd().split("\n").map(line => JSON.parse(line)); + expect(lines[1].payload.replacement_history).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "keep" }] }, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\nfixture summary` }], + }, + { type: "compaction", id: "cmp_native", encrypted_content: "native-opaque" }, + ]); + expect(lines[2].payload.encrypted_content).toStartWith("ocx1:"); + expect(result.content.endsWith("\n")).toBe(true); + }); + + test("is byte-stable when no repairable compaction exists", () => { + const source = `${JSON.stringify({ + type: "compacted", + payload: { replacement_history: [{ type: "compaction", encrypted_content: "native-opaque" }] }, + })}\nnot-json\n`; + + expect(rewriteOcxCompactionsForNativeReplay(source)).toEqual({ content: source, replaced: 0 }); + }); + + test("backs up and atomically repairs one database-selected rollout", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-compaction-recovery-")); + try { + const codexHome = join(root, "codex"); + const rolloutDir = join(codexHome, "sessions", "2026", "09", "07"); + const backupRoot = join(root, "backups"); + mkdirSync(rolloutDir, { recursive: true }); + const threadId = "01a018e6-242f-7801-81b8-ffc0a5c6d589"; + const rolloutPath = join(rolloutDir, `rollout-fixture-${threadId}.jsonl`); + const original = `${JSON.stringify({ + type: "compacted", + payload: { + replacement_history: [{ + type: "compaction", + id: "cmp_fixture", + encrypted_content: encodeCompactionSummary("recover me"), + }], + }, + })}\n`; + writeFileSync(rolloutPath, original, "utf8"); + const stateDbPath = join(codexHome, "state_5.sqlite"); + const db = new Database(stateDbPath, { create: true }); + db.exec("CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL)"); + db.query("INSERT INTO threads (id, rollout_path) VALUES (?, ?)").run(threadId, rolloutPath); + db.close(); + + const result = recoverOcxCompactionHistory({ + threadId, + codexHome, + stateDbPath, + backupRoot, + now: () => new Date("2026-09-07T00:00:00.000Z"), + }); + + expect(result.replaced).toBe(1); + expect(result.backupPath).not.toBeNull(); + expect(readFileSync(result.backupPath!, "utf8")).toBe(original); + expect(readFileSync(rolloutPath, "utf8")).toContain(`${SUMMARY_PREFIX}\\nrecover me`); + expect(readFileSync(rolloutPath, "utf8")).not.toContain("ocx1:"); + } finally { + removeTreeWithRetry(root); + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fbd8836dea..7afa15668b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -521,6 +521,7 @@ "gui-static.test.ts": "gui", "health-scoring.test.ts": "server", "history-migration-guardian.test.ts": "codex-integration", + "history-ocx-compaction-recovery.test.ts": "codex-integration", "hyperbolic-provider.test.ts": "providers", "identity-neutralize.test.ts": "adapters", "init-backup-cleanup.test.ts": "service",