Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
aaa7c4e
test: reject unindexed Codex review restore IDs
lawrencecchen Jul 17, 2026
97203e4
fix: require Codex-owned restore sessions
lawrencecchen Jul 17, 2026
df802cb
Merge remote-tracking branch 'origin/main' into feat-durable-codex-se…
lawrencecchen Jul 17, 2026
01fbee4
Merge remote-tracking branch 'origin/main' into feat-durable-codex-se…
lawrencecchen Jul 24, 2026
6e949f8
test: reproduce indexed Codex subagent restore takeover
lawrencecchen Jul 25, 2026
bb1022b
fix: resolve Codex subagents to restore roots
lawrencecchen Jul 25, 2026
2d7469e
chore: keep restore fix scoped
lawrencecchen Jul 25, 2026
71372d0
fix: verify detached split restore inputs
lawrencecchen Jul 25, 2026
e740f7f
Merge remote-tracking branch 'origin/main' into feat-durable-codex-se…
lawrencecchen Jul 25, 2026
50a4e21
test: reject indexed codex exec workers
lawrencecchen Jul 26, 2026
e0b687f
fix: keep codex exec workers off restore bindings
lawrencecchen Jul 26, 2026
bcec85d
test: expose stale codex thread cache
lawrencecchen Jul 26, 2026
72885ea
fix: refresh codex restore ownership per lookup
lawrencecchen Jul 26, 2026
2c0463c
test: preserve live codex restore bindings
lawrencecchen Jul 26, 2026
d3af20b
fix: keep live codex restore bindings
lawrencecchen Jul 26, 2026
346b6e0
test: preserve codex executable after resume
lawrencecchen Jul 26, 2026
5317803
fix: retain codex executable after resume
lawrencecchen Jul 26, 2026
67caf90
test: preserve codex binding across empty scans
lawrencecchen Jul 26, 2026
e78b706
fix: keep agent restore identity across scans
lawrencecchen Jul 26, 2026
62cff23
test: rebind resumed codex to current pid
lawrencecchen Jul 26, 2026
cfe8d7e
fix: rebind resumed agents to current pid
lawrencecchen Jul 26, 2026
209e39c
test: close Codex restore evidence gaps
lawrencecchen Jul 26, 2026
e8ee02a
fix: verify legacy Codex restore bindings
lawrencecchen Jul 26, 2026
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
22 changes: 22 additions & 0 deletions CLI/CMUXCLI+AgentHookRestoreEvidence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,28 @@ import CMUXAgentLaunch

extension CMUXCLI {
private static let codexPermissionEvidenceChunkBytes = 64 * 1024
private static let codexSessionResumeVerifier = CodexSessionResumeVerifier()

func agentHookProviderOwnsResumeTarget(
kind: String,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stale thread-index cache for long-lived process

codexSessionResumeVerifier is a process-lifetime static with an internal CodexThreadIndexCache that loads state_5.sqlite once per database path and never re-reads it. Any Codex session created after the first hook fires won't appear in the frozen dictionary; those hooks fall through to the transcriptPath / legacyRollout path. For modern Codex hooks that include transcript_path this is a silent degradation, but for hooks that don't include it (e.g. stripped environments) the valid new session is incorrectly rejected as a review UUID. A targeted re-query on cache miss — rather than treating an empty result as final — would keep the cache warm for subsequent look-ups while still loading new entries that Codex indexed after startup.

sessionId: String,
transcriptPath: String?,
launchCommand: AgentHookLaunchCommandRecord?
) -> Bool {
guard kind == "codex" else { return true }
let environment = ProcessInfo.processInfo.environment
let codexHome = normalizedHookValue(launchCommand?.environment?["CODEX_HOME"])
?? normalizedHookValue(environment["CODEX_HOME"])
?? URL(
fileURLWithPath: normalizedHookValue(environment["HOME"]) ?? NSHomeDirectory(),
isDirectory: true
).appendingPathComponent(".codex", isDirectory: true).path
return Self.codexSessionResumeVerifier.evidence(
sessionId: sessionId,
transcriptPath: transcriptPath,
codexHome: codexHome
) != nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

private func codexLaunchHasExplicitPermissions(_ launchCommand: AgentHookLaunchCommandRecord?) -> Bool {
guard let launchCommand,
Expand Down
5 changes: 5 additions & 0 deletions CLI/CMUXCLI+SessionsList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,11 @@ extension CMUXCLI {
let launchBacked = record.launchCommand != nil && agentHookSessionHasDurableResumeEvidence(
kind: spec.name,
launchCommand: record.launchCommand
) && agentHookProviderOwnsResumeTarget(
kind: spec.name,
sessionId: record.sessionId,
transcriptPath: record.transcriptPath,
launchCommand: record.launchCommand
)
payload["launch_backed"] = launchBacked

Expand Down
24 changes: 18 additions & 6 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27728,9 +27728,16 @@ struct CMUXCLI {
sessionId: String,
cwd: String?,
launchCommand: AgentHookLaunchCommandRecord?,
transcriptPath: String? = nil,
observedPermissionMode: String? = nil
) {
if !agentHookSessionHasDurableResumeEvidence(kind: kind, launchCommand: launchCommand) {
if !agentHookSessionHasDurableResumeEvidence(kind: kind, launchCommand: launchCommand)
|| !agentHookProviderOwnsResumeTarget(
kind: kind,
sessionId: sessionId,
transcriptPath: transcriptPath,
launchCommand: launchCommand
) {
clearAgentSurfaceResumeBinding(client: client, workspaceId: workspaceId, surfaceId: surfaceId, sessionId: sessionId)
return
}
Expand Down Expand Up @@ -30648,7 +30655,8 @@ export default CMUXSessionRestore;
displayName: def.displayName,
sessionId: sessionId,
cwd: preferredAgentHookResumeWorkingDirectory(kind: def.name, current: launchCommand, currentCwd: hookCwd, mapped: mapped),
launchCommand: resumeLaunchCommand
launchCommand: resumeLaunchCommand,
transcriptPath: input.transcriptPath ?? mapped?.transcriptPath
)
}
}
Expand Down Expand Up @@ -30714,7 +30722,8 @@ export default CMUXSessionRestore;
displayName: def.displayName,
sessionId: sessionId,
cwd: latest.cwd,
launchCommand: latest.launchCommand
launchCommand: latest.launchCommand,
transcriptPath: latest.transcriptPath
)
if let lifecycle = latest.agentLifecycle {
setAgentLifecycle(
Expand Down Expand Up @@ -30914,7 +30923,8 @@ export default CMUXSessionRestore;
displayName: def.displayName,
sessionId: sessionId,
cwd: preferredAgentHookResumeWorkingDirectory(kind: def.name, current: launchCommand, currentCwd: hookCwd, mapped: mapped),
launchCommand: resumeLaunchCommand
launchCommand: resumeLaunchCommand,
transcriptPath: transcriptPathForStore
)
if codexPromptTurnWentTerminal() {
stopStaleCodexPromptSubmit(restoreVisibleState: true)
Expand Down Expand Up @@ -31188,7 +31198,8 @@ export default CMUXSessionRestore;
displayName: def.displayName,
sessionId: sessionId,
cwd: cwd,
launchCommand: resumeLaunchCommand
launchCommand: resumeLaunchCommand,
transcriptPath: input.transcriptPath ?? mapped?.transcriptPath
)
}
if let pid, !suppressVisibleMutations {
Expand Down Expand Up @@ -31392,7 +31403,8 @@ export default CMUXSessionRestore;
displayName: def.displayName,
sessionId: sessionId,
cwd: preferredAgentHookResumeWorkingDirectory(kind: def.name, current: launchCommand, currentCwd: hookCwd, mapped: mapped),
launchCommand: resumeLaunchCommand
launchCommand: resumeLaunchCommand,
transcriptPath: input.transcriptPath ?? mapped?.transcriptPath
)
}
if let pid, !suppressVisibleMutations {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import Foundation
import SQLite3

/// Evidence owned by Codex that a session identifier can be passed to `codex resume`.
public struct CodexSessionResumeEvidence: Equatable, Sendable {
public enum Source: Equatable, Sendable {
case threadIndex
case legacyRollout
}

public let rolloutPath: String
public let source: Source
}

/// Verifies Codex resume identifiers against Codex's thread index or a legacy rollout.
public struct CodexSessionResumeVerifier: Sendable {
private let indexCache = CodexThreadIndexCache()

public init() {}

/// Returns evidence only when Codex owns a non-empty rollout for `sessionId`.
///
/// Modern Codex sessions must be present in `state_5.sqlite`. The rollout fallback
/// preserves sessions created by older Codex versions, but requires the rollout's
/// `session_meta` record to contain the exact identifier.
public func evidence(
sessionId: String,
transcriptPath: String?,
codexHome: String,
fileManager: FileManager = .default
) -> CodexSessionResumeEvidence? {
guard let sessionId = normalized(sessionId) else { return nil }
let expandedCodexHome = (codexHome as NSString).expandingTildeInPath
let databasePath = URL(fileURLWithPath: expandedCodexHome, isDirectory: true)
.appendingPathComponent("state_5.sqlite", isDirectory: false)
.path

if let rolloutPath = indexCache.rolloutPath(
sessionId: sessionId,
databasePath: databasePath,
fileManager: fileManager
) {
return CodexSessionResumeEvidence(rolloutPath: rolloutPath, source: .threadIndex)
}

guard let transcriptPath = normalized(transcriptPath).map({ ($0 as NSString).expandingTildeInPath }),
rolloutContainsSessionMetadata(
sessionId: sessionId,
path: transcriptPath,
fileManager: fileManager
) else {
return nil
}
return CodexSessionResumeEvidence(rolloutPath: transcriptPath, source: .legacyRollout)
}

private final class CodexThreadIndexCache: @unchecked Sendable {
private let lock = NSLock()
private var rolloutPathsByDatabase: [String: [String: String]] = [:]

func rolloutPath(
sessionId: String,
databasePath: String,
fileManager: FileManager
) -> String? {
lock.lock()
if let cached = rolloutPathsByDatabase[databasePath] {
lock.unlock()
return cached[sessionId].flatMap {
regularNonEmptyFileExists(atPath: $0, fileManager: fileManager) ? $0 : nil
}
}
lock.unlock()

let loaded = loadRolloutPaths(databasePath: databasePath, fileManager: fileManager)
lock.lock()
let paths = rolloutPathsByDatabase[databasePath] ?? loaded
rolloutPathsByDatabase[databasePath] = paths
lock.unlock()
return paths[sessionId].flatMap {
regularNonEmptyFileExists(atPath: $0, fileManager: fileManager) ? $0 : nil
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

private func loadRolloutPaths(
databasePath: String,
fileManager: FileManager
) -> [String: String] {
guard fileManager.fileExists(atPath: databasePath) else { return [:] }
var database: OpaquePointer?
guard sqlite3_open_v2(
databasePath,
&database,
SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX,
nil
) == SQLITE_OK, let database else {
sqlite3_close(database)
return [:]
}
defer { sqlite3_close(database) }

var statement: OpaquePointer?
let sql = "SELECT id, rollout_path FROM threads"
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK,
let statement else {
sqlite3_finalize(statement)
return [:]
}
defer { sqlite3_finalize(statement) }

var paths: [String: String] = [:]
while sqlite3_step(statement) == SQLITE_ROW {
guard let idBytes = sqlite3_column_text(statement, 0),
let pathBytes = sqlite3_column_text(statement, 1) else {
continue
}
let sessionId = String(cString: idBytes).trimmingCharacters(in: .whitespacesAndNewlines)
let rolloutPath = (
String(cString: pathBytes).trimmingCharacters(in: .whitespacesAndNewlines) as NSString
).expandingTildeInPath
guard !sessionId.isEmpty, !rolloutPath.isEmpty else { continue }
paths[sessionId] = rolloutPath
}
return paths
}

private func regularNonEmptyFileExists(atPath path: String, fileManager: FileManager) -> Bool {
guard let attributes = try? fileManager.attributesOfItem(atPath: path),
attributes[.type] as? FileAttributeType == .typeRegular,
let size = attributes[.size] as? NSNumber else {
return false
}
return size.int64Value > 0
}
}

private func rolloutContainsSessionMetadata(
sessionId: String,
path: String,
fileManager: FileManager
) -> Bool {
guard regularNonEmptyFileExists(atPath: path, fileManager: fileManager),
let handle = FileHandle(forReadingAtPath: path) else {
return false
}
defer { try? handle.close() }

let prefix = handle.readData(ofLength: 256 * 1024)
guard let text = String(data: prefix, encoding: .utf8) else { return false }
for line in text.split(whereSeparator: \Character.isNewline).prefix(32) {
guard let data = String(line).data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
object["type"] as? String == "session_meta",
let payload = object["payload"] as? [String: Any],
payload["id"] as? String == sessionId else {
continue
}
return true
}
return false
}

private func regularNonEmptyFileExists(atPath path: String, fileManager: FileManager) -> Bool {
guard let attributes = try? fileManager.attributesOfItem(atPath: path),
attributes[.type] as? FileAttributeType == .typeRegular,
let size = attributes[.size] as? NSNumber else {
return false
}
return size.int64Value > 0
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Duplicate regularNonEmptyFileExists implementation

regularNonEmptyFileExists(atPath:fileManager:) is defined identically — same body, same signature — on both the private CodexThreadIndexCache class (line 127) and on CodexSessionResumeVerifier itself (line 163). The outer struct's copy is used only by rolloutContainsSessionMetadata; the inner class uses its own copy. Consider extracting a single fileprivate free function or a shared internal helper to avoid drift if the implementation needs to change.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


private func normalized(_ value: String?) -> String? {
guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty else {
return nil
}
return value
}
}
Loading