diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..ed932e1c1e --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,104 @@ +{ + "configurations": [ + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:CodexCommander}/app", + "name": "Debug CodexCommanderMenuBar (app)", + "target": "CodexCommanderMenuBar", + "configuration": "debug", + "preLaunchTask": "swift: Build Debug CodexCommanderMenuBar (app)" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:CodexCommander}/app", + "name": "Release CodexCommanderMenuBar (app)", + "target": "CodexCommanderMenuBar", + "configuration": "release", + "preLaunchTask": "swift: Build Release CodexCommanderMenuBar (app)" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:CodexCommander}/app", + "name": "Debug MenuBarCoreTests (app)", + "target": "MenuBarCoreTests", + "configuration": "debug", + "preLaunchTask": "swift: Build Debug MenuBarCoreTests (app)" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:CodexCommander}/app", + "name": "Release MenuBarCoreTests (app)", + "target": "MenuBarCoreTests", + "configuration": "release", + "preLaunchTask": "swift: Build Release MenuBarCoreTests (app)" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:CodexCommander}/app", + "name": "Debug MenuBarUITests (app)", + "target": "MenuBarUITests", + "configuration": "debug", + "preLaunchTask": "swift: Build Debug MenuBarUITests (app)" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:CodexCommander}/app", + "name": "Release MenuBarUITests (app)", + "target": "MenuBarUITests", + "configuration": "release", + "preLaunchTask": "swift: Build Release MenuBarUITests (app)" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:CodexCommander}/app", + "name": "Debug UIProbe (app)", + "target": "UIProbe", + "configuration": "debug", + "preLaunchTask": "swift: Build Debug UIProbe (app)" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:CodexCommander}/app", + "name": "Release UIProbe (app)", + "target": "UIProbe", + "configuration": "release", + "preLaunchTask": "swift: Build Release UIProbe (app)" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:CodexCommander}/app", + "name": "Debug IconProbe (app)", + "target": "IconProbe", + "configuration": "debug", + "preLaunchTask": "swift: Build Debug IconProbe (app)" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:CodexCommander}/app", + "name": "Release IconProbe (app)", + "target": "IconProbe", + "configuration": "release", + "preLaunchTask": "swift: Build Release IconProbe (app)" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 71b62207bf..bbead13e98 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,11 @@ bun run build:macos open dist/macos/CodexCommander.app ``` -Double-clicking that source build ensures the proxy through the same checkout; if startup fails, the -menu app stays open so its diagnostics and **Start** control remain available. **Quit** closes only -the companion UI. **Stop** and **Restart** are separate, confirmation-gated proxy actions. +Every built app launches only the Bun runtime and server resources embedded in its own +`Contents/Resources/runtime`; it never executes checkout `src/` or an ambient `ccx`. Rebuild the app +to pick up source changes. If startup fails, the menu app stays open so its diagnostics and **Start** +control remain available. **Quit** closes only the companion UI. **Stop** and **Restart** are +separate, confirmation-gated proxy actions. On its first launch from `dist/macos` or Applications, the app enables **Launch at Login** so the menu icon returns after sign-in. The startup row exposes the actual mode: **Desktop** launches the diff --git a/app/Sources/MenuBarCore/Discovery.swift b/app/Sources/MenuBarCore/Discovery.swift index 50e4a5dedf..63ff8a3d49 100644 --- a/app/Sources/MenuBarCore/Discovery.swift +++ b/app/Sources/MenuBarCore/Discovery.swift @@ -73,22 +73,47 @@ public struct ProxyEndpoint: Equatable, Sendable { } } +/// Protected process identity read from `runtime-port.json`. The secret never +/// leaves the process; it authenticates `/healthz` immediately before the app +/// sends a management credential or request body. +public struct ProxyRuntimeAttestation: Equatable, Sendable { + public let host: String + public let port: Int + public let pid: Int + let secret: String + + public init?(host: String, port: Int, pid: Int, secret: String) { + guard let normalized = ProxyEndpoint.normalizedLoopbackHost(host), + ProxyEndpoint.validPorts.contains(port), + pid > 0, + secret.range(of: "^[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil + else { return nil } + self.host = normalized + self.port = port + self.pid = pid + self.secret = secret + } +} + public struct ProxyInstallation: Sendable { public let endpoint: ProxyEndpoint public let credential: String? public let credentialAvailability: ManagementCredentialAvailability public let configDirectory: URL + public let runtimeAttestation: ProxyRuntimeAttestation? public init( endpoint: ProxyEndpoint, credential: String?, credentialAvailability: ManagementCredentialAvailability, - configDirectory: URL + configDirectory: URL, + runtimeAttestation: ProxyRuntimeAttestation? = nil ) { self.endpoint = endpoint self.credential = credential self.credentialAvailability = credentialAvailability self.configDirectory = configDirectory + self.runtimeAttestation = runtimeAttestation } } @@ -268,6 +293,7 @@ public enum ProxyDiscovery { } let endpoint: ProxyEndpoint + let runtimeAttestation: ProxyRuntimeAttestation? do { let data = try directory.readFile(named: "runtime-port.json", maxBytes: 16 * 1024) guard let record = try? JSONDecoder().decode(RuntimePortRecord.self, from: data), @@ -285,8 +311,17 @@ public enum ProxyDiscovery { throw DiscoveryError.unsafeRuntimeRecord } endpoint = discovered + runtimeAttestation = record.attestationSecret.flatMap { + ProxyRuntimeAttestation( + host: record.hostname ?? "127.0.0.1", + port: record.port, + pid: record.pid, + secret: $0 + ) + } } catch SecureReadError.missing { endpoint = .default + runtimeAttestation = nil } catch let error as DiscoveryError { throw error } catch { @@ -298,7 +333,8 @@ public enum ProxyDiscovery { endpoint: endpoint, credential: inherited, credentialAvailability: .inheritedEnvironment, - configDirectory: directoryURL + configDirectory: directoryURL, + runtimeAttestation: runtimeAttestation ) } @@ -317,14 +353,16 @@ public enum ProxyDiscovery { endpoint: endpoint, credential: token, credentialAvailability: .file, - configDirectory: directoryURL + configDirectory: directoryURL, + runtimeAttestation: runtimeAttestation ) } catch { return ProxyInstallation( endpoint: endpoint, credential: nil, credentialAvailability: .unavailable, - configDirectory: directoryURL + configDirectory: directoryURL, + runtimeAttestation: runtimeAttestation ) } } diff --git a/app/Sources/MenuBarCore/LifecycleHelper.swift b/app/Sources/MenuBarCore/LifecycleHelper.swift index ae5ef8f1f1..4e123e0a06 100644 --- a/app/Sources/MenuBarCore/LifecycleHelper.swift +++ b/app/Sources/MenuBarCore/LifecycleHelper.swift @@ -134,10 +134,19 @@ public protocol LifecycleCommandRunning: Sendable { public struct LifecycleInvocation: Equatable, Sendable { public let executable: URL public let prefixArguments: [String] + public let workingDirectory: URL? + public let appOwnedRuntime: Bool - public init(executable: URL, prefixArguments: [String] = []) { + public init( + executable: URL, + prefixArguments: [String] = [], + workingDirectory: URL? = nil, + appOwnedRuntime: Bool = false + ) { self.executable = executable self.prefixArguments = prefixArguments + self.workingDirectory = workingDirectory + self.appOwnedRuntime = appOwnedRuntime } } @@ -148,17 +157,11 @@ public enum LifecycleHelperDiscovery { home: URL = FileManager.default.homeDirectoryForCurrentUser, fileManager: FileManager = .default ) -> LifecycleInvocation? { - // The repository build is deliberately live: while developing, edits in the - // checkout must take effect without rebuilding the copied Resources snapshot. - if let source = sourceInvocation(bundleURL: bundleURL, fileManager: fileManager) { - return source - } - - // A released companion carries the complete Bun + CodexCommander package under - // Contents/Resources/runtime. Resolve it before global installs so a copied app - // never accidentally controls a different checkout or npm installation. - if let bundled = bundledInvocation(bundleURL: bundleURL, fileManager: fileManager) { - return bundled + // Every .app is an app-owned trust boundary, regardless of its display name. + // A damaged bundle must fail closed instead of silently executing a mutable + // global/dev install with different code than the UI that launched it. + if bundleURL.pathExtension.lowercased() == "app" { + return bundledInvocation(bundleURL: bundleURL, fileManager: fileManager) } // Release companions may be launched outside the source tree. Only inspect @@ -197,39 +200,40 @@ public enum LifecycleHelperDiscovery { fileManager: FileManager ) -> LifecycleInvocation? { let bundle = bundleURL.resolvingSymlinksInPath() - guard bundle.lastPathComponent == "CodexCommander.app" else { return nil } - let runtime = bundle + guard isDirectory(bundle.path, fileManager: fileManager) else { return nil } + let declaredRuntime = bundle .appendingPathComponent("Contents", isDirectory: true) .appendingPathComponent("Resources", isDirectory: true) .appendingPathComponent("runtime", isDirectory: true) - return repositoryInvocation(runtime, fileManager: fileManager) - } - - private static func sourceInvocation( - bundleURL: URL, - fileManager: FileManager - ) -> LifecycleInvocation? { - // /dist/macos/CodexCommander.app -> . - // Requiring every fixed path component keeps a copied/lookalike app from - // selecting a nearby script. - let bundle = bundleURL.resolvingSymlinksInPath() - guard bundle.lastPathComponent == "CodexCommander.app", - bundle.deletingLastPathComponent().lastPathComponent == "macos", - bundle.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent == "dist" + .standardizedFileURL + let runtime = declaredRuntime.resolvingSymlinksInPath() + // Contents, Resources, and runtime itself must be real app-owned directories. + // Reject an otherwise plausible bundle whose runtime root redirects elsewhere. + guard runtime.path == declaredRuntime.path, + isDirectory(runtime.path, fileManager: fileManager) else { return nil } - let repository = bundle - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - return repositoryInvocation(repository, fileManager: fileManager) + return repositoryInvocation( + runtime, + fileManager: fileManager, + containmentRoot: runtime, + appOwnedRuntime: true + ) } private static func repositoryInvocation( _ repository: URL, - fileManager: FileManager + fileManager: FileManager, + containmentRoot: URL? = nil, + appOwnedRuntime: Bool = false ) -> LifecycleInvocation? { - let package = repository.appendingPathComponent("package.json") - let entry = repository.appendingPathComponent("src/cli/index.ts") + let repository = repository.standardizedFileURL.resolvingSymlinksInPath() + guard isDirectory(repository.path, fileManager: fileManager) else { return nil } + let root = containmentRoot?.standardizedFileURL.resolvingSymlinksInPath() + let package = repository.appendingPathComponent("package.json").resolvingSymlinksInPath() + let entry = repository.appendingPathComponent("src/cli/index.ts").resolvingSymlinksInPath() + if let root { + guard isContained(package, in: root), isContained(entry, in: root) else { return nil } + } guard isCodexCommanderPackage(package, fileManager: fileManager), isRegularFile(entry.path, fileManager: fileManager) else { return nil } @@ -239,10 +243,28 @@ public enum LifecycleHelperDiscovery { repository.appendingPathComponent("node_modules/bun/bin/bun"), repository.appendingPathComponent("node_modules/bun/bin/bun.exe"), ] - guard let bun = bunCandidates.first(where: { - isExecutable($0.path, fileManager: fileManager) - }) else { return nil } - return LifecycleInvocation(executable: bun, prefixArguments: [entry.path]) + guard let bun = bunCandidates.lazy + .map({ $0.resolvingSymlinksInPath() }) + .first(where: { candidate in + (root.map({ isContained(candidate, in: $0) }) ?? true) + && isExecutable(candidate.path, fileManager: fileManager) + }) + else { return nil } + return LifecycleInvocation( + executable: bun, + prefixArguments: (appOwnedRuntime + ? ["--no-install", "--no-env-file", "--config=/dev/null"] + : []) + [entry.path], + workingDirectory: repository, + appOwnedRuntime: appOwnedRuntime + ) + } + + private static func isContained(_ candidate: URL, in root: URL) -> Bool { + let rootComponents = root.standardizedFileURL.pathComponents + let candidateComponents = candidate.standardizedFileURL.pathComponents + return candidateComponents.count > rootComponents.count + && candidateComponents.prefix(rootComponents.count).elementsEqual(rootComponents) } private static func isCodexCommanderPackage( @@ -267,6 +289,13 @@ public enum LifecycleHelperDiscovery { return true } + private static func isDirectory(_ path: String, fileManager: FileManager) -> Bool { + guard let attributes = try? fileManager.attributesOfItem(atPath: path), + attributes[.type] as? FileAttributeType == .typeDirectory + else { return false } + return true + } + private static func isExecutable(_ path: String, fileManager: FileManager) -> Bool { isRegularFile(URL(fileURLWithPath: path).resolvingSymlinksInPath().path, fileManager: fileManager) && fileManager.isExecutableFile(atPath: path) @@ -343,9 +372,10 @@ public actor LifecycleHelper: LifecycleCommandRunning { let timeoutState = TimeoutState() process.executableURL = invocation.executable process.arguments = invocation.prefixArguments + ["__macos-lifecycle", action.rawValue] + process.currentDirectoryURL = invocation.workingDirectory process.standardOutput = pipe process.standardError = FileHandle.nullDevice - process.environment = Self.controlledEnvironment(for: invocation.executable) + process.environment = Self.controlledEnvironment(for: invocation) pipe.fileHandleForReading.readabilityHandler = { handle in output.append(handle.availableData) } @@ -416,10 +446,14 @@ public actor LifecycleHelper: LifecycleCommandRunning { /// Preserve CodexCommander/Codex configuration while removing runtime preloads and an /// attacker-controlled PATH from this privileged fixed-action bridge. - private nonisolated static func controlledEnvironment(for executable: URL) -> [String: String] { + private nonisolated static func controlledEnvironment(for invocation: LifecycleInvocation) -> [String: String] { var environment = ProcessInfo.processInfo.environment environment["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin" - if executable.path.contains("/Contents/Resources/runtime/") { + if let workingDirectory = invocation.workingDirectory { + environment["PWD"] = workingDirectory.path + } + environment.removeValue(forKey: "CCX_APP_RUNTIME") + if invocation.appOwnedRuntime { // The app-owned runtime must never enter npm/source self-update paths. // This marker is inherited by the Bun proxy process and its management API. environment["CCX_APP_RUNTIME"] = "1" diff --git a/app/Sources/MenuBarCore/PollingCoordinator.swift b/app/Sources/MenuBarCore/PollingCoordinator.swift index 7ed4a1c54f..4ee859ca9f 100644 --- a/app/Sources/MenuBarCore/PollingCoordinator.swift +++ b/app/Sources/MenuBarCore/PollingCoordinator.swift @@ -116,9 +116,16 @@ public actor PollingCoordinator { await drainPendingRefresh() return } + let readiness = try await pollReadiness() + guard cycle == generation else { + refreshInFlight = false + await drainPendingRefresh() + return + } snapshot.endpoint = await client.currentEndpoint snapshot.credentialAvailability = await client.credentialAvailability snapshot.consecutiveFailures = 0 + snapshot.readiness = readiness if health.isDiagnosticStale { diagnosticStaleRefreshes += 1 if diagnosticStaleRefreshes <= Self.maxDiagnosticStaleRefreshes { @@ -244,8 +251,23 @@ public actor PollingCoordinator { } } + /// Readiness is an orthogonal observation. Once authenticated startup health has + /// succeeded, a public-probe transport or contract failure means "unavailable"—it + /// must not be allowed to rewrite the live process as stopped or degraded. + private func pollReadiness() async throws -> ProxyReadinessState { + do { + let observation = try await client.readiness() + return ProxyReadinessState(status: observation.status) + } catch is CancellationError { + throw CancellationError() + } catch { + return .unavailable + } + } + private func apply(_ error: ProxyError) { diagnosticStaleRefreshes = 0 + snapshot.readiness = .unavailable snapshot.consecutiveFailures += 1 switch error { case .unreachable: diff --git a/app/Sources/MenuBarCore/ProxyClient.swift b/app/Sources/MenuBarCore/ProxyClient.swift index 6759c603da..00f0242e56 100644 --- a/app/Sources/MenuBarCore/ProxyClient.swift +++ b/app/Sources/MenuBarCore/ProxyClient.swift @@ -1,4 +1,5 @@ import Foundation +import CryptoKit public enum ProxyError: Error, Equatable, Sendable { case unreachable @@ -73,6 +74,59 @@ private struct HealthIdentity: Decodable { private struct EmptyBody: Encodable {} +private struct GuiLaunchTicketRequest: Encodable { + let route: String +} + +private struct GuiLaunchTicketResponse: Decodable { + let ticket: String + let origin: String + let route: String + let expiresAt: Double +} + +private let attestationChallengeHeader = "x-codexcommander-attestation-challenge" +private let attestationProofHeader = "x-codexcommander-attestation-proof" +private let attestedHealthBodyLimit = 16 * 1024 + +private func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") +} + +private func decodeBase64URL(_ value: String) -> Data? { + guard value.range(of: "^[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil else { + return nil + } + var standard = value + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + standard += String(repeating: "=", count: (4 - standard.count % 4) % 4) + return Data(base64Encoded: standard) +} + +private func makeAttestationChallenge() -> String { + let key = SymmetricKey(size: .bits256) + return base64URL(key.withUnsafeBytes { Data($0) }) +} + +private func validAttestationProof( + _ proof: String?, + challenge: String, + runtime: ProxyRuntimeAttestation +) -> Bool { + guard let proof, let bytes = decodeBase64URL(proof) else { return false } + let payload = "codexcommander-local-management-v1\n\(challenge)\n\(runtime.pid)\n\(runtime.port)" + let key = SymmetricKey(data: Data(runtime.secret.utf8)) + return HMAC.isValidAuthenticationCode( + bytes, + authenticating: Data(payload.utf8), + using: key + ) +} + public struct RestartAccepted: Decodable, Equatable, Sendable { public let success: Bool public let message: String @@ -132,6 +186,7 @@ public actor ProxyClient { private let session: URLSession private let discovery: @Sendable () throws -> ProxyInstallation + private let attestationChallenge: @Sendable () -> String private var installation: ProxyInstallation public init( @@ -144,6 +199,7 @@ public actor ProxyClient { self.discovery = discovery self.installation = try installation ?? discovery() self.session = session ?? Self.secureSession() + self.attestationChallenge = { makeAttestationChallenge() } } /// Deterministic initializer for transport tests. Production uses @@ -151,18 +207,29 @@ public actor ProxyClient { public init( endpoint: ProxyEndpoint, session: URLSession? = nil, - credentials: CredentialStore + credentials: CredentialStore, + attestationSecret: String? ) { let credential = credentials.loadAPIKey() let fixed = ProxyInstallation( endpoint: endpoint, credential: credential, credentialAvailability: credential == nil ? .unavailable : .file, - configDirectory: URL(fileURLWithPath: "/") + configDirectory: URL(fileURLWithPath: "/"), + runtimeAttestation: attestationSecret.flatMap { secret in + guard let pid = endpoint.expectedPID else { return nil } + return ProxyRuntimeAttestation( + host: endpoint.host, + port: endpoint.port, + pid: pid, + secret: secret + ) + } ) self.installation = fixed self.discovery = { fixed } self.session = session ?? Self.secureSession() + self.attestationChallenge = { makeAttestationChallenge() } } public var currentEndpoint: ProxyEndpoint { installation.endpoint } @@ -184,6 +251,48 @@ public actor ProxyClient { try await authenticatedGet("api/startup-health") } + /// Public post-startup readiness. This request intentionally carries no management + /// credential, and accepts the endpoint's contractually meaningful 503 response for + /// `pending` and `failed` observations. + public func readiness(timeout: TimeInterval = 1.5) async throws -> ProxyReadinessObservation { + try rediscover() + let current = installation + let (data, response) = try await performResponse( + installation: current, + method: "GET", + path: "readyz", + query: [], + body: nil as EmptyBody?, + credential: nil, + timeout: timeout + ) + guard response.statusCode == 200 || response.statusCode == 503 else { + throw ProxyError.http(response.statusCode) + } + + let observation: ProxyReadinessObservation + do { + observation = try JSONDecoder().decode(ProxyReadinessObservation.self, from: data) + } catch { + throw ProxyError.decoding + } + + guard observation.service == "codexcommander", + !observation.version.isEmpty, + observation.uptime.isFinite, + observation.uptime >= 0, + observation.pid > 0, + observation.port >= 1, + observation.port <= 65_535, + observation.port == current.endpoint.port, + current.endpoint.expectedPID == nil + || current.endpoint.expectedPID == observation.pid, + (observation.status == .ready && response.statusCode == 200) + || (observation.status != .ready && response.statusCode == 503) + else { throw ProxyError.identityMismatch } + return observation + } + public func providers() async throws -> [ProviderSummary] { try await authenticatedGet("api/providers") } @@ -212,6 +321,56 @@ public actor ProxyClient { } } + /// Mint a short-lived, single-use browser handoff through the attested admin + /// channel. The returned bearer lives only in the URL fragment and is handed + /// directly to NSWorkspace; it is never persisted or logged by the app. + public func confirmedGuiLaunchURL(route: String) async throws -> URL { + guard !route.isEmpty, + route.count <= 512, + !route.hasPrefix("/"), + !route.contains("#"), + route.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) + else { throw ProxyError.decoding } + + let data = try await authenticatedSend( + method: "POST", + path: "api/gui-launch-ticket", + body: GuiLaunchTicketRequest(route: route) + ) + let ticket: GuiLaunchTicketResponse + do { + ticket = try JSONDecoder().decode(GuiLaunchTicketResponse.self, from: data) + } catch { + throw ProxyError.decoding + } + let now = Date().timeIntervalSince1970 * 1_000 + guard ticket.route == route, + ticket.ticket.range( + of: #"^ccx_launch_[A-Za-z0-9_-]{43}$"#, + options: .regularExpression + ) != nil, + ticket.expiresAt > now, + ticket.expiresAt <= now + 60_000, + var origin = URLComponents(string: ticket.origin), + origin.scheme == "http", + ProxyEndpoint.normalizedLoopbackHost(origin.host) != nil, + origin.host?.lowercased() == installation.endpoint.baseURL.host?.lowercased(), + origin.port == installation.endpoint.port, + origin.path.isEmpty || origin.path == "/", + origin.query == nil, + origin.fragment == nil + else { throw ProxyError.identityMismatch } + + var fragment = URLComponents() + fragment.queryItems = [ + URLQueryItem(name: "ccx-launch-ticket", value: ticket.ticket), + URLQueryItem(name: "ccx-route", value: route), + ] + origin.percentEncodedFragment = fragment.percentEncodedQuery + guard let url = origin.url else { throw ProxyError.decoding } + return url + } + /// Advisory launch-at-login report to the proxy (`PUT /api/startup-health/companion`). /// Success is a 204 with no body; callers treat any thrown error as non-blocking. public func reportCompanionStartupState(launchAtLogin: LaunchAtLoginStatus) async throws { @@ -321,12 +480,31 @@ public actor ProxyClient { query: [URLQueryItem], body: Body? ) async throws -> Data { - guard let credential = installation.credential, !credential.isEmpty else { + guard installation.credential?.isEmpty == false else { throw ProxyError.authenticationUnavailable } - _ = try await validateHealthIdentity(installation: installation, timeout: 2) - return try await perform( + guard let runtime = installation.runtimeAttestation, + installation.endpoint.expectedPID == runtime.pid, + installation.endpoint.host == runtime.host, + installation.endpoint.port == runtime.port + else { throw ProxyError.identityMismatch } + _ = try await validateAttestedHealthIdentity( installation: installation, + runtime: runtime, + timeout: 2 + ) + + // Close the record-rotation window before attaching either the bearer or + // body. A restart/port reuse/secret rotation forces a fresh attempt. + let current = try discovery() + guard current.endpoint == installation.endpoint, + current.runtimeAttestation == runtime, + let credential = current.credential, + !credential.isEmpty + else { throw ProxyError.identityMismatch } + self.installation = current + return try await perform( + installation: current, method: method, path: path, query: query, @@ -361,6 +539,126 @@ public actor ProxyClient { return identity } + private func validateAttestedHealthIdentity( + installation: ProxyInstallation, + runtime: ProxyRuntimeAttestation, + timeout: TimeInterval + ) async throws -> HealthIdentity { + let challenge = attestationChallenge() + guard challenge.range(of: "^[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil else { + throw ProxyError.identityMismatch + } + let (data, response) = try await boundedAttestedHealthResponse( + installation: installation, + runtime: runtime, + challenge: challenge, + timeout: timeout + ) + guard let identity = try? JSONDecoder().decode(HealthIdentity.self, from: data), + identity.status == "ok", + identity.service == "codexcommander", + !identity.version.isEmpty, + identity.pid == runtime.pid, + identity.port == runtime.port, + validAttestationProof( + response.value(forHTTPHeaderField: attestationProofHeader), + challenge: challenge, + runtime: runtime + ) + else { throw ProxyError.identityMismatch } + return identity + } + + /// Header-first listener proof. A foreign loopback listener never gets to + /// choose how much body the app buffers: invalid proof/status/framing cancels + /// immediately, and a valid chunked response still has a strict streaming cap. + private func boundedAttestedHealthResponse( + installation: ProxyInstallation, + runtime: ProxyRuntimeAttestation, + challenge: String, + timeout: TimeInterval + ) async throws -> (Data, HTTPURLResponse) { + let url = installation.endpoint.baseURL.appendingPathComponent("healthz") + guard url.scheme == "http", + ProxyEndpoint.normalizedLoopbackHost(url.host) != nil, + url.port == installation.endpoint.port + else { throw ProxyError.identityMismatch } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = timeout + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + request.setValue("no-store", forHTTPHeaderField: "cache-control") + request.setValue(challenge, forHTTPHeaderField: attestationChallengeHeader) + + do { + let (bytes, response) = try await session.bytes(for: request) + guard let http = response as? HTTPURLResponse else { + bytes.task.cancel() + throw ProxyError.decoding + } + let encoding = http.value(forHTTPHeaderField: "content-encoding")? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard http.statusCode == 200, + encoding == nil || encoding == "identity", + validAttestationProof( + http.value(forHTTPHeaderField: attestationProofHeader), + challenge: challenge, + runtime: runtime + ) + else { + bytes.task.cancel() + throw ProxyError.identityMismatch + } + + let expectedLength: Int? + if let rawLength = http.value(forHTTPHeaderField: "content-length") { + let normalized = rawLength.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalized.range(of: "^[0-9]+$", options: .regularExpression) != nil, + let parsed = Int(normalized), + parsed <= attestedHealthBodyLimit + else { + bytes.task.cancel() + throw ProxyError.identityMismatch + } + expectedLength = parsed + } else { + expectedLength = nil + } + + var data = Data() + data.reserveCapacity(expectedLength ?? 512) + for try await byte in bytes { + guard data.count < attestedHealthBodyLimit else { + bytes.task.cancel() + throw ProxyError.identityMismatch + } + data.append(byte) + } + guard expectedLength == nil || expectedLength == data.count else { + throw ProxyError.identityMismatch + } + return (data, http) + } catch let error as ProxyError { + throw error + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError { + switch error.code { + case .cancelled: + throw CancellationError() + case .cannotConnectToHost: + throw ProxyError.unreachable + case .timedOut, .networkConnectionLost, .notConnectedToInternet, + .cannotFindHost, .dnsLookupFailed: + throw ProxyError.inconclusive + default: + throw ProxyError.transport + } + } + } + private func perform( installation: ProxyInstallation, method: String, @@ -368,8 +666,38 @@ public actor ProxyClient { query: [URLQueryItem], body: Body?, credential: String?, + headers: [String: String] = [:], timeout: TimeInterval ) async throws -> Data { + let (data, response) = try await performResponse( + installation: installation, + method: method, + path: path, + query: query, + body: body, + credential: credential, + headers: headers, + timeout: timeout + ) + if response.statusCode == 401 { throw ProxyError.unauthorized } + guard (200..<300).contains(response.statusCode) else { + throw ProxyError.http(response.statusCode) + } + return data + } + + /// Shared hardened transport. Status interpretation stays with the endpoint so + /// `/readyz` can decode its intentional 503 without weakening management calls. + private func performResponse( + installation: ProxyInstallation, + method: String, + path: String, + query: [URLQueryItem], + body: Body?, + credential: String?, + headers: [String: String] = [:], + timeout: TimeInterval + ) async throws -> (Data, HTTPURLResponse) { guard var components = URLComponents( url: installation.endpoint.baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false @@ -386,6 +714,9 @@ public actor ProxyClient { request.timeoutInterval = timeout request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData request.setValue("no-store", forHTTPHeaderField: "cache-control") + for (name, value) in headers { + request.setValue(value, forHTTPHeaderField: name) + } if let credential { request.setValue(credential, forHTTPHeaderField: "x-codexcommander-api-key") } @@ -403,11 +734,7 @@ public actor ProxyClient { guard let http = response as? HTTPURLResponse else { throw ProxyError.decoding } - if http.statusCode == 401 { throw ProxyError.unauthorized } - guard (200..<300).contains(http.statusCode) else { - throw ProxyError.http(http.statusCode) - } - return data + return (data, http) } catch let error as ProxyError { throw error } catch is CancellationError { diff --git a/app/Sources/MenuBarCore/ProxyModels.swift b/app/Sources/MenuBarCore/ProxyModels.swift index a16a2346a8..54bead42da 100644 --- a/app/Sources/MenuBarCore/ProxyModels.swift +++ b/app/Sources/MenuBarCore/ProxyModels.swift @@ -4,6 +4,72 @@ import Foundation // fail decoding when an older or unrelated listener answers this app; nullable keys are // optionals only where the server emits JSON null. +/// The three intentional wire states returned by the public `GET /readyz` endpoint. +/// This is deliberately closed: an unknown value is not treated as ready. +public enum ProxyReadinessStatus: String, Decodable, Equatable, Sendable { + case pending + case ready + case failed +} + +/// Strict, sanitized identity returned by the public `GET /readyz` endpoint. +/// +/// Unlike most management responses, the exact key set is part of this endpoint's +/// contract. Rejecting additions as well as omissions keeps a foreign listener or a +/// different endpoint from being mistaken for the readiness signal. +public struct ProxyReadinessObservation: Decodable, Equatable, Sendable { + public let service: String + public let version: String + public let uptime: Double + public let pid: Int + public let port: Int + public let status: ProxyReadinessStatus + + private enum CodingKeys: String, CodingKey, CaseIterable { + case service + case version + case uptime + case pid + case port + case status + } + + private struct DynamicCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(intValue: Int) { + self.stringValue = String(intValue) + self.intValue = intValue + } + } + + public init(from decoder: Decoder) throws { + let dynamic = try decoder.container(keyedBy: DynamicCodingKey.self) + let actualKeys = Set(dynamic.allKeys.map(\.stringValue)) + let expectedKeys = Set(CodingKeys.allCases.map(\.rawValue)) + guard actualKeys == expectedKeys else { + throw DecodingError.dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: "Unexpected /readyz response shape" + )) + } + + let values = try decoder.container(keyedBy: CodingKeys.self) + service = try values.decode(String.self, forKey: .service) + version = try values.decode(String.self, forKey: .version) + uptime = try values.decode(Double.self, forKey: .uptime) + pid = try values.decode(Int.self, forKey: .pid) + port = try values.decode(Int.self, forKey: .port) + status = try values.decode(ProxyReadinessStatus.self, forKey: .status) + } +} + /// `GET /api/startup-health` public struct StartupHealth: Decodable, Equatable, Sendable { public let status: String diff --git a/app/Sources/MenuBarCore/ProxySnapshot.swift b/app/Sources/MenuBarCore/ProxySnapshot.swift index 87be810243..2c560bd4f2 100644 --- a/app/Sources/MenuBarCore/ProxySnapshot.swift +++ b/app/Sources/MenuBarCore/ProxySnapshot.swift @@ -73,6 +73,27 @@ public enum ProxyState: Equatable, Sendable { } } +/// Post-startup routing readiness, kept separate from process liveness and startup +/// protection. A proxy may be running while catalog synchronization is still pending +/// or has failed. +public enum ProxyReadinessState: Equatable, Sendable { + /// No readiness probe has completed yet. + case unknown + /// The public readiness endpoint could not be observed or failed validation. + case unavailable + case pending + case ready + case failed + + public init(status: ProxyReadinessStatus) { + switch status { + case .pending: self = .pending + case .ready: self = .ready + case .failed: self = .failed + } + } +} + /// What the user should do next. `loading` deliberately has none — there is nothing to /// act on yet — but every other non-running state names one. public enum NextAction: Equatable, Sendable { @@ -85,6 +106,7 @@ public enum NextAction: Equatable, Sendable { public struct ProxySnapshot: Equatable, Sendable { public var state: ProxyState + public var readiness: ProxyReadinessState public var endpoint: ProxyEndpoint public var quotas: [QuotaReport] public var quotaAvailability: [ProviderQuotaAvailability] @@ -107,6 +129,7 @@ public struct ProxySnapshot: Equatable, Sendable { public init( state: ProxyState = .loading, + readiness: ProxyReadinessState = .unknown, endpoint: ProxyEndpoint, quotas: [QuotaReport] = [], quotaAvailability: [ProviderQuotaAvailability] = [], @@ -122,6 +145,7 @@ public struct ProxySnapshot: Equatable, Sendable { credentialAvailability: ManagementCredentialAvailability = .unavailable ) { self.state = state + self.readiness = readiness self.endpoint = endpoint self.quotas = quotas self.quotaAvailability = quotaAvailability diff --git a/app/Sources/MenuBarCoreTests/DiscoverySuite.swift b/app/Sources/MenuBarCoreTests/DiscoverySuite.swift index 5f6b077230..ab6e815cec 100644 --- a/app/Sources/MenuBarCoreTests/DiscoverySuite.swift +++ b/app/Sources/MenuBarCoreTests/DiscoverySuite.swift @@ -3,6 +3,7 @@ import MenuBarCore enum DiscoverySuite { private static let token = "ccx_admin_" + String(repeating: "a", count: 43) + private static let attestationSecret = String(repeating: "S", count: 43) static func run(_ t: TestRunner) { t.test("discovery: accepts only explicit literal loopback and maps wildcards") { @@ -23,6 +24,9 @@ enum DiscoverySuite { t.equal(found.endpoint.host, expected, host ?? "missing host") t.equal(found.endpoint.port, 18181) t.equal(found.endpoint.expectedPID, 4242) + t.equal(found.runtimeAttestation?.pid, 4242) + t.equal(found.runtimeAttestation?.port, 18181) + t.equal(found.runtimeAttestation?.host, expected) } } } @@ -230,9 +234,9 @@ enum DiscoverySuite { private static func runtimeJSON(host: String?) -> String { if let host { - return #"{"schemaVersion":1,"pid":4242,"port":18181,"hostname":"\#(host)"}"# + return #"{"schemaVersion":1,"pid":4242,"port":18181,"hostname":"\#(host)","attestationSecret":"\#(attestationSecret)"}"# } - return #"{"schemaVersion":1,"pid":4242,"port":18181}"# + return #"{"schemaVersion":1,"pid":4242,"port":18181,"attestationSecret":"\#(attestationSecret)"}"# } private static func writeRuntime(_ root: URL, host: String?) throws { diff --git a/app/Sources/MenuBarCoreTests/LifecycleHelperSuite.swift b/app/Sources/MenuBarCoreTests/LifecycleHelperSuite.swift index 704be0e4e1..e74516618b 100644 --- a/app/Sources/MenuBarCoreTests/LifecycleHelperSuite.swift +++ b/app/Sources/MenuBarCoreTests/LifecycleHelperSuite.swift @@ -1,12 +1,13 @@ import Foundation +import Darwin import MenuBarCore enum LifecycleHelperSuite { static func run(_ t: TestRunner) { - t.test("lifecycle helper: released app prefers its bundled runtime outside the repository") { + t.test("lifecycle helper: renamed app resolves only its bundled runtime") { try withTemporaryDirectory { root in let bundle = root.appendingPathComponent( - "Applications/CodexCommander.app", + "Applications/My Renamed Commander.app", isDirectory: true ) let runtime = bundle.appendingPathComponent( @@ -42,7 +43,10 @@ enum LifecycleHelperSuite { home: root.appendingPathComponent("outside-repo", isDirectory: true) ) t.equal(invocation?.executable.path, bun.path) - t.equal(invocation?.prefixArguments, [entry.path]) + t.equal(invocation?.prefixArguments, + ["--no-install", "--no-env-file", "--config=/dev/null", entry.path]) + t.equal(invocation?.workingDirectory?.path, runtime.path) + t.equal(invocation?.appOwnedRuntime, true) } } @@ -56,10 +60,14 @@ enum LifecycleHelperSuite { at: executable.deletingLastPathComponent(), withIntermediateDirectories: true ) + try Data().write( + to: executable.deletingLastPathComponent().appendingPathComponent("trusted-cwd") + ) let body = """ #!/bin/sh if [ "$CCX_APP_RUNTIME" != "1" ]; then exit 9; fi if [ "$PATH" != "/usr/bin:/bin:/usr/sbin:/sbin" ]; then exit 10; fi + if [ ! -f trusted-cwd ]; then exit 11; fi printf '{"schemaVersion":1,"action":"%s","ok":true,"state":"running","changed":false,"pid":null,"port":null,"message":"bundled"}\\n' "$2" """ try Data(body.utf8).write(to: executable) @@ -69,7 +77,11 @@ enum LifecycleHelperSuite { ) let helper = LifecycleHelper( - invocation: LifecycleInvocation(executable: executable), + invocation: LifecycleInvocation( + executable: executable, + workingDirectory: executable.deletingLastPathComponent(), + appOwnedRuntime: true + ), timeout: 2 ) let result = sync { try await helper.run(.status) } @@ -82,7 +94,7 @@ enum LifecycleHelperSuite { } } - t.test("lifecycle helper: source app resolves the repository Bun entry") { + t.test("lifecycle helper: dist app prefers its bundled runtime over the checkout") { try withTemporaryDirectory { root in let repository = root.appendingPathComponent("repo", isDirectory: true) let bundle = repository.appendingPathComponent( @@ -145,8 +157,198 @@ enum LifecycleHelperSuite { environment: ["PATH": ""], home: root ) - t.equal(invocation?.executable.path, bun.path) - t.equal(invocation?.prefixArguments, [entry.path]) + t.equal(invocation?.executable.path, bundledBun.path) + t.equal(invocation?.prefixArguments, + ["--no-install", "--no-env-file", "--config=/dev/null", bundledEntry.path]) + t.equal(invocation?.workingDirectory?.path, bundledRuntime.path) + t.equal(invocation?.appOwnedRuntime, true) + } + } + + t.test("lifecycle helper: missing or damaged app runtime never falls through to a global install") { + try withTemporaryDirectory { root in + _ = try installFixedGlobalRuntime(in: root) + + let missingBundle = root.appendingPathComponent("Missing Runtime.app", isDirectory: true) + try FileManager.default.createDirectory(at: missingBundle, withIntermediateDirectories: true) + t.isNil(LifecycleHelperDiscovery.discover( + bundleURL: missingBundle, + environment: ["PATH": ""], + home: root + ), "missing embedded runtime") + + let damagedBundle = root.appendingPathComponent("Damaged Runtime.app", isDirectory: true) + let damaged = try makeBundledRuntime(in: damagedBundle) + try Data(#"{"name":"not-codexcommander"}"#.utf8).write( + to: damaged.runtime.appendingPathComponent("package.json"), + options: .atomic + ) + t.isNil(LifecycleHelperDiscovery.discover( + bundleURL: damagedBundle, + environment: ["PATH": ""], + home: root + ), "damaged embedded runtime") + } + } + + t.test("lifecycle helper: bundled entry and Bun symlink escapes fail containment") { + try withTemporaryDirectory { root in + let entryBundle = root.appendingPathComponent("Entry Escape.app", isDirectory: true) + let entryRuntime = try makeBundledRuntime(in: entryBundle) + try FileManager.default.removeItem(at: entryRuntime.entry) + let sibling = root.appendingPathComponent("runtime-evil", isDirectory: true) + try FileManager.default.createDirectory(at: sibling, withIntermediateDirectories: true) + let escapedEntry = sibling.appendingPathComponent("index.ts") + try Data().write(to: escapedEntry) + try FileManager.default.createSymbolicLink( + at: entryRuntime.entry, + withDestinationURL: escapedEntry + ) + t.isNil(LifecycleHelperDiscovery.discover(bundleURL: entryBundle, home: root), + "runtime-evil entry prefix") + + let bunBundle = root.appendingPathComponent("Bun Escape.app", isDirectory: true) + let bunRuntime = try makeBundledRuntime(in: bunBundle) + try FileManager.default.removeItem(at: bunRuntime.bun) + let escapedBun = try makeExecutable( + in: root, + named: "outside-bun", + body: "#!/bin/sh\nexit 0\n" + ) + try FileManager.default.createSymbolicLink( + at: bunRuntime.bun, + withDestinationURL: escapedBun + ) + t.isNil(LifecycleHelperDiscovery.discover(bundleURL: bunBundle, home: root), + "escaped Bun") + + let runtimeBundle = root.appendingPathComponent("Runtime Escape.app", isDirectory: true) + let resources = runtimeBundle.appendingPathComponent( + "Contents/Resources", + isDirectory: true + ) + try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true) + let outsideRuntime = root.appendingPathComponent("outside-runtime", isDirectory: true) + _ = try makeRuntime(at: outsideRuntime) + try FileManager.default.createSymbolicLink( + at: resources.appendingPathComponent("runtime"), + withDestinationURL: outsideRuntime + ) + t.isNil(LifecycleHelperDiscovery.discover(bundleURL: runtimeBundle, home: root), + "escaped runtime root") + } + } + + t.test("lifecycle helper: bundled Bun ignores ambient and runtime bunfig preloads") { + try withTemporaryDirectory { root in + let bundle = root.appendingPathComponent("Hermetic.app", isDirectory: true) + let bundled = try makeBundledRuntime(in: bundle) + guard let projectBun = findProjectBun() else { + t.expect(false, "project Bun executable not found") + return + } + try FileManager.default.removeItem(at: bundled.bun) + do { + try FileManager.default.linkItem(at: projectBun, to: bundled.bun) + } catch { + try FileManager.default.copyItem(at: projectBun, to: bundled.bun) + } + + let sentinel = root.appendingPathComponent("preload-executed") + let preloadSource = "await Bun.write(\(String(reflecting: sentinel.path)), \"executed\");\n" + let maliciousConfig = "preload = [\"./preload.ts\"]\n" + try Data(preloadSource.utf8).write( + to: bundled.runtime.appendingPathComponent("preload.ts"), + options: .atomic + ) + try Data(maliciousConfig.utf8).write( + to: bundled.runtime.appendingPathComponent("bunfig.toml"), + options: .atomic + ) + let fakeHome = root.appendingPathComponent("home", isDirectory: true) + let fakeXDG = root.appendingPathComponent("xdg", isDirectory: true) + let globalPreload = root.appendingPathComponent("global-preload.ts") + try FileManager.default.createDirectory(at: fakeHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: fakeXDG, withIntermediateDirectories: true) + try Data(preloadSource.utf8).write(to: globalPreload, options: .atomic) + let globalConfig = "preload = [\(String(reflecting: globalPreload.path))]\n" + try Data(globalConfig.utf8).write( + to: fakeHome.appendingPathComponent(".bunfig.toml"), + options: .atomic + ) + try Data(globalConfig.utf8).write( + to: fakeXDG.appendingPathComponent(".bunfig.toml"), + options: .atomic + ) + let entrySource = """ + const action = process.argv.at(-1); + const pwdTrusted = process.env.PWD?.endsWith("/Contents/Resources/runtime") === true + && process.cwd().endsWith("/Contents/Resources/runtime"); + const homeIsolated = process.env.HOME === \(String(reflecting: fakeHome.path)); + console.log(JSON.stringify({ + schemaVersion: 1, + action, + ok: true, + state: "running", + changed: false, + pid: null, + port: null, + message: `hermetic:${pwdTrusted}:${homeIsolated}` + })); + """ + try Data(entrySource.utf8).write(to: bundled.entry, options: .atomic) + + let ambient = root.appendingPathComponent("ambient", isDirectory: true) + try FileManager.default.createDirectory(at: ambient, withIntermediateDirectories: true) + try Data(preloadSource.utf8).write( + to: ambient.appendingPathComponent("preload.ts"), + options: .atomic + ) + try Data(maliciousConfig.utf8).write( + to: ambient.appendingPathComponent("bunfig.toml"), + options: .atomic + ) + let previousHome = getenv("HOME").map { String(cString: $0) } + let previousXDG = getenv("XDG_CONFIG_HOME").map { String(cString: $0) } + guard setenv("HOME", fakeHome.path, 1) == 0, + setenv("XDG_CONFIG_HOME", fakeXDG.path, 1) == 0 + else { + t.expect(false, "could not isolate Bun global config homes") + return + } + defer { + if let previousHome { setenv("HOME", previousHome, 1) } else { unsetenv("HOME") } + if let previousXDG { setenv("XDG_CONFIG_HOME", previousXDG, 1) } + else { unsetenv("XDG_CONFIG_HOME") } + } + let originalDirectory = FileManager.default.currentDirectoryPath + guard FileManager.default.changeCurrentDirectoryPath(ambient.path) else { + t.expect(false, "could not enter ambient test directory") + return + } + defer { _ = FileManager.default.changeCurrentDirectoryPath(originalDirectory) } + + guard let invocation = LifecycleHelperDiscovery.discover( + bundleURL: bundle, + environment: ["PATH": ""], + home: root + ) else { + t.expect(false, "bundled invocation not found") + return + } + t.equal(invocation.prefixArguments, + ["--no-install", "--no-env-file", "--config=/dev/null", bundled.entry.path]) + t.equal(invocation.workingDirectory?.path, bundled.runtime.path) + let helper = LifecycleHelper(invocation: invocation, timeout: 5) + let result = sync { try await helper.run(.status) } + switch result { + case .success(let value): + t.equal(value.message, "hermetic:true:true") + t.expect(!FileManager.default.fileExists(atPath: sentinel.path), + "no bunfig preload executed") + case .failure(let error): + t.expect(false, "unexpected bundled Bun failure: \(error)") + } } } @@ -163,7 +365,7 @@ enum LifecycleHelperSuite { body: "#!/bin/sh\nexit 0\n" ) let invocation = LifecycleHelperDiscovery.discover( - bundleURL: root.appendingPathComponent("Copied.app"), + bundleURL: root.appendingPathComponent("Copied.bundle"), environment: ["PATH": untrusted.path], home: root ) @@ -204,12 +406,14 @@ enum LifecycleHelperSuite { ) let invocation = LifecycleHelperDiscovery.discover( - bundleURL: root.appendingPathComponent("Release.app"), + bundleURL: root.appendingPathComponent("Release.bundle"), environment: ["PATH": ""], home: root ) t.equal(invocation?.executable.path, bun.path) t.equal(invocation?.prefixArguments, [entry.path]) + t.equal(invocation?.workingDirectory?.path, repository.path) + t.equal(invocation?.appOwnedRuntime, false) } } @@ -417,6 +621,104 @@ enum LifecycleHelperSuite { } } + private static func makeBundledRuntime( + in bundle: URL + ) throws -> (runtime: URL, entry: URL, bun: URL) { + let runtime = bundle.appendingPathComponent( + "Contents/Resources/runtime", + isDirectory: true + ) + return try makeRuntime(at: runtime) + } + + private static func makeRuntime( + at runtime: URL + ) throws -> (runtime: URL, entry: URL, bun: URL) { + let entry = runtime.appendingPathComponent("src/cli/index.ts") + let bun = runtime.appendingPathComponent("node_modules/bun/bin/bun") + try FileManager.default.createDirectory( + at: entry.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: bun.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data(#"{"name":"codexcommander"}"#.utf8).write( + to: runtime.appendingPathComponent("package.json"), + options: .atomic + ) + try Data().write(to: entry, options: .atomic) + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: bun, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: bun.path + ) + return (runtime, entry, bun) + } + + private static func installFixedGlobalRuntime( + in root: URL + ) throws -> (entry: URL, bun: URL) { + let repository = root.appendingPathComponent("global-package", isDirectory: true) + let bin = repository.appendingPathComponent("bin", isDirectory: true) + let entry = repository.appendingPathComponent("src/cli/index.ts") + let bun = repository.appendingPathComponent("node_modules/.bin/bun") + let installedBin = root.appendingPathComponent(".local/bin", isDirectory: true) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: entry.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: bun.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory(at: installedBin, withIntermediateDirectories: true) + try Data(#"{"name":"codexcommander"}"#.utf8).write( + to: repository.appendingPathComponent("package.json") + ) + try Data().write(to: entry) + let launcher = try makeExecutable( + in: bin, + named: "ccx.mjs", + body: "#!/usr/bin/env node\n" + ) + _ = try makeExecutable( + in: bun.deletingLastPathComponent(), + named: "bun", + body: "#!/bin/sh\nexit 0\n" + ) + try FileManager.default.createSymbolicLink( + at: installedBin.appendingPathComponent("ccx"), + withDestinationURL: launcher + ) + return (entry, bun) + } + + private static func findProjectBun() -> URL? { + var directory = URL( + fileURLWithPath: FileManager.default.currentDirectoryPath, + isDirectory: true + ).standardizedFileURL + for _ in 0..<8 { + for relative in [ + "node_modules/bun/bin/bun.exe", + "node_modules/bun/bin/bun", + "node_modules/.bin/bun", + ] { + let candidate = directory.appendingPathComponent(relative).resolvingSymlinksInPath() + if FileManager.default.isExecutableFile(atPath: candidate.path) { + return candidate + } + } + let parent = directory.deletingLastPathComponent() + if parent.path == directory.path { break } + directory = parent + } + return nil + } + private static func withTemporaryDirectory(_ body: (URL) throws -> T) throws -> T { let root = FileManager.default.temporaryDirectory.appendingPathComponent( "CodexCommander-Lifecycle-\(UUID().uuidString)", diff --git a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift index 5593135b0d..3a3a04dfd4 100644 --- a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift +++ b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift @@ -26,6 +26,9 @@ enum ModelDecodingSuite { "restoreNative":"ccx restore"}} """ + private static let liveReadiness = + #"{"service":"codexcommander","version":"0.1.0","uptime":12.5,"pid":42,"port":10100,"status":"ready"}"# + private static let liveQuotas = """ {"generatedAt":1784915336899,"reports":[ {"provider":"openai","label":"OpenAI (Codex login)","source":"chatgpt:wham", @@ -77,6 +80,49 @@ enum ModelDecodingSuite { t.expect(rejects(StartupHealth.self, #"{"status":"protected"}"#), "partial health must be rejected") } + t.test("readiness: decodes only the exact public contract and closed statuses") { + let ready = try decode(ProxyReadinessObservation.self, liveReadiness) + t.equal(ready.status, .ready) + t.equal(ready.service, "codexcommander") + t.equal(ready.version, "0.1.0") + t.equal(ready.uptime, 12.5) + t.equal(ready.pid, 42) + t.equal(ready.port, 10100) + + let pending = try decode( + ProxyReadinessObservation.self, + liveReadiness.replacingOccurrences(of: "ready", with: "pending") + ) + t.equal(pending.status, .pending) + let failed = try decode( + ProxyReadinessObservation.self, + liveReadiness.replacingOccurrences(of: "ready", with: "failed") + ) + t.equal(failed.status, .failed) + + t.expect( + rejects( + ProxyReadinessObservation.self, + #"{"service":"codexcommander","version":"0.1.0","uptime":1,"pid":42,"port":10100,"status":"warming"}"# + ), + "unknown readiness status must be rejected" + ) + t.expect( + rejects( + ProxyReadinessObservation.self, + #"{"service":"codexcommander","version":"0.1.0","uptime":1,"pid":42,"port":10100,"status":"ready","detail":"extra"}"# + ), + "extra readiness keys must be rejected" + ) + t.expect( + rejects( + ProxyReadinessObservation.self, + #"{"service":"codexcommander","version":"0.1.0","pid":42,"port":10100,"status":"ready"}"# + ), + "missing readiness keys must be rejected" + ) + } + t.test("restart: rejects a partial accepted response") { t.expect( rejects(RestartAccepted.self, #"{"success":true,"activeTurnCount":0,"drainTimeoutMs":1000,"alreadyDraining":false}"#), diff --git a/app/Sources/MenuBarCoreTests/PollingSuite.swift b/app/Sources/MenuBarCoreTests/PollingSuite.swift index 3096fa6ffb..5cce7ffa72 100644 --- a/app/Sources/MenuBarCoreTests/PollingSuite.swift +++ b/app/Sources/MenuBarCoreTests/PollingSuite.swift @@ -12,7 +12,8 @@ enum PollingSuite { let client = ProxyClient( endpoint: endpoint, session: ProxyClient.secureSessionForTesting(protocolClasses: [StubProtocol.self]), - credentials: StaticCredentialStore("admin-secret") + credentials: StaticCredentialStore("admin-secret"), + attestationSecret: StubProtocol.attestationSecret ) let coordinator = PollingCoordinator(client: client, endpoint: endpoint) @@ -21,6 +22,7 @@ enum PollingSuite { let snapshot = sync { await coordinator.current } t.equal(sync { await coordinator.currentInterval }, PollingCoordinator.openInterval) t.equal(snapshot.state.isRunning, true) + t.equal(snapshot.readiness, .ready) t.equal(snapshot.activityLoaded, true) t.equal(snapshot.activity?.activities.count, 2) t.equal(snapshot.quotasLoaded, true) @@ -31,6 +33,7 @@ enum PollingSuite { StubProtocol.reset([ .init(status: 200, body: identity), .init(status: 200, body: startupHealth(status: "protected", diagnosticStale: false)), + readinessResponse(status: "ready"), .init(status: 200, body: identity), .init(status: 200, body: """ {"schemaVersion":1,"generatedAt":3,"proxyState":"active", @@ -58,7 +61,8 @@ enum PollingSuite { let client = ProxyClient( endpoint: endpoint, session: ProxyClient.secureSessionForTesting(protocolClasses: [StubProtocol.self]), - credentials: StaticCredentialStore("admin-secret") + credentials: StaticCredentialStore("admin-secret"), + attestationSecret: StubProtocol.attestationSecret ) let coordinator = PollingCoordinator(client: client, endpoint: endpoint) sync { await coordinator.refresh() } @@ -66,20 +70,22 @@ enum PollingSuite { sync { await coordinator.refresh() } let snapshot = sync { await coordinator.current } t.equal(snapshot.state, .unreachable) + t.equal(snapshot.readiness, .unavailable) t.equal(snapshot.consecutiveFailures, 3) t.equal(sync { await coordinator.currentInterval }, PollingCoordinator.backoffInterval) } t.test("polling: stale startup health stays neutral until revalidation completes") { StubProtocol.reset( - healthResponses(startupHealth(status: "at-risk", diagnosticStale: true)) - + healthResponses(startupHealth(status: "protected", diagnosticStale: false)) + startupResponses(startupHealth(status: "at-risk", diagnosticStale: true)) + + startupResponses(startupHealth(status: "protected", diagnosticStale: false)) ) let endpoint = ProxyEndpoint(host: "127.0.0.1", port: 10100, expectedPID: 42)! let client = ProxyClient( endpoint: endpoint, session: ProxyClient.secureSessionForTesting(protocolClasses: [StubProtocol.self]), - credentials: StaticCredentialStore("admin-secret") + credentials: StaticCredentialStore("admin-secret"), + attestationSecret: StubProtocol.attestationSecret ) let coordinator = PollingCoordinator(client: client, endpoint: endpoint) @@ -97,15 +103,16 @@ enum PollingSuite { t.test("polling: stale revalidation preserves the last known protected state") { StubProtocol.reset( - healthResponses(startupHealth(status: "protected", diagnosticStale: false)) - + healthResponses(startupHealth(status: "at-risk", diagnosticStale: true)) - + healthResponses(startupHealth(status: "protected", diagnosticStale: false)) + startupResponses(startupHealth(status: "protected", diagnosticStale: false)) + + startupResponses(startupHealth(status: "at-risk", diagnosticStale: true)) + + startupResponses(startupHealth(status: "protected", diagnosticStale: false)) ) let endpoint = ProxyEndpoint(host: "127.0.0.1", port: 10100, expectedPID: 42)! let client = ProxyClient( endpoint: endpoint, session: ProxyClient.secureSessionForTesting(protocolClasses: [StubProtocol.self]), - credentials: StaticCredentialStore("admin-secret") + credentials: StaticCredentialStore("admin-secret"), + attestationSecret: StubProtocol.attestationSecret ) let coordinator = PollingCoordinator(client: client, endpoint: endpoint) @@ -124,7 +131,7 @@ enum PollingSuite { } t.test("polling: persistently stale diagnostics eventually surface at-risk") { - let stale = healthResponses(startupHealth(status: "at-risk", diagnosticStale: true)) + let stale = startupResponses(startupHealth(status: "at-risk", diagnosticStale: true)) StubProtocol.reset( Array( repeating: stale, @@ -135,7 +142,8 @@ enum PollingSuite { let client = ProxyClient( endpoint: endpoint, session: ProxyClient.secureSessionForTesting(protocolClasses: [StubProtocol.self]), - credentials: StaticCredentialStore("admin-secret") + credentials: StaticCredentialStore("admin-secret"), + attestationSecret: StubProtocol.attestationSecret ) let coordinator = PollingCoordinator(client: client, endpoint: endpoint) @@ -152,15 +160,16 @@ enum PollingSuite { t.test("polling: activity failures do not erase successful quota data") { var responses = openResponses() - // The third logical response is activity's management response (index 3 - // after health/startup-health and activity health). - responses[3] = .init(status: 500) + // Readiness follows startup health, so activity's management response is + // index 4 (after health/startup-health/readyz and activity health). + responses[4] = .init(status: 500) StubProtocol.reset(responses) let endpoint = ProxyEndpoint(host: "127.0.0.1", port: 10100, expectedPID: 42)! let client = ProxyClient( endpoint: endpoint, session: ProxyClient.secureSessionForTesting(protocolClasses: [StubProtocol.self]), - credentials: StaticCredentialStore("admin-secret") + credentials: StaticCredentialStore("admin-secret"), + attestationSecret: StubProtocol.attestationSecret ) let coordinator = PollingCoordinator(client: client, endpoint: endpoint) sync { await coordinator.setPopoverOpen(true) } @@ -170,13 +179,56 @@ enum PollingSuite { t.equal(snapshot.quotas.count, 2) } + t.test("polling: pending readiness is separate from authenticated liveness") { + StubProtocol.reset(startupResponses( + startupHealth(status: "protected", diagnosticStale: false), + readinessStatus: "pending" + )) + let endpoint = ProxyEndpoint(host: "127.0.0.1", port: 10100, expectedPID: 42)! + let client = ProxyClient( + endpoint: endpoint, + session: ProxyClient.secureSessionForTesting(protocolClasses: [StubProtocol.self]), + credentials: StaticCredentialStore("admin-secret"), + attestationSecret: StubProtocol.attestationSecret + ) + let coordinator = PollingCoordinator(client: client, endpoint: endpoint) + sync { await coordinator.refresh() } + + let snapshot = sync { await coordinator.current } + t.equal(snapshot.state.isRunning, true) + t.equal(snapshot.readiness, .pending) + t.equal(snapshot.consecutiveFailures, 0) + } + + t.test("polling: unavailable readiness does not overwrite successful liveness") { + StubProtocol.reset( + healthResponses(startupHealth(status: "protected", diagnosticStale: false)) + + [.init(status: 0, urlError: .timedOut)] + ) + let endpoint = ProxyEndpoint(host: "127.0.0.1", port: 10100, expectedPID: 42)! + let client = ProxyClient( + endpoint: endpoint, + session: ProxyClient.secureSessionForTesting(protocolClasses: [StubProtocol.self]), + credentials: StaticCredentialStore("admin-secret"), + attestationSecret: StubProtocol.attestationSecret + ) + let coordinator = PollingCoordinator(client: client, endpoint: endpoint) + sync { await coordinator.refresh() } + + let snapshot = sync { await coordinator.current } + t.equal(snapshot.state.isRunning, true) + t.equal(snapshot.readiness, .unavailable) + t.equal(snapshot.consecutiveFailures, 0) + } + t.test("polling: manual refresh bypasses the proxy quota cache") { StubProtocol.reset(openResponses()) let endpoint = ProxyEndpoint(host: "127.0.0.1", port: 10100, expectedPID: 42)! let client = ProxyClient( endpoint: endpoint, session: ProxyClient.secureSessionForTesting(protocolClasses: [StubProtocol.self]), - credentials: StaticCredentialStore("admin-secret") + credentials: StaticCredentialStore("admin-secret"), + attestationSecret: StubProtocol.attestationSecret ) let coordinator = PollingCoordinator(client: client, endpoint: endpoint) sync { await coordinator.setPopoverOpen(true) } @@ -201,7 +253,8 @@ enum PollingSuite { let client = ProxyClient( endpoint: endpoint, session: ProxyClient.secureSessionForTesting(protocolClasses: [StubProtocol.self]), - credentials: StaticCredentialStore("admin-secret") + credentials: StaticCredentialStore("admin-secret"), + attestationSecret: StubProtocol.attestationSecret ) let coordinator = PollingCoordinator(client: client, endpoint: endpoint) let opening = Task { await coordinator.setPopoverOpen(true) } @@ -246,7 +299,7 @@ enum PollingSuite { "availability":[{"provider":"xai","status":"unavailable", "reason":"local_cli_refresh_required","checkedAt":1780000000000}]} """ - return healthResponses(startupHealth(status: "protected", diagnosticStale: false)) + return startupResponses(startupHealth(status: "protected", diagnosticStale: false)) + healthResponses(activity) + healthResponses(#"[{"name":"openai"},{"name":"kimi"}]"#) + healthResponses(quotas) @@ -256,6 +309,23 @@ enum PollingSuite { [.init(status: 200, body: identity), .init(status: 200, body: body)] } + private static func startupResponses( + _ body: String, + readinessStatus: String = "ready" + ) -> [StubProtocol.Response] { + healthResponses(body) + [readinessResponse(status: readinessStatus)] + } + + private static func readinessResponse(status: String) -> StubProtocol.Response { + .init( + status: status == "ready" ? 200 : 503, + body: """ + {"service":"codexcommander","version":"0.1.0","uptime":1, + "pid":42,"port":10100,"status":"\(status)"} + """ + ) + } + private static func startupHealth(status: String, diagnosticStale: Bool) -> String { """ {"status":"\(status)","routingKind":"codexcommander-local","routingInjected":true, diff --git a/app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift b/app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift index 1381d399e8..76601c6d72 100644 --- a/app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift +++ b/app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift @@ -100,6 +100,26 @@ enum SnapshotStateSuite { t.equal(snapshot.providers, providers) } + t.test("snapshot: readiness is orthogonal to process and routing state") { + let initial = ProxySnapshot( + state: .running(health("protected")), + endpoint: endpoint + ) + t.equal(initial.readiness, .unknown) + + for readiness: ProxyReadinessState in [ + .unavailable, .pending, .ready, .failed, + ] { + let snapshot = ProxySnapshot( + state: .running(health("protected")), + readiness: readiness, + endpoint: endpoint + ) + t.equal(snapshot.state.isRunning, true, "\(readiness) liveness") + t.equal(snapshot.readiness, readiness) + } + } + t.test("polling: the interval backs off only after repeated failures") { t.equal(PollingCoordinator.openInterval, 2) t.equal(PollingCoordinator.closedInterval, 30) diff --git a/app/Sources/MenuBarCoreTests/TransportSuite.swift b/app/Sources/MenuBarCoreTests/TransportSuite.swift index 2ca2beaeb4..6dd4e0a481 100644 --- a/app/Sources/MenuBarCoreTests/TransportSuite.swift +++ b/app/Sources/MenuBarCoreTests/TransportSuite.swift @@ -1,7 +1,9 @@ import Foundation +import CryptoKit import MenuBarCore final class StubProtocol: URLProtocol, @unchecked Sendable { + static let attestationSecret = String(repeating: "S", count: 43) final class RequestGate: @unchecked Sendable { private let started = DispatchSemaphore(value: 0) private let resumeSignal = DispatchSemaphore(value: 0) @@ -23,23 +25,27 @@ final class StubProtocol: URLProtocol, @unchecked Sendable { let body: String let headers: [String: String] let urlError: URLError.Code? + let automaticAttestation: Bool init( status: Int, body: String = "", headers: [String: String] = [:], - urlError: URLError.Code? = nil + urlError: URLError.Code? = nil, + automaticAttestation: Bool = true ) { self.status = status self.body = body self.headers = headers self.urlError = urlError + self.automaticAttestation = automaticAttestation } } nonisolated(unsafe) private static var queue: [Response] = [] nonisolated(unsafe) private static var requests: [URLRequest] = [] nonisolated(unsafe) private static var nextGate: RequestGate? + nonisolated(unsafe) private static var stoppedLoads = 0 private static let lock = NSLock() static func reset(_ responses: [Response]) { @@ -47,6 +53,7 @@ final class StubProtocol: URLProtocol, @unchecked Sendable { queue = responses requests = [] nextGate = nil + stoppedLoads = 0 lock.unlock() } @@ -64,6 +71,12 @@ final class StubProtocol: URLProtocol, @unchecked Sendable { return requests } + static var cancellationCount: Int { + lock.lock() + defer { lock.unlock() } + return stoppedLoads + } + override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } @@ -85,25 +98,87 @@ final class StubProtocol: URLProtocol, @unchecked Sendable { client?.urlProtocol(self, didFailWithError: URLError(code)) return } + var headers = response.headers + if response.automaticAttestation, + request.url?.path == "/healthz", + headers["x-codexcommander-attestation-proof"] == nil, + let proof = Self.attestationProof(request: request, body: response.body) { + headers["x-codexcommander-attestation-proof"] = proof + } let http = HTTPURLResponse( url: request.url!, statusCode: response.status, httpVersion: "HTTP/1.1", - headerFields: response.headers + headerFields: headers )! client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: Data(response.body.utf8)) client?.urlProtocolDidFinishLoading(self) } - override func stopLoading() {} + override func stopLoading() { + Self.lock.lock() + Self.stoppedLoads += 1 + Self.lock.unlock() + } + + private static func attestationProof(request: URLRequest, body: String) -> String? { + guard let challenge = request.value( + forHTTPHeaderField: "x-codexcommander-attestation-challenge" + ), + challenge.range(of: "^[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil, + let data = body.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let pid = json["pid"] as? Int, + let port = json["port"] as? Int + else { return nil } + let payload = "codexcommander-local-management-v1\n\(challenge)\n\(pid)\n\(port)" + let key = SymmetricKey(data: Data(attestationSecret.utf8)) + let digest = Data(HMAC.authenticationCode( + for: Data(payload.utf8), + using: key + )) + return digest.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } } + +private final class InstallationSequence: @unchecked Sendable { + private let lock = NSLock() + private var values: [ProxyInstallation] + + init(_ values: [ProxyInstallation]) { self.values = values } + + func next() throws -> ProxyInstallation { + lock.lock() + defer { lock.unlock() } + guard !values.isEmpty else { throw ProxyError.identityMismatch } + return values.removeFirst() + } +} + enum TransportSuite { private static let identity = #"{"status":"ok","service":"codexcommander","version":"0.1.0","pid":42,"port":10100}"# private static let startupHealth = #"{"status":"protected","routingKind":"codexcommander-local","routingInjected":true,"localRoutingDependency":true,"autostartEnabled":false,"rebootSafe":true,"protection":"service","serviceInstalled":true,"serviceViable":true,"serviceEnabled":true,"serviceRunning":true,"serviceStale":false,"serviceConflict":false,"shimInstalled":false,"shimHealthy":false,"shimCoverage":"none","serviceSupported":true,"platform":"darwin","diagnosticStale":false,"recommendedCommand":null,"commands":{"installService":"ccx service install","repairService":"ccx service repair","installShim":"ccx codex-shim install","restoreNative":"ccx restore"}}"# + private static func readinessBody( + status: String, + service: String = "codexcommander", + version: String = "0.1.0", + uptime: Double = 1, + pid: Int = 42, + port: Int = 10100 + ) -> String { + """ + {"service":"\(service)","version":"\(version)","uptime":\(uptime), + "pid":\(pid),"port":\(port),"status":"\(status)"} + """ + } + static func run(_ t: TestRunner) { t.test("transport: validates identity immediately before a credential-bearing request") { StubProtocol.reset([ @@ -116,6 +191,12 @@ enum TransportSuite { let requests = StubProtocol.recorded t.equal(requests.count, 2) t.equal(requests[0].url?.path, "/healthz") + t.expect( + requests[0].value( + forHTTPHeaderField: "x-codexcommander-attestation-challenge" + )?.range(of: "^[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil, + "health challenge" + ) t.isNil( requests[0].value(forHTTPHeaderField: "x-codexcommander-api-key"), "health credential" @@ -131,6 +212,77 @@ enum TransportSuite { t.equal(credentialHeaders.count, 1, "one management credential header") } + t.test("transport: public readiness accepts ready, pending, and failed without credentials") { + StubProtocol.reset([ + .init(status: 200, body: readinessBody(status: "ready")), + .init(status: 503, body: readinessBody(status: "pending")), + .init(status: 503, body: readinessBody(status: "failed")), + ]) + let client = makeClient(credential: "never-send-publicly") + let ready = sync { try? await client.readiness() } + let pending = sync { try? await client.readiness() } + let failed = sync { try? await client.readiness() } + t.equal(ready?.status, .ready) + t.equal(pending?.status, .pending) + t.equal(failed?.status, .failed) + + let requests = StubProtocol.recorded + t.equal(requests.map { $0.url?.path ?? "" }, ["/readyz", "/readyz", "/readyz"]) + for request in requests { + t.isNil( + request.value(forHTTPHeaderField: "x-codexcommander-api-key"), + "public readiness management credential" + ) + t.isNil( + request.value(forHTTPHeaderField: "authorization"), + "public readiness authorization" + ) + } + } + + t.test("transport: readiness rejects foreign, stale, and inconsistent contracts") { + let cases: [(String, Int, ProxyError, String)] = [ + (readinessBody(status: "ready", service: "other"), 200, .identityMismatch, "service"), + (readinessBody(status: "ready", version: ""), 200, .identityMismatch, "version"), + (readinessBody(status: "ready", uptime: -1), 200, .identityMismatch, "uptime"), + (readinessBody(status: "ready", pid: 41), 200, .identityMismatch, "pid"), + (readinessBody(status: "ready", port: 10101), 200, .identityMismatch, "port"), + (readinessBody(status: "ready"), 503, .identityMismatch, "503 ready"), + (readinessBody(status: "pending"), 200, .identityMismatch, "200 pending"), + (readinessBody(status: "ready"), 500, .http(500), "unexpected HTTP"), + ] + for (body, status, expected, label) in cases { + StubProtocol.reset([.init(status: status, body: body)]) + let client = makeClient(credential: "never-send-publicly") + let error = sync { await proxyError { try await client.readiness() } } + t.equal(error, expected, label) + t.isNil( + StubProtocol.recorded.first?.value( + forHTTPHeaderField: "x-codexcommander-api-key" + ), + "\(label) credential" + ) + } + } + + t.test("transport: readiness rejects non-exact response shapes") { + StubProtocol.reset([ + .init( + status: 200, + body: #"{"service":"codexcommander","version":"0.1.0","uptime":1,"pid":42,"port":10100,"status":"ready","detail":"not-public"}"# + ), + ]) + let client = makeClient(credential: "never-send-publicly") + let error = sync { await proxyError { try await client.readiness() } } + t.equal(error, .decoding) + t.isNil( + StubProtocol.recorded.first?.value( + forHTTPHeaderField: "x-codexcommander-api-key" + ), + "non-exact readiness credential" + ) + } + t.test("transport: a foreign health response blocks the token completely") { StubProtocol.reset([ .init( @@ -148,6 +300,134 @@ enum TransportSuite { ) } + t.test("transport: missing or wrong health proof exposes neither token nor body") { + let cases: [(StubProtocol.Response, String)] = [ + (.init( + status: 200, + body: identity, + automaticAttestation: false + ), "missing"), + (.init( + status: 200, + body: identity, + headers: ["x-codexcommander-attestation-proof": String(repeating: "Z", count: 43)], + automaticAttestation: false + ), "wrong"), + ] + for (response, label) in cases { + StubProtocol.reset([response]) + let client = makeClient(credential: "never-send") + let error = sync { await proxyError { + try await client.reportCompanionStartupState(launchAtLogin: .enabled) + } } + t.equal(error, .identityMismatch, label) + t.equal(StubProtocol.recorded.count, 1, "\(label) request count") + let request = StubProtocol.recorded[0] + t.equal(request.url?.path, "/healthz", "\(label) path") + t.isNil( + request.value(forHTTPHeaderField: "x-codexcommander-api-key"), + "\(label) credential" + ) + t.isNil(request.httpBody, "\(label) body") + t.isNil(request.httpBodyStream, "\(label) body stream") + } + } + + t.test("transport: attested health caps chunked and declared response bodies") { + let prefix = identity.dropLast() + let oversized = "\(prefix),\"padding\":\"\(String(repeating: "x", count: 20_000))\"}" + let cases: [(StubProtocol.Response, String)] = [ + (.init(status: 200, body: oversized), "chunked"), + (.init( + status: 200, + body: oversized, + headers: ["Content-Length": "20000"] + ), "declared"), + ] + for (response, label) in cases { + StubProtocol.reset([response]) + let client = makeClient(credential: "never-send") + let error = sync { await proxyError { try await client.health() } } + t.equal(error, .identityMismatch, label) + t.equal(StubProtocol.recorded.count, 1, "\(label) request count") + let request = StubProtocol.recorded[0] + t.equal(request.url?.path, "/healthz", "\(label) path") + t.isNil( + request.value(forHTTPHeaderField: "x-codexcommander-api-key"), + "\(label) credential" + ) + t.isNil(request.httpBody, "\(label) body") + t.isNil(request.httpBodyStream, "\(label) body stream") + if label == "chunked" { + t.expect(StubProtocol.cancellationCount >= 1, "chunked response cancelled") + } + } + } + + t.test("transport: config-source or pid-less discovery cannot receive a credential") { + StubProtocol.reset([]) + let endpoint = ProxyEndpoint(host: "127.0.0.1", port: 10100)! + let client = ProxyClient( + endpoint: endpoint, + session: ProxyClient.secureSessionForTesting(protocolClasses: [StubProtocol.self]), + credentials: StaticCredentialStore("never-send"), + attestationSecret: StubProtocol.attestationSecret + ) + let error = sync { await proxyError { + try await client.reportCompanionStartupState(launchAtLogin: .enabled) + } } + t.equal(error, .identityMismatch) + t.equal(StubProtocol.recorded.count, 0) + } + + t.test("transport: runtime record rotation after proof blocks token and body") { + let endpoint = ProxyEndpoint(host: "127.0.0.1", port: 10100, expectedPID: 42)! + let firstRuntime = ProxyRuntimeAttestation( + host: endpoint.host, + port: endpoint.port, + pid: 42, + secret: StubProtocol.attestationSecret + )! + let rotatedRuntime = ProxyRuntimeAttestation( + host: endpoint.host, + port: endpoint.port, + pid: 42, + secret: String(repeating: "R", count: 43) + )! + let first = ProxyInstallation( + endpoint: endpoint, + credential: "never-send", + credentialAvailability: .file, + configDirectory: URL(fileURLWithPath: "/"), + runtimeAttestation: firstRuntime + ) + let rotated = ProxyInstallation( + endpoint: endpoint, + credential: "never-send", + credentialAvailability: .file, + configDirectory: URL(fileURLWithPath: "/"), + runtimeAttestation: rotatedRuntime + ) + let sequence = InstallationSequence([first, rotated]) + let session = ProxyClient.secureSessionForTesting(protocolClasses: [StubProtocol.self]) + let client = try! ProxyClient( + installation: first, + session: session, + discovery: { try sequence.next() } + ) + StubProtocol.reset([.init(status: 200, body: identity)]) + let error = sync { await proxyError { + try await client.reportCompanionStartupState(launchAtLogin: .enabled) + } } + t.equal(error, .identityMismatch) + t.equal(StubProtocol.recorded.count, 1) + let request = StubProtocol.recorded[0] + t.equal(request.url?.path, "/healthz") + t.isNil(request.value(forHTTPHeaderField: "x-codexcommander-api-key"), "credential") + t.isNil(request.httpBody, "body") + t.isNil(request.httpBodyStream, "body stream") + } + t.test("transport: missing management auth fails before networking") { StubProtocol.reset([]) let client = makeClient(credential: nil) @@ -295,6 +575,59 @@ enum TransportSuite { "error text must not echo response bodies" ) } + + t.test("transport: confirmed GUI launch stays fragment-only and origin-bound") { + let expiry = Date().timeIntervalSince1970 * 1_000 + 30_000 + let launch = """ + {"ticket":"ccx_launch_\(String(repeating: "a", count: 43))",\ + "origin":"http://127.0.0.1:10100","route":"subagents",\ + "expiresAt":\(expiry)} + """ + StubProtocol.reset([ + .init(status: 200, body: identity), + .init(status: 200, body: launch), + ]) + let client = makeClient(credential: "admin-secret") + let url = sync { try? await client.confirmedGuiLaunchURL(route: "subagents") } + t.equal(url?.scheme, "http") + t.equal(url?.host, "127.0.0.1") + t.equal(url?.port, 10100) + let fragment = URLComponents(string: "http://local/?\(url?.fragment ?? "")") + let items: [String: String] = Dictionary(uniqueKeysWithValues: (fragment?.queryItems ?? []).compactMap { + guard let value = $0.value else { return nil } + return ($0.name, value) + }) + t.equal(items["ccx-route"], "subagents") + t.equal(items["ccx-launch-ticket"], "ccx_launch_\(String(repeating: "a", count: 43))") + + let requests = StubProtocol.recorded + t.equal(requests.map { $0.url?.path ?? "" }, ["/healthz", "/api/gui-launch-ticket"]) + t.equal(requests[1].value(forHTTPHeaderField: "x-codexcommander-api-key"), "admin-secret") + let bodyData = requests[1].httpBody ?? requestStreamBodyData(requests[1]) + let body = bodyData.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + t.equal(body?["route"] as? String, "subagents") + t.expect(!(requests[1].url?.absoluteString.contains("ccx_launch_") ?? true), "ticket absent from request URL") + } + + t.test("transport: a localhost response cannot change a 127.0.0.1 ticket origin") { + let expiry = Date().timeIntervalSince1970 * 1_000 + 30_000 + StubProtocol.reset([ + .init(status: 200, body: identity), + .init( + status: 200, + body: """ + {"ticket":"ccx_launch_\(String(repeating: "b", count: 43))",\ + "origin":"http://localhost:10100","route":"dashboard",\ + "expiresAt":\(expiry)} + """ + ), + ]) + let client = makeClient(credential: "admin-secret") + let error = sync { await proxyError { + try await client.confirmedGuiLaunchURL(route: "dashboard") + } } + t.equal(error, .identityMismatch) + } } private static func makeClient(credential: String?) -> ProxyClient { @@ -303,7 +636,8 @@ enum TransportSuite { return ProxyClient( endpoint: endpoint, session: session, - credentials: StaticCredentialStore(credential) + credentials: StaticCredentialStore(credential), + attestationSecret: StubProtocol.attestationSecret ) } diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift index ecac00152d..913f530e95 100644 --- a/app/Sources/MenuBarUI/AppDelegate.swift +++ b/app/Sources/MenuBarUI/AppDelegate.swift @@ -277,7 +277,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid /// decisions), so the tray hands the recommendation off instead of surfacing a raw /// `ccx service install` command the app would never execute. private func openStartupOptions() { - NSWorkspace.shared.open(startupOptionsURL()) + openHash("startup") } /// Package-visible seam so the UI tests can pin the destination without opening a @@ -296,12 +296,29 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid } private func openHash(_ hash: String) { - var components = URLComponents(url: endpoint.baseURL, resolvingAgainstBaseURL: false) - components?.fragment = hash - if let url = components?.url { - NSWorkspace.shared.open(url) - } else { - NSWorkspace.shared.open(endpoint.baseURL) + guard let client else { + controller.showResult( + "Secure dashboard launch is unavailable. Start CodexCommander and try again.", + isError: true + ) + return + } + Task { @MainActor [weak self, client] in + let result: Result + do { + result = .success(try await client.confirmedGuiLaunchURL(route: hash)) + } catch { + result = .failure(error) + } + guard let self else { return } + switch result { + case .success(let launch): + NSWorkspace.shared.open(launch) + case .failure(let error): + let message = (error as? ProxyError)?.userMessage + ?? "Secure dashboard launch failed. Try again." + self.controller.showResult(message, isError: true) + } } } @@ -513,95 +530,67 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid } } - /// Refreshes activity immediately before confirmation, then invokes the fixed - /// catalog helper. A missing activity response is presented as unknown, never idle. + /// Manual ChatGPT restart is the reliable catalog reload boundary. Keep the + /// menu action informational; guarded worker-only interruption remains an + /// advanced dashboard/CLI fallback. private func applyCodexCatalog() { guard catalogUpdateReady, !catalogActionInFlight, !lifecycleInFlight, !restartInFlight else { return } + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = "Restart ChatGPT to load the agent catalog" + alert.informativeText = "Quit ChatGPT completely, reopen it, then start a new task. CodexCommander will keep running. After ChatGPT reopens, return here and check the catalog status." + alert.addButton(withTitle: "Check status") + alert.addButton(withTitle: "Close") + panel.isPresentingModal = true + NSApp.activate(ignoringOtherApps: true) + let response = alert.runModal() + panel.isPresentingModal = false + if panel.isShown { panel.makeKeyAndOrderFront(nil) } + if response == .alertFirstButtonReturn { + recheckCodexCatalog() + } + } + + private func recheckCodexCatalog() { + guard catalogUpdateReady, + !catalogActionInFlight, + !lifecycleInFlight, + !restartInFlight + else { return } catalogActionInFlight = true updateApplicationMenu() controller.setLifecycleControlsEnabled(false) controller.setCatalogApplyEnabled(false) + controller.showResult("Checking agent catalog…", isError: false) - Task { [actions, client, coordinator] in - let activity: CatalogUpdateActivity - if let client { - do { - activity = CatalogUpdateActivity(snapshot: try await client.activity()) - } catch { - activity = .unknown - } - } else { - activity = .unknown - } - - let choice = await MainActor.run { [weak self] in - self?.confirmCatalogUpdate(activity: activity) ?? .later - } - guard choice == .applyNow else { - await MainActor.run { [weak self] in - guard let self else { return } - self.catalogActionInFlight = false - self.updateApplicationMenu() - self.controller.setLifecycleControlsEnabled(true) - self.refreshCatalogApplyAvailability() - } - return - } - - await MainActor.run { [weak self] in - self?.controller.showResult("Applying agent catalog…", isError: false) - } - let outcome = await actions?.applyCodexCatalog() - ?? .failed("Catalog lifecycle control is unavailable.") + Task { [actions, coordinator] in + let outcome = await actions?.ensure() + ?? .failed("Catalog status is unavailable.") await coordinator?.forceRefresh() await MainActor.run { [weak self] in guard let self else { return } self.catalogActionInFlight = false self.updateApplicationMenu() switch outcome { - case .applied(let summary): + case .running: self.clearCatalogUpdate() - if summary.stoppedWorkerCount == 0 { - let message = summary.catalogUpdated - ? "Agent catalog updated. No Codex background worker restart was needed." - : "Agent catalog is already current." - self.controller.showResult(message, isError: false) - } else { - let workers = summary.stoppedWorkerCount == 1 - ? "1 stale Codex background worker" - : "\(summary.stoppedWorkerCount) stale Codex background workers" - let pronoun = summary.stoppedWorkerCount == 1 ? "it" : "them" - let applied = summary.catalogUpdated - ? "Agent catalog updated." - : "Agent catalog applied." - self.controller.showResult( - "\(applied) Stopped \(workers); Codex will recreate \(pronoun) when needed.", - isError: false - ) - } - case .incomplete(let message, let stopped, let surviving): - self.presentCatalogUpdate(staleWorkerCount: surviving > 0 ? surviving : nil) - if surviving > 0 { - let stoppedText: String - if stopped == 1 { - stoppedText = " One stale worker stopped." - } else if stopped > 1 { - stoppedText = " \(stopped) stale workers stopped." - } else { - stoppedText = "" - } - self.controller.showResult( - "Agent catalog updated, but \(surviving) Codex background worker\(surviving == 1 ? " is" : "s are") still running.\(stoppedText)", - isError: true - ) - } else { - self.controller.showResult(message, isError: true) - } + self.controller.showResult( + "No stale ChatGPT worker is detected. Start a new task after ChatGPT reopens.", + isError: false + ) + case .catalogUpdateReady(let count): + self.presentCatalogUpdate(staleWorkerCount: count) + self.controller.showResult( + "ChatGPT is still using the previous agent catalog. Quit it completely, reopen it, then check again.", + isError: false + ) + case .stopped: + self.controller.showResult("CodexCommander is not running.", isError: true) case .failed(let message): self.controller.showResult(message, isError: true) } diff --git a/app/Sources/MenuBarUI/Views.swift b/app/Sources/MenuBarUI/Views.swift index 498ee7e082..3601c30b96 100644 --- a/app/Sources/MenuBarUI/Views.swift +++ b/app/Sources/MenuBarUI/Views.swift @@ -36,7 +36,7 @@ func makeSeparator() -> NSView { /// hold an older in-memory model roster. public final class CatalogUpdateView: NSView { private let title = makeLabel( - "Agent catalog update ready", + "Restart ChatGPT to load models", font: Theme.label, color: Theme.amber ) @@ -58,10 +58,10 @@ public final class CatalogUpdateView: NSView { layer?.borderWidth = 1 layer?.borderColor = Theme.amber.withAlphaComponent(0.32).cgColor - applyButton.title = "Apply agent catalog…" + applyButton.title = "Show restart steps…" applyButton.image = NSImage( - systemSymbolName: "arrow.triangle.2.circlepath", - accessibilityDescription: "Apply agent catalog update" + systemSymbolName: "info.circle", + accessibilityDescription: "Show ChatGPT restart steps and check status" ) applyButton.imagePosition = .imageLeading applyButton.bezelStyle = .recessed @@ -72,7 +72,7 @@ public final class CatalogUpdateView: NSView { applyButton.target = self applyButton.action = #selector(applyTapped) applyButton.setAccessibilityLabel( - "Apply the agent catalog update by restarting only Codex background workers" + "Show how to restart ChatGPT and check the saved agent catalog status" ) let column = NSStackView(views: [title, detail, applyButton]) @@ -110,8 +110,8 @@ public final class CatalogUpdateView: NSView { default: workerText = "Codex background workers are using an older model roster." } - detail.stringValue = "\(workerText) Applying the update restarts only those workers and may interrupt active answers. CodexCommander remains running." - setAccessibilityLabel("Agent catalog update ready. \(detail.stringValue)") + detail.stringValue = "\(workerText) Quit and reopen ChatGPT, then start a new task. CodexCommander remains running." + setAccessibilityLabel("ChatGPT restart required. \(detail.stringValue)") isHidden = false } @@ -135,13 +135,15 @@ public final class CatalogUpdateView: NSView { // MARK: - Status header -/// Brand mark + CodexCommander title + truthful status + active count. +/// Brand mark + separate proxy-health, readiness, in-flight-request, and Codex-route status. public final class StatusHeaderView: NSView { private let brand = NSImageView() private let title = makeLabel("CodexCommander", font: Theme.title, color: Theme.text) private let dot = StatusDotView() private let status = makeLabel("", font: Theme.captionMedium, color: Theme.muted) - private let active = makeLabel("", font: Theme.caption, color: Theme.faint) + private let requestCount = makeLabel("", font: Theme.caption, color: Theme.faint) + private let readiness = makeLabel("", font: Theme.micro, color: Theme.faint) + private let codexRoute = makeLabel("", font: Theme.micro, color: Theme.faint) private let divider: NSView = { let view = NSView() view.wantsLayer = true @@ -163,7 +165,11 @@ public final class StatusHeaderView: NSView { brand.heightAnchor.constraint(equalToConstant: 25).isActive = true let left = makeRow([brand, title], spacing: 8) - let right = makeRow([dot, status, divider, active], spacing: 7) + let proxyState = makeRow([dot, status, divider, requestCount], spacing: 7) + let right = NSStackView(views: [proxyState, readiness, codexRoute]) + right.orientation = .vertical + right.alignment = .trailing + right.spacing = 1 let row = NSStackView(views: [left, NSView(), right]) row.orientation = .horizontal row.alignment = .centerY @@ -187,31 +193,62 @@ public final class StatusHeaderView: NSView { func apply(_ snapshot: ProxySnapshot) { let state = snapshot.state let activeCount = snapshot.activity?.activeTurnCount - if state.isRunning, let activeCount, snapshot.activityLoaded { - status.stringValue = activeCount > 0 ? "Active" : "Idle" - } else { - status.stringValue = state.title - } + status.stringValue = "Proxy \(state.title.lowercased())" status.textColor = Theme.color(for: bridge(state.tone)) dot.tone = bridge(state.tone) if let activeCount, snapshot.activityLoaded { - active.stringValue = activeCount == 1 ? "1 active" : "\(activeCount) active" - active.isHidden = false + requestCount.stringValue = activeCount == 1 ? "1 in flight" : "\(activeCount) in flight" + requestCount.isHidden = false divider.isHidden = false } else { - active.stringValue = "" - active.isHidden = true + requestCount.stringValue = "" + requestCount.isHidden = true divider.isHidden = true } - var label = "CodexCommander \(state.title)" + let readinessText = "Readiness · \(Self.readinessName(snapshot.readiness))" + readiness.stringValue = readinessText + + let routeText: String? + if case .running(let health) = state { + routeText = "Codex route · \(Self.codexRouteName(health))" + } else { + routeText = nil + } + codexRoute.stringValue = routeText ?? "" + codexRoute.isHidden = routeText == nil + + var label = "CodexCommander, proxy \(state.title.lowercased())" if let activeCount, snapshot.activityLoaded { - label += ", \(activeCount) active" + label += ", \(activeCount) request\(activeCount == 1 ? "" : "s") in flight" } + label += ", \(readinessText)" + if let routeText { label += ", \(routeText)" } setAccessibilityLabel(label) } + private static func readinessName(_ state: ProxyReadinessState) -> String { + switch state { + case .unknown: return "Checking" + case .unavailable: return "Unavailable" + case .pending: return "Starting" + case .ready: return "Ready" + case .failed: return "Startup failed" + } + } + + private static func codexRouteName(_ health: StartupHealth) -> String { + if health.diagnosticStale { return "Unknown" } + if health.routingInjected { return "CodexCommander" } + switch health.routingKind { + case "native": return "Native OpenAI" + case "custom-local": return "Custom local" + case "custom-remote": return "Custom remote" + default: return "Unknown" + } + } + private func bridge(_ tone: ProxyState.Tone) -> ProxyToneBridge { switch tone { case .neutral: return .neutral @@ -220,6 +257,15 @@ public final class StatusHeaderView: NSView { case .bad: return .bad } } + + package var statusText: String { status.stringValue } + package var requestCountText: String? { + requestCount.isHidden ? nil : requestCount.stringValue + } + package var readinessText: String { readiness.stringValue } + package var codexRouteText: String? { + codexRoute.isHidden ? nil : codexRoute.stringValue + } } final class StatusDotView: NSView { @@ -247,11 +293,11 @@ final class StatusDotView: NSView { } } -// MARK: - Agent activity +// MARK: - Live proxy requests -/// One-level tree of primary agents with emitted children; orphan subagents stand alone. +/// One-level tree of in-flight primary/child turns; orphan child requests stand alone. public final class AgentActivityView: NSView { - private let heading = makeLabel("Agent activity", font: Theme.captionMedium, color: Theme.muted) + private let heading = makeLabel("Live proxy requests", font: Theme.captionMedium, color: Theme.muted) private let body = NSStackView() private let empty = makeLabel("", font: Theme.caption, color: Theme.muted) @@ -280,7 +326,7 @@ public final class AgentActivityView: NSView { ]) setAccessibilityElement(true) setAccessibilityRole(.group) - setAccessibilityLabel("Agent activity") + setAccessibilityLabel("Live proxy requests") } required init?(coder: NSCoder) { nil } @@ -294,16 +340,16 @@ public final class AgentActivityView: NSView { guard snapshot.activityLoaded else { body.isHidden = true empty.isHidden = false - empty.stringValue = "Activity unavailable" - setAccessibilityLabel("Agent activity unavailable") + empty.stringValue = "Request activity unavailable" + setAccessibilityLabel("Live proxy requests unavailable") return } guard let activity = snapshot.activity, activity.isSupported else { body.isHidden = true empty.isHidden = false - empty.stringValue = "Activity unavailable" - setAccessibilityLabel("Agent activity unavailable") + empty.stringValue = "Request activity unavailable" + setAccessibilityLabel("Live proxy requests unavailable") return } @@ -314,7 +360,7 @@ public final class AgentActivityView: NSView { if visible.isEmpty { body.isHidden = true empty.isHidden = false - empty.stringValue = "No active agents" + empty.stringValue = "No requests in flight" setAccessibilityLabel(empty.stringValue) return } @@ -353,11 +399,11 @@ public final class AgentActivityView: NSView { } if activity.truncated { - let note = makeLabel("Showing active subset", font: Theme.micro, color: Theme.faint) + let note = makeLabel("Showing in-flight subset", font: Theme.micro, color: Theme.faint) body.addArrangedSubview(note) } - setAccessibilityLabel("Agent activity, \(visible.count) shown") + setAccessibilityLabel("Live proxy requests, \(visible.count) shown") } private func appendRow(_ activity: AgentActivity, indented: Bool) { @@ -366,6 +412,9 @@ public final class AgentActivityView: NSView { body.addArrangedSubview(row) row.widthAnchor.constraint(equalTo: body.widthAnchor).isActive = true } + + package var headingText: String { heading.stringValue } + package var emptyText: String? { empty.isHidden ? nil : empty.stringValue } } final class AgentActivityRowView: NSView { @@ -405,7 +454,7 @@ final class AgentActivityRowView: NSView { font: Theme.caption, color: activity.phase == .running ? Theme.green : Theme.muted ) - let role = activity.role == .primary ? "Primary" : "Subagent" + let role = activity.role == .primary ? "Primary turn" : "Subagent turn" let meta = makeLabel(role, font: Theme.micro, color: Theme.faint) let elapsed = makeLabel(elapsedText(since: activity.startedAt), font: Theme.numericSmall, color: Theme.faint) elapsed.alignment = .right @@ -451,7 +500,7 @@ final class AgentActivityRowView: NSView { setAccessibilityElement(true) setAccessibilityRole(.staticText) - setAccessibilityLabel("\(activity.displayName), \(activity.phase.rawValue)") + setAccessibilityLabel("\(activity.displayName), \(role), \(activity.phase.rawValue)") } required init?(coder: NSCoder) { nil } diff --git a/app/Sources/MenuBarUITests/main.swift b/app/Sources/MenuBarUITests/main.swift index ab02a2c0b8..2e0b84d341 100644 --- a/app/Sources/MenuBarUITests/main.swift +++ b/app/Sources/MenuBarUITests/main.swift @@ -45,6 +45,8 @@ func decodeQuotaAvailability(_ json: String) -> [ProviderQuotaAvailability] { func currentHealth( status: String = "protected", protection: String = "none", + routingKind: String = "codexcommander-local", + routingInjected: Bool = true, serviceInstalled: Bool = false, serviceEnabled: Bool = false, diagnosticStale: Bool = false, @@ -54,9 +56,9 @@ func currentHealth( status: status, protection: protection, platform: "darwin", - routingKind: "codexcommander-local", - routingInjected: true, - localRoutingDependency: true, + routingKind: routingKind, + routingInjected: routingInjected, + localRoutingDependency: routingKind != "native" && routingKind != "custom-remote", autostartEnabled: serviceEnabled, serviceRunning: serviceEnabled, serviceInstalled: serviceInstalled, @@ -82,6 +84,7 @@ func currentHealth( func activitySnapshot( activities: String, + activeTurnCount: Int = 2, unattributed: Int = 0, truncated: Bool = false ) -> AgentActivitySnapshot { @@ -90,7 +93,7 @@ func activitySnapshot( "schemaVersion": 1, "generatedAt": 1, "proxyState": "running", - "activeTurnCount": 2, + "activeTurnCount": \(activeTurnCount), "displayedActivityCount": 2, "unattributedActiveCount": \(unattributed), "truncated": \(truncated ? "true" : "false"), @@ -106,6 +109,7 @@ func makeSnapshot( activity: AgentActivitySnapshot? = nil, providers: [ProviderSummary] = [], health: StartupHealth = currentHealth(), + readiness: ProxyReadinessState = .unknown, recommendedCommand: String? = nil, providersLoaded: Bool = false, quotasLoaded: Bool = true, @@ -113,6 +117,7 @@ func makeSnapshot( ) -> ProxySnapshot { ProxySnapshot( state: .running(health), + readiness: readiness, endpoint: .default, quotas: quotas, quotaAvailability: quotaAvailability, @@ -191,7 +196,7 @@ runner.test("ui: footer exposes navigation, proxy lifecycle, and both exit contr ) } -runner.test("ui: catalog update is a persistent action outside the proxy footer") { +runner.test("ui: catalog update presents manual ChatGPT restart outside the proxy footer") { let controller = PopoverViewController() _ = controller.view controller.apply(makeSnapshot()) @@ -201,7 +206,7 @@ runner.test("ui: catalog update is a persistent action outside the proxy footer" controller.onApplyCodexCatalog = { applied = true } controller.showCatalogUpdate(staleWorkerCount: 2) runner.equal(controller.catalogUpdateVisible, true) - runner.equal(controller.catalogUpdateButtonTitle, "Apply agent catalog…") + runner.equal(controller.catalogUpdateButtonTitle, "Show restart steps…") runner.expect( controller.catalogUpdateDetail.contains("2 Codex background workers"), "card reports a count without exposing process identifiers" @@ -211,12 +216,16 @@ runner.test("ui: catalog update is a persistent action outside the proxy footer" "card distinguishes the catalog action from a proxy restart" ) runner.expect( - controller.catalogUpdateAccessibilityLabel?.contains("Agent catalog update ready") == true, + controller.catalogUpdateDetail.contains("Quit and reopen ChatGPT"), + "card makes the reliable manual reload boundary primary" + ) + runner.expect( + controller.catalogUpdateAccessibilityLabel?.contains("ChatGPT restart required") == true, "catalog card has an accessible state label" ) runner.expect( - controller.catalogUpdateButtonAccessibilityLabel?.contains("restarting only Codex") == true, - "catalog action explains its narrow restart boundary" + controller.catalogUpdateButtonAccessibilityLabel?.contains("check the saved agent catalog status") == true, + "catalog action explains the manual restart and revalidation boundary" ) controller.apply(makeSnapshot()) runner.equal( @@ -569,16 +578,82 @@ runner.test("ui: activity empty and unavailable states stay compact") { let unloaded = makeSnapshot(activityLoaded: false) controller.apply(unloaded) + runner.equal(controller.activityView.headingText, "Live proxy requests", "request heading") + runner.equal(controller.activityView.emptyText, "Request activity unavailable", "unavailable copy") runner.expect(controller.activityView.accessibilityLabel()?.contains("unavailable") == true || controller.activityView.accessibilityLabel()?.contains("Activity") == true, "unavailable label present") - let empty = activitySnapshot(activities: "") + let empty = activitySnapshot(activities: "", activeTurnCount: 0) controller.apply(makeSnapshot(activity: empty)) + runner.equal(controller.activityView.emptyText, "No requests in flight", "empty request copy") // Should not crash and should keep preferred width. runner.equal(controller.preferredContentSize.width, 387, "width stable") } +runner.test("ui: header separates proxy requests from the Codex route") { + let controller = PopoverViewController() + _ = controller.view + let activity = activitySnapshot(activities: "", activeTurnCount: 2) + + controller.apply(makeSnapshot(activity: activity)) + runner.equal(controller.headerView.statusText, "Proxy running", "proxy status") + runner.equal(controller.headerView.requestCountText, "2 in flight", "request count") + runner.equal(controller.headerView.readinessText, "Readiness · Checking", "initial readiness") + runner.equal( + controller.headerView.codexRouteText, + "Codex route · CodexCommander", + "managed Codex route" + ) + + controller.apply(makeSnapshot( + activity: activity, + health: currentHealth( + status: "native", + routingKind: "native", + routingInjected: false + ) + )) + runner.equal(controller.headerView.codexRouteText, "Codex route · Native OpenAI", "native route") + runner.expect( + controller.headerView.accessibilityLabel()?.contains("2 requests in flight") == true, + "request count is explicit to assistive technology" + ) + + controller.apply(makeSnapshot( + activity: activity, + health: currentHealth(diagnosticStale: true) + )) + runner.equal(controller.headerView.codexRouteText, "Codex route · Unknown", "stale route fails closed") +} + +runner.test("ui: header keeps readiness separate from liveness and routing") { + let controller = PopoverViewController() + _ = controller.view + let states: [(ProxyReadinessState, String)] = [ + (.unknown, "Checking"), + (.pending, "Starting"), + (.ready, "Ready"), + (.failed, "Startup failed"), + (.unavailable, "Unavailable"), + ] + + for (state, label) in states { + controller.apply(makeSnapshot(readiness: state)) + runner.equal(controller.headerView.statusText, "Proxy running", "liveness stays running for \(label)") + runner.equal(controller.headerView.readinessText, "Readiness · \(label)", "readiness \(label)") + runner.equal( + controller.headerView.codexRouteText, + "Codex route · CodexCommander", + "route stays independent for \(label)" + ) + } + runner.expect( + controller.headerView.accessibilityLabel()?.contains("Readiness · Unavailable") == true, + "readiness is explicit to assistive technology" + ) +} + runner.test("ui: activity rows render once and elapsed timers clear the scrollbar") { let now = Int64(Date().timeIntervalSince1970 * 1_000) let activity = activitySnapshot( @@ -601,6 +676,7 @@ runner.test("ui: activity rows render once and elapsed timers clear the scrollba controller.view.layoutSubtreeIfNeeded() let fields = textFields(in: controller.activityView) + runner.expect(fields.contains { $0.stringValue == "Subagent turn" }, "child row is a turn, not a durable agent") runner.expect( fields.allSatisfy { !$0.stringValue.localizedCaseInsensitiveContains("unattributed") }, "already-rendered subagents should not be counted again in a footer" diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 95d6365209..d3fb4d5f60 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -121,7 +121,7 @@ forces Codex's model cache stale after a toggle. ## Multi-agent surface mode -The Models page labels the three collaboration choices **Classic v1**, **Follow Codex defaults** (the +The Models page labels the three collaboration choices **Reliable v1**, **Codex native** (the base/upstream behavior), and **Concurrent v2**. This control changes which Codex collaboration surface each picker entry uses; see [Sub-agent Surface](/guides/sub-agent-surface/) for the canonical mode, delegation, inheritance, fallback, and encrypted-task behavior. diff --git a/docs-site/src/content/docs/guides/macos-menu-bar.md b/docs-site/src/content/docs/guides/macos-menu-bar.md index 4f0a311789..5477f2e54d 100644 --- a/docs-site/src/content/docs/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -1,6 +1,6 @@ --- title: macOS Menu Bar Companion -description: Install and use the native CodexCommander status, agent-activity, and provider-quota companion. +description: Install and use the native CodexCommander proxy, startup-readiness, Codex-route, live-request, and provider-quota companion. --- The macOS companion puts the most useful CodexCommander state in the menu bar without replacing the @@ -45,23 +45,32 @@ override it. ## What the panel shows -- **Agent activity** — the current active count and live model/provider rows. A spawned child is - nested only when CodexCommander can prove its active parent from request metadata; otherwise it is - shown as a standalone subagent. The companion never invents queued, reviewing, rate-limited, or - completed history. +- **Proxy status** — reports process liveness without treating a running server as proof that startup + synchronization finished or that Codex uses the proxy. +- **Readiness** — reports startup and catalog synchronization as **Checking**, **Starting**, **Ready**, + **Startup failed**, or **Unavailable**. This signal is independent of proxy liveness. +- **Codex route** — reports whether Codex currently routes through CodexCommander, native OpenAI, or + another custom route. A running proxy does not by itself mean that Codex is using it. +- **Live proxy requests** — the current in-flight request count and live model/provider turn rows. A + spawned-child request is nested only when CodexCommander can prove its in-flight parent from request + metadata; otherwise it is shown as a standalone subagent turn. A row disappears when that model + request settles, even if Codex keeps the child thread alive and idle for later work. This is not a + persistent Codex agent-lifecycle view, and the companion never invents queued, reviewing, + rate-limited, or completed history. - **Provider quotas** — provider-reported 5-hour, weekly, monthly, or provider-specific credit windows and reset times when available. OpenCode Go instead shows its published caps and local observations, never an invented live balance. Missing data is shown as unavailable, never as zero usage or unlimited capacity. -- **Dashboard and Logs** — open the corresponding local dashboard view in your default browser. +- **Dashboard and Logs** — open the corresponding local dashboard view in your default browser with + a one-time launch authorization for full dashboard changes, including catalog Apply. - **Startup options…** — opens the dashboard's Startup page when an optional startup upgrade or repair is available; the panel does not make a raw CLI command the primary action. - **Manage** — opens the selected provider's Accounts or API Keys tab. OAuth, API-key entry, reauthentication, account switching, and provider configuration stay in the dashboard. -- **Agent catalog update ready** — a persistent, nonfatal card shown when running Codex background +- **Restart ChatGPT to load models** — a persistent, nonfatal card shown when running Codex background workers still hold an older model roster. The CodexCommander proxy remains healthy and running. -- **Apply agent catalog…** — opens a confirmation that reports fresh request activity when - available, warns that applying may interrupt an answer, and offers **Apply Now** or **Later**. +- **Show restart steps…** — explains the recommended reload boundary: quit ChatGPT completely, reopen + it, and then start a new task. The menu app does not force-restart background workers from this card. - **Stop Proxy…** — always asks for confirmation, interrupts active client and sub-agent requests, restores native Codex, and leaves the menu app open. - **Restart Proxy…** — always asks for confirmation, lets the proxy drain active requests for up to 60 @@ -91,18 +100,22 @@ missing optional crash recovery does not turn a healthy running app into an alar Opening the app automatically synchronizes the Codex model catalog with the providers currently configured in CodexCommander. If no Codex worker is running, the new roster is ready for the next Codex task. If a long-lived worker loaded an older roster, CodexCommander stays running and the panel keeps the -nonfatal **Agent catalog update ready** card visible. +nonfatal **Restart ChatGPT to load models** card visible. -Choose **Apply agent catalog…** to review the interruption risk. The confirmation requests a fresh -active-request count when possible, but zero active requests is not presented as proof that Codex is -idle: another request can begin before the action runs. **Apply Now** synchronizes once more, sends -`SIGTERM` only to exact current-user `codex … app-server` and `codex-code-mode-host` process matches, -and briefly verifies that the old process IDs exited. It never uses a broad `pkill`, restarts the -CodexCommander proxy, or closes the menu app. Codex creates a fresh background host on the next task and -loads the current roster. +Choose **Show restart steps…**, quit ChatGPT completely, reopen it, and then start a new task. This is +the recommended and most predictable way to replace the old worker. CodexCommander and the menu app +remain running throughout. -The current companion does not include **Apply when idle**. If an answer is active, choose **Later** and apply -the update when you are ready; the card remains available. The advanced CLI fallback is: +A new task or fork inside the same old background host is not a catalog-reload boundary. The menu card +therefore stays available until status observes a current worker. If the dashboard instead reports a +pending or unknown catalog, or says managed routing is not injected, choose **Apply to Codex** there so +it can reconcile and prove those files first; quitting ChatGPT alone is not that repair. + +For an already-converged stale worker, the dashboard/API **Force-restart workers** action and the CLI +remain advanced fallbacks. They re-synchronize, use a desired-revision fence, signal only exact +current-user `codex … app-server` and `codex-code-mode-host` matches, and never escalate to a broad +`pkill`. Because they bypass ChatGPT's normal app lifecycle, ChatGPT may show **stopped unexpectedly**. +The CLI form is: ```bash ccx sync --restart-codex @@ -119,6 +132,14 @@ validated, no-follow file descriptor, keeps the value only in process memory, an an identity-verified loopback CodexCommander process. It never displays, logs, copies, stores, or places the token in a browser URL. +When the companion opens the dashboard, it asks that verified local proxy for a short-lived, +single-use launch ticket. The ticket appears only in the URL fragment and is removed during its +one-time exchange; the durable admin token never enters the URL or web storage. The resulting +full-featured session is process-memory-only, lasts up to eight hours, and is never renewed. Expiry +or proxy restart makes the next API request return `401`, and the page tells the user to reopen +through the companion or `ccx gui`. A manually opened loopback dashboard receives no API session and +never prompts for or sends the durable admin token. + Provider credentials remain owned by CodexCommander. The companion never reads ChatGPT, Kimi, Grok, Anthropic, or other provider tokens and never calls provider login endpoints directly. @@ -127,19 +148,19 @@ variable is inherited by the app process. Apps launched from Finder usually do n variables; if there is no protected token file, the companion reports that management authentication is unavailable instead of presenting a token-entry form. -Live agent records are memory-only. The management response contains process-ephemeral row ids, +Live request records are memory-only. The management response contains process-ephemeral row ids, provider/model identifiers, timestamps, and aggregate counts. It contains no prompts, titles, working directories, tool arguments, account identifiers, credentials, request bodies, raw thread/session ids, or historical activity. ## Polling -The app refreshes lightweight activity frequently while the panel is open and slows down when it +The app refreshes lightweight in-flight request activity frequently while the panel is open and slows down when it is closed. Provider quotas refresh at a separate, slower cadence and use the upstream timestamps reported by CodexCommander. Repeated failures back off automatically, and overlapping refreshes are coalesced. -Use **Refresh** for an immediate activity refresh and a forced quota refresh. +Use **Refresh** for an immediate live-request refresh and a forced quota refresh. ## Build from source @@ -154,11 +175,12 @@ bun run build:macos open dist/macos/CodexCommander.app ``` -The source app is exactly `dist/macos/CodexCommander.app`. It discovers the checkout's `src/cli/index.ts` -and bundled Bun, so it should stay in that location while you work on this repository. Double-clicking -it attempts to ensure the proxy, but a missing CLI, offline failure, or failed start does not close -the app: its status panel remains available and **Start** can be retried. This source workflow does -not install or copy the app into Application Support. A rebuild at the same path is detected on the +The development app is exactly `dist/macos/CodexCommander.app`. Every build embeds the Bun runtime and +CodexCommander server resources inside the app bundle; the running app never executes `src/` from the +checkout. Rebuild the app to pick up source changes. Double-clicking it attempts to ensure the proxy, +but an offline failure or failed start does not close the app: its status panel remains available and +**Start** can be retried. This source workflow does not install or copy the app into Application +Support. A rebuild at the same path is detected on the next launch and refreshes the existing Login Item registration only when Launch at Login remains on. Each build stamps its exact Git revision into `CodexCommanderSourceRevision` in the bundle's `Info.plist` and prints it at the end of the build. Uncommitted source is marked with `-dirty`, so commit before @@ -181,9 +203,10 @@ making a final distributable bundle. kills a process or rewrites service state as a fallback. - **Only native models appear after a stop, a Codex update, or a cold start** — reopen CodexCommander. Launch automatically synchronizes the catalog and restores still-configured routed models from its - protected last-known-good catalog when live provider discovery is temporarily empty. If **Agent - catalog update ready** remains visible, choose **Apply agent catalog…**, or use the CLI fallback in - [Agent catalog updates](#agent-catalog-updates). + protected last-known-good catalog when live provider discovery is temporarily empty. If **Restart + ChatGPT to load models** remains visible, quit and reopen ChatGPT, then start a new task. Use the + advanced dashboard/API or CLI fallback in [Agent catalog updates](#agent-catalog-updates) only if + manual restart is unsuitable. ## Uninstall diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index a28d5a7f9d..111f804116 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -17,7 +17,7 @@ Choose the mode for **new sessions**. Existing sessions keep the surface they st | Mode | What Codex gets | Who should pick it | | --- | --- | --- | | **v1** | Classic namespaced `spawn_agent`, `send_input`, `resume_agent`, and `close_agent` tools. A spawn can select another model directly. | Beginners who need reliable delegation across different providers, especially native-to-routed children. | -| **base** (default; **Follow Codex defaults** in the GUI) | Upstream model pins: GPT-5.6 Sol/Terra use v2, Luna uses v1, and unpinned models follow Codex's `multi_agent_v2` feature flag. | Most users. It follows Codex's intended surface for each model without forcing one globally. | +| **base** (default; **Codex native** in the GUI) | Upstream model pins: GPT-5.6 Sol/Terra use v2, Luna uses v1, and unpinned models follow Codex's `multi_agent_v2` feature flag. | Most users. It follows Codex's intended surface for each model without forcing one globally. | | **v2** | Flat `spawn_agent`, `send_message`, `followup_task`, `interrupt_agent`, and agent-list tools, with concurrent sessions. | Users who want the newer concurrent workflow. Mixed-provider parents must also choose the plaintext compatibility delivery policy described below. | :::tip[Not sure?] @@ -37,6 +37,21 @@ The selected mode controls the `multi_agent_version` field in every catalog entr CodexCommander applies this as the final pass to both the live `/v1/models` catalog and the catalog synced to disk. That is why a mode change affects newly created App, CLI, and TUI sessions consistently. +### A mode is not a worker reload + +Changing to **v2** makes Luna *eligible for the V2 collaboration surface* because the generated +catalog stamps it as V2. It does not, by itself, make Luna (or any other model) available to a +currently running Codex worker. For a model to be usable by `spawn_agent`, all of these must hold: + +1. It is selected, surface-compatible, picker-visible, and inside the five-model advertised window. +2. The deterministic CodexCommander catalog containing it has been written to disk. +3. The current Codex app-server has loaded that catalog (not an older in-memory copy). +4. Its proxy route is enabled and can actually serve the request. + +This separation is deliberate: protocol selection controls catalog semantics; catalog activation +controls what an already-running Codex worker has loaded. In particular, opening a **new task** or +forking a task does **not** reload an existing app-server's model catalog. + For a v2 roster, eligibility has three states: an entry stamped `"v2"`, explicitly set to `null`, or with no `multi_agent_version` field is eligible. A genuine `"v1"` pin is excluded because it states that the model belongs to the other collaboration surface. @@ -119,7 +134,7 @@ CodexCommander fails safely instead of forwarding an empty or unreadable task: | Policy | Behavior | | --- | --- | | `"encrypted"` (default) | Preserves ChatGPT's reserved encrypted collaboration schema and the fail-closed behavior above. Use native ChatGPT workers or v1 for external workers. | -| `"plaintext"` | Experimental mixed-provider V2 compatibility. For ChatGPT parents, CodexCommander presents a non-reserved plaintext collaboration namespace and restores the canonical namespace on the client-facing response. For routed parents, it marks only completed V2 message calls as plaintext. Both paths activate Codex's plaintext V2 handler, while its graph, mailbox, wait, follow-up, and completion lifecycle remain native. | +| `"plaintext"` | Experimental mixed-provider V2 compatibility. It changes only V2 **task-message delivery** so a routed provider can read the delegated task; it is not a general key or credential setting. For ChatGPT parents, CodexCommander presents a non-reserved plaintext collaboration namespace and restores the canonical namespace on the client-facing response. For routed parents, it marks only completed V2 message calls as plaintext. Both paths activate Codex's plaintext V2 handler, while its graph, mailbox, wait, follow-up, and completion lifecycle remain native. | The plaintext decision is made when the parent tool schema is created, before the worker model is known. Consequently **every** V2 `spawn_agent`, `send_message`, and `followup_task` message from that @@ -139,7 +154,7 @@ switching an active conversation in place. ### GUI - **Dashboard** → first stat cell: choose **v1**, **base**, or **v2**. -- **Models** → **Current behavior** → **Collaboration**: choose **Classic v1**, **Follow Codex defaults** (base), or **Concurrent v2**. +- **Models** → **Current behavior** → **Collaboration**: choose **Reliable v1**, **Codex native** (base/default semantics), or **Concurrent v2**. - **Subagents** → **Agent Command Center**: - **Active Roster** chooses and orders the five model overrides advertised first to `spawn_agent`. Drag rows, use the arrow buttons, or press Alt + /. @@ -151,9 +166,12 @@ switching an active conversation in place. guidance, and native Codex-default sync. Save policy changes separately from roster changes. Leaving **Thread limit** blank restores the Codex default. V2 counts total threads including the root; -V1 counts child threads. Protocol and thread-limit changes apply to new sessions; guidance and -fallback apply to future spawned child turns. The page reports when a running Codex app-server still -has a stale catalog. +V1 counts child threads. A protocol or thread-limit change updates managed boot configuration: use +the catalog status to choose the reload path. When the catalog and managed routing are current but the +worker is stale, quit ChatGPT completely, reopen it, and start a new task for the session-bound tool +shape. If the catalog is pending/unknown or routing is not injected, use **Apply to Codex** to reconcile +them first; restarting ChatGPT alone is not that repair. Guidance and fallback apply to future spawned +child turns. A V2 delivery-only change needs a new task but does not dirty the catalog or require Apply. ### CLI @@ -189,8 +207,10 @@ The management API exposes matching `GET` and `PUT` endpoints: | `/api/v2` | Surface mode, V2 message delivery, native feature flag, and thread settings | | `/api/injection-model` | Preferred model, effort, custom prompt, guidance, and native-default sync | | `/api/effort-caps` | Main-agent and sub-agent effort ceilings | -| `/api/subagent-models` | Ordered roster of up to five models | +| `/api/subagent-models` | Ordered roster of up to five models; saving it is non-disruptive and also reports catalog activation state | | `/api/subagent-model-fallback` | Global fallback order and poll interval | +| `/api/codex-catalog/status` | Read desired configuration, deterministic on-disk catalog evidence, and current-worker activation evidence | +| `/api/codex-catalog/apply` | Guarded reconciliation for a pending catalog or uninjected managed route, followed when necessary by a confirmed force-restart of verified stale workers. For an already-converged stale worker this is an advanced fallback that may make ChatGPT show **stopped unexpectedly**; browser use requires a confirmed `ccx gui` or menu-app launch | Sending `multiAgentV2MessageDelivery: "encrypted"` or `null` to `PUT /api/v2` removes the explicit override and restores the encrypted default. @@ -224,16 +244,31 @@ a positive partial count before passing a model or effort override. It may be picker-hidden, outside the five-model display limit, missing from the catalog, or pinned to v1. A `"v2"`, `null`, or absent surface value is eligible; a real `"v1"` pin is not. +### Does V2 make Luna available immediately? + +No. Forced V2 removes Luna's upstream V1 surface pin, so it can be eligible for the V2 roster. It +still needs to be selected and advertised, written into the catalog, loaded by the current Codex +worker, and routable through the proxy. Use the dashboard's catalog status to see which condition +is pending. If the catalog and routing are current and only the worker is stale, quit ChatGPT +completely, reopen it, and start a new task. If the catalog is pending or routing is not injected, +use **Apply to Codex** for reconciliation first. + ### Do mode changes affect running sessions? -No. Start a new Codex session after changing the mode. If a long-running App host still shows stale -catalog state, run `ccx sync` and restart that Codex surface. +No. Start a new Codex session after changing the mode. That controls the collaboration protocol but +does not reload an already-running App host's model catalog. Save writes desired configuration and +converges the on-disk catalog without interrupting work. When the catalog and managed routing are +current but the worker is stale, quit ChatGPT completely, reopen it, and then start the new task. The +guarded **Force-restart workers** action and `ccx sync --restart-codex` remain advanced fallbacks and may +make ChatGPT show **stopped unexpectedly**. A pending catalog or uninjected managed route still needs +**Apply to Codex** reconciliation first. There is intentionally no auto-apply, idle queue, or persisted +“pending” snapshot to manage. ### Can Sol V2 delegate to Kimi, Grok, or DeepSeek? Yes, with **V2 message delivery → Plaintext compatibility** and a fresh session. The policy keeps the V2 lifecycle but makes that parent's delegated messages plaintext. Leave delivery encrypted for -the native-only confidentiality contract, or use Classic v1 for the older cross-provider surface. +the native-only confidentiality contract, or use Reliable v1 for the established cross-provider surface. ### Reasoning effort diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 17531311ca..927ad93784 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -23,15 +23,30 @@ bun run dev:gui ## Sign-in -On the default loopback bind (`localhost` / `127.0.0.1`) the dashboard never asks for a token: -the proxy mints short-lived GUI sessions into the served page and renews them silently when -they expire or the proxy restarts. Only a dashboard bound to a non-loopback hostname requires -the admin token (`CODEXCOMMANDER_ADMIN_AUTH_TOKEN`, or the auto-generated -`~/.codexcommander/admin-api-token` file). - -When a remote dashboard needs that credential, it presents a standard password form so a browser -password manager can offer to save and autofill it. The dashboard itself still keeps the token only -in memory and does not write it to `localStorage` or `sessionStorage`; whether it is saved is entirely +A dashboard opened directly through any loopback form—`localhost`, `*.localhost`, any address in +`127.0.0.0/8`, `::1`, or an IPv4-mapped `127/8` address—receives no API credential. The page shell +can load, but its API requests remain unauthorized. Reopen it with `ccx gui` or the macOS menu app. +A loopback page never asks for or transmits the durable admin token because another local OS user +could impersonate an inactive listener. Loopback browser access requires a confirmed launcher +session; loopback is not an authentication bypass. + +For the full dashboard, open it with `ccx gui` or the macOS menu app. The launcher uses admin authority +to mint a short-lived, single-use ticket, puts only that ticket in the URL fragment, and the dashboard +removes it immediately as it exchanges it for a confirmed session. A confirmed session lives only in +the proxy and browser process for up to eight hours and is never renewed. Expiry or a proxy restart +makes the next API request return `401`, and the loopback page requires a new launcher handoff. The +durable admin token never enters the URL or browser storage. + +A dashboard bound to a non-loopback hostname may use the admin token +(`CODEXCOMMANDER_ADMIN_AUTH_TOKEN`, or the auto-generated `~/.codexcommander/admin-api-token` file), +but the browser prompt is enabled only on a trusted HTTPS origin. A plaintext remote page never asks +for or sends the bearer. Without trusted HTTPS, use a local or SSH tunnel that presents the dashboard +as loopback, then open it through `ccx gui`. Raw admin remains available to headless management API +clients, but catalog Apply is deliberately restricted to a confirmed local dashboard launch. + +On trusted HTTPS, a remote dashboard presents a standard password form so a browser password manager +can offer to save and autofill the credential. The dashboard itself still keeps the token only in +memory and does not write it to `localStorage` or `sessionStorage`; whether it is saved is entirely the browser or password manager's decision. ## What you can do @@ -48,8 +63,8 @@ the browser or password manager's decision. | **Providers** | Add, edit, set the default (enabled providers only), enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Removing the current default switches to the first remaining enabled provider when one exists; otherwise deletion is refused and the current default is kept. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. For Claude (Anthropic) OAuth pools, each logged-in account shows its own 5-hour and weekly rate-limit bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. | | **Add provider** | Search registry-backed presets for account login, API-key services, local servers, or a custom endpoint. A query searches Accounts, Free and Paid together while the tabs remain useful for browsing. | | **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, enable or disable quota auto-switch, set its 1–100% threshold, and configure transient-failure failover. | -| **Subagents** | Open the **Agent Command Center** to choose and order the five models advertised to `spawn_agent`, search the current catalog, and configure Run Policy for protocol, V2 delivery, guidance, fallback, and thread limits. Saved entries that are not advertised are reported explicitly. | -| **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose **Classic v1**, **Follow Codex defaults**, or **Concurrent v2**, and configure the v2 thread limit. The Current behavior card reports context as **Uncapped**, **Limited**, or **Mixed limits**. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. Each routed-provider row reports **Auto-discovery on** or **Static catalog only** and links to the owning Provider setting. | +| **Subagents** | Open the **Agent Command Center** to choose and order the five models advertised to `spawn_agent`, search the current catalog, and configure Run Policy for protocol, V2 delivery, guidance, fallback, and thread limits. Saved entries that are not advertised are reported explicitly. Its status distinguishes saved configuration, the generated on-disk catalog, and the roster loaded by current Codex workers. | +| **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose **Reliable v1**, **Codex native**, or **Concurrent v2**, and configure the v2 thread limit. The Current behavior card reports context as **Uncapped**, **Limited**, or **Mixed limits**. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. Each routed-provider row reports **Auto-discovery on** or **Static catalog only** and links to the owning Provider setting. | | **Client Apps** | Inspect configured and available local clients, apply or remove managed config where supported, review backups, and reach Codex, Claude Code/Desktop, Grok Build, OpenCode and the file-managed clients without treating providers as clients. | | **API Access** | Issue and manage keys that authenticate other apps to the CodexCommander proxy. Provider credentials remain under Providers. | | **Logs** | Auto-refresh recent requests with tokens, requested effort and (when available) effective outbound effort, resolved model, provider, status, request id, duration, and error details. The detail view includes the exact reasoning wire field when the adapter emits one. Filter by opaque conversation/session id (when the client sends one) to total tokens and estimated list-price cost for the currently loaded Logs ring. | @@ -80,6 +95,38 @@ providers are capped or their saved values differ. Native OpenAI models always k Automatic upstream catalog refresh is configured per provider under **Providers → Settings**. The Models page shows that state and links directly to it; it does not keep a second discovery setting. +## Catalog activation + +Saving model visibility, the featured roster, or collaboration mode is deliberately non-disruptive: +CodexCommander saves the desired configuration and converges its deterministic catalog on disk. It +does not terminate Codex while you are working. The **Agent Command Center** then shows whether the +current Codex app-server has actually loaded that catalog. + +When the catalog and managed routing are already current and only the running worker is stale, the +recommended action is to quit ChatGPT completely, reopen it, and then start a new task. The dashboard +keeps the saved state visible and offers **Check status** after you return. + +Do not use restart guidance as a substitute for reconciliation. If the status says the catalog is +pending or unknown, or CodexCommander routing is not injected, choose **Apply to Codex** first. That +guarded action synchronizes and proves the catalog and managed routing before it considers interrupting +a verified stale worker. External or unknown routing stays blocked so CodexCommander does not overwrite +configuration it does not own. + +For an already-converged stale worker, **Force-restart workers** remains an advanced fallback in a +dashboard opened through `ccx gui` or the macOS menu app. It checks recent activity, asks for explicit +confirmation, and signals only verified Codex workers. Active-work count is warning context, not an idle +guarantee; an unknown worker identity blocks the action rather than guessing. It does not restart the +proxy, kill unrelated processes, queue itself for idle time, or save a separate pending-update record. +Because this bypasses ChatGPT's normal app lifecycle, ChatGPT may show **stopped unexpectedly**. + +If a manually opened loopback dashboard lacks a confirmed session, or its session expired, reopen it +with `ccx gui` or from the macOS menu app. Never paste the raw admin token into a loopback page; their +one-time browser launch restores API access without exposing it. + +A new task or fork within the same ChatGPT worker does not reload its model catalog. Quit and reopen +ChatGPT first, then start the new task. For advanced automation, `ccx sync --restart-codex` remains +available with the same worker-interruption caveat as the dashboard fallback. + ## Delegation picker vs spawn routing The Dashboard's **Sub-agent delegation** picker stores `injectionModel` and, optionally, @@ -173,7 +220,8 @@ The GUI is a thin client over the proxy's JSON management API. Useful endpoints | `PUT /api/startup-health/companion` | Let the authenticated native companion refresh its short-lived, memory-only Launch at Login observation. This endpoint requires the raw admin token; a browser GUI session is rejected. | | `POST /api/startup-action` | Install the background service or Codex launcher shim through fixed, allowlisted actions. | | `GET` / `POST /api/windows-tray` | Read or change the Windows tray installation and visible-process state. POST accepts `install`, `start`, `stop`, or `uninstall`. | -| `POST /api/sync` | Rebuild the shared model catalog and stale the Codex model cache. | +| `POST /api/sync` | Rebuild the shared model catalog and stale the Codex model cache without interrupting workers. | +| `GET /api/codex-catalog/status` · `POST /api/codex-catalog/apply` | Read catalog, routing, and worker activation evidence. The guarded Apply endpoint reconciles pending catalog or managed-routing state, then may force-restart only verified stale workers behind a desired-revision fence and explicit interruption confirmation. For an already-converged stale worker it is an advanced fallback that may make ChatGPT show **stopped unexpectedly**. A browser GUI session also needs the one-time launch authorization described above. | | `GET` / `PUT /api/sidecar-settings` | Read or set search/vision sidecar model settings. | | `GET` / `PUT /api/injection-model` | Read or set the shared sub-agent model/effort selection and the independent guidance/native-default switches. | | `GET` / `PUT /api/v2` | Read or set the surface mode, Codex feature flag, and v2 thread limit. | diff --git a/docs-site/src/content/docs/ja/guides/codex-app-models.md b/docs-site/src/content/docs/ja/guides/codex-app-models.md index ce891c1d2d..f418079410 100644 --- a/docs-site/src/content/docs/ja/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ja/guides/codex-app-models.md @@ -75,7 +75,7 @@ exact selector 行が表示されず、切り替えることもできません ## マルチエージェントサーフェスモード -モデルページでは 3 つのコラボレーション選択肢を **Classic v1**、**Follow Codex defaults**(base / upstream の動作)、**Concurrent v2** と表示します。このコントロールは各ピッカーエントリが使用する Codex コラボレーションサーフェスを変更します。正規モード、委任、継承、フォールバック、および暗号化されたタスクの動作については、[サブエージェントサーフェス](/guides/sub-agent-surface/) を参照してください。 +モデルページでは 3 つのコラボレーション選択肢を **Reliable v1**、**Codex native**(base/default・upstream の動作)、**Concurrent v2** と表示します。このコントロールは各ピッカーエントリが使用する Codex コラボレーションサーフェスを変更します。正規モード、委任、継承、フォールバック、および暗号化されたタスクの動作については、[サブエージェントサーフェス](/guides/sub-agent-surface/) を参照してください。 ## 上位層の推論 diff --git a/docs-site/src/content/docs/ja/guides/macos-menu-bar.md b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md index 481170acff..dd714691ab 100644 --- a/docs-site/src/content/docs/ja/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md @@ -1,6 +1,6 @@ --- title: macOS メニューバーコンパニオン -description: CodexCommander のネイティブなステータス、エージェントアクティビティ、プロバイダークォータのコンパニオンをインストールして使用します。 +description: CodexCommander のプロキシ状態、起動準備状況、Codex ルート、ライブリクエスト、プロバイダークォータを表示するネイティブコンパニオンをインストールして使用します。 --- macOS コンパニオンは、プロキシを置き換えたり Web ダッシュボードを重複させたりせずに、最も @@ -27,21 +27,29 @@ macOS コンパニオンは、プロキシを置き換えたり Web ダッシュ ## パネルに表示される内容 -- **エージェントアクティビティ** — 現在のアクティブ数とライブのモデル/プロバイダー行です。 - CodexCommander がリクエストメタデータからアクティブな親を証明できる場合にのみ、生成された子が - ネストされます。それ以外の場合は独立したサブエージェントとして表示されます。コンパニオンは、 - キュー済み、レビュー中、レート制限中、または完了済みの履歴を作り上げることはありません。 +- **プロキシ状態** — プロセスが動作しているかを示します。サーバーが実行中でも、起動同期の完了や + Codex による使用を意味しません。 +- **準備状況** — 起動とカタログ同期を **Checking**、**Starting**、**Ready**、**Startup failed**、 + **Unavailable** のいずれかで示します。これはプロキシの稼働状態とは独立した信号です。 +- **Codex ルート** — Codex が現在 CodexCommander、ネイティブ OpenAI、または別のカスタムルートを + 使っているかを示します。プロキシが実行中でも、それだけで Codex が使用しているとは限りません。 +- **ライブプロキシリクエスト** — 現在処理中のリクエスト数とモデル/プロバイダーの turn 行です。 + CodexCommander がリクエストメタデータから処理中の親を証明できる場合にのみ、生成された子リクエストを + ネストします。それ以外は独立したサブエージェント turn として表示します。モデルリクエストが完了すると、 + Codex が子スレッドを後の作業用にアイドル状態で保持していても行は消えます。これは永続的な Codex + エージェントライフサイクル表示ではなく、キュー済み、レビュー中、レート制限中、完了済みの履歴を + 作り上げることもありません。 - **プロバイダークォータ** — 利用可能な場合、プロバイダーが報告した 5 時間、週間、月間、または 固有の枠とリセット時刻を表示します。OpenCode Go は現在残高を推測せず、公開上限とローカルの 観測値を表示します。欠けているデータは利用不可と表示され、使用量ゼロや無制限の容量として 表示されることはありません。 -- **Dashboard と Logs** — デフォルトブラウザーで対応するローカルダッシュボードビューを開きます。 +- **Dashboard と Logs** — デフォルトブラウザーで対応するローカルダッシュボードビューを開き、カタログ Apply を含む変更操作向けの 1 回限りの起動権限も渡します。 - **管理** — 選択したプロバイダーの Accounts または API Keys タブを開きます。OAuth、API キー 入力、再認証、アカウント切り替え、プロバイダー設定は引き続きダッシュボードで行います。 -- **Agent catalog update ready** — 実行中の Codex バックグラウンドワーカーが古いモデル一覧を +- **Restart ChatGPT to load models** — 実行中の Codex バックグラウンドワーカーが古いモデル一覧を 保持しているときに表示される、永続的で非致命的なカードです。CodexCommander プロキシは正常に実行を続けます。 -- **Apply agent catalog…** — 可能な場合は最新のリクエストアクティビティを表示し、回答が中断される - 可能性を警告する確認画面を開きます。選択肢は **Apply Now** と **Later** です。 +- **Show restart steps…** — 推奨される再読み込み手順を説明します。ChatGPT を完全に終了して開き直し、 + その後に新しいタスクを開始します。このカードからメニューアプリがバックグラウンドワーカーを強制再起動することはありません。 - **Stop Proxy…** — 常に確認を求め、アクティブなクライアントとサブエージェントのリクエストを中断し、 ネイティブ Codex を復元してメニューバーアプリは開いたままにします。 - **Restart Proxy…** — 確認を求め、プロキシが最大 60 秒間アクティブなリクエストをドレインしてから、 @@ -68,17 +76,23 @@ Provider 設定を開けます。リンクされた Grok または Kimi CLI の アプリを開くと、現在 CodexCommander に設定されているプロバイダーから Codex モデルカタログを自動的に 同期します。Codex ワーカーが実行されていなければ、新しい一覧は次の Codex タスクで使用されます。 長時間実行中のワーカーが古い一覧を読み込んでいる場合も CodexCommander は動作を続け、パネルには非致命的な -**Agent catalog update ready** カードが表示され続けます。 +**Restart ChatGPT to load models** カードが表示され続けます。 -中断の可能性を確認するには **Apply agent catalog…** を選びます。可能な場合は直前にアクティブな -リクエスト数を取得しますが、ゼロ件でも Codex がアイドルである証明とは表示しません。処理開始前に -新しいリクエストが始まる可能性があるためです。**Apply Now** はもう一度同期し、現在のユーザーが所有する -正確な `codex … app-server` と `codex-code-mode-host` の一致だけに `SIGTERM` を送り、古いプロセス ID が -終了したことを短時間確認します。広範な `pkill` は使わず、CodexCommander プロキシを再起動せず、メニュー -アプリも閉じません。次のタスクで Codex が新しいバックグラウンドホストを作成し、最新の一覧を読み込みます。 +**Show restart steps…** を選び、ChatGPT を完全に終了して開き直してから、新しいタスクを開始します。これが +古いワーカーを置き換えるための推奨される、最も予測しやすい方法です。その間も CodexCommander と +メニューアプリは実行を続けます。 -現在のコンパニオンには **Apply when idle** はありません。回答が進行中なら **Later** を選び、準備ができてから -更新してください。カードは表示されたままです。上級者向けの CLI フォールバックは次のとおりです。 +同じ古いバックグラウンドホスト内で新しいタスクや fork を作るだけでは、カタログは再読み込みされません。 +そのため、最新のワーカーが確認されるまでカードは表示されたままです。ダッシュボードにカタログが +保留中または不明と表示される場合や、管理対象のルーティングが未注入の場合は、ダッシュボードで +**Codex に適用**を選び、先にファイルを整合して検証します。ChatGPT を終了するだけではこの修復になりません。 +外部管理または不明なルーティングと、無効な Codex 連携の扱いは変わりません。 + +カタログとルーティングがすでに整合済みでワーカーだけが古い場合、ダッシュボード/API の +**ワーカーを強制再起動**と CLI は上級者向けの代替手段です。これらは再同期と desired-revision fence を行い、 +現在のユーザーが所有する正確な `codex … app-server` と `codex-code-mode-host` の一致だけにシグナルを送り、 +広範な `pkill` へエスカレートしません。ChatGPT の通常のアプリライフサイクルを迂回するため、ChatGPT に +「予期せず停止しました」と表示される場合があります。CLI 形式は次のとおりです。 ```bash ccx sync --restart-codex @@ -96,6 +110,8 @@ ccx sync --restart-codex ループバックの CodexCommander プロセスにのみ送信します。トークンを表示、ログ記録、コピー、保存したり、 ブラウザー URL に入れたりすることはありません。 +コンパニオンがダッシュボードを開くときは、検証済みローカルプロキシに短時間・1 回限りの起動チケットを要求します。チケットは URL フラグメントだけに入り、1 回の交換中に削除されます。永続的な管理トークンが URL や Web Storage に入ることはありません。確認済みの全機能セッションはプロセスメモリ内だけに最大 8 時間存在し、更新されません。期限切れまたはプロキシ再起動後の次の API リクエストは `401` になり、コンパニオンか `ccx gui` から開き直すよう案内されます。手動で開いたループバックダッシュボードには API セッションがなく、永続的な管理トークンを要求も送信もしません。 + プロバイダー認証情報の管理は引き続き CodexCommander が担います。コンパニオンは ChatGPT、Kimi、 Grok、Anthropic、その他のプロバイダートークンを読み取らず、プロバイダーのログイン エンドポイントを直接呼び出すこともありません。 @@ -105,19 +121,19 @@ Grok、Anthropic、その他のプロバイダートークンを読み取らず 継承しません。保護されたトークンファイルがない場合、コンパニオンはトークン入力フォームを 表示せず、管理認証が利用できないことを報告します。 -ライブエージェントレコードはメモリ内だけに存在します。管理レスポンスには、プロセスの存続中 +ライブリクエストレコードはメモリ内だけに存在します。管理レスポンスには、プロセスの存続中 だけ有効な行 ID、プロバイダー/モデル識別子、タイムスタンプ、集計数が含まれます。プロンプト、 タイトル、作業ディレクトリ、ツール引数、アカウント識別子、認証情報、リクエスト本文、生の スレッド/セッション ID、または過去のアクティビティは含まれません。 ## ポーリング -パネルが開いている間、アプリは軽量なアクティビティ情報を頻繁に更新し、閉じると頻度を +パネルが開いている間、アプリは軽量な処理中リクエスト情報を頻繁に更新し、閉じると頻度を 下げます。プロバイダークォータは別のより遅い間隔で更新され、CodexCommander が報告するアップ ストリームのタイムスタンプを使用します。失敗が繰り返されると自動的にバックオフし、重複する 更新はまとめられます。 -アクティビティを即時更新し、クォータを強制更新するには**更新**を使用します。 +処理中のリクエストを即時更新し、クォータを強制更新するには**更新**を使用します。 ## ソースからビルド @@ -131,10 +147,11 @@ bun run build:macos open dist/macos/CodexCommander.app ``` -ソースアプリの場所は `dist/macos/CodexCommander.app` です。同じ checkout の Bun と CLI を使うため、 -先に `bun install` が必要です。開発中はこの場所に置き、Application Support へコピーしないでください。 -ダブルクリックするとプロキシの起動を試みますが、オフラインまたは起動失敗でもアプリは閉じず、 -パネルと **Start** コントロールは利用できます。 +開発アプリの場所は `dist/macos/CodexCommander.app` です。各ビルドで Bun ランタイムと +CodexCommander サーバーリソースがアプリバンドルに埋め込まれ、実行中のアプリが checkout の `src/` を +直接実行することはありません。ソース変更を反映するには再ビルドしてください。ダブルクリックすると +プロキシの起動を試みますが、オフラインまたは起動失敗でもアプリは閉じず、パネルと **Start** +コントロールは利用できます。開発中はこの場所に置き、Application Support へコピーしないでください。 各ビルドは正確な Git リビジョンをバンドルの `Info.plist` の `CodexCommanderSourceRevision` に記録し、 ビルド完了時にも表示します。未コミットのソースには `-dirty` が付くため、最終配布ビルドの前に コミットしてください。 @@ -154,9 +171,9 @@ open dist/macos/CodexCommander.app フォールバックとしてプロセスを強制終了したり、サービス状態を書き換えたりしません。 - **停止・Codex 更新・コールドスタート後にネイティブモデルしか表示されない** — CodexCommander を再度開いて ください。起動時にカタログを自動同期し、プロバイダー検出が一時的に空でも、保護された最終正常 - カタログから現在も設定されているルートモデルを復元します。**Agent catalog update ready** が残る場合は - **Apply agent catalog…** を選ぶか、[エージェントカタログの更新](#エージェントカタログの更新)にある - CLI フォールバックを使用してください。 + カタログから現在も設定されているルートモデルを復元します。**Restart ChatGPT to load models** が残る場合は、 + ChatGPT を終了して開き直し、新しいタスクを開始してください。手動の再起動が適さない場合にのみ、 + [エージェントカタログの更新](#エージェントカタログの更新)にある上級者向けのダッシュボード/API または CLI を使用してください。 ## アンインストール diff --git a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md index 56d51a5708..5492ba4df5 100644 --- a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md @@ -93,13 +93,15 @@ CodexCommander は、空のタスクまたは読み取り不可能なタスク ### GUI - **ダッシュボード** → 最初の統計セル: **v1**、**base**、または **v2** を選択します。 -- **モデル** → **現在の動作** → **コラボレーション**: **Classic v1**、**Codex の既定値に従う**(base)、**Concurrent v2** から選択します。 +- **モデル** → **現在の動作** → **コラボレーション**: **Reliable v1**、**Codex native**(base/default の意味)、**Concurrent v2** から選択します。 - **サブエージェント** → **Agent Command Center**: - **Active Roster** は、`spawn_agent` に最初に公開される 5 つのモデル オーバーライドを選択して順序付けします。行をドラッグするか、矢印ボタンを使うか、Alt + / を押します。 - **Agent Library** は現在のモデルカタログを検索し、reasoning、long context、vision、tool support などの事実上の機能でフィルタリングします。ルートが利用可能な場合、5 枠のロスター外のエントリも正確な id で指定できます。 - **Run Policy** は、エージェントプロトコル、V2 メッセージ配信、優先ガイダンスモデルと effort、生成された子タスク用のグローバルフォールバック、ヘルス再確認間隔、スレッド制限、サブエージェント effort 上限、ロースターガイダンス、ネイティブ Codex デフォルト同期をステージングします。ポリシーの変更はロースターの変更とは別に保存します。 -**スレッド上限** を空欄にすると Codex のデフォルトに戻ります。V2 はルートを含む総スレッド、V1 は子スレッドを数えます。プロトコルと上限は新しいセッションに、ガイダンスとフォールバックは今後の子タスクに適用されます。実行中の Codex app-server が古いカタログを保持している場合、ページがそれを報告します。 +**スレッド上限** を空欄にすると Codex のデフォルトに戻ります。V2 はルートを含む総スレッド、V1 は子スレッドを数えます。プロトコルまたは上限の変更は boot config を更新します。保存後にディスク上のカタログが **pending** / **unknown**、またはルーティングが未注入と表示された場合は、まず **Codex に適用**して整合させます。手動の再起動だけではこの手順を代用できません。 + +ディスク上のカタログとルーティングが最新で、実行中ワーカーだけが古い場合は、ChatGPT を完全に終了して開き直し、その後に新しいタスクを開始するのが既定かつ最も確実な方法です。ダッシュボードの **ワーカーを強制再起動**は上級者向けの代替手段で、ChatGPT に「予期せず停止しました」と表示される場合があります。ガイダンスとフォールバックは今後の子タスクに適用されます。V2 配信だけの変更は新しいタスクだけでよく、カタログを dirty にしません。 ### CLI @@ -136,6 +138,8 @@ ccx agent effort set --subagent max | `/api/effort-caps` |メインエージェントとサブエージェントの作業量の上限 | | `/api/subagent-models` |最大 5 つのモデルの注文リスト | | `/api/subagent-model-fallback` |グローバルフォールバック順序とポーリング間隔 | +| `/api/codex-catalog/status` |保存済み設定、決定的なディスク上のカタログ、ルーティング、実行中ワーカーの有効化状態 | +| `/api/codex-catalog/apply` | 保留中のカタログまたは未注入の管理対象ルーティングを保護された手順で整合し、必要な場合は確認後に検証済みの古いワーカーだけを強制再起動。整合済みでワーカーだけが古い場合は、ChatGPT に「予期せず停止しました」と表示されることがある上級者向けの代替手段。ブラウザーからの実行は確認済みの `ccx gui` またはメニューアプリ起動に限定 | 例えば: @@ -163,9 +167,17 @@ curl -X PUT http://localhost:10100/api/injection-model \ ピッカーで非表示になっているか、5 つのモデルの表示制限を超えているか、カタログから欠落しているか、v1 に固定されている可能性があります。 `"v2"`、`null`、または表面値が存在しない場合は対象となります。実際の `"v1"` ピンはそうではありません。 +### V2 を選ぶと Luna はすぐ使えますか? + +いいえ。強制 V2 は Luna を V2 サーフェスの対象にしますが、実行中ワーカーを再読み込みしません。モデルは選択され、表示可能で、5 件の広告枠内にあり、ディスクのカタログに書かれ、現在の app-server に読み込まれ、プロキシでルーティング可能でなければなりません。 + +ディスク上のカタログが保留中または不明な場合や、ルーティングが未注入の場合は、まず **Codex に適用**します。カタログとルーティングが最新で、ワーカーだけが古い場合は、ChatGPT を完全に終了して開き直し、新しいタスクを開始してください。新しいタスクや fork だけでは再読み込みされません。**ワーカーを強制再起動**は上級者向けの代替手段です。 + ### モードの変更は実行中のセッションに影響しますか? -いいえ。モードを変更した後、新しい Codex セッションを開始します。長時間実行されているアプリ ホストで依然として古いカタログ状態が表示される場合は、`ccx sync` を実行して、その Codex サーフェスを再起動します。 +いいえ。モードを変更した後、新しい Codex セッションを開始します。ただし、新しいタスクだけでは長時間実行中のワーカーがカタログやルーティングを再読み込みしません。ディスク上のカタログが保留中または不明な場合や、ルーティングが未注入の場合は、先に **Codex に適用**して整合させます。手動の再起動だけでは不十分です。 + +ディスク上のカタログとルーティングが最新で、ワーカーだけが古い場合は、ChatGPT を完全に終了して開き直し、新しいタスクを開始してください。これが既定の手順です。保護された **ワーカーを強制再起動**と `ccx sync --restart-codex` は、ChatGPT に「予期せず停止しました」と表示される可能性がある上級者向けの代替手段です。外部管理または不明なルーティングと、無効な Codex 連携については、表示される既存の状態案内に従ってください。自動適用、アイドルキュー、管理対象の永続的な「保留中」スナップショットは意図的に用意されていません。 ### 推論負荷 diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 4c64a19673..574384fbb8 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -23,9 +23,13 @@ bun run dev:gui ## サインイン -`localhost` や `127.0.0.1` などのループバックアドレスで開いたダッシュボードは、短時間有効な GUI セッションを自動的に受け取るため、通常はトークン入力が不要です。ループバック以外のホストで公開する場合は、`CODEXCOMMANDER_ADMIN_AUTH_TOKEN`、または自動生成される `~/.codexcommander/admin-api-token` ファイルの管理トークンが必要です。 +`localhost`、`*.localhost`、`127.0.0.0/8` 内のアドレス、`::1`、IPv4-mapped の `127/8` アドレスなど、どのループバック形式でも、手動で開いたダッシュボードには API 資格情報がありません。ページの枠は読み込めますが、API リクエストは未認証のままです。`ccx gui` または macOS メニューアプリから開き直してください。別のローカル OS ユーザーが停止中の listener を偽装できるため、ループバックページは永続的な管理トークンを要求も送信もしません。ループバックのブラウザーアクセスには確認済みランチャーセッションが必要で、認証の迂回路にはなりません。 -リモートダッシュボードでは標準のパスワードフォームが表示され、ブラウザのパスワードマネージャーで保存・自動入力できます。ダッシュボード自体はトークンをメモリ内だけに保持し、`localStorage` や `sessionStorage` には書き込みません。保存するかどうかはブラウザまたはパスワードマネージャーだけが決定します。 +全機能を使うには `ccx gui` または macOS メニューアプリから開きます。ランチャーは管理者権限で短時間・1 回限りのチケットを発行し、そのチケットだけを URL フラグメントに入れます。ダッシュボードは 1 回の交換中にすぐ削除します。交換後の確認済みセッションはプロキシとブラウザープロセスのメモリ内だけに最大 8 時間存在し、更新されません。期限切れまたはプロキシ再起動後の次の API リクエストは `401` になり、ループバックページでは新しいランチャーハンドオフが必要です。永続的な管理トークンが URL やブラウザーストレージに入ることはありません。 + +ループバック以外のホストでは `CODEXCOMMANDER_ADMIN_AUTH_TOKEN` または `~/.codexcommander/admin-api-token` の管理トークンを使用できますが、ブラウザーの入力欄は信頼できる HTTPS origin でだけ有効です。平文のリモートページは bearer を要求も送信もしません。信頼できる HTTPS がない場合は、ダッシュボードをループバックとして提示するローカルまたは SSH tunnel を使い、`ccx gui` から開いてください。生の管理トークンは headless management API client では引き続き使用できますが、カタログ Apply は確認済みのローカルダッシュボード起動だけに制限されます。 + +信頼できる HTTPS 上のリモートダッシュボードでは標準のパスワードフォームが表示され、ブラウザのパスワードマネージャーで保存・自動入力できます。ダッシュボード自体はトークンをメモリ内だけに保持し、`localStorage` や `sessionStorage` には書き込みません。保存するかどうかはブラウザまたはパスワードマネージャーだけが決定します。 ## できること @@ -42,7 +46,7 @@ bun run dev:gui | **プロバイダー追加** | レジストリベースのプリセットからアカウントログイン、API キーサービス、ローカルサーバー、custom エンドポイントを検索します。検索中は Accounts、Free、Paid をまとめて対象にし、タブはブラウズに使えます。 | | **Codex 認証** | ChatGPT/Codex プールアカウントを追加し、次回セッションアカウントを選び、5 時間 / 週間 / 30 日クォータを更新し、クォータ自動切り替えのオン/オフと 1~100% のしきい値、一時的失敗フェイルオーバーを設定します。 | | **サブエージェント** | **Agent Command Center** で `spawn_agent` に公開する 5 モデルの選択と並べ替え、現在のカタログ検索、プロトコル・V2 配信・ガイダンス・フォールバック・スレッド上限の Run Policy 設定を行います。保存済みでも公開されない項目は明示的に表示されます。 | -| **モデル** | ネイティブ GPT とルーティングモデルをオン/オフし、プロバイダー許可リストとコンテキスト上限を設定し、**Classic v1**、**Follow Codex defaults**、**Concurrent v2** を選択して v2 スレッド数を設定します。「現在の動作」カードではコンテキストを **上限なし**、**制限あり**、**混在** として表示します。各ルーティングプロバイダーには **自動検出オン** または **静的カタログのみ** が表示され、管理元のプロバイダー設定へ移動できます。 | +| **モデル** | ネイティブ GPT とルーティングモデルをオン/オフし、プロバイダー許可リストとコンテキスト上限を設定し、**Reliable v1**、**Codex native**、**Concurrent v2** を選択して v2 スレッド数を設定します。「現在の動作」カードではコンテキストを **上限なし**、**制限あり**、**混在** として表示します。各ルーティングプロバイダーには **自動検出オン** または **静的カタログのみ** が表示され、管理元のプロバイダー設定へ移動できます。 | | **Client Apps** | 設定済み・利用可能なローカルクライアントを確認し、対応する管理設定の適用/削除とバックアップ確認を行い、プロバイダーと混同せずに Codex、Claude Code/Desktop、Grok Build、OpenCode、ファイル管理クライアントへ移動します。 | | **API Access** | 他のアプリが CodexCommander プロキシへ接続するための認証キーを発行・管理します。上流プロバイダーの認証情報は Providers に残ります。 | | **ログ** | トークン、要求された強度と(利用可能な場合は)実際に送信された強度、実際のモデル、プロバイダー、状態、リクエスト ID、所要時間、エラー詳細を含む最近のリクエストを自動更新します。アダプターが reasoning パラメーターを送信した場合、詳細表示に正確な wire field も表示されます。 | @@ -63,6 +67,18 @@ bun run dev:gui 上流カタログの自動更新はプロバイダーごとに **プロバイダー → 設定** で管理します。Models ページはその状態を表示して直接リンクしますが、別の検出設定は保持しません。 +## カタログの有効化 + +保存は非中断です。設定と決定的なディスク上のカタログを更新しますが、実行中の Codex ワーカーは終了しません。Agent Command Center は、保存済み設定、ディスク上のカタログ、Codex ルーティング、現在のワーカーが読み込んだカタログを別々に表示します。 + +ディスク上のカタログが **pending** または **unknown** の場合や、ルーティングがまだ注入されていない場合は、まず **Codex に適用**してカタログとルーティングを整合させます。ChatGPT を手動で再起動するだけでは、未完成のカタログや未注入のルーティングは修復されません。外部管理または不明なルーティングと、無効な Codex 連携については、従来どおり画面に表示される個別の案内に従ってください。 + +ディスク上のカタログとルーティングが最新で、実行中のワーカーだけが古い場合、既定かつ最も確実な方法は、ChatGPT を完全に終了し、開き直してから新しいタスクを開始することです。戻った後はダッシュボードの **状態を確認**で保存済み状態を再確認できます。同じワーカー内で新しいタスクや fork を作るだけではカタログを再読み込みしません。 + +確認済みのローカルダッシュボードに表示される **ワーカーを強制再起動**、同じ保護を持つ API、`ccx sync --restart-codex` は上級者向けの代替手段です。これらは検証済みの古いバックグラウンドワーカーだけを対象にし、不明なワーカーは中断しません。強制再起動すると、ChatGPT に「予期せず停止しました」と表示される場合があります。アクティビティ数は中断警告であり、アイドル保証ではありません。自動適用やアイドルキューはありません。 + +手動で開いたループバックダッシュボードに確認済みセッションがない場合や期限切れの場合は、`ccx gui` または macOS メニューアプリから開き直します。生の管理トークンをループバックページへ貼り付けないでください。 + ## 委任セレクターとスポーンルーティングの違い ダッシュボードの **サブエージェント委任** セレクターは `injectionModel` とオプションの `injectionEffort` を @@ -127,7 +143,8 @@ GUI はプロキシの JSON 管理 API を使うシンクライアントです | `GET /api/startup-health` | 秘密情報を含まないルーティング、起動方式、クラッシュ復旧、サービス、shim、再起動安全性診断を読み取ります。 | | `PUT /api/startup-health/companion` | 認証済みネイティブコンパニオンが、メモリ内だけに保持される短時間の「ログイン時に起動」観測を更新します。raw 管理トークンが必要で、ブラウザ GUI セッションは拒否されます。 | | `GET` / `POST /api/windows-tray` | Windows トレイの導入・表示状態を読み取り、`install`、`start`、`stop`、`uninstall` を実行します。 | -| `POST /api/sync` | 共有モデルカタログを再構築し Codex モデルキャッシュを古い状態としてマークします。 | +| `POST /api/sync` | ワーカーを中断せずに共有モデルカタログを再構築し、Codex モデルキャッシュを古い状態としてマークします。 | +| `GET /api/codex-catalog/status` · `POST /api/codex-catalog/apply` | カタログ、ルーティング、ワーカーの有効化状態を読み取り、保留中のカタログまたは管理対象ルーティングを明示的に整合させます。確認済みローカル起動から上級者向けの強制再起動を選んだ場合のみ、revision fence と中断確認を使って検証済みの古いワーカーを置き換えます。この場合、ChatGPT に「予期せず停止しました」と表示されることがあります。 | | `GET` / `PUT /api/sidecar-settings` | 検索/ビジョンサイドカーモデル設定を読むか変えます。 | | `GET` / `PUT /api/injection-model` | 委任ガイダンスのモデル/effort、ガイダンストグル、Codex ネイティブサブエージェント既定値の同期トグルを読み取りまたは変更します。 | | `GET` / `PUT /api/v2` | サーフェスモード、Codex 機能フラグ、v2 スレッド上限を読むか変えます。 | diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index 12e283b78b..22bc937846 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -37,7 +37,7 @@ ccx v2 on ccx v2 threads 16 ``` -`mode` サブコマンドは、`multiAgentMode` を CodexCommander 設定に書き込み、Codex カタログを再同期します。モードとフラグの遷移により、現在の数値スレッド制限が有効な v1/v2 Codex キー間で移動します。移行が失敗すると、元の `config.toml` が復元されます。変更は新しい Codex セッションに適用されますが、実行中のセッションでは固定されたサーフェスが維持されます。 +`mode` サブコマンドは、`multiAgentMode` を CodexCommander 設定に書き込み、Codex カタログを再同期します。モードとフラグの遷移により、現在の数値スレッド制限が有効な v1/v2 Codex キー間で移動します。移行が失敗すると、元の `config.toml` が復元されます。モード、フラグ、thread の変更は boot config を更新します。実行中 worker に反映するには `ccx sync --restart-codex`(または dashboard の **Apply agent catalog**)を使い、その後に新しい task を開始します。 ## コンボルーティング 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 162a66f7bc..2c6b3d8f86 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -130,6 +130,10 @@ readiness ではなく別の liveness 確認です。 **OAuth の信頼性** セクションでは、資格情報ストレージが書き込み可能かどうか、リフレッシュ シングルフライト/ロック ファイルが `CODEXCOMMANDER_HOME` で作成できるかどうか、回復 `Action:` を持つ正常でない OAuth または Codex プール アカウント (編集された ID)、および Codex 転送パスが公式クライアント メタデータを作成しない静的 OK が報告されます。 Doctor は資格情報を変更したり、修復を適用したりすることはありません。 +:::note[アップグレード時の 1 回限りの再起動] +古いビルドから実行中のプロキシでは、保護されたランタイムレコードに `attestationSecret` がない場合があります。CLI 管理コマンドや資格情報を渡す Claude/OpenCode クライアントを起動する前に、そのプロキシを 1 回再起動してください。それまでは機密リクエストは fail closed となり、公開 health 情報や設定ポートだけで見つかった listener に token や request body を送る fallback は行いません。 +::: + ## カタログの同期 ### `ccx sync [--restart-codex]` @@ -138,6 +142,8 @@ readiness ではなく別の liveness 確認です。 存続期間の長い Codex `app-server` プロセスがまだ実行されている場合、`ccx sync` は、`codexcommander-catalog.json` / `models_cache.json` が更新されても、以前のメモリ内モデル リストを提供し続ける可能性があることを警告します。現在のユーザーが所有する一致する `codex … app-server` および `codex-code-mode-host` プロセスにのみ `SIGTERM` を送信するには、`--restart-codex` を渡します (アクティブなターンが中断される可能性があります)。広範な `pkill -f codex` 一致は意図的に回避されます。 +通常の `ccx sync` は非中断です。同じ app-server 内で新しいタスクを開始または fork してもカタログは再読み込みされません。ダッシュボードの **Apply agent catalog**、`ccx sync --restart-codex`、または Codex Desktop の終了・再起動を使用します。 + ### `ccx sync-cache [--restart-codex]` Codex のローカル モデル ピッカー キャッシュを無効にし、アクティブな CodexCommander カタログから再構築されるようにします。 `ccx sync` と同じ、古い `app-server` 警告とオプションの `--restart-codex` 動作が適用されます。 @@ -202,4 +208,4 @@ Windows ステータス トレイ アイコンをインストールして制御 ### `ccx gui` -`http://localhost:` で [ウェブダッシュボード](/guides/web-dashboard/) を開き、プロキシが実行されていない場合は自動起動します。 +`http://localhost:` で [ウェブダッシュボード](/guides/web-dashboard/) を開き、必要ならプロキシを自動起動します。短時間・1 回限りのブラウザー起動チケットにより、確認済みの **Apply agent catalog** を含む変更操作が利用できます。チケットは URL フラグメントだけで渡され、交換中に削除されます。永続的な管理トークンが URL や Web Storage に入ることはありません。確認済みセッションはプロセスメモリ内だけに最大 8 時間存在し、更新されません。期限切れまたはプロキシ再起動後の次の API リクエストは `401` になります。`ccx gui` または macOS メニューアプリから開き直してください。ループバックページを手動で開いても API セッションは発行されず、永続的な管理トークンを要求も送信もしません。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/agents.md b/docs-site/src/content/docs/ja/reference/configuration/agents.md index 7282cf5eba..db3b50aab0 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ja/reference/configuration/agents.md @@ -9,8 +9,8 @@ description: マルチエージェント サーフェス、委任ガイダンス |フィールド |タイプ |デフォルト |意味 | | --- | --- | --- | --- | -| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` はすべてのカタログ モデルを v1 としてスタンプします。 `v2` はすべてのモデルを v2 としてスタンプします。 `default` はアップストリーム ピン (Sol/Terra v2、Luna v1) を復元し、それ以外の場合はネイティブの `multi_agent_v2` フラグに従います。新しいセッションに適用されます。 | -| `multiAgentV2MessageDelivery?` | `"encrypted" \| "plaintext"` | `"encrypted"` | V2 親メッセージの配信方針です。`encrypted` は ChatGPT の予約済み暗号化契約を維持します。実験的な `plaintext` は以降の V2 親リクエストを複数プロバイダー互換にし、その親の全委任メッセージを平文にします。ルーティングされた親のメッセージ呼び出しにも Codex の平文マーカーを付与します。変更後は新しいセッションを開始してください。 | +| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` はすべてのカタログ モデルを v1 としてスタンプします。 `v2` はすべてのモデルを v2 としてスタンプします。 `default` はアップストリーム ピン (Sol/Terra v2、Luna v1) を復元し、それ以外の場合はネイティブの `multi_agent_v2` フラグに従います。変更後は Apply で実行中 worker を置き換え、新しい task を開始します。 | +| `multiAgentV2MessageDelivery?` | `"encrypted" \| "plaintext"` | `"encrypted"` | V2 タスクメッセージ配信だけの方針であり、認証情報の暗号化ではありません。`encrypted` は ChatGPT の予約済み暗号化契約を維持します。実験的な `plaintext` は以降の V2 親リクエストを複数プロバイダー互換にし、その親の全委任メッセージを平文にします。変更後は新しい task を開始しますが、カタログは dirty にならず Apply も不要です。 | | `subagentModels?` | `string[]` | `gpt-5.5`、`gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna`、`gpt-5.4-mini` | 最大 5 つの bare native id、account-qualified `/` id、または routed `provider/model` id をサブエージェント ピッカーで優先公開します。ダッシュボードは account-qualified を含む設定済みの exact selector を保持し、保存された項目のうち実際に公開されたものと除外されたものを表示します。現在のカタログにない選択には `ccx agent subagents set` を使用するか、設定を直接編集してください。明示的な空リストも保持されます。 | | `injectionModel?` | `string` | — |プロキシ作成の v2 委任ガイダンスで使用される、優先されるネイティブまたはルーティングされたサブエージェント モデル。 | | `injectionEffort?` | `string` | — |優先努力 (`low` ~ `ultra`)。`injectionModel` でのみ意味があります。 | @@ -22,7 +22,7 @@ description: マルチエージェント サーフェス、委任ガイダンス | `effortCap?` | `string` | — | v2 のメイン ターンとマークされた子ターンの条件を満たすためのハード シーリング。 `low` ~ `ultra` を受け入れます。 | | `subagentEffortCap?` | `string` | — |スポーンされた子のターンのみの追加の上限。両方の上限が適用される場合は、低い方が優先されます。 | -ダッシュボードまたは `ccx v2 status|on|off|mode |threads ` でサーフェスを管理します。モードの変更は新しいセッションに適用されます。 `maxConcurrentThreadsPerSession` は `PUT /api/v2` フィールドであり、`config.json` キーではありません。 `ccx v2 threads ` は、v2 が有効になった後、Codex の `$CODEX_HOME/config.toml` の `[features.multi_agent_v2]` の下に `max_concurrent_threads_per_session` を書き込みます。 +ダッシュボードまたは `ccx v2 status|on|off|mode |threads ` でサーフェスを管理します。モード、プロトコル、thread の変更は boot config を更新するため、実行中 worker には Apply、その後に新しい task が必要です。 `maxConcurrentThreadsPerSession` は `PUT /api/v2` フィールドであり、`config.json` キーではありません。 `ccx v2 threads ` は、v2 が有効になった後、Codex の `$CODEX_HOME/config.toml` の `[features.multi_agent_v2]` の下に `max_concurrent_threads_per_session` を書き込みます。 管理 API は、`GET`/`PUT /api/v2`、`/api/injection-model`、`/api/effort-caps`、`/api/subagent-models`、および `/api/subagent-model-fallback` を公開します。インジェクションモデルの更新は部分的です。カスタム プロンプトは、その API の `prompt` フィールドです。 diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index d468134cf9..156646b8b2 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -16,7 +16,7 @@ Management API には、データプレーン API キーとは独立した独自 ファイルベースのトークンは、そのディレクトリとファイルのアクセス許可または ACL が強化された後にのみ受け入れられます。それが保証できない場合、環境トークンが提供されるかファイルの状態が修復されるまで、管理認証は失敗して閉じられ、API は 503 を返します。 -管理者トークンを次のいずれかの形式で送信します。 +Headless API client は、信頼できる transport 上で管理者トークンを次のいずれかの形式で送信します。 ```http X-CodexCommander-API-Key: @@ -27,14 +27,18 @@ Authorization: Bearer ``` :::caution -管理者トークンは、すべてのデータプレーン認証情報とは異なる必要があります。スタートアップは、プロキシ アドミッション キーと競合する管理資格情報を拒否します。管理者トークンを Codex、Claude Code、または別のモデル クライアントに配置しないでください。コントロールプレーンの変更を許可します。 +管理者トークンは、すべてのデータプレーン認証情報とは異なる必要があります。スタートアップは、プロキシ アドミッション キーと競合する管理資格情報を拒否します。管理者トークンを Codex、Claude Code、または別のモデル クライアントに配置しないでください。コントロールプレーンの変更を許可します。ブラウザーが要求できるのは信頼できる非ループバック HTTPS origin だけです。平文のリモートページへ貼り付けたり送信したりしないでください。 ::: -### ループバック ダッシュボード セッション +### ダッシュボード起動セッション -ループバック バインドでは、ダッシュボード ブートストラップは有効期間の短い `ccx_session_*` 資格情報を受け取ることができます。各セッションは 5 分間続き、正確なダッシュボードのオリジンにバインドされます。安全なリクエストはそのオリジンと一致する必要があります。安全でないメソッドには、ブラウザ `Origin` とセッションの CSRF トークンも必要です。 +手動で開いたループバックダッシュボードには API 資格情報がありません。静的なページ枠は読み込めますが、`ccx gui` または macOS メニューアプリから開き直すまで、すべての `/api/*` リクエストは `401` を返します。どのループバック hostname/address でも永続的な管理トークンを要求も送信もしません。ブラウザーの origin は listener を所有するローカル OS ユーザーを証明しないため、ループバックは認証済み listener identity でも認証の迂回路でもありません。 -リモート バインドを含むデータ プレーン認証が必要な場合、セッションの発行は無効になります。リモート オペレーターは、生の管理トークンを使用して認証する必要があります。ループバック スタイルの GUI セッションは作成されません。 +ランチャーは生の管理資格情報を使い、要求ルートとオリジンに結び付いた短時間・1 回限りのチケットを発行します。チケットは URL フラグメントだけで渡され、1 回の交換中にすぐ削除されます。交換後の確認済み GUI セッションは全機能を持ち、プロセスメモリ内だけに最大 8 時間存在します。更新はされず、期限切れまたはプロキシ再起動後の次の API リクエストは `401` になり、その後はローカルランチャーの流れが再度必要です。永続的な管理トークンが URL や Web Storage に入ることはありません。 + +生の管理トークンは通常の API 変更に引き続き有効です。カタログ Apply は意図的に厳しく、`POST /api/codex-catalog/apply` は確認済み GUI セッションだけを受け入れます。スクリプトでは `ccx sync --restart-codex` を使用します。 + +リモート operator のブラウザーが生の管理トークンで認証できるのは信頼できる HTTPS 上だけで、平文のリモートページは要求も送信もしません。信頼できる HTTPS がない場合は、ループバックとして提示するローカルまたは SSH tunnel を使い、`ccx gui` から開いてください。Headless API client は信頼できる transport 上で raw-admin 認証を引き続き使用できます。確認済みブラウザーセッションは、正確なオリジンとルートに結び付いたローカル起動チケットの交換だけで発行されます。 ## よくあるエラー @@ -56,10 +60,10 @@ Authorization: Bearer |メソッドとパス |目的 |注目すべきエラー | | --- | --- | --- | -| `GET, PUT /api/v2` |エージェントプロトコル、V2 メッセージ配信、スレッド設定を読み取りまたは変更します。`multiAgentV2MessageDelivery` は `plaintext` または既定の `encrypted` を受け付け、`encrypted` か `null` で明示的な平文オーバーライドを解除します。配信変更後は新しいセッションを開始してください。`maxConcurrentThreadsPerSession: null` で Codex のデフォルトに戻ります | 400 の無効な設定。 502 移行または永続化の失敗 | +| `GET, PUT /api/v2` |エージェントプロトコル、V2 task メッセージ配信、スレッド設定を読み取りまたは変更します。モード/プロトコル/thread の変更では Apply で実行中 worker を置き換え、その後に新しい task を開始します。配信変更では新しい task だけでよく、カタログは dirty になりません。`maxConcurrentThreadsPerSession: null` で Codex のデフォルトに戻ります | 400 の無効な設定。 502 移行または永続化の失敗 | | `GET, PUT /api/injection-model` |優先ガイダンスモデル、エフォート、プロンプト、ガイダンス設定を読み取りまたは設定します。ネイティブ既定値の同期を有効にしない限り助言的です | 400 無効なモデル、エフォート、またはボディ | | `GET, PUT /api/effort-caps` |グローバルおよびサブエージェントの推論工数の上限を読み取りまたは設定する | 400 無効なラダー値 | -| `GET, PUT /api/subagent-models` | `spawn_agent` のクイック候補を最大 5 モデルまで読み取り、または順序付けします。ルーティングは強制しません。応答では保存済みの `chosen` と実際に有効な `advertised` を分け、反映されなかった候補を `excluded` で報告します | 400 の無効なリストまたは 5 つ以上のモデル | +| `GET, PUT /api/subagent-models` | `spawn_agent` のクイック候補を最大 5 モデルまで読み取り、または順序付けします。ルーティングは強制しません。応答では保存済みの `chosen` と実際に有効な `advertised` を分け、反映されなかった候補を `excluded` と加算的な `activation` 状態で報告します | 400 の無効なリストまたは 5 つ以上のモデル | | `GET, PUT /api/subagent-model-fallback` |生成された子タスク用のグローバルな順序付きフォールバックとポーリング間隔を読み取るか設定します。 | 400 無効なリストまたはポーリング間隔 | | `GET /api/grok` | Grok 管理対象設定のステータスと候補モデルを読む | 400 ステータス読み取り失敗 | | `PUT /api/grok/selection` |除外された Grok モデルを永続化します。 400 個の無効な選択またはサイズが大きすぎる選択 | @@ -93,7 +97,9 @@ Authorization: Bearer | `POST /api/startup-action` |サービスまたは Codex シムをインストールまたは修復する | 400 無効なアクション。 500 アクション失敗 | | `GET, POST /api/windows-tray` | Windows トレイの状態を読み取るか、インストール/起動/停止/アンインストールする | 400 のサポートされていないプラットフォーム/アクション。 500 操作失敗 | | `GET /api/diagnostics/project-config` |キャッシュされたプロジェクト設定の読み取りに関する警告 | — | -| `POST /api/sync` | 現在のモデルカタログを Codex に同期し、`catalogQuality`、`rehydrated`、Codex app-server の `catalogState`、必要な再起動ヒントを返す | 409 書き込み権限の拒否、500 同期失敗 | +| `POST /api/sync` | 実行中ワーカーを中断せず現在のモデルカタログを Codex に同期し、`activation` 状態を返す | 409 書き込み権限の拒否、500 同期失敗 | +| `GET /api/codex-catalog/status` | 保存済み設定、ディスク上のカタログ、実行中ワーカーの読み込み状態を読む | — | +| `POST /api/codex-catalog/apply` | `{ "expectedDesiredRevision": "…", "confirmInterrupt": true }` で確認済みの古いワーカーへ明示的に適用する。1 回限りの起動ハンドオフで作成された確認済み GUI セッションだけを受け入れる | 400 無効な本文、403 確認済みダッシュボード起動が必要、409 競合/不明な識別、503 busy | | `GET, PUT /api/sidecar-settings` | Web 検索およびビジョンのサイドカー モデル/バックエンド設定の読み取りまたは更新 | 400 無効な形状、バックエンド、または制限 | | `GET, PUT /api/shadow-call-settings` |シャドウ コール インターセプト設定の読み取りまたは更新 | 400 無効な形状または値 | diff --git a/docs-site/src/content/docs/ko/guides/codex-app-models.md b/docs-site/src/content/docs/ko/guides/codex-app-models.md index 20d2d495c0..3a6790f82e 100644 --- a/docs-site/src/content/docs/ko/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ko/guides/codex-app-models.md @@ -101,7 +101,7 @@ Codex의 모델 캐시를 강제로 오래된 상태로 만듭니다. ## 멀티 에이전트 서피스 모드 -Models 페이지는 세 협업 선택지를 **Classic v1**, **Follow Codex defaults**(base/upstream 동작), **Concurrent v2**로 +Models 페이지는 세 협업 선택지를 **Reliable v1**, **Codex native**(base/default·upstream 동작), **Concurrent v2**로 표시합니다. 이 컨트롤은 각 피커 항목이 사용하는 Codex 협업 서피스를 바꿉니다. 기준 모드, delegation, 상속, 폴백, 암호화된 작업 동작은 [서브에이전트 서피스](/guides/sub-agent-surface/)를 참고하세요. diff --git a/docs-site/src/content/docs/ko/guides/macos-menu-bar.md b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md index b8d8561823..10599df798 100644 --- a/docs-site/src/content/docs/ko/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md @@ -1,6 +1,6 @@ --- title: macOS 메뉴 막대 컴패니언 -description: CodexCommander의 네이티브 상태, 에이전트 활동 및 제공자 할당량 컴패니언을 설치하고 사용합니다. +description: CodexCommander의 프록시 상태, 시작 준비 상태, Codex 경로, 실시간 요청 및 제공자 할당량을 보여 주는 네이티브 컴패니언을 설치하고 사용합니다. --- macOS 컴패니언은 프록시를 대체하거나 웹 대시보드를 중복하지 않으면서 가장 유용한 CodexCommander @@ -27,21 +27,28 @@ CodexCommander 인스턴스와만 통신합니다. ## 패널에 표시되는 내용 -- **에이전트 활동** — 현재 활성 수와 실시간 모델/제공자 행입니다. CodexCommander가 요청 - 메타데이터에서 활성 부모를 입증할 수 있을 때만 생성된 자식이 중첩되며, 그렇지 않으면 독립형 - 서브에이전트로 표시됩니다. 컴패니언은 대기 중, 검토 중, 속도 제한됨 또는 완료 기록을 - 만들어내지 않습니다. +- **프록시 상태** — 프록시 프로세스가 실행 중인지를 표시합니다. 서버가 실행 중이어도 시작 동기화가 + 완료되었거나 Codex가 해당 프록시를 사용한다는 뜻은 아닙니다. +- **준비 상태** — 시작 및 카탈로그 동기화를 **Checking**, **Starting**, **Ready**, **Startup failed** + 또는 **Unavailable**로 표시합니다. 이 신호는 프록시 실행 상태와 독립적입니다. +- **Codex 경로** — Codex가 현재 CodexCommander, 네이티브 OpenAI 또는 다른 사용자 지정 경로를 + 사용하는지 표시합니다. 프록시가 실행 중이라는 사실만으로 Codex가 이를 사용한다는 뜻은 아닙니다. +- **실시간 프록시 요청** — 현재 처리 중인 요청 수와 모델/제공자 turn 행입니다. CodexCommander가 요청 + 메타데이터에서 처리 중인 부모를 입증할 수 있을 때만 생성된 자식 요청이 중첩되며, 그렇지 않으면 + 독립형 서브에이전트 turn으로 표시됩니다. 모델 요청이 끝나면 Codex가 이후 작업을 위해 자식 스레드를 + 유휴 상태로 유지하더라도 행은 사라집니다. 이는 지속적인 Codex 에이전트 수명 주기 보기가 아니며, + 대기 중, 검토 중, 속도 제한됨 또는 완료 기록을 만들어내지 않습니다. - **제공자 할당량** — 제공자가 보고하는 5시간, 주간, 월간 또는 제공자별 기간과 재설정 시간을 사용할 수 있을 때 표시합니다. OpenCode Go는 임의의 현재 잔액 대신 공개 한도와 로컬 관측값을 표시합니다. 누락된 데이터는 사용할 수 없음으로 표시하며 사용량 0 또는 무제한 용량으로 표시하지 않습니다. -- **대시보드 및 Logs** — 기본 브라우저에서 해당 로컬 대시보드 보기를 엽니다. +- **대시보드 및 Logs** — 기본 브라우저에서 해당 로컬 대시보드 보기를 열고 카탈로그 Apply를 포함한 변경 작업을 위한 일회용 시작 권한도 전달합니다. - **관리** — 선택한 제공자의 Accounts 또는 API Keys 탭을 엽니다. OAuth, API 키 입력, 재인증, 계정 전환 및 제공자 구성은 대시보드에서 계속 처리합니다. -- **Agent catalog update ready** — 실행 중인 Codex 백그라운드 워커가 이전 모델 목록을 계속 보유할 때 +- **Restart ChatGPT to load models** — 실행 중인 Codex 백그라운드 워커가 이전 모델 목록을 계속 보유할 때 표시되는 지속적이고 치명적이지 않은 카드입니다. CodexCommander 프록시는 정상적으로 계속 실행됩니다. -- **Apply agent catalog…** — 가능한 경우 최신 요청 활동을 표시하고 답변이 중단될 수 있음을 경고하는 - 확인 창을 엽니다. 선택지는 **Apply Now**와 **Later**입니다. +- **Show restart steps…** — 권장 다시 불러오기 절차를 설명합니다. ChatGPT를 완전히 종료하고 다시 연 다음 + 새 작업을 시작하세요. 메뉴 앱은 이 카드에서 백그라운드 워커를 강제로 다시 시작하지 않습니다. - **Stop Proxy…** — 항상 확인을 요청하고 활성 클라이언트 및 서브에이전트 요청을 중단하며, 네이티브 Codex를 복원하고 메뉴 막대 앱은 계속 실행합니다. - **Restart Proxy…** — 확인을 요청하고 프록시가 최대 60초 동안 활성 요청을 드레이닝하도록 한 다음 @@ -66,18 +73,23 @@ Grok은 접힌 요약으로 표시됩니다. 구성된 할당량 지원 제공 앱을 열면 현재 CodexCommander에 구성된 제공자와 Codex 모델 카탈로그를 자동으로 동기화합니다. 실행 중인 Codex 워커가 없으면 새 목록은 다음 Codex 작업에서 사용됩니다. 장시간 실행 중인 워커가 이전 목록을 -로드한 경우에도 CodexCommander는 계속 실행되며 패널에는 치명적이지 않은 **Agent catalog update ready** 카드가 +로드한 경우에도 CodexCommander는 계속 실행되며 패널에는 치명적이지 않은 **Restart ChatGPT to load models** 카드가 계속 표시됩니다. -중단 위험을 검토하려면 **Apply agent catalog…**을 선택합니다. 가능한 경우 확인 직전에 활성 요청 수를 -가져오지만, 요청이 0개여도 Codex가 유휴 상태라는 증거로 표시하지 않습니다. 작업이 실행되기 전에 새 -요청이 시작될 수 있기 때문입니다. **Apply Now**는 다시 동기화한 뒤 현재 사용자가 소유한 정확한 -`codex … app-server` 및 `codex-code-mode-host` 일치 프로세스에만 `SIGTERM`을 보내고, 이전 프로세스 ID가 -종료되었는지 잠시 확인합니다. 광범위한 `pkill`을 사용하거나 CodexCommander 프록시를 재시작하거나 메뉴 앱을 -닫지 않습니다. 다음 작업에서 Codex가 새 백그라운드 호스트를 만들고 최신 목록을 로드합니다. +**Show restart steps…**를 선택하고 ChatGPT를 완전히 종료한 뒤 다시 열어 새 작업을 시작하세요. 이 방법이 +오래된 워커를 교체하는 데 권장되는 가장 예측 가능한 방법입니다. 그동안 CodexCommander와 메뉴 앱은 계속 실행됩니다. -현재 컴패니언에는 **Apply when idle**이 없습니다. 답변이 진행 중이면 **Later**를 선택하고 준비가 되었을 때 -업데이트를 적용하세요. 카드는 계속 표시됩니다. 고급 CLI 대체 방법은 다음과 같습니다. +같은 오래된 백그라운드 호스트에서 새 작업이나 fork만 만들어도 카탈로그를 다시 불러오지는 않습니다. +따라서 최신 워커가 확인될 때까지 카드가 계속 표시됩니다. 대시보드에 카탈로그가 보류 중이거나 알 수 없다고 +표시되거나 관리 라우팅이 주입되지 않았다면, 대시보드에서 **Codex에 적용**을 선택해 먼저 파일을 일치시키고 +검증하세요. ChatGPT를 종료하는 것만으로는 이 문제가 해결되지 않습니다. 외부 관리 또는 알 수 없는 라우팅과 +꺼진 Codex 통합의 동작은 바뀌지 않습니다. + +카탈로그와 라우팅이 이미 일치하고 워커만 오래되었다면 대시보드/API의 **워커 강제 재시작**과 CLI는 +고급 대안입니다. 이 작업은 다시 동기화하고 desired-revision fence를 사용하며, 현재 사용자가 소유한 정확한 +`codex … app-server` 및 `codex-code-mode-host` 일치 프로세스에만 신호를 보내고 광범위한 `pkill`로 확대하지 +않습니다. ChatGPT의 정상 앱 수명 주기를 우회하므로 ChatGPT에 ‘예기치 않게 중지됨’이 표시될 수 있습니다. +CLI 형식은 다음과 같습니다. ```bash ccx sync --restart-codex @@ -94,6 +106,8 @@ Keychain에서 제공자 자격 증명을 읽지도 않습니다. 메모리에만 유지하며, 신원이 확인된 루프백 CodexCommander 프로세스에만 보냅니다. 토큰을 표시, 기록, 복사 또는 저장하거나 브라우저 URL에 넣지 않습니다. +컴패니언이 대시보드를 열 때는 확인된 로컬 프록시에 수명이 짧고 일회용인 시작 티켓을 요청합니다. 티켓은 URL fragment에만 들어가며 한 번의 교환 중 제거됩니다. 영구 관리자 토큰은 URL이나 Web Storage에 들어가지 않습니다. 확인된 전체 기능 세션은 프로세스 메모리에만 최대 8시간 유지되며 갱신되지 않습니다. 만료 또는 프록시 재시작 후 다음 API 요청은 `401`을 반환하고 컴패니언이나 `ccx gui`에서 다시 열라는 안내가 표시됩니다. 직접 연 loopback 대시보드에는 API 세션이 없으며 영구 관리자 토큰을 요구하거나 전송하지 않습니다. + 제공자 자격 증명은 계속 CodexCommander가 소유합니다. 컴패니언은 ChatGPT, Kimi, Grok, Anthropic 또는 기타 제공자 토큰을 읽지 않으며 제공자 로그인 엔드포인트를 직접 호출하지 않습니다. @@ -102,19 +116,19 @@ Keychain에서 제공자 자격 증명을 읽지도 않습니다. 파일이 없으면 컴패니언은 토큰 입력 양식을 표시하는 대신 관리 인증을 사용할 수 없다고 보고합니다. -실시간 에이전트 레코드는 메모리에만 존재합니다. 관리 응답에는 프로세스 수명 동안만 유효한 행 +실시간 요청 레코드는 메모리에만 존재합니다. 관리 응답에는 프로세스 수명 동안만 유효한 행 ID, 제공자/모델 식별자, 타임스탬프 및 집계 수가 포함됩니다. 프롬프트, 제목, 작업 디렉터리, 도구 인수, 계정 식별자, 자격 증명, 요청 본문, 원시 스레드/세션 ID 또는 과거 활동은 포함되지 않습니다. ## 폴링 -앱은 패널이 열려 있는 동안 가벼운 활동 정보를 자주 새로 고치고, 패널이 닫히면 속도를 늦춥니다. +앱은 패널이 열려 있는 동안 가벼운 처리 중 요청 정보를 자주 새로 고치고, 패널이 닫히면 속도를 늦춥니다. 제공자 할당량은 별도의 느린 주기로 새로 고치며 CodexCommander가 보고한 업스트림 타임스탬프를 사용합니다. 반복되는 실패에는 자동으로 백오프가 적용되고 겹치는 새로 고침은 하나로 통합됩니다. -즉시 활동을 새로 고치고 할당량을 강제로 새로 고치려면 **새로 고침**을 사용합니다. +처리 중 요청을 즉시 새로 고치고 할당량을 강제로 새로 고치려면 **새로 고침**을 사용합니다. ## 소스에서 빌드 @@ -128,8 +142,9 @@ bun run build:macos open dist/macos/CodexCommander.app ``` -소스 앱의 위치는 정확히 `dist/macos/CodexCommander.app`입니다. 같은 체크아웃의 Bun과 CLI를 -사용하므로 `bun install` 종속성이 필요합니다. 개발 중에는 이 위치에 두고 Application Support로 +개발 앱의 위치는 정확히 `dist/macos/CodexCommander.app`입니다. 빌드할 때마다 Bun 런타임과 +CodexCommander 서버 리소스가 앱 번들에 포함되며, 실행 중인 앱은 체크아웃의 `src/`를 직접 실행하지 +않습니다. 소스 변경을 반영하려면 앱을 다시 빌드하세요. 개발 중에는 이 위치에 두고 Application Support로 복사하지 마세요. 더블클릭하면 프록시 시작을 시도하지만 오프라인이거나 시작에 실패해도 앱은 닫히지 않으며 패널과 **Start** 컨트롤을 계속 사용할 수 있습니다. 각 빌드는 정확한 Git 리비전을 번들의 `Info.plist`에 있는 `CodexCommanderSourceRevision`에 기록하고 빌드 @@ -150,9 +165,9 @@ open dist/macos/CodexCommander.app 대체 수단으로 프로세스를 강제 종료하거나 서비스 상태를 다시 작성하지 않습니다. - **중지·Codex 업데이트·콜드 스타트 후 네이티브 모델만 표시됨** — CodexCommander를 다시 여세요. 시작 시 카탈로그를 자동으로 동기화하고, 제공자 검색 결과가 일시적으로 비어 있어도 보호된 마지막 정상 - 카탈로그에서 아직 구성된 라우팅 모델을 복원합니다. **Agent catalog update ready**가 계속 표시되면 - **Apply agent catalog…**을 선택하거나 [에이전트 카탈로그 업데이트](#에이전트-카탈로그-업데이트)의 CLI - 대체 방법을 사용하세요. + 카탈로그에서 아직 구성된 라우팅 모델을 복원합니다. **Restart ChatGPT to load models**가 계속 표시되면 + ChatGPT를 종료하고 다시 연 다음 새 작업을 시작하세요. 수동 재시작이 적합하지 않은 경우에만 + [에이전트 카탈로그 업데이트](#에이전트-카탈로그-업데이트)의 고급 대시보드/API 또는 CLI 대안을 사용하세요. ## 제거 diff --git a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md index cf2a879b07..89c5ad0f1b 100644 --- a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md @@ -91,13 +91,15 @@ CodexCommander는 읽을 수 없거나 빈 작업을 그대로 넘기지 않고 ### GUI - **Dashboard** → 첫 번째 상태 셀: **v1**, **base**, **v2**를 고릅니다. -- **Models** → **Current behavior** → **Collaboration**: **Classic v1**, **Codex 기본값 따르기**(base), **Concurrent v2** 중에서 고릅니다. +- **Models** → **Current behavior** → **Collaboration**: **Reliable v1**, **Codex native**(base/default 의미), **Concurrent v2** 중에서 고릅니다. - **Subagents** → **Agent Command Center**: - **Active Roster**는 `spawn_agent`에 가장 먼저 알려지는 다섯 개의 모델 오버라이드를 선택하고 순서를 정합니다. 행을 드래그하거나, 화살표 버튼을 쓰거나, Alt + /를 누르세요. - **Agent Library**는 현재 모델 카탈로그를 검색하고 reasoning, long context, vision, tool support 같은 사실 기반 capability로 필터링합니다. 라우트가 사용 가능하면 다섯 칸 로스터 밖의 항목도 정확한 id로 지정할 수 있습니다. - **Run Policy**는 에이전트 프로토콜, V2 메시지 전달, 선호 안내 모델과 강도, 생성된 하위 작업의 전역 폴백 체인, 헬스 재확인 간격, 스레드 제한, 서브에이전트 강도 상한, 로스터 안내, 네이티브 Codex 기본값 동기화를 스테이징합니다. 정책 변경은 로스터 변경과 별도로 저장하세요. -**스레드 한도**를 비워 두면 Codex 기본값으로 돌아갑니다. V2는 루트를 포함한 전체 스레드 수를, V1은 하위 스레드 수를 셉니다. 프로토콜과 한도는 새 세션에, 안내와 폴백은 이후 하위 작업에 적용되며, 실행 중인 Codex app-server에 오래된 카탈로그가 남아 있으면 페이지가 이를 알립니다. +**스레드 한도**를 비워 두면 Codex 기본값으로 돌아갑니다. V2는 루트를 포함한 전체 스레드 수를, V1은 하위 스레드 수를 셉니다. 프로토콜 또는 한도 변경은 boot config를 바꿉니다. 저장 후 디스크 카탈로그가 **pending** / **unknown**이거나 라우팅이 주입되지 않았다고 표시되면 먼저 **Codex에 적용**하여 일치시키세요. 수동 재시작만으로는 이 단계를 대신할 수 없습니다. + +디스크 카탈로그와 라우팅이 최신이고 실행 중 워커만 오래되었다면, 기본이자 가장 확실한 방법은 ChatGPT를 완전히 종료하고 다시 연 다음 새 작업을 시작하는 것입니다. 대시보드의 **워커 강제 재시작**은 고급 대안이며 ChatGPT에 ‘예기치 않게 중지됨’이 표시될 수 있습니다. 안내와 폴백은 이후 하위 작업에 적용됩니다. V2 전달만 바꾸는 경우 새 작업만 필요하며 카탈로그를 dirty로 만들지 않습니다. ### CLI @@ -134,6 +136,8 @@ ccx agent effort set --subagent max | `/api/effort-caps` | 메인 에이전트와 서브에이전트의 추론 상한 | | `/api/subagent-models` | 최대 다섯 모델의 순서가 있는 로스터 | | `/api/subagent-model-fallback` | 전역 폴백 순서와 폴링 간격 | +| `/api/codex-catalog/status` | 저장된 설정, 결정적인 디스크 카탈로그, 라우팅, 실행 중 워커의 활성화 상태 | +| `/api/codex-catalog/apply` | 보류된 카탈로그 또는 주입되지 않은 관리 라우팅을 보호된 절차로 일치시키고, 필요하면 확인 후 확인된 오래된 워커만 강제로 다시 시작합니다. 이미 일치하고 워커만 오래된 경우에는 ChatGPT에 ‘예기치 않게 중지됨’이 표시될 수 있는 고급 대안입니다. 브라우저 실행은 확인된 `ccx gui` 또는 메뉴 앱 시작으로 제한됩니다. | 예를 들면 다음과 같습니다. @@ -161,9 +165,17 @@ curl -X PUT http://localhost:10100/api/injection-model \ 선택기에서 숨겨져 있거나, 다섯 모델 표시 한도를 넘었거나, 카탈로그에 없거나, v1에 고정되어 있을 수 있습니다. `v2`, `null`, 또는 값이 없는 서피스 값은 사용할 수 있지만, 실제 `"v1"` 핀은 사용할 수 없습니다. +### V2를 고르면 Luna를 바로 쓸 수 있나요? + +아닙니다. 강제 V2는 Luna를 V2 서피스에 적합하게 만들지만 실행 중인 워커의 카탈로그를 다시 읽게 하지는 않습니다. 모델은 선택되고, 표시 가능하며, 다섯 개 광고 창 안에 있어야 하고, 디스크 카탈로그에 기록되어 현재 app-server가 읽고 프록시가 실제로 라우팅할 수 있어야 합니다. + +디스크 카탈로그가 보류 중이거나 알 수 없거나 라우팅이 주입되지 않았다면 먼저 **Codex에 적용**하세요. 카탈로그와 라우팅이 최신이고 워커만 오래되었다면 ChatGPT를 완전히 종료하고 다시 연 다음 새 작업을 시작하세요. 새 작업이나 fork만으로는 다시 불러오지 않습니다. **워커 강제 재시작**은 고급 대안입니다. + ### 모드를 바꾸면 실행 중인 세션에도 반영되나요? -아닙니다. 모드를 바꾼 뒤에는 새 Codex 세션을 시작하세요. 오래 실행 중인 App 호스트에 오래된 카탈로그 상태가 남아 있으면 `ccx sync`를 실행한 뒤 해당 Codex 서피스를 다시 시작하세요. +아닙니다. 모드를 바꾼 뒤에는 새 Codex 세션을 시작하세요. 다만 새 작업만 시작해도 오래 실행 중인 워커가 카탈로그나 라우팅을 다시 불러오지는 않습니다. 디스크 카탈로그가 보류 중이거나 알 수 없거나 라우팅이 주입되지 않았다면 먼저 **Codex에 적용**하여 일치시키세요. 수동 재시작만으로는 충분하지 않습니다. + +디스크 카탈로그와 라우팅이 최신이고 워커만 오래되었다면 ChatGPT를 완전히 종료하고 다시 연 다음 새 작업을 시작하세요. 이것이 기본 절차입니다. 보호된 **워커 강제 재시작**과 `ccx sync --restart-codex`는 ChatGPT에 ‘예기치 않게 중지됨’이 표시될 수 있는 고급 대안입니다. 외부 관리 또는 알 수 없는 라우팅과 꺼진 Codex 통합은 화면에 표시되는 기존 상태 안내를 따르세요. 자동 적용, 유휴 큐, 관리해야 할 영구 ‘보류 중’ 스냅샷은 의도적으로 제공하지 않습니다. ### 추론 강도 diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 2e55e6e3d2..1ded226dad 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -23,9 +23,13 @@ bun run dev:gui ## 로그인 -`localhost`나 `127.0.0.1` 같은 loopback 주소에서 연 대시보드는 짧게 유지되는 GUI 세션을 자동으로 받으므로 보통 토큰을 입력할 필요가 없습니다. loopback이 아닌 호스트로 공개한 대시보드에는 `CODEXCOMMANDER_ADMIN_AUTH_TOKEN` 또는 자동 생성되는 `~/.codexcommander/admin-api-token` 파일의 관리자 토큰이 필요합니다. +`localhost`, `*.localhost`, `127.0.0.0/8`의 모든 주소, `::1`, IPv4-mapped `127/8` 주소 등 어떤 loopback 형식으로 직접 열어도 대시보드에는 API 자격 증명이 없습니다. 페이지 틀은 로드되지만 API 요청은 인증되지 않습니다. `ccx gui` 또는 macOS 메뉴 앱에서 다시 여세요. 다른 로컬 OS 사용자가 비활성 listener를 가장할 수 있으므로 loopback 페이지는 영구 관리자 토큰을 요구하거나 전송하지 않습니다. loopback 브라우저 접근에는 확인된 런처 세션이 필요하며 인증 우회가 아닙니다. -원격 대시보드는 표준 비밀번호 폼을 표시하므로 브라우저 비밀번호 관리자가 토큰 저장과 자동 완성을 제안할 수 있습니다. 대시보드 자체는 토큰을 메모리에만 보관하며 `localStorage`나 `sessionStorage`에 쓰지 않습니다. 저장 여부는 전적으로 브라우저 또는 비밀번호 관리자가 결정합니다. +전체 기능을 사용하려면 `ccx gui` 또는 macOS 메뉴 앱에서 여세요. 런처는 관리자 권한으로 수명이 짧고 일회용인 티켓을 발급하고 티켓만 URL fragment에 넣습니다. 대시보드는 한 번의 교환 중 즉시 이를 제거합니다. 확인된 세션은 프록시와 브라우저 프로세스 메모리에만 최대 8시간 유지되며 갱신되지 않습니다. 만료 또는 프록시 재시작 후 다음 API 요청은 `401`을 반환하며 loopback 페이지에는 새 런처 handoff가 필요합니다. 영구 관리자 토큰은 URL이나 브라우저 저장소에 들어가지 않습니다. + +loopback이 아닌 호스트에서는 `CODEXCOMMANDER_ADMIN_AUTH_TOKEN` 또는 `~/.codexcommander/admin-api-token`의 관리자 토큰을 사용할 수 있지만 브라우저 입력창은 신뢰할 수 있는 HTTPS origin에서만 활성화됩니다. 평문 원격 페이지는 bearer를 요구하거나 전송하지 않습니다. 신뢰할 수 있는 HTTPS가 없다면 대시보드를 loopback으로 보이게 하는 로컬 또는 SSH tunnel을 사용하고 `ccx gui`에서 여세요. 원시 관리자 토큰은 headless management API client에서 계속 사용할 수 있지만 카탈로그 Apply는 확인된 로컬 대시보드 시작으로만 제한됩니다. + +신뢰할 수 있는 HTTPS의 원격 대시보드는 표준 비밀번호 폼을 표시하므로 브라우저 비밀번호 관리자가 토큰 저장과 자동 완성을 제안할 수 있습니다. 대시보드 자체는 토큰을 메모리에만 보관하며 `localStorage`나 `sessionStorage`에 쓰지 않습니다. 저장 여부는 전적으로 브라우저 또는 비밀번호 관리자가 결정합니다. ## 할 수 있는 일 @@ -42,7 +46,7 @@ bun run dev:gui | **Add provider** | 레지스트리 기반 프리셋에서 계정 로그인, API key 서비스, 로컬 서버, custom endpoint를 검색합니다. 검색어는 Accounts, Free, Paid를 함께 찾고 탭은 둘러보기에 사용됩니다. | | **Codex Auth** | ChatGPT/Codex 풀 계정을 추가하고, 다음 세션 계정을 선택하고, 5시간 / 주간 / 30일 할당량을 갱신하며, 할당량 자동 전환을 켜거나 끄고 1~100% 임계값과 일시적 실패 failover를 설정합니다. | | **Subagents** | **Agent Command Center**에서 `spawn_agent`에 노출할 다섯 모델을 선택하고 순서를 정하며, 현재 카탈로그를 검색하고 프로토콜·V2 전달·안내·폴백·스레드 제한의 Run Policy를 설정합니다. 저장됐지만 노출되지 않은 항목은 명시적으로 보고됩니다. | -| **Models** | 네이티브 GPT와 라우팅 모델을 켜고 끄고, 프로바이더 allowlist와 컨텍스트 상한을 설정하며, **Classic v1**, **Follow Codex defaults**, **Concurrent v2**를 선택하고 v2 thread 수를 설정합니다. Current behavior 카드는 컨텍스트를 **Uncapped**, **Limited**, **Mixed limits**로 표시합니다. 각 라우팅 프로바이더에는 **자동 검색 켜짐** 또는 **정적 카탈로그만** 상태와 해당 프로바이더 설정 링크가 표시됩니다. | +| **Models** | 네이티브 GPT와 라우팅 모델을 켜고 끄고, 프로바이더 allowlist와 컨텍스트 상한을 설정하며, **Reliable v1**, **Codex native**, **Concurrent v2**를 선택하고 v2 thread 수를 설정합니다. Current behavior 카드는 컨텍스트를 **Uncapped**, **Limited**, **Mixed limits**로 표시합니다. 각 라우팅 프로바이더에는 **자동 검색 켜짐** 또는 **정적 카탈로그만** 상태와 해당 프로바이더 설정 링크가 표시됩니다. | | **Client Apps** | 설정된 로컬 클라이언트와 연결 가능한 클라이언트를 확인하고, 지원되는 관리 설정을 적용하거나 제거하며 백업을 검토합니다. Codex, Claude Code/Desktop, Grok Build, OpenCode와 파일 관리 클라이언트를 프로바이더와 구분해 한곳에서 찾을 수 있습니다. | | **API Access** | 다른 앱이 CodexCommander 프록시에 인증할 키를 발급하고 관리합니다. 업스트림 프로바이더 자격 증명은 Providers에 남습니다. | | **Logs** | 토큰, 요청한 강도와 (사용 가능한 경우) 실제 전송 강도, 실제 모델, 프로바이더, 상태, 요청 id, 소요 시간, 오류 상세가 포함된 최근 요청을 자동 갱신합니다. 어댑터가 reasoning 매개변수를 전송한 경우 상세 보기에 정확한 wire field도 표시됩니다. 클라이언트가 보낸 불투명 대화/세션 id로 필터하면 현재 로드된 Logs 링의 토큰·추정 정가 합계를 볼 수 있습니다. | @@ -63,6 +67,18 @@ bun run dev:gui 업스트림 카탈로그 자동 갱신은 프로바이더별 **Providers → Settings**에서 관리합니다. Models 페이지는 그 상태를 표시하고 바로 연결할 뿐, 별도의 검색 설정을 저장하지 않습니다. +## 카탈로그 활성화 + +저장은 작업을 중단하지 않습니다. 설정과 결정적인 디스크 카탈로그를 갱신하지만 실행 중인 Codex 워커를 종료하지 않습니다. Agent Command Center는 저장된 설정, 디스크 카탈로그, Codex 라우팅, 현재 워커가 불러온 카탈로그를 각각 표시합니다. + +디스크 카탈로그가 **pending** 또는 **unknown**이거나 라우팅이 아직 주입되지 않았다면 먼저 **Codex에 적용**하여 카탈로그와 라우팅을 일치시키세요. ChatGPT를 수동으로 다시 시작하는 것만으로는 준비되지 않은 카탈로그나 주입되지 않은 라우팅을 고칠 수 없습니다. 외부 관리 또는 알 수 없는 라우팅과 꺼진 Codex 통합은 기존과 마찬가지로 화면에 표시되는 해당 안내를 따르세요. + +디스크 카탈로그와 라우팅이 최신이고 실행 중인 워커만 오래되었다면, 기본이자 가장 확실한 방법은 ChatGPT를 완전히 종료하고 다시 연 다음 새 작업을 시작하는 것입니다. 돌아온 뒤 대시보드의 **상태 확인**에서 저장된 상태를 다시 확인할 수 있습니다. 같은 워커 안에서 새 작업이나 fork만 만들어도 카탈로그를 다시 불러오지는 않습니다. + +확인된 로컬 대시보드의 **워커 강제 재시작**, 같은 보호 장치를 사용하는 API, `ccx sync --restart-codex`는 고급 대안입니다. 이 작업은 확인된 오래된 백그라운드 워커만 대상으로 하며 알 수 없는 워커는 중단하지 않습니다. 강제로 다시 시작하면 ChatGPT에 ‘예기치 않게 중지됨’이 표시될 수 있습니다. 활동 수는 중단 경고일 뿐 유휴 보장이 아니며 자동 적용이나 유휴 큐는 없습니다. + +직접 연 loopback 대시보드에 확인된 세션이 없거나 세션이 만료되면 `ccx gui` 또는 macOS 메뉴 앱에서 다시 여세요. 원시 관리자 토큰을 loopback 페이지에 붙여 넣지 마세요. + ## 위임 선택기와 스폰 라우팅의 차이 Dashboard의 **Sub-agent delegation** 선택기는 `injectionModel`과 선택적인 `injectionEffort`를 @@ -127,7 +143,8 @@ GUI는 프록시의 JSON 관리 API를 사용하는 얇은 클라이언트입니 | `GET /api/startup-health` | 비밀값 없이 라우팅, 시작 방식, 충돌 복구, 서비스, shim 및 재부팅 안전성 진단을 읽습니다. | | `PUT /api/startup-health/companion` | 인증된 네이티브 컴패니언이 메모리에만 잠시 유지되는 로그인 시 시작 관측을 갱신합니다. 원본 관리 토큰이 필요하며 브라우저 GUI 세션은 거부됩니다. | | `GET` / `POST /api/windows-tray` | Windows 트레이 설치 및 표시 상태를 읽거나 `install`, `start`, `stop`, `uninstall` 작업을 수행합니다. | -| `POST /api/sync` | 공유 모델 카탈로그를 다시 만들고 Codex 모델 캐시를 오래된 상태로 표시합니다. | +| `POST /api/sync` | 워커를 중단하지 않고 공유 모델 카탈로그를 다시 만들며 Codex 모델 캐시를 오래된 상태로 표시합니다. | +| `GET /api/codex-catalog/status` · `POST /api/codex-catalog/apply` | 카탈로그, 라우팅, 워커 활성화 상태를 읽고 보류된 카탈로그 또는 관리 라우팅을 명시적으로 일치시킵니다. 확인된 로컬 시작에서 고급 강제 재시작을 선택한 경우에만 revision fence와 중단 확인을 사용해 확인된 오래된 워커를 교체합니다. 이 경우 ChatGPT에 ‘예기치 않게 중지됨’이 표시될 수 있습니다. | | `GET` / `PUT /api/sidecar-settings` | 검색/비전 사이드카 모델 설정을 읽거나 바꿉니다. | | `GET` / `PUT /api/injection-model` | 위임 가이드의 모델/강도, 가이드 토글, Codex 네이티브 서브에이전트 기본값 동기화 토글을 읽거나 바꿉니다. | | `GET` / `PUT /api/v2` | 서피스 모드, Codex 기능 플래그, v2 thread 상한을 읽거나 바꿉니다. | diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index 25a2366aa4..5b1dcba535 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -40,7 +40,7 @@ ccx v2 threads 16 `mode` 하위 명령은 `multiAgentMode`를 CodexCommander config에 쓰고 Codex catalog를 다시 동기화합니다. mode와 flag 전환은 현재 숫자 thread 한도를 유효한 v1/v2 Codex key 사이로 옮깁니다. -전환이 실패하면 원래의 `config.toml`이 복원됩니다. 변경은 새 Codex 세션에만 적용되고, 실행 중인 세션은 고정된 surface를 유지합니다. +전환이 실패하면 원래의 `config.toml`이 복원됩니다. mode, flag, thread 변경은 boot config를 바꿉니다. 실행 중 worker에 반영하려면 `ccx sync --restart-codex`(또는 dashboard의 **Apply agent catalog**)를 사용한 뒤 새 task를 시작하세요. ## 콤보 라우팅 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 8da01bcba5..b4a910216e 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -163,6 +163,10 @@ single-flight/lock 파일을 만들 수 있는지, 건강하지 않은 OAuth 또 복구용 `Action:`, 그리고 Codex 전달 경로가 공식 클라이언트 메타데이터를 꾸며 내지 않는다는 정적 OK를 보고합니다. doctor는 자격 증명을 변경하거나 복구를 적용하지 않습니다. +:::note[업그레이드 후 한 번 재시작] +이전 빌드에서 계속 실행 중인 프록시는 보호된 런타임 레코드에 `attestationSecret`이 없을 수 있습니다. CLI 관리 명령이나 자격 증명을 전달하는 Claude/OpenCode 클라이언트를 사용하기 전에 해당 프록시를 한 번 재시작하세요. 그전에는 민감한 요청이 fail closed되며, 공개 health 정보나 설정 포트로만 찾은 listener에 token 또는 request body를 보내는 fallback은 없습니다. +::: + ## 카탈로그 동기화 ### `ccx sync [--restart-codex]` @@ -176,6 +180,8 @@ single-flight/lock 파일을 만들 수 있는지, 건강하지 않은 OAuth 또 프로세스 중 일치하는 것에만 `SIGTERM`을 보냅니다(활성 작업이 중단될 수 있습니다). 광범위한 `pkill -f codex` 매칭은 의도적으로 피합니다. +일반 `ccx sync`는 중단하지 않습니다. 같은 app-server에서 새 task를 시작하거나 fork해도 카탈로그를 다시 읽지 않습니다. 대시보드의 **Apply agent catalog**, `ccx sync --restart-codex`, 또는 Codex Desktop을 종료 후 다시 여세요. + ### `ccx sync-cache [--restart-codex]` Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 CodexCommander 카탈로그에서 다시 빌드되게 합니다. @@ -260,4 +266,4 @@ Windows 상태 트레이 아이콘을 설치하고 제어합니다. Windows 로 ### `ccx gui` 프록시가 실행 중이 아니면 자동으로 시작하면서 [웹 대시보드](/guides/web-dashboard/)를 -`http://localhost:`에서 엽니다. +`http://localhost:`에서 엽니다. 수명이 짧고 일회용인 브라우저 시작 티켓으로 확인된 **Apply agent catalog**를 포함한 변경 작업을 사용할 수 있습니다. 티켓은 URL fragment로만 전달되고 교환 중 제거됩니다. 영구 관리자 토큰은 URL이나 Web Storage에 들어가지 않습니다. 확인된 세션은 프로세스 메모리에만 최대 8시간 유지되며 갱신되지 않습니다. 만료 또는 프록시 재시작 후 다음 API 요청은 `401`을 반환합니다. `ccx gui` 또는 macOS 메뉴 앱에서 다시 여세요. loopback 페이지를 직접 열면 API 세션이 발급되지 않으며 영구 관리자 토큰을 요구하거나 전송하지 않습니다. diff --git a/docs-site/src/content/docs/ko/reference/configuration/agents.md b/docs-site/src/content/docs/ko/reference/configuration/agents.md index 9284fe8541..228ea5e603 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ko/reference/configuration/agents.md @@ -9,8 +9,8 @@ description: 멀티 에이전트 표면, 위임 안내, 선호 모델, 대체 | 필드 | 형식 | 기본값 | 의미 | | --- | --- | --- | --- | -| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1`은 카탈로그의 모든 모델에 v1을 표시하고, `v2`는 모든 모델에 v2를 표시합니다. `default`는 상위 고정값(Sol/Terra는 v2, Luna는 v1)을 복원하고, 그 외에는 네이티브 `multi_agent_v2` 플래그를 따릅니다. 새 세션에 적용됩니다. | -| `multiAgentV2MessageDelivery?` | `"encrypted" \| "plaintext"` | `"encrypted"` | V2 부모 메시지 전달 정책입니다. `encrypted`는 ChatGPT의 예약된 암호화 계약을 유지합니다. 실험적인 `plaintext`는 이후 V2 부모 요청을 다중 프로바이더 호환 모드로 전환하며, 해당 부모의 모든 위임 메시지를 평문으로 만듭니다. 라우팅된 부모의 메시지 호출에도 Codex 평문 마커를 추가합니다. 변경 후 새 세션을 시작하세요. | +| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1`은 카탈로그의 모든 모델에 v1을 표시하고, `v2`는 모든 모델에 v2를 표시합니다. `default`는 상위 고정값(Sol/Terra는 v2, Luna는 v1)을 복원하고, 그 외에는 네이티브 `multi_agent_v2` 플래그를 따릅니다. 변경 후 Apply로 실행 중 worker를 교체하고 새 task를 시작하세요. | +| `multiAgentV2MessageDelivery?` | `"encrypted" \| "plaintext"` | `"encrypted"` | V2 task 메시지 전달만의 정책이며 자격 증명 암호화가 아닙니다. `encrypted`는 ChatGPT의 예약된 암호화 계약을 유지합니다. 실험적인 `plaintext`는 이후 V2 부모 요청을 다중 프로바이더 호환 모드로 전환하며, 해당 부모의 모든 위임 메시지를 평문으로 만듭니다. 변경 후 새 task만 시작하면 되고 카탈로그를 dirty로 만들거나 Apply할 필요가 없습니다. | | `subagentModels?` | `string[]` | `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4-mini` | 최대 다섯 개의 bare native id, account-qualified `/` id 또는 routed `provider/model` id를 서브에이전트 선택기에 우선 노출합니다. 대시보드는 account-qualified 선택을 포함한 기존 exact selector를 보존하고, 저장된 항목 중 실제로 노출되거나 제외된 항목을 보고합니다. 현재 카탈로그에 없는 선택은 `ccx agent subagents set`을 사용하거나 설정을 직접 편집하세요. 명시적인 빈 목록도 그대로 보존됩니다. | | `injectionModel?` | `string` | — | 프록시가 작성한 v2 위임 안내에서 사용하는 선호 네이티브 또는 라우팅된 서브에이전트 모델입니다. | | `injectionEffort?` | `string` | — | 선호 노력(`low`부터 `ultra`까지)입니다. `injectionModel`이 있을 때만 의미가 있습니다. | @@ -22,7 +22,7 @@ description: 멀티 에이전트 표면, 위임 안내, 선호 모델, 대체 | `effortCap?` | `string` | — | 자격을 갖춘 v2 메인 턴과 표시된 생성 하위 턴에 대한 하드 상한입니다. `low`부터 `ultra`까지 허용합니다. | | `subagentEffortCap?` | `string` | — | 생성된 하위 턴에만 적용되는 추가 상한입니다. 두 상한이 모두 적용되면 더 낮은 값이 이깁니다. | -이 표면은 대시보드나 `ccx v2 status|on|off|mode |threads `로 관리합니다. 모드 변경은 새 세션에 적용됩니다. `maxConcurrentThreadsPerSession`은 `config.json` 키가 아니라 `PUT /api/v2` 필드입니다. `ccx v2 threads `는 v2가 활성화된 뒤 Codex의 `$CODEX_HOME/config.toml` 안 `[features.multi_agent_v2]` 아래에 `max_concurrent_threads_per_session`을 기록합니다. +이 표면은 대시보드나 `ccx v2 status|on|off|mode |threads `로 관리합니다. 모드, 프로토콜, thread 변경은 boot config를 바꾸므로 실행 중 worker에는 Apply 후 새 task가 필요합니다. `maxConcurrentThreadsPerSession`은 `config.json` 키가 아니라 `PUT /api/v2` 필드입니다. `ccx v2 threads `는 v2가 활성화된 뒤 Codex의 `$CODEX_HOME/config.toml` 안 `[features.multi_agent_v2]` 아래에 `max_concurrent_threads_per_session`을 기록합니다. 관리 API는 `GET`/`PUT /api/v2`, `/api/injection-model`, `/api/effort-caps`, `/api/subagent-models`, `/api/subagent-model-fallback`를 제공합니다. injection-model 업데이트는 부분 업데이트입니다. 사용자 지정 프롬프트는 이 API의 `prompt` 필드입니다. diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index fd3c191c7b..3b3020f2d9 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -16,7 +16,7 @@ Management API에는 데이터 평면 API 키와는 독립된 자체 관리자 파일 기반 토큰은 해당 디렉터리와 파일 권한 또는 ACL이 강화된 뒤에만 허용됩니다. 이를 보장할 수 없으면 관리 인증은 실패를 닫는 방식으로 처리되며, 환경 토큰이 제공되거나 파일 상태가 복구될 때까지 API는 503을 반환합니다. -관리자 토큰은 다음 두 형식 중 하나로 보내면 됩니다. +Headless API client는 신뢰할 수 있는 transport에서 관리자 토큰을 다음 두 형식 중 하나로 보냅니다. ```http X-CodexCommander-API-Key: @@ -27,14 +27,18 @@ Authorization: Bearer ``` :::caution -관리자 토큰은 모든 데이터 평면 자격 증명과 달라야 합니다. 시작 시 프록시 admission key와 충돌하는 관리 자격 증명은 거부됩니다. 관리자 토큰을 Codex, Claude Code, 또는 다른 모델 클라이언트에 넣지 마십시오. 이 토큰은 제어 평면 변경 권한을 부여합니다. +관리자 토큰은 모든 데이터 평면 자격 증명과 달라야 합니다. 시작 시 프록시 admission key와 충돌하는 관리 자격 증명은 거부됩니다. 관리자 토큰을 Codex, Claude Code, 또는 다른 모델 클라이언트에 넣지 마십시오. 이 토큰은 제어 평면 변경 권한을 부여합니다. 브라우저는 신뢰할 수 있는 non-loopback HTTPS origin에서만 이를 요청할 수 있습니다. 평문 원격 페이지에 붙여 넣거나 전송하지 마세요. ::: -### 루프백 대시보드 세션 +### 대시보드 시작 세션 -루프백 바인드에서는 대시보드 초기화가 수명이 짧은 `ccx_session_*` 자격 증명을 받을 수 있습니다. 각 세션은 5분 동안 유지되며 정확한 대시보드 origin에 묶입니다. 안전한 요청은 그 origin과 일치해야 합니다. 안전하지 않은 메서드에는 브라우저 `Origin`과 세션의 CSRF 토큰도 필요합니다. +직접 연 loopback 대시보드에는 API 자격 증명이 없습니다. 정적 페이지 틀은 로드되지만 `ccx gui` 또는 macOS 메뉴 앱에서 다시 열 때까지 모든 `/api/*` 요청은 `401`을 반환합니다. 어떤 loopback hostname/address에서도 영구 관리자 토큰을 요구하거나 전송하지 않습니다. 브라우저 origin은 listener를 소유한 로컬 OS 사용자를 증명하지 않으므로 loopback은 인증된 listener identity도 인증 우회도 아닙니다. -세션 발급은 원격 바인드와 같이 데이터 평면 인증이 필요한 경우에는 항상 비활성화됩니다. 원격 운영자는 원시 관리자 토큰으로 인증해야 하며, 루프백 방식의 GUI 세션은 발급되지 않습니다. +런처는 원시 관리자 자격 증명을 사용해 요청한 route와 origin에 묶인 수명이 짧고 일회용인 티켓을 발급합니다. 티켓은 URL fragment로만 전달되고 한 번의 교환 중 즉시 제거됩니다. 확인된 GUI 세션은 전체 기능을 제공하며 프로세스 메모리에만 최대 8시간 유지됩니다. 갱신되지 않으며 만료 또는 프록시 재시작 후 다음 API 요청은 `401`을 반환하고 로컬 런처 흐름이 다시 필요합니다. 영구 관리자 토큰은 URL이나 Web Storage에 들어가지 않습니다. + +원시 관리자 토큰은 일반 API 변경에 계속 유효합니다. 카탈로그 Apply는 더 엄격하여 `POST /api/codex-catalog/apply`가 확인된 GUI 세션만 허용합니다. 스크립트는 `ccx sync --restart-codex`를 사용합니다. + +원격 운영자 브라우저는 신뢰할 수 있는 HTTPS에서만 원시 관리자 토큰으로 인증할 수 있으며 평문 원격 페이지는 이를 요구하거나 전송하지 않습니다. 신뢰할 수 있는 HTTPS가 없다면 loopback으로 보이게 하는 로컬 또는 SSH tunnel을 사용하고 `ccx gui`에서 여세요. Headless API client는 신뢰할 수 있는 transport에서 raw-admin 인증을 계속 사용할 수 있습니다. 확인된 브라우저 세션은 정확한 origin과 route에 묶인 로컬 시작 티켓 교환으로만 발급됩니다. ## 공통 오류 @@ -56,10 +60,10 @@ Authorization: Bearer | Method and path | 목적 | 주요 오류 | | --- | --- | --- | -| `GET, PUT /api/v2` | 에이전트 프로토콜, V2 메시지 전달, thread 설정을 읽거나 변경합니다. `multiAgentV2MessageDelivery`는 `plaintext` 또는 기본값 `encrypted`를 받으며, `encrypted` 또는 `null`을 보내면 명시적 평문 재정의가 제거됩니다. 전달 변경 후에는 새 세션을 시작하세요. `maxConcurrentThreadsPerSession: null`은 Codex 기본값을 복원합니다 | 400 잘못된 설정; 502 전환 또는 영속화 실패 | +| `GET, PUT /api/v2` | 에이전트 프로토콜, V2 task 메시지 전달, thread 설정을 읽거나 변경합니다. 모드/프로토콜/thread 변경에는 Apply로 실행 중 worker를 교체한 뒤 새 task가 필요합니다. 전달 변경에는 새 task만 필요하며 카탈로그를 dirty로 만들지 않습니다. `maxConcurrentThreadsPerSession: null`은 Codex 기본값을 복원합니다 | 400 잘못된 설정; 502 전환 또는 영속화 실패 | | `GET, PUT /api/injection-model` | 선호 안내 모델, effort, prompt, guidance 설정을 읽거나 설정합니다. 네이티브 기본값 동기화를 켜지 않으면 자문용입니다 | 400 잘못된 모델, effort, 또는 본문 | | `GET, PUT /api/effort-caps` | 전역 및 sub-agent reasoning-effort 상한을 읽거나 설정합니다 | 400 잘못된 ladder 값 | -| `GET, PUT /api/subagent-models` | `spawn_agent` 빠른 선택 모델을 최대 5개까지 읽거나 순서를 조정합니다. 라우팅을 강제하지 않습니다. 응답은 저장된 `chosen` 목록과 실제 `advertised` 목록을 구분하고 반영되지 않은 선택을 `excluded`로 보고합니다 | 400 잘못된 목록 또는 모델 5개 초과 | +| `GET, PUT /api/subagent-models` | `spawn_agent` 빠른 선택 모델을 최대 5개까지 읽거나 순서를 조정합니다. 라우팅을 강제하지 않습니다. 응답은 저장된 `chosen` 목록과 실제 `advertised` 목록을 구분하고 반영되지 않은 선택 및 추가 `activation` 상태를 보고합니다 | 400 잘못된 목록 또는 모델 5개 초과 | | `GET, PUT /api/subagent-model-fallback` | 생성된 하위 작업의 전역 fallback 순서와 poll interval을 읽거나 설정합니다 | 400 잘못된 목록 또는 poll interval | | `GET /api/grok` | Grok 관리 구성 상태와 후보 모델을 읽습니다 | 400 상태 읽기 실패 | | `PUT /api/grok/selection` | 제외할 Grok 모델을 영속화합니다 | 400 잘못되었거나 너무 큰 선택 | @@ -93,7 +97,9 @@ Authorization: Bearer | `POST /api/startup-action` | 서비스 또는 Codex shim을 설치하거나 복구합니다 | 400 잘못된 작업; 500 작업 실패 | | `GET, POST /api/windows-tray` | Windows tray 상태를 읽거나 설치, 시작, 중지, 제거합니다 | 400 지원되지 않는 플랫폼/작업; 500 작업 실패 | | `GET /api/diagnostics/project-config` | 캐시된 프로젝트 구성 경고를 읽습니다 | — | -| `POST /api/sync` | 현재 모델 카탈로그를 Codex에 동기화하고 `catalogQuality`, `rehydrated`, Codex app-server `catalogState`, 필요한 재시작 힌트를 반환합니다 | 409 쓰기 권한 거부, 500 동기화 실패 | +| `POST /api/sync` | 실행 중 워커를 중단하지 않고 현재 모델 카탈로그를 Codex에 동기화하며 `activation` 상태를 반환합니다 | 409 쓰기 권한 거부, 500 동기화 실패 | +| `GET /api/codex-catalog/status` | 저장된 설정, 디스크 카탈로그, 실행 중 워커의 로드 상태를 읽습니다 | — | +| `POST /api/codex-catalog/apply` | `{ "expectedDesiredRevision": "…", "confirmInterrupt": true }`로 확인한 오래된 워커에 명시적으로 적용합니다. 일회용 시작 handoff가 만든 확인된 GUI 세션만 허용합니다 | 400 잘못된 본문, 403 확인된 대시보드 시작 필요, 409 충돌/알 수 없는 신원, 503 busy | | `GET, PUT /api/sidecar-settings` | web-search 및 vision sidecar 모델/backend 설정을 읽거나 업데이트합니다 | 400 잘못된 형태, backend, 또는 한도 | | `GET, PUT /api/shadow-call-settings` | shadow-call interception 설정을 읽거나 업데이트합니다 | 400 잘못된 형태 또는 값 | diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 3538ed3dc8..24aa111b04 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -41,8 +41,9 @@ ccx v2 threads 16 The `mode` subcommand writes `multiAgentMode` to the CodexCommander config and resyncs the Codex catalog. Mode and flag transitions move the current numeric thread limit between the valid v1/v2 Codex keys; -a failed transition restores the original `config.toml`. Changes apply to new Codex sessions, while -running sessions keep their pinned surface. +a failed transition restores the original `config.toml`. Mode, flag, and thread changes update managed +boot configuration. To affect a running worker, use `ccx sync --restart-codex` (or dashboard **Apply +agent catalog**) and then start a new task for its session-bound tool shape. ## Combo routing diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index b9ca85de7d..15811d379a 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -161,6 +161,13 @@ single-flight/lock files can be created under `CODEXCOMMANDER_HOME`, non-healthy accounts (redacted ids) with a recovery `Action:`, and a static OK that the Codex forward path does not fabricate official-client metadata. Doctor never mutates credentials or applies repairs. +:::note[One-time upgrade restart] +An already-running proxy from an older build may have a protected runtime record without an +`attestationSecret`. Restart that proxy once before using CLI management commands or launching +credential-bearing Claude/OpenCode clients. Until then, sensitive requests fail closed: no token or +request body falls back to a listener found only through public health or a configured port. +::: + ## Catalog sync ### `ccx sync [--restart-codex]` @@ -174,6 +181,11 @@ were updated. Pass `--restart-codex` to send `SIGTERM` only to matching `codex `codex-code-mode-host` processes owned by the current user (active turns may be interrupted). Broad `pkill -f codex` matching is intentionally avoided. +`ccx sync` itself is non-disruptive. Starting a new task or forking one in the same already-running +Codex app-server does not make it reload the catalog. Use the dashboard's explicit **Apply agent +catalog**, `ccx sync --restart-codex`, or quit and reopen Codex Desktop; reopening Desktop is the +reliable manual worker-replacement boundary. + ### `ccx sync-cache [--restart-codex]` Invalidate Codex's local model picker cache so it is rebuilt from the active CodexCommander catalog. The @@ -310,4 +322,10 @@ proxy controls. `start` and `stop` control the icon only; use its menu to contro ### `ccx gui` Open the [web dashboard](/guides/web-dashboard/) at `http://localhost:`, auto-starting the proxy -if it is not running. +if it is not running. The command mints a short-lived, single-use browser launch ticket so the +dashboard can make changes, including confirmed **Apply agent catalog**. The ticket travels only in +the URL fragment and is removed during exchange; the durable admin token never enters the URL or web +storage. The resulting confirmed session is process-memory-only, lasts up to eight hours, and is not +renewed. Expiry or proxy restart makes the next API request return `401`; open the page through +`ccx gui` or the macOS menu app again. Opening `localhost` manually supplies no API session and never +prompts for or sends the durable admin token. diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 0b8f2fea92..03eb2b7752 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -10,8 +10,8 @@ routes, and limits delegated work. | Field | Type | Default | Meaning | | --- | --- | --- | --- | -| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` stamps every catalog model as v1; `v2` stamps every model as v2. `default` restores upstream pins (Sol/Terra v2, Luna v1) and otherwise follows the native `multi_agent_v2` flag. Applies to new sessions. | -| `multiAgentV2MessageDelivery?` | `"encrypted" \| "plaintext"` | `"encrypted"` | V2 parent-message delivery. `encrypted` preserves ChatGPT's reserved backend contract and native-only ciphertext guard. `plaintext` opts subsequent V2 parent requests into experimental mixed-provider compatibility; all delegated messages from that parent become plaintext, and routed parents receive the stock Codex plaintext marker on message-bearing collaboration calls. Start a new session after changing it. | +| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` stamps every catalog model as v1; `v2` stamps every model as v2. `default` restores upstream pins (Sol/Terra v2, Luna v1) and otherwise follows the native `multi_agent_v2` flag. After changing it, Apply replaces a running worker; then start a new task for the session-bound tool shape. | +| `multiAgentV2MessageDelivery?` | `"encrypted" \| "plaintext"` | `"encrypted"` | V2 task-message delivery only, not credential encryption. `encrypted` preserves ChatGPT's reserved backend contract and native-only ciphertext guard. `plaintext` opts subsequent V2 parent requests into experimental mixed-provider compatibility; all delegated messages from that parent become plaintext, and routed parents receive the stock Codex plaintext marker on message-bearing collaboration calls. Start a new task after changing it; it does not dirty the catalog or need Apply. | | `subagentModels?` | `string[]` | `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4-mini` | Up to five bare native, account-qualified `/`, or routed `provider/model` ids advertised first in the sub-agent picker. The dashboard preserves configured exact selectors, including account-qualified choices, and reports which saved entries are advertised or excluded. Use `ccx agent subagents set` or edit the configuration for choices that are not in the current catalog. An explicit empty list is preserved. | | `injectionModel?` | `string` | — | Preferred native or routed sub-agent model used in proxy-authored v2 delegation guidance. | | `injectionEffort?` | `string` | — | Preferred effort (`low` through `ultra`), meaningful only with `injectionModel`. | @@ -24,7 +24,8 @@ routes, and limits delegated work. | `subagentEffortCap?` | `string` | — | Additional ceiling for spawned-child turns only. When both caps apply, the lower wins. | Manage the surface with the dashboard or `ccx v2 status|on|off|mode |threads `. -Mode changes apply to new sessions. `maxConcurrentThreadsPerSession` is a `PUT /api/v2` field, not a +Mode, protocol, and thread changes update managed Codex boot configuration. If a Codex worker is +already running, choose **Apply agent catalog** to replace it, then start a new task. `maxConcurrentThreadsPerSession` is a `PUT /api/v2` field, not a `config.json` key; `ccx v2 threads ` writes `max_concurrent_threads_per_session` under `[features.multi_agent_v2]` in Codex's `$CODEX_HOME/config.toml` after v2 is enabled. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 244df6e093..8648b2a866 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -23,7 +23,7 @@ The file-backed token is accepted only after its directory and file permissions hardened. If that cannot be guaranteed, management authentication fails closed and the API returns 503 until an environment token is supplied or the file state is repaired. -Send the admin token in either form: +Headless API clients send the admin token over a trusted transport in either form: ```http X-CodexCommander-API-Key: @@ -36,18 +36,33 @@ Authorization: Bearer :::caution The admin token must differ from every data-plane credential. Startup rejects a management credential that conflicts with a proxy admission key. Do not put the admin token in Codex, -Claude Code, or another model client; it authorizes control-plane mutations. +Claude Code, or another model client; it authorizes control-plane mutations. A browser may request +it only on a trusted non-loopback HTTPS origin. Never paste or send it from a plaintext remote page. ::: -### Loopback dashboard sessions +### Dashboard launch sessions -On a loopback bind, the dashboard bootstrap can receive a short-lived `ccx_session_*` credential. -Each session lasts five minutes and is bound to the exact dashboard origin. Safe requests must -match that origin. Unsafe methods also require the browser `Origin` and the session's CSRF token. +A manually opened loopback dashboard receives no API credential. The static page shell can load, but +every `/api/*` request is authenticated and therefore returns `401` until the user reopens the page +through `ccx gui` or the macOS menu app. A loopback page never prompts for or sends the durable admin +token; loopback is not an authenticated listener identity or an authentication bypass. -Session issuance is disabled whenever data-plane authentication is required, which includes remote -binds. A remote operator must authenticate with the raw admin token; no loopback-style GUI session -is minted. +Those launchers use the raw admin credential to mint a short-lived, single-use ticket bound to the +requested route and origin. The ticket travels only in the URL fragment and is removed immediately +during its one-time exchange. The resulting confirmed GUI session is full-featured, process-memory- +only, and valid for at most eight hours. It is never renewed: expiry or proxy restart makes the next +API request return `401`, after which the local launcher flow is required again. The durable admin +token never enters a URL or web storage. + +The raw admin bearer remains valid for ordinary API mutations. Catalog Apply is deliberately stricter: +`POST /api/codex-catalog/apply` accepts only a confirmed GUI session, so scripts use +`ccx sync --restart-codex` instead. + +A remote operator browser may authenticate with the raw admin token only over trusted HTTPS; a +plaintext remote page never prompts for or sends it. Without trusted HTTPS, use a local or SSH tunnel +that presents loopback and open the dashboard through `ccx gui`. Headless API clients retain raw-admin +authentication over a trusted transport. Confirmed browser sessions are minted only by the +exact-origin, exact-route local launch-ticket exchange. ## Common errors @@ -70,10 +85,10 @@ route-specific results rather than repeating this table. | Method and path | Purpose | Notable errors | | --- | --- | --- | -| `GET, PUT /api/v2` | Read or change the agent protocol, V2 message delivery, and thread settings. `multiAgentV2MessageDelivery` accepts `plaintext` or the `encrypted` default; sending `encrypted` or `null` removes the explicit plaintext override. Start a new session after changing delivery. `maxConcurrentThreadsPerSession: null` restores the Codex default | 400 invalid settings; 502 transition or persistence failure | +| `GET, PUT /api/v2` | Read or change the agent protocol, V2 task-message delivery, and thread settings. A protocol/mode/thread boot-config change needs **Apply agent catalog** to replace a running worker, then a new task for its session-bound tool shape. `multiAgentV2MessageDelivery` accepts `plaintext` or the `encrypted` default; sending `encrypted` or `null` removes the explicit plaintext override. Delivery changes need only a new task and do not dirty the catalog. `maxConcurrentThreadsPerSession: null` restores the Codex default | 400 invalid settings; 502 transition or persistence failure | | `GET, PUT /api/injection-model` | Read or set the preferred guidance model, effort, prompt, and guidance settings; this is advisory unless native-default sync is enabled | 400 invalid model, effort, or body | | `GET, PUT /api/effort-caps` | Read or set global and sub-agent reasoning-effort ceilings | 400 invalid ladder value | -| `GET, PUT /api/subagent-models` | Read or order up to five requested `spawn_agent` quick picks; this does not force routing. Responses keep the persisted `chosen` list separate from the effective `advertised` list and report any `excluded` choices | 400 invalid list or more than five models | +| `GET, PUT /api/subagent-models` | Read or order up to five requested `spawn_agent` quick picks; this does not force routing. Responses keep the persisted `chosen` list separate from the effective `advertised` list, report any `excluded` choices, and include additive `activation` evidence for the desired config, on-disk catalog, and running Codex worker | 400 invalid list or more than five models | | `GET, PUT /api/subagent-model-fallback` | Read or set the ordered global fallback chain for spawned child turns and its poll interval | 400 invalid list or poll interval | | `GET /api/grok` | Read Grok managed-config status and candidate models | 400 status read failure | | `PUT /api/grok/selection` | Persist the excluded Grok models | 400 invalid or oversized selection | @@ -86,6 +101,27 @@ route-specific results rather than repeating this table. For the concepts behind the model roster and encrypted worker-task behavior, see [Sub-agent Surface](/guides/sub-agent-surface/). +#### Catalog activation + +The sub-agent roster has three separate facts: the **desired** saved configuration, the +deterministically generated **on-disk** catalog, and the model catalog loaded by a **running Codex +worker**. Saving a roster or calling `/api/sync` updates the first two without interrupting active +work. A task or fork created through an already-running Codex Desktop app-server does not cause it +to reload the catalog. + +Use `GET /api/codex-catalog/status` to decide whether anything is pending. If the endpoint can +identify verified stale workers, an operator using a confirmed dashboard launch may call +`POST /api/codex-catalog/apply` with the status response's `expectedDesiredRevision` and explicit +`confirmInterrupt: true`. Activity count is +advisory only: an unknown worker identity blocks signaling, while a nonzero count warns about an +interruption but does not prohibit an informed Apply. Results distinguish already-current, no-worker, +applied, partial, superseded, and blocked outcomes. The endpoint never accepts a PID, command, or +path from the caller, never queues an idle apply, and does not persist a separate activation +snapshot. + +For scripts or the native companion, `ccx sync --restart-codex` remains the compatible advanced +fallback. Quitting and reopening Codex Desktop is the reliable manual worker-replacement boundary. + ### Combos | Method and path | Purpose | Notable errors | @@ -108,7 +144,9 @@ See [Combos](/guides/combos/) for target strategies, cooldowns, aliases, and rou | `POST /api/startup-action` | Install or repair the service or Codex shim | 400 invalid action; 500 action failure | | `GET, POST /api/windows-tray` | Read Windows tray state or install/start/stop/uninstall it | 400 unsupported platform/action; 500 operation failure | | `GET /api/diagnostics/project-config` | Read cached project configuration warnings | — | -| `POST /api/sync` | Sync the current model catalog into Codex; returns `catalogQuality` (`live`, `retained`, or `native-only`), `rehydrated`, current Codex app-server `catalogState`, and a restart hint when stale | 409 refused write authority; 500 failed sync | +| `POST /api/sync` | Sync the current model catalog into Codex without interrupting workers; returns `catalogQuality` (`live`, `retained`, or `native-only`), `rehydrated`, current Codex app-server `catalogState`, and additive `activation` evidence | 409 refused write authority; 500 failed sync | +| `GET /api/codex-catalog/status` | Read the catalog activation state: desired configuration revision, deterministic on-disk catalog evidence, and whether verified current-user Codex workers have loaded it | — | +| `POST /api/codex-catalog/apply` | Explicitly converge then apply the current catalog to verified stale Codex workers. The body must be `{ "expectedDesiredRevision": "…", "confirmInterrupt": true }`; the revision fence prevents applying a superseded choice. This browser endpoint accepts only the confirmed GUI session created by the single-use launch handoff | 400 invalid confirmation/body; 403 confirmed dashboard launch required; 409 superseded or unsafe worker identity; 503 apply busy (`Retry-After: 1`) | | `GET, PUT /api/sidecar-settings` | Read or update web-search and vision sidecar model/backend settings | 400 invalid shape, backend, or limit | | `GET, PUT /api/shadow-call-settings` | Read or update shadow-call interception settings | 400 invalid shape or value | diff --git a/docs-site/src/content/docs/ru/guides/codex-app-models.md b/docs-site/src/content/docs/ru/guides/codex-app-models.md index 361e13d448..2f5032634d 100644 --- a/docs-site/src/content/docs/ru/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ru/guides/codex-app-models.md @@ -107,7 +107,7 @@ routed provider-id `provider/model`. Account-qualified id `/`, автоматически -запустив прокси, если он ещё не работает. +запустив прокси, если он ещё не работает. Краткоживущий одноразовый browser launch-ticket открывает изменения, включая подтверждённый **Apply agent catalog**. Ticket передаётся только во fragment URL и удаляется во время обмена; постоянный admin-token не попадает в URL или Web Storage. Подтверждённая сессия хранится только в памяти процессов до восьми часов и не продлевается. После истечения срока или перезапуска прокси следующий API-запрос получает `401`; откройте страницу снова через `ccx gui` или приложение строки меню macOS. При ручном открытии loopback-страницы API-сессия не выдаётся, и постоянный admin-token никогда не запрашивается и не отправляется. diff --git a/docs-site/src/content/docs/ru/reference/configuration/agents.md b/docs-site/src/content/docs/ru/reference/configuration/agents.md index 0a22390292..bc792893de 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ru/reference/configuration/agents.md @@ -10,8 +10,8 @@ description: Multi-agent surface, guidance при делегировании, pr | Поле | Тип | По умолчанию | Значение | | --- | --- | --- | --- | -| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` штампует все модели как v1; `v2` штампует все модели как v2. `default` восстанавливает upstream pin'ы (Sol/Terra — v2, Luna — v1) и для остальных следует native flag `multi_agent_v2`. Применяется к новым сессиям. | -| `multiAgentV2MessageDelivery?` | `"encrypted" \| "plaintext"` | `"encrypted"` | Политика доставки сообщений V2-родителя. `encrypted` сохраняет зарезервированный шифрованный контракт ChatGPT. Экспериментальный `plaintext` включает совместимость между провайдерами для последующих V2-запросов родителя и делает все его сообщения делегирования открытыми; вызовы сообщений маршрутизируемого родителя также получают plaintext-маркер Codex. После изменения начните новую сессию. | +| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` штампует все модели как v1; `v2` штампует все модели как v2. `default` восстанавливает upstream pin'ы (Sol/Terra — v2, Luna — v1) и для остальных следует native flag `multi_agent_v2`. После изменения замените запущенный worker через Apply и начните новый task. | +| `multiAgentV2MessageDelivery?` | `"encrypted" \| "plaintext"` | `"encrypted"` | Политика только для доставки V2 task-сообщений, а не шифрования credentials. `encrypted` сохраняет зарезервированный шифрованный контракт ChatGPT. Экспериментальный `plaintext` включает совместимость между провайдерами для последующих V2-запросов родителя и делает все его сообщения делегирования открытыми. После изменения начните новый task; каталог не становится dirty и Apply не нужен. | | `subagentModels?` | `string[]` | `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4-mini` | До пяти bare native-id, account-qualified id `/` или routed-id `provider/model`, которые первыми рекламируются в picker'е подагентов. Дашборд сохраняет настроенные exact selector'ы, включая account-qualified варианты, и показывает, какие сохранённые записи реально рекламируются или исключены. Для вариантов, отсутствующих в текущем каталоге, используйте `ccx agent subagents set` или отредактируйте конфигурацию. Явный пустой список сохраняется. | | `injectionModel?` | `string` | — | Предпочитаемая native- или routed-модель подагента, которую proxy использует в собственном guidance v2. | | `injectionEffort?` | `string` | — | Предпочитаемый effort (`low`–`ultra`), имеющий смысл только вместе с `injectionModel`. | @@ -24,7 +24,7 @@ description: Multi-agent surface, guidance при делегировании, pr | `subagentEffortCap?` | `string` | — | Дополнительный потолок только для spawned-child turn'ов. Если применимы оба cap'а, выигрывает более низкий. | Управляйте surface через дашборд или `ccx v2 status|on|off|mode |threads `. -Смена режима применяется к новым сессиям. `maxConcurrentThreadsPerSession` — это поле +Смена режима, протокола или thread обновляет boot config: для уже запущенного worker нужен Apply, затем новый task. `maxConcurrentThreadsPerSession` — это поле `PUT /api/v2`, а не ключ `config.json`; `ccx v2 threads ` записывает `max_concurrent_threads_per_session` в `[features.multi_agent_v2]` файла `$CODEX_HOME/config.toml` после включения v2. diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index 9906446b0c..60416b7546 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -23,7 +23,7 @@ CodexCommander разрешает его в таком порядке: hardened-permissions или ACL. Если это гарантировать нельзя, management-аутентификация закрывается, и API возвращает 503, пока вы не зададите env-token или не исправите состояние файла. -Передавайте admin-token в любой из двух форм: +Headless API-клиенты передают admin-token по доверенному transport в одной из двух форм: ```http X-CodexCommander-API-Key: @@ -36,19 +36,24 @@ Authorization: Bearer :::caution Admin-token должен отличаться от любого credential data plane. При старте отвергается management-credential, конфликтующий с proxy-admission key. Не записывайте admin-token в Codex, -Claude Code или любого другого клиента моделей; он даёт право на мутации в control plane. +Claude Code или любого другого клиента моделей; он даёт право на мутации в control plane. Browser +может запросить его только на доверенном non-loopback HTTPS origin. Никогда не вставляйте и не +отправляйте его со страницы по plaintext remote. ::: -### Loopback-сессии дашборда +### Сессии запуска дашборда -На loopback-привязке bootstrap дашборда может получить short-lived credential `ccx_session_*`. -Каждая такая сессия живёт пять минут и привязана к точному origin дашборда. Safe-запросы должны -совпадать с этим origin. Для unsafe-method'ов браузер дополнительно обязан передать `Origin` и -CSRF-token этой сессии. +Вручную открытый loopback-дашборд не получает API-credential. Статическая оболочка страницы может загрузиться, но каждый запрос `/api/*` возвращает `401`, пока страницу не откроют заново через `ccx gui` или приложение строки меню macOS. Ни на одном loopback hostname/address страница не запрашивает и не отправляет постоянный admin-token. Browser origin не доказывает, какой локальный пользователь ОС владеет listener'ом, поэтому loopback не является ни аутентифицированной listener identity, ни обходом аутентификации. -Выдача таких сессий отключена всякий раз, когда для data plane требуется аутентификация, в том -числе на удалённых bind'ах. Удалённый оператор обязан аутентифицироваться сырым admin-token'ом; -GUI-сессия в стиле loopback не выпускается. +Launcher использует сырой admin credential, чтобы выпустить краткоживущий одноразовый ticket, привязанный к запрошенному route и origin. Ticket передаётся только во fragment URL и немедленно удаляется во время однократного обмена. Полученная подтверждённая GUI-сессия полнофункциональна, хранится только в памяти процессов и действует не более восьми часов. Она не продлевается: после истечения срока или перезапуска прокси следующий API-запрос получает `401`, после чего снова нужен локальный launcher flow. Постоянный admin-token не попадает в URL или Web Storage. + +Сырой admin-token сохраняет право на обычные API-мутации. Apply каталога намеренно строже: `POST /api/codex-catalog/apply` принимает только подтверждённую GUI-сессию. Для скриптов используйте `ccx sync --restart-codex`. + +Browser удалённого operator'а может аутентифицироваться сырым admin-token только по доверенному HTTPS; +страница по plaintext remote никогда его не запрашивает и не отправляет. Если доверенного HTTPS нет, +используйте локальный или SSH tunnel, представляющий loopback, и откройте дашборд через `ccx gui`. +Headless API-клиенты сохраняют raw-admin аутентификацию по доверенному transport. Подтверждённая +browser-сессия выдаётся только при обмене локального launch-ticket, привязанного к точным origin и route. ## Общие ошибки @@ -71,10 +76,10 @@ GUI-сессия в стиле loopback не выпускается. | Метод и путь | Назначение | Особые ошибки | | --- | --- | --- | -| `GET, PUT /api/v2` | Прочитать или изменить протокол агента, доставку сообщений V2 и настройки потоков. `multiAgentV2MessageDelivery` принимает `plaintext` или значение по умолчанию `encrypted`; `encrypted` или `null` удаляет явное переопределение открытого текста. После изменения доставки начните новую сессию. `maxConcurrentThreadsPerSession: null` восстанавливает значение Codex по умолчанию | 400 invalid settings; 502 transition or persistence failure | +| `GET, PUT /api/v2` | Прочитать или изменить протокол агента, доставку V2 task-сообщений и настройки потоков. Изменение mode/protocol/thread требует Apply для замены запущенного worker, затем нового task. Для изменения доставки нужен только новый task, каталог не становится dirty. `maxConcurrentThreadsPerSession: null` восстанавливает значение Codex по умолчанию | 400 invalid settings; 502 transition or persistence failure | | `GET, PUT /api/injection-model` | Прочитать или задать предпочтительную модель подсказки, effort, prompt и guidance settings; без синхронизации нативных значений это только рекомендация | 400 invalid model, effort or body | | `GET, PUT /api/effort-caps` | Прочитать или задать глобальный и sub-agent потолок reasoning effort | 400 invalid ladder value | -| `GET, PUT /api/subagent-models` | Прочитать или упорядочить до пяти быстрых вариантов для `spawn_agent`; маршрутизацию это не принуждает. Ответ разделяет сохранённый список `chosen` и фактически объявленный `advertised`, а неприменённые варианты сообщает в `excluded` | 400 invalid list or more than five models | +| `GET, PUT /api/subagent-models` | Прочитать или упорядочить до пяти быстрых вариантов для `spawn_agent`; маршрутизацию это не принуждает. Ответ разделяет сохранённый список `chosen` и фактически объявленный `advertised`, сообщает неприменённые варианты в `excluded` и добавляет состояние `activation` | 400 invalid list or more than five models | | `GET, PUT /api/subagent-model-fallback` | Прочитать или задать глобальный порядок fallback для созданных дочерних задач и poll interval | 400 invalid list or poll interval | | `GET /api/grok` | Прочитать статус управляемой конфигурации Grok и кандидатные модели | 400 status read failure | | `PUT /api/grok/selection` | Сохранить список исключённых моделей Grok | 400 invalid or oversized selection | @@ -109,7 +114,9 @@ GUI-сессия в стиле loopback не выпускается. | `POST /api/startup-action` | Установить или починить службу или Codex shim | 400 invalid action; 500 action failure | | `GET, POST /api/windows-tray` | Прочитать состояние Windows tray или установить/запустить/остановить/удалить её | 400 unsupported platform/action; 500 operation failure | | `GET /api/diagnostics/project-config` | Прочитать кэшированные предупреждения project config | — | -| `POST /api/sync` | Синхронизировать каталог моделей в Codex; возвращает `catalogQuality`, `rehydrated`, `catalogState` app-server и подсказку о перезапуске | 409 отказ в праве записи; 500 ошибка синхронизации | +| `POST /api/sync` | Синхронизировать каталог моделей в Codex без прерывания воркеров; возвращает состояние `activation` | 409 отказ в праве записи; 500 ошибка синхронизации | +| `GET /api/codex-catalog/status` | Прочитать сохранённую конфигурацию, каталог на диске и состояние загрузки текущих воркеров | — | +| `POST /api/codex-catalog/apply` | Явно применить к подтверждённым устаревшим воркерам с `{ "expectedDesiredRevision": "…", "confirmInterrupt": true }`; принимается только подтверждённая GUI-сессия из одноразового launch-handoff | 400 неверное тело; 403 требуется подтверждённый запуск дашборда; 409 конфликт/неизвестная идентичность; 503 busy | | `GET, PUT /api/sidecar-settings` | Прочитать или обновить model/backend-settings web-search и vision sidecar'ов | 400 invalid shape, backend or limit | | `GET, PUT /api/shadow-call-settings` | Прочитать или обновить настройки shadow-call interception | 400 invalid shape or value | diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md index 0d74a1f429..cee36530d4 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md @@ -72,7 +72,7 @@ visibility = "list" ## 多代理界面模式 -Models 页面将三个协作选项标为 **Classic v1**、**Follow Codex defaults**(base/upstream 行为)和 **Concurrent v2**。该控件会改变每个选择器条目使用的 Codex 协作界面;有关规范模式、委派、继承、fallback 以及加密任务行为,请参见 [Sub-agent Surface](/guides/sub-agent-surface/)。 +Models 页面将三个协作选项标为 **Reliable v1**、**Codex native**(base/default/upstream 行为)和 **Concurrent v2**。该控件会改变每个选择器条目使用的 Codex 协作界面;有关规范模式、委派、继承、fallback 以及加密任务行为,请参见 [Sub-agent Surface](/guides/sub-agent-surface/)。 ## 推理顶档 diff --git a/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md index 1101b48aab..1dbfeeae26 100644 --- a/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md @@ -1,6 +1,6 @@ --- title: macOS 菜单栏伴侣 -description: 安装并使用原生 CodexCommander 状态、智能体活动和提供商配额伴侣。 +description: 安装并使用显示 CodexCommander 代理状态、启动就绪状态、Codex 路由、实时请求和提供商配额的原生伴侣。 --- macOS 伴侣会在菜单栏中显示最有用的 CodexCommander 状态,同时不会取代代理或重复实现 Web @@ -26,19 +26,25 @@ macOS 伴侣会在菜单栏中显示最有用的 CodexCommander 状态,同时 ## 面板显示的内容 -- **智能体活动** — 当前活动数量以及实时模型/提供商行。只有当 CodexCommander 能够根据请求元数据 - 证明其活动父项时,派生的子项才会嵌套显示;否则,它会显示为独立的子智能体。伴侣绝不会 - 虚构排队中、审阅中、受速率限制或已完成的历史记录。 +- **代理状态** — 显示代理进程是否在运行。服务器正在运行并不能证明启动同步已完成,也不能证明 Codex 正在使用该代理。 +- **就绪状态** — 将启动和目录同步显示为 **Checking**、**Starting**、**Ready**、 + **Startup failed** 或 **Unavailable**。该信号与代理运行状态相互独立。 +- **Codex 路由** — 显示 Codex 当前是否通过 CodexCommander、原生 OpenAI 或其他自定义路由。 + 代理正在运行本身并不表示 Codex 正在使用它。 +- **实时代理请求** — 显示当前进行中的请求数量以及模型/提供商 turn 行。只有当 CodexCommander 能够 + 根据请求元数据证明其进行中的父请求时,派生的子请求才会嵌套显示;否则,它会显示为独立的 + 子智能体 turn。模型请求结束后,即使 Codex 仍将子线程保持为空闲状态以供后续使用,该行也会消失。 + 这不是持久的 Codex 智能体生命周期视图;伴侣也绝不会虚构排队中、审阅中、受速率限制或已完成的历史记录。 - **提供商配额** — 在可用时显示提供商报告的 5 小时、每周、每月或特定额度窗口及重置时间。 OpenCode Go 显示公开上限和本地观测值,不会编造当前余额。缺失数据会显示为不可用,绝不会 显示为零使用量或无限容量。 -- **Dashboard 和 Logs** — 在默认浏览器中打开对应的本地控制面板视图。 +- **Dashboard 和 Logs** — 在默认浏览器中打开对应的本地控制面板视图,并为包括目录 Apply 在内的更改操作传递一次性启动授权。 - **管理** — 打开所选提供商的 Accounts 或 API Keys 标签页。OAuth、API 密钥输入、重新认证、 账户切换和提供商配置仍在控制面板中进行。 -- **Agent catalog update ready** — 当正在运行的 Codex 后台工作进程仍持有旧模型列表时显示的 - 持久、非故障卡片。CodexCommander 代理会保持健康并继续运行。 -- **Apply agent catalog…** — 打开确认窗口,在可用时显示最新请求活动,警告应用更新可能中断 - 回答,并提供 **Apply Now** 和 **Later**。 +- **Restart ChatGPT to load models** — 当正在运行的 Codex 后台工作进程仍持有旧模型列表时显示的 + 持久、非致命提示卡片。CodexCommander 代理会保持健康并继续运行。 +- **Show restart steps…** — 说明推荐的重新加载边界:完全退出 ChatGPT,重新打开后再开始新任务。 + 菜单栏应用不会通过这张卡片强制重启后台工作器。 - **Stop Proxy…** — 始终请求确认,会中断活动客户端和子智能体请求、恢复原生 Codex,并让菜单栏应用保持打开。 - **Restart Proxy…** — 请求确认,允许代理用最多 60 秒排空活动请求,然后重新连接到替代进程。接受重启 请求不会被显示为完成;应用会等待新进程通过身份检查。 @@ -57,16 +63,19 @@ macOS 伴侣会在菜单栏中显示最有用的 CodexCommander 状态,同时 打开应用时,它会自动将 Codex 模型目录与 CodexCommander 当前配置的提供商同步。如果没有 Codex 工作 进程在运行,新列表会在下一个 Codex 任务中生效。如果长时间运行的工作进程载入了旧列表,CodexCommander -仍会继续运行,面板会持续显示非故障的 **Agent catalog update ready** 卡片。 +仍会继续运行,面板会持续显示非致命的 **Restart ChatGPT to load models** 提示卡片。 -选择 **Apply agent catalog…** 可查看中断风险。确认前会尽可能获取最新的活动请求数量,但请求数为 -零不会被描述为 Codex 已空闲的证明,因为操作执行前仍可能开始新请求。**Apply Now** 会再次同步, -仅向当前用户所有、精确匹配 `codex … app-server` 和 `codex-code-mode-host` 的进程发送 `SIGTERM`,并 -短暂验证旧进程 ID 已退出。它不会使用宽泛的 `pkill`,不会重启 CodexCommander 代理,也不会关闭菜单栏 -应用。Codex 会在下一个任务中创建新的后台主机并载入当前列表。 +选择 **Show restart steps…**,完全退出 ChatGPT,重新打开后再开始新任务。这是替换旧工作器时推荐且 +最可预测的方式。CodexCommander 和菜单栏应用在此期间会继续运行。 -当前配套应用不包含 **Apply when idle**。如果回答仍在进行,请选择 **Later**,并在准备好后应用更新; -卡片会继续保留。高级 CLI 回退命令如下: +在同一个旧后台主机中启动新 task 或 fork 并不是 catalog 重新加载边界,因此卡片会一直保留,直到 +状态检查发现工作器已是最新。如果仪表盘显示 catalog 为 pending 或 unknown,或者受管理路由尚未注入, +请先在那里选择**应用到 Codex**,让它协调并验证这些文件;仅退出 ChatGPT 并不能完成这项修复。 + +对于 catalog 已经协调、只有工作器过时的情况,仪表盘/API 的**强制重启工作器**操作和 CLI +仍是高级备用方案。它们会再次同步,使用目标修订围栏,只向当前用户所有、精确匹配 +`codex … app-server` 和 `codex-code-mode-host` 的进程发送信号,并且绝不会升级为宽泛的 `pkill`。 +由于这会绕过 ChatGPT 的正常应用生命周期,ChatGPT 可能显示 **stopped unexpectedly**。CLI 形式为: ```bash ccx sync --restart-codex @@ -82,6 +91,8 @@ ccx sync --restart-codex 验证的回环 CodexCommander 进程。它绝不会显示、记录、复制或存储该令牌,也不会将其放入浏览器 URL。 +伴侣打开仪表盘时,会向经过验证的本地代理请求一个短期、一次性启动票据。票据只出现在 URL fragment 中,并在一次性交换过程中清除;长期管理员 token 不会进入 URL 或 Web Storage。确认的完整功能 session 只存在于进程内存中,最长八小时,且不会续期。到期或代理重启后的下一个 API 请求会返回 `401`,页面会提示通过伴侣或 `ccx gui` 重新打开。手动打开的 loopback 仪表盘没有 API session,也绝不会请求或发送长期管理员 token。 + 提供商凭据仍由 CodexCommander 管理。伴侣绝不会读取 ChatGPT、Kimi、Grok、Anthropic 或其他 提供商令牌,也绝不会直接调用提供商登录端点。 @@ -89,17 +100,17 @@ URL。 从 Finder 启动的应用通常不会继承 shell 变量;如果没有受保护的令牌文件,伴侣会报告管理 身份验证不可用,而不会显示令牌输入表单。 -实时智能体记录仅保存在内存中。管理响应包含仅在进程生命周期内有效的行 ID、提供商/模型 +实时请求记录仅保存在内存中。管理响应包含仅在进程生命周期内有效的行 ID、提供商/模型 标识符、时间戳和汇总计数。它不包含提示词、标题、工作目录、工具参数、账户标识符、凭据、 请求正文、原始线程/会话 ID 或历史活动。 ## 轮询 -面板打开时,应用会频繁刷新轻量级活动信息;面板关闭时则会降低频率。提供商配额按独立且 +面板打开时,应用会频繁刷新轻量级的进行中请求信息;面板关闭时则会降低频率。提供商配额按独立且 更慢的节奏刷新,并使用 CodexCommander 报告的上游时间戳。重复失败会自动退避,重叠的刷新会被 合并。 -使用**刷新**可立即刷新活动信息并强制刷新配额。 +使用**刷新**可立即刷新进行中的请求并强制刷新配额。 ## 从源代码构建 @@ -113,9 +124,10 @@ bun run build:macos open dist/macos/CodexCommander.app ``` -源码应用的唯一位置是 `dist/macos/CodexCommander.app`。它使用同一检出中的 Bun 和 CLI,因此需要先运行 -`bun install`。开发期间请保留在此位置,不要复制到 Application Support。双击会尝试确保代理运行; -即使离线或启动失败,应用也不会关闭,面板和 **Start** 控件仍可使用。 +开发应用的唯一位置是 `dist/macos/CodexCommander.app`。每次构建都会把 Bun 运行时和 CodexCommander +服务器资源嵌入应用包;运行中的应用不会直接执行检出目录里的 `src/`。源代码发生变化后请重新构建 +应用。开发期间请保留在此位置,不要复制到 Application Support。双击会尝试确保代理运行;即使离线 +或启动失败,应用也不会关闭,面板和 **Start** 控件仍可使用。 每次构建都会把准确的 Git 修订写入应用包 `Info.plist` 的 `CodexCommanderSourceRevision`,并在构建结束时 输出。未提交的源码会带有 `-dirty`,因此制作最终包前请先提交。 @@ -132,8 +144,8 @@ open dist/macos/CodexCommander.app 服务状态作为回退措施。 - **停止、Codex 更新或冷启动后只显示原生模型** — 重新打开 CodexCommander。启动时会自动同步目录;即使 提供商发现暂时为空,CodexCommander 也会从受保护的最近正常目录中恢复仍在配置中的路由模型。如果 - **Agent catalog update ready** 仍然显示,请选择 **Apply agent catalog…**,或使用 - [智能体目录更新](#智能体目录更新)中的 CLI 回退命令。 + **Restart ChatGPT to load models** 仍然显示,请退出并重新打开 ChatGPT,然后开始新任务。只有在手动 + 重启不合适时,才使用[智能体目录更新](#智能体目录更新)中的高级仪表盘/API 或 CLI 备用方案。 ## 卸载 diff --git a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md index c2d64bbb09..266701e430 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md @@ -91,13 +91,20 @@ CodexCommander 会安全失败,而不是转发空任务或不可读任务: ### GUI - **Dashboard** → 第一个状态单元:选择 **v1**、**base** 或 **v2**。 -- **Models** → **Current behavior** → **Collaboration**:选择 **Classic v1**、**遵循 Codex 默认值**(base)或 **Concurrent v2**。 +- **Models** → **Current behavior** → **Collaboration**:选择 **Reliable v1**、**Codex native**(base/default 语义)或 **Concurrent v2**。 - **Subagents** → **Agent Command Center**: - **Active Roster** 选择并排序最先向 `spawn_agent` 公布的五个模型 override。拖动行、使用箭头按钮,或按 Alt + /。 - **Agent Library** 搜索当前模型目录,并按事实能力进行筛选,例如推理、长上下文、视觉和工具支持。路由可用时,五个槽位 roster 之外的条目仍可通过精确 id 指定。 - **Run Policy** 暂存代理协议、V2 消息传递、首选指导模型和 effort、已生成子任务的全局回退链、健康复查间隔、线程限制、子代理 effort 上限、名单指导,以及原生 Codex 默认值同步。策略变更与 roster 变更分开保存。 -将 **线程上限** 留空可恢复 Codex 默认值。V2 计算包含根代理的总线程数,V1 计算子线程数。协议和上限适用于新会话,指导和回退适用于之后生成的子任务;如果正在运行的 Codex app-server 仍保留过时的 catalog,页面会予以报告。 +将 **线程上限** 留空可恢复 Codex 默认值。V2 计算包含根代理的总线程数,V1 计算子线程数。 +协议或上限变更会更新 boot config;保存后请检查激活状态。如果磁盘 catalog 和受管理路由已经是 +最新,而只有运行中工作器过时,请完全退出 ChatGPT,重新打开后再开始新任务。这是推荐且最可靠的 +路径。如果 catalog 为 pending 或 unknown,或者路由尚未注入,请先使用**应用到 Codex**;仅重启 +ChatGPT 并不足够。外部或未知路由仍会被阻止,关闭的集成仍保持关闭。受保护的 +**强制重启工作器**、`/api/codex-catalog/apply` 和 `ccx sync --restart-codex` 是高级备用方案; +它们可能会让 ChatGPT 显示 **“stopped unexpectedly”**。指导和回退适用于之后生成的子任务。仅更改 +V2 传递时只需新任务,不会弄脏 catalog。 ### CLI @@ -134,6 +141,8 @@ ccx agent effort set --subagent max | `/api/effort-caps` | 主代理和子代理的 effort 上限 | | `/api/subagent-models` | 最多五个模型的有序 roster | | `/api/subagent-model-fallback` | 全局 fallback 顺序和轮询间隔 | +| `/api/codex-catalog/status` | 已保存配置、磁盘 catalog 与运行中 worker 的激活状态 | +| `/api/codex-catalog/apply` | 协调 pending catalog 或尚未注入的受管理路由,然后在需要时经中断确认,只强制重启已验证的过期工作器。当二者已是 current、只有工作器过期时,它属于高级备用方案,且可能让 ChatGPT 显示 **“stopped unexpectedly”**;浏览器调用仅限通过确认的 `ccx gui` 或菜单栏应用启动后使用。 | 例如: @@ -161,9 +170,22 @@ curl -X PUT http://localhost:10100/api/injection-model \ 它可能在 picker 中被隐藏、超出了五个模型的显示上限、从目录中缺失,或者被固定到 v1。`"v2"`、`null` 或缺失的界面值都可以;真正的 `"v1"` 固定值不可以。 +### 选择 V2 后 Luna 会立刻可用吗? + +不会。强制 V2 会让 Luna 在 V2 界面中具备资格,但不会让正在运行的 worker 重新加载 catalog。 +模型还必须被选中、可在 picker 中显示、位于五个广告模型窗口内、写入磁盘 catalog、被当前 +app-server 加载,并能由 proxy 实际路由。新 task 或 fork 不是重新加载边界。如果磁盘 catalog 和 +路由已经是最新,而只有工作器过时,请完全退出 ChatGPT,重新打开后再开始新任务。如果 catalog +为 pending/unknown 或路由尚未注入,请先选择**应用到 Codex**;仅手动重启并不足够。 + ### 模式更改会影响正在运行的会话吗? -不会。更改模式后请启动一个新的 Codex 会话。如果长时间运行的 App host 仍然显示旧的目录状态,请运行 `ccx sync` 并重启那个 Codex 界面。 +不会。更改模式后请启动一个新的 Codex 会话,但这不会让旧 App host 重新读取 catalog。保存和 +`ccx sync` 都不会中断工作。当磁盘 catalog 和路由已经是最新时,请完全退出 ChatGPT,重新打开后 +再开始新任务,以替换过期工作器。如果 catalog 为 pending/unknown,或受管理路由尚未注入,请先使用 +**应用到 Codex**;重启本身不会完成这项工作。外部或未知路由保持不变,关闭的集成也仍保持关闭。 +强制重启已验证的过期后台工作器仍是高级备用方案,并可能让 ChatGPT 显示 +**“stopped unexpectedly”**。 ### 推理强度 diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index f61e4bfb01..639ee75bbf 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -22,9 +22,13 @@ bun run dev:gui ## 登录 -通过 `localhost`、`127.0.0.1` 等 loopback 地址打开仪表盘时,它会自动获得一个短期 GUI session,因此通常无需输入 token。在非 loopback 主机上公开仪表盘时,必须使用 `CODEXCOMMANDER_ADMIN_AUTH_TOKEN` 或自动生成的 `~/.codexcommander/admin-api-token` 文件中的管理员 token。 +手动通过任何 loopback 形式打开的仪表盘都不会获得 API 凭证,包括 `localhost`、`*.localhost`、`127.0.0.0/8` 中的任意地址、`::1` 和 IPv4-mapped `127/8` 地址。页面框架可以加载,但 API 请求仍未通过身份验证。请通过 `ccx gui` 或 macOS 菜单栏应用重新打开。由于另一个本地 OS 用户可以冒充未使用端口上的 listener,loopback 页面绝不会请求或发送长期管理员 token。loopback 浏览器访问需要确认的 launcher session,不能绕过身份验证。 -远程仪表盘会显示标准密码表单,浏览器密码管理器可以提示保存并自动填充 token。仪表盘本身只在内存中保存 token,不会写入 `localStorage` 或 `sessionStorage`;是否持久保存完全由浏览器或密码管理器决定。 +要使用完整功能,请通过 `ccx gui` 或 macOS 菜单栏应用打开。launcher 使用管理员权限签发一个短期、一次性票据,只把票据放入 URL fragment;仪表盘会在一次性交换过程中立即将其清除。确认 session 只存在于代理和浏览器进程内存中,最长八小时,且不会续期。到期或代理重启后的下一个 API 请求会返回 `401`,loopback 页面需要新的 launcher handoff。长期管理员 token 绝不会进入 URL 或浏览器存储。 + +非 loopback 主机可以使用 `CODEXCOMMANDER_ADMIN_AUTH_TOKEN` 或 `~/.codexcommander/admin-api-token` 中的管理员 token,但浏览器输入框仅在受信任的 HTTPS origin 上启用。明文远程页面绝不会请求或发送 bearer。若没有受信任的 HTTPS,请使用把仪表盘呈现为 loopback 的本地或 SSH tunnel,并通过 `ccx gui` 打开。原始管理员 token 仍可供 headless management API client 使用,但 catalog Apply 只允许来自确认的本地仪表盘启动。 + +受信任 HTTPS 上的远程仪表盘会显示标准密码表单,浏览器密码管理器可以提示保存并自动填充 token。仪表盘本身只在内存中保存 token,不会写入 `localStorage` 或 `sessionStorage`;是否持久保存完全由浏览器或密码管理器决定。 ## 可以完成哪些操作 @@ -41,7 +45,7 @@ bun run dev:gui | **Add provider** | 搜索 registry preset,选择账号登录、API key 服务、本地服务器或自定义 endpoint。输入搜索词时会同时搜索 Accounts、Free 和 Paid;标签仍可用于浏览。 | | **Codex Auth** | 添加 ChatGPT/Codex 池账号,选择下一 session 的账号,刷新 5h / 每周 / 30d 配额,启用或停用配额自动切换,设置其 1–100% 阈值和临时故障 failover。 | | **Subagents** | 在 **Agent Command Center** 中选择并排序向 `spawn_agent` 公开的五个模型、搜索当前目录,并配置协议、V2 传递、引导、回退和线程上限等 Run Policy。已保存但未公开的条目会被明确报告。 | -| **Models** | 开关原生 GPT 与路由模型,配置 provider allowlist 和上下文上限,选择 **Classic v1**、**Follow Codex defaults** 或 **Concurrent v2**,并设置 v2 thread 数量。Current behavior 卡片会将上下文显示为 **Uncapped**、**Limited** 或 **Mixed limits**。每个路由 provider 都会显示 **自动发现已开启** 或 **仅静态目录**,并链接到对应的 provider 设置。 | +| **Models** | 开关原生 GPT 与路由模型,配置 provider allowlist 和上下文上限,选择 **Reliable v1**、**Codex native** 或 **Concurrent v2**,并设置 v2 thread 数量。Current behavior 卡片会将上下文显示为 **Uncapped**、**Limited** 或 **Mixed limits**。每个路由 provider 都会显示 **自动发现已开启** 或 **仅静态目录**,并链接到对应的 provider 设置。 | | **Client Apps** | 查看已配置和可连接的本地客户端;在支持时应用或移除托管配置并检查备份;集中访问 Codex、Claude Code/Desktop、Grok Build、OpenCode 及文件托管客户端,同时避免把客户端与提供商混为一谈。 | | **API Access** | 签发和管理其他应用连接 CodexCommander 代理时使用的认证密钥。上游提供商凭据仍归 Providers 管理。 | | **Logs** | 自动刷新近期请求,显示 token、请求强度以及(可用时)实际发送强度、实际模型、provider、状态、request id、耗时和错误详情。适配器发送 reasoning 参数时,详情中还会显示准确的 wire field。可按不透明会话/对话 ID(客户端提供时)筛选,并对当前已加载的 Logs 环形缓冲合计 token 与估算标价成本。 | @@ -62,6 +66,28 @@ bun run dev:gui 上游目录自动刷新按 provider 在 **Providers → Settings** 中管理。Models 页面只显示该状态并直接链接到设置,不会保存第二份发现开关。 +## Catalog 激活 + +保存不会中断工作:它会更新目标配置和确定性的磁盘 catalog,但不会终止当前 ChatGPT +后台工作器。**Agent Command Center** 会分别显示磁盘 catalog、Codex 路由和运行中工作器所加载 +roster 的状态。 + +如果磁盘 catalog 已是最新、CodexCommander 路由已注入,而只有已验证的运行中工作器过时, +推荐操作是完全退出 ChatGPT,重新打开后再开始新任务。这是加载已保存 roster 最可靠的方式。 +仅在原后台工作器中启动新 task 或 fork 并不会重新加载 catalog。仪表盘会保持该状态可见,并在你 +返回后提供**检查状态**。 + +如果 catalog 状态为 pending 或 unknown,或者路由尚未注入,请先选择**应用到 Codex**,以协调 +catalog 和受管理路由。仅手动重启 ChatGPT 不会修复这些文件。对于外部或未知路由,Apply 仍会被 +阻止,以免覆盖用户配置;集成关闭时,已保存的 roster 仍只保留在 CodexCommander 中。 + +对于 catalog 与受管理路由均为 current、只有工作器过期的情况,通过 `ccx gui` 或 macOS 菜单栏应用 +打开的仪表盘中的**强制重启工作器**仍是高级备用方案;受保护的 management API 与 +`ccx sync --restart-codex` 亦同。它们只会影响已验证的过期后台工作器,但可能会让 ChatGPT 显示 +**“stopped unexpectedly”**。活动请求数只是中断警告,不是空闲保证;没有自动应用或空闲队列。若 +手动打开的 loopback 仪表盘没有确认 session,或 session 已过期,请通过 `ccx gui` 或 macOS 菜单栏 +应用重新打开。绝不要把原始管理员 token 粘贴到 loopback 页面。 + ## 委派选择器与生成路由的区别 Dashboard 的 **Sub-agent delegation** 选择器会保存 `injectionModel`,以及可选的 @@ -119,6 +145,7 @@ GUI 是代理 JSON 管理 API 之上的轻量客户端。常用 endpoint 包括 | `PUT /api/startup-health/companion` | 让已认证的原生伴侣应用刷新仅保存在内存中的短期“登录时启动”观测。该端点需要原始管理令牌,并拒绝浏览器 GUI 会话。 | | `GET` / `POST /api/windows-tray` | 读取或更改 Windows 托盘安装和显示状态;POST 支持 `install`、`start`、`stop`、`uninstall`。 | | `POST /api/sync` | 重建共享模型目录,并把 Codex 模型缓存标记为过期。 | +| `GET /api/codex-catalog/status` · `POST /api/codex-catalog/apply` | 读取 catalog、路由和工作器的激活证据。受保护的 Apply 会协调 pending catalog 或尚未注入的受管理路由,然后可能在目标修订围栏与明确中断确认后,只强制重启已验证的过期工作器。当二者已是 current、只有工作器过期时,这是可能让 ChatGPT 显示 **stopped unexpectedly** 的高级备用方案;浏览器 session 还需要上文所述的一次性启动授权。 | | `GET` / `PUT /api/sidecar-settings` | 读取或设置 search/vision sidecar 模型。 | | `GET` / `PUT /api/injection-model` | 读取或设置委派指引模型/强度、指引开关及 Codex 原生子代理默认值同步开关。 | | `GET` / `PUT /api/v2` | 读取或设置界面模式、Codex feature flag 和 v2 thread 上限。 | diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index 8eebb90db7..309bae6a97 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -37,7 +37,7 @@ ccx v2 on ccx v2 threads 16 ``` -`mode` 子命令会将 `multiAgentMode` 写入 CodexCommander 配置,并重新同步 Codex 目录。模式和标志的切换会在有效的 v1/v2 Codex 键之间迁移当前的数值线程上限;如果切换失败,会恢复原始的 `config.toml`。更改只会应用于新的 Codex 会话,正在运行的会话会保持其已固定的 surface。 +`mode` 子命令会将 `multiAgentMode` 写入 CodexCommander 配置,并重新同步 Codex 目录。模式和标志的切换会在有效的 v1/v2 Codex 键之间迁移当前的数值线程上限;如果切换失败,会恢复原始的 `config.toml`。模式、标志或 thread 的变更会更新 boot config。要影响运行中的 worker,请使用 `ccx sync --restart-codex`(或 dashboard 中的 **Apply agent catalog**),再启动新 task。 ## Combo routing 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 c997282453..8290931b0d 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 @@ -127,6 +127,10 @@ ccx status --json **OAuth 可靠性** 部分会报告凭据存储是否可写、是否能够在 `CODEXCOMMANDER_HOME` 下创建刷新 single-flight/锁文件、不健康的 OAuth 或 Codex 池账户(脱敏 ID)及其恢复 `Action:`,并给出一条静态 OK,说明 Codex 转发路径不会伪造官方客户端元数据。Doctor 绝不会修改凭据或执行修复。 +:::note[升级后一次性重启] +从旧版本持续运行的代理,其受保护运行时记录中可能没有 `attestationSecret`。在使用 CLI 管理命令或启动会携带凭据的 Claude/OpenCode 客户端之前,请将该代理重启一次。在此之前,敏感请求会 fail closed:token 和 request body 绝不会回退发送到仅通过公开 health 信息或配置端口发现的 listener。 +::: + ## 目录同步 ### `ccx sync [--restart-codex]` @@ -135,6 +139,8 @@ ccx status --json 如果仍有长期运行的 Codex `app-server` 进程,`ccx sync` 会警告它们可能继续提供旧的内存模型列表,即使 `codexcommander-catalog.json` / `models_cache.json` 已更新。传入 `--restart-codex` 会仅向当前用户拥有、匹配 `codex … app-server` 和 `codex-code-mode-host` 的进程发送 `SIGTERM`(当前活跃会话可能会被打断)。故意避免使用宽泛的 `pkill -f codex` 匹配。 +普通 `ccx sync` 不会中断工作。同一 app-server 中的新 task 或 fork 不会使其重新加载 catalog。请使用仪表板中的 **Apply agent catalog**、`ccx sync --restart-codex`,或退出并重新打开 Codex Desktop。 + ### `ccx sync-cache [--restart-codex]` 使 Codex 的本地模型选择器缓存失效,让它根据当前激活的 CodexCommander 目录重新生成。与 `ccx sync` 相同的陈旧 `app-server` 警告和可选 `--restart-codex` 行为同样适用。 @@ -199,4 +205,4 @@ ccx codex-shim uninstall ### `ccx gui` -在 `http://localhost:` 打开 [web dashboard](/guides/web-dashboard/),如果代理未运行则会自动启动。 +在 `http://localhost:` 打开 [web dashboard](/guides/web-dashboard/),如果代理未运行则会自动启动。短期、一次性的浏览器启动票据会解锁更改操作,包括确认后的 **Apply agent catalog**。票据只通过 URL fragment 传递,并在交换过程中清除;长期管理员 token 不会进入 URL 或 Web Storage。确认 session 只存在于进程内存中,最长八小时,且不会续期。到期或代理重启后的下一个 API 请求会返回 `401`;请通过 `ccx gui` 或 macOS 菜单栏应用重新打开。手动打开 loopback 页面不会获得 API session,也绝不会请求或发送长期管理员 token。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md index 804f9ad80e..72cfb14f78 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md @@ -9,8 +9,8 @@ description: 多代理界面、委派引导、首选模型、回退链、原生 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | -| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` 会把目录中的每个模型都标记为 v1;`v2` 会把每个模型都标记为 v2。`default` 会恢复上游固定值(Sol/Terra 为 v2,Luna 为 v1),否则遵循原生 `multi_agent_v2` 标志。适用于新会话。 | -| `multiAgentV2MessageDelivery?` | `"encrypted" \| "plaintext"` | `"encrypted"` | V2 父级消息传递策略。`encrypted` 保留 ChatGPT 的预留加密协议;实验性的 `plaintext` 为后续 V2 父级请求启用跨提供方兼容,并使该父级的所有委派消息成为明文。路由父级的消息调用也会获得 Codex 明文标记。更改后请启动新会话。 | +| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` 会把目录中的每个模型都标记为 v1;`v2` 会把每个模型都标记为 v2。`default` 会恢复上游固定值(Sol/Terra 为 v2,Luna 为 v1),否则遵循原生 `multi_agent_v2` 标志。更改后请用 Apply 替换运行中的 worker,再启动新 task。 | +| `multiAgentV2MessageDelivery?` | `"encrypted" \| "plaintext"` | `"encrypted"` | 仅为 V2 task 消息传递的策略,并非凭据加密。`encrypted` 保留 ChatGPT 的预留加密协议;实验性的 `plaintext` 为后续 V2 父级请求启用跨提供方兼容,并使该父级的所有委派消息成为明文。更改后只需启动新 task;不会弄脏 catalog,也不需要 Apply。 | | `subagentModels?` | `string[]` | `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4-mini` | 最多五个裸原生 id、账户限定的 `/` id 或路由 `provider/model` id 会优先公开在子代理选择器中。仪表盘会保留已配置的精确 selector(包括账户限定选项),并报告哪些已保存条目实际被公开或排除。对于当前目录中不存在的选项,请使用 `ccx agent subagents set` 或直接编辑配置。显式空列表会被保留。 | | `injectionModel?` | `string` | — | 在代理生成的 v2 委派引导中使用的首选原生或路由后的子代理模型。 | | `injectionEffort?` | `string` | — | 首选 effort(`low` 到 `ultra`),只有在 `injectionModel` 存在时才有意义。 | @@ -22,7 +22,7 @@ description: 多代理界面、委派引导、首选模型、回退链、原生 | `effortCap?` | `string` | — | 对符合条件的 v2 主轮次和标记的派生子轮次设置硬上限。接受 `low` 到 `ultra`。 | | `subagentEffortCap?` | `string` | — | 仅针对派生子轮次的额外上限。两个上限同时适用时,较低者生效。 | -通过仪表板或 `ccx v2 status|on|off|mode |threads ` 管理该界面。模式变更会应用于新会话。`maxConcurrentThreadsPerSession` 是 `PUT /api/v2` 字段,不是 `config.json` 键;`ccx v2 threads ` 会在启用 v2 后,将 `max_concurrent_threads_per_session` 写入 Codex 的 `$CODEX_HOME/config.toml` 中的 `[features.multi_agent_v2]` 下。 +通过仪表板或 `ccx v2 status|on|off|mode |threads ` 管理该界面。模式、协议或 thread 的变更会更新 boot config;对于已运行的 worker,先 Apply,再启动新 task。`maxConcurrentThreadsPerSession` 是 `PUT /api/v2` 字段,不是 `config.json` 键;`ccx v2 threads ` 会在启用 v2 后,将 `max_concurrent_threads_per_session` 写入 Codex 的 `$CODEX_HOME/config.toml` 中的 `[features.multi_agent_v2]` 下。 管理 API 公开 `GET`/`PUT /api/v2`、`/api/injection-model`、`/api/effort-caps`、`/api/subagent-models` 和 `/api/subagent-model-fallback`。injection-model 更新是部分更新;自定义 prompt 是该 API 上的 `prompt` 字段。 diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index 91be044aa5..8f5e911ad3 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -16,7 +16,7 @@ Management API 有自己独立的管理员凭证,与数据平面 API 密钥无 只有在其目录和文件权限或 ACL 已加固后,才会接受基于文件的令牌。如果无法保证这一点,管理身份验证将以拒绝式失败结束,API 会返回 503,直到提供环境变量令牌或修复文件状态为止。 -管理员令牌可用以下任一形式发送: +Headless API client 可通过受信任 transport 用以下任一形式发送管理员 token: ```http X-CodexCommander-API-Key: @@ -27,14 +27,18 @@ Authorization: Bearer ``` :::caution -管理员令牌必须与所有数据平面凭证都不同。启动时会拒绝与代理准入密钥冲突的管理凭证。不要把管理员令牌放进 Codex、Claude Code 或其他模型客户端;它授权的是控制平面变更。 +管理员 token 必须与所有数据平面凭证都不同。启动时会拒绝与代理准入密钥冲突的管理凭证。不要把管理员 token 放进 Codex、Claude Code 或其他模型客户端;它授权的是控制平面变更。浏览器只能在受信任的 non-loopback HTTPS origin 上请求它。绝不要从明文远程页面粘贴或发送它。 ::: -### 回环仪表板会话 +### 仪表盘启动会话 -在回环绑定上,仪表板引导可以接收一个短期的 `ccx_session_*` 凭证。每个会话持续五分钟,并绑定到精确的仪表板来源。安全请求必须匹配该来源。非安全方法还要求浏览器的 `Origin` 和该会话的 CSRF 令牌。 +手动打开的 loopback 仪表盘不会获得 API 凭证。静态页面框架可以加载,但在通过 `ccx gui` 或 macOS 菜单栏应用重新打开之前,每个 `/api/*` 请求都会返回 `401`。任何 loopback hostname/address 都不会请求或发送长期管理员 token。浏览器 origin 无法证明哪个本地 OS 用户拥有 listener,因此 loopback 既不是经过身份验证的 listener identity,也不能绕过身份验证。 -当需要数据平面身份验证时,会禁用会话签发,这也包括远程绑定。远程操作员必须使用原始管理员令牌进行身份验证;不会签发类似回环的 GUI 会话。 +launcher 使用原始管理员凭证签发一个绑定到请求 route 和 origin 的短期、一次性票据。票据只通过 URL fragment 传递,并在一次性交换过程中立即清除。得到的确认 GUI session 功能完整,只存在于进程内存中,最长八小时。它不会续期:到期或代理重启后的下一个 API 请求会返回 `401`,之后需要再次使用本地 launcher 流程。长期管理员 token 不会进入 URL 或 Web Storage。 + +原始管理员 token 仍可执行普通 API 更改。catalog Apply 会更严格:`POST /api/codex-catalog/apply` 只接受确认 GUI session。脚本请使用 `ccx sync --restart-codex`。 + +远程操作员浏览器只能通过受信任的 HTTPS 使用原始管理员 token 进行身份验证;明文远程页面绝不会请求或发送它。若没有受信任的 HTTPS,请使用呈现 loopback 的本地或 SSH tunnel,并通过 `ccx gui` 打开仪表盘。Headless API client 仍可在受信任 transport 上使用 raw-admin 身份验证。确认的浏览器 session 只通过绑定到精确 origin 和 route 的本地启动票据交换来签发。 ## 常见错误 @@ -56,10 +60,10 @@ Authorization: Bearer | 方法和路径 | 用途 | 典型错误 | | --- | --- | --- | -| `GET, PUT /api/v2` | 读取或更改代理协议、V2 消息传递和线程设置。`multiAgentV2MessageDelivery` 接受 `plaintext` 或默认的 `encrypted`;发送 `encrypted` 或 `null` 会移除显式明文覆盖。更改传递后请启动新会话。`maxConcurrentThreadsPerSession: null` 恢复 Codex 默认值 | 400 无效设置;502 过渡或持久化失败 | +| `GET, PUT /api/v2` | 读取或更改代理协议、V2 task 消息传递和线程设置。更改模式/协议/thread 时,先用 Apply 替换运行中的 worker,再启动新 task。更改传递只需新 task,不会弄脏 catalog。`maxConcurrentThreadsPerSession: null` 恢复 Codex 默认值 | 400 无效设置;502 过渡或持久化失败 | | `GET, PUT /api/injection-model` | 读取或设置首选指导模型、努力程度、提示词和指导设置;未启用原生默认值同步时仅供指导 | 400 无效模型、努力程度或请求体 | | `GET, PUT /api/effort-caps` | 读取或设置全局和子代理推理努力上限 | 400 无效的阶梯值 | -| `GET, PUT /api/subagent-models` | 读取或排序最多五个 `spawn_agent` 快速选择;不会强制路由。响应会区分已保存的 `chosen` 列表与实际生效的 `advertised` 列表,并在 `excluded` 中报告未生效的选择 | 400 无效列表或超过五个模型 | +| `GET, PUT /api/subagent-models` | 读取或排序最多五个 `spawn_agent` 快速选择;不会强制路由。响应会区分已保存的 `chosen` 列表与实际生效的 `advertised` 列表,在 `excluded` 中报告未生效的选择,并附加 `activation` 状态 | 400 无效列表或超过五个模型 | | `GET, PUT /api/subagent-model-fallback` | 读取或设置已生成子任务的全局回退顺序和轮询间隔 | 400 无效列表或轮询间隔 | | `GET /api/grok` | 读取 Grok 托管配置状态和候选模型 | 400 状态读取失败 | | `PUT /api/grok/selection` | 持久化被排除的 Grok 模型 | 400 选择无效或超出大小限制 | @@ -93,7 +97,9 @@ Authorization: Bearer | `POST /api/startup-action` | 安装或修复服务或 Codex shim | 400 无效动作;500 动作失败 | | `GET, POST /api/windows-tray` | 读取 Windows 托盘状态,或安装、启动、停止、卸载它 | 400 不支持的平台/动作;500 操作失败 | | `GET /api/diagnostics/project-config` | 读取缓存的项目配置警告 | — | -| `POST /api/sync` | 将当前模型目录同步到 Codex,并返回 `catalogQuality`、`rehydrated`、Codex app-server `catalogState` 和所需的重启提示 | 409 写入权限被拒绝;500 同步失败 | +| `POST /api/sync` | 在不中断 worker 的情况下将当前模型目录同步到 Codex,并返回 `activation` 状态 | 409 写入权限被拒绝;500 同步失败 | +| `GET /api/codex-catalog/status` | 读取已保存配置、磁盘 catalog 和当前 worker 的加载状态 | — | +| `POST /api/codex-catalog/apply` | 使用 `{ "expectedDesiredRevision": "…", "confirmInterrupt": true }` 显式应用到已确认的过时 worker;只接受一次性启动交接创建的确认 GUI 会话 | 400 无效请求体;403 需要确认的仪表盘启动;409 冲突/未知身份;503 busy | | `GET, PUT /api/sidecar-settings` | 读取或更新 web 搜索和 vision sidecar 的模型/后端设置 | 400 结构、后端或限制无效 | | `GET, PUT /api/shadow-call-settings` | 读取或更新 shadow-call 拦截设置 | 400 结构或值无效 | diff --git a/gui/src/App.tsx b/gui/src/App.tsx index d95973551c..65667841b8 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -16,8 +16,15 @@ import ErrorBoundary from "./components/ErrorBoundary"; import { SidebarGithubRow } from "./components/sidebar-github-row"; import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconKey, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconX, IconRoute, IconTerminal } from "./icons"; import { useI18n, useT, LOCALES, type Locale, type TKey } from "./i18n/shared"; -import { Select } from "./ui"; -import { installApiAuthFetch } from "./api"; +import { Notice, Select } from "./ui"; +import { + installApiAuthFetch, + isBrowserLoopbackHostname, + isGuiMutationAuthorized, + isRawAdminPromptAllowed, + subscribeGuiLaunchCapability, + whenGuiLaunchCapabilitySettles, +} from "./api"; import { resolvedNavigationHash, type Page } from "./app-routing"; import { useAppRouteState } from "./use-app-route-state"; import { requestProxyStop } from "./stop-proxy"; @@ -126,6 +133,14 @@ export default function App() { const [theme, setTheme] = useState(readStoredTheme); const { locale, setLocale } = useI18n(); const t = useT(); + const [mutationAuthorized, setMutationAuthorized] = useState(null); + + useEffect(() => { + const update = () => setMutationAuthorized(isGuiMutationAuthorized()); + const unsubscribe = subscribeGuiLaunchCapability(update); + void whenGuiLaunchCapabilitySettles().then(update); + return unsubscribe; + }, []); // Narrow screens: the sidebar becomes an off-canvas drawer behind a hamburger toggle. const [navOpen, setNavOpen] = useState(false); @@ -302,6 +317,13 @@ export default function App() {
+ {mutationAuthorized === false && ( + {t(isRawAdminPromptAllowed() + ? "app.adminRequiredDashboard" + : isBrowserLoopbackHostname(window.location.hostname) + ? "app.launchRequiredDashboard" + : "app.secureOriginRequiredDashboard")} + )} | null = null; -/** Unwrapped fetch captured at install time — used for session re-bootstrap so the - * bootstrap document request itself never enters the 401 handling path. */ +/** Unwrapped fetch captured at install time for the one-time launch exchange and + * raw-admin verification, neither of which may enter the global 401 retry path. */ let rawFetch: typeof fetch | null = null; /** * After the user cancels (or submits blank) once, suppress further prompts for this page @@ -16,20 +16,50 @@ let promptCancelled = false; type AdminTokenPrompt = (verifyToken: AdminTokenVerifier) => Promise; let requestAdminToken: AdminTokenPrompt = promptForAdminToken; -/** - * Document path re-fetched to mint a fresh loopback GUI session (server injects meta tags). - * Deliberately NOT "/": the Vite dev server owns that route for the app shell, so the dev - * proxy forwards this dedicated extensionless path to the backend with the original host. - */ -const SESSION_REBOOTSTRAP_PATH = "/codexcommander-session"; -const SESSION_META_NAMES = { - token: "codexcommander-session-token", - csrf: "codexcommander-session-csrf", - origin: "codexcommander-session-origin", -} as const; +const GUI_LAUNCH_EXCHANGE_PATH = "/api/gui-launch-exchange"; +const GUI_LAUNCH_TICKET_PARAM = "ccx-launch-ticket"; +const GUI_LAUNCH_ROUTE_PARAM = "ccx-route"; /** Safe authenticated read used to validate a raw admin token before closing the sign-in form. */ const ADMIN_TOKEN_VALIDATION_PATH = "/api/settings"; +/** + * Loopback is not an authenticated browser origin: another local OS user can + * bind the expected port and serve a convincing page while the real proxy is + * stopped. Never release the durable admin token to such a page. Remote + * operator deployments may keep the explicit token prompt because their + * origin (normally TLS) is the listener-authentication boundary. + */ +export function isBrowserLoopbackHostname(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase().replace(/\.$/, "").replace(/^\[|\]$/g, ""); + if (normalized === "" || normalized === "localhost" || normalized.endsWith(".localhost")) return true; + if (normalized === "::1") return true; + + const octets = normalized.split("."); + if (octets.length === 4 + && octets.every(octet => /^\d{1,3}$/.test(octet) && Number(octet) <= 255) + && Number(octets[0]) === 127) return true; + + // URL parsers may retain dotted IPv4 or canonicalize it to the final two + // hextets. Both forms below cover IPv4-mapped 127/8 loopback addresses. + const mappedPrefix = normalized.startsWith("::ffff:") + ? "::ffff:" + : normalized.startsWith("0:0:0:0:0:ffff:") + ? "0:0:0:0:0:ffff:" + : null; + if (mappedPrefix) { + const mapped = normalized.slice(mappedPrefix.length); + if (mapped.startsWith("127.")) return true; + const firstMappedHextet = mapped.split(":", 1)[0]; + return /^7f[0-9a-f]{2}$/.test(firstMappedHextet ?? ""); + } + return false; +} + +export function isRawAdminPromptAllowed(): boolean { + return window.location.protocol === "https:" + && !isBrowserLoopbackHostname(window.location.hostname); +} + function needsApiAuth(input: RequestInfo | URL): boolean { try { const raw = input instanceof Request ? input.url : String(input); @@ -46,6 +76,22 @@ function needsApiAuth(input: RequestInfo | URL): boolean { let memoryToken: string | null = null; let memoryCsrfToken: string | null = null; let memorySessionOrigin: string | null = null; +let memoryConfirmedGuiLaunch = false; +let memoryAdminCredential = false; +let guiLaunchCapabilityReady: Promise = Promise.resolve(false); +const guiLaunchCapabilityListeners = new Set<() => void>(); + +function setConfirmedGuiLaunch(confirmed: boolean): void { + if (memoryConfirmedGuiLaunch === confirmed) return; + memoryConfirmedGuiLaunch = confirmed; + for (const listener of guiLaunchCapabilityListeners) listener(); +} + +function setAdminCredential(admin: boolean): void { + if (memoryAdminCredential === admin) return; + memoryAdminCredential = admin; + for (const listener of guiLaunchCapabilityListeners) listener(); +} function readToken(): string | null { return memoryToken; @@ -53,26 +99,16 @@ function readToken(): string | null { function storeToken(token: string): void { memoryToken = token; + setConfirmedGuiLaunch(false); + setAdminCredential(true); } function clearToken(): void { memoryToken = null; memoryCsrfToken = null; memorySessionOrigin = null; -} - -function takeMetaContent(name: string): string | null { - const element = document.querySelector(`meta[name="${name}"]`) as HTMLMetaElement | null; - const content = element?.content.trim() || null; - element?.remove(); - return content; -} - -function loadInjectedSession(): void { - const token = takeMetaContent(SESSION_META_NAMES.token); - const csrfToken = takeMetaContent(SESSION_META_NAMES.csrf); - const origin = takeMetaContent(SESSION_META_NAMES.origin); - storeSession(token, csrfToken, origin); + setConfirmedGuiLaunch(false); + setAdminCredential(false); } /** Clear memory only when it still holds `expected` (avoid wiping a newer concurrent store). */ @@ -81,48 +117,77 @@ function clearTokenIfCurrent(expected: string | null): void { } /** Validate and store a server-minted GUI session; rejects anything bound to another origin. */ -function storeSession(token: string | null, csrfToken: string | null, origin: string | null): boolean { +function storeSession( + token: string | null, + csrfToken: string | null, + origin: string | null, + confirmedLaunch = false, +): boolean { if (!token?.startsWith("ccx_session_") || !csrfToken || origin !== window.location.origin) return false; memoryToken = token; memoryCsrfToken = csrfToken; memorySessionOrigin = origin; + setAdminCredential(false); + setConfirmedGuiLaunch(confirmedLaunch); return true; } -/** Read one named meta tag out of a served HTML document (attribute order varies). */ -function metaContentFromHtml(html: string, name: string): string | null { - for (const tag of html.match(/]*>/gi) ?? []) { - const nameMatch = tag.match(/\bname="([^"]+)"/i); - if (nameMatch?.[1] !== name) continue; - const contentMatch = tag.match(/\bcontent="([^"]*)"/i); - return contentMatch?.[1]?.trim() || null; - } - return null; +function isGuiLaunchRoute(route: string | null): route is string { + return route !== null + && route.length > 0 + && route.length <= 512 + && !route.startsWith("/") + && !route.includes("#") + && !/[\u0000-\u001f\u007f]/.test(route); } /** - * Silently renew the GUI session from a freshly served document. Loopback servers mint - * short-lived sessions into the HTML on every page load, so an expired session (5-minute - * TTL) or one invalidated by a proxy restart is replaced without ever asking the user for - * a token. Returns null when the server refuses to mint sessions (non-loopback operator - * dashboards), where the manual admin-token prompt remains the fallback. + * Read the process-local handoff out of the fragment, then scrub it before any + * network request or React render. Ordinary application hashes are untouched. */ -async function reBootstrapSessionToken(): Promise { - if (!rawFetch) return null; +function takeGuiLaunchFragment(): { ticket: string; route: string } | null { + const raw = window.location.hash.startsWith("#") ? window.location.hash.slice(1) : ""; + const params = new URLSearchParams(raw); + if (!params.has(GUI_LAUNCH_TICKET_PARAM)) return null; + const ticket = params.get(GUI_LAUNCH_TICKET_PARAM); + const route = params.get(GUI_LAUNCH_ROUTE_PARAM); + const validRoute = isGuiLaunchRoute(route); + const replacement = `${window.location.pathname}${window.location.search}${validRoute ? `#${route}` : ""}`; + window.history.replaceState(window.history.state, "", replacement); + return ticket?.startsWith("ccx_launch_") && validRoute ? { ticket, route } : null; +} + +async function exchangeGuiLaunchFragment( + launch: { ticket: string; route: string } | null, +): Promise { + if (!launch || !rawFetch) return false; try { - const response = await rawFetch(SESSION_REBOOTSTRAP_PATH, { cache: "no-store" }); - if (!response.ok) return null; - const html = await response.text(); - const stored = storeSession( - metaContentFromHtml(html, SESSION_META_NAMES.token), - metaContentFromHtml(html, SESSION_META_NAMES.csrf), - metaContentFromHtml(html, SESSION_META_NAMES.origin), - ); - return stored ? readToken() : null; + const response = await rawFetch(GUI_LAUNCH_EXCHANGE_PATH, { + method: "POST", + cache: "no-store", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(launch), + }); + if (!response.ok) return false; + const value: unknown = await response.json(); + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const envelope = value as Record; + const session = envelope.session; + if (session === null || typeof session !== "object" || Array.isArray(session)) return false; + const record = session as Record; + const stored = envelope.route === launch.route + && record.confirmedLaunch === true + && storeSession( + typeof record.token === "string" ? record.token : null, + typeof record.csrfToken === "string" ? record.csrfToken : null, + typeof record.origin === "string" ? record.origin : null, + true, + ); + return stored; } catch { - return null; + return false; } } @@ -163,14 +228,19 @@ async function resolveTokenAfter401(failedToken: string | null): Promise { if (promptCancelled) return null; const current = readToken(); if (current && current !== failedToken) return current; - const renewed = await reBootstrapSessionToken(); - if (renewed) return renewed; - const prompted = await requestAdminToken(verifyAdminToken); if (prompted) { storeToken(prompted); @@ -188,12 +258,17 @@ async function resolveTokenAfter401(failedToken: string | null): Promise { if (!needsApiAuth(input)) return originalFetch(input, init); + // A launcher-confirmed page must finish its one-time exchange before the + // dashboard fan-out attempts any authenticated management request. + await guiLaunchCapabilityReady; + const token = readToken(); const [firstInput, firstInit] = token ? withToken(input, init, token) : [input, init]; const response = await originalFetch(firstInput, firstInit); @@ -220,12 +295,40 @@ export function installApiAuthFetch(): void { }; } +export function isConfirmedGuiLaunch(): boolean { + return memoryConfirmedGuiLaunch; +} + +export function isGuiMutationAuthorized(): boolean { + return memoryConfirmedGuiLaunch || memoryAdminCredential; +} + +export function whenGuiLaunchCapabilitySettles(): Promise { + return guiLaunchCapabilityReady; +} + +export function subscribeGuiLaunchCapability(listener: () => void): () => void { + guiLaunchCapabilityListeners.add(listener); + return () => guiLaunchCapabilityListeners.delete(listener); +} + +/** Test-only capability seam for component tests that do not install App auth. */ +export function setConfirmedGuiLaunchForTests(confirmed: boolean): void { + memoryAdminCredential = false; + setConfirmedGuiLaunch(confirmed); + guiLaunchCapabilityReady = Promise.resolve(confirmed); +} + /** Test-only: allow a fresh `installApiAuthFetch()` in the same module instance. */ export function resetApiAuthFetchForTests(adminTokenPrompt: AdminTokenPrompt = promptForAdminToken): void { installed = false; memoryToken = null; memoryCsrfToken = null; memorySessionOrigin = null; + memoryConfirmedGuiLaunch = false; + memoryAdminCredential = false; + guiLaunchCapabilityReady = Promise.resolve(false); + guiLaunchCapabilityListeners.clear(); resolutionInFlight = null; rawFetch = null; promptCancelled = false; diff --git a/gui/src/components/subagents-workspace/SubagentRunPolicySection.tsx b/gui/src/components/subagents-workspace/SubagentRunPolicySection.tsx index 01fdfc479e..a67c71ae3d 100644 --- a/gui/src/components/subagents-workspace/SubagentRunPolicySection.tsx +++ b/gui/src/components/subagents-workspace/SubagentRunPolicySection.tsx @@ -166,7 +166,7 @@ export default function SubagentRunPolicySection({ {(policy.error || delegation.error) && {policy.error || delegation.error}} {feedback === "saved" && {t("sub.policy.saved")}} {feedback === "failed" && !policy.error && !delegation.error && {t("sub.policy.saveFailed")}} - {(policy.mode === "v2" || (policy.mode === "default" && policy.messageDelivery === "plaintext")) && {t( + {policy.mode !== "v1" && {t( policy.messageDelivery === "plaintext" ? "sub.policy.compatibilityV2Plaintext" : "sub.policy.compatibilityV2", diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index d4d35564af..0cd82383f7 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -427,9 +427,9 @@ export const de: Record = { "models.currentBehavior": "Aktuelles Verhalten", "models.collaborationTitle": "Zusammenarbeit", "models.change": "Ändern", - "models.newSessionsOnly": "Nur neue Sitzungen", - "models.modeLabel_v1": "Klassisch v1", - "models.modeLabel_default": "Codex-Standards folgen", + "models.newSessionsOnly": "Speichern, anwenden, dann neue Aufgabe starten", + "models.modeLabel_v1": "Zuverlässiges v1", + "models.modeLabel_default": "Codex-nativ", "models.modeLabel_v2": "Parallel v2", "models.modeStatus_v1": "Flexible Modellauswahl", "models.modeStatus_default": "Codex-Standards", @@ -497,10 +497,10 @@ export const de: Record = { "models.v2ModeDesc_v1": "Alle Modelle → v1-Oberfläche", "models.v2ModeDesc_default": "Upstream-Standard (sol/terra=v2, luna=v1)", "models.v2ModeDesc_v2": "Alle Modelle → v2-Oberfläche", - "models.v2Help": "Steuert die Multi-Agent-Oberfläche für alle Modelle.\n\nv1: Klassischer Single-Thread-Agent. Jedes Modell nutzt die v1-Collab-Oberfläche.\nbase: Upstream-Standard — sol/terra nutzen v2, luna v1, andere folgen dem Codex-Feature-Flag.\nv2: Multi-Thread-Agent mit spawn_agent. Jedes Modell nutzt die v2-Collab-Oberfläche.\n\nÄnderungen gelten für neue Sitzungen.", + "models.v2Help": "Steuert die Multi-Agent-Oberfläche für alle Modelle.\n\nv1: Klassischer Single-Thread-Agent. Jedes Modell nutzt die v1-Collab-Oberfläche.\nbase: Upstream-Standard — sol/terra nutzen v2, luna v1, andere folgen dem Codex-Feature-Flag.\nv2: Multi-Thread-Agent mit spawn_agent. Jedes Modell nutzt die v2-Collab-Oberfläche.\n\nZuerst speichern. Läuft ein Codex-Worker, mit Anwenden ersetzen und danach für sitzungsgebundene Tool-Schemas eine neue Aufgabe starten. Eine neue Aufgabe allein lädt keinen bestehenden Worker neu.", "dash.multiAgent": "Sub-Agent", "models.v2Conflict": "[agents] max_threads ist gesetzt — codex verweigert den Start; entferne es aus config.toml", - "models.v2Applied": "Sub-Agent-Modus aktualisiert — gilt für neue Sitzungen (Codex-App neu starten, um die Auswahl zu aktualisieren)", + "models.v2Applied": "Sub-Agent-Modus gespeichert. Laufenden Worker mit Anwenden ersetzen, dann für sitzungsgebundene Änderungen eine neue Aufgabe starten.", "models.v2ThreadsLabel": "Max. Threads", "models.v2ThreadsDefault": "Standard (4)", "models.v2ThreadsApplied": "Thread-Limit aktualisiert — gilt für neue Sitzungen", @@ -513,7 +513,7 @@ export const de: Record = { "models.collapseAll": "Alle einklappen", "models.expandAll": "Alle ausklappen", "models.orderHint": "Reihenfolge in der Modellauswahl: Subagents-Auswahl (in der festgelegten Reihenfolge) → übrige geroutete Modelle alphabetisch nach Anbieter, dann Modell-ID → native Modelle. Sichtbarkeitsschalter filtern nur; sie ändern diese Reihenfolge nicht.", - "models.catalogBehaviorHint": "Ausgeblendete Modelle verschwinden aus Katalog und Auswahl, bleiben aber über ihre exakte ID aufrufbar. Änderungen gelten ab der nächsten Codex-Runde; ein Neustart ist nicht nötig.", + "models.catalogBehaviorHint": "Ausgeblendete Modelle verschwinden aus Katalog und Auswahl, bleiben aber über ihre exakte ID aufrufbar. Katalogänderungen speichern, bei laufendem Worker anwenden und dann für sitzungsgebundene Tool-Schemas eine neue Aufgabe starten. Eine neue Aufgabe allein lädt keinen bestehenden Worker neu.", "models.custom": "Benutzerdefiniert…", "models.customApply": "Anwenden", "models.customPlaceholder": "Tokens (z. B. 420000)", @@ -544,7 +544,7 @@ export const de: Record = { "models.tipStatus": "Status", "models.tipActive": "Aktiv", "models.tipDisabled": "Deaktiviert", - "models.applied": "Angewendet — greift bei der nächsten Codex-Runde.", + "models.applied": "Gespeichert. Laufenden Worker mit Anwenden ersetzen und dann für sitzungsgebundene Änderungen eine neue Aufgabe starten.", "models.saveFailed": "Speichern fehlgeschlagen", "models.networkError": "Netzwerkfehler — läuft der Proxy?", "models.loadFail": "Modelle konnten nicht geladen werden — läuft der Proxy?", @@ -578,9 +578,13 @@ export const de: Record = { "sub.settings": "Ausführungsrichtlinie", "sub.delegation.model": "Bevorzugtes Anleitungsmodell", "sub.delegation.modelHint": "Das Modell, das CodexCommander in der Anleitung zuerst nennt. Der Roster bleibt für explizite Overrides verfügbar.", - "sub.saved": "{n} Schnellauswahl-Einträge gespeichert. Starte eine neue Codex-Sitzung oder führe {cmd} aus, um bestehende Sitzungen zu aktualisieren.", - "sub.savedExcluded": "{n} Schnellauswahl-Einträge gespeichert, aber {missing} werden derzeit nicht für V2-Worker angeboten.", - "sub.savedRefreshFailed": "{n} Schnellauswahl-Einträge auf der Festplatte gespeichert, aber der laufende Katalog wurde nicht sauber aktualisiert. Führe {cmd} aus, bevor du dich auf die neue Reihenfolge verlässt.", + "sub.saved": "{n} Schnellauswahl-Einträge gespeichert. Datenträger und generierter Katalog sind aktuell; kein Codex-Worker wurde neu gestartet.", + "sub.savedExcluded": "{n} Schnellauswahl-Einträge gespeichert, aber {missing} werden auf der gewählten Agentenoberfläche derzeit nicht angeboten.", + "sub.savedRefreshFailed": "{n} Schnellauswahl-Einträge auf der Festplatte gespeichert, aber der Katalog konnte nicht sauber aktualisiert werden. Bestehende Worker wurden nicht neu gestartet.", + "sub.savedSuperseded": "Dieser Speichervorgang wurde durch eine neuere Änderung ersetzt. Die zuletzt gespeicherte Auswahl wird angezeigt.", + "sub.savedIntegrationDisabled": "{n} Schnellauswahl-Einträge in CodexCommander gespeichert. Die Codex-Integration ist deaktiviert; Codex-Routing und Katalogdateien blieben unverändert.", + "sub.savedNeedsApply": "{n} Schnellauswahl-Einträge in CodexCommander gespeichert. Codex verwendet noch natives Routing. Wähle „Auf Codex anwenden“, sobald du es verbinden und diese Auswahl veröffentlichen möchtest.", + "sub.savedRoutingPreserved": "{n} Schnellauswahl-Einträge in CodexCommander gespeichert. Bestehendes Codex-Routing und Katalogdateien wurden beibehalten, da CodexCommander nicht bestätigen konnte, dass es das aktuelle Routing verwaltet.", "sub.saveFailed": "Speichern fehlgeschlagen", "sub.networkError": "Netzwerkfehler — läuft der Proxy?", "sub.loadFail": "Modelle konnten nicht geladen werden — läuft der Proxy?", @@ -588,6 +592,21 @@ export const de: Record = { "sub.metadataLimited": "Der Kader ist verfügbar, aber die Details zu Modellfähigkeiten konnten nicht geladen werden. Filter und Badges können eingeschränkt sein.", "sub.loading": "Lädt…", "sub.saving": "Speichert…", + "sub.applying": "Wird angewendet…", + "sub.applyActivityFailed": "Aktive Codex-Arbeit konnte nicht geprüft werden", + "sub.applyFailed": "CodexCommander-Routing und Katalog konnten nicht auf Codex angewendet werden", + "sub.apply.dialogTitle": "CodexCommander auf Codex anwenden?", + "sub.apply.dialogIdle": "Dies gleicht das Codex-Routing und den gespeicherten Agentenkatalog ab und ersetzt bei Bedarf nur verifizierte veraltete Hintergrundworker. Keine aktive Proxy-Arbeit erkannt.", + "sub.apply.dialogActive": "CodexCommander hat aktive Proxy-Arbeit erkannt. Anwenden gleicht Routing und Agentenkatalog ab und kann einen verifizierten veralteten Hintergrundworker ersetzen und aktuelle Arbeit unterbrechen.", + "sub.apply.dialogUnknown": "CodexCommander konnte aktive Proxy-Arbeit nicht prüfen. Anwenden gleicht Routing und Agentenkatalog ab und kann nur einen verifizierten veralteten Hintergrundworker ersetzen.", + "sub.apply.confirm": "Auf Codex anwenden", + "sub.apply.applied": "CodexCommander-Routing und Katalog auf Codex angewendet. Neue oder Ersatz-Worker verwenden den gespeicherten Kader.", + "sub.apply.alreadyCurrent": "Codex-Routing und Worker verwenden bereits die gespeicherte Konfiguration.", + "sub.apply.noWorkers": "Codex-Routing und Katalog sind aktuell. Kein Hintergrundworker musste ersetzt werden.", + "sub.apply.partial": "Einige verifizierte veraltete Codex-Worker wurden ersetzt; Status aktualisieren, bevor erneut angewendet wird.", + "sub.apply.superseded": "Die gespeicherte Konfiguration wurde vor Abschluss geändert. Aktuellen Status prüfen und erneut versuchen.", + "sub.apply.blocked": "CodexCommander konnte Routing und Katalog nicht sicher abgleichen oder die Worker prüfen. Nichts wurde unterbrochen.", + "sub.apply.completed": "Anwendung abgeschlossen. Status aktualisieren, um Codex-Routing und Worker-Aktivierung zu bestätigen.", "sub.saveRoster": "Kader speichern", "sub.moveUp": "{m} nach oben", "sub.moveDown": "{m} nach unten", @@ -607,24 +626,24 @@ export const de: Record = { "sub.policy.messageDelivery_encrypted": "Verschlüsselt (nativ)", "sub.policy.messageDelivery_plaintext": "Klartext-Kompatibilität", "sub.policy.messageDeliveryHint_encrypted": "Behält ChatGPTs nativen verschlüsselten Vertrag bei; externe V2-Worker können nicht verfügbar sein.", - "sub.policy.messageDeliveryHint_plaintext": "Experimentell. Aktiviert V2 über mehrere Anbieter; jede V2-Worker-Nachricht dieses Elternagenten ist Klartext. Nach einer Änderung eine neue Sitzung starten.", + "sub.policy.messageDeliveryHint_plaintext": "Experimentell. Aktiviert V2 über mehrere Anbieter; die V2-Aufgabennachrichtenzustellung dieses Elternagenten ist Klartext. Speichern und dann eine neue Aufgabe starten. Für diese reine Zustellungsänderung ist kein Anwenden nötig.", "sub.policy.preferred": "Bevorzugtes Anleitungsmodell", "sub.policy.noPreferred": "Kein bevorzugtes Modell — Codex wählt aus dem Roster", "sub.policy.fallback": "Globaler Kind-Fallback", "sub.policy.fallbackHint": "Für gestartete Kindläufe nach dem angeforderten Modell und einem Rollen-Fallback; nicht verfügbare oder kontingentierte Kandidaten werden übersprungen.", "sub.policy.noFallback": "Kein Fallback", "sub.policy.concurrency": "Thread-Limit", - "sub.policy.concurrencyHint": "V2 zählt Threads inklusive Root, V1 zählt Kind-Threads. Leer setzt den Codex-Standard wieder her. Gilt für neue Sitzungen.", + "sub.policy.concurrencyHint": "V2 zählt Threads inklusive Root, V1 zählt Kind-Threads. Leer setzt den Codex-Standard wieder her. Speichern, bei laufendem Worker anwenden und dann eine neue Aufgabe starten.", "sub.policy.codexDefault": "Codex-Standard", "sub.policy.increaseConcurrency": "Subagent-Parallelität erhöhen", "sub.policy.decreaseConcurrency": "Subagent-Parallelität verringern", "sub.policy.save": "Änderungen speichern", - "sub.policy.saved": "Ausführungsrichtlinie gespeichert. Protokoll und Thread-Limit gelten für neue Sitzungen; V2-Zustellung wirkt auf folgende Anfragen, daher eine neue Sitzung starten; Anleitung und Fallback gelten für künftige Kindläufe.", + "sub.policy.saved": "Ausführungsrichtlinie gespeichert. Laufenden Worker mit Anwenden ersetzen und dann für Protokoll, Thread-Limit und sitzungsgebundene Tool-Schemas eine neue Aufgabe starten. V2-Aufgabennachrichtenzustellung wirkt auf spätere Anfragen; Anleitung und Fallback auf künftige Kindläufe.", "sub.policy.saveFailed": "Einige Richtlinien-Änderungen konnten nicht gespeichert werden. Deine ungespeicherten Auswahlen werden weiterhin angezeigt.", "sub.policy.loading": "Ausführungsrichtlinie wird geladen…", "sub.policy.retry": "Richtlinie neu laden", "sub.policy.details": "Fallback-Kette und Schutzmaßnahmen", - "sub.policy.timing": "Protokoll, V2-Nachrichtenzustellung und Thread-Limit gelten für neue Sitzungen; Anleitung, Fallback und Effort gelten für nachfolgende geeignete Anfragen.", + "sub.policy.timing": "Zuerst speichern. Anwenden ersetzt einen laufenden Worker; danach erhält eine neue Aufgabe Protokoll, Thread-Limit und sitzungsgebundene Tool-Schemas. V2-Aufgabennachrichtenzustellung, Anleitung, Fallback und Effort wirken auf spätere geeignete Anfragen.", "sub.policy.fallbackChain": "Geordnete Fallback-Kette", "sub.policy.fallbackChainHint": "Bei gestarteten Kindläufen folgt diese globale Liste auf angefordertes Modell und Rollen-Fallback. Leer lassen, um die globale Liste zu deaktivieren.", "sub.policy.addFallback": "Fallback hinzufügen", @@ -638,8 +657,8 @@ export const de: Record = { "sub.policy.preferredEffortHint": "Reasoning-Stufe, die in der Anleitung genannt wird, wenn das bevorzugte Modell verfügbar ist.", "sub.policy.guidance": "Roster als Worker-Anleitung verwenden", "sub.policy.guidanceHint": "Nennt geeignete Roster-Modelle in der Anleitung. Erzwingt keine Delegierung und routet nicht jeden Kindlauf.", - "sub.policy.compatibilityV2": "Externe Anbieter können native verschlüsselte V2-Aufgaben nicht lesen (#92). Automatische Anleitung filtert bekannte inkompatible Worker; explizite Overrides schlagen sicher fehl. Für V2 über mehrere Anbieter Klartext-Kompatibilität wählen oder Classic v1 als etablierten Pfad nutzen.", - "sub.policy.compatibilityV2Plaintext": "Experimentelles V2 über mehrere Anbieter ist aktiviert. CodexCommander übersetzt das native Kollaborationsprotokoll; dadurch sind alle V2-Worker-Nachrichten dieses Elternagenten Klartext, auch an native Worker. Nach dem Speichern eine neue Sitzung starten; unbekannte Schemas schlagen weiter sicher fehl.", + "sub.policy.compatibilityV2": "Codex-nativ und Parallel v2 können native verschlüsselte V2-Aufgaben senden, die externe Anbieter nicht lesen können (#92). Für V2 über mehrere Anbieter Klartext-Kompatibilität oder Zuverlässiges v1 wählen. Die Protokollwahl aktiviert keinen veralteten Worker.", + "sub.policy.compatibilityV2Plaintext": "Experimentelles V2 über mehrere Anbieter ist aktiviert. Die V2-Aufgabennachrichtenzustellung dieses Elternagenten ist Klartext, auch an native Worker. V2 selbst aktiviert keinen veralteten Codex-Worker; unbekannte Schemas schlagen weiter sicher fehl.", "sub.policy.subagentCap": "Effort-Obergrenze für Subagenten", "sub.policy.subagentCapHint": "Begrenzt den Effort von Child-Agenten, ohne niedrigere Anforderungen anzuheben.", "sub.filter.label": "Modelle nach Fähigkeit filtern", @@ -655,11 +674,30 @@ export const de: Record = { "sub.cap.context": "{n} Kontext", "sub.catalog.current": "Codex-Worker aktuell", "sub.catalog.restartNeeded": "Neustart erforderlich", - "sub.catalog.nextSession": "Gilt ab nächster Sitzung", + "sub.catalog.applyNeeded": "Anwenden erforderlich", + "sub.catalog.restartChatGPT": "ChatGPT neu starten", + "sub.catalog.applyUnavailable": "Anwenden nicht verfügbar", + "sub.catalog.integrationDisabled": "Codex-Integration aus", + "sub.catalog.pending": "Katalog nicht bereit", + "sub.catalog.nextSession": "Wird beim nächsten Worker-Start geladen", "sub.catalog.unknown": "Katalogstatus unbekannt", - "sub.catalog.staleNotice": "Der gespeicherte Katalog weicht von {n} laufenden Codex-Sitzung(en) ab. Führe {cmd} aus, wenn du bereit bist, sie neu zu starten und diesen Kader anzuwenden.", - "sub.catalog.notRunningNotice": "Keine laufende Codex-Sitzung verwendet diesen Katalog. Gespeicherte Änderungen gelten ab der nächsten Sitzung.", - "sub.roster.excludedNotice": "Gespeicherte Roster-Modelle werden derzeit nicht für V2-spawn_agent angeboten ({n}): {models}. Die Katalogdatei ist geladen, aber dieses Roster ist nicht vollständig wirksam.", + "sub.catalog.applyNotice": "CodexCommander-Routing und der gespeicherte Katalog sind bereit, aber verifizierte Codex-Worker verwenden noch eine ältere Konfiguration.", + "sub.catalog.routingNotInjectedNotice": "Codex verwendet noch natives Routing. Anwenden verbindet Codex mit CodexCommander und lädt den gespeicherten Agentenkatalog.", + "sub.catalog.externalRoutingNotice": "Codex verwendet benutzerdefiniertes Routing, das CodexCommander nicht besitzt. Anwenden ist nicht verfügbar, damit das Dashboard diese Konfiguration nicht überschreibt.", + "sub.catalog.unknownRoutingNotice": "Das Codex-Routing konnte nicht sicher erkannt werden. Anwenden bleibt nicht verfügbar, bis die Codex-Integration repariert oder wiederhergestellt wurde.", + "sub.catalog.integrationDisabledNotice": "Die Codex-Integration ist deaktiviert. Der Kader bleibt in CodexCommander gespeichert, wird aber nicht auf Codex angewendet.", + "sub.catalog.pendingNotice": "Der gespeicherte Katalog muss noch abgeglichen werden. Anwenden synchronisiert zuerst Routing und Katalogdateien und unterbricht einen verifizierten veralteten Worker nur nach erfolgreichem Abschluss.", + "sub.catalog.blockedNotice": "Die Änderungen sind gespeichert, aber dieses Dashboard kann Anwenden nicht sicher autorisieren. Aktualisieren Sie CodexCommander; kein Worker wird unterbrochen.", + "sub.catalog.launcherRequiredNotice": "Dieses Dashboard ist für Anwenden schreibgeschützt. Öffnen Sie es mit `ccx gui` oder über die CodexCommander-Menüleisten-App, um die Prozessunterbrechung zu bestätigen.", + "sub.catalog.applyAction": "Auf Codex anwenden", + "sub.catalog.restartChatGPTNotice": "Der Katalog ist gespeichert. Beende ChatGPT vollständig, öffne es erneut und starte danach eine neue Aufgabe. So wird die Liste am zuverlässigsten geladen.", + "sub.catalog.checkStatusAction": "Status prüfen", + "sub.catalog.forceRestartNotice": "Erweiterter Fallback: Das erzwungene Neustarten von Hintergrund-Workern kann in ChatGPT die Meldung „unerwartet beendet“ auslösen.", + "sub.catalog.forceRestartAction": "Worker zwangsweise neu starten", + "sub.catalog.newTaskNote": "Eine neue Aufgabe allein lädt den Codex-Hintergrundworker nicht neu und ändert sein Routing nicht. Anwenden gleicht beides ab und ersetzt nur verifizierte veraltete Worker.", + "sub.catalog.legacyStaleNotice": "Der gespeicherte Katalog weicht von {n} laufenden Codex-Sitzung(en) ab. Dieser Proxy bietet noch kein gesichertes Anwenden; nutze {cmd} als erweiterten Fallback, wenn du sie neu starten möchtest.", + "sub.catalog.notRunningNotice": "Kein Codex-Hintergrundworker läuft. Der nächste Workerstart lädt den gespeicherten Katalog.", + "sub.roster.excludedNotice": "Gespeicherte Roster-Modelle werden auf der gewählten spawn_agent-Oberfläche derzeit nicht angeboten ({n}): {models}. Die Katalogdatei ist geladen, aber dieses Roster ist nicht vollständig wirksam.", "sub.workspace.addToFeatured": "{m} zum aktiven Kader hinzufügen", "sub.workspace.featuredFull": "Aktiver Kader ist voll (max. 5)", "sub.workspace.removeFromFeatured": "{m} aus dem aktiven Kader entfernen", @@ -1424,6 +1462,9 @@ export const de: Record = { "common.close": "Schließen", "common.ok": "OK", "app.logoAria": "CodexCommander-Logo", + "app.launchRequiredDashboard": "Der Dashboard-Zugriff ist nicht bestätigt. Öffnen Sie es erneut mit `ccx gui` oder über die CodexCommander-Menüleisten-App.", + "app.adminRequiredDashboard": "Der Dashboard-Zugriff ist nicht autorisiert. Geben Sie bei Aufforderung das konfigurierte Admin-Token ein.", + "app.secureOriginRequiredDashboard": "Der Dashboard-Zugriff ist nicht autorisiert. Die Remote-Anmeldung erfordert vertrauenswürdiges HTTPS; andernfalls öffnen Sie das Dashboard lokal mit `ccx gui` oder der Menüleisten-App.", "app.claudeOn": "Claude AN", "app.claudeOff": "Claude AUS", "usage.dayMon": "Mo", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index a0cbd12572..128c1cc9da 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -32,6 +32,9 @@ export const en = { "auth.adminTokenRejected": "That admin token was rejected. Check it and try again.", "auth.adminTokenUnavailable": "The admin token could not be verified. Try again.", "app.logoAria": "CodexCommander logo", + "app.launchRequiredDashboard": "Dashboard access is not confirmed. Reopen it with `ccx gui` or from the CodexCommander menu bar app.", + "app.adminRequiredDashboard": "Dashboard access is not authorized. Enter the configured admin token when prompted.", + "app.secureOriginRequiredDashboard": "Dashboard access is not authorized. Remote operator sign-in requires trusted HTTPS; otherwise open it locally with `ccx gui` or the menu bar app.", "app.claudeOn": "Claude ON", "app.claudeOff": "Claude OFF", "theme.label": "Theme", @@ -445,9 +448,9 @@ export const en = { "models.currentBehavior": "Current behavior", "models.collaborationTitle": "Collaboration", "models.change": "Change", - "models.newSessionsOnly": "New sessions only", - "models.modeLabel_v1": "Classic v1", - "models.modeLabel_default": "Follow Codex defaults", + "models.newSessionsOnly": "Save, Apply, then start a new task", + "models.modeLabel_v1": "Reliable v1", + "models.modeLabel_default": "Codex native", "models.modeLabel_v2": "Concurrent v2", "models.modeStatus_v1": "Flexible model selection", "models.modeStatus_default": "Codex defaults", @@ -515,10 +518,10 @@ export const en = { "models.v2ModeDesc_v1": "All models → v1 surface", "models.v2ModeDesc_default": "Upstream defaults (sol/terra=v2, luna=v1)", "models.v2ModeDesc_v2": "All models → v2 surface", - "models.v2Help": "Controls the multi-agent surface for all models.\n\nv1: Classic single-thread agent. Every model uses the v1 collab surface.\nbase: Upstream defaults — sol/terra use v2, luna uses v1, others follow the codex feature flag.\nv2: Multi-thread agent with spawn_agent. Every model uses the v2 collab surface.\n\nChanges apply to new sessions.", + "models.v2Help": "Controls the multi-agent surface for all models.\n\nv1: Classic single-thread agent. Every model uses the v1 collab surface.\nbase: Upstream defaults — sol/terra use v2, luna uses v1, others follow the codex feature flag.\nv2: Multi-thread agent with spawn_agent. Every model uses the v2 collab surface.\n\nSave first. If Codex has a running worker, use Apply to replace it, then start a new task for session-bound tool schemas. A new task alone never reloads an existing worker.", "dash.multiAgent": "Sub-agent", "models.v2Conflict": "[agents] max_threads is set — codex will refuse to start; remove it from config.toml", - "models.v2Applied": "Sub-agent mode updated — applies to new sessions (restart the Codex app to refresh the picker)", + "models.v2Applied": "Sub-agent mode saved. Apply to replace a running worker, then start a new task for session-bound changes.", "models.v2ThreadsLabel": "Max threads", "models.v2ThreadsDefault": "default (4)", "models.v2ThreadsApplied": "Thread limit updated — applies to new sessions", @@ -531,7 +534,7 @@ export const en = { "models.collapseAll": "Collapse all", "models.expandAll": "Expand all", "models.orderHint": "Picker order: Subagents picks (in the selected order) → remaining routed models alphabetically by provider, then model ID → native models. Visibility switches only filter models; they do not change this order.", - "models.catalogBehaviorHint": "Hidden models leave the catalog and picker but remain callable by exact ID. Changes take effect on the next Codex turn; no restart is needed.", + "models.catalogBehaviorHint": "Hidden models leave the catalog and picker but remain callable by exact ID. Save catalog changes, Apply if a worker is running, then start a new task for session-bound tool schemas. A new task alone never reloads an existing worker.", "models.custom": "Custom…", "models.customApply": "Apply", "models.customPlaceholder": "Tokens (e.g. 420000)", @@ -562,7 +565,7 @@ export const en = { "models.tipStatus": "Status", "models.tipActive": "Active", "models.tipDisabled": "Disabled", - "models.applied": "Applied — takes effect on the next Codex turn.", + "models.applied": "Saved. Apply to replace a running worker, then start a new task for session-bound changes.", "models.saveFailed": "Save failed", "models.networkError": "Network error — is the proxy running?", "models.loadFail": "Failed to load models — is the proxy running?", @@ -598,9 +601,13 @@ export const en = { "sub.settings": "Run Policy", "sub.delegation.model": "Preferred delegate", "sub.delegation.modelHint": "The model CodexCommander names first when guiding delegated work. The active roster remains available for explicit overrides.", - "sub.saved": "Saved {n} quick picks. Start a new Codex session or run {cmd} to refresh existing sessions.", - "sub.savedExcluded": "Saved {n} quick picks, but {missing} are not currently advertised to V2 workers.", - "sub.savedRefreshFailed": "Saved {n} quick picks to disk, but the running catalog did not refresh cleanly. Run {cmd} before relying on the new order.", + "sub.saved": "Saved {n} quick picks. Disk and the generated catalog are up to date; no Codex worker was restarted.", + "sub.savedExcluded": "Saved {n} quick picks, but {missing} are not currently advertised on the selected agent surface.", + "sub.savedRefreshFailed": "Saved {n} quick picks to disk, but the catalog could not be refreshed cleanly. Existing workers were not restarted.", + "sub.savedSuperseded": "This save was superseded by a newer change. The latest saved roster is shown.", + "sub.savedIntegrationDisabled": "Saved {n} quick picks in CodexCommander. Codex integration is off, so Codex routing and catalog files were left unchanged.", + "sub.savedNeedsApply": "Saved {n} quick picks in CodexCommander. Codex is still using native routing; choose Apply to Codex when you are ready to connect it and publish this roster.", + "sub.savedRoutingPreserved": "Saved {n} quick picks in CodexCommander. Existing Codex routing and catalog files were preserved because CodexCommander could not verify that it owns the current routing.", "sub.saveFailed": "Save failed", "sub.networkError": "Network error — is the proxy running?", "sub.loadFail": "Failed to load models — is the proxy running?", @@ -608,6 +615,21 @@ export const en = { "sub.metadataLimited": "The roster is available, but model capability details could not be loaded. Filters and badges may be limited.", "sub.loading": "Loading…", "sub.saving": "Saving…", + "sub.applying": "Applying…", + "sub.applyActivityFailed": "Could not check active Codex work", + "sub.applyFailed": "Could not apply CodexCommander routing and catalog to Codex", + "sub.apply.dialogTitle": "Apply CodexCommander to Codex?", + "sub.apply.dialogIdle": "This reconciles Codex routing and the saved agent catalog, then replaces only verified stale background workers when needed. No active proxy work was detected.", + "sub.apply.dialogActive": "CodexCommander detected active proxy work. Applying reconciles routing and the saved agent catalog, and may replace a verified stale background worker and interrupt current work.", + "sub.apply.dialogUnknown": "CodexCommander could not check active proxy work. Applying reconciles routing and the saved agent catalog, and may replace only a verified stale background worker.", + "sub.apply.confirm": "Apply to Codex", + "sub.apply.applied": "Applied CodexCommander routing and the catalog to Codex. New or replacement workers will use the saved roster.", + "sub.apply.alreadyCurrent": "Codex routing and workers already use the saved configuration.", + "sub.apply.noWorkers": "Codex routing and the catalog are current. No background worker needed replacement.", + "sub.apply.partial": "Some verified stale Codex workers were replaced; refresh the status before applying again.", + "sub.apply.superseded": "The saved configuration changed before Apply completed. Review the current status and try again.", + "sub.apply.blocked": "CodexCommander could not safely reconcile routing and the catalog or verify the workers. Nothing was interrupted.", + "sub.apply.completed": "Apply completed. Refresh the status to confirm Codex routing and worker activation.", "sub.saveRoster": "Save roster", "sub.moveUp": "Move {m} up", "sub.moveDown": "Move {m} down", @@ -627,24 +649,24 @@ export const en = { "sub.policy.messageDelivery_encrypted": "Encrypted (native)", "sub.policy.messageDelivery_plaintext": "Plaintext compatibility", "sub.policy.messageDeliveryHint_encrypted": "Keeps ChatGPT's native encrypted contract; external V2 workers may be unavailable.", - "sub.policy.messageDeliveryHint_plaintext": "Experimental. Enables mixed-provider V2; every V2 worker message from this parent is plaintext. Start a new session after changing it.", + "sub.policy.messageDeliveryHint_plaintext": "Experimental. Enables mixed-provider V2; V2 task-message delivery from this parent is plaintext. Save, then start a new task. This delivery-only change does not require Apply.", "sub.policy.preferred": "Preferred guidance model", "sub.policy.noPreferred": "No preferred model — Codex chooses from roster", "sub.policy.fallback": "Global child fallback", "sub.policy.fallbackHint": "Used for spawned child turns after the requested model and any role fallback; unavailable or quota-limited candidates are skipped.", "sub.policy.noFallback": "No fallback", "sub.policy.concurrency": "Thread limit", - "sub.policy.concurrencyHint": "V2 counts total threads including the root; V1 counts child threads. Blank restores the Codex default. Applies to new sessions.", + "sub.policy.concurrencyHint": "V2 counts total threads including the root; V1 counts child threads. Blank restores the Codex default. Save, Apply if a worker is running, then start a new task.", "sub.policy.codexDefault": "Codex default", "sub.policy.increaseConcurrency": "Increase sub-agent concurrency", "sub.policy.decreaseConcurrency": "Decrease sub-agent concurrency", "sub.policy.save": "Save changes", - "sub.policy.saved": "Run policy saved. Protocol and thread-limit changes apply to new sessions; V2 delivery changes affect subsequent requests, so start a new session; guidance and fallback apply to future spawned turns.", + "sub.policy.saved": "Run policy saved. Apply to replace a running worker, then start a new task for protocol, thread-limit, and session-bound tool-schema changes. V2 task-message delivery affects later requests; guidance and fallback affect future spawned turns.", "sub.policy.saveFailed": "Some run-policy changes could not be saved. Your unsaved choices are still shown.", "sub.policy.loading": "Loading run policy…", "sub.policy.retry": "Reload policy", "sub.policy.details": "Fallback chain and safeguards", - "sub.policy.timing": "Protocol, V2 message delivery, and thread limit apply to new sessions; guidance, fallback, and effort settings apply to subsequent eligible requests.", + "sub.policy.timing": "Save first. Apply replaces a running worker; then a new task receives protocol, thread-limit, and session-bound tool-schema changes. V2 task-message delivery, guidance, fallback, and effort affect later eligible requests.", "sub.policy.fallbackChain": "Ordered fallback chain", "sub.policy.fallbackChainHint": "For spawned child turns, global candidates are tried after the requested model and role fallback. Leave empty to disable the global fallback list.", "sub.policy.addFallback": "Add fallback", @@ -658,8 +680,8 @@ export const en = { "sub.policy.preferredEffortHint": "Reasoning level named in guidance when the preferred model is available.", "sub.policy.guidance": "Use roster as worker guidance", "sub.policy.guidanceHint": "Names eligible roster models in guidance. It does not force delegation or route every child.", - "sub.policy.compatibilityV2": "Native encrypted V2 tasks cannot be read by external providers (#92). Automatic guidance filters known-incompatible workers and exact overrides fail closed. Choose Plaintext compatibility for mixed-provider V2, or Classic v1 for the established cross-provider path.", - "sub.policy.compatibilityV2Plaintext": "Experimental mixed-provider V2 is enabled. CodexCommander translates the native collaboration wire, so every V2 worker message from this parent—including messages to native workers—is plaintext. Start a new session after saving; unrecognized schemas still fail closed.", + "sub.policy.compatibilityV2": "Codex native and Concurrent v2 can send native encrypted V2 tasks, which external providers cannot read (#92). Choose Plaintext compatibility for mixed-provider V2, or Reliable v1 for the established cross-provider path. Protocol selection does not activate a stale worker.", + "sub.policy.compatibilityV2Plaintext": "Experimental mixed-provider V2 is enabled. V2 task-message delivery from this parent is plaintext, including messages to native workers. V2 itself does not activate a stale Codex worker; unrecognized schemas still fail closed.", "sub.policy.subagentCap": "Sub-agent effort ceiling", "sub.policy.subagentCapHint": "Limits child-agent effort without raising lower requests.", "sub.filter.label": "Filter models by capability", @@ -675,11 +697,30 @@ export const en = { "sub.cap.context": "{n} ctx", "sub.catalog.current": "Codex workers current", "sub.catalog.restartNeeded": "Restart needed", - "sub.catalog.nextSession": "Applies next session", + "sub.catalog.applyNeeded": "Apply needed", + "sub.catalog.restartChatGPT": "Restart ChatGPT", + "sub.catalog.applyUnavailable": "Apply unavailable", + "sub.catalog.integrationDisabled": "Codex integration off", + "sub.catalog.pending": "Catalog not ready", + "sub.catalog.nextSession": "Loads on next worker start", "sub.catalog.unknown": "Catalog status unknown", - "sub.catalog.staleNotice": "Saved catalog differs from {n} running Codex session(s). Run {cmd} when you are ready to restart them and apply this roster.", - "sub.catalog.notRunningNotice": "No running Codex session is using this catalog. Saved changes will apply when the next session starts.", - "sub.roster.excludedNotice": "Saved roster models are not currently advertised to V2 spawn_agent ({n}): {models}. The catalog file is loaded, but this roster is not fully effective.", + "sub.catalog.applyNotice": "CodexCommander routing and the saved catalog are ready, but verified Codex workers still use an older configuration.", + "sub.catalog.routingNotInjectedNotice": "Codex is still using native routing. Apply connects Codex to CodexCommander and loads the saved agent catalog.", + "sub.catalog.externalRoutingNotice": "Codex uses custom routing that CodexCommander does not own. Apply is unavailable so the dashboard does not overwrite that configuration.", + "sub.catalog.unknownRoutingNotice": "Codex routing could not be classified safely. Apply is unavailable until the Codex integration is repaired or restored.", + "sub.catalog.integrationDisabledNotice": "Codex integration is off. The roster remains saved in CodexCommander, but it will not be applied to Codex.", + "sub.catalog.pendingNotice": "The saved catalog still needs reconciliation. Apply first synchronizes routing and catalog files, and interrupts a verified stale worker only after that succeeds.", + "sub.catalog.blockedNotice": "Changes are saved, but this dashboard cannot safely authorize Apply. Refresh CodexCommander before trying again; no worker will be interrupted.", + "sub.catalog.launcherRequiredNotice": "This dashboard is read-only for Apply. Open it with `ccx gui` or from the CodexCommander menu bar app to confirm process interruption.", + "sub.catalog.applyAction": "Apply to Codex", + "sub.catalog.restartChatGPTNotice": "Catalog saved. Quit ChatGPT completely, reopen it, then start a new task. This is the most reliable way to load the roster.", + "sub.catalog.checkStatusAction": "Check status", + "sub.catalog.forceRestartNotice": "Advanced fallback: force-restarting background workers may make ChatGPT show ‘stopped unexpectedly.’", + "sub.catalog.forceRestartAction": "Force-restart workers", + "sub.catalog.newTaskNote": "A new task alone does not reload the Codex background worker or change its routing. Apply reconciles both and replaces only verified stale workers.", + "sub.catalog.legacyStaleNotice": "Saved catalog differs from {n} running Codex session(s). This proxy does not yet expose guarded Apply; use {cmd} as an advanced fallback when ready to restart them.", + "sub.catalog.notRunningNotice": "No Codex background worker is running. The next worker start loads the saved catalog.", + "sub.roster.excludedNotice": "Saved roster models are not currently advertised on the selected spawn_agent surface ({n}): {models}. The catalog file is loaded, but this roster is not fully effective.", "sub.workspace.addToFeatured": "Add {m} to active roster", "sub.workspace.featuredFull": "Active roster is full (max 5)", "sub.workspace.removeFromFeatured": "Remove {m} from active roster", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 19eedf1d40..c4691dccb8 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -77,6 +77,9 @@ export const ja: Record = { "auth.adminTokenRejected": "管理者トークンが拒否されました。確認してもう一度お試しください。", "auth.adminTokenUnavailable": "管理者トークンを確認できませんでした。もう一度お試しください。", "app.logoAria": "CodexCommander ロゴ", + "app.launchRequiredDashboard": "ダッシュボードへのアクセスが確認されていません。`ccx gui` または CodexCommander メニューバーアプリから開き直してください。", + "app.adminRequiredDashboard": "ダッシュボードへのアクセスは認証されていません。求められたら設定済みの管理者トークンを入力してください。", + "app.secureOriginRequiredDashboard": "ダッシュボードへのアクセスは認証されていません。リモート管理者のサインインには信頼できる HTTPS が必要です。それ以外は `ccx gui` またはメニューバーアプリからローカルで開いてください。", "app.claudeOn": "Claude オン", "app.claudeOff": "Claude オフ", "theme.label": "テーマ", @@ -434,9 +437,9 @@ export const ja: Record = { "models.currentBehavior": "現在の動作", "models.collaborationTitle": "コラボレーション", "models.change": "変更", - "models.newSessionsOnly": "新しいセッションのみ", - "models.modeLabel_v1": "クラシック v1", - "models.modeLabel_default": "Codex の既定値に従う", + "models.newSessionsOnly": "保存、適用、新しいタスクの開始", + "models.modeLabel_v1": "高信頼 v1", + "models.modeLabel_default": "Codex ネイティブ", "models.modeLabel_v2": "並行 v2", "models.modeStatus_v1": "柔軟なモデル選択", "models.modeStatus_default": "Codex の既定値", @@ -504,10 +507,10 @@ export const ja: Record = { "models.v2ModeDesc_v1": "すべてのモデル → v1 サーフェス", "models.v2ModeDesc_default": "上流のデフォルト(sol/terra=v2、luna=v1)", "models.v2ModeDesc_v2": "すべてのモデル → v2 サーフェス", - "models.v2Help": "すべてのモデルのマルチエージェントサーフェスを制御します。\n\nv1: クラシックな単一スレッドエージェント。すべてのモデルが v1 コラボサーフェスを使います。\nベース: 上流のデフォルト — sol/terra は v2、luna は v1、それ以外は codex のフィーチャーフラグに従います。\nv2: spawn_agent を備えたマルチスレッドエージェント。すべてのモデルが v2 コラボサーフェスを使います。\n\n変更は新規セッションに適用されます。", + "models.v2Help": "すべてのモデルのマルチエージェントサーフェスを制御します。\n\nv1: クラシックな単一スレッドエージェント。すべてのモデルが v1 コラボサーフェスを使います。\nベース: 上流のデフォルト — sol/terra は v2、luna は v1、それ以外は codex のフィーチャーフラグに従います。\nv2: spawn_agent を備えたマルチスレッドエージェント。すべてのモデルが v2 コラボサーフェスを使います。\n\nまず保存します。Codex ワーカーが動作中なら適用で置き換え、その後セッションに紐付くツールスキーマのため新しいタスクを開始します。新しいタスクだけでは既存ワーカーは再読み込みされません。", "dash.multiAgent": "サブエージェント", "models.v2Conflict": "[agents] max_threads が設定されています — codex は起動を拒否します; config.toml から削除してください", - "models.v2Applied": "サブエージェントモードを更新しました — 新規セッションに適用(ピッカーを更新するには Codex アプリを再起動)", + "models.v2Applied": "サブエージェントモードを保存しました。実行中のワーカーに適用し、セッションに紐付く変更のため新しいタスクを開始してください。", "models.v2ThreadsLabel": "最大スレッド数", "models.v2ThreadsDefault": "デフォルト (4)", "models.v2ThreadsApplied": "スレッド上限を更新しました — 新規セッションに適用", @@ -520,11 +523,11 @@ export const ja: Record = { "models.collapseAll": "すべて折りたたむ", "models.expandAll": "すべて展開", "models.orderHint": "ピッカーの順序: サブエージェントの選択(選択順) → 残りのルーティングモデルはプロバイダー別、次にモデル ID 別のアルファベット順 → ネイティブモデル。表示切り替えはモデルをフィルタするだけで、この順序は変更しません。", - "models.catalogBehaviorHint": "非表示モデルはカタログとピッカーから外れますが、正確な ID では引き続き呼び出せます。変更は次の Codex ターンで反映され、再起動は不要です。", + "models.catalogBehaviorHint": "非表示モデルはカタログとピッカーから外れますが、正確な ID では引き続き呼び出せます。カタログ変更を保存し、ワーカーが動作中なら適用してから、セッションに紐付くツールスキーマのため新しいタスクを開始します。新しいタスクだけでは既存ワーカーは再読み込みされません。", "models.custom": "カスタム…", "models.customApply": "適用", "models.customPlaceholder": "トークン (例: 420000)", - "models.applied": "適用しました — 次回の Codex ターンで有効になります。", + "models.applied": "保存しました。実行中のワーカーに適用し、セッションに紐付く変更のため新しいタスクを開始してください。", "models.saveFailed": "保存に失敗しました", "models.networkError": "ネットワークエラー — プロキシは起動していますか?", "models.loadFail": "モデルの読み込みに失敗しました — プロキシは起動していますか?", @@ -560,9 +563,13 @@ export const ja: Record = { "sub.settings": "実行ポリシー", "sub.delegation.model": "優先ガイダンスモデル", "sub.delegation.modelHint": "CodexCommander がガイダンスで最初に示すモデルです。ロースターは明示的なオーバーライドにも使えます。", - "sub.saved": "{n} 件のクイックピックを保存しました。新しい Codex セッションを開始するか {cmd} を実行して、既存セッションを更新してください。", - "sub.savedExcluded": "{n} 件のクイックピックを保存しましたが、{missing} 件は現在 V2 ワーカーに表示されていません。", - "sub.savedRefreshFailed": "{n} 件のクイックピックをディスクに保存しましたが、実行中のカタログが正常に更新されませんでした。新しい順序に依存する前に {cmd} を実行してください。", + "sub.saved": "{n} 件のクイックピックを保存しました。ディスクと生成済みカタログは最新ですが、Codex ワーカーは再起動していません。", + "sub.savedExcluded": "{n} 件のクイックピックを保存しましたが、{missing} 件は選択したエージェント画面に現在表示されていません。", + "sub.savedRefreshFailed": "{n} 件のクイックピックをディスクに保存しましたが、カタログを正常に更新できませんでした。既存のワーカーは再起動していません。", + "sub.savedSuperseded": "この保存は、より新しい変更によって置き換えられました。最新の保存済みロスターを表示しています。", + "sub.savedIntegrationDisabled": "CodexCommander に {n} 件のクイックピックを保存しました。Codex 連携はオフのため、Codex のルーティングとカタログファイルは変更していません。", + "sub.savedNeedsApply": "CodexCommander に {n} 件のクイックピックを保存しました。Codex はまだネイティブルーティングを使用しています。接続してこのロスターを公開する準備ができたら、「Codex に適用」を選択してください。", + "sub.savedRoutingPreserved": "CodexCommander に {n} 件のクイックピックを保存しました。現在のルーティングを CodexCommander が管理していることを確認できなかったため、既存の Codex ルーティングとカタログファイルは保持しました。", "sub.saveFailed": "保存に失敗しました", "sub.networkError": "ネットワークエラー — プロキシは起動していますか?", "sub.loadFail": "モデルの読み込みに失敗しました — プロキシは起動していますか?", @@ -570,6 +577,21 @@ export const ja: Record = { "sub.metadataLimited": "ロースターは利用可能ですが、モデル機能の詳細を読み込めませんでした。フィルターやバッジが制限される場合があります。", "sub.loading": "読み込み中…", "sub.saving": "保存中…", + "sub.applying": "適用中…", + "sub.applyActivityFailed": "アクティブな Codex 作業を確認できませんでした", + "sub.applyFailed": "CodexCommander のルーティングとカタログを Codex に適用できませんでした", + "sub.apply.dialogTitle": "CodexCommander を Codex に適用しますか?", + "sub.apply.dialogIdle": "Codex のルーティングと保存済みエージェントカタログを整合させ、必要な場合のみ検証済みの古いバックグラウンドワーカーを置き換えます。アクティブなプロキシ作業は検出されませんでした。", + "sub.apply.dialogActive": "CodexCommander がアクティブなプロキシ作業を検出しました。適用はルーティングとエージェントカタログを整合させ、検証済みの古いバックグラウンドワーカーを置き換えて現在の作業を中断する可能性があります。", + "sub.apply.dialogUnknown": "CodexCommander はアクティブなプロキシ作業を確認できませんでした。適用はルーティングとエージェントカタログを整合させ、検証済みの古いバックグラウンドワーカーのみを置き換える可能性があります。", + "sub.apply.confirm": "Codex に適用", + "sub.apply.applied": "CodexCommander のルーティングとカタログを Codex に適用しました。新規または置換後のワーカーは保存済みロースターを使用します。", + "sub.apply.alreadyCurrent": "Codex のルーティングとワーカーはすでに保存済み設定を使用しています。", + "sub.apply.noWorkers": "Codex のルーティングとカタログは最新です。置換が必要なバックグラウンドワーカーはありませんでした。", + "sub.apply.partial": "一部の検証済み古い Codex ワーカーを置き換えました。再度適用する前に状態を更新してください。", + "sub.apply.superseded": "適用完了前に保存済み設定が変更されました。現在の状態を確認して再試行してください。", + "sub.apply.blocked": "CodexCommander はルーティングとカタログの安全な整合、またはワーカーの検証を行えませんでした。中断された作業はありません。", + "sub.apply.completed": "適用が完了しました。状態を更新して Codex のルーティングとワーカーの有効化を確認してください。", "sub.saveRoster": "ロースターを保存", "sub.moveUp": "{m} を上へ移動", "sub.moveDown": "{m} を下へ移動", @@ -589,24 +611,24 @@ export const ja: Record = { "sub.policy.messageDelivery_encrypted": "暗号化(ネイティブ)", "sub.policy.messageDelivery_plaintext": "平文互換モード", "sub.policy.messageDeliveryHint_encrypted": "ChatGPT のネイティブ暗号化契約を維持します。外部 V2 ワーカーは利用できない場合があります。", - "sub.policy.messageDeliveryHint_plaintext": "実験的機能。複数プロバイダーの V2 を有効にし、この親からの全 V2 ワーカーメッセージを平文にします。変更後は新しいセッションを開始してください。", + "sub.policy.messageDeliveryHint_plaintext": "実験的機能。複数プロバイダーの V2 を有効にし、この親からの V2 タスクメッセージ配信は平文です。保存してから新しいタスクを開始してください。配信のみの変更に適用は不要です。", "sub.policy.preferred": "優先ガイダンスモデル", "sub.policy.noPreferred": "優先モデルなし — Codex がロースターから選択", "sub.policy.fallback": "グローバル子タスクフォールバック", "sub.policy.fallbackHint": "要求モデルとロールのフォールバックの後、生成された子タスクで使います。利用不可またはクォータ制限の候補はスキップします。", "sub.policy.noFallback": "フォールバックなし", "sub.policy.concurrency": "スレッド上限", - "sub.policy.concurrencyHint": "V2 はルートを含む総スレッド数、V1 は子スレッド数です。空欄で Codex の既定値に戻ります。新しいセッションに適用されます。", + "sub.policy.concurrencyHint": "V2 はルートを含む総スレッド数、V1 は子スレッド数です。空欄で Codex の既定値に戻ります。保存し、ワーカーが動作中なら適用してから新しいタスクを開始してください。", "sub.policy.codexDefault": "Codex デフォルト", "sub.policy.increaseConcurrency": "サブエージェントの同時実行数を増やす", "sub.policy.decreaseConcurrency": "サブエージェントの同時実行数を減らす", "sub.policy.save": "変更を保存", - "sub.policy.saved": "実行ポリシーを保存しました。プロトコルとスレッド上限は新しいセッションに適用されます。V2 配信は次のリクエストから変わるため、新しいセッションを開始してください。ガイダンスとフォールバックは今後の子タスクに適用されます。", + "sub.policy.saved": "実行ポリシーを保存しました。実行中のワーカーに適用してから、プロトコル、スレッド上限、セッションに紐付くツールスキーマのため新しいタスクを開始してください。V2 タスクメッセージ配信は後続リクエストに、ガイダンスとフォールバックは今後の子タスクに適用されます。", "sub.policy.saveFailed": "一部の実行ポリシー変更を保存できませんでした。未保存の選択内容は引き続き表示されています。", "sub.policy.loading": "実行ポリシーを読み込み中…", "sub.policy.retry": "ポリシーを再読み込み", "sub.policy.details": "フォールバックチェーンと安全策", - "sub.policy.timing": "プロトコル、V2 メッセージ配信、スレッド上限は新しいセッションに、ガイダンス、フォールバック、effort は次の対象リクエストに適用されます。", + "sub.policy.timing": "まず保存します。適用は実行中のワーカーを置き換え、その後の新しいタスクでプロトコル、スレッド上限、セッションに紐付くツールスキーマを受け取ります。V2 タスクメッセージ配信、ガイダンス、フォールバック、effort は後続の対象リクエストに適用されます。", "sub.policy.fallbackChain": "順序付きフォールバックチェーン", "sub.policy.fallbackChainHint": "生成された子タスクでは、要求モデルとロールのフォールバックの後にグローバル候補を上から試します。空欄でグローバル一覧を無効にします。", "sub.policy.addFallback": "フォールバックを追加", @@ -620,8 +642,8 @@ export const ja: Record = { "sub.policy.preferredEffortHint": "優先モデルが利用可能な場合にガイダンスで示す推論レベル。", "sub.policy.guidance": "ロースターをワーカーガイダンスに使う", "sub.policy.guidanceHint": "利用可能なロースターモデルをガイダンスで示します。委任や全子タスクのルーティングを強制しません。", - "sub.policy.compatibilityV2": "外部プロバイダーはネイティブの暗号化 V2 タスクを読めません (#92)。自動ガイダンスは既知の非互換ワーカーを除外し、明示的なオーバーライドは安全に失敗します。複数プロバイダーの V2 には平文互換モード、確立済みの経路には Classic v1 を選んでください。", - "sub.policy.compatibilityV2Plaintext": "実験的な複数プロバイダー V2 が有効です。CodexCommander がネイティブ連携ワイヤーを変換するため、この親からのすべての V2 ワーカーメッセージ(ネイティブ宛ても含む)は平文になります。保存後に新規セッションを開始してください。未知のスキーマは引き続き安全に失敗します。", + "sub.policy.compatibilityV2": "Codex ネイティブと並行 v2 は、外部プロバイダーが読めないネイティブ暗号化 V2 タスクを送信できます (#92)。複数プロバイダー V2 には平文互換モード、または高信頼 v1 を選んでください。プロトコルの選択だけでは古いワーカーは更新されません。", + "sub.policy.compatibilityV2Plaintext": "実験的な複数プロバイダー V2 が有効です。この親からの V2 タスクメッセージ配信は、ネイティブワーカー宛ても含めて平文です。V2 自体は古い Codex ワーカーを有効化しません。未知のスキーマは引き続き安全に失敗します。", "sub.policy.subagentCap": "サブエージェントの effort 上限", "sub.policy.subagentCapHint": "低い要求を引き上げることなく、子エージェントの effort を制限します。", "sub.filter.label": "機能でモデルをフィルター", @@ -637,11 +659,30 @@ export const ja: Record = { "sub.cap.context": "{n} コンテキスト", "sub.catalog.current": "Codex ワーカーは最新です", "sub.catalog.restartNeeded": "再起動が必要です", - "sub.catalog.nextSession": "次のセッションから適用", + "sub.catalog.applyNeeded": "適用が必要です", + "sub.catalog.restartChatGPT": "ChatGPT を再起動", + "sub.catalog.applyUnavailable": "適用できません", + "sub.catalog.integrationDisabled": "Codex 連携はオフです", + "sub.catalog.pending": "カタログの準備未完了", + "sub.catalog.nextSession": "次のワーカー起動時に読み込み", "sub.catalog.unknown": "カタログの状態は不明です", - "sub.catalog.staleNotice": "保存されたカタログが実行中の {n} 件の Codex セッションと異なります。それらを再起動してこのロースターを適用する準備ができたら {cmd} を実行してください。", - "sub.catalog.notRunningNotice": "このカタログを使用している実行中の Codex セッションはありません。保存した変更は次のセッション開始時に適用されます。", - "sub.roster.excludedNotice": "保存済みロースターのモデルが現在 V2 spawn_agent に表示されていません({n} 件):{models}。カタログファイルは読み込まれていますが、このロースターは完全には有効ではありません。", + "sub.catalog.applyNotice": "CodexCommander のルーティングと保存済みカタログは準備できていますが、検証済み Codex ワーカーはまだ古い設定を使用しています。", + "sub.catalog.routingNotInjectedNotice": "Codex はまだネイティブルーティングを使用しています。適用すると Codex を CodexCommander に接続し、保存済みエージェントカタログを読み込みます。", + "sub.catalog.externalRoutingNotice": "Codex は CodexCommander が所有しないカスタムルーティングを使用しています。その設定を上書きしないよう、適用は利用できません。", + "sub.catalog.unknownRoutingNotice": "Codex のルーティングを安全に判定できませんでした。Codex 連携が修復または復元されるまで適用は利用できません。", + "sub.catalog.integrationDisabledNotice": "Codex 連携はオフです。ロースターは CodexCommander に保存されたままですが、Codex には適用されません。", + "sub.catalog.pendingNotice": "保存済みカタログはまだ整合が必要です。適用はまずルーティングとカタログファイルを同期し、成功した場合にのみ検証済みの古いワーカーを中断します。", + "sub.catalog.blockedNotice": "変更は保存されましたが、このダッシュボードでは適用を安全に許可できません。CodexCommander を更新してから再試行してください。ワーカーは中断されません。", + "sub.catalog.launcherRequiredNotice": "このダッシュボードでは適用は読み取り専用です。`ccx gui` または CodexCommander メニューバーアプリから開き、プロセスの中断を確認してください。", + "sub.catalog.applyAction": "Codex に適用", + "sub.catalog.restartChatGPTNotice": "カタログは保存済みです。ChatGPT を完全に終了して開き直し、その後に新しいタスクを開始してください。これがロスターを最も確実に読み込む方法です。", + "sub.catalog.checkStatusAction": "状態を確認", + "sub.catalog.forceRestartNotice": "上級者向けの代替手段: バックグラウンドワーカーを強制再起動すると、ChatGPT に「予期せず停止しました」と表示される場合があります。", + "sub.catalog.forceRestartAction": "ワーカーを強制再起動", + "sub.catalog.newTaskNote": "新しいタスクを開始するだけでは Codex バックグラウンドワーカーの再読み込みもルーティング変更も行われません。適用は両方を整合させ、検証済みの古いワーカーのみを置き換えます。", + "sub.catalog.legacyStaleNotice": "保存済みカタログは実行中の {n} 件の Codex セッションと異なります。このプロキシはまだ保護された適用を提供していません。再起動する準備ができたら上級者向けの代替として {cmd} を使ってください。", + "sub.catalog.notRunningNotice": "Codex バックグラウンドワーカーは動作していません。次のワーカー開始時に保存済みカタログが読み込まれます。", + "sub.roster.excludedNotice": "保存済みロースターのモデルが選択した spawn_agent 画面に現在表示されていません({n} 件):{models}。カタログファイルは読み込まれていますが、このロースターは完全には有効ではありません。", "sub.workspace.addToFeatured": "{m} をアクティブ・ロースターに追加", "sub.workspace.featuredFull": "アクティブ・ロースターがいっぱいです(最大 5)", "sub.workspace.removeFromFeatured": "{m} をアクティブ・ロースターから削除", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 83b1e27dd5..5a25996177 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -437,9 +437,9 @@ export const ko: Record = { "models.currentBehavior": "현재 동작", "models.collaborationTitle": "협업", "models.change": "변경", - "models.newSessionsOnly": "새 세션에만 적용", - "models.modeLabel_v1": "클래식 v1", - "models.modeLabel_default": "Codex 기본값 따르기", + "models.newSessionsOnly": "저장, 적용, 새 작업 시작", + "models.modeLabel_v1": "안정적 v1", + "models.modeLabel_default": "Codex 네이티브", "models.modeLabel_v2": "동시 v2", "models.modeStatus_v1": "유연한 모델 선택", "models.modeStatus_default": "Codex 기본값", @@ -506,11 +506,11 @@ export const ko: Record = { "models.v2ModeDesc_v1": "전 모델 → v1 서피스", "models.v2ModeDesc_default": "업스트림 기본값 (sol/terra=v2, luna=v1)", "models.v2ModeDesc_v2": "전 모델 → v2 서피스", - "models.v2Help": "모든 모델의 멀티에이전트 서피스를 제어합니다.\n\nv1: 단일 스레드 에이전트. 모든 모델이 v1 서피스를 사용합니다.\nbase: 업스트림 기본값 — sol/terra는 v2, luna는 v1, 나머지는 codex 플래그를 따릅니다.\nv2: 멀티 스레드 에이전트(spawn_agent). 모든 모델이 v2 서피스를 사용합니다.\n\n새 세션부터 적용됩니다.", + "models.v2Help": "모든 모델의 멀티에이전트 서피스를 제어합니다.\n\nv1: 단일 스레드 에이전트. 모든 모델이 v1 서피스를 사용합니다.\nbase: 업스트림 기본값 — sol/terra는 v2, luna는 v1, 나머지는 codex 플래그를 따릅니다.\nv2: 멀티 스레드 에이전트(spawn_agent). 모든 모델이 v2 서피스를 사용합니다.\n\n먼저 저장하세요. Codex 워커가 실행 중이면 적용으로 교체한 뒤 세션 종속 도구 스키마를 위해 새 작업을 시작하세요. 새 작업만으로 기존 워커가 다시 로드되지는 않습니다.", "models.v2DocsLink": "v1 / v2가 뭔가요?", "dash.multiAgent": "서브에이전트", "models.v2Conflict": "[agents] max_threads가 남아 있어 codex가 부팅을 거부합니다 — config.toml에서 제거하세요", - "models.v2Applied": "서브에이전트 모드 변경됨 — 새 세션부터 적용 (피커 갱신은 Codex 앱 재시작)", + "models.v2Applied": "서브에이전트 모드가 저장되었습니다. 실행 중인 워커에 적용한 뒤 세션 종속 변경을 위해 새 작업을 시작하세요.", "models.v2ThreadsLabel": "최대 스레드", "models.v2ThreadsDefault": "기본값 (4)", "models.v2ThreadsApplied": "스레드 한도 변경됨 — 새 세션부터 적용", @@ -523,7 +523,7 @@ export const ko: Record = { "models.collapseAll": "모두 접기", "models.expandAll": "모두 펼치기", "models.orderHint": "피커 순서: Subagents에서 지정한 순서 → 나머지 라우팅 모델(프로바이더, 모델 ID 순 알파벳 정렬) → 네이티브 모델. 노출 토글은 모델을 필터링할 뿐 이 순서를 바꾸지 않습니다.", - "models.catalogBehaviorHint": "숨긴 모델은 카탈로그와 피커에서 제외되지만 정확한 ID로는 계속 호출할 수 있습니다. 변경 사항은 다음 Codex 턴부터 적용되며 재시작할 필요가 없습니다.", + "models.catalogBehaviorHint": "숨긴 모델은 카탈로그와 피커에서 제외되지만 정확한 ID로는 계속 호출할 수 있습니다. 카탈로그 변경을 저장하고 워커가 실행 중이면 적용한 뒤 세션 종속 도구 스키마를 위해 새 작업을 시작하세요. 새 작업만으로 기존 워커가 다시 로드되지는 않습니다.", "models.custom": "직접 입력…", "models.customApply": "적용", "models.customPlaceholder": "토큰 (예: 420000)", @@ -554,7 +554,7 @@ export const ko: Record = { "models.tipStatus": "상태", "models.tipActive": "활성", "models.tipDisabled": "비활성", - "models.applied": "적용됨 — 다음 Codex 턴부터 반영됩니다.", + "models.applied": "저장되었습니다. 실행 중인 워커에 적용한 뒤 세션 종속 변경을 위해 새 작업을 시작하세요.", "models.saveFailed": "저장 실패", "models.networkError": "네트워크 오류 — 프록시가 실행 중인가요?", "models.loadFail": "모델을 불러오지 못했습니다 — 프록시가 실행 중인가요?", @@ -590,9 +590,13 @@ export const ko: Record = { "sub.settings": "실행 정책", "sub.delegation.model": "선호 안내 모델", "sub.delegation.modelHint": "CodexCommander가 안내에서 가장 먼저 지정하는 모델입니다. 로스터는 명시적 오버라이드에도 사용할 수 있습니다.", - "sub.saved": "빠른 선택 {n}개를 저장했습니다. 새 Codex 세션을 시작하거나 {cmd} 를 실행해 기존 세션을 새로고침하세요.", - "sub.savedExcluded": "빠른 선택 {n}개를 저장했지만 {missing}개는 현재 V2 워커에 표시되지 않습니다.", - "sub.savedRefreshFailed": "빠른 선택 {n}개를 디스크에 저장했지만 실행 중인 카탈로그가 깔끔하게 새로고침되지 않았습니다. 새 순서에 의존하기 전에 {cmd} 를 실행하세요.", + "sub.saved": "빠른 선택 {n}개를 저장했습니다. 디스크와 생성된 카탈로그는 최신이지만 Codex 워커를 다시 시작하지 않았습니다.", + "sub.savedExcluded": "빠른 선택 {n}개를 저장했지만 {missing}개는 선택한 에이전트 화면에 현재 표시되지 않습니다.", + "sub.savedRefreshFailed": "빠른 선택 {n}개를 디스크에 저장했지만 카탈로그를 정상적으로 새로고침하지 못했습니다. 기존 워커를 다시 시작하지 않았습니다.", + "sub.savedSuperseded": "이 저장은 더 최신 변경으로 대체되었습니다. 마지막으로 저장된 로스터를 표시합니다.", + "sub.savedIntegrationDisabled": "CodexCommander에 빠른 선택 {n}개를 저장했습니다. Codex 통합이 꺼져 있어 Codex 라우팅과 카탈로그 파일은 변경하지 않았습니다.", + "sub.savedNeedsApply": "CodexCommander에 빠른 선택 {n}개를 저장했습니다. Codex는 아직 네이티브 라우팅을 사용합니다. 연결하고 이 로스터를 게시할 준비가 되면 'Codex에 적용'을 선택하세요.", + "sub.savedRoutingPreserved": "CodexCommander에 빠른 선택 {n}개를 저장했습니다. CodexCommander가 현재 라우팅을 소유하는지 확인할 수 없어 기존 Codex 라우팅과 카탈로그 파일을 보존했습니다.", "sub.saveFailed": "저장 실패", "sub.networkError": "네트워크 오류 — 프록시가 실행 중인가요?", "sub.loadFail": "모델을 불러오지 못했습니다 — 프록시가 실행 중인가요?", @@ -600,6 +604,21 @@ export const ko: Record = { "sub.metadataLimited": "로스터는 사용할 수 있지만 모델 기능 세부 정보를 불러오지 못했습니다. 필터와 배지가 제한될 수 있습니다.", "sub.loading": "불러오는 중…", "sub.saving": "저장 중…", + "sub.applying": "적용 중…", + "sub.applyActivityFailed": "활성 Codex 작업을 확인하지 못했습니다", + "sub.applyFailed": "CodexCommander 라우팅과 카탈로그를 Codex에 적용하지 못했습니다", + "sub.apply.dialogTitle": "CodexCommander를 Codex에 적용할까요?", + "sub.apply.dialogIdle": "Codex 라우팅과 저장된 에이전트 카탈로그를 일치시키고 필요한 경우에만 확인된 오래된 백그라운드 워커를 교체합니다. 활성 프록시 작업은 감지되지 않았습니다.", + "sub.apply.dialogActive": "CodexCommander가 활성 프록시 작업을 감지했습니다. 적용하면 라우팅과 에이전트 카탈로그를 일치시키고 확인된 오래된 백그라운드 워커를 교체해 현재 작업이 중단될 수 있습니다.", + "sub.apply.dialogUnknown": "CodexCommander가 활성 프록시 작업을 확인하지 못했습니다. 적용하면 라우팅과 에이전트 카탈로그를 일치시키고 확인된 오래된 백그라운드 워커만 교체할 수 있습니다.", + "sub.apply.confirm": "Codex에 적용", + "sub.apply.applied": "CodexCommander 라우팅과 카탈로그를 Codex에 적용했습니다. 새 워커나 교체 워커는 저장된 로스터를 사용합니다.", + "sub.apply.alreadyCurrent": "Codex 라우팅과 워커가 이미 저장된 구성을 사용합니다.", + "sub.apply.noWorkers": "Codex 라우팅과 카탈로그가 최신입니다. 교체할 백그라운드 워커가 없습니다.", + "sub.apply.partial": "일부 확인된 오래된 Codex 워커를 교체했습니다. 다시 적용하기 전에 상태를 새로고침하세요.", + "sub.apply.superseded": "적용이 끝나기 전에 저장된 구성이 변경되었습니다. 현재 상태를 검토하고 다시 시도하세요.", + "sub.apply.blocked": "CodexCommander가 라우팅과 카탈로그를 안전하게 일치시키거나 워커를 확인할 수 없었습니다. 작업은 중단되지 않았습니다.", + "sub.apply.completed": "적용이 완료되었습니다. 상태를 새로고침하여 Codex 라우팅과 워커 활성화를 확인하세요.", "sub.saveRoster": "로스터 저장", "sub.moveUp": "{m} 위로 이동", "sub.moveDown": "{m} 아래로 이동", @@ -619,24 +638,24 @@ export const ko: Record = { "sub.policy.messageDelivery_encrypted": "암호화(네이티브)", "sub.policy.messageDelivery_plaintext": "평문 호환 모드", "sub.policy.messageDeliveryHint_encrypted": "ChatGPT의 네이티브 암호화 계약을 유지합니다. 외부 V2 워커를 사용할 수 없을 수 있습니다.", - "sub.policy.messageDeliveryHint_plaintext": "실험적 기능입니다. 다중 프로바이더 V2를 활성화하며 이 부모의 모든 V2 워커 메시지를 평문으로 전송합니다. 변경 후 새 세션을 시작하세요.", + "sub.policy.messageDeliveryHint_plaintext": "실험적 기능입니다. 다중 프로바이더 V2를 활성화하며 이 부모의 V2 작업 메시지 전달은 평문입니다. 저장한 뒤 새 작업을 시작하세요. 전달 전용 변경에는 적용이 필요하지 않습니다.", "sub.policy.preferred": "선호 안내 모델", "sub.policy.noPreferred": "선호 모델 없음 — Codex가 로스터에서 선택", "sub.policy.fallback": "전역 하위 작업 폴백", "sub.policy.fallbackHint": "요청 모델과 역할 폴백 다음의 생성된 하위 작업에 사용하며, 사용할 수 없거나 할당량 제한인 후보는 건너뜁니다.", "sub.policy.noFallback": "폴백 없음", "sub.policy.concurrency": "스레드 한도", - "sub.policy.concurrencyHint": "V2는 루트를 포함한 전체 스레드 수, V1은 하위 스레드 수를 셉니다. 비워 두면 Codex 기본값으로 돌아가며 새 세션에 적용됩니다.", + "sub.policy.concurrencyHint": "V2는 루트를 포함한 전체 스레드 수, V1은 하위 스레드 수를 셉니다. 비워 두면 Codex 기본값으로 돌아갑니다. 저장하고 워커가 실행 중이면 적용한 뒤 새 작업을 시작하세요.", "sub.policy.codexDefault": "Codex 기본값", "sub.policy.increaseConcurrency": "서브에이전트 동시 실행 수 늘리기", "sub.policy.decreaseConcurrency": "서브에이전트 동시 실행 수 줄이기", "sub.policy.save": "변경 사항 저장", - "sub.policy.saved": "실행 정책이 저장되었습니다. 프로토콜과 스레드 한도는 새 세션에 적용됩니다. V2 전달은 이후 요청부터 바뀌므로 새 세션을 시작하세요. 안내와 폴백은 이후 하위 작업에 적용됩니다.", + "sub.policy.saved": "실행 정책이 저장되었습니다. 실행 중인 워커에 적용한 뒤 프로토콜, 스레드 한도, 세션 종속 도구 스키마를 위해 새 작업을 시작하세요. V2 작업 메시지 전달은 이후 요청에, 안내와 폴백은 이후 하위 작업에 적용됩니다.", "sub.policy.saveFailed": "일부 실행 정책 변경 사항을 저장할 수 없었습니다. 저장되지 않은 선택은 계속 표시됩니다.", "sub.policy.loading": "실행 정책을 불러오는 중…", "sub.policy.retry": "정책 다시 불러오기", "sub.policy.details": "폴백 체인 및 보호 장치", - "sub.policy.timing": "프로토콜, V2 메시지 전달, 스레드 한도는 새 세션에, 안내·폴백·effort 설정은 이후 해당 요청에 적용됩니다.", + "sub.policy.timing": "먼저 저장하세요. 적용은 실행 중인 워커를 교체하고, 이후 새 작업이 프로토콜, 스레드 한도, 세션 종속 도구 스키마를 받습니다. V2 작업 메시지 전달, 안내, 폴백, effort는 이후 해당 요청에 적용됩니다.", "sub.policy.fallbackChain": "정렬된 폴백 체인", "sub.policy.fallbackChainHint": "생성된 하위 작업에서 요청 모델과 역할 폴백 다음 전역 후보를 위에서 아래로 시도합니다. 비워 두면 전역 목록을 끕니다.", "sub.policy.addFallback": "폴백 추가", @@ -650,8 +669,8 @@ export const ko: Record = { "sub.policy.preferredEffortHint": "선호 모델이 사용 가능할 때 안내에서 지정하는 추론 수준입니다.", "sub.policy.guidance": "로스터를 워커 안내에 사용", "sub.policy.guidanceHint": "사용 가능한 로스터 모델을 안내에 이름으로 넣습니다. 위임이나 모든 하위 작업의 라우팅을 강제하지 않습니다.", - "sub.policy.compatibilityV2": "외부 프로바이더는 네이티브 암호화 V2 작업을 읽을 수 없습니다 (#92). 자동 안내는 알려진 비호환 워커를 제외하고 명시적 오버라이드는 안전하게 실패합니다. 다중 프로바이더 V2에는 평문 호환 모드를, 검증된 경로에는 클래식 v1을 선택하세요.", - "sub.policy.compatibilityV2Plaintext": "실험적 다중 프로바이더 V2가 활성화되었습니다. CodexCommander가 네이티브 협업 와이어를 변환하므로 네이티브 워커를 포함해 이 부모의 모든 V2 워커 메시지가 평문이 됩니다. 저장 후 새 세션을 시작하세요. 알 수 없는 스키마는 계속 안전하게 실패합니다.", + "sub.policy.compatibilityV2": "Codex 네이티브와 동시 v2는 외부 프로바이더가 읽을 수 없는 네이티브 암호화 V2 작업을 보낼 수 있습니다 (#92). 다중 프로바이더 V2에는 평문 호환 모드 또는 안정적 v1을 선택하세요. 프로토콜 선택만으로 오래된 워커가 활성화되지는 않습니다.", + "sub.policy.compatibilityV2Plaintext": "실험적 다중 프로바이더 V2가 활성화되었습니다. 이 부모의 V2 작업 메시지 전달은 네이티브 워커를 포함하여 평문입니다. V2 자체는 오래된 Codex 워커를 활성화하지 않으며 알 수 없는 스키마는 계속 안전하게 실패합니다.", "sub.policy.subagentCap": "서브에이전트 effort 상한", "sub.policy.subagentCapHint": "더 낮은 요청을 올리지 않으면서 하위 에이전트의 effort를 제한합니다.", "sub.filter.label": "기능별로 모델 필터링", @@ -667,11 +686,30 @@ export const ko: Record = { "sub.cap.context": "{n} 컨텍스트", "sub.catalog.current": "Codex 워커 최신", "sub.catalog.restartNeeded": "재시작 필요", - "sub.catalog.nextSession": "다음 세션부터 적용", + "sub.catalog.applyNeeded": "적용 필요", + "sub.catalog.restartChatGPT": "ChatGPT 다시 시작", + "sub.catalog.applyUnavailable": "적용할 수 없음", + "sub.catalog.integrationDisabled": "Codex 통합 꺼짐", + "sub.catalog.pending": "카탈로그 준비 안 됨", + "sub.catalog.nextSession": "다음 워커 시작 시 로드", "sub.catalog.unknown": "카탈로그 상태 알 수 없음", - "sub.catalog.staleNotice": "저장된 카탈로그가 실행 중인 Codex 세션 {n}개와 다릅니다. 세션을 재시작하고 이 로스터를 적용할 준비가 되면 {cmd} 를 실행하세요.", - "sub.catalog.notRunningNotice": "이 카탈로그를 사용하는 실행 중인 Codex 세션이 없습니다. 저장된 변경 사항은 다음 세션 시작 시 적용됩니다.", - "sub.roster.excludedNotice": "저장된 로스터 모델이 현재 V2 spawn_agent에 표시되지 않습니다({n}개): {models}. 카탈로그 파일은 로드됐지만 이 로스터는 완전히 적용되지 않았습니다.", + "sub.catalog.applyNotice": "CodexCommander 라우팅과 저장된 카탈로그는 준비되었지만 확인된 Codex 워커가 아직 이전 구성을 사용합니다.", + "sub.catalog.routingNotInjectedNotice": "Codex가 아직 기본 라우팅을 사용합니다. 적용하면 Codex를 CodexCommander에 연결하고 저장된 에이전트 카탈로그를 불러옵니다.", + "sub.catalog.externalRoutingNotice": "Codex가 CodexCommander가 소유하지 않는 사용자 지정 라우팅을 사용합니다. 해당 구성을 덮어쓰지 않도록 적용을 사용할 수 없습니다.", + "sub.catalog.unknownRoutingNotice": "Codex 라우팅을 안전하게 분류할 수 없습니다. Codex 통합이 복구되거나 복원될 때까지 적용을 사용할 수 없습니다.", + "sub.catalog.integrationDisabledNotice": "Codex 통합이 꺼져 있습니다. 로스터는 CodexCommander에 저장되지만 Codex에는 적용되지 않습니다.", + "sub.catalog.pendingNotice": "저장된 카탈로그를 아직 조정해야 합니다. 적용은 먼저 라우팅과 카탈로그 파일을 동기화하고 성공한 경우에만 확인된 오래된 워커를 중단합니다.", + "sub.catalog.blockedNotice": "변경 사항은 저장되었지만 이 대시보드에서 적용을 안전하게 승인할 수 없습니다. CodexCommander를 새로 고친 뒤 다시 시도하세요. 워커는 중단되지 않습니다.", + "sub.catalog.launcherRequiredNotice": "이 대시보드에서는 적용이 읽기 전용입니다. 프로세스 중단을 확인하려면 `ccx gui` 또는 CodexCommander 메뉴 막대 앱에서 여세요.", + "sub.catalog.applyAction": "Codex에 적용", + "sub.catalog.restartChatGPTNotice": "카탈로그가 저장되었습니다. ChatGPT를 완전히 종료하고 다시 연 다음 새 작업을 시작하세요. 이 방법이 로스터를 가장 확실하게 불러옵니다.", + "sub.catalog.checkStatusAction": "상태 확인", + "sub.catalog.forceRestartNotice": "고급 대안: 백그라운드 워커를 강제로 다시 시작하면 ChatGPT에 ‘예기치 않게 중지됨’이 표시될 수 있습니다.", + "sub.catalog.forceRestartAction": "워커 강제 재시작", + "sub.catalog.newTaskNote": "새 작업만 시작해도 Codex 백그라운드 워커가 다시 로드되거나 라우팅이 바뀌지 않습니다. 적용은 둘을 일치시키고 확인된 오래된 워커만 교체합니다.", + "sub.catalog.legacyStaleNotice": "저장된 카탈로그가 실행 중인 Codex 세션 {n}개와 다릅니다. 이 프록시는 아직 보호된 적용을 제공하지 않습니다. 재시작할 준비가 되면 고급 대안으로 {cmd} 를 사용하세요.", + "sub.catalog.notRunningNotice": "실행 중인 Codex 백그라운드 워커가 없습니다. 다음 워커 시작 시 저장된 카탈로그를 불러옵니다.", + "sub.roster.excludedNotice": "저장된 로스터 모델이 선택한 spawn_agent 화면에 현재 표시되지 않습니다({n}개): {models}. 카탈로그 파일은 로드됐지만 이 로스터는 완전히 적용되지 않았습니다.", "sub.workspace.addToFeatured": "{m}을(를) 활성 로스터에 추가", "sub.workspace.featuredFull": "활성 로스터가 가득 찼습니다 (최대 5개)", "sub.workspace.removeFromFeatured": "{m}을(를) 활성 로스터에서 제거", @@ -1446,6 +1484,9 @@ export const ko: Record = { "common.close": "닫기", "common.ok": "확인", "app.logoAria": "CodexCommander 로고", + "app.launchRequiredDashboard": "대시보드 접근이 확인되지 않았습니다. `ccx gui` 또는 CodexCommander 메뉴 막대 앱에서 다시 여세요.", + "app.adminRequiredDashboard": "대시보드 접근이 인증되지 않았습니다. 메시지가 표시되면 설정된 관리자 토큰을 입력하세요.", + "app.secureOriginRequiredDashboard": "대시보드 접근이 인증되지 않았습니다. 원격 운영자 로그인은 신뢰할 수 있는 HTTPS가 필요합니다. 그렇지 않으면 `ccx gui` 또는 메뉴 막대 앱에서 로컬로 여세요.", "app.claudeOn": "Claude ON", "app.claudeOff": "Claude OFF", "usage.dayMon": "월", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 1eca5dd872..4bbd741cf8 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -77,6 +77,9 @@ export const ru: Record = { "auth.adminTokenRejected": "Токен администратора отклонён. Проверьте его и повторите попытку.", "auth.adminTokenUnavailable": "Не удалось проверить токен администратора. Повторите попытку.", "app.logoAria": "Логотип CodexCommander", + "app.launchRequiredDashboard": "Доступ к панели не подтверждён. Откройте её заново через `ccx gui` или приложение CodexCommander в строке меню.", + "app.adminRequiredDashboard": "Доступ к панели не авторизован. При запросе введите настроенный токен администратора.", + "app.secureOriginRequiredDashboard": "Доступ к панели не авторизован. Удалённый вход оператора требует доверенного HTTPS; иначе откройте панель локально через `ccx gui` или приложение в строке меню.", "app.claudeOn": "Claude ВКЛ", "app.claudeOff": "Claude ВЫКЛ", "theme.label": "Тема", @@ -439,9 +442,9 @@ export const ru: Record = { "models.currentBehavior": "Текущее поведение", "models.collaborationTitle": "Совместная работа", "models.change": "Изменить", - "models.newSessionsOnly": "Только новые сессии", - "models.modeLabel_v1": "Классический v1", - "models.modeLabel_default": "Следовать настройкам Codex", + "models.newSessionsOnly": "Сохранить, применить, начать новую задачу", + "models.modeLabel_v1": "Надёжный v1", + "models.modeLabel_default": "Нативный Codex", "models.modeLabel_v2": "Параллельный v2", "models.modeStatus_v1": "Гибкий выбор модели", "models.modeStatus_default": "Настройки Codex", @@ -509,10 +512,10 @@ export const ru: Record = { "models.v2ModeDesc_v1": "Все модели → поверхность v1", "models.v2ModeDesc_default": "Вышестоящие значения по умолчанию (sol/terra=v2, luna=v1)", "models.v2ModeDesc_v2": "Все модели → поверхность v2", - "models.v2Help": "Управляет мультиагентной поверхностью для всех моделей.\n\nv1: Классический однопоточный агент. Каждая модель использует поверхность взаимодействия v1.\nbase: Вышестоящие значения по умолчанию — sol/terra используют v2, luna использует v1, остальные следуют функциональному флагу codex.\nv2: Многопоточный агент со spawn_agent. Каждая модель использует поверхность взаимодействия v2.\n\nИзменения применяются к новым сессиям.", + "models.v2Help": "Управляет мультиагентной поверхностью для всех моделей.\n\nv1: Классический однопоточный агент. Каждая модель использует поверхность взаимодействия v1.\nbase: Вышестоящие значения по умолчанию — sol/terra используют v2, luna использует v1, остальные следуют функциональному флагу codex.\nv2: Многопоточный агент со spawn_agent. Каждая модель использует поверхность взаимодействия v2.\n\nСначала сохраните. Если воркер Codex уже запущен, замените его через Применить, затем начните новую задачу для сессионных схем инструментов. Одна новая задача не перезагружает существующий воркер.", "dash.multiAgent": "Подагент", "models.v2Conflict": "Задан [agents] max_threads — codex откажется запускаться; удалите его из config.toml", - "models.v2Applied": "Режим подагента обновлён — применяется к новым сессиям (перезапустите приложение Codex, чтобы обновить селектор моделей)", + "models.v2Applied": "Режим подагента сохранён. Примените к работающему воркеру, затем начните новую задачу для сессионных изменений.", "models.v2ThreadsLabel": "Макс. потоков", "models.v2ThreadsDefault": "по умолчанию (4)", "models.v2ThreadsApplied": "Лимит потоков обновлён — применяется к новым сессиям", @@ -525,7 +528,7 @@ export const ru: Record = { "models.collapseAll": "Свернуть все", "models.expandAll": "Развернуть все", "models.orderHint": "Порядок в селекторе: модели, выбранные на странице «Подагенты» (в заданном порядке) → остальные маршрутизируемые модели по алфавиту — сначала по провайдеру, затем по ID модели → нативные модели. Переключатели видимости лишь фильтруют модели и не меняют этот порядок.", - "models.catalogBehaviorHint": "Скрытые модели исчезают из каталога и селектора, но остаются доступны по точному ID. Изменения действуют со следующего хода Codex; перезапуск не требуется.", + "models.catalogBehaviorHint": "Скрытые модели исчезают из каталога и селектора, но остаются доступны по точному ID. Сохраните изменения каталога, примените при работающем воркере, затем начните новую задачу для сессионных схем инструментов. Одна новая задача не перезагружает существующий воркер.", "models.custom": "Другое…", "models.customApply": "Применить", "models.customPlaceholder": "Токены (напр. 420000)", @@ -556,7 +559,7 @@ export const ru: Record = { "models.tipStatus": "Статус", "models.tipActive": "Активна", "models.tipDisabled": "Отключена", - "models.applied": "Применено — вступит в силу на следующем ходе Codex.", + "models.applied": "Сохранено. Примените к работающему воркеру, затем начните новую задачу для сессионных изменений.", "models.saveFailed": "Не удалось сохранить", "models.networkError": "Ошибка сети — запущен ли прокси?", "models.loadFail": "Не удалось загрузить модели — запущен ли прокси?", @@ -592,9 +595,13 @@ export const ru: Record = { "sub.settings": "Политика запуска", "sub.delegation.model": "Предпочтительная модель подсказки", "sub.delegation.modelHint": "Модель, которую CodexCommander первой называет в подсказке. Состав также доступен для явных переопределений.", - "sub.saved": "Сохранено быстрых выборов: {n}. Начните новую сессию Codex или выполните {cmd}, чтобы обновить существующие сессии.", - "sub.savedExcluded": "Сохранено быстрых выборов: {n}, но {missing} сейчас не рекламируются V2-воркерам.", - "sub.savedRefreshFailed": "Сохранено быстрых выборов на диск: {n}, но работающий каталог не обновился корректно. Выполните {cmd}, прежде чем полагаться на новый порядок.", + "sub.saved": "Сохранено быстрых выборов: {n}. Диск и сгенерированный каталог актуальны; воркеры Codex не перезапускались.", + "sub.savedExcluded": "Сохранено быстрых выборов: {n}, но {missing} сейчас не показываются на выбранной агентной поверхности.", + "sub.savedRefreshFailed": "Быстрые выборы сохранены на диск: {n}, но каталог не удалось корректно обновить. Существующие воркеры не перезапускались.", + "sub.savedSuperseded": "Это сохранение было заменено более новым изменением. Показан последний сохранённый ростер.", + "sub.savedIntegrationDisabled": "Быстрые выборы сохранены в CodexCommander: {n}. Интеграция с Codex отключена, поэтому маршрутизация Codex и файлы каталога не изменялись.", + "sub.savedNeedsApply": "Быстрые выборы сохранены в CodexCommander: {n}. Codex всё ещё использует нативную маршрутизацию; когда будете готовы подключить его и опубликовать этот ростер, выберите «Применить к Codex».", + "sub.savedRoutingPreserved": "Быстрые выборы сохранены в CodexCommander: {n}. Существующая маршрутизация Codex и файлы каталога сохранены, поскольку не удалось подтвердить, что CodexCommander управляет текущей маршрутизацией.", "sub.saveFailed": "Не удалось сохранить", "sub.networkError": "Ошибка сети — запущен ли прокси?", "sub.loadFail": "Не удалось загрузить модели — запущен ли прокси?", @@ -602,6 +609,21 @@ export const ru: Record = { "sub.metadataLimited": "Состав доступен, но сведения о возможностях моделей загрузить не удалось. Фильтры и значки могут быть ограничены.", "sub.loading": "Загрузка…", "sub.saving": "Сохранение…", + "sub.applying": "Применение…", + "sub.applyActivityFailed": "Не удалось проверить активную работу Codex", + "sub.applyFailed": "Не удалось применить маршрутизацию CodexCommander и каталог к Codex", + "sub.apply.dialogTitle": "Применить CodexCommander к Codex?", + "sub.apply.dialogIdle": "Маршрутизация Codex и сохранённый каталог агентов будут согласованы, а при необходимости заменены только подтверждённо устаревшие фоновые воркеры. Активная работа прокси не обнаружена.", + "sub.apply.dialogActive": "CodexCommander обнаружил активную работу прокси. Применение согласует маршрутизацию и каталог агентов, а также может заменить подтверждённо устаревший фоновый воркер и прервать текущую работу.", + "sub.apply.dialogUnknown": "CodexCommander не смог проверить активную работу прокси. Применение согласует маршрутизацию и каталог агентов и может заменить только подтверждённо устаревший фоновый воркер.", + "sub.apply.confirm": "Применить к Codex", + "sub.apply.applied": "Маршрутизация CodexCommander и каталог применены к Codex. Новые или заменённые воркеры будут использовать сохранённый состав.", + "sub.apply.alreadyCurrent": "Маршрутизация и воркеры Codex уже используют сохранённую конфигурацию.", + "sub.apply.noWorkers": "Маршрутизация Codex и каталог актуальны. Фоновый воркер не требовал замены.", + "sub.apply.partial": "Некоторые подтверждённо устаревшие воркеры Codex заменены; обновите статус перед повторным применением.", + "sub.apply.superseded": "Сохранённая конфигурация изменилась до завершения применения. Проверьте текущий статус и повторите попытку.", + "sub.apply.blocked": "CodexCommander не смог безопасно согласовать маршрутизацию и каталог или проверить воркеры. Ничего не было прервано.", + "sub.apply.completed": "Применение завершено. Обновите статус, чтобы подтвердить маршрутизацию Codex и активацию воркеров.", "sub.saveRoster": "Сохранить состав", "sub.moveUp": "Переместить {m} вверх", "sub.moveDown": "Переместить {m} вниз", @@ -621,24 +643,24 @@ export const ru: Record = { "sub.policy.messageDelivery_encrypted": "Шифрование (нативное)", "sub.policy.messageDelivery_plaintext": "Совместимость в открытом виде", "sub.policy.messageDeliveryHint_encrypted": "Сохраняет нативный зашифрованный контракт ChatGPT; внешние V2-воркеры могут быть недоступны.", - "sub.policy.messageDeliveryHint_plaintext": "Экспериментально. Включает V2 между провайдерами; все V2-сообщения этого родителя передаются открытым текстом. После изменения начните новую сессию.", + "sub.policy.messageDeliveryHint_plaintext": "Экспериментально. Включает V2 между провайдерами; доставка сообщений V2-задач от этого родителя идёт открытым текстом. Сохраните и начните новую задачу. Для изменения только доставки применять каталог не нужно.", "sub.policy.preferred": "Предпочтительная модель подсказки", "sub.policy.noPreferred": "Нет предпочтительной модели — Codex выберет из состава", "sub.policy.fallback": "Глобальный fallback дочерних задач", "sub.policy.fallbackHint": "Используется для созданных дочерних задач после запрошенной модели и fallback роли; недоступные или квотные кандидаты пропускаются.", "sub.policy.noFallback": "Без запасного варианта", "sub.policy.concurrency": "Лимит потоков", - "sub.policy.concurrencyHint": "V2 считает все потоки вместе с корневым, V1 — дочерние потоки. Пусто возвращает значение Codex по умолчанию. Только новые сессии.", + "sub.policy.concurrencyHint": "V2 считает все потоки вместе с корневым, V1 — дочерние потоки. Пусто возвращает значение Codex по умолчанию. Сохраните, примените при работающем воркере, затем начните новую задачу.", "sub.policy.codexDefault": "По умолчанию в Codex", "sub.policy.increaseConcurrency": "Увеличить параллелизм субагентов", "sub.policy.decreaseConcurrency": "Уменьшить параллелизм субагентов", "sub.policy.save": "Сохранить изменения", - "sub.policy.saved": "Политика запуска сохранена. Протокол и лимит потоков применятся к новым сессиям. Доставка V2 меняется для следующих запросов, поэтому начните новую сессию; подсказки и fallback относятся к будущим дочерним задачам.", + "sub.policy.saved": "Политика запуска сохранена. Примените к работающему воркеру, затем начните новую задачу для протокола, лимита потоков и сессионных схем инструментов. Доставка сообщений V2-задач влияет на последующие запросы; подсказки и fallback — на будущие дочерние задачи.", "sub.policy.saveFailed": "Некоторые изменения политики запуска сохранить не удалось. Ваши несохранённые варианты по-прежнему отображаются.", "sub.policy.loading": "Загрузка политики запуска…", "sub.policy.retry": "Перезагрузить политику", "sub.policy.details": "Цепочка запасных вариантов и защитные меры", - "sub.policy.timing": "Протокол, доставка сообщений V2 и лимит потоков применяются к новым сессиям; подсказки, fallback и effort — к последующим подходящим запросам.", + "sub.policy.timing": "Сначала сохраните. Применить заменяет работающий воркер; затем новая задача получает протокол, лимит потоков и сессионные схемы инструментов. Доставка сообщений V2-задач, подсказки, fallback и effort влияют на последующие подходящие запросы.", "sub.policy.fallbackChain": "Упорядоченная цепочка запасных вариантов", "sub.policy.fallbackChainHint": "Для созданных дочерних задач глобальные кандидаты пробуются сверху вниз после запрошенной модели и fallback роли. Пустой список отключает глобальную цепочку.", "sub.policy.addFallback": "Добавить запасной вариант", @@ -652,8 +674,8 @@ export const ru: Record = { "sub.policy.preferredEffortHint": "Уровень рассуждения, который указывается в подсказке, если предпочтительная модель доступна.", "sub.policy.guidance": "Использовать состав в подсказке воркера", "sub.policy.guidanceHint": "Называет подходящие модели состава в подсказке. Не принуждает к делегированию и не маршрутизирует каждого ребёнка.", - "sub.policy.compatibilityV2": "Внешние провайдеры не могут прочитать нативные зашифрованные V2-задачи (#92). Автоматические подсказки исключают известные несовместимые модели, а явные overrides завершаются безопасной ошибкой. Для V2 между провайдерами выберите совместимость в открытом виде, либо используйте проверенный Classic v1.", - "sub.policy.compatibilityV2Plaintext": "Экспериментальный V2 между провайдерами включён. CodexCommander преобразует нативный протокол совместной работы, поэтому все V2-сообщения этого родителя, включая сообщения нативным воркерам, передаются открытым текстом. После сохранения начните новую сессию; неизвестные схемы по-прежнему завершаются безопасной ошибкой.", + "sub.policy.compatibilityV2": "Нативный Codex и Параллельный v2 могут отправлять нативные зашифрованные V2-задачи, недоступные внешним провайдерам (#92). Для V2 между провайдерами выберите открытый режим либо Надёжный v1. Выбор протокола сам по себе не обновляет устаревший воркер.", + "sub.policy.compatibilityV2Plaintext": "Экспериментальный V2 между провайдерами включён. Доставка сообщений V2-задач от этого родителя идёт открытым текстом, включая нативные воркеры. Сам V2 не активирует устаревший воркер Codex; неизвестные схемы по-прежнему завершаются безопасной ошибкой.", "sub.policy.subagentCap": "Потолок effort для субагентов", "sub.policy.subagentCapHint": "Ограничивает effort дочерних агентов, не повышая более низкие запросы.", "sub.filter.label": "Фильтровать модели по возможностям", @@ -669,11 +691,30 @@ export const ru: Record = { "sub.cap.context": "{n} контекст", "sub.catalog.current": "Воркеры Codex актуальны", "sub.catalog.restartNeeded": "Требуется перезапуск", - "sub.catalog.nextSession": "Применится со следующей сессии", + "sub.catalog.applyNeeded": "Требуется применить", + "sub.catalog.restartChatGPT": "Перезапустить ChatGPT", + "sub.catalog.applyUnavailable": "Применение недоступно", + "sub.catalog.integrationDisabled": "Интеграция Codex отключена", + "sub.catalog.pending": "Каталог не готов", + "sub.catalog.nextSession": "Загрузится при следующем запуске воркера", "sub.catalog.unknown": "Статус каталога неизвестен", - "sub.catalog.staleNotice": "Сохранённый каталог отличается от {n} запущенных сессий Codex. Выполните {cmd}, когда будете готовы перезапустить их и применить этот состав.", - "sub.catalog.notRunningNotice": "Ни одна запущенная сессия Codex не использует этот каталог. Сохранённые изменения применятся при запуске следующей сессии.", - "sub.roster.excludedNotice": "Сохранённые модели состава сейчас не рекламируются V2 spawn_agent ({n}): {models}. Файл каталога загружен, но состав действует не полностью.", + "sub.catalog.applyNotice": "Маршрутизация CodexCommander и сохранённый каталог готовы, но подтверждённые воркеры Codex всё ещё используют старую конфигурацию.", + "sub.catalog.routingNotInjectedNotice": "Codex всё ещё использует собственную маршрутизацию. Применение подключит Codex к CodexCommander и загрузит сохранённый каталог агентов.", + "sub.catalog.externalRoutingNotice": "Codex использует пользовательскую маршрутизацию, которой CodexCommander не управляет. Применение недоступно, чтобы панель не перезаписала эту конфигурацию.", + "sub.catalog.unknownRoutingNotice": "Не удалось безопасно определить маршрутизацию Codex. Применение недоступно, пока интеграция Codex не будет исправлена или восстановлена.", + "sub.catalog.integrationDisabledNotice": "Интеграция Codex отключена. Состав останется сохранённым в CodexCommander, но не будет применён к Codex.", + "sub.catalog.pendingNotice": "Сохранённый каталог ещё нужно согласовать. Применение сначала синхронизирует маршрутизацию и файлы каталога и прервёт проверенный устаревший воркер только после успеха.", + "sub.catalog.blockedNotice": "Изменения сохранены, но эта панель не может безопасно разрешить применение. Обновите CodexCommander и повторите; воркеры не будут прерваны.", + "sub.catalog.launcherRequiredNotice": "В этой панели применение доступно только для чтения. Откройте её через `ccx gui` или приложение CodexCommander в строке меню, чтобы подтвердить прерывание процесса.", + "sub.catalog.applyAction": "Применить к Codex", + "sub.catalog.restartChatGPTNotice": "Каталог сохранён. Полностью закройте ChatGPT, откройте его снова и затем начните новую задачу. Это самый надёжный способ загрузить список.", + "sub.catalog.checkStatusAction": "Проверить статус", + "sub.catalog.forceRestartNotice": "Расширенный резервный вариант: принудительный перезапуск фоновых воркеров может вызвать в ChatGPT сообщение «неожиданно остановлен».", + "sub.catalog.forceRestartAction": "Принудительно перезапустить воркеры", + "sub.catalog.newTaskNote": "Новая задача сама по себе не перезагружает фоновый воркер Codex и не меняет его маршрутизацию. Применение согласует оба состояния и заменяет только подтверждённо устаревшие воркеры.", + "sub.catalog.legacyStaleNotice": "Сохранённый каталог отличается от {n} запущенных сессий Codex. Этот прокси ещё не предоставляет защищённое применение; используйте {cmd} как расширенный резервный вариант, когда будете готовы перезапустить их.", + "sub.catalog.notRunningNotice": "Фоновый воркер Codex не запущен. Следующий запуск воркера загрузит сохранённый каталог.", + "sub.roster.excludedNotice": "Сохранённые модели состава сейчас не показываются на выбранной поверхности spawn_agent ({n}): {models}. Файл каталога загружен, но состав действует не полностью.", "sub.workspace.addToFeatured": "Добавить {m} в активный состав", "sub.workspace.featuredFull": "Активный состав заполнен (макс. 5)", "sub.workspace.removeFromFeatured": "Убрать {m} из активного состава", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 29209fbdb7..9add3cf086 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -434,9 +434,9 @@ export const zh: Record = { "models.currentBehavior": "当前行为", "models.collaborationTitle": "协作", "models.change": "更改", - "models.newSessionsOnly": "仅新会话", - "models.modeLabel_v1": "经典 v1", - "models.modeLabel_default": "遵循 Codex 默认值", + "models.newSessionsOnly": "保存、应用、启动新任务", + "models.modeLabel_v1": "可靠 v1", + "models.modeLabel_default": "Codex 原生", "models.modeLabel_v2": "并发 v2", "models.modeStatus_v1": "灵活选择模型", "models.modeStatus_default": "Codex 默认值", @@ -503,11 +503,11 @@ export const zh: Record = { "models.v2ModeDesc_v1": "所有模型 → v1 界面", "models.v2ModeDesc_default": "上游默认值 (sol/terra=v2, luna=v1)", "models.v2ModeDesc_v2": "所有模型 → v2 界面", - "models.v2Help": "控制所有模型的多代理界面。\n\nv1: 经典单线程代理。所有模型使用 v1 协作界面。\nbase: 上游默认值 — sol/terra 使用 v2,luna 使用 v1,其余跟随 codex 功能标志。\nv2: 多线程代理(spawn_agent)。所有模型使用 v2 协作界面。\n\n更改在新会话中生效。", + "models.v2Help": "控制所有模型的多代理界面。\n\nv1: 经典单线程代理。所有模型使用 v1 协作界面。\nbase: 上游默认值 — sol/terra 使用 v2,luna 使用 v1,其余跟随 codex 功能标志。\nv2: 多线程代理(spawn_agent)。所有模型使用 v2 协作界面。\n\n先保存。如果 Codex 工作者正在运行,请用应用替换它,然后为会话绑定的工具架构启动新任务。仅启动新任务不会重新加载现有工作器。", "models.v2DocsLink": "v1 / v2 是什么?", "dash.multiAgent": "子代理", "models.v2Conflict": "[agents] max_threads 仍存在 — codex 将拒绝启动,请从 config.toml 移除", - "models.v2Applied": "子代理模式已更新 — 新会话生效(重启 Codex 应用以刷新选择器)", + "models.v2Applied": "子代理模式已保存。对运行中的工作器应用后,再为会话绑定的更改启动新任务。", "models.v2ThreadsLabel": "最大线程", "models.v2ThreadsDefault": "默认 (4)", "models.v2ThreadsApplied": "线程上限已更新 — 新会话生效", @@ -520,7 +520,7 @@ export const zh: Record = { "models.collapseAll": "全部折叠", "models.expandAll": "全部展开", "models.orderHint": "选择器顺序:Subagents 中的选择(按所选顺序)→ 其余已路由模型(依次按提供方、模型 ID 字母排序)→ 原生模型。可见性开关仅用于筛选,不会改变此顺序。", - "models.catalogBehaviorHint": "隐藏的模型会从目录和选择器中移除,但仍可通过精确 ID 调用。更改会在下一次 Codex 对话轮次生效,无需重启。", + "models.catalogBehaviorHint": "隐藏的模型会从目录和选择器中移除,但仍可通过精确 ID 调用。保存目录更改;如果工作器正在运行则应用,然后为会话绑定的工具架构启动新任务。仅启动新任务不会重新加载现有工作器。", "models.custom": "自定义…", "models.customApply": "应用", "models.customPlaceholder": "令牌 (例如 420000)", @@ -551,7 +551,7 @@ export const zh: Record = { "models.tipStatus": "状态", "models.tipActive": "已启用", "models.tipDisabled": "已禁用", - "models.applied": "已应用 — 将在下一个 Codex 回合生效。", + "models.applied": "已保存。对运行中的工作器应用后,再为会话绑定的更改启动新任务。", "models.saveFailed": "保存失败", "models.networkError": "网络错误 — 代理在运行吗?", "models.loadFail": "加载模型失败 — 代理在运行吗?", @@ -587,9 +587,13 @@ export const zh: Record = { "sub.settings": "运行策略", "sub.delegation.model": "首选指导模型", "sub.delegation.modelHint": "CodexCommander 在指导中首先点名的模型。活跃名单仍可用于显式覆盖。", - "sub.saved": "已保存 {n} 个快捷选择。启动新的 Codex 会话或运行 {cmd} 以刷新现有会话。", - "sub.savedExcluded": "已保存 {n} 个快捷选择,但其中 {missing} 个当前未向 V2 工作器展示。", - "sub.savedRefreshFailed": "已将 {n} 个快捷选择保存到磁盘,但运行中的目录未能正常刷新。在依赖新顺序之前请运行 {cmd}。", + "sub.saved": "已保存 {n} 个快捷选择。磁盘和生成的目录已是最新,但没有重启任何 Codex 工作者。", + "sub.savedExcluded": "已保存 {n} 个快捷选择,但其中 {missing} 个当前未在所选代理界面中展示。", + "sub.savedRefreshFailed": "已将 {n} 个快捷选择保存到磁盘,但目录未能正常刷新。未重启现有工作器。", + "sub.savedSuperseded": "此次保存已被更新的更改取代。当前显示的是最新保存的名册。", + "sub.savedIntegrationDisabled": "已在 CodexCommander 中保存 {n} 个快捷选择。Codex 集成已关闭,因此未更改 Codex 路由和目录文件。", + "sub.savedNeedsApply": "已在 CodexCommander 中保存 {n} 个快捷选择。Codex 仍在使用原生路由;准备好连接并发布此名册时,请选择“应用到 Codex”。", + "sub.savedRoutingPreserved": "已在 CodexCommander 中保存 {n} 个快捷选择。由于无法确认 CodexCommander 拥有当前路由,因此保留了现有 Codex 路由和目录文件。", "sub.saveFailed": "保存失败", "sub.networkError": "网络错误 — 代理在运行吗?", "sub.loadFail": "加载模型失败 — 代理在运行吗?", @@ -597,6 +601,21 @@ export const zh: Record = { "sub.metadataLimited": "名单可用,但无法加载模型能力详情。筛选和徽标可能受限。", "sub.loading": "加载中…", "sub.saving": "保存中…", + "sub.applying": "正在应用…", + "sub.applyActivityFailed": "无法检查活跃的 Codex 工作", + "sub.applyFailed": "无法将 CodexCommander 路由和目录应用到 Codex", + "sub.apply.dialogTitle": "要将 CodexCommander 应用到 Codex 吗?", + "sub.apply.dialogIdle": "这会协调 Codex 路由和已保存的代理目录,并仅在需要时替换已验证的过期后台工作器。未检测到活跃的代理工作。", + "sub.apply.dialogActive": "CodexCommander 检测到活跃的代理工作。应用会协调路由和代理目录,并可能替换已验证的过期后台工作器,从而中断当前工作。", + "sub.apply.dialogUnknown": "CodexCommander 无法检查活跃的代理工作。应用会协调路由和代理目录,并且只可能替换已验证的过期后台工作器。", + "sub.apply.confirm": "应用到 Codex", + "sub.apply.applied": "已将 CodexCommander 路由和目录应用到 Codex。新的或替换后的工作器将使用已保存的名单。", + "sub.apply.alreadyCurrent": "Codex 路由和工作器已经使用已保存的配置。", + "sub.apply.noWorkers": "Codex 路由和目录已是最新。无需替换后台工作器。", + "sub.apply.partial": "已替换部分已验证的过期 Codex 工作者;请刷新状态后再应用。", + "sub.apply.superseded": "应用完成前已保存的配置发生了变化。请查看当前状态后重试。", + "sub.apply.blocked": "CodexCommander 无法安全协调路由和目录或验证工作器。没有中断任何工作。", + "sub.apply.completed": "应用已完成。请刷新状态以确认 Codex 路由和工作器已激活。", "sub.saveRoster": "保存名单", "sub.moveUp": "上移 {m}", "sub.moveDown": "下移 {m}", @@ -616,24 +635,24 @@ export const zh: Record = { "sub.policy.messageDelivery_encrypted": "加密(原生)", "sub.policy.messageDelivery_plaintext": "明文兼容模式", "sub.policy.messageDeliveryHint_encrypted": "保留 ChatGPT 原生加密协议;外部 V2 工作者可能不可用。", - "sub.policy.messageDeliveryHint_plaintext": "实验功能。启用跨提供方 V2;该父级发送的所有 V2 工作者消息均为明文。更改后请启动新会话。", + "sub.policy.messageDeliveryHint_plaintext": "实验功能。启用跨提供方 V2;此父级发送的 V2 任务消息均为明文。保存后启动新任务。仅更改消息传递时无需应用。", "sub.policy.preferred": "首选指导模型", "sub.policy.noPreferred": "无首选模型 — Codex 从名单中选择", "sub.policy.fallback": "全局子任务回退", "sub.policy.fallbackHint": "在请求模型和角色回退之后用于已生成的子任务;不可用或受配额限制的候选项会被跳过。", "sub.policy.noFallback": "无回退", "sub.policy.concurrency": "线程上限", - "sub.policy.concurrencyHint": "V2 计算包含根代理的总线程数;V1 计算子线程数。留空恢复 Codex 默认值,仅适用于新会话。", + "sub.policy.concurrencyHint": "V2 计算包含根代理的总线程数;V1 计算子线程数。留空恢复 Codex 默认值。保存;若工作器正在运行则应用,然后启动新任务。", "sub.policy.codexDefault": "Codex 默认值", "sub.policy.increaseConcurrency": "增加子代理并发数", "sub.policy.decreaseConcurrency": "减少子代理并发数", "sub.policy.save": "保存更改", - "sub.policy.saved": "运行策略已保存。协议和线程上限适用于新会话;V2 传递会影响后续请求,因此请启动新会话;指导和回退适用于之后生成的子任务。", + "sub.policy.saved": "运行策略已保存。对运行中的工作器应用后,再为协议、线程上限和会话绑定的工具架构启动新任务。V2 任务消息传递影响后续请求;指导和回退影响之后生成的子任务。", "sub.policy.saveFailed": "部分运行策略更改无法保存。您未保存的选择仍会显示。", "sub.policy.loading": "正在加载运行策略…", "sub.policy.retry": "重新加载策略", "sub.policy.details": "回退链与保护措施", - "sub.policy.timing": "协议、V2 消息传递和线程上限适用于新会话;指导、回退和 effort 设置适用于之后符合条件的请求。", + "sub.policy.timing": "先保存。应用会替换运行中的工作器;然后新任务会获得协议、线程上限和会话绑定的工具架构。V2 任务消息传递、指导、回退和 effort 影响后续符合条件的请求。", "sub.policy.fallbackChain": "有序回退链", "sub.policy.fallbackChainHint": "对于已生成的子任务,请求模型和角色回退之后按从上到下顺序尝试全局候选项。留空则禁用全局列表。", "sub.policy.addFallback": "添加回退项", @@ -647,8 +666,8 @@ export const zh: Record = { "sub.policy.preferredEffortHint": "首选模型可用时,在指导中指定的推理级别。", "sub.policy.guidance": "将名单用于工作者指导", "sub.policy.guidanceHint": "在指导中点名可用的名单模型。不强制委派,也不为每个子任务路由。", - "sub.policy.compatibilityV2": "外部提供方无法读取原生加密的 V2 任务(#92)。自动指导会过滤已知不兼容的工作者,显式覆盖会安全失败。跨提供方 V2 可选择明文兼容模式,或使用已验证的经典 v1 路径。", - "sub.policy.compatibilityV2Plaintext": "已启用实验性的跨提供方 V2。CodexCommander 会转换原生协作协议,因此该父级的所有 V2 工作者消息(包括发给原生工作者的消息)均为明文。保存后请启动新会话;未知协议仍会安全失败。", + "sub.policy.compatibilityV2": "Codex 原生和并发 v2 可以发送外部提供方无法读取的原生加密 V2 任务(#92)。跨提供方 V2 请选择明文兼容模式,或使用可靠 v1。仅选择协议不会更新过期工作器。", + "sub.policy.compatibilityV2Plaintext": "已启用实验性的跨提供方 V2。此父级的 V2 任务消息传递均为明文,包括发给原生工作者的消息。V2 本身不会激活过期 Codex 工作者;未知协议仍会安全失败。", "sub.policy.subagentCap": "子代理 effort 上限", "sub.policy.subagentCapHint": "限制子代理的 effort,但不会提升更低的请求。", "sub.filter.label": "按能力筛选模型", @@ -664,11 +683,30 @@ export const zh: Record = { "sub.cap.context": "{n} 上下文", "sub.catalog.current": "Codex 工作器已是最新", "sub.catalog.restartNeeded": "需要重启", - "sub.catalog.nextSession": "下个会话生效", + "sub.catalog.applyNeeded": "需要应用", + "sub.catalog.restartChatGPT": "重启 ChatGPT", + "sub.catalog.applyUnavailable": "无法应用", + "sub.catalog.integrationDisabled": "Codex 集成已关闭", + "sub.catalog.pending": "目录尚未就绪", + "sub.catalog.nextSession": "下次工作器启动时加载", "sub.catalog.unknown": "目录状态未知", - "sub.catalog.staleNotice": "保存的目录与 {n} 个正在运行的 Codex 会话不同。准备好重启这些会话并应用此名单时,请运行 {cmd}。", - "sub.catalog.notRunningNotice": "没有正在运行的 Codex 会话使用此目录。保存的更改将在下个会话启动时生效。", - "sub.roster.excludedNotice": "已保存的阵容模型当前未向 V2 spawn_agent 展示({n} 个):{models}。目录文件已加载,但该阵容尚未完全生效。", + "sub.catalog.applyNotice": "CodexCommander 路由和已保存的目录已准备好,但已验证的 Codex 工作器仍在使用较旧的配置。", + "sub.catalog.routingNotInjectedNotice": "Codex 仍在使用原生路由。应用会将 Codex 连接到 CodexCommander,并加载已保存的代理目录。", + "sub.catalog.externalRoutingNotice": "Codex 使用不归 CodexCommander 管理的自定义路由。为避免控制面板覆盖该配置,应用不可用。", + "sub.catalog.unknownRoutingNotice": "无法安全判断 Codex 路由。在修复或恢复 Codex 集成之前,应用不可用。", + "sub.catalog.integrationDisabledNotice": "Codex 集成已关闭。名单仍会保存在 CodexCommander 中,但不会应用到 Codex。", + "sub.catalog.pendingNotice": "保存的目录仍需协调。应用会先同步路由和目录文件,并且仅在成功后中断经过验证的旧工作器。", + "sub.catalog.blockedNotice": "更改已保存,但此控制面板无法安全授权应用。请刷新 CodexCommander 后重试;不会中断工作器。", + "sub.catalog.launcherRequiredNotice": "此控制面板中的应用操作为只读。请通过 `ccx gui` 或 CodexCommander 菜单栏应用打开,以确认中断进程。", + "sub.catalog.applyAction": "应用到 Codex", + "sub.catalog.restartChatGPTNotice": "目录已保存。请完全退出 ChatGPT,重新打开后再开始新任务。这是加载此名单最可靠的方式。", + "sub.catalog.checkStatusAction": "检查状态", + "sub.catalog.forceRestartNotice": "高级备用方案:强制重启后台工作器可能会让 ChatGPT 显示“意外停止”。", + "sub.catalog.forceRestartAction": "强制重启工作器", + "sub.catalog.newTaskNote": "仅启动新任务不会重新加载 Codex 后台工作器或更改其路由。应用会协调两者,并且只替换已验证的过期工作器。", + "sub.catalog.legacyStaleNotice": "已保存的目录与 {n} 个正在运行的 Codex 会话不同。此代理尚未提供受保护的应用;准备重启它们时,请将 {cmd} 用作高级备用方案。", + "sub.catalog.notRunningNotice": "没有运行中的 Codex 后台工作器。下一次工作器启动会加载已保存的目录。", + "sub.roster.excludedNotice": "已保存的阵容模型当前未在所选 spawn_agent 界面中展示({n} 个):{models}。目录文件已加载,但该阵容尚未完全生效。", "sub.workspace.addToFeatured": "将 {m} 添加到活跃名单", "sub.workspace.featuredFull": "活跃名单已满(最多 5 个)", "sub.workspace.removeFromFeatured": "将 {m} 从活跃名单中移除", @@ -1443,6 +1481,9 @@ export const zh: Record = { "common.close": "关闭", "common.ok": "确定", "app.logoAria": "CodexCommander 徽标", + "app.launchRequiredDashboard": "控制面板访问尚未确认。请通过 `ccx gui` 或 CodexCommander 菜单栏应用重新打开。", + "app.adminRequiredDashboard": "控制面板访问未授权。请在提示时输入已配置的管理员令牌。", + "app.secureOriginRequiredDashboard": "控制面板访问未授权。远程运维登录需要可信的 HTTPS;否则请通过 `ccx gui` 或菜单栏应用在本地打开。", "app.claudeOn": "Claude 开", "app.claudeOff": "Claude 关", "usage.dayMon": "一", diff --git a/gui/src/pages/Subagents.tsx b/gui/src/pages/Subagents.tsx index 76eceb5a55..b9c1201497 100644 --- a/gui/src/pages/Subagents.tsx +++ b/gui/src/pages/Subagents.tsx @@ -13,6 +13,12 @@ import { DataSurfaceSkeleton } from "../components/data-surface"; import { useSubagentDelegation } from "./use-subagent-delegation"; import { useSubagentRunPolicy } from "./use-subagent-run-policy"; import SubagentRunPolicySection from "../components/subagents-workspace/SubagentRunPolicySection"; +import { setClientResourceData } from "../client-resource"; +import { + isConfirmedGuiLaunch, + subscribeGuiLaunchCapability, + whenGuiLaunchCapabilitySettles, +} from "../api"; type SubagentsSnapshot = { available: string[]; @@ -21,6 +27,7 @@ type SubagentsSnapshot = { excluded: RosterExclusion[]; models: AgentModelRow[]; catalogState?: CatalogState; + activation?: CatalogActivation; metadataLimited?: boolean; }; @@ -31,16 +38,177 @@ type RosterExclusion = { }; type SaveResponse = { + superseded?: boolean; applied?: string[]; advertised?: string[]; excluded?: RosterExclusion[]; catalogRefresh?: { ok?: boolean; status?: "committed" | "skipped" | "failed"; + reason?: string; notices?: string[]; }; + activation?: unknown; }; +type CatalogActivation = { + desiredRevision: string | null; + reloadRequired: boolean; + applyAllowed: boolean; + workerState: string | null; + catalogStatus: string | null; + applyReason: string | null; + routingStatus: "current" | "not_injected" | "external" | "unknown" | "not_required"; + routingKind: "native" | "codexcommander-local" | "custom-local" | "custom-remote" | "unknown"; + protocol: string | null; + advertised: string[]; + excluded: RosterExclusion[]; +}; + +type ApplyDialogState = "idle" | "active" | "unknown"; + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +const routingStatuses = ["current", "not_injected", "external", "unknown", "not_required"] as const; +const routingKinds = ["native", "codexcommander-local", "custom-local", "custom-remote", "unknown"] as const; +const catalogStatuses = ["current", "pending", "degraded", "unknown"] as const; +const workerStates = ["current", "reload_required", "not_running", "unknown"] as const; +const applyReasons = [ + "reload-required", + "routing-not-injected", + "external-routing", + "routing-unknown", + "integration-disabled", + "already-current", + "no-workers", + "worker-state-unknown", + "catalog-not-ready", + "confirmed-launch-required", +] as const; + +function enumValue(value: unknown, allowed: readonly T[]): T | null { + return typeof value === "string" && allowed.includes(value as T) ? value as T : null; +} + +function routingPairIsCoherent( + status: (typeof routingStatuses)[number], + kind: (typeof routingKinds)[number], +): boolean { + if (status === "current") return kind === "codexcommander-local"; + if (status === "not_injected") return kind === "native"; + if (status === "external") return kind === "custom-local" || kind === "custom-remote"; + if (status === "unknown") return kind === "unknown"; + return true; +} + +/** Parse display fields defensively while keeping Apply authorization strict. */ +function parseActivation(value: unknown): CatalogActivation | undefined { + const activation = asRecord(value); + if (!activation) return undefined; + const desired = asRecord(activation.desired); + const workers = asRecord(activation.workers); + const apply = asRecord(activation.apply); + const catalog = asRecord(activation.catalog); + const routing = asRecord(activation.routing); + const revision = typeof activation.desiredRevision === "string" + ? activation.desiredRevision + : typeof activation.revision === "string" + ? activation.revision + : typeof desired?.revision === "string" ? desired.revision : null; + const desiredRevision = revision !== null && revision.length >= 4 && revision.length <= 128 + ? revision + : null; + const rawWorkerState = typeof workers?.status === "string" + ? workers.status + : typeof workers?.state === "string" + ? workers.state + : typeof activation.workerState === "string" + ? activation.workerState + : typeof activation.state === "string" ? activation.state : null; + const workerState = rawWorkerState === "fresh" + ? "current" + : rawWorkerState === "stale" + ? "reload_required" + : enumValue(rawWorkerState, workerStates); + const catalogStatus = enumValue(catalog?.status, catalogStatuses); + const parsedRoutingStatus = enumValue(routing?.status, routingStatuses); + const parsedRoutingKind = enumValue(routing?.kind, routingKinds); + const routingPairValid = parsedRoutingStatus !== null + && parsedRoutingKind !== null + && routingPairIsCoherent(parsedRoutingStatus, parsedRoutingKind); + const routingStatus = routingPairValid ? parsedRoutingStatus : "unknown"; + const routingKind = routingPairValid ? parsedRoutingKind : "unknown"; + const applyReason = enumValue(apply?.reason, applyReasons); + const reloadRequired = apply?.required === true + || activation.reloadRequired === true + || workerState === "reload_required" + || routingStatus === "not_injected"; + const routingCanApply = routingStatus === "current" || routingStatus === "not_injected"; + const reasonCanApply = applyReason === "reload-required" + || applyReason === "routing-not-injected" + || applyReason === "catalog-not-ready"; + // Apply can interrupt a background worker and rewrite Codex routing. Require + // the complete current authorization shape; missing, malformed, or + // contradictory fields always disable the client action. + const applyAllowed = apply?.allowed === true + && apply?.required === true + && desiredRevision !== null + && routingCanApply + && workerState !== null + && workerState !== "unknown" + && reasonCanApply; + const advertised = Array.isArray(catalog?.advertised) + ? catalog.advertised.filter((model): model is string => typeof model === "string") + : []; + const excluded = Array.isArray(catalog?.excluded) + ? catalog.excluded.flatMap(value => { + const item = asRecord(value); + return typeof item?.configured === "string" && typeof item.reason === "string" + ? [{ + configured: item.configured, + reason: item.reason, + ...(typeof item.catalogModel === "string" ? { catalogModel: item.catalogModel } : {}), + }] + : []; + }) + : []; + return { + desiredRevision, + reloadRequired, + applyAllowed, + workerState, + catalogStatus, + applyReason, + routingStatus, + routingKind, + protocol: typeof desired?.protocol === "string" ? desired.protocol : null, + advertised, + excluded, + }; +} + +function applyOutcome(value: unknown): string | null { + const response = asRecord(value); + if (!response) return null; + for (const key of ["outcome", "status", "result"] as const) { + if (typeof response[key] === "string") return response[key]; + } + return null; +} + +async function readApplyResponse(response: Response, fallback: string): Promise<{ data: unknown; ok: boolean }> { + if (response.ok) return { data: await readJsonOrThrow(response, fallback), ok: true }; + try { + return { data: await response.json(), ok: false }; + } catch { + throw new Error(fallback); + } +} + function seedSubagents(cacheKey: string): SubagentsSnapshot | null { return readSessionListCache(cacheKey); } @@ -54,10 +222,19 @@ export default function Subagents({ apiBase }: { apiBase: string }) { const [status, setStatus] = useState(""); const [statusTone, setStatusTone] = useState<"ok" | "warn" | "err">("ok"); const [busy, setBusy] = useState(false); + const [applyDialog, setApplyDialog] = useState(null); const [catalogLive, setCatalogLive] = useState(false); + const [confirmedGuiLaunch, setConfirmedGuiLaunch] = useState(isConfirmedGuiLaunch); /** Sync guard: state-only `busy` can miss clicks before the disabled re-render commits. */ const saveInFlight = useRef(false); const catalogRefreshRequested = useRef(false); + const applyInFlight = useRef(false); + /** Last server-persisted roster; the dirty guard for polled revalidations. */ + const committedChosenRef = useRef(cached?.chosen ?? []); + const applyTriggerRef = useRef(null); + const applyCancelRef = useRef(null); + const applyConfirmRef = useRef(null); + const busyRef = useRef(busy); const delegation = useSubagentDelegation(apiBase); const runPolicy = useSubagentRunPolicy(apiBase); @@ -69,6 +246,7 @@ export default function Subagents({ apiBase }: { apiBase: string }) { advertised?: string[]; excluded?: RosterExclusion[]; catalogState?: CatalogState; + activation?: unknown; }>(res, t("sub.loadFail"))); const metadataRequest = fetch(`${apiBase}/api/models`) .then(res => readJsonOrThrow(res, t("sub.metadataLoadFail"))) @@ -88,9 +266,18 @@ export default function Subagents({ apiBase }: { apiBase: string }) { excluded: response.excluded ?? [], models: (modelRows ?? []).filter(model => !model.disabled), catalogState: response.catalogState, + activation: parseActivation(response.activation), metadataLimited: modelRows === null, }; - setChosen(next.chosen); + // The 5s surface poll must never clobber unsaved roster edits: adopt the + // server list only while the visible roster matches the last committed one. + setChosen(previous => { + const committed = committedChosenRef.current; + if (previous.length === committed.length && previous.every((model, index) => model === committed[index])) { + return nextChosen; + } + return previous; + }); setCommittedChosen(next.chosen); setCatalogLive(true); writeSessionListCache(cacheKey, next); @@ -101,7 +288,7 @@ export default function Subagents({ apiBase }: { apiBase: string }) { cacheKey, [apiBase], loadSubagents, - { isEmpty: () => false, initialData: cached ?? undefined }, + { isEmpty: () => false, initialData: cached ?? undefined, pollMs: 5000 }, ); const { state } = resource; const load = resource.refresh; @@ -110,6 +297,82 @@ export default function Subagents({ apiBase }: { apiBase: string }) { const models = snapshot?.models ?? []; const hasRoutedModels = models.some(model => model.native !== true && model.namespaced.includes("/")); const catalogState = catalogLive ? snapshot?.catalogState : undefined; + // The sessionStorage seed is a warm-cache convenience, not live process state. + // Gate activation-derived UI (banners, Apply/force-restart affordances) on the + // first live fetch completing — mirroring `catalogState` — so a stale seed can + // never paint an actionable banner as final state before revalidation resolves. + const activation = catalogLive ? snapshot?.activation : undefined; + const reloadRequired = activation?.reloadRequired === true; + const applyAllowed = activation?.applyAllowed === true; + const integrationDisabled = activation?.routingStatus === "not_required" + || activation?.applyReason === "integration-disabled"; + const externalRouting = !integrationDisabled && (activation?.routingStatus === "external" + || activation?.applyReason === "external-routing"); + const unknownRouting = !integrationDisabled && !externalRouting && (activation?.routingStatus === "unknown" + || activation?.applyReason === "routing-unknown"); + const routingBlocked = integrationDisabled || externalRouting || unknownRouting; + const routingNotInjected = activation?.routingStatus === "not_injected"; + const catalogReady = activation?.catalogStatus === "current" || activation?.catalogStatus === "degraded"; + const catalogNeedsConvergence = activation?.catalogStatus === "pending" || activation?.catalogStatus === "unknown"; + const manualRestartRequired = catalogReady + && activation?.routingStatus === "current" + && activation?.workerState === "reload_required"; + const canApply = applyAllowed && confirmedGuiLaunch; + const launcherRequired = reloadRequired && !routingBlocked && ( + activation?.applyReason === "confirmed-launch-required" || (applyAllowed && !confirmedGuiLaunch) + ); + + useEffect(() => { + const update = () => setConfirmedGuiLaunch(isConfirmedGuiLaunch()); + const unsubscribe = subscribeGuiLaunchCapability(update); + void whenGuiLaunchCapabilitySettles().then(update); + return unsubscribe; + }, []); + + useEffect(() => { + busyRef.current = busy; + }, [busy]); + + useEffect(() => { + committedChosenRef.current = committedChosen; + }, [committedChosen]); + + useEffect(() => { + if (!applyDialog) return; + const focused = document.activeElement; + const previouslyFocused = focused && "focus" in focused + ? focused as HTMLElement + : null; + const fallbackTrigger = applyTriggerRef.current; + applyCancelRef.current?.focus(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + if (!busyRef.current) setApplyDialog(null); + return; + } + if (event.key !== "Tab") return; + const dialog = applyCancelRef.current?.closest('[role="alertdialog"]'); + const focusable = dialog + ? [...dialog.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')] + : []; + if (focusable.length === 0) return; + const first = focusable[0]!; + const last = focusable[focusable.length - 1]!; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + if (previouslyFocused?.isConnected) previouslyFocused.focus(); + else fallbackTrigger?.focus(); + }; + }, [applyDialog]); // A warm data-surface cache can skip the loader on a route revisit. Catalog // freshness is live process state, so force one bounded revalidation instead @@ -171,26 +434,55 @@ export default function Subagents({ apiBase }: { apiBase: string }) { const applied = data?.applied ?? chosen; const advertised = data?.advertised ?? []; const excluded = data?.excluded ?? []; - setChosen(applied); - setCommittedChosen(applied); - writeSessionListCache(cacheKey, { + const parsedActivation = parseActivation(data?.activation); + const nextActivation = parsedActivation ?? snapshot?.activation; + const nextSnapshot: SubagentsSnapshot = { available, chosen: applied, - advertised, - excluded, + advertised: parsedActivation ? parsedActivation.advertised : advertised, + excluded: parsedActivation ? parsedActivation.excluded : excluded, models, catalogState: snapshot?.catalogState, + activation: nextActivation, metadataLimited: snapshot?.metadataLimited, - }); + }; + setChosen(applied); + setCommittedChosen(applied); + setClientResourceData(cacheKey, nextSnapshot); + writeSessionListCache(cacheKey, nextSnapshot); const refreshStatus = data?.catalogRefresh?.status; const refreshFailed = data?.catalogRefresh?.ok === false || (refreshStatus !== undefined && refreshStatus !== "committed"); - setStatusTone(refreshFailed || excluded.length > 0 ? "warn" : "ok"); - setStatus(refreshFailed - ? t("sub.savedRefreshFailed", { n: applied.length, cmd: "ccx sync --restart-codex" }) - : excluded.length > 0 - ? t("sub.savedExcluded", { n: applied.length, missing: excluded.length }) - : t("sub.saved", { n: applied.length, cmd: "ccx sync --restart-codex" })); + const policySkipped = refreshStatus === "skipped" && data?.catalogRefresh?.reason === "refused"; + const saveIntegrationDisabled = policySkipped && (nextActivation?.routingStatus === "not_required" + || nextActivation?.applyReason === "integration-disabled"); + const saveNeedsApply = policySkipped && (nextActivation?.routingStatus === "not_injected" + || nextActivation?.applyReason === "routing-not-injected"); + const saveRoutingPreserved = policySkipped && (nextActivation?.routingStatus === "external" + || nextActivation?.routingStatus === "unknown" + || nextActivation?.applyReason === "external-routing" + || nextActivation?.applyReason === "routing-unknown"); + const superseded = data?.superseded === true; + const saveWarned = superseded + || saveIntegrationDisabled + || saveNeedsApply + || saveRoutingPreserved + || refreshFailed + || excluded.length > 0; + setStatusTone(saveWarned ? "warn" : "ok"); + setStatus(superseded + ? t("sub.savedSuperseded") + : saveIntegrationDisabled + ? t("sub.savedIntegrationDisabled", { n: applied.length }) + : saveNeedsApply + ? t("sub.savedNeedsApply", { n: applied.length }) + : saveRoutingPreserved + ? t("sub.savedRoutingPreserved", { n: applied.length }) + : refreshFailed + ? t("sub.savedRefreshFailed", { n: applied.length }) + : excluded.length > 0 + ? t("sub.savedExcluded", { n: applied.length, missing: excluded.length }) + : t("sub.saved", { n: applied.length })); load(); } catch (error) { setStatusTone("err"); @@ -201,6 +493,82 @@ export default function Subagents({ apiBase }: { apiBase: string }) { } }; + const prepareApply = async () => { + if (busy || applyInFlight.current || !activation?.desiredRevision || !canApply) return; + setStatus(""); + try { + const response = await fetch(`${apiBase}/api/agent-activity`); + const activity = await readJsonOrThrow>(response, t("sub.applyActivityFailed")); + const activeTurnCount = typeof activity?.activeTurnCount === "number" && activity.activeTurnCount > 0 + ? activity.activeTurnCount + : 0; + setApplyDialog(activeTurnCount > 0 ? "active" : "idle"); + } catch { + // The Apply endpoint remains the final safety fence. Do not hide the + // action merely because advisory activity data could not be read. + setApplyDialog("unknown"); + } + }; + + const apply = async () => { + if (busy || applyInFlight.current || !activation?.desiredRevision || !canApply) return; + applyInFlight.current = true; + setBusy(true); + setStatus(""); + try { + const response = await fetch(`${apiBase}/api/codex-catalog/apply`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ expectedDesiredRevision: activation.desiredRevision, confirmInterrupt: true }), + }); + const { data, ok } = await readApplyResponse(response, t("sub.applyFailed")); + const outcome = applyOutcome(data); + if (!ok && outcome !== "superseded" && outcome !== "blocked") { + throw new Error(t("sub.applyFailed")); + } + const messageKey = outcome === "applied" + ? "sub.apply.applied" + : outcome === "already_current" + ? "sub.apply.alreadyCurrent" + : outcome === "no_workers" + ? "sub.apply.noWorkers" + : outcome === "partial" + ? "sub.apply.partial" + : outcome === "superseded" + ? "sub.apply.superseded" + : outcome === "blocked" + ? "sub.apply.blocked" + : "sub.apply.completed"; + setStatusTone(outcome === "partial" || outcome === "no_workers" || outcome === "blocked" || outcome === "superseded" ? "warn" : "ok"); + setStatus(t(messageKey)); + const responseActivation = parseActivation(asRecord(data)?.activation); + if (responseActivation && snapshot) { + const nextSnapshot: SubagentsSnapshot = { + ...snapshot, + advertised: responseActivation.advertised, + excluded: responseActivation.excluded, + activation: responseActivation, + }; + setClientResourceData(cacheKey, nextSnapshot); + writeSessionListCache(cacheKey, nextSnapshot); + } + setApplyDialog(null); + load(); + } catch (error) { + setStatusTone("err"); + setStatus(error instanceof Error && error.message ? error.message : t("sub.applyFailed")); + } finally { + applyInFlight.current = false; + setBusy(false); + } + }; + + const saveRunPolicy = async () => { + const saved = await runPolicy.save(); + if (saved) await load(); + return saved; + }; + if (state.showSkeleton && !snapshot) { return ; } @@ -215,15 +583,50 @@ export default function Subagents({ apiBase }: { apiBase: string }) { ); } - const catalogLabel = catalogState?.state === "fresh" - ? t("sub.catalog.current") - : catalogState?.state === "stale" - ? t("sub.catalog.restartNeeded") - : catalogState?.state === "not_running" - ? t("sub.catalog.nextSession") - : catalogState?.state === "unknown" - ? t("sub.catalog.unknown") - : null; + const catalogLabel = activation + ? integrationDisabled + ? t("sub.catalog.integrationDisabled") + : externalRouting || unknownRouting + ? t("sub.catalog.applyUnavailable") + : catalogNeedsConvergence + ? applyAllowed + ? t("sub.catalog.applyNeeded") + : activation.catalogStatus === "unknown" + ? t("sub.catalog.unknown") + : t("sub.catalog.pending") + : reloadRequired + ? manualRestartRequired + ? t("sub.catalog.restartChatGPT") + : applyAllowed + ? t("sub.catalog.applyNeeded") + : t("sub.catalog.applyUnavailable") + : activation.workerState === "current" || activation.workerState === "fresh" + ? t("sub.catalog.current") + : activation.workerState === "not_running" && catalogReady + ? t("sub.catalog.nextSession") + : activation.workerState === "unknown" + ? t("sub.catalog.unknown") + : null + : catalogState?.state === "fresh" + ? t("sub.catalog.current") + : catalogState?.state === "stale" + ? t("sub.catalog.restartNeeded") + : catalogState?.state === "not_running" + ? t("sub.catalog.nextSession") + : catalogState?.state === "unknown" + ? t("sub.catalog.unknown") + : null; + const catalogBadgeState = activation + ? integrationDisabled + ? "not_required" + : externalRouting || unknownRouting + ? "unknown" + : catalogNeedsConvergence + ? activation.catalogStatus === "unknown" ? "unknown" : "stale" + : reloadRequired + ? applyAllowed ? "stale" : activation.catalogStatus === "unknown" ? "unknown" : "stale" + : activation.workerState === "current" ? "fresh" : activation.workerState ?? undefined + : catalogState?.state; const excluded = snapshot?.excluded ?? []; const excludedModels = excluded.map(item => item.configured).join(", "); @@ -235,7 +638,7 @@ export default function Subagents({ apiBase }: { apiBase: string }) {

{t("sub.pageSubtitle")}

{catalogLabel && ( - + )} @@ -249,12 +652,72 @@ export default function Subagents({ apiBase }: { apiBase: string }) { {t("sub.roster.excludedNotice", { n: excluded.length, models: excludedModels })} )} - {catalogState?.state === "stale" && ( + {externalRouting && {t("sub.catalog.externalRoutingNotice")}} + {unknownRouting && {t("sub.catalog.unknownRoutingNotice")}} + {integrationDisabled && ( +

{t("sub.catalog.integrationDisabledNotice")}

+ )} + {manualRestartRequired && ( - {t("sub.catalog.staleNotice", { n: catalogState.processes?.length ?? 0, cmd: "ccx sync --restart-codex" })} +
+ {t("sub.catalog.restartChatGPTNotice")} + +
)} - {catalogState?.state === "not_running" && {t("sub.catalog.notRunningNotice")}} + {manualRestartRequired && launcherRequired && ( + {t("sub.catalog.launcherRequiredNotice")} + )} + {manualRestartRequired && canApply && ( +
+ {t("sub.catalog.forceRestartNotice")} + +
+ )} + {reloadRequired && !manualRestartRequired && !routingBlocked && ( + +
+ {t( + launcherRequired + ? "sub.catalog.launcherRequiredNotice" + : applyAllowed + ? catalogNeedsConvergence + ? "sub.catalog.pendingNotice" + : routingNotInjected + ? "sub.catalog.routingNotInjectedNotice" + : "sub.catalog.applyNotice" + : activation?.catalogStatus !== "current" && activation?.catalogStatus !== "degraded" + ? "sub.catalog.pendingNotice" + : "sub.catalog.blockedNotice", + )} + {canApply && } +
+
+ )} + {reloadRequired && !manualRestartRequired && !routingBlocked &&

{t("sub.catalog.newTaskNote")}

} + {!activation && catalogState?.state === "stale" && ( + {t("sub.catalog.legacyStaleNotice", { n: catalogState.processes?.length ?? 0, cmd: "ccx sync --restart-codex" })} + )} + {((!reloadRequired && !routingBlocked && catalogReady && activation?.workerState === "not_running") + || (!activation && catalogState?.state === "not_running")) + && {t("sub.catalog.notRunningNotice")}} {snapshot?.metadataLimited && {t("sub.metadataLimited")}} {state.showError && {t("sub.loadFail")}} )} /> + {applyDialog && ( +
{ if (!busy) setApplyDialog(null); }}> +
event.stopPropagation()} + > +

{t("sub.apply.dialogTitle")}

+

{t( + applyDialog === "active" + ? "sub.apply.dialogActive" + : applyDialog === "unknown" + ? "sub.apply.dialogUnknown" + : "sub.apply.dialogIdle", + )}

+
+ + +
+
+
+ )} ); } diff --git a/gui/src/styles-subagents-workspace.css b/gui/src/styles-subagents-workspace.css index c2bcfb1242..ea5290bb8b 100644 --- a/gui/src/styles-subagents-workspace.css +++ b/gui/src/styles-subagents-workspace.css @@ -55,6 +55,42 @@ background: var(--amber-soft); } +.subagents-activation-notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.subagents-activation-note { + margin: calc(-1 * var(--space-1)) 0 var(--space-3); + max-width: var(--prose-measure); + color: var(--muted); + font-size: var(--text-label); + line-height: var(--leading-body); +} + +.subagents-force-restart { + display: flex; + align-items: center; + gap: var(--space-3); + margin: calc(-1 * var(--space-1)) 0 var(--space-3); + max-width: var(--prose-measure); + color: var(--muted); + font-size: var(--text-label); + line-height: var(--leading-body); +} + +.subagents-force-restart .btn { + flex: 0 0 auto; +} + +.subagents-apply-dialog p { + margin: 0; + color: var(--muted); + line-height: var(--leading-body); +} + .subagents-workspace-shell { width: 100%; min-width: 0; @@ -875,6 +911,16 @@ margin-top: var(--space-2); } + .subagents-activation-notice { + align-items: flex-start; + flex-direction: column; + } + + .subagents-force-restart { + align-items: flex-start; + flex-direction: column; + } + .swi-card-head, .swi-library-head { flex-direction: column; diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index cebc84bb8b..c4d1dd0276 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -1,8 +1,12 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; -import { installApiAuthFetch, resetApiAuthFetchForTests } from "../src/api"; +import { + installApiAuthFetch, + isBrowserLoopbackHostname, + isConfirmedGuiLaunch, + resetApiAuthFetchForTests, +} from "../src/api"; -const SESSION_PATHS = new Set(["/codexcommander-session"]); const globals = ["document", "window", "navigator", "sessionStorage", "fetch"] as const; let previousGlobals: Record<(typeof globals)[number], unknown>; let testWindow: Window; @@ -42,7 +46,72 @@ async function installMockAuthFetch(handler: typeof fetch): Promise { Object.defineProperty(globalThis, "fetch", { configurable: true, value: window.fetch }); } +function useRemoteOperatorOrigin(): void { + window.location.href = "https://operator.example/"; +} + +test("loopback classification covers aliases and the full OS loopback families", () => { + for (const hostname of [ + "localhost", + "LOCALHOST.", + "dashboard.localhost", + "127.0.0.1", + "127.0.0.2", + "127.255.255.254", + "::1", + "[::1]", + "::ffff:127.0.0.9", + "[::ffff:7f00:1]", + "0:0:0:0:0:ffff:7fff:1", + ]) { + expect(isBrowserLoopbackHostname(hostname)).toBe(true); + } + for (const hostname of ["example.test", "192.0.2.10", "126.255.255.255", "128.0.0.1", "::2"]) { + expect(isBrowserLoopbackHostname(hostname)).toBe(false); + } +}); + +test("a manual loopback page never prompts for or validates a durable admin token", async () => { + let promptCalls = 0; + const seenPaths: string[] = []; + const mockFetch = (async (input: RequestInfo | URL) => { + seenPaths.push(new URL(input instanceof Request ? input.url : String(input), window.location.href).pathname); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + window.prompt = () => { + promptCalls += 1; + return "must-not-leave-the-browser"; + }; + await installMockAuthFetch(mockFetch); + + expect((await fetch("/api/config")).status).toBe(401); + expect(promptCalls).toBe(0); + expect(seenPaths).toEqual(["/api/config"]); + expect(seenPaths).not.toContain("/api/settings"); +}); + +test("a plaintext remote page never prompts for or validates a durable admin token", async () => { + window.location.href = "http://operator.example/"; + let promptCalls = 0; + const seenPaths: string[] = []; + const mockFetch = (async (input: RequestInfo | URL) => { + seenPaths.push(new URL(input instanceof Request ? input.url : String(input), window.location.href).pathname); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + window.prompt = () => { + promptCalls += 1; + return "must-not-cross-plaintext-http"; + }; + await installMockAuthFetch(mockFetch); + + expect((await fetch("/api/config")).status).toBe(401); + expect(promptCalls).toBe(0); + expect(seenPaths).toEqual(["/api/config"]); + expect(seenPaths).not.toContain("/api/settings"); +}); + test("prompted API tokens stay memory-only and are not written to sessionStorage", async () => { + useRemoteOperatorOrigin(); let authorized = false; const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { const headers = new Headers(init?.headers); @@ -63,6 +132,7 @@ test("prompted API tokens stay memory-only and are not written to sessionStorage }); test("validates prompted tokens with a safe read before retrying the failed request", async () => { + useRemoteOperatorOrigin(); const validationResults: string[] = []; const seenRequests: Array<[string, string | null]> = []; resetApiAuthFetchForTests(async (verifyToken) => { @@ -94,6 +164,7 @@ test("validates prompted tokens with a safe read before retrying the failed requ }); test("cross-origin /api/* requests do not receive the API key or token prompt", async () => { + useRemoteOperatorOrigin(); let promptCalls = 0; let phase: "seed" | "cross" = "seed"; const seenHeaders: Array = []; @@ -125,6 +196,7 @@ test("cross-origin /api/* requests do not receive the API key or token prompt", }); test("concurrent 401s share one token prompt and all retry with the stored token", async () => { + useRemoteOperatorOrigin(); // Repro for #647: many /api/* requests start without a token (dashboard fan-out). // Delivering 401s one-by-one after each auth cycle finishes matches the browser case where // window.prompt blocks the main thread: each continuation still holds a captured null token @@ -133,11 +205,6 @@ test("concurrent 401s share one token prompt and all retry with the stored token const release401: Array<() => void> = []; const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { const headers = new Headers(init?.headers); - // Session re-bootstrap probe: this fixture never mints sessions, so fail it fast - // instead of letting it join the release queue below. - if (SESSION_PATHS.has(new URL(_input instanceof Request ? _input.url : String(_input), "http://localhost/").pathname)) { - return new Response("unauthorized", { status: 401 }); - } if (headers.get("X-CodexCommander-API-Key") === "shared-token") { return new Response("{}", { status: 200 }); } @@ -194,6 +261,7 @@ test("concurrent 401s share one token prompt and all retry with the stored token }); test("stale concurrent 401 does not clear a token refreshed by another request", async () => { + useRemoteOperatorOrigin(); // Codex/CodeRabbit race: request A prompts and stores T2; request B still holding stale T1 // must not wipe T2 (clearTokenIfCurrent) before its re-read / shared gate join. let promptCalls = 0; @@ -252,13 +320,11 @@ test("stale concurrent 401 does not clear a token refreshed by another request", }); test("canceling the token prompt once does not reopen it for the rest of the 401 fan-out", async () => { + useRemoteOperatorOrigin(); let promptCalls = 0; const release401: Array<() => void> = []; const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { const headers = new Headers(init?.headers); - if (SESSION_PATHS.has(new URL(_input instanceof Request ? _input.url : String(_input), "http://localhost/").pathname)) { - return new Response("unauthorized", { status: 401 }); - } if (headers.get("X-CodexCommander-API-Key")) { return new Response("{}", { status: 200 }); } @@ -330,96 +396,65 @@ test("data-plane requests never receive the management token or prompt", async ( expect(promptCalls).toBe(beforeCrossPrompts); }); -function injectSessionMeta( - token: string, - csrf: string, - origin: string, -): void { - for (const [name, content] of [ - ["codexcommander-session-token", token], - ["codexcommander-session-csrf", csrf], - ["codexcommander-session-origin", origin], - ] as const) { - const meta = document.createElement("meta"); - meta.setAttribute("name", name); - meta.setAttribute("content", content); - document.head.appendChild(meta); - } -} - -function sessionDocumentHtml( - token: string, - csrf: string, - origin: string, -): string { - return [ - "", - ``, - ``, - ``, - "", - ].join(""); -} - -test("expired session silently re-bootstraps from the served document without prompting", async () => { - // Regression for the post-security-hardening UX bug: loopback sessions expire after the - // 5-minute TTL (or die on proxy restart), and the dashboard used to demand an admin token - // the user never chose. The fetch wrapper must renew the session from a freshly served - // document instead — token entry is not part of the default loopback experience. - injectSessionMeta("ccx_session_stale", "stale-csrf", "http://localhost"); - +test("an expired confirmed loopback session fails closed and requires relaunch", async () => { + const launchTicket = `ccx_launch_${"A".repeat(43)}`; + window.location.hash = `ccx-launch-ticket=${launchTicket}&ccx-route=dashboard`; let promptCalls = 0; - let bootstrapFetches = 0; + let exchangeCalls = 0; const seenApiKeys: Array = []; - const seenGuiOrigins: Array = []; const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const raw = input instanceof Request ? input.url : String(input); const url = new URL(raw, "http://localhost/"); const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); - if (url.pathname === "/codexcommander-session") { - bootstrapFetches += 1; - return new Response(sessionDocumentHtml("ccx_session_fresh", "fresh-csrf", "http://localhost"), { - status: 200, - headers: { "Content-Type": "text/html" }, + if (url.pathname === "/api/gui-launch-exchange") { + exchangeCalls += 1; + return Response.json({ + route: "dashboard", + session: { + token: "ccx_session_expired", + csrfToken: "expired-csrf", + origin: "http://localhost", + expiresAt: Date.now() - 1, + confirmedLaunch: true, + }, }); } seenApiKeys.push(headers.get("X-CodexCommander-API-Key")); - seenGuiOrigins.push(headers.get("X-CodexCommander-GUI-Origin")); - if (headers.get("X-CodexCommander-API-Key") === "ccx_session_fresh" - && headers.get("X-CodexCommander-GUI-Origin") === "http://localhost") { - return new Response("{}", { status: 200 }); - } return new Response("unauthorized", { status: 401 }); }) as typeof fetch; window.prompt = () => { promptCalls += 1; - return null; + return "manual-admin-token"; }; await installMockAuthFetch(mockFetch); const res = await fetch("/api/config"); - expect(res.status).toBe(200); + expect(res.status).toBe(401); expect(promptCalls).toBe(0); - expect(bootstrapFetches).toBe(1); - expect(seenApiKeys).toEqual(["ccx_session_stale", "ccx_session_fresh"]); - expect(seenGuiOrigins).toEqual(["http://localhost", "http://localhost"]); + expect(exchangeCalls).toBe(1); + expect(seenApiKeys).toEqual(["ccx_session_expired"]); + expect(isConfirmedGuiLaunch()).toBe(false); }); -test("a session minted for another origin is rejected and the prompt fallback stays", async () => { - // Non-loopback dashboards never get server-minted sessions; a re-bootstrap document whose - // origin does not match must not be trusted, and the operator-only prompt remains. +test("a launch exchange session for another origin is rejected without a loopback token prompt", async () => { + window.location.hash = `ccx-launch-ticket=ccx_launch_${"B".repeat(43)}&ccx-route=dashboard`; let promptCalls = 0; const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const raw = input instanceof Request ? input.url : String(input); const url = new URL(raw, "http://localhost/"); const headers = new Headers(init?.headers); - if (SESSION_PATHS.has(url.pathname)) { - return new Response(sessionDocumentHtml("ccx_session_foreign", "foreign-csrf", "http://192.0.2.10:10100"), { - status: 200, - headers: { "Content-Type": "text/html" }, + if (url.pathname === "/api/gui-launch-exchange") { + return Response.json({ + route: "dashboard", + session: { + token: "ccx_session_foreign", + csrfToken: "foreign-csrf", + origin: "http://192.0.2.10:10100", + expiresAt: Date.now() + 60_000, + confirmedLaunch: true, + }, }); } - if (headers.get("X-CodexCommander-API-Key") === "manual-admin-token") return new Response("{}", { status: 200 }); return new Response("unauthorized", { status: 401 }); }) as typeof fetch; window.prompt = () => { @@ -429,6 +464,7 @@ test("a session minted for another origin is rejected and the prompt fallback st await installMockAuthFetch(mockFetch); const res = await fetch("/api/config"); - expect(res.status).toBe(200); - expect(promptCalls).toBe(1); + expect(res.status).toBe(401); + expect(promptCalls).toBe(0); + expect(isConfirmedGuiLaunch()).toBe(false); }); diff --git a/gui/tests/models-empty-provider.test.tsx b/gui/tests/models-empty-provider.test.tsx index 75adedd86f..beb4cc12de 100644 --- a/gui/tests/models-empty-provider.test.tsx +++ b/gui/tests/models-empty-provider.test.tsx @@ -232,7 +232,7 @@ test("Models page combines final visibility, atomic actions, discovery status, a expect(discoveryLink?.textContent).toContain("Auto-discovery on"); expect(discoveryLink?.getAttribute("aria-label")).toContain("Open provider settings"); expect(container.textContent).not.toContain("Not selected"); - expect(container.textContent).toContain("Classic v1"); + expect(container.textContent).toContain("Reliable v1"); expect(container.textContent).toContain("Flexible model selection"); expect(container.textContent).toContain("Uncapped"); expect(container.textContent).toContain("Models use their full advertised window"); @@ -264,7 +264,7 @@ test("Models page combines final visibility, atomic actions, discovery status, a automaticRadio.click(); await new Promise(resolve => testWindow.setTimeout(resolve, 0)); }); - expect(container.textContent).toContain("Follow Codex defaults"); + expect(container.textContent).toContain("Codex native"); expect(automaticRadio.checked).toBe(true); await act(async () => collaborationChange.click()); diff --git a/gui/tests/models-status-toast.test.tsx b/gui/tests/models-status-toast.test.tsx index 55a0b5757f..bfe8bad508 100644 --- a/gui/tests/models-status-toast.test.tsx +++ b/gui/tests/models-status-toast.test.tsx @@ -165,7 +165,7 @@ test("apply feedback renders as a fixed toast, not an inline notice before the w expect(toast).not.toBeNull(); expect(toast!.className).toContain("notice-ok"); expect(toast!.getAttribute("role")).toBe("status"); - expect(toast!.textContent).toContain("Applied"); + expect(toast!.textContent).toContain("Saved"); // No inline notice sits in the flow before the workspace anymore. const workspace = container.querySelector(".models-workspace-root"); expect(workspace?.previousElementSibling?.classList.contains("action-toast")).toBe(true); diff --git a/gui/tests/subagents-classic.test.tsx b/gui/tests/subagents-classic.test.tsx index c1ed8ebd8c..9aab859136 100644 --- a/gui/tests/subagents-classic.test.tsx +++ b/gui/tests/subagents-classic.test.tsx @@ -4,6 +4,7 @@ import { act } from "react"; import type { Root } from "react-dom/client"; import Subagents from "../src/pages/Subagents"; import { LanguageProvider } from "../src/i18n/provider"; +import { setConfirmedGuiLaunchForTests } from "../src/api"; /** * Behavioural contract for the denser Subagents workspace: five-slot cap, @@ -25,10 +26,17 @@ let catalogState: { state: "fresh" | "stale"; processes?: Array<{ pid: number; s let policyMode: "v1" | "default" | "v2" = "default"; let messageDelivery: "encrypted" | "plaintext" = "encrypted"; let catalogRefresh: Record = { status: "committed", changed: true }; +let activation: Record | undefined; +let saveResponseOverride: Record | undefined; +let activityResponse: Response | null = null; +let failRosterReads = false; +let holdRosterReads: Promise | null = null; +let releaseRosterReads: (() => void) | null = null; let caseSequence = 0; let apiBase = ""; beforeEach(() => { + setConfirmedGuiLaunchForTests(true); previousGlobals = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previousGlobals; testWindow = new Window({ url: "http://localhost/" }); Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); @@ -51,6 +59,12 @@ beforeEach(() => { policyMode = "default"; messageDelivery = "encrypted"; catalogRefresh = { status: "committed", changed: true }; + activation = undefined; + saveResponseOverride = undefined; + activityResponse = null; + failRosterReads = false; + holdRosterReads = null; + releaseRosterReads = null; apiBase = `/classic-${++caseSequence}`; Object.defineProperty(globalThis, "fetch", { configurable: true, @@ -61,10 +75,38 @@ beforeEach(() => { if (path.endsWith("/api/subagent-models")) { if (method === "PUT") { const models = JSON.parse(String(init?.body)).models as string[]; - chosen = models; - return Response.json({ ok: true, applied: models, advertised, excluded, catalogRefresh }); + const overrideApplied = saveResponseOverride?.applied; + const applied = Array.isArray(overrideApplied) + ? overrideApplied.filter((model): model is string => typeof model === "string") + : models; + chosen = applied; + return Response.json({ + ok: true, + applied, + advertised, + excluded, + catalogRefresh, + activation, + ...saveResponseOverride, + }); } - return Response.json({ available, chosen, advertised, excluded, catalogState }); + if (failRosterReads) return Response.json({ error: "refresh failed" }, { status: 503 }); + if (holdRosterReads) await holdRosterReads; + return Response.json({ available, chosen, advertised, excluded, catalogState, activation }); + } + if (path.endsWith("/api/agent-activity")) { + return activityResponse ?? Response.json({ activeTurnCount: 0 }); + } + if (path.endsWith("/api/codex-catalog/apply")) { + activation = { + schemaVersion: 1, + desired: { revision: "revision-1", chosen, protocol: "v2" }, + catalog: { status: "current", advertised, excluded }, + routing: { status: "current", kind: "codexcommander-local" }, + workers: { status: "current", runningCount: 1, staleCount: 0, evidence: "verified" }, + apply: { required: false, allowed: false, reason: "already-current" }, + }; + return Response.json({ ok: true, outcome: "applied", activation, stoppedWorkerCount: 1, survivingWorkerCount: 0 }); } if (path.endsWith("/api/models")) { return Response.json(modelRows); @@ -93,6 +135,7 @@ afterEach(async () => { await act(async () => { current.unmount(); }); root = null; } + setConfirmedGuiLaunchForTests(false); for (const key of globals) { Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); } @@ -138,10 +181,17 @@ test("renders one active roster, one agent library, and one run-policy card", as expect(container.textContent).toContain("No preferred model — Codex chooses from roster"); }); -test("shows the V2 encryption compatibility notice only for the V2 protocol", async () => { +test("shows the encrypted V2 compatibility notice for base and V2, but not classic V1", async () => { policyMode = "v2"; await mount(); - expect(container.textContent).toContain("cannot be read by external providers (#92)"); + expect(container.textContent).toContain("external providers cannot read (#92)"); + + const v2Root = root!; + await act(async () => { v2Root.unmount(); }); + root = null; + policyMode = "default"; + await mount(); + expect(container.textContent).toContain("external providers cannot read (#92)"); const currentRoot = root!; await act(async () => { currentRoot.unmount(); }); @@ -156,7 +206,8 @@ test("shows the plaintext privacy notice when Codex defaults may select V2", asy messageDelivery = "plaintext"; await mount(); expect(container.textContent).toContain("including messages to native workers"); - expect(container.textContent).toContain("Start a new session after saving"); + expect(container.textContent).toContain("V2 task-message delivery from this parent is plaintext"); + expect(container.textContent).toContain("does not require Apply"); }); test("caps featured selections at five", async () => { @@ -208,6 +259,89 @@ test("saves the featured order with PUT and the models payload", async () => { test("warns when the roster persists but catalog convergence is skipped", async () => { catalogRefresh = { status: "skipped", reason: "busy", retryable: true }; + activation = { + desired: { revision: "revision-1", chosen: ["a-1"], protocol: "default" }, + catalog: { status: "current", advertised: [], excluded: [] }, + routing: { status: "not_injected", kind: "native" }, + workers: { status: "not_running" }, + apply: { required: true, allowed: true, reason: "routing-not-injected" }, + }; + await mount(); + + await act(async () => { addToggle("a-1").click(); }); + const save = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Save roster") as HTMLButtonElement; + await act(async () => { save.click(); }); + + expect(container.textContent).toContain("catalog could not be refreshed cleanly"); + expect(container.textContent).toContain("Existing workers were not restarted"); +}); + +test("shows the latest durable roster when a save is superseded", async () => { + saveResponseOverride = { superseded: true, applied: ["a-2"] }; + await mount(); + + await act(async () => { addToggle("a-1").click(); }); + const save = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Save roster") as HTMLButtonElement; + await act(async () => { save.click(); }); + + expect(container.querySelector(".notice-warn")?.textContent).toContain("superseded by a newer change"); + expect(container.querySelector(".notice-warn")?.textContent).toContain("latest saved roster is shown"); + expect(Array.from(container.querySelectorAll(".swi-roster-name")).map(node => node.textContent?.trim())).toEqual(["a-2"]); +}); + +test("explains an intentional catalog skip when Codex integration is off", async () => { + catalogRefresh = { status: "skipped", reason: "refused", retryable: false }; + activation = { + desired: { revision: "revision-1", chosen: ["a-1"], protocol: "v2" }, + catalog: { status: "current", advertised: [], excluded: [] }, + routing: { status: "not_required", kind: "native" }, + workers: { status: "not_running" }, + apply: { required: false, allowed: false, reason: "integration-disabled" }, + }; + await mount(); + + await act(async () => { addToggle("a-1").click(); }); + const save = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Save roster") as HTMLButtonElement; + await act(async () => { save.click(); }); + + expect(container.textContent).toContain("Codex integration is off"); + expect(container.textContent).toContain("routing and catalog files were left unchanged"); + expect(container.textContent).not.toContain("catalog could not be refreshed cleanly"); +}); + +test("explains that native routing needs an explicit Apply after Save", async () => { + catalogRefresh = { status: "skipped", reason: "refused", retryable: false }; + activation = { + desired: { revision: "revision-1", chosen: ["a-1"], protocol: "default" }, + catalog: { status: "current", advertised: [], excluded: [] }, + routing: { status: "not_injected", kind: "native" }, + workers: { status: "not_running" }, + apply: { required: true, allowed: true, reason: "routing-not-injected" }, + }; + await mount(); + + await act(async () => { addToggle("a-1").click(); }); + const save = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Save roster") as HTMLButtonElement; + await act(async () => { save.click(); }); + + expect(container.textContent).toContain("still using native routing"); + expect(container.textContent).toContain("choose Apply to Codex when you are ready"); + expect(container.textContent).not.toContain("catalog could not be refreshed cleanly"); +}); + +test("explains that unowned or unknown routing files are preserved", async () => { + catalogRefresh = { status: "skipped", reason: "refused", retryable: false }; + activation = { + desired: { revision: "revision-1", chosen: ["a-1"], protocol: "v2" }, + catalog: { status: "current", advertised: [], excluded: [] }, + routing: { status: "unknown", kind: "unknown" }, + workers: { status: "unknown" }, + apply: { required: false, allowed: false, reason: "routing-unknown" }, + }; await mount(); await act(async () => { addToggle("a-1").click(); }); @@ -215,8 +349,376 @@ test("warns when the roster persists but catalog convergence is skipped", async .find((button) => button.textContent?.trim() === "Save roster") as HTMLButtonElement; await act(async () => { save.click(); }); - expect(container.textContent).toContain("running catalog did not refresh cleanly"); - expect(container.textContent).toContain("ccx sync --restart-codex"); + expect(container.textContent).toContain("Existing Codex routing and catalog files were preserved"); + expect(container.textContent).toContain("could not verify that it owns the current routing"); + expect(container.textContent).not.toContain("catalog could not be refreshed cleanly"); +}); + +test("makes manual ChatGPT restart primary and keeps guarded worker restart as an advanced fallback", async () => { + activation = { + schemaVersion: 1, + desired: { revision: "revision-1", chosen: [], protocol: "v2" }, + catalog: { status: "current", advertised: [], excluded: [] }, + routing: { status: "current", kind: "codexcommander-local" }, + workers: { status: "reload_required", runningCount: 1, staleCount: 1, evidence: "verified" }, + apply: { required: true, allowed: true, reason: "reload-required" }, + }; + await mount(); + + expect(container.textContent).toContain("Restart ChatGPT"); + expect(container.textContent).toContain("Quit ChatGPT completely"); + expect(container.textContent).toContain("most reliable way to load the roster"); + expect(requests.some(request => request.url.endsWith("/api/agent-activity"))).toBe(false); + const applyButton = Array.from(container.querySelectorAll("button")) + .find(button => button.textContent?.trim() === "Force-restart workers")!; + await act(async () => { applyButton.click(); }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); + expect(requests.some(request => request.url.endsWith("/api/agent-activity"))).toBe(true); + expect(container.querySelector('[role="alertdialog"]')?.textContent).toContain("No active proxy work was detected"); + + const confirm = Array.from(container.querySelectorAll('[role="alertdialog"] button')) + .find(button => button.textContent?.trim() === "Apply to Codex")!; + await act(async () => { confirm.click(); }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); + + const applyRequest = requests.find(request => request.url.endsWith("/api/codex-catalog/apply")); + expect(applyRequest?.init?.method).toBe("POST"); + expect(applyRequest?.init?.body).toBe(JSON.stringify({ expectedDesiredRevision: "revision-1", confirmInterrupt: true })); + expect(container.textContent).toContain("Applied CodexCommander routing and the catalog to Codex"); + expect(container.textContent).toContain("Codex workers current"); + expect(Array.from(container.querySelectorAll("button")).some(button => button.textContent?.trim() === "Apply to Codex")).toBe(false); +}); + +test("offers Apply to reconcile a pending catalog before any worker interruption", async () => { + activation = { + schemaVersion: 1, + desired: { revision: "revision-1", chosen: ["a-1"], protocol: "default" }, + catalog: { status: "pending", advertised: [], excluded: [{ configured: "a-1", reason: "missing_catalog_entry" }] }, + routing: { status: "current", kind: "codexcommander-local" }, + workers: { status: "reload_required", runningCount: 1, staleCount: 1, evidence: "verified" }, + apply: { required: true, allowed: true, reason: "catalog-not-ready" }, + }; + await mount(); + + expect(container.textContent).toContain("Apply needed"); + expect(container.textContent).toContain("first synchronizes routing and catalog files"); + expect(Array.from(container.querySelectorAll("button")).some(button => button.textContent?.trim() === "Apply to Codex")).toBe(true); +}); + +test("manual dashboard still gives reliable restart guidance while force restart stays launcher-gated", async () => { + setConfirmedGuiLaunchForTests(false); + activation = { + schemaVersion: 1, + desired: { revision: "revision-1", chosen: [], protocol: "v2" }, + catalog: { status: "current", advertised: [], excluded: [] }, + routing: { status: "current", kind: "codexcommander-local" }, + workers: { status: "reload_required", runningCount: 1, staleCount: 1, evidence: "verified" }, + apply: { required: true, allowed: false, reason: "confirmed-launch-required" }, + }; + await mount(); + + expect(container.textContent).toContain("Restart ChatGPT"); + expect(container.textContent).toContain("Quit ChatGPT completely"); + expect(container.textContent).toContain("This dashboard is read-only for Apply"); + expect(container.textContent).toContain("Open it with `ccx gui`"); + expect(container.textContent).not.toContain("Force-restart workers"); + expect(Array.from(container.querySelectorAll("button")).some(button => button.textContent?.trim() === "Apply to Codex")).toBe(false); +}); + +test("confirmed-launch-required keeps Apply read-only even outside the manual-restart branch", async () => { + setConfirmedGuiLaunchForTests(false); + activation = { + schemaVersion: 1, + desired: { revision: "revision-1", chosen: [], protocol: "v2" }, + catalog: { status: "current", advertised: [], excluded: [] }, + routing: { status: "not_injected", kind: "native" }, + workers: { status: "not_running", runningCount: 0, staleCount: 0 }, + apply: { required: true, allowed: false, reason: "confirmed-launch-required" }, + }; + await mount(); + + expect(container.textContent).toContain("This dashboard is read-only for Apply"); + expect(container.textContent).toContain("Open it with `ccx gui`"); + expect(container.textContent).not.toContain("Force-restart workers"); + expect(Array.from(container.querySelectorAll("button")).some(button => button.textContent?.trim() === "Apply to Codex")).toBe(false); +}); + +test("polls the command center on the surface poll interval", async () => { + const priorSetInterval = Object.getOwnPropertyDescriptor(globalThis, "setInterval"); + const priorClearInterval = Object.getOwnPropertyDescriptor(globalThis, "clearInterval"); + const polls: Array<{ handler: () => void; ms: number }> = []; + const recordPoll = (handler: () => void, ms?: number, ..._args: unknown[]) => { + polls.push({ handler, ms: typeof ms === "number" ? ms : 0 }); + return polls.length; + }; + Object.defineProperty(globalThis, "setInterval", { configurable: true, value: recordPoll }); + Object.defineProperty(globalThis, "clearInterval", { configurable: true, value: () => {} }); + try { + await mount(); + + const surfacePoll = polls.find(poll => poll.ms === 5000); + expect(surfacePoll).toBeDefined(); + + const readsBefore = requests.filter(request => request.url.endsWith("/api/subagent-models") && !request.init?.method).length; + await act(async () => { + surfacePoll!.handler(); + await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + const readsAfter = requests.filter(request => request.url.endsWith("/api/subagent-models") && !request.init?.method).length; + expect(readsAfter).toBeGreaterThan(readsBefore); + } finally { + if (priorSetInterval) Object.defineProperty(globalThis, "setInterval", priorSetInterval); + if (priorClearInterval) Object.defineProperty(globalThis, "clearInterval", priorClearInterval); + } +}); + +test("polled revalidation never clobbers unsaved roster edits", async () => { + const priorSetInterval = Object.getOwnPropertyDescriptor(globalThis, "setInterval"); + const priorClearInterval = Object.getOwnPropertyDescriptor(globalThis, "clearInterval"); + const polls: Array<{ handler: () => void; ms: number }> = []; + const recordPoll = (handler: () => void, ms?: number, ..._args: unknown[]) => { + polls.push({ handler, ms: typeof ms === "number" ? ms : 0 }); + return polls.length; + }; + Object.defineProperty(globalThis, "setInterval", { configurable: true, value: recordPoll }); + Object.defineProperty(globalThis, "clearInterval", { configurable: true, value: () => {} }); + try { + await mount(); + const surfacePoll = polls.find(poll => poll.ms === 5000); + expect(surfacePoll).toBeDefined(); + + // User toggles a model but does not save; the server still reports the old roster. + await act(async () => { addToggle("a-1").click(); }); + expect(removeButtons().length).toBe(1); + + await act(async () => { + surfacePoll!.handler(); + await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + // The unsaved selection survives the polled revalidation. + expect(removeButtons().length).toBe(1); + expect(Array.from(container.querySelectorAll(".swi-roster-name")).map(node => node.textContent?.trim())).toEqual(["a-1"]); + } finally { + if (priorSetInterval) Object.defineProperty(globalThis, "setInterval", priorSetInterval); + if (priorClearInterval) Object.defineProperty(globalThis, "clearInterval", priorClearInterval); + } +}); + +test("seeded stale activation never paints an actionable banner before the first live fetch resolves", async () => { + const seed = { + available, + chosen: [], + advertised, + excluded, + models: modelRows, + catalogState: { state: "stale", processes: [{ pid: 1001, startedAtMs: 1 }] }, + activation: { + desiredRevision: "revision-1", + reloadRequired: true, + applyAllowed: false, + workerState: "reload_required", + catalogStatus: "current", + applyReason: "confirmed-launch-required", + routingStatus: "current", + routingKind: "codexcommander-local", + protocol: "v2", + advertised: [], + excluded: [], + }, + metadataLimited: false, + }; + testWindow.sessionStorage.setItem(`ccx.subagents.v2:${apiBase}`, JSON.stringify(seed)); + + holdRosterReads = new Promise(resolve => { releaseRosterReads = resolve; }); + await mount(); + + // While the live revalidation is in flight the seed must not paint stale action banners. + expect(container.textContent).not.toContain("Restart ChatGPT"); + expect(container.textContent).not.toContain("Quit ChatGPT completely"); + expect(container.textContent).not.toContain("Force-restart workers"); + expect(container.textContent).not.toContain("Apply to Codex"); + expect(container.textContent).not.toContain("This dashboard is read-only for Apply"); + + // Live state says everything is current; after the fetch resolves the banner updates. + activation = { + schemaVersion: 1, + desired: { revision: "revision-1", chosen: [], protocol: "v2" }, + catalog: { status: "current", advertised: [], excluded: [] }, + routing: { status: "current", kind: "codexcommander-local" }, + workers: { status: "current", runningCount: 1, staleCount: 0 }, + apply: { required: false, allowed: false, reason: "already-current" }, + }; + releaseRosterReads!(); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 50)); }); + + expect(container.textContent).toContain("Codex workers current"); + expect(container.textContent).not.toContain("Quit ChatGPT completely"); + expect(container.textContent).not.toContain("Force-restart workers"); +}); + +test("an omitted Apply permission never enables process interruption", async () => { + activation = { + desired: { revision: "revision-1", chosen: [], protocol: "v2" }, + catalog: { status: "current", advertised: [], excluded: [] }, + routing: { status: "current", kind: "codexcommander-local" }, + workers: { status: "reload_required" }, + apply: { required: true, reason: "reload-required" }, + }; + await mount(); + + expect(container.textContent).toContain("Restart ChatGPT"); + expect(container.textContent).not.toContain("Force-restart workers"); + expect(Array.from(container.querySelectorAll("button")).some(button => button.textContent?.trim() === "Apply to Codex")).toBe(false); +}); + +test("a malformed Apply permission remains unavailable", async () => { + activation = { + desired: { revision: "revision-1", chosen: [], protocol: "v2" }, + catalog: { status: "current", advertised: [], excluded: [] }, + routing: { status: "current", kind: "codexcommander-local" }, + workers: { status: "reload_required" }, + apply: { required: true, allowed: "true", reason: "reload-required" }, + }; + await mount(); + + expect(container.textContent).toContain("Restart ChatGPT"); + expect(container.textContent).not.toContain("Force-restart workers"); + expect(Array.from(container.querySelectorAll("button")).some(button => button.textContent?.trim() === "Apply to Codex")).toBe(false); +}); + +test("an incoherent routing status and kind fail closed", async () => { + activation = { + desired: { revision: "revision-1", chosen: [], protocol: "v2" }, + catalog: { status: "current", advertised: [], excluded: [] }, + routing: { status: "current", kind: "custom-remote" }, + workers: { status: "reload_required" }, + apply: { required: true, allowed: true, reason: "reload-required" }, + }; + await mount(); + + expect(container.textContent).toContain("Apply unavailable"); + expect(container.textContent).toContain("routing could not be classified safely"); + expect(Array.from(container.querySelectorAll("button")).some(button => button.textContent?.trim() === "Apply to Codex")).toBe(false); +}); + +test("native Codex routing offers full route and catalog reconciliation", async () => { + activation = { + schemaVersion: 1, + desired: { revision: "revision-1", chosen: [], protocol: "default" }, + catalog: { status: "current", advertised: [], excluded: [] }, + routing: { status: "not_injected", kind: "native" }, + workers: { status: "not_running", runningCount: 0, staleCount: 0, evidence: "no-processes" }, + apply: { required: true, allowed: true, reason: "routing-not-injected" }, + }; + await mount(); + + expect(container.textContent).toContain("Apply needed"); + expect(container.textContent).toContain("Codex is still using native routing"); + expect(container.textContent).toContain("connects Codex to CodexCommander"); + expect(Array.from(container.querySelectorAll("button")).some(button => button.textContent?.trim() === "Apply to Codex")).toBe(true); +}); + +test("external Codex routing is explained and never overwritten by Apply", async () => { + activation = { + schemaVersion: 1, + desired: { revision: "revision-1", chosen: [], protocol: "v2" }, + catalog: { status: "current", advertised: [], excluded: [] }, + routing: { status: "external", kind: "custom-remote" }, + workers: { status: "reload_required", runningCount: 1, staleCount: 1, evidence: "process-start-vs-activation-fence" }, + apply: { required: true, allowed: false, reason: "external-routing" }, + }; + await mount(); + + expect(container.textContent).toContain("Apply unavailable"); + expect(container.textContent).toContain("custom routing that CodexCommander does not own"); + expect(Array.from(container.querySelectorAll("button")).some(button => button.textContent?.trim() === "Apply to Codex")).toBe(false); +}); + +test("unknown Codex routing explains the fail-closed Apply state", async () => { + activation = { + schemaVersion: 1, + desired: { revision: "revision-1", chosen: [], protocol: "v2" }, + catalog: { status: "current", advertised: [], excluded: [] }, + routing: { status: "unknown", kind: "unknown" }, + workers: { status: "current", runningCount: 1, staleCount: 0, evidence: "process-start-vs-activation-fence" }, + apply: { required: false, allowed: false, reason: "routing-unknown" }, + }; + await mount(); + + expect(container.textContent).toContain("Apply unavailable"); + expect(container.textContent).toContain("routing could not be classified safely"); + expect(Array.from(container.querySelectorAll("button")).some(button => button.textContent?.trim() === "Apply to Codex")).toBe(false); +}); + +test("a disabled Codex integration is informational rather than actionable", async () => { + activation = { + schemaVersion: 1, + desired: { revision: "revision-1", chosen: [], protocol: "v2" }, + catalog: { status: "current", advertised: [], excluded: [] }, + routing: { status: "not_required", kind: "native" }, + workers: { status: "reload_required", runningCount: 1, staleCount: 1, evidence: "process-start-vs-activation-fence" }, + apply: { required: true, allowed: false, reason: "integration-disabled" }, + }; + await mount(); + + expect(container.textContent).toContain("Codex integration off"); + expect(container.textContent).toContain("roster remains saved in CodexCommander"); + expect(container.textContent).not.toContain("Apply unavailable"); + expect(Array.from(container.querySelectorAll("button")).some(button => button.textContent?.trim() === "Apply to Codex")).toBe(false); +}); + +test("keeps the mutation response authoritative when follow-up roster refresh fails", async () => { + await mount(); + await act(async () => { addToggle("a-1").click(); }); + activation = { + desired: { revision: "revision-2", chosen: ["a-1"], protocol: "v2" }, + catalog: { status: "current", advertised: ["a-1"], excluded: [] }, + routing: { status: "current", kind: "codexcommander-local" }, + workers: { status: "reload_required", runningCount: 1, staleCount: 1 }, + apply: { required: true, allowed: true, reason: "reload-required" }, + }; + failRosterReads = true; + const save = Array.from(container.querySelectorAll("button")) + .find(button => button.textContent?.trim() === "Save roster")!; + await act(async () => { + save.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + expect(container.textContent).toContain("Restart ChatGPT"); + expect(Array.from(container.querySelectorAll("button")).some(button => button.textContent?.trim() === "Force-restart workers")).toBe(true); +}); + +test("confirmation starts on Cancel, restores focus, and Escape closes safely", async () => { + activation = { + desired: { revision: "revision-1", chosen: [], protocol: "v2" }, + catalog: { status: "current", advertised: [], excluded: [] }, + routing: { status: "current", kind: "codexcommander-local" }, + workers: { status: "reload_required", runningCount: 1, staleCount: 1 }, + apply: { required: true, allowed: true, reason: "reload-required" }, + }; + await mount(); + const trigger = Array.from(container.querySelectorAll("button")) + .find(button => button.textContent?.trim() === "Force-restart workers")!; + trigger.focus(); + await act(async () => { + trigger.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + const dialog = container.querySelector('[role="alertdialog"]')!; + const cancel = Array.from(dialog.querySelectorAll("button")) + .find(button => button.textContent?.trim() === "Cancel")!; + expect(document.activeElement).toBe(cancel); + + await act(async () => { + window.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "Escape" })); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(container.querySelector('[role="alertdialog"]')).toBeNull(); + expect(document.activeElement).toBe(trigger); }); test("shows truthful catalog state, capability filters, and keyboard reordering", async () => { diff --git a/gui/vite.config.ts b/gui/vite.config.ts index 3406a4ca9a..f07feed433 100644 --- a/gui/vite.config.ts +++ b/gui/vite.config.ts @@ -18,9 +18,8 @@ export default defineConfig({ */ server: proxyTarget ? { proxy: { - // Keep the original Host on forwarded requests: the backend mints loopback GUI - // sessions bound to that origin, and /api session checks must see the same origin. - '/codexcommander-session': { target: proxyTarget, changeOrigin: false }, + // Keep the browser's original Host so confirmed launch sessions remain bound + // to the exact Vite origin during local integration work. '/api': { target: proxyTarget, changeOrigin: false }, '/healthz': { target: proxyTarget, changeOrigin: false }, }, diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index 83895af405..cd01024679 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -3,7 +3,12 @@ * per-family account readers. Kept separate from account.ts (command handlers) * per the 400-line module budget. */ -import { findLiveProxy, probeHostname } from "../server/proxy-liveness"; +import { + attestLiveManagementProxy, + findLiveProxy, + probeHostname, + type ManagementAttestationIo, +} from "../server/proxy-liveness"; import { runningProxyUpdateHeaders } from "../oauth/login-cli"; import { isPublicOAuthProvider } from "../oauth/index"; import { getProviderRegistryEntry, providerCodexAccountMode } from "../providers/registry"; @@ -45,6 +50,9 @@ export interface AccountDeps { /** Test injection: skip findLiveProxy and call the API at this base URL. */ baseUrl?: string; fetchImpl?: typeof fetch; + managementAttestation?: Omit; + /** Test seam for an explicitly attested management transport. */ + attestLiveManagementProxyImpl?: typeof attestLiveManagementProxy; loadConfigImpl?: () => CodexCommanderConfig; stdinImpl?: AccountStdin; stdinTimeoutMs?: number; @@ -83,7 +91,7 @@ export interface ApiResult { export async function apiJson( deps: AccountDeps, - baseUrl: string, + _baseUrl: string, method: "GET" | "PUT" | "POST" | "DELETE", path: string, body?: unknown, @@ -91,7 +99,13 @@ export async function apiJson( ): Promise { const fetchImpl = deps.fetchImpl ?? fetch; try { - const res = await fetchImpl(`${baseUrl}${path}`, { + const attest = deps.attestLiveManagementProxyImpl ?? attestLiveManagementProxy; + const target = await attest({ + ...(deps.managementAttestation ?? {}), + fetchFn: fetchImpl, + }); + if (!target) return { status: 0, json: {} }; + const res = await fetchImpl(`${target.baseUrl}${path}`, { method, headers: runningProxyUpdateHeaders(), body: body === undefined ? undefined : JSON.stringify(body), diff --git a/src/cli/catalog-activation.ts b/src/cli/catalog-activation.ts new file mode 100644 index 0000000000..53020bd57e --- /dev/null +++ b/src/cli/catalog-activation.ts @@ -0,0 +1,434 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; + +import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; +import { + applyCodexCatalog, + applyCodexCatalogWorkers, + codexCatalogSyncCanSignal, + runCodexCatalogApply, + type ApplyCodexCatalogLifecycleResult, + type ApplyCodexCatalogWorkersResult, +} from "../codex/catalog-apply"; +import { + captureCodexCatalogDesiredSnapshot, + collectCodexCatalogActivationWorkerState, + resetCodexCatalogActivationWorkerStateCache, + type CodexCatalogDesiredSnapshot, +} from "../codex/catalog-activation"; +import { + listCodexAppServerProcesses, + resetCodexAppServerCatalogStateCache, + restartCodexAppServers, +} from "../codex/app-server-processes"; +import { activeCodexModelsCachePath, readCodexCatalogPath } from "../codex/catalog/parsing"; +import { getCodexRoutingKind, type CodexRoutingKind } from "../codex/inject"; +import { syncModelsToCodex, type CodexSyncResult } from "../codex/sync"; +import { RuntimeApiError, runtimeRequest } from "./runtime-api"; + +interface CatalogActivationReceipt { + catalog: { status: "current" | "pending" | "degraded" | "unknown" }; +} + +export type CliCodexSyncResult = CodexSyncResult & { + activation?: CatalogActivationReceipt; +}; + +function isCodexSyncResult(value: unknown): value is CodexSyncResult { + if (value === null || typeof value !== "object") return false; + const candidate = value as Partial; + return (candidate.status === "applied" || candidate.status === "skipped" || candidate.status === "refused") + && typeof candidate.ok === "boolean" + && typeof candidate.catalogExists === "boolean" + && typeof candidate.catalogWritten === "boolean" + && typeof candidate.cacheSynced === "boolean" + && typeof candidate.message === "string"; +} + +function hasCatalogActivationReceipt(value: unknown): value is CodexSyncResult & { + activation: CatalogActivationReceipt; +} { + if (!isCodexSyncResult(value)) return false; + const activation = (value as { activation?: unknown }).activation; + if (activation === null || typeof activation !== "object") return false; + const catalog = (activation as { catalog?: unknown }).catalog; + if (catalog === null || typeof catalog !== "object") return false; + const status = (catalog as { status?: unknown }).status; + return status === "current" || status === "pending" || status === "degraded" || status === "unknown"; +} + +export function liveCatalogActivationIsReady(result: CliCodexSyncResult): boolean { + return result.activation?.catalog.status === "current" + || result.activation?.catalog.status === "degraded"; +} + +export function catalogSyncCanApply( + result: CliCodexSyncResult, + requireLiveReceipt: boolean, +): boolean { + return codexCatalogSyncCanSignal(result) + && (!requireLiveReceipt || liveCatalogActivationIsReady(result)); +} + +interface CliCatalogSyncDeps { + syncModelsToCodex: typeof syncModelsToCodex; + runtimeRequest: typeof runtimeRequest; +} + +const defaultCliSyncDeps: CliCatalogSyncDeps = { syncModelsToCodex, runtimeRequest }; + +/** + * Keep catalog publication in the exact runtime-record proxy when one exists. + * Its process-local convergence receipt then remains available to the dashboard; + * public config-port discovery and offline CLI use converge the caller's own + * files locally. + */ +export async function syncCodexCatalogForCli( + live: LiveProxy | null, + deps: CliCatalogSyncDeps = defaultCliSyncDeps, +): Promise { + // Public /healthz identity at the configured port is not management + // authority. It may be an unrelated CodexCommander instance using another + // home (a common test/development collision), and it has no protected + // runtime record from which this CLI can attest it. Only an exact + // runtime-record PID may receive the authenticated POST; otherwise converge + // the caller's own files locally without releasing a credential or body to + // the listener. Cross-process catalog serialization still protects a legacy + // same-home proxy whose runtime record was lost. + if (!live || live.source !== "runtime" || live.pid === null) { + return deps.syncModelsToCodex(); + } + try { + const response = await deps.runtimeRequest("/api/sync", { + method: "POST", + signal: AbortSignal.timeout(45_000), + }, { + baseUrl: `http://${probeHostname(live.hostname)}:${live.port}`, + }); + if (!hasCatalogActivationReceipt(response)) { + throw new RuntimeApiError("The running proxy returned an unverified Codex sync result.", 502, response); + } + return response; + } catch (error) { + // /api/sync preserves the structured sync body on refused/failed responses. + // Keep the established CLI projection without falling back to a competing + // local writer that would strand the server's process-local receipt. + if (error instanceof RuntimeApiError && hasCatalogActivationReceipt(error.body)) return error.body; + throw error; + } +} + +interface FileFingerprint { + path: string; + sha256: string; +} + +interface CatalogArtifactSnapshot { + catalog: FileFingerprint; +} + +interface CacheArtifactSnapshot { + cache: FileFingerprint; +} + +export interface CatalogApplyFence { + desired: CodexCatalogDesiredSnapshot; + artifacts: CatalogArtifactSnapshot; +} + +export interface CacheApplyFence { + desired: CodexCatalogDesiredSnapshot; + artifacts: CacheArtifactSnapshot; +} + +function fingerprint(path: string): FileFingerprint { + return { + path, + sha256: createHash("sha256").update(readFileSync(path)).digest("hex"), + }; +} + +function captureCatalogArtifacts(): CatalogArtifactSnapshot { + return { + catalog: fingerprint(readCodexCatalogPath()), + }; +} + +function captureCacheArtifact(): CacheArtifactSnapshot { + return { cache: fingerprint(activeCodexModelsCachePath()) }; +} + +function sameFileFingerprint(left: FileFingerprint, right: FileFingerprint): boolean { + return left.path === right.path && left.sha256 === right.sha256; +} + +function catalogArtifactsStillMatch(expected: CatalogArtifactSnapshot): boolean { + try { + const current = captureCatalogArtifacts(); + return sameFileFingerprint(current.catalog, expected.catalog); + } catch { + return false; + } +} + +function cacheArtifactStillMatches(expected: CacheArtifactSnapshot): boolean { + try { + return sameFileFingerprint(captureCacheArtifact().cache, expected.cache); + } catch { + return false; + } +} + +export function catalogApplyFenceArtifactsStillMatch(expected: CatalogApplyFence): boolean { + return catalogArtifactsStillMatch(expected.artifacts); +} + +export function cacheApplyFenceArtifactStillMatches(expected: CacheApplyFence): boolean { + return cacheArtifactStillMatches(expected.artifacts); +} + +/** Bind the exact authoritative catalog bytes only after convergence succeeds. */ +export function bindCatalogArtifactsForApply( + desired: CodexCatalogDesiredSnapshot | null, +): CatalogApplyFence | null { + if (!desired) return null; + try { + return { desired, artifacts: captureCatalogArtifacts() }; + } catch { + return null; + } +} + +/** Bind the exact cache bytes for the explicit advanced `sync-cache` command. */ +export function bindCacheArtifactForApply( + desired: CodexCatalogDesiredSnapshot | null, +): CacheApplyFence | null { + if (!desired) return null; + try { + return { desired, artifacts: captureCacheArtifact() }; + } catch { + return null; + } +} + +interface CompanionCatalogApplyDeps { + findLiveProxy: typeof findLiveProxy; + syncCatalog: typeof syncCodexCatalogForCli; + applyCatalog: typeof applyCodexCatalog; + captureDesiredSnapshot?: typeof captureCodexCatalogDesiredSnapshot; + captureArtifacts?: typeof captureCatalogArtifacts; + artifactsStillMatch?: typeof catalogArtifactsStillMatch; + getRoutingKind?: () => CodexRoutingKind; +} + +const defaultCompanionDeps: CompanionCatalogApplyDeps = { + findLiveProxy, + syncCatalog: syncCodexCatalogForCli, + applyCatalog: applyCodexCatalog, +}; + +/** + * The fixed native bridge keeps convergence in the authenticated live proxy, + * then delegates all exact-worker and revision-fence policy to catalog-apply. + */ +export async function applyCodexCatalogForCompanion( + deps: CompanionCatalogApplyDeps = defaultCompanionDeps, +): Promise { + const captureDesiredSnapshot = deps.captureDesiredSnapshot ?? captureCodexCatalogDesiredSnapshot; + const captureArtifacts = deps.captureArtifacts ?? captureCatalogArtifacts; + const artifactsStillMatch = deps.artifactsStillMatch ?? catalogArtifactsStillMatch; + const routingKind = deps.getRoutingKind ?? getCodexRoutingKind; + let artifactFence: CatalogArtifactSnapshot | null = null; + const resetCatalogStateCache = () => { + resetCodexAppServerCatalogStateCache(); + resetCodexCatalogActivationWorkerStateCache(); + }; + return deps.applyCatalog({ + findLiveProxy: deps.findLiveProxy, + captureDesiredSnapshot, + syncModelsToCodex: async () => { + const live = await deps.findLiveProxy(); + if (!live) throw new Error("CodexCommander stopped before catalog synchronization."); + const result = await deps.syncCatalog(live); + if (catalogSyncCanApply(result, true)) { + artifactFence = captureArtifacts(); + } + return result; + }, + inspectArtifactProof: () => artifactFence !== null && artifactsStillMatch(artifactFence) + ? "current" + : "drifted", + getRoutingKind: routingKind, + resetCatalogStateCache, + collectCatalogState: collectCodexCatalogActivationWorkerState, + listCodexWorkers: listCodexAppServerProcesses, + restartCodexWorkers: restartCodexAppServers, + }); +} + +export function captureCatalogRestartFence( + restartRequested: boolean, +): CodexCatalogDesiredSnapshot | null { + if (!restartRequested) return null; + try { + return captureCodexCatalogDesiredSnapshot(); + } catch { + return null; + } +} + +function cacheRestartFenceStillMatches(expected: CacheApplyFence): boolean { + try { + return captureCodexCatalogDesiredSnapshot().revision === expected.desired.revision + && getCodexRoutingKind() === "codexcommander-local" + && cacheApplyFenceArtifactStillMatches(expected); + } catch { + return false; + } +} + +export function reportCatalogWorkerApply( + result: ApplyCodexCatalogWorkersResult, + output: Pick = console, +): boolean { + switch (result.outcome) { + case "applied": + output.log(result.stoppedWorkerCount > 0 + ? `Stopped ${result.stoppedWorkerCount} verified stale Codex worker(s). A replacement will load the synchronized catalog.` + : "The verified stale Codex workers exited before signaling completed. A replacement will load the synchronized catalog."); + return true; + case "already_current": + output.log("Running Codex workers already match the synchronized catalog; no process was stopped."); + return true; + case "no_workers": + output.log("No Codex background worker is running. The synchronized catalog will load when Codex starts one."); + return true; + case "superseded": + output.error("The saved CodexCommander configuration changed during synchronization; no further Codex workers were stopped. Run the command again."); + return false; + case "partial": + output.error(`${result.survivingWorkerCount} verified stale Codex worker(s) are still running after SIGTERM.`); + return false; + case "blocked": + output.error("Codex worker identity or start time could not be verified; no process was stopped."); + return false; + } +} + +export interface ApplySynchronizedCatalogWorkersDeps { + captureDesiredSnapshot: typeof captureCodexCatalogDesiredSnapshot; + artifactFenceStillMatches: (expected: CatalogApplyFence) => boolean; + getRoutingKind: () => CodexRoutingKind; + resetWorkerObservation: () => void; + collectWorkerState: typeof collectCodexCatalogActivationWorkerState; + applyWorkers: ( + authorizeSignal: () => boolean, + observedBefore: ReturnType, + ) => Promise; +} + +const defaultSynchronizedApplyDeps: ApplySynchronizedCatalogWorkersDeps = { + captureDesiredSnapshot: captureCodexCatalogDesiredSnapshot, + artifactFenceStillMatches: catalogApplyFenceArtifactsStillMatch, + getRoutingKind: getCodexRoutingKind, + resetWorkerObservation: () => { + resetCodexAppServerCatalogStateCache(); + resetCodexCatalogActivationWorkerStateCache(); + }, + collectWorkerState: collectCodexCatalogActivationWorkerState, + applyWorkers: (authorizeSignal, observedBefore) => applyCodexCatalogWorkers( + authorizeSignal, + undefined, + observedBefore, + ), +}; + +export async function applySynchronizedCatalogWorkers( + expected: CatalogApplyFence | null, + synchronized: CliCodexSyncResult, + deps: ApplySynchronizedCatalogWorkersDeps = defaultSynchronizedApplyDeps, +): Promise { + if (!expected) return null; + const result = await runCodexCatalogApply({ + expectedDesiredRevision: expected.desired.revision, + }, { + captureDesiredSnapshot: deps.captureDesiredSnapshot, + // The CLI completed the canonical full sync immediately before binding + // this exact catalog-byte fence; replay only its structured, warning-bearing receipt. + syncCatalog: async () => synchronized, + inspectArtifactProof: () => deps.artifactFenceStillMatches(expected) + ? "current" + : "drifted", + getRoutingKind: deps.getRoutingKind, + resetWorkerObservation: deps.resetWorkerObservation, + collectWorkerState: deps.collectWorkerState, + applyWorkers: deps.applyWorkers, + }); + return { + outcome: result.outcome, + staleWorkerCount: result.staleWorkerCount, + stoppedWorkerCount: result.stoppedWorkerCount, + survivingWorkerCount: result.survivingWorkerCount, + }; +} + +/** Apply the advanced sync-cache command against its own exact write fence. */ +export async function applyInvalidatedCacheWorkers( + expected: CacheApplyFence | null, +): Promise { + if (!expected) return null; + const [processes, parsing, fs] = await Promise.all([ + import("../codex/app-server-processes"), + import("../codex/catalog/parsing"), + import("node:fs"), + ]); + const cachePath = parsing.activeCodexModelsCachePath(); + const cacheMtimeMs = () => { + try { + return fs.statSync(cachePath).mtimeMs; + } catch { + return null; + } + }; + return applyCodexCatalogWorkers( + () => cacheRestartFenceStillMatches(expected), + { + resetCatalogStateCache: processes.resetCodexAppServerCatalogStateCache, + collectCatalogState: () => processes.collectCodexAppServerCatalogState({ catalogMtimeMs: cacheMtimeMs }), + listCodexWorkers: processes.listCodexAppServerProcesses, + restartCodexWorkers: processes.restartCodexAppServers, + }, + ); +} + +export async function warnAfterCatalogWrite(fence: "activation" | "cache"): Promise { + const processes = await import("../codex/app-server-processes"); + let status; + if (fence === "activation") { + const activation = await import("../codex/catalog-activation"); + processes.resetCodexAppServerCatalogStateCache(); + activation.resetCodexCatalogActivationWorkerStateCache(); + status = activation.collectCodexCatalogActivationWorkerState(); + } else { + const [{ activeCodexModelsCachePath }, fs] = await Promise.all([ + import("../codex/catalog/parsing"), + import("node:fs"), + ]); + const cachePath = activeCodexModelsCachePath(); + processes.resetCodexAppServerCatalogStateCache(); + status = processes.collectCodexAppServerCatalogState({ + catalogMtimeMs: () => { + try { + return fs.statSync(cachePath).mtimeMs; + } catch { + return null; + } + }, + }); + } + if (status.state === "stale") { + console.error(processes.formatStaleCodexAppServerWarning(status.processes)); + } else if (status.state === "unknown") { + console.error("WARNING: Codex files changed, but running worker identity or start time could not be verified. Restart Codex manually if its model list stays stale."); + } +} diff --git a/src/cli/claude.ts b/src/cli/claude.ts index deeab5f8a4..6ddc317304 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -12,7 +12,12 @@ import { injectClaudeAgentDefs } from "../claude/agents-inject"; import { effectiveModelEnv, resolveAutoContext } from "../claude/context-windows"; import { refreshGatewayModelCacheFromProxy } from "../claude/gateway-cache"; import { commandInvocation } from "../lib/win-exec"; -import { findLiveProxy } from "../server/proxy-liveness"; +import { + attestLiveManagementProxy, + findLiveProxy, + type AttestedLiveManagementProxy, + type ManagementAttestationIo, +} from "../server/proxy-liveness"; import type { CodexCommanderConfig } from "../types"; import { configuredAdminToken } from "../lib/admin-secrets"; import { API_KEY_HEADER } from "../identity"; @@ -180,12 +185,19 @@ export function buildClaudeEnv( * daemon registers every selector form — audit R3#1). 3s bound + management auth header. * (no [1m] marking, conservative). */ -export async function fetchClaudeContextWindows(config: CodexCommanderConfig, port: number, timeoutMs = 3_000): Promise> { +export async function fetchClaudeContextWindows( + _config: CodexCommanderConfig, + port: number, + timeoutMs = 3_000, + io: ManagementAttestationIo = {}, +): Promise> { try { + const target = await attestLiveManagementProxy({ ...io, timeoutMs }); + if (!target || target.port !== port) return {}; const headers = new Headers(); const token = configuredAdminToken(); if (token) headers.set(API_KEY_HEADER, token); - const res = await fetch(`http://127.0.0.1:${port}/api/claude-code`, { + const res = await (io.fetchFn ?? fetch)(`${target.baseUrl}/api/claude-code`, { headers, signal: AbortSignal.timeout(timeoutMs), }); @@ -198,9 +210,11 @@ export async function fetchClaudeContextWindows(config: CodexCommanderConfig, po } } -async function ensureProxyForClaude(): Promise { - const live = await findLiveProxy(); - if (live) return live.port; +async function ensureProxyForClaude(): Promise { + const attested = await attestLiveManagementProxy(); + if (attested) return attested; + // A lookalike/unattested listener must not receive the Claude launch token. + if (await findLiveProxy()) return null; const cfgPort = loadConfig().port; const pinPort = typeof cfgPort === "number" && cfgPort > 0 ? cfgPort : 10100; const child = spawn(process.execPath, [process.argv[1], "start", "--port", String(pinPort)], { @@ -212,8 +226,8 @@ async function ensureProxyForClaude(): Promise { child.unref(); const deadline = Date.now() + 8_000; while (Date.now() < deadline) { - const started = await findLiveProxy(); - if (started) return started.port; + const started = await attestLiveManagementProxy(); + if (started) return started; await new Promise(resolve => setTimeout(resolve, 250)); } return null; @@ -240,11 +254,12 @@ export async function cmdClaude(args: string[]): Promise { console.error("Claude inbound is disabled (config.claudeCode.enabled=false — flip the Claude ON toggle in the GUI or edit config)."); return 1; } - const port = await ensureProxyForClaude(); - if (!port) { - console.error("❌ Proxy did not become healthy after starting."); + const target = await ensureProxyForClaude(); + if (!target) { + console.error("❌ Proxy did not become healthy with a protected runtime identity after starting."); return 1; } + const port = target.port; const contextWindows = await fetchClaudeContextWindows(config, port); const env = buildClaudeEnv(config, port, process.env, contextWindows); // Pre-write the CLI's gateway-model cache (implementation contract): without a token the CLI @@ -268,6 +283,15 @@ export async function cmdClaude(args: string[]): Promise { const message = error instanceof Error ? error.message : String(error); console.error(`⚠ Claude agent definitions could not be synced: ${message}`); } + // Re-prove the exact listener immediately before releasing Claude's admission + // token and routed request bodies to the child process. + const launchTarget = await attestLiveManagementProxy({ expectedPid: target.pid }); + if (!launchTarget + || launchTarget.port !== target.port + || launchTarget.hostname !== target.hostname) { + console.error("❌ Proxy identity changed before Claude could launch; retry the command."); + return 1; + } return await new Promise(resolve => { const inv = commandInvocation("claude", args); const child = spawn(inv.file, inv.args, { stdio: "inherit", env: env as NodeJS.ProcessEnv, ...inv.options }); diff --git a/src/cli/debug.ts b/src/cli/debug.ts index e11ba2efae..5ada6d6585 100644 --- a/src/cli/debug.ts +++ b/src/cli/debug.ts @@ -1,24 +1,21 @@ -import { findLiveProxy, probeHostname } from "../server/proxy-liveness"; +import { attestLiveManagementProxy, findLiveProxy } from "../server/proxy-liveness"; import { DEBUG_ENV, type DebugSettingsView } from "../lib/debug-settings"; import { runningProxyUpdateHeaders } from "../oauth/login-cli"; type DebugScope = "provider" | "usage" | "injection" | "claude"; -async function requireLiveProxy() { - const live = await findLiveProxy(); - if (!live) { - console.error("Proxy is not running. Start it with: ccx start"); - process.exit(1); - } - return live; +async function fetchAttestedDebug(path: string, init: RequestInit = {}): Promise { + const target = await attestLiveManagementProxy(); + if (!target) throw new Error("the live proxy could not be authenticated from its protected runtime record"); + return fetch(`${target.baseUrl}${path}`, { + ...init, + headers: runningProxyUpdateHeaders(), + }); } async function fetchDebugSettings(): Promise { - const live = await requireLiveProxy(); try { - const res = await fetch(`http://${probeHostname(live.hostname)}:${live.port}/api/debug`, { - headers: runningProxyUpdateHeaders(), - }); + const res = await fetchAttestedDebug("/api/debug"); if (!res.ok) { console.error(`Failed to read debug settings (${res.status})`); process.exit(1); @@ -31,10 +28,8 @@ async function fetchDebugSettings(): Promise { } async function putDebugSettings(body: Record): Promise { - const live = await requireLiveProxy(); - const res = await fetch(`http://${probeHostname(live.hostname)}:${live.port}/api/debug`, { + const res = await fetchAttestedDebug("/api/debug", { method: "PUT", - headers: runningProxyUpdateHeaders(), body: JSON.stringify(body), }); if (!res.ok) { @@ -70,12 +65,9 @@ function envDebugEnabled(): boolean { } async function printProviderLogs(follow: boolean): Promise { - const live = await requireLiveProxy(); - const base = `http://${probeHostname(live.hostname)}:${live.port}/api/debug/logs`; - let after = 0; try { - const res = await fetch(`${base}?limit=500`, { headers: runningProxyUpdateHeaders() }); + const res = await fetchAttestedDebug("/api/debug/logs?limit=500"); if (!res.ok) { console.error(`Failed to read debug logs (${res.status})`); process.exit(1); @@ -93,7 +85,7 @@ async function printProviderLogs(follow: boolean): Promise { while (true) { await new Promise(resolve => setTimeout(resolve, 1000)); try { - const res = await fetch(`${base}?after=${after}&limit=500`, { headers: runningProxyUpdateHeaders() }); + const res = await fetchAttestedDebug(`/api/debug/logs?after=${after}&limit=500`); if (!res.ok) continue; const entries = await res.json() as { seq: number; line: string }[]; for (const entry of entries) console.log(entry.line); @@ -105,12 +97,9 @@ async function printProviderLogs(follow: boolean): Promise { } async function printUsageLogs(follow: boolean): Promise { - const live = await requireLiveProxy(); - const base = `http://${probeHostname(live.hostname)}:${live.port}/api/debug/usage-logs`; - let after = 0; try { - const res = await fetch(`${base}?limit=500`, { headers: runningProxyUpdateHeaders() }); + const res = await fetchAttestedDebug("/api/debug/usage-logs?limit=500"); if (!res.ok) { console.error(`Failed to read usage debug logs (${res.status})`); process.exit(1); @@ -129,7 +118,7 @@ async function printUsageLogs(follow: boolean): Promise { while (true) { await new Promise(resolve => setTimeout(resolve, 1000)); try { - const res = await fetch(`${base}?after=${after}&limit=500`, { headers: runningProxyUpdateHeaders() }); + const res = await fetchAttestedDebug(`/api/debug/usage-logs?after=${after}&limit=500`); if (!res.ok) continue; const entries = await res.json() as { seq: number; line: string }[]; for (const entry of entries) console.log(entry.line); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 236c34f3e4..7efb1cace6 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -11,7 +11,11 @@ import { accessSync, constants, existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { getConfigDir, getConfigPath, readConfigDiagnostics, readPid, readRuntimePort, resolveEnvValue } from "../config"; -import { findLiveProxy } from "../server/proxy-liveness"; +import { + attestLiveManagementProxy, + findLiveProxy, + type ManagementAttestationIo, +} from "../server/proxy-liveness"; import { gracefulStopHost } from "../lib/process-control"; import { BUN_RUNTIME_SOURCES } from "../lib/bun-runtime"; import type { BunRuntimeSource } from "../lib/bun-runtime"; @@ -608,9 +612,18 @@ export async function fetchServiceMemory( port: number, token: string | null, fetchImpl: typeof fetch = fetch, + deps: { + managementAttestation?: Omit; + attestLiveManagementProxyImpl?: typeof attestLiveManagementProxy; + } = {}, ): Promise { try { - const res = await fetchImpl(`http://${host}:${port}/api/system/memory`, { + const attest = deps.attestLiveManagementProxyImpl ?? attestLiveManagementProxy; + const target = await attest({ ...(deps.managementAttestation ?? {}), fetchFn: fetchImpl }); + if (!target || target.baseUrl !== `http://${host}:${port}`) { + return { status: "unreachable", error: "runtime attestation failed" }; + } + const res = await fetchImpl(`${target.baseUrl}/api/system/memory`, { headers: token ? { [API_KEY_HEADER]: token } : {}, signal: AbortSignal.timeout(SERVICE_MEMORY_TIMEOUT_MS), }); diff --git a/src/cli/gui-launch.ts b/src/cli/gui-launch.ts new file mode 100644 index 0000000000..e162dd61f5 --- /dev/null +++ b/src/cli/gui-launch.ts @@ -0,0 +1,257 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { GUI_LAUNCH_TICKET_PATH } from "../identity"; +import { + forgetEphemeralSecretPath, + hardenSecretDir, + hardenSecretPath, +} from "../lib/windows-secret-acl"; +import { isLoopbackHostname } from "../server/auth-cors"; +import { runtimeRequest } from "./runtime-api"; + +export interface GuiLaunchTicketResponse { + ticket: string; + origin: string; + route: string; + expiresAt: number; +} + +interface GuiLaunchDeps { + runtimeRequest?: typeof runtimeRequest; + now?: () => number; +} + +export interface GuiLaunchHandoff { + directory: string; + file: string; + command: string; + args: string[]; + cleanup: () => void; +} + +function validRoute(route: string): boolean { + return route.length > 0 + && route.length <= 512 + && !route.startsWith("/") + && !route.includes("#") + && !/[\u0000-\u001f\u007f]/.test(route); +} + +function validateTicketResponse( + value: GuiLaunchTicketResponse, + route: string, + expectedBaseUrl: string, + now: number, +): GuiLaunchTicketResponse { + let origin: URL; + try { + origin = new URL(value.origin); + } catch { + throw new Error("CodexCommander returned an invalid dashboard launch confirmation."); + } + if (!/^ccx_launch_[A-Za-z0-9_-]{43}$/.test(value.ticket) + || value.route !== route + || !Number.isFinite(value.expiresAt) + || value.expiresAt <= now + || value.expiresAt > now + 60_000 + || origin.protocol !== "http:" + || !isLoopbackHostname(origin.hostname) + || origin.origin !== new URL(expectedBaseUrl).origin + || origin.pathname !== "/" + || origin.search !== "" + || origin.hash !== "") { + throw new Error("CodexCommander returned an invalid dashboard launch confirmation."); + } + return value; +} + +export function buildConfirmedGuiLaunchUrl(ticket: GuiLaunchTicketResponse): string { + const url = new URL(ticket.origin); + url.hash = new URLSearchParams({ + "ccx-launch-ticket": ticket.ticket, + "ccx-route": ticket.route, + }).toString(); + return url.toString(); +} + +export async function mintConfirmedGuiLaunch( + baseUrl: string, + port: number, + route: string, + deps: GuiLaunchDeps = {}, +): Promise<{ url: string; origin: string }> { + if (!validRoute(route)) throw new Error("Invalid dashboard route."); + if (Number(new URL(baseUrl).port || "80") !== port) { + throw new Error("Invalid dashboard endpoint."); + } + const request = deps.runtimeRequest ?? runtimeRequest; + const value = await request(GUI_LAUNCH_TICKET_PATH, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ route }), + }, { baseUrl }); + const ticket = validateTicketResponse(value, route, baseUrl, (deps.now ?? Date.now)()); + return { url: buildConfirmedGuiLaunchUrl(ticket), origin: ticket.origin }; +} + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function windowsRundll32(): string { + const windowsRoot = process.env.SystemRoot || process.env.WINDIR || "C:\\Windows"; + const candidate = join(windowsRoot, "System32", "rundll32.exe"); + return existsSync(candidate) ? candidate : "rundll32"; +} + +/** + * Put the fragment bearer in a user-private handoff file, never a launcher argv, + * environment value, console line, or durable browser store. The OS launcher sees + * only the random file path; cleanup is idempotent and outlives launcher delegation. + */ +export function createGuiLaunchHandoff( + url: string, + options: { platform?: NodeJS.Platform; temporaryRoot?: string } = {}, +): GuiLaunchHandoff { + const parsed = new URL(url); + if (parsed.protocol !== "http:" + || !isLoopbackHostname(parsed.hostname) + || !parsed.hash.includes("ccx-launch-ticket=")) { + throw new Error("Invalid confirmed dashboard URL."); + } + const platform = options.platform ?? process.platform; + const directory = mkdtempSync(join(options.temporaryRoot ?? tmpdir(), "ccx-gui-launch-")); + const cleanup = () => { + rmSync(directory, { recursive: true, force: true }); + forgetEphemeralSecretPath(join(directory, "dashboard.webloc")); + forgetEphemeralSecretPath(join(directory, "dashboard.url")); + forgetEphemeralSecretPath(join(directory, "dashboard.html")); + forgetEphemeralSecretPath(directory); + }; + const extension = platform === "darwin" ? "webloc" : platform === "win32" ? "url" : "html"; + const file = join(directory, `dashboard.${extension}`); + const contents = platform === "darwin" + ? `URL${escapeXml(url)}` + : platform === "win32" + ? `[InternetShortcut]\r\nURL=${url}\r\n` + : `CodexCommander`; + try { + chmodSync(directory, 0o700); + if (platform === "win32" && !hardenSecretDir(directory, { required: true }).ok) { + throw new Error("directory ACL hardening failed"); + } + writeFileSync(file, contents, { encoding: "utf8", flag: "wx", mode: 0o600 }); + chmodSync(file, 0o600); + if (platform === "win32" && !hardenSecretPath(file, { required: true }).ok) { + throw new Error("file ACL hardening failed"); + } + } catch { + cleanup(); + throw new Error("Could not create a private dashboard launch handoff."); + } + const command = platform === "darwin" + ? "/usr/bin/open" + : platform === "win32" + ? windowsRundll32() + : "xdg-open"; + const args = platform === "win32" + ? ["url.dll,FileProtocolHandler", file] + : [file]; + let cleaned = false; + return { + directory, + file, + command, + args, + cleanup: () => { + if (cleaned) return; + cleaned = true; + cleanup(); + }, + }; +} + +function scheduleGuiLaunchCleanup( + handoff: GuiLaunchHandoff, + platform: NodeJS.Platform, + spawnImpl: typeof spawn, +): void { + const seconds = "65"; + const cleanup = platform === "win32" + ? { + command: join( + process.env.SystemRoot || process.env.WINDIR || "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ), + args: [ + "-NoProfile", + "-NonInteractive", + "-Command", + "Start-Sleep -Seconds 65; Remove-Item -LiteralPath $args[0] -Recurse -Force -ErrorAction SilentlyContinue", + handoff.directory, + ], + } + : { + command: "/bin/sh", + args: ["-c", `sleep ${seconds}; rm -rf -- "$1"`, "ccx-gui-cleanup", handoff.directory], + }; + try { + const child = spawnImpl(cleanup.command, cleanup.args, { + detached: true, + stdio: "ignore", + shell: false, + }); + child.on("error", () => {}); + child.unref(); + } catch { + // The in-process fallback below still cleans up when the CLI stays alive; + // otherwise the private temp file contains only an already-expiring ticket. + } +} + +export function openConfirmedGuiUrl( + url: string, + options: { + platform?: NodeJS.Platform; + temporaryRoot?: string; + spawnImpl?: typeof spawn; + setTimeoutImpl?: typeof setTimeout; + } = {}, +): void { + const handoff = createGuiLaunchHandoff(url, options); + let child: ChildProcess; + try { + child = (options.spawnImpl ?? spawn)(handoff.command, handoff.args, { + detached: false, + stdio: "ignore", + shell: false, + }); + } catch { + handoff.cleanup(); + throw new Error("Could not open the CodexCommander dashboard."); + } + child.once("error", handoff.cleanup); + // A launcher can exit immediately after delegating to the browser. Retain the + // private file through the 30-second ticket TTL, then remove it with margin. + scheduleGuiLaunchCleanup(handoff, options.platform ?? process.platform, options.spawnImpl ?? spawn); + const fallback = (options.setTimeoutImpl ?? setTimeout)(handoff.cleanup, 65_000); + fallback.unref?.(); + child.unref(); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 5e8fc74c15..99d7dc4f49 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -33,6 +33,18 @@ import { startTokenGuardian } from "../oauth/token-guardian"; import { maybeAutoRestoreCodexShim } from "./codex-shim-autorestore"; import { scheduleCatalogPrewarm } from "./catalog-prewarm"; import { syncModelsToCodex } from "../codex/sync"; +import { + applyInvalidatedCacheWorkers, + applySynchronizedCatalogWorkers, + bindCacheArtifactForApply, + bindCatalogArtifactsForApply, + catalogSyncCanApply, + captureCatalogRestartFence, + reportCatalogWorkerApply, + syncCodexCatalogForCli, + type CliCodexSyncResult, + warnAfterCatalogWrite, +} from "./catalog-activation"; import { setIntegrationEnabled, shouldSyncCodexOnStart, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state"; import { collectOrcaCodexHomeDiagnostic } from "../codex/home"; import { removeOwnedConfigState } from "../lib/config-ownership"; @@ -355,10 +367,10 @@ async function handleStart(options: { block?: boolean } = {}) { // Drive readiness from the real startup catalog sync while /healthz remains available. const startupSync = await syncCodexOnStartIfEnabled(port, config, undefined, readinessGate); if (!startupSync.ran) console.log(" Codex integration OFF; startup left Codex native."); - // Warn once, after both startup write sites settle, when a stale Codex app-server may - // still be holding the previous model catalog. - const { consumeStartupCacheInvalidationWrite } = await import("../server"); - if (consumeStartupCacheInvalidationWrite() || startupSync.catalogWritten || startupSync.cacheSynced) { + // The canonical startup convergence is the only startup catalog/cache writer. + // Warn only when that convergence reports a real artifact write; server listen + // itself never moves an mtime or manufactures a stale-worker fence. + if (startupSync.catalogWritten || startupSync.cacheSynced) { const { warnIfStaleCodexAppServersAfterStartupWrite } = await import("../codex/app-server-processes"); warnIfStaleCodexAppServersAfterStartupWrite({ log: console }); } @@ -794,20 +806,47 @@ switch (command) { } case "sync": { const restartCodex = args.slice(1).includes("--restart-codex"); - const synced = await syncModelsToCodex((await findLiveProxy())?.port); + // Capture consent + desired generation before any awaited discovery. The + // same opaque fence is checked again immediately before every eligible + // SIGTERM by the shared Apply helper. + const restartFence = captureCatalogRestartFence( + restartCodex && shouldSyncCodexOnStart(loadConfig()), + ); + let synced: CliCodexSyncResult; + const live = await findLiveProxy(); + try { + synced = await syncCodexCatalogForCli(live); + } catch (error) { + process.exitCode = 1; + console.error(`Codex sync did not complete: ${error instanceof Error ? error.message : String(error)}`); + break; + } if (synced.status === "skipped") { console.log("Codex integration is OFF; sync skipped and no Codex files changed."); } else if (!synced.ok) { process.exitCode = 1; console.error("Codex sync did not complete. Fix the reported Codex config issue and retry."); + } else if (live && synced.message) { + // Local sync already emits its established progress messages. A live + // server intentionally keeps its logs out of this CLI process. + console.log(synced.message); } - // Only warn/restart when a catalog or models_cache write actually happened. This is - // deliberately not an `else`: refreshCodexModelCatalog runs before injectCodexConfig, - // so a sync can fail (`ok: false`) after the catalog was already rewritten — which is - // exactly when a long-lived app-server is holding the stale list. - if (synced.catalogWritten || synced.cacheSynced) { - const { afterCatalogWriteHandleAppServers } = await import("../codex/app-server-processes"); - afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console }); + const synchronizedCatalogIsUsable = catalogSyncCanApply(synced, live !== null); + if (restartCodex && synchronizedCatalogIsUsable) { + // Explicit Apply also resolves a worker left stale by an earlier write, + // even when this convergence is a semantic no-op with preserved mtimes. + const applied = await applySynchronizedCatalogWorkers( + bindCatalogArtifactsForApply(restartFence), + synced, + ); + if (!applied) { + console.error("The saved configuration could not be fenced before synchronization; no Codex worker was stopped."); + process.exitCode = 1; + } else if (!reportCatalogWorkerApply(applied)) { + process.exitCode = 1; + } + } else if (!restartCodex && (synced.catalogWritten || synced.cacheSynced)) { + await warnAfterCatalogWrite("activation"); } break; } @@ -822,22 +861,34 @@ switch (command) { console.log("Codex integration is OFF; cache sync skipped and no Codex files changed."); break; } + const restartFence = captureCatalogRestartFence(restartCodex); const { withCatalogWriteSerialization } = await import("../codex/catalog-write-serialization"); const { invalidateCodexModelsCacheWithPermit } = await import("../codex/catalog/sync"); const { getCodexHome } = await import("../codex/paths"); const owningCodexHome = getCodexHome(); const invalidated = withCatalogWriteSerialization(owningCodexHome, permit => invalidateCodexModelsCacheWithPermit(permit, owningCodexHome)); - // Only warn/restart when models_cache was actually rewritten from a readable catalog. + // Cache invalidation remains its own advanced command. Its exact cache mtime + // is the evidence boundary, so only workers proven older than this write are + // eligible for explicit interruption. if (invalidated.kind === "completed" && invalidated.value) { - const { afterCatalogWriteHandleAppServers } = await import("../codex/app-server-processes"); - afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console }); + if (restartCodex) { + const applied = await applyInvalidatedCacheWorkers( + bindCacheArtifactForApply(restartFence), + ); + if (!applied) { + console.error("The saved configuration could not be fenced before cache invalidation; no Codex worker was stopped."); + process.exitCode = 1; + } else if (!reportCatalogWorkerApply(applied)) { + process.exitCode = 1; + } + } else { + await warnAfterCatalogWrite("cache"); + } } break; } case "gui": { - const cfg = await import("../config"); - const config = cfg.loadConfig(); const ensured = await ensureProxyLifecycle({ honorAutoStart: false, ensureCompanion: true, @@ -854,13 +905,19 @@ switch (command) { process.exitCode = 1; break; } - // Open the host the proxy actually binds — `localhost` only answers for - // loopback/wildcard binds, not a concrete LAN/IPv6 hostname. - const guiHost = probeHostname(live?.hostname ?? config.hostname); - const guiUrl = `http://${guiHost === "127.0.0.1" ? "localhost" : guiHost}:${live.port}`; - console.log(`Opening ${guiUrl}`); - const { openUrl } = await import("../lib/open-url"); - openUrl(guiUrl); + // Mint through the attested admin channel, then hand the short-lived bearer + // to the browser without putting it in a launcher argv or console line. + const guiHost = probeHostname(live.hostname); + const baseUrl = `http://${guiHost}:${live.port}`; + try { + const { mintConfirmedGuiLaunch, openConfirmedGuiUrl } = await import("./gui-launch"); + const launch = await mintConfirmedGuiLaunch(baseUrl, live.port, "dashboard"); + console.log(`Opening ${launch.origin}`); + openConfirmedGuiUrl(launch.url); + } catch (error) { + console.error(`❌ ${error instanceof Error ? error.message : "Could not open the CodexCommander dashboard."}`); + process.exitCode = 1; + } break; } case "service": diff --git a/src/cli/macos-lifecycle.ts b/src/cli/macos-lifecycle.ts index 2829b5ea9a..54e0dd6c87 100644 --- a/src/cli/macos-lifecycle.ts +++ b/src/cli/macos-lifecycle.ts @@ -8,9 +8,9 @@ import { } from "./proxy-lifecycle"; import { APPLY_CODEX_CATALOG_ACTION, - applyCodexCatalog, type ApplyCodexCatalogLifecycleResult, } from "../codex/catalog-apply"; +import { applyCodexCatalogForCompanion } from "./catalog-activation"; export const MACOS_LIFECYCLE_HELPER_COMMAND = "__macos-lifecycle"; export const MACOS_LIFECYCLE_JSON_MAX_BYTES = 2 * 1024; @@ -88,7 +88,7 @@ async function perform(action: MacOSLifecycleAction): Promise; + attestLiveManagementProxyImpl?: typeof attestLiveManagementProxy; + } = {}, ): Promise { - const baseUrl = `http://${probeHostname(live.hostname)}:${live.port}`; const fetchImpl = deps.fetchImpl ?? fetch; + const attest = deps.attestLiveManagementProxyImpl ?? attestLiveManagementProxy; + const target = await attest({ + ...(deps.managementAttestation ?? {}), + fetchFn: fetchImpl, + timeoutMs: deps.timeoutMs ?? OPENCODE_PROXY_MODELS_TIMEOUT_MS, + }); + if (!target + || target.port !== live.port + || target.hostname !== live.hostname + || live.pid === null + || target.pid !== live.pid) { + throw new Error("Management request refused: the live proxy could not be authenticated from its protected runtime record."); + } const headers = new Headers({ Accept: "application/json" }); - const token = apiKey.trim(); + // `/api/models` is management-plane. The child data-plane admission key must + // never be substituted for the independent admin credential. + const token = configuredAdminToken(); if (token) headers.set(API_KEY_HEADER, token); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), deps.timeoutMs ?? OPENCODE_PROXY_MODELS_TIMEOUT_MS); @@ -202,7 +225,7 @@ export async function fetchOpencodeProxyModels( let text: string; try { response = await Promise.race([ - fetchImpl(`${baseUrl}/api/models`, { + fetchImpl(`${target.baseUrl}/api/models`, { headers, signal: controller.signal, }), @@ -433,16 +456,16 @@ export async function cmdOpencode(args: string[]): Promise { ensureCompanion: true, startEnv: opencodeProxyStartEnv(process.env) as NodeJS.ProcessEnv, }); - const live = ensured.ok ? await findLiveProxy() : null; + const live = ensured.ok ? await attestLiveManagementProxy() : null; if (!live) { - console.error(`❌ ${ensured.ok ? "Proxy identity disappeared after starting." : ensured.message}`); + console.error(`❌ ${ensured.ok ? "Proxy identity could not be authenticated after starting." : ensured.message}`); return 1; } const apiKey = opencodeApiKey(config); let proxyModels: OpencodeProxyModelRow[]; try { - proxyModels = await fetchOpencodeProxyModels(live, apiKey); + proxyModels = await fetchOpencodeProxyModels(live); } catch (error) { const reason = error instanceof Error ? error.message : String(error); console.error(`❌ Could not fetch the model catalog from the proxy: ${reason}`); @@ -470,6 +493,15 @@ export async function cmdOpencode(args: string[]): Promise { return 1; } const env = builtEnv; + // Re-prove the exact listener immediately before the child receives the durable + // data-plane key and provider config that route request bodies to it. + const launchTarget = await attestLiveManagementProxy({ expectedPid: live.pid }); + if (!launchTarget + || launchTarget.port !== live.port + || launchTarget.hostname !== live.hostname) { + console.error("❌ Proxy identity changed before OpenCode could launch; retry the command."); + return 1; + } return await new Promise(resolve => { const inv = commandInvocation("opencode", args); const child = spawn(inv.file, inv.args, { stdio: "inherit", env: env as NodeJS.ProcessEnv, ...inv.options }); diff --git a/src/cli/runtime-api.ts b/src/cli/runtime-api.ts index e1e9232e1a..fc95da6690 100644 --- a/src/cli/runtime-api.ts +++ b/src/cli/runtime-api.ts @@ -9,14 +9,24 @@ * - 다른 대안 대신 이 방식을 선택한 이유: GUI/CLI의 검증 규칙이 갈라지지 않고 fallback port도 안전하게 찾는다. * - 장점, 단점 및 영향: 동작 일관성이 높아지는 대신 live 관리 명령은 실행 중인 proxy가 필요하다. */ -import { findLiveProxy, probeHostname } from "../server/proxy-liveness"; +import { + attestLiveManagementProxy, + findLiveProxy, + probeHostname, + type ManagementAttestationIo, +} from "../server/proxy-liveness"; import { runningProxyUpdateHeaders } from "../oauth/login-cli"; +import { API_KEY_HEADER } from "../identity"; export type CliStdin = NodeJS.ReadableStream & { isTTY?: boolean; readableEnded?: boolean }; export interface RuntimeApiDeps { baseUrl?: string; fetchImpl?: typeof fetch; + /** Injectable discovery/record seams; no option bypasses the proof contract. */ + managementAttestation?: Omit; + /** Test seam for an explicitly attested transport. */ + attestLiveManagementProxyImpl?: typeof attestLiveManagementProxy; /** Test injection for commands that read a secret from stdin instead of argv. */ stdinImpl?: CliStdin; stdinTimeoutMs?: number; @@ -63,10 +73,40 @@ export async function runtimeRequest( init: RequestInit = {}, deps: RuntimeApiDeps = {}, ): Promise { - const baseUrl = await runtimeBaseUrl(deps); const headers = runningProxyUpdateHeaders(); for (const [key, value] of new Headers(init.headers).entries()) headers.set(key, value); const fetchImpl = deps.fetchImpl ?? fetch; + const method = (init.method ?? "GET").toUpperCase(); + const credentialHeaders = [ + API_KEY_HEADER, + "authorization", + "x-api-key", + "cookie", + "proxy-authorization", + ]; + const releasesSensitiveRequest = credentialHeaders.some(name => Boolean(headers.get(name)?.trim())) + || (init.body !== undefined && init.body !== null) + || (method !== "GET" && method !== "HEAD" && method !== "OPTIONS"); + let baseUrl: string; + if (releasesSensitiveRequest) { + const attest = deps.attestLiveManagementProxyImpl ?? attestLiveManagementProxy; + const target = await attest({ + ...(deps.managementAttestation ?? {}), + fetchFn: fetchImpl, + }); + if (!target) { + throw new RuntimeApiError( + "Management request refused: the live proxy could not be authenticated from its protected runtime record.", + 503, + null, + ); + } + // A caller-provided base URL is discovery/test metadata only. Sensitive bytes + // always go to the freshly attested runtime target. + baseUrl = target.baseUrl; + } else { + baseUrl = await runtimeBaseUrl(deps); + } let response: Response; try { response = await fetchImpl(`${baseUrl}${path.startsWith("/") ? path : `/${path}`}`, { ...init, headers }); diff --git a/src/cli/v2.ts b/src/cli/v2.ts index 26171adf84..46e56406d2 100644 --- a/src/cli/v2.ts +++ b/src/cli/v2.ts @@ -15,8 +15,15 @@ import { dirname } from "node:path"; import { activeCodexConfigPath, getAgentsEnabled, getAgentsMaxDepth, getLogicalMaxThreads, getSubagentDeveloperInstructions, hasAgentsMaxThreads, isMultiAgentV2Enabled, transitionMultiAgentV2 } from "../codex/features"; import { commandInvocation, type SpawnInvocation } from "../lib/win-exec"; -import { loadConfig, saveConfig } from "../config"; +import { + loadConfig, + mutatePersistedConfig, + readConfigDiagnostics, + saveConfig, + withConfigMutationLockSync, +} from "../config"; import { resolveAndPersistCodexRuntime, type ResolveCodexRuntimeDeps } from "../codex/runtime"; +import type { CodexCommanderConfig } from "../types"; export interface V2CliDeps { execFile?: (file: string, args: string[], options?: SpawnInvocation["options"]) => void; @@ -111,6 +118,57 @@ export function multiAgentModeLine(mode: string): string { } } +function applyMultiAgentModeField( + config: CodexCommanderConfig, + mode: "v1" | "default" | "v2", +): boolean { + const next = mode === "default" ? undefined : mode; + if (config.multiAgentMode === next) return false; + if (next === undefined) delete config.multiAgentMode; + else config.multiAgentMode = next; + return true; +} + +/** + * Persist only the collaboration policy against the newest on-disk config. + * + * `mutatePersistedConfig` deliberately refuses a missing config. The CLI has a + * longstanding first-run contract, though: `ccx v2 mode ...` creates the + * default config after a successful Codex feature transition. The narrow + * fallback below preserves that behavior without turning a config that + * vanished during the (potentially slow) transition into permission to + * recreate a stale snapshot. The second attempt and first-run initialization + * share the same cross-process mutation transaction, so a config created by a + * cooperating writer in between is rebased rather than replaced. + */ +function persistMultiAgentMode( + mode: "v1" | "default" | "v2", + firstRunConfig?: CodexCommanderConfig, +) { + const mutateCurrent = () => mutatePersistedConfig(current => ({ + changed: applyMultiAgentModeField(current, mode), + value: current.multiAgentMode, + })); + + let outcome = mutateCurrent(); + if (outcome.status !== "unavailable" || outcome.reason !== "missing" || !firstRunConfig) { + return outcome; + } + + outcome = withConfigMutationLockSync(() => { + // A writer may have created the file after the first missing observation. + // If so, the ordinary field-scoped mutator now rebases onto those bytes. + const retry = mutateCurrent(); + if (retry.status !== "unavailable" || retry.reason !== "missing") return retry; + + const initialized = structuredClone(firstRunConfig); + applyMultiAgentModeField(initialized, mode); + saveConfig(initialized); + return { status: "committed" as const, value: initialized.multiAgentMode }; + }); + return outcome; +} + export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () => Promise): Promise { const log = deps.log ?? console; const isEnabled = deps.isEnabled ?? isMultiAgentV2Enabled; @@ -157,7 +215,10 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () log.error("v2 mode: expected v1|default|v2"); return 1; } - const cfg = loadConfig(); + // Capture whether this invocation is a genuine first run before the Codex + // feature command gets a chance to block while another writer changes disk. + const initialDiagnostics = readConfigDiagnostics(); + const initialConfig = loadConfig(); if (modeArg !== "default") { const target = modeArg === "v2"; const transition = transitionMultiAgentV2(target, enabled => runCodexFeatures(enabled ? "enable" : "disable", deps)); @@ -166,9 +227,14 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () return 1; } } - if (modeArg === "default") delete cfg.multiAgentMode; - else cfg.multiAgentMode = modeArg as "v1" | "v2"; - saveConfig(cfg); + const persisted = persistMultiAgentMode( + modeArg, + initialDiagnostics.source === "default" ? initialConfig : undefined, + ); + if (persisted.status === "unavailable") { + log.error(`multi-agent mode could not be saved (${persisted.reason})`); + return 1; + } try { const sync = deps.sync ?? (await import("../codex/sync")).syncModelsToCodex; await sync(findPort ? await findPort() : undefined); diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index 6664f015be..ca8d3c21c6 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -92,7 +92,18 @@ export interface CodexAppServerProcessIo { waitExit?: (pid: number, timeoutMs: number) => boolean; now?: () => number; readStartMs?: (pid: number) => number | null; + /** + * Uncertainty of `readStartMs` in milliseconds. Injected readers are exact + * unless they declare otherwise. Darwin's default `ps lstart` and Linux's + * epoch conversion from whole-second `/proc/stat` btime use a one-second + * window. + */ + startTimePrecisionMs?: number; catalogMtimeMs?: () => number | null; + /** Exact current-user process snapshot used by the final per-PID signal fence. */ + readSnapshot?: (pid: number) => ProcessSnapshot | null; + /** Final caller-owned consent/revision fence, evaluated immediately before SIGTERM. */ + authorizeSignal?: () => boolean; } /** Split a process command line into argv-like tokens (handles simple quotes). */ @@ -252,6 +263,7 @@ function listUnixProcSnapshots(uid: number | undefined): ProcessSnapshot[] { // "no processes" — the staleness collector must not read it as not_running. if (!existsSync("/proc")) throw new Error("procfs_unavailable"); const out: ProcessSnapshot[] = []; + let candidateVerificationFailed = false; for (const ent of readdirSync("/proc")) { if (!/^\d+$/.test(ent)) continue; const pid = Number(ent); @@ -259,54 +271,72 @@ function listUnixProcSnapshots(uid: number | undefined): ProcessSnapshot[] { try { const status = readFileSync(`/proc/${pid}/status`, "utf8"); const processUid = parseUnixProcStatusUid(status); - if (uid !== undefined && processUid !== undefined && processUid !== uid) continue; + if (uid !== undefined && processUid !== uid) continue; const commandLine = readFileSync(`/proc/${pid}/cmdline`) .toString("utf8") .replace(/\0/g, " ") .trim(); if (!commandLine) continue; - out.push({ pid, commandLine, uid: processUid }); + if (!isCodexAppServerCommandLine(commandLine)) { + out.push({ pid, commandLine, uid: processUid }); + continue; + } + + // Bind argv and birth at one enumeration boundary. If the candidate + // changes while it is being read, fail the whole enumeration closed; + // omitting it could otherwise be misreported as `not_running`. + const startedAtMs = readLinuxProcStartMs(pid); + const verifiedCommandLine = readFileSync(`/proc/${pid}/cmdline`) + .toString("utf8") + .replace(/\0/g, " ") + .trim(); + const verifiedStartedAtMs = readLinuxProcStartMs(pid); + if ( + startedAtMs === null + || verifiedStartedAtMs !== startedAtMs + || verifiedCommandLine !== commandLine + ) { + candidateVerificationFailed = true; + continue; + } + out.push({ pid, commandLine, uid: processUid, startedAtMs }); } catch { /* process exited mid-scan */ } } + if (candidateVerificationFailed) throw new Error("linux_codex_identity_unverified"); return out; } -function listDarwinSnapshots(uid: number | undefined): ProcessSnapshot[] { +function parseDarwinSnapshotLine(line: string, expectedUid: number): ProcessSnapshot | null { + const match = /^(\d+)\s+(\S+\s+\S+\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+)$/.exec(line); + if (!match) return null; + const pid = Number(match[1]); + const startedAtMs = Date.parse(match[2]!.trim()); + const commandLine = match[3]!.trim(); + if (!Number.isSafeInteger(pid) || pid <= 1 || !commandLine) return null; + return { + pid, + commandLine, + uid: expectedUid, + ...(Number.isFinite(startedAtMs) ? { startedAtMs } : {}), + }; +} + +function listDarwinSnapshots(uid: number): ProcessSnapshot[] { const out: ProcessSnapshot[] = []; // Top-level exec failure propagates: callers decide their own safe default // (restart flow → treat as none; staleness check → unknown, never "fresh"). - const output = uid !== undefined - ? execFileSync("ps", ["-u", String(uid), "-o", "pid=,command="], { - encoding: "utf-8", - stdio: ["ignore", "pipe", "ignore"], - timeout: 5_000, - }) - : execFileSync("ps", ["-axo", "pid=,uid=,command="], { - encoding: "utf-8", - stdio: ["ignore", "pipe", "ignore"], - timeout: 5_000, - }); + const output = execFileSync("ps", ["-u", String(uid), "-o", "pid=,lstart=,command="], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 5_000, + }); for (const raw of output.split(/\r?\n/)) { const line = raw.trim(); if (!line) continue; - if (uid !== undefined) { - const match = /^(\d+)\s+(.*)$/.exec(line); - if (!match) continue; - const pid = Number(match[1]); - const commandLine = match[2]?.trim() ?? ""; - if (!Number.isSafeInteger(pid) || pid <= 1 || !commandLine) continue; - out.push({ pid, commandLine, uid }); - continue; - } - const match = /^(\d+)\s+(\d+)\s+(.*)$/.exec(line); - if (!match) continue; - const pid = Number(match[1]); - const processUid = Number(match[2]); - const commandLine = match[3]?.trim() ?? ""; - if (!Number.isSafeInteger(pid) || pid <= 1 || !commandLine) continue; - out.push({ pid, commandLine, uid: Number.isSafeInteger(processUid) ? processUid : undefined }); + const snapshot = parseDarwinSnapshotLine(line, uid); + if (snapshot) out.push(snapshot); } return out; } @@ -348,7 +378,8 @@ export function listWindowsSnapshots(): ProcessSnapshot[] { " $owner=if($o.Domain){\"$($o.Domain)\\$($o.User)\"}else{$o.User}", " if($owner -ine $me){return}", " $cmd=($_.CommandLine -replace \"`t\",\" \")", - " \"{0}`t{1}`t{2}\" -f $_.ProcessId, $cmd, $owner", + " $born=$_.CreationDate.ToUniversalTime().ToString(\"o\")", + " \"{0}`t{1}`t{2}`t{3}\" -f $_.ProcessId, $born, $cmd, $owner", " } catch { \"__CCX_ENUM_INCOMPLETE__\" }", "}", ].join("\n"); @@ -366,20 +397,93 @@ export function listWindowsSnapshots(): ProcessSnapshot[] { const tab = line.indexOf("\t"); if (tab <= 0) continue; const tab2 = line.indexOf("\t", tab + 1); - if (tab2 <= tab) continue; + const tab3 = line.indexOf("\t", tab2 + 1); + if (tab2 <= tab || tab3 <= tab2) continue; const pid = Number(line.slice(0, tab)); - const commandLine = line.slice(tab + 1, tab2).trim(); - const owner = line.slice(tab2 + 1).trim(); + const startedAtMs = Date.parse(line.slice(tab + 1, tab2).trim()); + const commandLine = line.slice(tab2 + 1, tab3).trim(); + const owner = line.slice(tab3 + 1).trim(); if (!Number.isSafeInteger(pid) || pid <= 1 || !commandLine || !owner) continue; - out.push({ pid, commandLine, owner }); + out.push({ pid, commandLine, owner, ...(Number.isFinite(startedAtMs) ? { startedAtMs } : {}) }); } return out; } function defaultListSnapshots(platform: NodeJS.Platform, getuid: () => number | undefined): ProcessSnapshot[] { if (platform === "win32") return listWindowsSnapshots(); - if (platform === "darwin") return listDarwinSnapshots(getuid()); - return listUnixProcSnapshots(getuid()); + const uid = getuid(); + if (uid === undefined) throw new Error("current_user_unavailable"); + if (platform === "darwin") return listDarwinSnapshots(uid); + return listUnixProcSnapshots(uid); +} + +function defaultReadSnapshot( + pid: number, + platform: NodeJS.Platform, + getuid: () => number | undefined, +): ProcessSnapshot | null { + if (platform !== "darwin" && platform !== "win32") { + const expectedUid = getuid(); + if (expectedUid === undefined) return null; + const startedAtMs = readLinuxProcStartMs(pid); + const status = readFileSync(`/proc/${pid}/status`, "utf8"); + const uid = parseUnixProcStatusUid(status); + if (uid !== expectedUid) return null; + const commandLine = readFileSync(`/proc/${pid}/cmdline`) + .toString("utf8") + .replace(/\0/g, " ") + .trim(); + const verifiedStartedAtMs = readLinuxProcStartMs(pid); + return commandLine && startedAtMs !== null && verifiedStartedAtMs === startedAtMs + ? { pid, commandLine, uid, startedAtMs } + : null; + } + if (platform === "darwin") { + const expectedUid = getuid(); + if (expectedUid === undefined) return null; + const output = execFileSync("ps", ["-o", "pid=,uid=,lstart=,command=", "-p", String(pid)], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 2_000, + }).trim(); + const match = /^(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+)$/.exec(output); + if (!match) return null; + const observedPid = Number(match[1]); + const uid = Number(match[2]); + const startedAtMs = Date.parse(match[3]!.trim()); + const commandLine = match[4]!.trim(); + if (observedPid !== pid || !commandLine || uid !== expectedUid || !Number.isFinite(startedAtMs)) return null; + return { pid, commandLine, uid, startedAtMs }; + } + + const psCommand = [ + "$ErrorActionPreference='Stop'", + "$me=[System.Security.Principal.WindowsIdentity]::GetCurrent().Name", + `$p=Get-CimInstance Win32_Process -Filter \"ProcessId=${pid}\"`, + "if($null -eq $p){return}", + "$o=Invoke-CimMethod -InputObject $p -MethodName GetOwner -ErrorAction Stop", + "if($null -eq $o -or $o.ReturnValue -ne 0 -or [string]::IsNullOrWhiteSpace($o.User)){throw 'owner_unavailable'}", + "$owner=if($o.Domain){\"$($o.Domain)\\$($o.User)\"}else{$o.User}", + "if($owner -ine $me){return}", + "$cmd=($p.CommandLine -replace \"`t\",\" \")", + "$born=$p.CreationDate.ToUniversalTime().ToString(\"o\")", + "\"{0}`t{1}`t{2}`t{3}\" -f $p.ProcessId, $born, $cmd, $owner", + ].join("\n"); + const output = execFileSync("powershell.exe", [ + "-NoProfile", "-NoLogo", "-NonInteractive", "-WindowStyle", "Hidden", + "-Command", psCommand, + ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, windowsHide: true }).trim(); + const tab = output.indexOf("\t"); + const tab2 = output.indexOf("\t", tab + 1); + const tab3 = output.indexOf("\t", tab2 + 1); + if (tab <= 0 || tab2 <= tab || tab3 <= tab2) return null; + const observedPid = Number(output.slice(0, tab)); + const startedAtMs = Date.parse(output.slice(tab + 1, tab2).trim()); + const commandLine = output.slice(tab2 + 1, tab3).trim(); + const owner = output.slice(tab3 + 1).trim(); + return observedPid === pid && commandLine && owner && Number.isFinite(startedAtMs) + ? { pid, commandLine, owner, startedAtMs } + : null; } export function listCodexAppServerProcesses(io: CodexAppServerProcessIo = {}): CodexAppServerProcess[] { @@ -409,7 +513,11 @@ export function listCodexAppServerProcesses(io: CodexAppServerProcessIo = {}): C if (seen.has(snapshot.pid)) continue; if (!isCodexAppServerCommandLine(snapshot.commandLine)) continue; seen.add(snapshot.pid); - matched.push({ pid: snapshot.pid, commandLine: snapshot.commandLine }); + matched.push({ + pid: snapshot.pid, + commandLine: snapshot.commandLine, + ...(snapshot.startedAtMs !== undefined ? { startedAtMs: snapshot.startedAtMs } : {}), + }); } return matched; } @@ -552,6 +660,80 @@ export interface CodexAppServerCatalogStatus { catalogMtimeMs: number | null; } +const verifiedProcessesByCatalogStatus = new WeakMap(); +const startTimePrecisionByCatalogStatus = new WeakMap(); + +function normalizedStartTimePrecision(value: number): number { + return Number.isFinite(value) && value > 0 ? value : 0; +} + +function classifyKnownProcessStarts( + processes: readonly { startedAtMs: number | null }[], + catalogMtimeMs: number, + precisionMs: number, +): "fresh" | "stale" | "unknown" { + let stale = false; + for (const process of processes) { + const startedAtMs = process.startedAtMs; + if (startedAtMs === null) return "unknown"; + if (precisionMs > 0 && startedAtMs <= catalogMtimeMs && startedAtMs + precisionMs > catalogMtimeMs) { + // The artifact fence overlaps the timestamp's uncertainty window. In + // particular, Darwin `ps lstart` cannot distinguish a worker that began + // just before the write from a replacement that began just after it in + // the same second. Neither freshness nor staleness is proven. + return "unknown"; + } + if (precisionMs > 0 ? startedAtMs + precisionMs <= catalogMtimeMs : startedAtMs <= catalogMtimeMs) { + stale = true; + } + } + return stale ? "stale" : "fresh"; +} + +function rememberCatalogStatusEvidence( + status: CodexAppServerCatalogStatus, + precisionMs: number, + verified?: readonly CodexAppServerProcess[], +): CodexAppServerCatalogStatus { + startTimePrecisionByCatalogStatus.set(status as object, precisionMs); + if (verified) verifiedProcessesByCatalogStatus.set(status as object, verified); + return status; +} + +/** Internal exact identities captured by the same scan that produced a status DTO. */ +export function verifiedCodexAppServerProcessesFromCatalogState( + status: CodexAppServerCatalogStatus, +): CodexAppServerProcess[] | null { + const verified = verifiedProcessesByCatalogStatus.get(status as object); + return verified ? verified.map(process => ({ ...process })) : null; +} + +/** Reclassify one already-enumerated worker snapshot against another artifact fence. */ +export function reclassifyCodexAppServerCatalogState( + observed: CodexAppServerCatalogStatus, + catalogMtimeMs: number | null, +): CodexAppServerCatalogStatus { + if (observed.processes.length === 0) { + return observed.state === "unknown" + ? { state: "unknown", processes: [], catalogMtimeMs: null } + : { state: "not_running", processes: [], catalogMtimeMs: null }; + } + if (catalogMtimeMs === null || observed.processes.some(process => process.startedAtMs === null)) { + return { state: "unknown", processes: observed.processes, catalogMtimeMs }; + } + const precisionMs = startTimePrecisionByCatalogStatus.get(observed as object) ?? 0; + const status: CodexAppServerCatalogStatus = { + state: classifyKnownProcessStarts(observed.processes, catalogMtimeMs, precisionMs), + processes: observed.processes, + catalogMtimeMs, + }; + return rememberCatalogStatusEvidence( + status, + precisionMs, + verifiedProcessesByCatalogStatus.get(observed as object), + ); +} + /** Resolve the catalog file Codex app-servers loaded at startup, for staleness checks. */ function defaultCatalogMtimeMs(): number | null { try { @@ -571,15 +753,14 @@ const CATALOG_STATE_TTL_MS = 5_000; * app-servers (#857): a server that started before the catalog changed keeps * an in-memory copy that disagrees with what ccx advertises. * - * Cost note: a cold call synchronously runs the platform listing plus ONE - * batched start-time query (hard bounds: ~5s+3s macOS, ~8s+5s Windows, - * microseconds on Linux); the 5s TTL then serves repeats. Typical cold cost - * is tens of milliseconds; fully-async background refresh is deliberately - * out of scope for this slice. + * Cost note: production listings capture start evidence in the same platform + * pass. A single batched fallback is used only when an injected/legacy + * snapshot omitted it. The 5s TTL then serves repeats; fully-async background + * refresh is deliberately out of scope for this slice. * * - not_running: no app-server process → nothing can disagree. - * - unknown: catalog unreadable, or any server's start time is unreadable — - * callers must treat this conservatively (suppress positive model claims). + * - unknown: catalog/start evidence is unreadable, or a coarse start-time + * window overlaps the artifact fence — callers must fail closed. * - stale: at least one server predates the catalog mtime. */ export function collectCodexAppServerCatalogState( @@ -587,7 +768,7 @@ export function collectCodexAppServerCatalogState( ): CodexAppServerCatalogStatus { const now = (io.now ?? Date.now)(); const fullyDefault = !io.listSnapshots && !io.readStartMs && !io.catalogMtimeMs - && !io.platform && !io.getuid && !io.now; + && io.startTimePrecisionMs === undefined && !io.platform && !io.getuid && !io.now; if (fullyDefault && catalogStateCache && now - catalogStateCache.atMs < CATALOG_STATE_TTL_MS) { return catalogStateCache.status; @@ -629,19 +810,38 @@ export function collectCodexAppServerCatalogState( : { state: "not_running", processes: [], catalogMtimeMs: null }; } const catalogMtimeMs = (io.catalogMtimeMs ?? defaultCatalogMtimeMs)(); + const precisionMs = normalizedStartTimePrecision( + io.startTimePrecisionMs + ?? (io.readStartMs ? 0 : platform === "darwin" || platform === "linux" ? 1_000 : 0), + ); const withStarts = io.readStartMs ? processes.map(proc => ({ pid: proc.pid, startedAtMs: io.readStartMs!(proc.pid) })) : (() => { - const batch = readProcessStartMsBatch(processes.map(proc => proc.pid), platform); - return processes.map(proc => ({ pid: proc.pid, startedAtMs: batch.get(proc.pid) ?? null })); + const captured = new Map( + snapshots + .filter(snapshot => snapshot.startedAtMs !== undefined) + .map(snapshot => [snapshot.pid, snapshot.startedAtMs!] as const), + ); + const missing = processes.map(proc => proc.pid).filter(pid => !captured.has(pid)); + const batch = readProcessStartMsBatch(missing, platform); + return processes.map(proc => ({ + pid: proc.pid, + startedAtMs: captured.get(proc.pid) ?? batch.get(proc.pid) ?? null, + })); })(); if (catalogMtimeMs === null || withStarts.some(proc => proc.startedAtMs === null)) { - return { state: "unknown", processes: withStarts, catalogMtimeMs }; + const status = { state: "unknown" as const, processes: withStarts, catalogMtimeMs }; + return rememberCatalogStatusEvidence(status, precisionMs, processes.map(process => ({ ...process }))); } - // `<=` is deliberate: coarse clocks (ps lstart is second-granularity) can - // report equal values when the catalog actually changed after startup. - const stale = withStarts.some(proc => proc.startedAtMs! <= catalogMtimeMs); - return { state: stale ? "stale" : "fresh", processes: withStarts, catalogMtimeMs }; + const status: CodexAppServerCatalogStatus = { + state: classifyKnownProcessStarts(withStarts, catalogMtimeMs, precisionMs), + processes: withStarts, + catalogMtimeMs, + }; + return rememberCatalogStatusEvidence(status, precisionMs, processes.map((process, index) => ({ + ...process, + startedAtMs: withStarts[index]!.startedAtMs!, + }))); }; const status = compute(); if (fullyDefault) { @@ -657,9 +857,12 @@ export function resetCodexAppServerCatalogStateCache(): void { export interface RestartCodexAppServersResult { requested: number[]; + /** PIDs that actually received SIGTERM after every identity/authorization fence. */ + signaled: number[]; stopped: number[]; surviving: number[]; failed: Array<{ pid: number; error: string }>; + authorizationRefused?: boolean; } /** Send SIGTERM to matched processes and wait briefly; never escalates to SIGKILL. */ @@ -671,37 +874,61 @@ export function restartCodexAppServers( const kill = io.kill ?? ((pid, signal) => { process.kill(pid, signal); }); const wait = io.waitExit ?? waitForExit; const now = io.now ?? Date.now; + const platform = io.platform ?? process.platform; + const getuid = io.getuid ?? (() => { + try { + return typeof process.getuid === "function" ? process.getuid() : undefined; + } catch { + return undefined; + } + }); + const readSnapshot = io.readSnapshot + ?? (io.listSnapshots + ? (pid: number) => io.listSnapshots!().find(snapshot => snapshot.pid === pid) ?? null + : (pid: number) => defaultReadSnapshot(pid, platform, getuid)); const requested = processes.map(process => process.pid); const stopped: number[] = []; const surviving: number[] = []; const failed: Array<{ pid: number; error: string }> = []; + let authorizationRefused = false; - // Re-resolve immediately before signaling so a recycled PID is never killed. - // Require the same pid+command-line identity as the original match — a new - // Codex-shaped process that reused the PID must not receive SIGTERM. - const liveByPid = new Map( - listCodexAppServerProcesses(io).map(process => [process.pid, process] as const), - ); const signaled: CodexAppServerProcess[] = []; - for (const proc of processes) { - const live = liveByPid.get(proc.pid); - if (!live || codexAppServerProcessIdentity(live) !== codexAppServerProcessIdentity(proc)) { - // Original target exited (or identity changed); do not signal a replacement. - if (!isAlive(proc.pid)) stopped.push(proc.pid); + for (let index = 0; index < processes.length; index += 1) { + const proc = processes[index]!; + // Caller consent/revision checks may perform durable reads and therefore + // take arbitrarily long. Authorize first, then take the final current-user + // PID/argv/birth snapshot immediately before SIGTERM. Reversing this order + // leaves a PID-reuse window while authorization is in flight. + if (io.authorizeSignal && !io.authorizeSignal()) { + authorizationRefused = true; + if (isAlive(proc.pid)) surviving.push(proc.pid); + // Consent/revision revocation is permanent for this operation. Do not + // inspect later targets in a way that might accidentally resume signals + // if a mutable authorizer flips back to true. + for (const remaining of processes.slice(index + 1)) { + if (isAlive(remaining.pid)) surviving.push(remaining.pid); + } + break; + } + let live: ProcessSnapshot | null = null; + try { + live = readSnapshot(proc.pid); + } catch { + // Unreadable ownership/argv is never authorization to signal. + } + if (!live + || !isCodexAppServerCommandLine(live.commandLine) + || codexAppServerProcessIdentity(live) !== codexAppServerProcessIdentity(proc)) { + // Original target exited (or identity/ownership changed); do not signal a replacement. continue; } - if (proc.startedAtMs !== undefined) { - const currentStartedAtMs = (io.readStartMs ?? (pid => readProcessStartMs(pid, io.platform ?? process.platform)))( - proc.pid, - ); - if (currentStartedAtMs !== proc.startedAtMs) { - // A same-command process can recycle a PID between classification and - // signal. The app-owned apply action supplies a birth time and fails - // closed when it changed or became unreadable. - if (!isAlive(proc.pid)) stopped.push(proc.pid); - continue; - } + const currentStartedAtMs = live.startedAtMs + ?? (io.readStartMs ?? (pid => readProcessStartMs(pid, platform)))(proc.pid); + if (proc.startedAtMs === undefined || currentStartedAtMs !== proc.startedAtMs) { + // Birth evidence is mandatory. An identical argv is not enough to + // distinguish a recycled PID from the classified stale worker. + continue; } try { kill(proc.pid, "SIGTERM"); @@ -713,8 +940,6 @@ export function restartCodexAppServers( error: error instanceof Error ? error.message : String(error), }); surviving.push(proc.pid); - } else { - stopped.push(proc.pid); } } } @@ -727,7 +952,14 @@ export function restartCodexAppServers( else surviving.push(proc.pid); } - return { requested, stopped, surviving, failed }; + return { + requested, + signaled: signaled.map(process => process.pid), + stopped: [...new Set(stopped)], + surviving: [...new Set(surviving)], + failed, + ...(authorizationRefused ? { authorizationRefused: true } : {}), + }; } export interface AfterCatalogWriteAppServerOptions { @@ -779,11 +1011,11 @@ export function afterCatalogWriteHandleAppServers( /** * Startup-safe counterpart to {@link afterCatalogWriteHandleAppServers} (#1046). * - * Service startup rewrites the catalog and the models cache, but an app-server - * that booted earlier keeps an in-memory model list — Codex builds a static - * manager from the catalog once and never rereads the file — so the picker shows - * a roster that no longer exists on disk. Every check a user runs reads the file; - * the picker renders memory. + * Canonical startup convergence may rewrite the catalog and models cache, but + * an app-server that booted earlier keeps an in-memory model list — Codex builds + * a static manager from the catalog once and never rereads the file — so the + * picker shows a roster that no longer exists on disk. Every check a user runs + * reads the file; the picker renders memory. * * Two things this deliberately does NOT do, both of which the `--restart-codex` * path does: diff --git a/src/codex/boot-fence.ts b/src/codex/boot-fence.ts new file mode 100644 index 0000000000..61516f526a --- /dev/null +++ b/src/codex/boot-fence.ts @@ -0,0 +1,138 @@ +import { createHash } from "node:crypto"; +import { readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { atomicWriteFile } from "../config"; +import { + activeCodexConfigPath, + getAgentsEnabled, + getAgentsMaxDepth, + getLogicalMaxThreads, + getSubagentDeveloperInstructions, + isMultiAgentV2Enabled, +} from "./features"; +import { resolveCodexHomeDir } from "./home"; + +const MARKER_FILENAME = "codexcommander-activation-fence.json"; + +interface CodexBootFenceMarker { + schemaVersion: 1; + bootHash: string; + changedAtMs: number; +} + +function fileMtimeMs(path: string): number | null { + try { + const value = statSync(path).mtimeMs; + return Number.isFinite(value) ? value : null; + } catch { + return null; + } +} + +export function codexBootFenceMarkerPath(): string { + return join(resolveCodexHomeDir(), MARKER_FILENAME); +} + +interface CodexBootConfigProjection { + hash: string; + /** True when the config carries CodexCommander's injected routing/catalog keys. */ + managed: boolean; +} + +function codexBootConfigProjection(configPath: string): CodexBootConfigProjection | null { + let parsed: Record; + try { + const content = readFileSync(configPath, "utf8").replace(/^\uFEFF/, ""); + parsed = Bun.TOML.parse(content) as Record; + } catch { + return null; + } + const rootString = (key: string): string | null => typeof parsed[key] === "string" ? parsed[key] as string : null; + try { + const projection = { + agentsEnabled: getAgentsEnabled(configPath), + agentsMaxDepth: getAgentsMaxDepth(configPath), + maxConcurrentThreadsPerSession: getLogicalMaxThreads(configPath), + modelCatalogJson: rootString("model_catalog_json"), + modelProvider: rootString("model_provider"), + multiAgentV2Enabled: isMultiAgentV2Enabled(configPath), + openaiBaseUrl: rootString("openai_base_url"), + subagentDeveloperInstructions: getSubagentDeveloperInstructions(configPath), + }; + return { + hash: createHash("sha256").update(JSON.stringify(projection)).digest("hex"), + managed: projection.modelCatalogJson !== null || projection.openaiBaseUrl !== null, + }; + } catch { + return null; + } +} + +/** Hash only values Codex consumes while booting a worker. */ +export function codexBootConfigHash(configPath = activeCodexConfigPath()): string | null { + return codexBootConfigProjection(configPath)?.hash ?? null; +} + +function readMarker(path: string): CodexBootFenceMarker | null { + try { + const value = JSON.parse(readFileSync(path, "utf8")) as Partial; + if (value.schemaVersion !== 1 || typeof value.bootHash !== "string" + || typeof value.changedAtMs !== "number" || !Number.isFinite(value.changedAtMs)) return null; + return value as CodexBootFenceMarker; + } catch { + return null; + } +} + +function persistMarker(marker: CodexBootFenceMarker): void { + try { + atomicWriteFile(codexBootFenceMarkerPath(), `${JSON.stringify(marker, null, 2)}\n`); + } catch { + // Best effort: observing the same drift again remains conservative and safe. + } +} + +export function observeCodexBootFence(): { mtimeMs: number | null } { + const configPath = activeCodexConfigPath(); + const projection = codexBootConfigProjection(configPath); + if (projection === null) return { mtimeMs: fileMtimeMs(configPath) }; + + const markerPath = codexBootFenceMarkerPath(); + const marker = readMarker(markerPath); + if (!marker) { + // A never-managed (uninjected or foreign) Codex home is only observed, never + // written: an integration-off home must stay byte-identical. Raw mtime keeps + // the pre-injection fence behavior. Once a managed home seeds the marker, the + // content-scoped fence stays in effect even if injection is later removed. + if (!projection.managed) return { mtimeMs: fileMtimeMs(configPath) }; + const seeded: CodexBootFenceMarker = { + schemaVersion: 1, + bootHash: projection.hash, + changedAtMs: fileMtimeMs(configPath) ?? Date.now(), + }; + persistMarker(seeded); + return { mtimeMs: seeded.changedAtMs }; + } + if (marker.bootHash === projection.hash) return { mtimeMs: marker.changedAtMs }; + + // Detection-on-read intentionally catches external edits such as `codex features`. + const drifted: CodexBootFenceMarker = { + schemaVersion: 1, + bootHash: projection.hash, + changedAtMs: Math.max(Date.now(), marker.changedAtMs), + }; + persistMarker(drifted); + return { mtimeMs: drifted.changedAtMs }; +} + +export function recordCodexBootFenceApplied(): void { + const bootHash = codexBootConfigHash(); + if (bootHash === null) return; + const previous = readMarker(codexBootFenceMarkerPath()); + persistMarker({ + schemaVersion: 1, + bootHash, + changedAtMs: Math.max(Date.now(), previous?.changedAtMs ?? 0), + }); +} diff --git a/src/codex/catalog-activation.ts b/src/codex/catalog-activation.ts new file mode 100644 index 0000000000..d8b8f843e7 --- /dev/null +++ b/src/codex/catalog-activation.ts @@ -0,0 +1,436 @@ +import { createHmac, randomBytes } from "node:crypto"; +import { statSync } from "node:fs"; + +import { loadConfig } from "../config"; +import type { CodexCommanderConfig } from "../types"; +import { + configuredSubagentModelMatchesEntry, + effectiveSubagentRoster, + readCatalog, + readCodexCatalogPath, + type EffectiveSubagentRoster, + type SubagentRosterExclusion, +} from "./catalog"; +import { + collectCodexAppServerCatalogState, + reclassifyCodexAppServerCatalogState, + type CodexAppServerCatalogStatus, +} from "./app-server-processes"; +import type { CatalogDisposition } from "./convergence-types"; +import { + codexCatalogConvergenceReceiptMatchesCurrent, + readCodexCatalogConvergenceReceipt, +} from "./convergence"; +import { + captureCatalogConfigAuthority, + CatalogAdmissionStaleConfigError, + type CatalogConfigAuthoritySnapshot, +} from "./catalog-admission"; +import { + activeCodexConfigPath, + getAgentsEnabled, + getAgentsMaxDepth, + getLogicalMaxThreads, + getSubagentDeveloperInstructions, + isMultiAgentV2Enabled, +} from "./features"; +import { getCodexRoutingKind, type CodexRoutingKind } from "./inject"; +import { observeCodexBootFence } from "./boot-fence"; + +export type CodexCatalogProtocol = "v1" | "default" | "v2"; +export type CodexCatalogArtifactProof = "current" | "drifted" | "unproven" | "not-required"; + +export interface CodexCatalogRosterProjection { + advertised: string[]; + excluded: SubagentRosterExclusion[]; +} + +export interface CodexCatalogActivationState { + schemaVersion: 1; + desired: { + revision: string; + chosen: string[]; + protocol: CodexCatalogProtocol; + }; + catalog: { + status: "current" | "pending" | "degraded" | "unknown"; + advertised: string[]; + excluded: SubagentRosterExclusion[]; + projections: { + v1: CodexCatalogRosterProjection; + v2: CodexCatalogRosterProjection; + }; + }; + routing: { + status: "current" | "not_injected" | "external" | "unknown" | "not_required"; + kind: CodexRoutingKind; + }; + workers: { + status: "current" | "reload_required" | "not_running" | "unknown"; + runningCount: number; + staleCount: number; + evidence: "process-start-vs-activation-fence" | "no-processes" | "unavailable"; + }; + apply: { + required: boolean; + allowed: boolean; + reason: + | "reload-required" + | "routing-not-injected" + | "external-routing" + | "routing-unknown" + | "integration-disabled" + | "already-current" + | "no-workers" + | "worker-state-unknown" + | "catalog-not-ready"; + }; +} + +function routingState( + config: Pick, + kind: CodexRoutingKind, +): CodexCatalogActivationState["routing"] { + if (config.clientIntegrations?.codex === false) return { status: "not_required", kind }; + if (kind === "codexcommander-local") return { status: "current", kind }; + if (kind === "native") return { status: "not_injected", kind }; + if (kind === "custom-local" || kind === "custom-remote") return { status: "external", kind }; + return { status: "unknown", kind }; +} + +function canonicalValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalValue); + if (value !== null && typeof value === "object") { + const entries = Object.entries(value as Record) + .filter(([, child]) => child !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, canonicalValue(child)] as const); + return Object.fromEntries(entries); + } + return value; +} + +// Process-local key: revisions are equality fences, not durable identifiers. +// Keying them prevents the management response from becoming an offline oracle +// over provider credentials that legitimately influence the generated catalog. +const desiredRevisionKey = randomBytes(32); + +/** Opaque optimistic-concurrency fence. Its input never crosses the API boundary. */ +export function codexCatalogDesiredRevision( + config: Readonly, + authority?: CatalogConfigAuthoritySnapshot, +): string { + let bootInputs: unknown = null; + try { + bootInputs = { + multiAgentV2Enabled: isMultiAgentV2Enabled(), + maxConcurrentThreadsPerSession: getLogicalMaxThreads(), + agentsEnabled: getAgentsEnabled(), + agentsMaxDepth: getAgentsMaxDepth(), + subagentDeveloperInstructions: getSubagentDeveloperInstructions(), + }; + } catch { + // The persisted CodexCommander config still provides a stable fence when + // native boot settings are temporarily unreadable. + } + const bytes = JSON.stringify(canonicalValue({ + config, + bootInputs, + configAuthority: authority + ? { + generation: authority.generation.value, + semanticIdentity: authority.semanticIdentity, + contentIdentity: authority.contentIdentity, + } + : null, + })); + return `v1:${createHmac("sha256", desiredRevisionKey).update(bytes).digest("hex")}`; +} + +export interface CodexCatalogDesiredSnapshot { + readonly config: CodexCommanderConfig; + readonly authority: CatalogConfigAuthoritySnapshot; + readonly revision: string; +} + +/** + * Read the desired config and bind it to its monotonic generation. A concurrent + * Save between the file read and authority capture is retried, never paired + * with the newer generation. The generation keeps A -> B -> A from passing an + * Apply fence merely because the semantic bytes returned to A. + */ +export function captureCodexCatalogDesiredSnapshot( + readConfig: () => CodexCommanderConfig = loadConfig, +): CodexCatalogDesiredSnapshot { + for (let attempt = 0; attempt < 3; attempt += 1) { + const config = readConfig(); + try { + const authority = captureCatalogConfigAuthority(config); + return { + config, + authority, + revision: codexCatalogDesiredRevision(config, authority), + }; + } catch (error) { + if (error instanceof CatalogAdmissionStaleConfigError) continue; + throw error; + } + } + throw new CatalogAdmissionStaleConfigError(); +} + +function projection(roster: EffectiveSubagentRoster): CodexCatalogRosterProjection { + return { + advertised: roster.advertised.map(model => model.model), + excluded: [...roster.excluded], + }; +} + +function defaultProjection( + chosen: readonly string[], + v1: CodexCatalogRosterProjection, + v2: CodexCatalogRosterProjection, +): CodexCatalogRosterProjection { + const advertisedSet = new Set([...v1.advertised, ...v2.advertised]); + const advertised = [...advertisedSet]; + const excluded = chosen.flatMap(configured => { + if (advertisedSet.has(configured)) return []; + const v2Reason = v2.excluded.find(item => item.configured === configured); + const v1Reason = v1.excluded.find(item => item.configured === configured); + return v2Reason ? [v2Reason] : v1Reason ? [v1Reason] : []; + }); + return { advertised, excluded }; +} + +function catalogStatus( + catalogReadable: boolean, + active: CodexCatalogRosterProjection, + orderMatches: boolean, + disposition?: CatalogDisposition, + artifactProof: CodexCatalogArtifactProof = "not-required", +): CodexCatalogActivationState["catalog"]["status"] { + if (disposition?.status === "failed") return "pending"; + if (disposition?.status === "skipped") { + if (disposition.reason !== "not-requested") { + return disposition.reason === "catalog-unavailable" ? "unknown" : "pending"; + } + } + if (artifactProof === "unproven") return "unknown"; + if (artifactProof === "drifted") return "pending"; + if (!catalogReadable) return "unknown"; + if (active.excluded.some(item => item.reason === "missing_catalog_entry")) return "pending"; + if (!orderMatches) return "pending"; + // Provider/fallback degradation belongs to the mutation's catalogRefresh + // receipt. It is not persisted, so folding it into activation would make a + // mutation response say `degraded` while an immediate GET over identical + // durable evidence says `current`. Deterministic, non-missing exclusions are + // part of the current roster projection; missing rows remain pending above. + return "current"; +} + +/** Process-local proof of the authoritative catalog; Codex owns its volatile cache. */ +export function inspectCodexCatalogArtifactProof( + config: Readonly, +): CodexCatalogArtifactProof { + if (!readCodexCatalogConvergenceReceipt()) return "unproven"; + return codexCatalogConvergenceReceiptMatchesCurrent({ + config, + catalogPath: readCodexCatalogPath(), + }) ? "current" : "drifted"; +} + +function configuredOrderMatchesCatalog( + chosen: readonly string[], + active: CodexCatalogRosterProjection, + entries: readonly Parameters[1][], +): boolean { + const excluded = new Set(active.excluded.map(item => item.configured)); + const expected = chosen.filter(configured => !excluded.has(configured)); + const actual: string[] = []; + for (const slug of active.advertised) { + const entry = entries.find(candidate => candidate.slug === slug); + if (!entry) continue; + const configured = chosen.find(candidate => configuredSubagentModelMatchesEntry(candidate, entry)); + if (configured !== undefined && !actual.includes(configured)) actual.push(configured); + } + return expected.length === actual.length + && expected.every((configured, index) => configured === actual[index]); +} + +function workerState(status: CodexAppServerCatalogStatus): CodexCatalogActivationState["workers"] { + const staleCount = status.state === "stale" && status.catalogMtimeMs !== null + ? status.processes.filter(process => process.startedAtMs !== null + && process.startedAtMs <= status.catalogMtimeMs!).length + : 0; + if (status.state === "fresh") { + return { + status: "current", + runningCount: status.processes.length, + staleCount: 0, + evidence: "process-start-vs-activation-fence", + }; + } + if (status.state === "stale") { + return { + status: "reload_required", + runningCount: status.processes.length, + staleCount, + evidence: "process-start-vs-activation-fence", + }; + } + if (status.state === "not_running") { + return { status: "not_running", runningCount: 0, staleCount: 0, evidence: "no-processes" }; + } + return { + status: "unknown", + runningCount: status.processes.length, + staleCount: 0, + evidence: "unavailable", + }; +} + +function fileMtimeMs(path: string): number | null { + try { + return statSync(path).mtimeMs; + } catch { + return null; + } +} + +/** Catalog rows and native Codex boot settings share one explicit Apply boundary. */ +function activationFenceObservation(): { + identity: string; + mtimeMs: number | null; +} { + const catalogPath = readCodexCatalogPath(); + const configPath = activeCodexConfigPath(); + const bootFenceMtimeMs = observeCodexBootFence().mtimeMs; + const mtimes = [fileMtimeMs(catalogPath), bootFenceMtimeMs] + .filter((value): value is number => value !== null && Number.isFinite(value)); + return { + identity: `${catalogPath}\0${configPath}`, + mtimeMs: mtimes.length > 0 ? Math.max(...mtimes) : null, + }; +} + +export function codexCatalogActivationFenceMtimeMs(): number | null { + return activationFenceObservation().mtimeMs; +} + +let activationWorkerStateCache: { + atMs: number; + fenceIdentity: string; + fenceMtimeMs: number | null; + status: CodexAppServerCatalogStatus; +} | null = null; +const ACTIVATION_WORKER_STATE_TTL_MS = 5_000; + +export function collectCodexCatalogActivationWorkerState(): CodexAppServerCatalogStatus { + const atMs = Date.now(); + const fence = activationFenceObservation(); + if (activationWorkerStateCache + && activationWorkerStateCache.fenceIdentity === fence.identity + && activationWorkerStateCache.fenceMtimeMs === fence.mtimeMs + && atMs - activationWorkerStateCache.atMs < ACTIVATION_WORKER_STATE_TTL_MS) { + return activationWorkerStateCache.status; + } + const status = collectCodexAppServerCatalogState({ + catalogMtimeMs: () => fence.mtimeMs, + }); + activationWorkerStateCache = { + atMs, + fenceIdentity: fence.identity, + fenceMtimeMs: fence.mtimeMs, + status, + }; + return status; +} + +/** Invalidate the combined catalog/native-config process observation after a write or Apply. */ +export function resetCodexCatalogActivationWorkerStateCache(): void { + activationWorkerStateCache = null; +} + +/** Preserve the legacy catalog-only status without paying for a second process scan. */ +export function catalogOnlyWorkerStateFromActivation( + observed: CodexAppServerCatalogStatus, +): CodexAppServerCatalogStatus { + return reclassifyCodexAppServerCatalogState( + observed, + fileMtimeMs(readCodexCatalogPath()), + ); +} + +export function inspectCodexCatalogActivation( + config: Readonly, + workers: CodexAppServerCatalogStatus, + disposition?: CatalogDisposition, + authority?: CatalogConfigAuthoritySnapshot, + artifactProof: CodexCatalogArtifactProof = authority + ? inspectCodexCatalogArtifactProof(config) + : "not-required", + routingKind: CodexRoutingKind = getCodexRoutingKind(), +): CodexCatalogActivationState { + const chosen = [...(config.subagentModels ?? [])]; + const protocol = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" + ? config.multiAgentMode + : "default"; + const catalog = readCatalog(readCodexCatalogPath()); + const entries = catalog?.models ?? []; + const v1 = projection(effectiveSubagentRoster(chosen, "v1", entries)); + const v2 = projection(effectiveSubagentRoster(chosen, "v2", entries)); + const active = protocol === "v1" ? v1 : protocol === "v2" ? v2 : defaultProjection(chosen, v1, v2); + const catalogState = catalogStatus( + catalog !== null, + active, + configuredOrderMatchesCatalog(chosen, active, entries), + disposition, + artifactProof, + ); + const routing = routingState(config, routingKind); + const worker = workerState(workers); + const catalogReady = catalogState === "current" || catalogState === "degraded"; + const routingCanApply = routing.status === "current" || routing.status === "not_injected"; + // Apply owns the repairing sync before it ever considers a worker signal. + // A pending/unproven catalog is therefore itself an Apply reason, not a gate + // that strands the only action able to establish the artifact receipt. + const required = !catalogReady + || routing.status === "not_injected" + || worker.status === "reload_required"; + const allowed = required && routingCanApply && worker.status !== "unknown"; + const reason = routing.status === "not_required" + ? "integration-disabled" + : routing.status === "external" + ? "external-routing" + : routing.status === "unknown" + ? "routing-unknown" + : !catalogReady + ? "catalog-not-ready" + : worker.status === "unknown" + ? "worker-state-unknown" + : routing.status === "not_injected" + ? "routing-not-injected" + : worker.status === "not_running" + ? "no-workers" + : worker.status === "current" + ? "already-current" + : "reload-required"; + + return { + schemaVersion: 1, + desired: { + revision: codexCatalogDesiredRevision(config, authority), + chosen, + protocol, + }, + catalog: { + status: catalogState, + advertised: active.advertised, + excluded: active.excluded, + projections: { v1, v2 }, + }, + routing, + workers: worker, + apply: { required, allowed, reason }, + }; +} diff --git a/src/codex/catalog-admission.ts b/src/codex/catalog-admission.ts index b3922a1380..c7965cfb6e 100644 --- a/src/codex/catalog-admission.ts +++ b/src/codex/catalog-admission.ts @@ -10,7 +10,13 @@ import { createHmac, randomBytes } from "node:crypto"; import { join, resolve } from "node:path"; -import { observeConfigGeneration } from "../config"; +import { + observeConfigGeneration, + readConfigAdmissionSnapshot, + readConfigGenerationInCurrentMutationTransaction, + validateConfigCandidate, + withConfigMutationLockSync, +} from "../config"; import type { CodexCommanderConfig } from "../types"; import type { CatalogAdmissionSnapshot, @@ -55,6 +61,22 @@ const CONFIG_IDENTITY_KEY = randomBytes(32); const configReferenceIdentities = new WeakMap(); let nextConfigReferenceIdentity = 0; +export class CatalogAdmissionStaleConfigError extends Error { + constructor() { + super("Cannot capture Codex catalog admission: the supplied config snapshot is stale."); + this.name = "CatalogAdmissionStaleConfigError"; + } +} + +export interface CatalogConfigAuthoritySnapshot { + /** Monotonic cooperating-write fence; include this in optimistic revisions. */ + readonly generation: ConfigGeneration; + /** Process-keyed semantic config identity, safe to compare or hash but not decode. */ + readonly semanticIdentity: string; + /** Process-keyed identity of the exact persisted bytes read with this authority. */ + readonly contentIdentity: string; +} + function encodeLengthPrefixed(value: string): string { return `${Buffer.byteLength(value, "utf8")}:${value}`; } @@ -114,6 +136,7 @@ function keyedConfigIdentity(domain: string, payload: string): string { function catalogConfigIdentity( config: Readonly, generation: ConfigGeneration, + authority: CatalogConfigAuthoritySnapshot, ): CatalogAdmissionSnapshot["configIdentity"] { let referenceIdentity = configReferenceIdentities.get(config); if (!referenceIdentity) { @@ -124,7 +147,50 @@ function catalogConfigIdentity( return Object.freeze({ referenceIdentity, generation: Object.freeze({ ...generation }), - snapshotIdentity: keyedConfigIdentity("catalog-config-snapshot-v1", canonicalConfigEncoding(config)), + snapshotIdentity: authority.semanticIdentity, + contentIdentity: authority.contentIdentity, + }); +} + +/** + * Atomically bind a supplied decoded config to the persisted bytes and their + * monotonic generation. Direct/manual semantic byte drift is refused even when + * a non-cooperating editor did not bump the generation. + */ +export function captureCatalogConfigAuthority( + config: Readonly, +): CatalogConfigAuthoritySnapshot { + // Unlike full catalog admission, status/revision observation is allowed to + // initialize generation zero for a valid pre-coordinator installation. + return withConfigMutationLockSync(() => { + const persisted = readConfigAdmissionSnapshot(); + const normalized = validateConfigCandidate(config); + const persistedFile = persisted.kind === "read" + && persisted.diagnostics.source === "file" + && persisted.diagnostics.error === null; + const persistedAbsent = persisted.kind === "unreadable" + && persisted.diagnostics.source === "default" + && persisted.diagnostics.error === null; + if ( + (!persistedFile && !persistedAbsent) + || !normalized.ok + || canonicalConfigEncoding(persisted.diagnostics.config) + !== canonicalConfigEncoding(normalized.config) + ) { + throw new CatalogAdmissionStaleConfigError(); + } + const normalizedEncoding = canonicalConfigEncoding(normalized.config); + return Object.freeze({ + generation: Object.freeze({ ...readConfigGenerationInCurrentMutationTransaction() }), + semanticIdentity: keyedConfigIdentity( + "catalog-config-snapshot-v1", + normalizedEncoding, + ), + contentIdentity: keyedConfigIdentity( + "catalog-config-content-v1", + persistedFile ? persisted.contentSha256 : "absent", + ), + }); }); } @@ -136,8 +202,8 @@ function catalogConfigIdentity( export function captureCatalogAdmissionSnapshot( config: Readonly, ): CatalogAdmissionSnapshot { - const generation = observeConfigGeneration(); - if (generation.kind === "absent") { + const observedGeneration = observeConfigGeneration(); + if (observedGeneration.kind === "absent") { /* * Absence is refused HERE and admitted elsewhere, and the difference is the * lock. Codex write admission may carry an absent generation because it goes @@ -155,10 +221,13 @@ export function captureCatalogAdmissionSnapshot( */ throw new Error("Cannot capture Codex catalog admission: config generation is database (absent)."); } - if (generation.kind !== "ready") { - throw new Error(`Cannot capture Codex catalog admission: config generation is ${generation.reason}.`); + if (observedGeneration.kind !== "ready") { + throw new Error(`Cannot capture Codex catalog admission: config generation is ${observedGeneration.reason}.`); } + const authority = captureCatalogConfigAuthority(config); + const generation = authority.generation; + const evidenceSession = createCatalogGatherEvidenceSession(); const homeSelection = captureAndSealCatalogHomeSelection(evidenceSession); const configPath = join(homeSelection.canonicalCodexHome, "config.toml"); @@ -184,8 +253,8 @@ export function captureCatalogAdmissionSnapshot( return { config, - generation: generation.generation, - configIdentity: catalogConfigIdentity(config, generation.generation), + generation, + configIdentity: catalogConfigIdentity(config, generation, authority), targets, sourceEvidence, }; diff --git a/src/codex/catalog-apply.ts b/src/codex/catalog-apply.ts index d347db777b..4624b956ef 100644 --- a/src/codex/catalog-apply.ts +++ b/src/codex/catalog-apply.ts @@ -4,21 +4,25 @@ import { listCodexAppServerProcesses, resetCodexAppServerCatalogStateCache, restartCodexAppServers, + verifiedCodexAppServerProcessesFromCatalogState, type CodexAppServerCatalogStatus, - type CodexAppServerProcess, type RestartCodexAppServersResult, } from "./app-server-processes"; +import { + captureCodexCatalogDesiredSnapshot, + collectCodexCatalogActivationWorkerState, + inspectCodexCatalogArtifactProof, + resetCodexCatalogActivationWorkerStateCache, + type CodexCatalogArtifactProof, + type CodexCatalogDesiredSnapshot, +} from "./catalog-activation"; +import { getCodexRoutingKind, type CodexRoutingKind } from "./inject"; import { syncModelsToCodex, type CodexSyncResult } from "./sync"; +import { recordCodexBootFenceApplied } from "./boot-fence"; export const APPLY_CODEX_CATALOG_ACTION = "applyCodexCatalog" as const; -/** - * Fixed, app-visible lifecycle frame for applying Codex's model catalog. - * - * The worker fields are counts deliberately. Process ids, command lines, - * filesystem paths, and platform error strings stay inside this module and - * never cross the menu-app helper boundary. - */ +/** Fixed, count-only lifecycle frame consumed by the native companion. */ export interface ApplyCodexCatalogLifecycleResult { schemaVersion: 1; action: typeof APPLY_CODEX_CATALOG_ACTION; @@ -36,51 +40,46 @@ export interface ApplyCodexCatalogLifecycleResult { survivingWorkerCount: number; } -export interface ApplyCodexCatalogDeps { - findLiveProxy: typeof findLiveProxy; - syncModelsToCodex: typeof syncModelsToCodex; +export type ApplyCodexCatalogWorkersOutcome = + | "applied" + | "already_current" + | "no_workers" + | "partial" + | "superseded" + | "blocked"; + +export interface ApplyCodexCatalogWorkersResult { + outcome: ApplyCodexCatalogWorkersOutcome; + staleWorkerCount: number; + stoppedWorkerCount: number; + survivingWorkerCount: number; +} + +export interface ApplyCodexCatalogWorkersDeps { resetCatalogStateCache: typeof resetCodexAppServerCatalogStateCache; collectCatalogState: typeof collectCodexAppServerCatalogState; listCodexWorkers: typeof listCodexAppServerProcesses; restartCodexWorkers: typeof restartCodexAppServers; } -const defaultDeps: ApplyCodexCatalogDeps = { - findLiveProxy, - syncModelsToCodex, - resetCatalogStateCache: resetCodexAppServerCatalogStateCache, - collectCatalogState: collectCodexAppServerCatalogState, +const defaultWorkerDeps: ApplyCodexCatalogWorkersDeps = { + resetCatalogStateCache: resetCatalogActivationStateCaches, + collectCatalogState: collectCodexCatalogActivationWorkerState, listCodexWorkers: listCodexAppServerProcesses, restartCodexWorkers: restartCodexAppServers, }; -const emptyRestartResult = (): RestartCodexAppServersResult => ({ - requested: [], - stopped: [], - surviving: [], - failed: [], -}); - -function intentionalSyncSkip(result: CodexSyncResult): boolean { - return result.status === "skipped" - && result.ok - && (result.skippedReason === "desired_disabled" || result.skippedReason === "external_provider"); -} - -function syncResultFailed(result: CodexSyncResult | undefined): boolean { - if (!result) return true; - if (intentionalSyncSkip(result)) return false; - return !result.ok - || result.status === "refused" - || (result.warning !== undefined && result.warning.length > 0) - || (result.catalogQuality === "native-only" && result.catalogWritten !== true); +function resetCatalogActivationStateCaches(): void { + resetCodexAppServerCatalogStateCache(); + resetCodexCatalogActivationWorkerStateCache(); } function unknownCatalogState(): CodexAppServerCatalogStatus { return { state: "unknown", processes: [], catalogMtimeMs: null }; } -function collectAfterInvalidation(deps: ApplyCodexCatalogDeps): CodexAppServerCatalogStatus { +function safeCatalogState(deps: Pick): CodexAppServerCatalogStatus { try { deps.resetCatalogStateCache(); return deps.collectCatalogState(); @@ -89,9 +88,7 @@ function collectAfterInvalidation(deps: ApplyCodexCatalogDeps): CodexAppServerCa } } -function staleProcessStarts( - status: CodexAppServerCatalogStatus, -): Map { +function staleProcessStarts(status: CodexAppServerCatalogStatus): Map { const starts = new Map(); if (status.state !== "stale" || status.catalogMtimeMs === null) return starts; for (const process of status.processes) { @@ -108,62 +105,367 @@ function staleProcessStarts( return starts; } -function currentStaleTargets( - status: CodexAppServerCatalogStatus, - deps: ApplyCodexCatalogDeps, -): CodexAppServerProcess[] { - const staleStarts = staleProcessStarts(status); - if (staleStarts.size === 0) return []; - let current: CodexAppServerProcess[]; - try { - current = deps.listCodexWorkers(); - } catch { - return []; +function uniqueCount(values: readonly number[]): number { + return new Set(values).size; +} + +/** + * Exact-process interruption primitive. `authorizeSignal` is evaluated once + * before entering the process helper and again by that helper immediately + * before every eligible SIGTERM, after PID/argv/birth-time revalidation. + */ +export async function applyCodexCatalogWorkers( + authorizeSignal: () => boolean, + deps: ApplyCodexCatalogWorkersDeps = defaultWorkerDeps, + observedBefore?: CodexAppServerCatalogStatus, +): Promise { + const before = observedBefore ?? safeCatalogState(deps); + if (before.state === "unknown") { + return { outcome: "blocked", staleWorkerCount: 0, stoppedWorkerCount: 0, survivingWorkerCount: 0 }; + } + if (before.state === "not_running") { + return { outcome: "no_workers", staleWorkerCount: 0, stoppedWorkerCount: 0, survivingWorkerCount: 0 }; + } + if (before.state === "fresh") { + return { outcome: "already_current", staleWorkerCount: 0, stoppedWorkerCount: 0, survivingWorkerCount: 0 }; + } + + const staleStarts = staleProcessStarts(before); + const staleWorkerCount = staleStarts.size; + let listed = verifiedCodexAppServerProcessesFromCatalogState(before); + if (listed === null) { + try { + listed = deps.listCodexWorkers(); + } catch { + return { outcome: "blocked", staleWorkerCount, stoppedWorkerCount: 0, survivingWorkerCount: staleWorkerCount }; + } } - return current.flatMap(process => { + const targets = listed.flatMap(process => { const startedAtMs = staleStarts.get(process.pid); return startedAtMs === undefined ? [] : [{ ...process, startedAtMs }]; }); -} + if (targets.length === 0) { + const current = safeCatalogState(deps); + const outcome = current.state === "not_running" + ? "no_workers" + : current.state === "fresh" + ? "already_current" + : "blocked"; + return { + outcome, + staleWorkerCount, + stoppedWorkerCount: 0, + survivingWorkerCount: outcome === "blocked" ? staleWorkerCount : 0, + }; + } + if (!authorizeSignal()) { + return { + outcome: "superseded", + staleWorkerCount, + stoppedWorkerCount: 0, + survivingWorkerCount: staleWorkerCount, + }; + } -function uniqueCount(values: readonly number[]): number { - return new Set(values).size; + let restart: RestartCodexAppServersResult = { + requested: [], + signaled: [], + stopped: [], + surviving: [], + failed: [], + }; + try { + restart = deps.restartCodexWorkers(targets, { authorizeSignal }); + } catch { + // The final process observation is authoritative; platform detail is private. + } + const after = safeCatalogState(deps); + const signaled = new Set(restart.signaled); + const stoppedWorkerCount = new Set( + restart.stopped.filter(pid => signaled.has(pid)), + ).size; + const survivingWorkerCount = after.state === "stale" + ? staleProcessStarts(after).size + : after.state === "unknown" + ? Math.max(uniqueCount(restart.surviving), staleWorkerCount - stoppedWorkerCount) + : 0; + const supersededBeforeAnySignal = restart.authorizationRefused === true + && restart.signaled.length === 0; + return { + outcome: supersededBeforeAnySignal + ? "superseded" + : restart.authorizationRefused === true || after.state === "stale" || after.state === "unknown" + ? "partial" + : "applied", + staleWorkerCount, + stoppedWorkerCount, + survivingWorkerCount, + }; } -function fixedMessage(options: { - syncFailed: boolean; - syncSkipped: boolean; - stateUnknown: boolean; - restartIncomplete: boolean; - staleDetected: boolean; +export type CodexCatalogApplyBlockReason = + | "desired-superseded" + | "sync-failed" + | "sync-warning" + | "integration-disabled" + | "external-routing" + | "artifact-not-current" + | "routing-not-owned" + | "authorization-changed" + | "worker-state-unknown" + | "workers-survived"; + +export interface CodexCatalogApplyResult extends ApplyCodexCatalogWorkersResult { catalogUpdated: boolean; -}): string { - if (options.syncFailed) return "Agent catalog update did not complete."; - if (options.syncSkipped) return "Agent catalog does not need to be applied."; - if (options.stateUnknown) { - return "Agent catalog was synchronized, but Codex worker state could not be verified."; + workerState: CodexAppServerCatalogStatus["state"]; + blockReason?: CodexCatalogApplyBlockReason; +} + +/** The one warning/degraded policy used by HTTP, CLI, and companion Apply. */ +export function codexCatalogSyncCanSignal(result: CodexSyncResult): boolean { + return result.status === "applied" + && result.ok === true + && result.catalogExists === true + && (result.warning === undefined || result.warning.length === 0); +} + +function syncBlockReason(result: CodexSyncResult): CodexCatalogApplyBlockReason { + if (result.status === "skipped" && result.skippedReason === "desired_disabled") { + return "integration-disabled"; } - if (options.restartIncomplete) { - return "Agent catalog was updated, but some stale Codex workers are still running."; + if (result.status === "skipped" && result.skippedReason === "external_provider") { + return "external-routing"; + } + if (result.warning !== undefined && result.warning.length > 0) return "sync-warning"; + return "sync-failed"; +} + +function countsFromObservation(status: CodexAppServerCatalogStatus): ApplyCodexCatalogWorkersResult { + const staleWorkerCount = staleProcessStarts(status).size; + return { + outcome: "blocked", + staleWorkerCount, + stoppedWorkerCount: 0, + survivingWorkerCount: staleWorkerCount, + }; +} + +export interface CodexCatalogApplyCoreDeps { + captureDesiredSnapshot: () => CodexCatalogDesiredSnapshot; + syncCatalog: (desired: CodexCatalogDesiredSnapshot) => Promise; + inspectArtifactProof: (desired: CodexCatalogDesiredSnapshot) => CodexCatalogArtifactProof; + getRoutingKind: () => CodexRoutingKind; + resetWorkerObservation: () => void; + collectWorkerState: () => CodexAppServerCatalogStatus; + applyWorkers: ( + authorizeSignal: () => boolean, + observedBefore: CodexAppServerCatalogStatus, + ) => Promise; + recordBootFenceApplied?: () => void; +} + +export interface CodexCatalogApplyInput { + /** Browser consent supplies this; CLI/companion bind it before convergence. */ + expectedDesiredRevision?: string; +} + +function observeWorkers(deps: Pick): CodexAppServerCatalogStatus { + try { + deps.resetWorkerObservation(); + return deps.collectWorkerState(); + } catch { + return unknownCatalogState(); } - if (options.staleDetected) return "Agent catalog applied. Codex will reload it on the next task."; - if (options.catalogUpdated) return "Agent catalog is current. No Codex worker restart was needed."; - return "Agent catalog is already current."; +} + +function blockedResult( + reason: CodexCatalogApplyBlockReason, + catalogUpdated: boolean, + workers: CodexAppServerCatalogStatus, + outcome: "blocked" | "superseded" = "blocked", +): CodexCatalogApplyResult { + return { + ...countsFromObservation(workers), + outcome, + catalogUpdated, + workerState: workers.state, + blockReason: reason, + }; } /** - * Apply the on-disk Codex catalog, classify long-lived workers, and terminate - * only workers proven stale. This is the implementation behind the fixed - * macOS helper action; it never starts, stops, or restarts the CodexCommander proxy. + * Canonical catalog Apply orchestration shared by every adapter. + * + * A successful sync may adopt native routing. External routing and OFF are + * preserved/refused by sync and never open the signal path. Exact artifact + * proof and CodexCommander-owned routing are required after convergence and + * are revalidated with the generation-bearing desired snapshot before every + * process signal. */ +export async function runCodexCatalogApply( + input: CodexCatalogApplyInput, + deps: CodexCatalogApplyCoreDeps, +): Promise { + let desired: CodexCatalogDesiredSnapshot; + try { + desired = deps.captureDesiredSnapshot(); + } catch { + return blockedResult("sync-failed", false, observeWorkers(deps)); + } + const expectedRevision = input.expectedDesiredRevision ?? desired.revision; + if (desired.revision !== expectedRevision) { + return blockedResult("desired-superseded", false, observeWorkers(deps), "superseded"); + } + + let syncResult: CodexSyncResult; + try { + syncResult = await deps.syncCatalog(desired); + } catch { + return blockedResult("sync-failed", false, observeWorkers(deps)); + } + const catalogUpdated = syncResult.catalogWritten === true || syncResult.cacheSynced === true; + + let convergedDesired: CodexCatalogDesiredSnapshot; + try { + convergedDesired = deps.captureDesiredSnapshot(); + } catch { + return blockedResult("desired-superseded", catalogUpdated, observeWorkers(deps), "superseded"); + } + if (convergedDesired.revision !== expectedRevision) { + return blockedResult("desired-superseded", catalogUpdated, observeWorkers(deps), "superseded"); + } + if (!codexCatalogSyncCanSignal(syncResult)) { + return blockedResult(syncBlockReason(syncResult), catalogUpdated, observeWorkers(deps)); + } + + let artifactProof: CodexCatalogArtifactProof; + let routingKind: CodexRoutingKind; + try { + artifactProof = deps.inspectArtifactProof(convergedDesired); + routingKind = deps.getRoutingKind(); + } catch { + return blockedResult("artifact-not-current", catalogUpdated, observeWorkers(deps)); + } + if (artifactProof !== "current") { + return blockedResult("artifact-not-current", catalogUpdated, observeWorkers(deps)); + } + if (routingKind !== "codexcommander-local") { + return blockedResult("routing-not-owned", catalogUpdated, observeWorkers(deps)); + } + + const workersBefore = observeWorkers(deps); + const authorizeSignal = (): boolean => { + try { + const current = deps.captureDesiredSnapshot(); + return current.revision === expectedRevision + && deps.getRoutingKind() === "codexcommander-local" + && deps.inspectArtifactProof(current) === "current"; + } catch { + return false; + } + }; + if (!authorizeSignal()) { + return blockedResult("authorization-changed", catalogUpdated, workersBefore, "superseded"); + } + + if (workersBefore.state === "unknown") { + return blockedResult("worker-state-unknown", catalogUpdated, workersBefore); + } + (deps.recordBootFenceApplied ?? recordCodexBootFenceApplied)(); + if (workersBefore.state === "not_running") { + return { + outcome: "no_workers", + staleWorkerCount: 0, + stoppedWorkerCount: 0, + survivingWorkerCount: 0, + catalogUpdated, + workerState: workersBefore.state, + }; + } + if (workersBefore.state === "fresh") { + return { + outcome: "already_current", + staleWorkerCount: 0, + stoppedWorkerCount: 0, + survivingWorkerCount: 0, + catalogUpdated, + workerState: workersBefore.state, + }; + } + + let result: ApplyCodexCatalogWorkersResult; + try { + result = await deps.applyWorkers(authorizeSignal, workersBefore); + } catch { + return blockedResult("worker-state-unknown", catalogUpdated, workersBefore); + } + return { + ...result, + catalogUpdated, + workerState: workersBefore.state, + ...(result.outcome === "superseded" + ? { blockReason: "authorization-changed" as const } + : result.outcome === "partial" + ? { blockReason: "workers-survived" as const } + : result.outcome === "blocked" + ? { blockReason: "worker-state-unknown" as const } + : {}), + }; +} + +export interface ApplyCodexCatalogDeps extends ApplyCodexCatalogWorkersDeps { + findLiveProxy: typeof findLiveProxy; + captureDesiredSnapshot: typeof captureCodexCatalogDesiredSnapshot; + syncModelsToCodex: typeof syncModelsToCodex; + inspectArtifactProof: (desired: CodexCatalogDesiredSnapshot) => CodexCatalogArtifactProof; + getRoutingKind: () => CodexRoutingKind; +} + +const defaultDeps: ApplyCodexCatalogDeps = { + findLiveProxy, + captureDesiredSnapshot: captureCodexCatalogDesiredSnapshot, + syncModelsToCodex, + inspectArtifactProof: desired => inspectCodexCatalogArtifactProof(desired.config), + getRoutingKind: getCodexRoutingKind, + resetCatalogStateCache: resetCatalogActivationStateCaches, + collectCatalogState: collectCodexCatalogActivationWorkerState, + listCodexWorkers: listCodexAppServerProcesses, + restartCodexWorkers: restartCodexAppServers, +}; + +function lifecycleMessage(result: CodexCatalogApplyResult): string { + if (result.blockReason === "integration-disabled") { + return "Agent catalog does not need to be applied."; + } + if (result.blockReason === "external-routing") { + return "Codex uses external routing; no Codex process was stopped."; + } + if (result.blockReason === "sync-failed" || result.blockReason === "sync-warning" + || result.blockReason === "artifact-not-current" || result.blockReason === "routing-not-owned") { + return "Agent catalog update did not complete."; + } + if (result.outcome === "superseded") { + return "Agent catalog Apply was superseded before a Codex process could be stopped."; + } + if (result.outcome === "blocked") { + return "Agent catalog was synchronized, but Codex worker state could not be verified."; + } + if (result.outcome === "partial") { + return "Agent catalog was updated, but some stale Codex workers are still running."; + } + if (result.outcome === "no_workers") { + return "Agent catalog applied. Codex will load it when a new background worker starts."; + } + if (result.outcome === "already_current") return "Agent catalog is already current."; + return "Agent catalog applied."; +} + +/** Thin native-companion adapter; it never restarts the CodexCommander proxy. */ export async function applyCodexCatalog( deps: ApplyCodexCatalogDeps = defaultDeps, ): Promise { const live = await deps.findLiveProxy().catch(() => null); - // This fixed app action is offered only from a healthy CodexCommander state. If - // that identity vanished before confirmation, refuse without touching the - // catalog or any Codex process; the standalone CLI keeps its own offline - // synchronization behavior. if (!live) { return { schemaVersion: 1, @@ -182,85 +484,53 @@ export async function applyCodexCatalog( survivingWorkerCount: 0, }; } - let syncResult: CodexSyncResult | undefined; - try { - syncResult = await deps.syncModelsToCodex(live.port, undefined, null); - } catch { - // Without a structured result there is no proof that a catalog/cache write - // landed. Continue only to report readiness; the signal gate below remains - // closed and no error detail crosses the helper boundary. - } - const catalogUpdated = syncResult?.catalogWritten === true || syncResult?.cacheSynced === true; - const syncSkipped = syncResult !== undefined && intentionalSyncSkip(syncResult); - const syncFailed = syncResultFailed(syncResult); - - const before = collectAfterInvalidation(deps); - const initialStaleStarts = staleProcessStarts(before); - const staleDetected = before.state === "stale"; - const staleWorkerCount = initialStaleStarts.size; - let restart = emptyRestartResult(); - let after = before; - - // Unknown is a fail-closed state. No listing or signal path is reached. - if (before.state === "stale" && !syncSkipped && !syncFailed) { - const targets = currentStaleTargets(before, deps); - try { - restart = deps.restartCodexWorkers(targets); - } catch { - // Verification below decides whether any stale worker remains. Platform - // error text is intentionally discarded at this boundary. - } - after = collectAfterInvalidation(deps); - } - - const remainingStale = staleProcessStarts(after).size; - const stoppedReported = uniqueCount(restart.stopped); - const stoppedByFinalState = after.state === "unknown" - ? 0 - : Math.max(0, staleWorkerCount - Math.min(staleWorkerCount, remainingStale)); - const stoppedWorkerCount = Math.min( - staleWorkerCount, - Math.max(stoppedReported, stoppedByFinalState), - ); - const survivingWorkerCount = syncSkipped - ? 0 - : after.state === "stale" - ? remainingStale - : after.state === "unknown" - ? uniqueCount(restart.surviving) - : 0; - const stateUnknown = !syncSkipped && (before.state === "unknown" || after.state === "unknown"); - const restartIncomplete = !syncSkipped && staleDetected && after.state === "stale"; - // Unknown cannot prove the old roster is gone, so the app must continue to - // present the update as incomplete instead of claiming success. - const codexRestartRequired = !syncSkipped && (after.state === "stale" || after.state === "unknown"); - const ok = !syncFailed && !stateUnknown && !restartIncomplete; + const result = await runCodexCatalogApply({}, { + captureDesiredSnapshot: deps.captureDesiredSnapshot, + syncCatalog: desired => deps.syncModelsToCodex(live.port, desired.config, null), + inspectArtifactProof: deps.inspectArtifactProof, + getRoutingKind: deps.getRoutingKind, + resetWorkerObservation: deps.resetCatalogStateCache, + collectWorkerState: deps.collectCatalogState, + applyWorkers: (authorizeSignal, observedBefore) => applyCodexCatalogWorkers(authorizeSignal, { + resetCatalogStateCache: deps.resetCatalogStateCache, + collectCatalogState: deps.collectCatalogState, + listCodexWorkers: deps.listCodexWorkers, + restartCodexWorkers: deps.restartCodexWorkers, + }, observedBefore), + }); + const ok = result.outcome === "applied" + || result.outcome === "already_current" + || result.outcome === "no_workers"; + const syncFailure = result.blockReason === "sync-failed" + || result.blockReason === "sync-warning" + || result.blockReason === "artifact-not-current" + || result.blockReason === "routing-not-owned" + || result.blockReason === "integration-disabled" + || result.blockReason === "external-routing"; + const intentionalPreservation = result.blockReason === "integration-disabled" + || result.blockReason === "external-routing"; + const codexRestartRequired = !intentionalPreservation + && (result.survivingWorkerCount > 0 + || result.workerState === "unknown" + || result.outcome === "partial" + || result.outcome === "superseded"); return { schemaVersion: 1, action: APPLY_CODEX_CATALOG_ACTION, ok, state: "running", - changed: catalogUpdated || stoppedWorkerCount > 0, + changed: result.catalogUpdated || result.stoppedWorkerCount > 0, pid: null, port: null, - message: fixedMessage({ - syncFailed, - syncSkipped, - stateUnknown, - restartIncomplete, - staleDetected, - catalogUpdated, - }), - ...(syncFailed - ? { errorCode: "SYNC_FAILED" as const } - : stateUnknown || restartIncomplete - ? { errorCode: "CODEX_RESTART_REQUIRED" as const } - : {}), - catalogUpdated, + message: lifecycleMessage(result), + ...(!ok + ? { errorCode: syncFailure ? "SYNC_FAILED" as const : "CODEX_RESTART_REQUIRED" as const } + : {}), + catalogUpdated: result.catalogUpdated, codexRestartRequired, - staleWorkerCount, - stoppedWorkerCount, - survivingWorkerCount, + staleWorkerCount: result.staleWorkerCount, + stoppedWorkerCount: result.stoppedWorkerCount, + survivingWorkerCount: result.survivingWorkerCount, }; } diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index 37bfddfbd2..aa8fd266ea 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -8,6 +8,6 @@ export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, c export { applyProviderConfigHints, resolveComboCatalogMember, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch"; export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation"; export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation"; -export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, effectiveSubagentRoster, buildCatalogEntries, mergeCatalogModelsWithNativeRecovery, mergeLiveRoutedEntriesWithRetained, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync"; +export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, effectiveSubagentRoster, configuredSubagentModelMatchesEntry, buildCatalogEntries, mergeCatalogModelsWithNativeRecovery, mergeLiveRoutedEntriesWithRetained, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync"; export type { SpawnAgentSurface, SubagentRosterExclusionReason, EffectiveSubagentModel, SubagentRosterExclusion, EffectiveSubagentRoster } from "./catalog/sync"; export { accountBoundNativeDisplayName, accountBoundNativeModelSlugs, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./catalog/account-models"; diff --git a/src/codex/catalog/bundled.ts b/src/codex/catalog/bundled.ts index 78f29d2417..c428ad8e87 100644 --- a/src/codex/catalog/bundled.ts +++ b/src/codex/catalog/bundled.ts @@ -433,7 +433,11 @@ export function resolveCatalogSourceForGather( const bytes = evidenceSession.readSource(role); if (bytes === null) continue; const catalog = parseCatalogJson(Buffer.from(bytes).toString("utf8")); - if (!catalog || !findNativeTemplate(catalog)) continue; + // Match the retained sync contract: a valid observed catalog is still a + // usable merge source when it has no native template (including an empty + // bootstrap catalog). Routed entries have a strict fallback constructor; + // requiring a native row here would skip provider discovery entirely. + if (!catalog) continue; return cloneAndDeepFreeze({ kind: "available" as const, source: role, @@ -454,6 +458,19 @@ export function resolveCatalogSourceForGather( }); } +/** + * Production orchestration for a cold/fresh home. Existing valid disk sources + * remain authoritative; only their absence primes the runtime-backed bundled + * memo that the observe-only gather may subsequently consume. + */ +export function primeBundledCatalogForGatherIfNeeded(): void { + const catalogPath = readCodexCatalogPath(); + if (readCatalog(catalogPath) + || readCatalog(catalogBackupPathFor(catalogPath)) + || readCatalog(activeCodexModelsCachePath())) return; + loadBundledCodexCatalog(); +} + export function materializeBundledCodexCatalog(path: string, deps: BundledCatalogDeps = {}): RawCatalog | null { const catalog = loadBundledCodexCatalog(deps); if (!catalog) return null; diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 4a90f31519..3e5787232c 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -482,12 +482,8 @@ export function readRetainedRoutedCatalog(): RawCatalog | null { return readCatalog(retainedRoutedCatalogPath()); } -/** - * Atomically persist routed rows (mode-600 via atomicWriteFile, which also - * records the write in the CodexCommander config ownership ledger). - */ -export function writeRetainedRoutedCatalog(models: RawEntry[]): void { - const path = retainedRoutedCatalogPath(); +/** Persist an already-admitted retained snapshot to its fixed observed path. */ +export function writeRetainedRoutedCatalogAtPath(path: string, models: RawEntry[]): void { const dir = dirname(path); if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); atomicWriteFile(path, `${JSON.stringify({ models }, null, 2)}\n`); diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 2c6df18b2b..c2f115dfab 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -1,60 +1,39 @@ -import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; -import { delimiter, dirname, join, resolve } from "node:path"; -import { expandUserPath, loadConfig, readConfigDiagnostics, websocketsEnabled } from "../../config"; +import { loadConfig, readConfigDiagnostics } from "../../config"; import { shouldSyncCodexOnStart } from "../desired-state"; -import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, getCodexHome, readRootTomlString, resolveCodexConfigPath } from "../paths"; -import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache"; -import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth"; -import type { CodexCommanderConfig, CodexCommanderProviderConfig } from "../../types"; +import { getCodexHome } from "../paths"; +import { clearModelCache } from "../model-cache"; +import type { CodexCommanderConfig } from "../../types"; import { modelInList } from "../../types"; -import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; -import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "../../generated/jawcode-model-metadata"; -import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; -import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; +import { CODEX_REASONING_LEVELS } from "../../reasoning-effort"; import { routedSlug } from "../../providers/slug-codec"; import { identifyRoutedModel } from "../../adapters/identity"; -import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; -import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; import { COMBO_NAMESPACE, comboModelId, getCombo, - listComboIds, - targetKey, } from "../../combos"; -import type { NormalizedComboConfig } from "../../combos/types"; -import { providerDestinationResolvedError } from "../../lib/destination-policy"; -import { redactSecretString } from "../../lib/redact"; -import upstreamModelsSnapshot from "../data/upstream-models.json"; -import { activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline, readRetainedRoutedCatalog, retainedRoutedCatalogPath, writeRetainedRoutedCatalog } from "./parsing"; +import { activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogModelSlug, ensureStrictCatalogFields, isRoutedModelCompatibilityExcluded, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath } from "./parsing"; import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing"; -import { applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, nativeOpenAiSlugs, NATIVE_OPENAI_MODELS, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; +import { applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; import { - bundledCatalogCacheState, - loadBundledCodexCatalog, resetBundledCatalogCacheForTests, - type BundledCatalogDeps, } from "./bundled"; import { isMultiAgentV2Enabled } from "../features"; -import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort"; -import { clearGatherRoutedModelsInflight, filterCatalogVisibleModels, gatherRoutedModels, lastDropWarnSignature } from "./provider-fetch"; -import { accountSelectorShadowCollisionWarnings, clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnAccountSelectorShadowedProviderOnce, warnComboMasqueradeCollisionOnce } from "./aggregation"; -import type { ComboCatalogOmission } from "./aggregation"; +import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort"; +import { clearGatherRoutedModelsInflight, lastDropWarnSignature } from "./provider-fetch"; +import { accountSelectorShadowCollisionWarnings, clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnAccountSelectorShadowedProviderOnce, warnComboMasqueradeCollisionOnce } from "./aggregation"; import { withCatalogWriteSerialization, type CatalogWritePermit, } from "../catalog-write-serialization"; import { - publishHashedCodexCatalogBackup, replaceActiveCodexCatalog, replaceCodexModelsCache, } from "../internal/catalog-writer"; -import { codexRuntimeStatePath } from "../runtime"; -import { accountBoundNativeDisplayName, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models"; +import { accountBoundNativeDisplayName, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug } from "./account-models"; export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5; @@ -119,7 +98,7 @@ export function configuredCatalogEntry(entries: readonly RawEntry[], configured: return entries.find(entry => entry.slug === configured); } -function configuredSubagentModelMatchesEntry(configured: string, entry: RawEntry): boolean { +export function configuredSubagentModelMatchesEntry(configured: string, entry: RawEntry): boolean { if (typeof entry.slug !== "string") return false; if (configured === entry.slug) return true; const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry); @@ -420,7 +399,7 @@ export function buildCatalogEntries( exactComboSlugs, ); routed.codexcommander_catalog_kind = CODEX_NATIVE_ALIAS_CATALOG_KIND; - const rankHit = rank.get(slug) ?? rank.get(`${nativeAlias.provider}/${nativeAlias.id}`); + const rankHit = rank.get(slug); if (rankHit !== undefined) routed.priority = rankHit * priorityStride; else if (accountSelectors.length > 0) { routed.priority = 1_000 + (typeof routed.priority === "number" ? routed.priority : 5); @@ -475,7 +454,7 @@ export function buildCatalogEntries( e.codexcommander_catalog_kind = CODEX_NATIVE_ALIAS_CATALOG_KIND; } // Featured picks are canonical Codex-facing selectors. - const rankHit = rank.get(slug) ?? rank.get(`${m.provider}/${m.id}`); + const rankHit = rank.get(slug); if (rankHit !== undefined) e.priority = rankHit * priorityStride; else if (accountSelectors.length > 0) { // Keep the generated account rows together in Codex's priority-sorted flat picker. @@ -532,7 +511,7 @@ export function orderForSubagents(goModels: CatalogModel[], featured?: string[]) * ownership), and `comp_hash` defaults to "codexcommander" for every normalized * row. */ -function isCodexCommanderAuthoredRoutedEntry(entry: RawEntry): boolean { +export function isCodexCommanderAuthoredRoutedEntry(entry: RawEntry): boolean { if (isNativeAliasCatalogEntry(entry)) return true; const desc = typeof entry.description === "string" ? entry.description : ""; const slug = typeof entry.slug === "string" ? entry.slug : ""; @@ -874,328 +853,6 @@ export function mergeCatalogEntriesForSync( ); } -interface RetainedCatalogSyncRead { - readonly catalogPath: string; - readonly catalog: RawCatalog; - readonly onDiskCatalog: RawCatalog | null; - readonly evidence: string; - /** - * Process-local epochs, baselined AFTER our own gather rather than with the - * filesystem bytes above. See `retainedCatalogProcessEvidence`. - */ - readonly processEvidence: string; -} - -interface RetainedCatalogSyncResult { - added: number; - path: string; - catalogWritten: boolean; - comboOmissions: ComboCatalogOmission[]; - catalogQuality: CatalogQuality; - /** Routed rows rehydrated from the retained last-known-good snapshot this sync. */ - rehydrated: number; - /** `desired_disabled` observed under K after the provider await; nothing was written. */ - skippedReason?: "desired_disabled"; -} - -interface RetainedCatalogSyncWrite { - readonly config: CodexCommanderConfig; - readonly goModels: CatalogModel[]; - readonly availableNativeSlugs: readonly string[]; - readonly deps: BundledCatalogDeps; - readonly comboOmissions: ComboCatalogOmission[]; - readonly read: RetainedCatalogSyncRead; - readonly permit: CatalogWritePermit; - readonly owningCodexHome: string; -} - -function optionalFileBytes(path: string): string | null { - try { - return readFileSync(path).toString("base64"); - } catch (error) { - if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return null; - throw error; - } -} - -function loadCatalogForRetainedSync(path: string, deps: BundledCatalogDeps): RawCatalog | null { - const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog(deps) : null; - if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog; - const active = readCatalog(path); - if (active && findNativeTemplate(active)) return active; - return readCatalog(catalogBackupPathFor(path)) - ?? readCatalog(activeCodexModelsCachePath()) - ?? active; -} - -function retainedCatalogSyncEvidence( - config: CodexCommanderConfig, - catalogPath: string, - catalog: RawCatalog, -): string { - return JSON.stringify({ - config, - catalogPath, - catalog, - catalogBytes: optionalFileBytes(catalogPath), - retainedBytes: optionalFileBytes(retainedRoutedCatalogPath()), - hashedBackupBytes: optionalFileBytes(catalogBackupPathFor(catalogPath)), - modelsCacheBytes: optionalFileBytes(activeCodexModelsCachePath()), - // The persisted runtime selection is a pre-await filesystem input, not a - // process epoch: another PROCESS can move runtime authority by rewriting this - // file, and that move is invisible to our in-process memo. Recorded PRESENT or - // ABSENT, because its absence is what makes the resolver fall back. - runtimeStateBytes: optionalFileBytes(codexRuntimeStatePath()), - }); -} - -/** - * The bundled-template half of the same evidence, observed separately. - * - * The runtime process memo is deliberately NOT here, and that exclusion took three - * attempts to get honest. Gathering resolves the Codex runtime lazily and under its - * own cache key, so this path cannot pre-settle that memo: baselining it before the - * await always detected our own side effect and refused every write, and baselining - * it after the await captured a runtime that ANOTHER process had moved as though it - * were ours — a catalog prepared from R1 committing after authority reached R2. - * - * Runtime authority is covered where it is actually durable instead: the persisted - * `codex-runtime.json` bytes sit in the pre-await filesystem evidence, PRESENT or - * ABSENT, so a cross-process runtime move is caught. What is left uncovered, and is - * written down rather than papered over, is a same-process in-memory runtime swap - * that never touches that file — WP11 owns the lock that makes that case decidable. - */ -function retainedCatalogProcessEvidence(): string { - return JSON.stringify({ - bundledCatalogCache: bundledCatalogCacheState(), - }); -} - -/** - * Capture every local catalog input the retained sync path consults before its - * provider await. The exact evidence is compared after K acquisition; a newer - * catalog/backup/cache or target selection makes this attempt a no-write. - */ -function readRetainedCatalogSync( - config: CodexCommanderConfig, - deps: BundledCatalogDeps, -): RetainedCatalogSyncRead | null { - const catalogPath = readCodexCatalogPath(); - const catalog = loadCatalogForRetainedSync(catalogPath, deps); - if (!catalog) return null; - - // The bundled catalog is a reliable native template on the default path, but it is not the - // merge source. Preservation must inspect the file that this sync is about to overwrite; - // otherwise an empty/partial provider gather cannot see routed or user-native rows on disk. - const onDiskCatalog = readCatalog(catalogPath); - const evidence = retainedCatalogSyncEvidence(config, catalogPath, catalog); - // `processEvidence` is filled in after the provider await, not here. - return { catalogPath, catalog, onDiskCatalog, evidence, processEvidence: "" }; -} - -function revalidateRetainedCatalogSync( - config: CodexCommanderConfig, - prepared: RetainedCatalogSyncRead, -): RetainedCatalogSyncRead | null { - const catalogPath = readCodexCatalogPath(); - if (catalogPath !== prepared.catalogPath) return null; - const evidence = retainedCatalogSyncEvidence(config, catalogPath, prepared.catalog); - if (evidence !== prepared.evidence) return null; - if (retainedCatalogProcessEvidence() !== prepared.processEvidence) return null; - return { - catalogPath, - catalog: JSON.parse(JSON.stringify(prepared.catalog)) as RawCatalog, - onDiskCatalog: readCatalog(catalogPath), - evidence, - processEvidence: prepared.processEvidence, - }; -} - -function pristineCatalogBytes(read: RetainedCatalogSyncRead): string | null { - if (read.onDiskCatalog && !catalogHasRoutedEntries(read.onDiskCatalog)) { - try { - return readFileSync(read.catalogPath, "utf8"); - } catch { - return null; - } - } - return catalogHasRoutedEntries(read.catalog) - ? null - : `${JSON.stringify(read.catalog, null, 2)}\n`; -} - -function catalogModelsForMergeWithNativeRecovery( - catalogPath: string, - catalog: RawCatalog, - onDiskCatalog: RawCatalog | null, -): RawEntry[] { - const primaryCatalogModels = onDiskCatalog?.models ?? catalog.models ?? []; - // Native-alias compatibility can omit disabled native rows because Desktop's remote - // allowlist ignores `visibility: "hide"`. Recovery sources retain genuine metadata so - // re-enabling a native model restores its original row rather than a routed clone. - return mergeCatalogModelsWithNativeRecovery(primaryCatalogModels, [ - catalog.models ?? [], - readCatalogBackup(catalogPath)?.models ?? [], - ]); -} - -function writeRetainedCatalogSync({ - config, - goModels, - availableNativeSlugs, - deps, - comboOmissions, - read, - permit, - owningCodexHome, -}: RetainedCatalogSyncWrite): RetainedCatalogSyncResult { - const { catalogPath, catalog, onDiskCatalog } = read; - const catalogModelsForMerge = catalogModelsForMergeWithNativeRecovery( - catalogPath, - catalog, - onDiskCatalog, - ); - const template = findNativeTemplate(catalog); - try { - // Once-only: preserve the PRISTINE pre-codexcommander catalog as the native-priority baseline - // (later syncs would otherwise overwrite it with featured-modified priorities). - const pristine = pristineCatalogBytes(read); - if (pristine !== null) { - publishHashedCodexCatalogBackup(permit, owningCodexHome, { - path: catalogBackupPathFor(catalogPath), - content: pristine, - }); - } - } catch { /* backup best-effort */ } - - // Hide disabled models from Codex, then feature the chosen subagent models (native OR routed) - // by giving them the lowest priority — see buildCatalogEntries for why priority, not array order. - const enabledGo = filterCatalogVisibleModels(goModels, config); - const featured = config.subagentModels ?? []; - const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities - const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; - const exactComboSlugs = exactComboCatalogSlugs(config); - const suppressedBareNativeSlugs = desktopAllowlistSuppressedNativeSlugs(config); - const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE); - const includeNativeOpenAi = shouldIncludeNativeOpenAi(config); - const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config); - const accountSelectors = includeAccountBoundNativeOpenAi - ? visibleCodexAccountSelectors(config) - : []; - const wsEnabled = websocketsEnabled(config); - const goEntries = buildCatalogEntries( - template ? JSON.parse(JSON.stringify(template)) : null, - [], - orderedGoModels, - featured, - wsEnabled, - multiAgentMode, - exactComboSlugs, - accountSelectors, - suppressedBareNativeSlugs, - new Set(), - ); - // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append - // routed providers as namespaced slugs. Cursor and other adopted providers can expose model ids - // like `gpt-5.5`; those must not delete the native OpenAI/Codex base row. - const baseline = readNativeBaseline(catalogPath); - const goIds = new Set(enabledGo.map(m => m.id)); - const gatheredProviderNames = new Set( - Object.entries(config.providers ?? {}) - .filter(([, prov]) => prov.disabled !== true) - .map(([name]) => name), - ); - // Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to - // native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a - // native template can never leak supports_websockets while the flag is off. - // #636: when the user only configured non-OpenAI providers (e.g. kimi), do not advertise - // bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no - // providers are configured yet (fresh install / catalog bootstrap tests). - const accountBoundEntries = includeAccountBoundNativeOpenAi && accountSelectors.length > 0 - ? buildCatalogEntries( - template ? JSON.parse(JSON.stringify(template)) : null, - [...availableNativeSlugs], - [], - featured, - wsEnabled, - multiAgentMode, - exactComboSlugs, - accountSelectors, - suppressedBareNativeSlugs, - new Set([...disabledNativeSlugs(config)].filter(slug => suppressedBareNativeSlugs.has(slug))), - ).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) - : []; - // Rehydration: fill provider-level gaps from the last-known-good CodexCommander - // snapshot. A stop-like native restore plus a total outage restores every - // still-configured provider; a partial live gather keeps its fresh providers - // and restores only providers that returned no rows. If the active catalog - // still has routes and the gather is wholly empty, the existing #855 on-disk - // preservation path remains authoritative. - const liveRoutedEntries = goEntries; - const onDiskHasRoutedEntries = catalogHasRoutedEntries({ models: catalogModelsForMerge }); - const { entries: routedEntriesForMerge, retainedRows } = mergeLiveRoutedEntriesWithRetained( - liveRoutedEntries, - readRetainedRoutedCatalog(), - config, - gatheredProviderNames, - onDiskHasRoutedEntries, - ); - catalog.models = mergeCatalogEntriesForSync( - catalogModelsForMerge, - routedEntriesForMerge, - baseline, - featured, - wsEnabled, - goIds, - template, - new Set(config.disabledModels ?? []), - gatheredProviderNames, - multiAgentMode, - exactComboSlugs, - hasPhysicalComboProvider, - includeNativeOpenAi, - accountBoundEntries, - availableNativeSlugs, - suppressedBareNativeSlugs, - ); - clampCatalogModelsToCodexSupport(catalog.models, deps); - - const ccxAuthoredRouted = catalog.models.filter(isCodexCommanderAuthoredRoutedEntry); - const retainedSlugs = new Set(retainedRows.flatMap(entry => - typeof entry.slug === "string" ? [entry.slug] : [] - )); - const rehydrated = ccxAuthoredRouted.filter(entry => - typeof entry.slug === "string" && retainedSlugs.has(entry.slug) - ).length; - const catalogQuality: CatalogQuality = retainedRows.length > 0 - ? "retained" - : liveRoutedEntries.length > 0 - ? "live" - : ccxAuthoredRouted.length > 0 - ? "retained" - : "native-only"; - replaceActiveCodexCatalog(permit, owningCodexHome, { - path: catalogPath, - content: `${JSON.stringify(catalog, null, 2)}\n`, - }); - // Persist the last-known-good snapshot ONLY from a successful live routed sync. - // An empty or failed gather never touches it, so a later rehydrate still sees the - // rows that were actually verified against live provider discovery. - if (liveRoutedEntries.length > 0) { - try { - writeRetainedRoutedCatalog(ccxAuthoredRouted); - } catch { /* snapshot is best-effort; the catalog write is the primary artifact */ } - } - return { - added: goEntries.length + accountBoundEntries.length, - path: catalogPath, - catalogWritten: true, - comboOmissions, - catalogQuality, - rehydrated, - }; -} - function visibleAccountReplacementNatives( models: readonly RawEntry[], disabledModels: ReadonlySet | null, @@ -1244,83 +901,6 @@ function currentDisabledModelsForRestore(): Set | null { } } -export async function syncCatalogModels( - config: CodexCommanderConfig, - deps: BundledCatalogDeps = {}, -): Promise { - const owningCodexHome = getCodexHome(); - const preflightRead = readRetainedCatalogSync(config, deps); - if (preflightRead === null) { - return { - added: 0, - path: readCodexCatalogPath(), - catalogWritten: false, - comboOmissions: [], - catalogQuality: "native-only", - rehydrated: 0, - }; - } - - const comboOmissions: ComboCatalogOmission[] = []; - // Settle the bundled template, then baseline, and only then await. Reading it - // here makes the memo ours before anyone else can move it, so a bundled swap - // during the await is an outside change rather than our own side effect. - // - // The persisted runtime selection is covered by the filesystem evidence above - // rather than by a process epoch; see `retainedCatalogProcessEvidence` for why - // the in-memory runtime memo cannot be baselined honestly from this path. - loadBundledCodexCatalog(deps); - const availableNativeSlugs = nativeOpenAiSlugs(deps); - const prepared: RetainedCatalogSyncRead = { - ...preflightRead, - evidence: retainedCatalogSyncEvidence(config, preflightRead.catalogPath, preflightRead.catalog), - processEvidence: retainedCatalogProcessEvidence(), - }; - const goModels = await gatherRoutedModels(config, { - comboOmissions, - nativeOpenAiSlugs: () => [...availableNativeSlugs], - }); - const committed = withCatalogWriteSerialization(owningCodexHome, permit => { - // Desired state can flip OFF during the provider await above. The catalog - // evidence revalidation below cannot see that — intent lives in our config, - // not in the catalog files — so the policy is re-read here, under K, right - // before the only write. A lost race becomes the discriminated skip instead - // of a routed catalog/cache surviving a completed disable. - if (!shouldSyncCodexOnStart(loadConfig())) { - return { - added: 0, - path: prepared.catalogPath, - catalogWritten: false, - comboOmissions, - catalogQuality: "native-only" as const, - rehydrated: 0, - skippedReason: "desired_disabled" as const, - }; - } - const current = revalidateRetainedCatalogSync(config, prepared); - if (current === null) return null; - return writeRetainedCatalogSync({ - config, - goModels, - availableNativeSlugs, - deps, - comboOmissions, - read: current, - permit, - owningCodexHome, - }); - }); - if (committed.kind === "completed" && committed.value !== null) return committed.value; - return { - added: 0, - path: prepared.catalogPath, - catalogWritten: false, - comboOmissions, - catalogQuality: "native-only", - rehydrated: 0, - }; -} - export function restoreCodexCatalogWithPermit( permit: CatalogWritePermit, owningCodexHome: string, @@ -1390,10 +970,9 @@ export function invalidateCodexModelsCacheWithPermit( owningCodexHome: string, ): boolean { try { - // This permit is a REACQUISITION: refreshCodexModelCatalog's commit released - // K before this rewrite runs, so the commit-path desired-state check cannot - // cover it. A disable landing in that gap must not be overwritten by a - // routed cache write — re-read intent under this permit, same as the commit. + // This advanced cache-only operation is intentionally separate from + // canonical convergence. Re-read durable intent while holding its permit so + // an explicit `sync-cache` cannot publish routed bytes after integration OFF. if (!shouldSyncCodexOnStart(loadConfig())) return false; const catalogPath = readCodexCatalogPath(); if (!existsSync(catalogPath)) return false; diff --git a/src/codex/convergence-types.ts b/src/codex/convergence-types.ts index 3c0105f9ce..75328f9cfc 100644 --- a/src/codex/convergence-types.ts +++ b/src/codex/convergence-types.ts @@ -372,6 +372,8 @@ export interface CatalogGatherAuthorityIdentity { readonly generation: ConfigGeneration; /** Keyed HMAC of the exact canonical config snapshot, including secret-bearing fields. */ readonly snapshotIdentity: string; + /** Process-keyed identity of the exact persisted config bytes bound to admission. */ + readonly contentIdentity: string; }>; readonly authSnapshotIdentity: string; readonly discoveryPolicyIdentity: string; diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index dcfb2e1a9f..5fdc1573d4 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -1,10 +1,23 @@ -import { join } from "node:path"; +import { createHash, createHmac, randomBytes } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; -import { getConfigDir, websocketsEnabled, withExpectedConfigGenerationSync } from "../config"; +import { + ConfigMutationLockError, + getConfigDir, + validateConfigCandidate, + websocketsEnabled, + withExpectedConfigGenerationSync, +} from "../config"; import { COMBO_NAMESPACE } from "../combos"; import { getAuthStorePath } from "../oauth/store"; import type { CodexCommanderConfig } from "../types"; -import { captureCatalogAdmissionSnapshot } from "./catalog-admission"; +import { + captureCatalogAdmissionSnapshot, + captureCatalogConfigAuthority, + CatalogAdmissionStaleConfigError, + type CatalogConfigAuthoritySnapshot, +} from "./catalog-admission"; import { type CatalogSourceForGather, bundledCatalogCacheState, @@ -32,17 +45,21 @@ import { findNativeTemplate, parseCatalogJson, retainedRoutedCatalogPath, + writeRetainedRoutedCatalogAtPath, type RawCatalog, type RawEntry, } from "./catalog/parsing"; import { buildCatalogEntries, + isCodexCommanderAuthoredRoutedEntry, mergeCatalogEntriesForSync, mergeCatalogModelsWithNativeRecovery, mergeLiveRoutedEntriesWithRetained, orderForSubagents, + type CatalogQuality, } from "./catalog/sync"; import { exactComboCatalogSlugs } from "./catalog/aggregation"; +import type { ComboCatalogOmission } from "./catalog/aggregation"; import { catalogSupportedReasoningEfforts, clampCatalogModelsToSupportedEfforts, @@ -57,6 +74,11 @@ import { } from "./catalog/metadata"; import { trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./catalog/account-models"; import { codexRuntimeStatePath, peekCodexRuntimeProcessCache } from "./runtime"; +import { isMultiAgentV2Enabled } from "./features"; +import { + codexCatalogWritePolicy, + nonDisruptiveCodexManagementWritePolicy, +} from "./management-write-policy"; import { withCatalogWriteSerialization } from "./catalog-write-serialization"; import { publishHashedCodexCatalogBackup, @@ -93,13 +115,65 @@ export interface CodexCatalogCandidate { readonly [catalogCandidateBrand]: true export type CodexCatalogGatherResult = | { readonly kind: "candidate"; readonly candidate: CodexCatalogCandidate } - | { readonly kind: "disposition"; readonly disposition: CatalogDisposition }; + | { + readonly kind: "disposition"; + readonly disposition: CatalogDisposition; + readonly staleReason?: Extract["reason"]; + }; + +/** Internal projection used to preserve the established sync response without a second pipeline. */ +export interface CodexCatalogConvergenceProjection { + readonly admittedGeneration: CatalogAdmissionSnapshot["generation"]; + readonly admittedConfigAuthority: CatalogConfigAuthoritySnapshot; + readonly path: string; + readonly added: number; + readonly catalogWritten: boolean; + readonly cacheSynced: boolean; + readonly comboOmissions: readonly ComboCatalogOmission[]; + readonly catalogQuality: CatalogQuality; + readonly rehydrated: number; + readonly staleReason?: Extract["reason"]; +} + +export interface CodexCatalogConvergenceResult { + readonly changed: boolean; + readonly catalogRefresh: CatalogDisposition; + readonly projection: CodexCatalogConvergenceProjection; +} + +/** + * Process-local proof that one admitted catalog input produced both active + * artifacts. The generation is diagnostic only: artifact-current comparison + * deliberately uses `catalogInputIdentity`, which excludes session-only config. + * + * `models_cache.json` is included as commit provenance, but it is not a durable + * activation fence: a running Codex worker legitimately rewrites or removes + * that cache. Steady-state activation therefore revalidates the authoritative + * catalog target and identity only. + */ +export interface CodexCatalogConvergenceReceipt { + readonly admittedGeneration: CatalogAdmissionSnapshot["generation"]; + readonly catalogInputIdentity: string; + readonly targets: Readonly<{ catalogPath: string; cachePath: string }>; + readonly semanticIdentities: Readonly<{ catalog: string; cache: string }>; +} + +export interface CodexCatalogConvergenceReceiptMatchInput { + readonly config: Readonly; + readonly catalogPath: string; +} type CommitAttempt = CodexCatalogCommitResult | { readonly kind: "busy" }; interface CandidateState { consumed: boolean; + readonly requiresManagedRouting: boolean; + readonly sequence: number; + readonly config: Readonly; readonly generation: CatalogAdmissionSnapshot["generation"]; + readonly configSemanticIdentity: string; + readonly configContentIdentity: string; + readonly catalogInputIdentity: string; readonly authority: CatalogGatherAuthorityIdentity; readonly sourceEvidence: CatalogSourceEvidence; readonly processLocal: CatalogProcessLocalEvidence; @@ -108,11 +182,25 @@ interface CandidateState { readonly catalog: PreparedCatalogFileWrite; readonly cache: PreparedCatalogFileWrite; readonly keyedBackup?: PreparedCatalogFileWrite; + readonly retained?: Readonly<{ path: string; models: RawEntry[] }>; + readonly catalogChanged: boolean; + readonly cacheChanged: boolean; + readonly retainedChanged: boolean; readonly changed: boolean; readonly notices: readonly CatalogNotice[]; + readonly added: number; + readonly comboOmissions: readonly ComboCatalogOmission[]; + readonly catalogQuality: CatalogQuality; + readonly rehydrated: number; } const candidateStates = new WeakMap(); +const CATALOG_INPUT_IDENTITY_KEY = randomBytes(32); +let nextCandidateSequence = 0; +let convergenceReceipt: Readonly<{ + sequence: number; + value: CodexCatalogConvergenceReceipt; +}> | null = null; function same(left: unknown, right: unknown): boolean { return JSON.stringify(left) === JSON.stringify(right); } @@ -131,6 +219,128 @@ function catalogBytes(catalog: RawCatalog): string { return `${JSON.stringify(catalog, null, 2)}\n`; } +function normalizeJsonValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(normalizeJsonValue); + if (value !== null && typeof value === "object") { + return Object.fromEntries(Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, normalizeJsonValue(child)])); + } + return value; +} + +function catalogInputIdentity(config: Readonly): string { + const normalized = validateConfigCandidate(config); + const value = normalized.ok ? normalized.config : config; + // Audited against gatherRoutedModelsForCatalogGather + prepareCatalog. Keep + // operational/session settings out: artifact-current must not drift for log, + // timeout, sidecar, cleanup, or message-delivery changes that cannot alter + // either prepared JSON document. + const catalogInput = { + providers: value.providers, + combos: value.combos ?? {}, + disabledModels: [...(value.disabledModels ?? [])].sort(), + customModels: value.customModels ?? [], + providerContextCaps: value.providerContextCaps ?? {}, + subagentModels: value.subagentModels ?? [], + multiAgentMode: value.multiAgentMode ?? "default", + // In default mode the native Codex feature flag decides the advertised + // multi_agent_version. Forced v1/v2 modes intentionally ignore it. + nativeMultiAgentV2Enabled: value.multiAgentMode === "v1" || value.multiAgentMode === "v2" + ? null + : isMultiAgentV2Enabled(), + websockets: websocketsEnabled(value), + codexAccounts: (value.codexAccounts ?? []) + .map(account => ({ id: account.id, isMain: account.isMain === true })) + .sort((left, right) => left.id.localeCompare(right.id)), + codexAccountNamespaces: value.codexAccountNamespaces ?? {}, + }; + return createHmac("sha256", CATALOG_INPUT_IDENTITY_KEY) + .update(JSON.stringify(normalizeJsonValue(catalogInput))) + .digest("hex"); +} + +function semanticJsonIdentity(bytes: string | Uint8Array): string | null { + try { + const parsed = JSON.parse(typeof bytes === "string" + ? bytes + : Buffer.from(bytes).toString("utf8")) as unknown; + return createHash("sha256") + .update(JSON.stringify(normalizeJsonValue(parsed))) + .digest("hex"); + } catch { + return null; + } +} + +function sameResolvedPath(left: string, right: string): boolean { + const leftResolved = resolve(left); + const rightResolved = resolve(right); + return process.platform === "win32" + ? leftResolved.toLowerCase() === rightResolved.toLowerCase() + : leftResolved === rightResolved; +} + +/** Last fully committed catalog+cache proof in this process, if any. */ +export function readCodexCatalogConvergenceReceipt(): CodexCatalogConvergenceReceipt | null { + return convergenceReceipt?.value ?? null; +} + +/** + * Require the current audited catalog input and exact authoritative catalog + * target/identity to match the last fully committed catalog+cache convergence. + * Codex owns `models_cache.json` after publication and may churn it at any time. + */ +export function codexCatalogConvergenceReceiptMatchesCurrent( + input: CodexCatalogConvergenceReceiptMatchInput, +): boolean { + const receipt = convergenceReceipt?.value; + if (!receipt || receipt.catalogInputIdentity !== catalogInputIdentity(input.config)) return false; + if (!sameResolvedPath(receipt.targets.catalogPath, input.catalogPath)) return false; + try { + return semanticJsonIdentity(readFileSync(input.catalogPath)) === receipt.semanticIdentities.catalog; + } catch { + return false; + } +} + +/** Test/process lifecycle seam; startup convergence repopulates the receipt. */ +export function resetCodexCatalogConvergenceReceiptForTests(): void { + convergenceReceipt = null; +} + +function publishConvergenceReceipt(state: CandidateState): void { + const catalog = semanticJsonIdentity(state.catalog.content); + const cache = semanticJsonIdentity(state.cache.content); + if (catalog === null || cache === null) return; + const value: CodexCatalogConvergenceReceipt = Object.freeze({ + admittedGeneration: Object.freeze({ ...state.generation }), + catalogInputIdentity: state.catalogInputIdentity, + targets: Object.freeze({ catalogPath: state.catalog.path, cachePath: state.cache.path }), + semanticIdentities: Object.freeze({ catalog, cache }), + }); + convergenceReceipt = Object.freeze({ sequence: state.sequence, value }); +} + +function invalidateConvergenceReceipt(state: CandidateState): void { + // A late failure from an older overlapping attempt must not erase a newer + // successful proof. The newest unsuccessful attempt is conservative: it + // leaves no process-local claim until convergence succeeds again. + if (convergenceReceipt && convergenceReceipt.sequence > state.sequence) return; + convergenceReceipt = null; +} + +/** JSON object key order and whitespace are not catalog behavior. Array order remains significant. */ +function sameJsonDocument(bytes: Uint8Array | null, prepared: unknown): boolean { + if (bytes === null) return false; + try { + const current = JSON.parse(Buffer.from(bytes).toString("utf8")) as unknown; + return JSON.stringify(normalizeJsonValue(current)) === JSON.stringify(normalizeJsonValue(prepared)); + } catch { + return false; + } +} + interface ReadonlyRawCatalogLike { readonly models?: readonly Readonly>[]; } @@ -176,7 +386,14 @@ function prepareCatalog( routedModels: Awaited>, retainedCatalog: RawCatalog | null, nativeRecoverySources: readonly (readonly RawEntry[])[] = [], -): { catalog: RawCatalog; retainedRows: RawEntry[] } { +): { + catalog: RawCatalog; + retainedRows: RawEntry[]; + retainedSnapshot: RawEntry[] | null; + added: number; + catalogQuality: CatalogQuality; + rehydrated: number; +} { const catalog = JSON.parse(JSON.stringify(source.catalog)) as RawCatalog; const template = findNativeTemplate(catalog); const enabled = filterCatalogVisibleModels(routedModels, config); @@ -253,18 +470,46 @@ function prepareCatalog( catalog.models, catalogSupportedReasoningEfforts(source.catalog), ); - return { catalog, retainedRows: retainedMerge.retainedRows }; + const ccxAuthoredRouted = catalog.models.filter(isCodexCommanderAuthoredRoutedEntry); + const retainedSlugs = new Set(retainedMerge.retainedRows.flatMap(entry => ( + typeof entry.slug === "string" ? [entry.slug] : [] + ))); + const rehydrated = ccxAuthoredRouted.filter(entry => ( + typeof entry.slug === "string" && retainedSlugs.has(entry.slug) + )).length; + const catalogQuality: CatalogQuality = retainedMerge.retainedRows.length > 0 + ? "retained" + : routedEntries.length > 0 + ? "live" + : ccxAuthoredRouted.length > 0 + ? "retained" + : "native-only"; + return { + catalog, + retainedRows: retainedMerge.retainedRows, + // Match the established recovery contract: only a successful live gather + // advances the LKG, and partial live discovery carries forward retained peers. + retainedSnapshot: routedEntries.length > 0 ? ccxAuthoredRouted : null, + added: routedEntries.length + accountBoundEntries.length, + catalogQuality, + rehydrated, + }; } export async function gatherCodexCatalogCandidate( snapshot: CatalogAdmissionSnapshot, + policy: Readonly<{ requiresManagedRouting?: boolean }> = {}, ): Promise { let providerGatherStarted = false; try { const session = createCatalogGatherEvidenceSession(); const home = captureAndSealCatalogHomeSelection(session); if (!same(home, snapshot.sourceEvidence.homeSelection)) { - return { kind: "disposition", disposition: { status: "skipped", reason: "stale", retryable: true } }; + return { + kind: "disposition", + disposition: { status: "skipped", reason: "stale", retryable: true }, + staleReason: "home-selection", + }; } const paths = bindGatherPaths(session, snapshot); const source = resolveCatalogSourceForGather(session); @@ -281,23 +526,62 @@ export async function gatherCodexCatalogCandidate( } const authOutcomes: CatalogGatherProviderAuthOutcome[] = []; const discoveryPolicies: CatalogProviderDiscoveryPolicySnapshot[] = []; + const comboOmissions: ComboCatalogOmission[] = []; providerGatherStarted = true; const routedModels = await gatherRoutedModelsForCatalogGather(snapshot.config, session, { providerAuthOutcomes: authOutcomes, discoveryPolicySnapshots: discoveryPolicies, + comboOmissions, }); const processLocal = processEvidence(source); const sourceEvidence = sealCatalogGatherEvidenceSession(session); if (!same(sourceEvidence.required, snapshot.sourceEvidence.required)) { - return { kind: "disposition", disposition: { status: "skipped", reason: "stale", retryable: true } }; + return { + kind: "disposition", + disposition: { status: "skipped", reason: "stale", retryable: true }, + staleReason: "source-observation", + }; } - const current = captureCatalogAdmissionSnapshot(snapshot.config); - if (!same(current.configIdentity, snapshot.configIdentity) - || !same(current.targets, snapshot.targets) + let current: CatalogAdmissionSnapshot; + try { + current = captureCatalogAdmissionSnapshot(snapshot.config); + } catch (error) { + if (error instanceof CatalogAdmissionStaleConfigError) { + return { + kind: "disposition", + disposition: { status: "skipped", reason: "stale", retryable: true }, + staleReason: "generation", + }; + } + const message = error instanceof Error ? error.message : ""; + if (error instanceof ConfigMutationLockError + || message.includes("config generation is busy") + || message.includes("config generation is database")) { + return { + kind: "disposition", + disposition: { status: "skipped", reason: "busy", retryable: true }, + }; + } + throw error; + } + if (!same(current.configIdentity, snapshot.configIdentity)) { + return { + kind: "disposition", + disposition: { status: "skipped", reason: "stale", retryable: true }, + staleReason: current.generation.value !== snapshot.generation.value + ? "generation" + : "source-observation", + }; + } + if (!same(current.targets, snapshot.targets) || !same(current.sourceEvidence.homeSelection, snapshot.sourceEvidence.homeSelection) || !same(current.sourceEvidence.required, snapshot.sourceEvidence.required)) { - return { kind: "disposition", disposition: { status: "skipped", reason: "stale", retryable: true } }; + return { + kind: "disposition", + disposition: { status: "skipped", reason: "stale", retryable: true }, + staleReason: "target-identity", + }; } const active = catalogFrom(activeBytes); @@ -307,11 +591,12 @@ export async function gatherCodexCatalogCandidate( ]); const preparedCatalog = prepared.catalog; const preparedCatalogBytes = catalogBytes(preparedCatalog); - const preparedCacheBytes = `${JSON.stringify({ + const preparedCache = { fetched_at: "2000-01-01T00:00:00Z", client_version: "0.0.0", models: preparedCatalog.models ?? [], - }, null, 2)}\n`; + }; + const preparedCacheBytes = `${JSON.stringify(preparedCache, null, 2)}\n`; const pristineBytes = active && !catalogHasRoutedEntries(active) ? Buffer.from(activeBytes!).toString("utf8") : !hasRoutedEntries(source.catalog) ? `${JSON.stringify(source.catalog, null, 2)}\n` : null; @@ -320,9 +605,22 @@ export async function gatherCodexCatalogCandidate( if (authOutcomes.some(outcome => outcome.state !== "available")) notices.add("provider-auth"); if (prepared.retainedRows.length > 0) notices.add("provider-network"); const candidate = {} as CodexCatalogCandidate; + const catalogChanged = !sameJsonDocument(activeBytes, preparedCatalog); + const cacheChanged = !sameJsonDocument(cacheBytes, preparedCache); + const retainedPrepared = prepared.retainedSnapshot === null + ? null + : { models: prepared.retainedSnapshot }; + const retainedChanged = retainedPrepared !== null + && !sameJsonDocument(retainedBytes, retainedPrepared); candidateStates.set(candidate, { consumed: false, + requiresManagedRouting: policy.requiresManagedRouting === true, + sequence: ++nextCandidateSequence, + config: snapshot.config, generation: snapshot.generation, + configSemanticIdentity: snapshot.configIdentity.snapshotIdentity, + configContentIdentity: snapshot.configIdentity.contentIdentity, + catalogInputIdentity: catalogInputIdentity(snapshot.config), authority: createCatalogGatherAuthorityIdentity( snapshot, sourceEvidence, @@ -336,9 +634,18 @@ export async function gatherCodexCatalogCandidate( catalog: { path: paths.catalog, content: preparedCatalogBytes }, cache: { path: paths.cache, content: preparedCacheBytes }, ...(pristineBytes ? { keyedBackup: { path: paths.keyedBackup, content: pristineBytes } } : {}), - changed: Buffer.from(activeBytes ?? []).toString("utf8") !== preparedCatalogBytes - || Buffer.from(cacheBytes ?? []).toString("utf8") !== preparedCacheBytes, + ...(prepared.retainedSnapshot === null + ? {} + : { retained: { path: paths.retained, models: prepared.retainedSnapshot } }), + catalogChanged, + cacheChanged, + retainedChanged, + changed: catalogChanged || cacheChanged || retainedChanged, notices: Object.freeze([...notices]), + added: prepared.added, + comboOmissions: Object.freeze([...comboOmissions]), + catalogQuality: prepared.catalogQuality, + rehydrated: prepared.rehydrated, }); return { kind: "candidate", candidate }; } catch (error) { @@ -356,6 +663,24 @@ export async function gatherCodexCatalogCandidate( } function revalidateCandidate(state: CandidateState): CodexCatalogCommitResult | null { + if (state.requiresManagedRouting + && !nonDisruptiveCodexManagementWritePolicy(state.config).allowed) { + return { kind: "refused", reason: "source-ambiguous" }; + } + try { + const authority = captureCatalogConfigAuthority(state.config); + if (authority.generation.value !== state.generation.value + || authority.semanticIdentity !== state.configSemanticIdentity + || authority.contentIdentity !== state.configContentIdentity) { + return { kind: "stale", reason: "generation" }; + } + } catch (error) { + if (error instanceof CatalogAdmissionStaleConfigError) { + return { kind: "stale", reason: "generation" }; + } + return { kind: "refused", reason: "source-unreadable" }; + } + let session: CatalogFilesystemEvidenceSession; let validatingTargets = false; try { @@ -416,10 +741,17 @@ function fixedCommit(state: CandidateState, permit: Parameters { const invalid = revalidateCandidate(state); - return invalid ?? fixedCommit(state, permit); + const result = invalid ?? fixedCommit(state, permit); + if (result.kind === "committed") publishConvergenceReceipt(state); + else invalidateConvergenceReceipt(state); + return result; }); - if (guarded.kind === "conflict") return { kind: "stale", reason: "generation" } as const; - if (guarded.kind === "unavailable") return { kind: "busy" } as const; + if (guarded.kind === "conflict") { + invalidateConvergenceReceipt(state); + return { kind: "stale", reason: "generation" } as const; + } + if (guarded.kind === "unavailable") { + invalidateConvergenceReceipt(state); + return { kind: "busy" } as const; + } return guarded.value; }); if (acquired.kind === "completed") return acquired.value; @@ -459,7 +800,8 @@ function projectCommit(result: CommitAttempt, notices: readonly CatalogNotice[]) if (result.kind === "stale") return { status: "skipped", reason: "stale", retryable: true }; if (result.kind === "refused") return { status: "skipped", reason: "refused", retryable: false }; const partialWrite = result.writes.keyedBackup === "written" - || result.writes.catalog === "written"; + || result.writes.catalog === "written" + || result.writes.cache === "written"; return { status: "failed", reason: "disk", phase: "commit", retryable: false, partialWrite }; } @@ -467,20 +809,79 @@ export async function convergeCodexCatalog( snapshot: CatalogAdmissionSnapshot, request: ConvergeRequest, lifecycle: Readonly<{ onCommitBegin?: () => void }> = {}, -): Promise> { +): Promise { + const emptyProjection = (): CodexCatalogConvergenceProjection => ({ + admittedGeneration: snapshot.generation, + admittedConfigAuthority: { + generation: snapshot.generation, + semanticIdentity: snapshot.configIdentity.snapshotIdentity, + contentIdentity: snapshot.configIdentity.contentIdentity, + }, + path: targetPath(snapshot.targets.catalog), + added: 0, + catalogWritten: false, + cacheSynced: false, + comboOmissions: [], + catalogQuality: "native-only", + rehydrated: 0, + }); if (request.scope !== "catalog" || request.action !== "converge") { return { changed: false, catalogRefresh: { status: "failed", reason: "disk", phase: "gather", retryable: false, partialWrite: false }, + projection: emptyProjection(), + }; + } + const policy = codexCatalogWritePolicy(snapshot.config, request); + if (!policy.allowed) { + return { + changed: false, + catalogRefresh: { status: "skipped", reason: "refused", retryable: false }, + projection: emptyProjection(), + }; + } + const gathered = await gatherCodexCatalogCandidate(snapshot, { + requiresManagedRouting: policy.requiresManagedRouting, + }); + if (gathered.kind === "disposition") { + return { + changed: false, + catalogRefresh: gathered.disposition, + projection: { + ...emptyProjection(), + ...(gathered.staleReason ? { staleReason: gathered.staleReason } : {}), + }, }; } - const gathered = await gatherCodexCatalogCandidate(snapshot); - if (gathered.kind === "disposition") return { changed: false, catalogRefresh: gathered.disposition }; const state = candidateStates.get(gathered.candidate as object)!; lifecycle.onCommitBegin?.(); const committed = await commitCodexCatalogCandidate(gathered.candidate, request.deadlineMs); + const catalogWritten = (committed.kind === "committed" || committed.kind === "failed") + && committed.writes.catalog === "written"; + const cacheSynced = (committed.kind === "committed" || committed.kind === "failed") + && committed.writes.cache === "written"; + const committedStateApplies = committed.kind === "committed" + || (committed.kind === "failed" && (catalogWritten || !state.catalogChanged)); return { changed: committed.kind === "committed" ? committed.changed : false, catalogRefresh: projectCommit(committed, state.notices), + projection: { + admittedGeneration: state.generation, + admittedConfigAuthority: { + generation: state.generation, + semanticIdentity: state.configSemanticIdentity, + contentIdentity: state.configContentIdentity, + }, + path: state.catalog.path, + // `added` and `rehydrated` describe rows this attempt actually published, + // not rows merely rediscovered during a semantic no-op. + added: catalogWritten ? state.added : 0, + catalogWritten, + cacheSynced, + comboOmissions: state.comboOmissions, + catalogQuality: committedStateApplies ? state.catalogQuality : "native-only", + rehydrated: catalogWritten ? state.rehydrated : 0, + ...(committed.kind === "stale" ? { staleReason: committed.reason } : {}), + }, }; } diff --git a/src/codex/inject.ts b/src/codex/inject.ts index e60adafae1..843c867c86 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -1,13 +1,20 @@ import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; import { atomicWriteFile, loadConfig, observeConfigGeneration, readConfigAdmissionSnapshot, + readConfigGenerationInCurrentMutationTransaction, subagentDefaultSyncEffective, + withConfigMutationLockSync, websocketsEnabled, } from "../config"; -import { CodexWriteLockSkipped, withCodexWriteLock } from "./codex-write-lock"; +import { + canonicalizeCodexHome, + CodexWriteLockSkipped, + withCodexWriteLock, +} from "./codex-write-lock"; import { shouldSyncCodexOnStart } from "./desired-state"; import { buildInjectWitness, @@ -28,13 +35,13 @@ import { resolveEffectiveUserIdentity, } from "./user-identity"; import { - markJournalInjectedState, - removeJournal, - restoreJournalState, + retireJournalForExternalProvider, + restoreJournalStateUnderCoordinatedWrite, writeJournal, } from "./journal"; import { withCatalogWriteSerialization } from "./catalog-write-serialization"; import { restoreCodexCatalogWithPermit } from "./catalog/sync"; +import { beginCodexCoordinatorTransaction } from "./transition-state"; import { isSectionMarkerLine, CCX_SECTION_MARKER, @@ -67,6 +74,11 @@ import { type ManagedSubagentDefaults, } from "./subagent-defaults"; import type { CodexCommanderConfig } from "../types"; +import type { ConfigGeneration } from "./convergence-types"; +import { + captureCatalogConfigAuthority, + type CatalogConfigAuthoritySnapshot, +} from "./catalog-admission"; // Ownership predicates live in `./injected-marker` so `journal.ts` can reach them // without importing this module back. Re-exported for existing external callers. @@ -132,6 +144,12 @@ export interface InjectCodexOptions { * is re-read, refuse before any native injection or journal mutation. */ expectedExternalProvider?: string; + /** Catalog admission generation that must still hold at native config publication. */ + expectedConfigGeneration?: ConfigGeneration; + /** Exact catalog-admitted config authority, including non-cooperating byte drift. */ + expectedConfigAuthority?: CatalogConfigAuthoritySnapshot; + /** Preserve-only fence for non-disruptive management reconciliation. */ + expectedRoutingKind?: CodexRoutingKind; } function configuredManagedSubagentDefaults( @@ -386,7 +404,9 @@ export function isCodexRoutingInjected(): boolean { } export function getCodexRoutingKind(): CodexRoutingKind { - const path = CODEX_CONFIG_PATH; + // Status/policy probes run after environment selection in embedded servers + // and isolated tests, so resolve the active home at call time. + const path = join(getCodexHome(), "config.toml"); if (!existsSync(path)) return "native"; try { return classifyCodexRouting(readFileSync(path, "utf8")); @@ -628,11 +648,25 @@ export function chooseCatalogPathForInjection( export interface CodexInjectResult { success: boolean; message: string; - status?: "skipped"; + status?: "skipped" | "stale"; skippedReason?: "desired_disabled" | "desired_enabled"; nativeSubagentDefaultsWarning?: string; } +class CodexInjectConfigGenerationStale extends Error { + constructor() { + super("CodexCommander configuration changed before native Codex config publication."); + this.name = "CodexInjectConfigGenerationStale"; + } +} + +class CodexInjectRoutingStale extends Error { + constructor() { + super("Codex routing ownership changed before native Codex config publication."); + this.name = "CodexInjectRoutingStale"; + } +} + export async function injectCodexConfig( port: number, config?: CodexCommanderConfig, @@ -646,6 +680,14 @@ export async function injectCodexConfig( } const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8"); + if (options.expectedRoutingKind !== undefined + && classifyCodexRouting(rawContent) !== options.expectedRoutingKind) { + return { + success: false, + status: "stale", + message: "Codex routing ownership changed before native Codex config publication; no files were changed.", + }; + } const activeProvider = externalCodexModelProvider(rawContent); if ( options.expectedExternalProvider !== undefined @@ -659,7 +701,7 @@ export async function injectCodexConfig( if (activeProvider) { // A launcher may have journaled before the provider manager took ownership. Never let shutdown // replay that stale snapshot over externally managed config. - removeJournal(); + retireJournalForExternalProvider(activeProvider); const nativeSubagentDefaultsWarning = configuredManagedSubagentDefaults( config, ) @@ -859,84 +901,129 @@ export async function injectCodexConfig( }; } - const applyNativeArtifacts = (): void => { + const applyNativeArtifacts = (preImages: ReturnType): void => { writeJournal({ currentStateIsNative: !hasInjectedCodexRouting(rawContent), configContent: baselineContent, + profileContent: preImages.profile, + intendedPostimage: { + config: content, + profile: profileContent, + }, }); - atomicWriteFile(CODEX_CONFIG_PATH, content); - atomicWriteFile(CODEX_PROFILE_PATH, profileContent); - markJournalInjectedState(content, profileContent); + if (preImages.config !== content) atomicWriteFile(CODEX_CONFIG_PATH, content); + if (preImages.profile !== profileContent) atomicWriteFile(CODEX_PROFILE_PATH, profileContent); }; { - const coordinated = await withCodexWriteLock( - { - timeoutMs: options.lockTimeoutMs ?? DEFAULT_INJECT_LOCK_TIMEOUT_MS, - admitted: { authoritySnapshotId: witness.comparisonId }, - readAdmissionUnderLock: () => ({ - authoritySnapshotId: recomputeInjectWitness({ - candidate: witness.candidate, - canonicalTargets: witness.evidence.canonicalTargets, - persistedIdentity, - generation, - observedOwnership: witness.observedOwnership, - }).comparisonId, - }), - }, - (ctx) => { - if (!shouldSyncCodexOnStart(loadConfig())) { - throw new CodexWriteLockSkipped("desired_disabled"); - } - /* - * Publish BEFORE touching the filesystem. `assertPublished` runs after this - * callback returns and throws unless a transition was recorded, so writing - * first would replace every file and only then fail — with SQLite rolling - * back and the filesystem staying changed. - * - * `beginTransition` returns a conflict rather than throwing, so its result - * is checked here; ignoring it would reach the same failure by a slower - * route. - */ - const published = ctx.coordinator.beginTransition( - { - nativeGeneration: ctx.expectation.nativeBefore, - currentTxId: ctx.currentTxId, - }, - { - txId: ctx.expectation.txId, - }, - ); - if (published.kind !== "updated") { - throw new CodexWriteConflictError( - `The Codex transition could not be published: ${published.kind}.`, + let coordinated; + try { + coordinated = await withCodexWriteLock( + { + timeoutMs: options.lockTimeoutMs ?? DEFAULT_INJECT_LOCK_TIMEOUT_MS, + admitted: { authoritySnapshotId: witness.comparisonId }, + readAdmissionUnderLock: () => ({ + authoritySnapshotId: recomputeInjectWitness({ + candidate: witness.candidate, + canonicalTargets: witness.evidence.canonicalTargets, + persistedIdentity, + generation, + observedOwnership: witness.observedOwnership, + }).comparisonId, + }), + }, + (ctx) => { + if (!shouldSyncCodexOnStart(loadConfig())) { + throw new CodexWriteLockSkipped("desired_disabled"); + } + if (options.expectedRoutingKind !== undefined + && classifyCodexRouting(readFileSync(CODEX_CONFIG_PATH, "utf8")) + !== options.expectedRoutingKind) { + throw new CodexInjectRoutingStale(); + } + if (options.expectedConfigAuthority) { + try { + const current = captureCatalogConfigAuthority(config ?? loadConfig()); + if ( + current.generation.value !== options.expectedConfigAuthority.generation.value + || current.semanticIdentity !== options.expectedConfigAuthority.semanticIdentity + || current.contentIdentity !== options.expectedConfigAuthority.contentIdentity + ) { + throw new CodexInjectConfigGenerationStale(); + } + } catch (error) { + if (error instanceof CodexInjectConfigGenerationStale) throw error; + throw new CodexInjectConfigGenerationStale(); + } + } else if ( + options.expectedConfigGeneration + && readConfigGenerationInCurrentMutationTransaction().value + !== options.expectedConfigGeneration.value + ) { + throw new CodexInjectConfigGenerationStale(); + } + /* + * Publish BEFORE touching the filesystem. `assertPublished` runs after this + * callback returns and throws unless a transition was recorded, so writing + * first would replace every file and only then fail — with SQLite rolling + * back and the filesystem staying changed. + * + * `beginTransition` returns a conflict rather than throwing, so its result + * is checked here; ignoring it would reach the same failure by a slower + * route. + */ + const published = ctx.coordinator.beginTransition( + { + nativeGeneration: ctx.expectation.nativeBefore, + currentTxId: ctx.currentTxId, + }, + { + txId: ctx.expectation.txId, + }, ); - } + if (published.kind !== "updated") { + throw new CodexWriteConflictError( + `The Codex transition could not be published: ${published.kind}.`, + ); + } - /* - * Exact pre-images, captured under the lock and used for compensation. - * - * A rolled-back coordinator row is not a rolled-back filesystem: each - * `atomicWriteFile` is atomic alone, never across the three together, so a - * failure partway leaves earlier replacements in place. `restoreJournalState` - * cannot be the undo — it restores whichever journal occupies the path, - * which need not be the one this operation wrote. - */ - const preImages = captureCodexPreImages(); - try { - applyNativeArtifacts(); - } catch (error) { - // Compensate, then ALWAYS throw. Returning a partial result would let the - // lock commit a row describing an apply that did not finish. - const restored = restoreCodexPreImages(preImages); - if (!restored.complete) { - throw new CodexPartialWriteError(restored.unrestored); + /* + * Exact pre-images, captured under the lock and used for compensation. + * + * A rolled-back coordinator row is not a rolled-back filesystem: each + * `atomicWriteFile` is atomic alone, never across the three together, so a + * failure partway leaves earlier replacements in place. Journal restoration + * cannot be the undo — it restores whichever journal occupies the path, + * which need not be the one this operation wrote. + */ + const preImages = captureCodexPreImages(); + try { + applyNativeArtifacts(preImages); + } catch (error) { + // Compensate, then ALWAYS throw. Returning a partial result would let the + // lock commit a row describing an apply that did not finish. + const restored = restoreCodexPreImages(preImages); + if (!restored.complete) { + throw new CodexPartialWriteError(restored.unrestored); + } + throw error; } - throw error; - } - return { kind: "applied" as const }; - }, - ); + return { kind: "applied" as const }; + }, + ); + } catch (error) { + if (error instanceof CodexInjectConfigGenerationStale + || error instanceof CodexInjectRoutingStale) { + return { + success: false, + status: "stale", + message: error instanceof CodexInjectRoutingStale + ? "Codex routing ownership changed before native Codex config publication; no stale Codex config was written. Retry the sync." + : "CodexCommander configuration changed before native Codex config publication; no stale Codex config was written. Retry the sync.", + }; + } + throw error; + } if (coordinated.status !== "acquired") { return codexInjectLockOutcome(coordinated); @@ -1190,7 +1277,7 @@ export function skippedRestoreEnvelope(success: boolean, message: string): Codex /** The config/profile half of a native restore, reported as one artifact. */ function restoreCodexConfigInline(): CodexRestoreConfigResult { try { - const journal = restoreJournalState(); + const journal = restoreJournalStateUnderCoordinatedWrite(); const restored = journal.configRestored ? { success: true, message: "Codex config restored from the CodexCommander journal." } : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged }); @@ -1207,6 +1294,99 @@ function restoreCodexConfigInline(): CodexRestoreConfigResult { } } +/** + * Synchronous shutdown callers cannot await the retrying lock helper, but they + * still participate in the same N -> C authority as injection. A missing + * coordinator over journal/residue is refused by the unchanged eligibility + * gate; there is no unlocked fallback to read/modify/write config.toml. + */ +function restoreCodexConfigCoordinatedSync( + revalidateDesiredState: boolean, +): CodexRestoreConfigResult | { skipped: true } { + const canonical = canonicalizeCodexHome(getCodexHome()); + if (!canonical.ok) { + return { + state: "failed", + changed: false, + action: "failed", + message: `Codex configuration was not restored: ${canonical.message}`, + }; + } + let coordinatorPath: string; + try { + coordinatorPath = resolveCodexCoordinatorDatabasePath( + resolveEffectiveUserIdentity(), + canonical.home.path, + ); + } catch (error) { + return { + state: "failed", + changed: false, + action: "failed", + message: error instanceof Error ? error.message : String(error), + }; + } + const eligibility = codexWriteCoordinationEligibility({ + coordinatorPath: () => coordinatorPath, + residue: () => classifyNativeRoutedResidue(), + integrationRecord: () => readIntegrationRecord(), + }); + if (eligibility.kind === "refused") { + return { + state: "failed", + changed: false, + action: "failed", + message: `Codex configuration was not restored: ${eligibility.reason}.`, + }; + } + + let transaction: ReturnType | undefined; + try { + transaction = beginCodexCoordinatorTransaction(coordinatorPath); + const expectation = transaction.expectation(); + const version = transaction.version(); + const config = withConfigMutationLockSync(() => { + if (revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) { + throw new CodexWriteLockSkipped("desired_enabled"); + } + const published = transaction!.capability.beginTransition( + { + nativeGeneration: expectation.nativeBefore, + currentTxId: version.currentTxId, + }, + { txId: expectation.txId }, + ); + if (published.kind !== "updated") { + throw new CodexWriteConflictError( + `The Codex restore transition could not be published: ${published.kind}.`, + ); + } + const preImages = captureCodexPreImages(); + try { + return restoreCodexConfigInline(); + } catch (error) { + const compensated = restoreCodexPreImages(preImages); + if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored); + throw error; + } + }); + transaction.assertPublished(expectation); + transaction.commit(); + return config; + } catch (error) { + transaction?.rollback(); + if (error instanceof CodexWriteLockSkipped) return { skipped: true }; + return { + state: "failed", + changed: false, + action: "failed", + message: error instanceof Error ? error.message : String(error), + }; + } finally { + transaction?.close(); + } +} + /** The catalog half, always inside its own K acquisition. */ function restoreCodexCatalogArtifact(revalidateDesiredState: boolean): CodexRestoreCatalogResult { const owningCodexHome = getCodexHome(); @@ -1248,7 +1428,7 @@ export async function restoreNativeCodexAsync( const activeProvider = currentExternalCodexModelProvider(); if (activeProvider) { // External-provider courtesy: only the stale journal is removed. - removeJournal(); + retireJournalForExternalProvider(activeProvider); return externalProviderRestoreResult(activeProvider); } @@ -1342,13 +1522,17 @@ export async function restoreNativeCodexAsync( export function restoreNativeCodex(options: { revalidateDesiredState?: boolean } = {}): CodexNativeRestoreResult { const activeProvider = currentExternalCodexModelProvider(); if (activeProvider) { - removeJournal(); + retireJournalForExternalProvider(activeProvider); return externalProviderRestoreResult(activeProvider); } if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) { return desiredEnabledRestoreSkip(); } - const config = restoreCodexConfigInline(); + const coordinatedConfig = restoreCodexConfigCoordinatedSync( + options.revalidateDesiredState === true, + ); + if ("skipped" in coordinatedConfig) return desiredEnabledRestoreSkip(); + const config = coordinatedConfig; const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true); const message = catalog.removed > 0 ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).` diff --git a/src/codex/journal.ts b/src/codex/journal.ts index 34d2a53023..91a7df4adc 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -1,13 +1,36 @@ import { createHash } from "node:crypto"; -import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { + existsSync, + lstatSync, + readFileSync, + realpathSync, + statSync, + unlinkSync, +} from "node:fs"; +import type { Stats } from "node:fs"; import { join } from "node:path"; -import { atomicWriteFile } from "../config"; +import { atomicWriteFile, withConfigMutationLockSync } from "../config"; +import { canonicalizeCodexHome } from "./codex-write-lock"; import { hasInjectedCodexRouting } from "./injected-marker"; +import { isOwnedProviderId } from "../identity"; +import { + classifyNativeRoutedResidueAfterJournalRestore, + classifyNativeRoutedResidue, + classifyNativeRoutedResidueWithoutJournal, + hasGeneratedCodexProfileRouting, +} from "./native-residue"; import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, + getCodexHome, } from "./paths"; +import { beginCodexCoordinatorRecoveryTransaction } from "./transition-state"; +import { resolveEffectiveProjectModelProvider } from "./project-config-warnings"; +import { + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "./user-identity"; /** * Exported so that anything reasoning ABOUT the journal points at the journal. @@ -38,10 +61,183 @@ interface RestoreJournalResult { complete: boolean; } +interface RestoreJournalAttempt { + readonly result: RestoreJournalResult; + readonly uncertain: boolean; +} + +type AuthorizeJournalMutation = () => boolean; +const authorizeUncoordinatedMutation: AuthorizeJournalMutation = () => true; + +interface JournalFileSnapshot { + readonly content: string; + readonly journal: Journal; + readonly stat: Stats; +} + +type SurfaceSnapshot = + | { readonly kind: "absent" } + | { + readonly kind: "file"; + readonly content: string; + readonly entryStat: Stats; + readonly targetPath: string; + readonly targetStat: Stats; + readonly symbolicLink: boolean; + }; + +export interface ReconcileJournalOptions { + /** Test-only race barrier before recovery starts from the captured journal. */ + beforeRecoveryRevalidation?: () => void; + /** Test-only race barrier immediately before a config recovery mutation. */ + beforeConfigMutationRevalidation?: () => void; + /** Test-only race barrier immediately before a profile recovery mutation. */ + beforeProfileMutationRevalidation?: () => void; + /** Test-only race barrier immediately before the final identity revalidation. */ + beforeRetireRevalidation?: () => void; +} + function sha256(content: string | null): string | null { return content === null ? null : createHash("sha256").update(content).digest("hex"); } +function validJournalShape(value: unknown): value is Journal { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const journal = value as Record; + return journal.version === 1 + && typeof journal.originalConfig === "string" + && (journal.originalProfile === null || typeof journal.originalProfile === "string") + && (journal.injectedConfigHash === undefined || typeof journal.injectedConfigHash === "string") + && (journal.injectedProfileHash === undefined + || journal.injectedProfileHash === null + || typeof journal.injectedProfileHash === "string") + && typeof journal.pid === "number" + && Number.isSafeInteger(journal.pid) + && journal.pid > 0 + && typeof journal.timestamp === "string"; +} + +function sameFileIdentity(left: Stats, right: Stats): boolean { + return left.dev === right.dev + && left.ino === right.ino + && left.size === right.size + && left.mtimeMs === right.mtimeMs + && left.ctimeMs === right.ctimeMs; +} + +/** + * Stronger than `readJournal`: stale retirement is destructive, so it accepts + * only one stable regular file with the complete known shape and retains its + * exact bytes + filesystem identity for the final compare-before-unlink. + */ +function readJournalFileSnapshot(): JournalFileSnapshot | null { + try { + const before = lstatSync(JOURNAL_PATH); + if (!before.isFile()) return null; + const content = readFileSync(JOURNAL_PATH, "utf-8"); + const after = lstatSync(JOURNAL_PATH); + if (!sameFileIdentity(before, after)) return null; + const parsed: unknown = JSON.parse(content); + if (!validJournalShape(parsed)) return null; + return { content, journal: parsed, stat: after }; + } catch { + return null; + } +} + +function sameJournalFile( + expected: JournalFileSnapshot, + current: JournalFileSnapshot, +): boolean { + return expected.content === current.content + && sameFileIdentity(expected.stat, current.stat); +} + +function errorCode(error: unknown): string | undefined { + return (error as NodeJS.ErrnoException | undefined)?.code; +} + +/** + * Stable symlink-aware observation used as the compare-before-mutate witness. + * Both the directory entry and its canonical target are bound. Recovery later + * writes the captured target path rather than resolving a potentially replaced + * link a second time. + */ +function readSurfaceSnapshot(path: string): SurfaceSnapshot | null { + try { + const entryBefore = lstatSync(path); + if (!entryBefore.isFile() && !entryBefore.isSymbolicLink()) return null; + const symbolicLink = entryBefore.isSymbolicLink(); + // Always canonicalize: the leaf can be regular while CODEX_HOME or another + // parent component is a symlink, and that parent identity is equally part + // of the write authority. + const targetPath = realpathSync.native(path); + const targetBefore = statSync(targetPath); + if (!targetBefore.isFile()) return null; + const content = readFileSync(targetPath, "utf-8"); + const targetAfter = statSync(targetPath); + const entryAfter = lstatSync(path); + if ( + !sameFileIdentity(entryBefore, entryAfter) + || !sameFileIdentity(targetBefore, targetAfter) + || realpathSync.native(path) !== targetPath + ) return null; + return { + kind: "file", + content, + entryStat: entryAfter, + targetPath, + targetStat: targetAfter, + symbolicLink, + }; + } catch (error) { + return errorCode(error) === "ENOENT" ? { kind: "absent" } : null; + } +} + +function sameSurfaceSnapshot( + expected: SurfaceSnapshot, + current: SurfaceSnapshot, +): boolean { + if (expected.kind === "absent" || current.kind === "absent") { + return expected.kind === current.kind; + } + return expected.content === current.content + && expected.targetPath === current.targetPath + && expected.symbolicLink === current.symbolicLink + && sameFileIdentity(expected.entryStat, current.entryStat) + && sameFileIdentity(expected.targetStat, current.targetStat); +} + +function surfaceValue( + snapshot: SurfaceSnapshot, + absentValue: "" | null, +): string | null { + return snapshot.kind === "file" ? snapshot.content : absentValue; +} + +function journalOwnerIsProvenDead(pid: number): boolean { + if (!Number.isSafeInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return false; + } catch (error) { + // EPERM is positive evidence that a process exists but is not signalable. + // Every error except the platform's explicit "no such process" stays + // unknown and therefore retains the recovery journal. + return (error as NodeJS.ErrnoException).code === "ESRCH"; + } +} + +function reportJournalRestore(): void { + console.error("⚠️ A previous Codex session did not shut down cleanly. Codex state was restored from its recovery journal."); +} + +function incompleteRecoveryResult(restoredAny: boolean): boolean { + if (restoredAny) reportJournalRestore(); + return restoredAny; +} + export interface WriteJournalOptions { /** * The caller's verdict on the config it is about to transform: false when @@ -57,6 +253,17 @@ export interface WriteJournalOptions { * another process rewrites config.toml mid-flight. */ configContent?: string; + /** Exact profile preimage captured by the coordinated writer (`null` = absent). */ + profileContent?: string | null; + /** + * Intended native postimages. New journals persist these hashes BEFORE the + * first native write, so a crash never leaves an authority-free recovery + * record. Omission exists only for reading/writing legacy fixtures. + */ + intendedPostimage?: { + config: string; + profile: string | null; + }; } /** @@ -70,7 +277,7 @@ export interface WriteJournalOptions { * so an unclean shutdown days later replays a day-one config over the user's * plugins, model choice, and trusted projects. */ -export function writeJournal(options: WriteJournalOptions = {}): void { +function writeJournalUnlocked(options: WriteJournalOptions): void { if (!existsSync(CODEX_CONFIG_PATH)) return; const config = options.configContent ?? readFileSync(CODEX_CONFIG_PATH, "utf-8"); // Ownership is decided HERE, from the bytes about to be journaled — never taken @@ -80,99 +287,619 @@ export function writeJournal(options: WriteJournalOptions = {}): void { // The caller's verdict only authorizes REPLACEMENT. It is weaker evidence than // the check above (it may describe bytes read a moment earlier), so an // unclassified call creates a first snapshot but never overwrites one. - if (existsSync(JOURNAL_PATH) && readJournal() && options.currentStateIsNative !== true) return; - const profile = existsSync(CODEX_PROFILE_PATH) - ? readFileSync(CODEX_PROFILE_PATH, "utf-8") - : null; + if (existsSync(JOURNAL_PATH)) { + // An unreadable, replaced, or unknown journal is authority, not garbage. + // Never erase it while trying to establish a newer snapshot. + if (!readJournalFileSnapshot() || options.currentStateIsNative !== true) return; + } + const profile = Object.hasOwn(options, "profileContent") + ? options.profileContent ?? null + : existsSync(CODEX_PROFILE_PATH) + ? readFileSync(CODEX_PROFILE_PATH, "utf-8") + : null; const journal: Journal = { version: 1, originalConfig: Buffer.from(config).toString("base64"), - originalProfile: profile ? Buffer.from(profile).toString("base64") : null, + originalProfile: profile === null ? null : Buffer.from(profile).toString("base64"), + ...(options.intendedPostimage + ? { + injectedConfigHash: sha256(options.intendedPostimage.config) ?? undefined, + injectedProfileHash: sha256(options.intendedPostimage.profile), + } + : {}), pid: process.pid, timestamp: new Date().toISOString(), }; atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal)); } -export function markJournalInjectedState(config: string, profile: string | null): void { - const journal = readJournal(); - if (!journal) return; - if (journal.injectedConfigHash) return; - journal.injectedConfigHash = sha256(config) ?? undefined; - journal.injectedProfileHash = sha256(profile); - atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal)); +export function writeJournal(options: WriteJournalOptions = {}): void { + withConfigMutationLockSync(() => writeJournalUnlocked(options)); +} + +const EMPTY_RESTORE: RestoreJournalResult = { + configRestored: false, + profileRestored: false, + configChanged: false, + profileChanged: false, + complete: false, +}; + +function journalAuthorityStillMatches( + expected: JournalFileSnapshot, + requireDeadOwner: boolean, +): boolean { + const current = readJournalFileSnapshot(); + return current !== null + && sameJournalFile(expected, current) + && (!requireDeadOwner || journalOwnerIsProvenDead(current.journal.pid)); +} + +function profileIsLegacyOwned(snapshot: SurfaceSnapshot): boolean { + return snapshot.kind === "file" && hasGeneratedCodexProfileRouting(snapshot.content); +} + +function restoreJournalStateFromSnapshot( + expected: JournalFileSnapshot, + options: ReconcileJournalOptions, + requireDeadOwner: boolean, + authorizeMutation: AuthorizeJournalMutation, +): RestoreJournalAttempt { + const journal = expected.journal; + const configBefore = readSurfaceSnapshot(CODEX_CONFIG_PATH); + const profileBefore = readSurfaceSnapshot(CODEX_PROFILE_PATH); + if (!configBefore || !profileBefore) { + return { + result: { ...EMPTY_RESTORE, configChanged: true, profileChanged: true }, + uncertain: true, + }; + } + + const originalConfig = Buffer.from(journal.originalConfig, "base64").toString("utf-8"); + const originalProfile = journal.originalProfile === null + ? null + : Buffer.from(journal.originalProfile, "base64").toString("utf-8"); + const currentConfig = surfaceValue(configBefore, "") as string; + const currentProfile = surfaceValue(profileBefore, null); + const configAlreadyOriginal = currentConfig === originalConfig; + const profileAlreadyOriginal = currentProfile === originalProfile; + const configHasExactPostimage = journal.injectedConfigHash !== undefined + && sha256(currentConfig) === journal.injectedConfigHash; + const profileHasExactPostimage = journal.injectedProfileHash !== undefined + && sha256(currentProfile) === journal.injectedProfileHash; + // A legacy config.toml is a mixed user/CCX surface: even when routing is + // recognizable, a post-injection user edit cannot be distinguished from the + // intended write without the missing hash. Never byte-replay it. The dedicated + // profile file, by contrast, may recover when its complete generated shape is + // provably CCX-owned. + const configMayRestore = configAlreadyOriginal + || configHasExactPostimage; + const profileMayRestore = profileAlreadyOriginal + || profileHasExactPostimage + || (journal.injectedProfileHash === undefined && profileIsLegacyOwned(profileBefore)); + + let configRestored = configAlreadyOriginal; + let profileRestored = profileAlreadyOriginal; + let configChanged = !configMayRestore; + let profileChanged = !profileMayRestore; + + if (!configRestored && configMayRestore) { + if (!authorizeMutation()) { + configChanged = true; + return { + result: { + configRestored, + profileRestored, + configChanged, + profileChanged, + complete: false, + }, + uncertain: true, + }; + } + options.beforeConfigMutationRevalidation?.(); + const current = readSurfaceSnapshot(CODEX_CONFIG_PATH); + if ( + !current + || !sameSurfaceSnapshot(configBefore, current) + || !journalAuthorityStillMatches(expected, requireDeadOwner) + ) { + configChanged = true; + return { + result: { + configRestored, + profileRestored, + configChanged, + profileChanged, + complete: false, + }, + uncertain: true, + }; + } + // Use the target captured by the just-revalidated snapshot. Resolving the + // logical link again inside the mutation would reopen a link-swap race. + if (current.kind !== "file") { + configChanged = true; + return { + result: { + configRestored, + profileRestored, + configChanged, + profileChanged, + complete: false, + }, + uncertain: true, + }; + } + atomicWriteFile(current.targetPath, originalConfig); + const written = readSurfaceSnapshot(CODEX_CONFIG_PATH); + configRestored = written?.kind === "file" && written.content === originalConfig; + configChanged = !configRestored; + } + + if (!profileRestored && profileMayRestore) { + if (!authorizeMutation()) { + profileChanged = true; + return { + result: { + configRestored, + profileRestored, + configChanged, + profileChanged, + complete: false, + }, + uncertain: true, + }; + } + options.beforeProfileMutationRevalidation?.(); + const current = readSurfaceSnapshot(CODEX_PROFILE_PATH); + if ( + !current + || !sameSurfaceSnapshot(profileBefore, current) + || !journalAuthorityStillMatches(expected, requireDeadOwner) + ) { + profileChanged = true; + return { + result: { + configRestored, + profileRestored, + configChanged, + profileChanged, + complete: false, + }, + uncertain: true, + }; + } + if (originalProfile !== null) { + atomicWriteFile( + current.kind === "file" ? current.targetPath : CODEX_PROFILE_PATH, + originalProfile, + ); + } else if (current.kind === "file") { + // An absent profile preimage can authorize removing only the regular file + // CodexCommander created. A later same-content symlink is user authority. + if (current.symbolicLink) { + profileChanged = true; + return { + result: { + configRestored, + profileRestored, + configChanged, + profileChanged, + complete: false, + }, + uncertain: true, + }; + } + try { + unlinkSync(CODEX_PROFILE_PATH); + } catch { + profileChanged = true; + return { + result: { + configRestored, + profileRestored, + configChanged, + profileChanged, + complete: false, + }, + uncertain: true, + }; + } + } + const written = readSurfaceSnapshot(CODEX_PROFILE_PATH); + profileRestored = written !== null + && surfaceValue(written, null) === originalProfile; + profileChanged = !profileRestored; + } + + return { + result: { + configRestored, + profileRestored, + configChanged, + profileChanged, + complete: configRestored && profileRestored, + }, + uncertain: false, + }; } -export function removeJournal(): void { - try { unlinkSync(JOURNAL_PATH); } catch { /* ignore */ } +function retireExpectedJournal( + expected: JournalFileSnapshot, + options: ReconcileJournalOptions, + requireDeadOwner: boolean, + requireCleanNativeSurfaces: boolean, + trustRestoredConfigAndProfile: boolean, + authorizeMutation: AuthorizeJournalMutation, +): boolean { + const classifyRemaining = trustRestoredConfigAndProfile + ? classifyNativeRoutedResidueAfterJournalRestore + : classifyNativeRoutedResidueWithoutJournal; + if (requireCleanNativeSurfaces) { + let classified; + try { + classified = classifyRemaining(); + } catch { + return false; + } + if (classified.kind !== "clean") return false; + } + + const configBefore = readSurfaceSnapshot(CODEX_CONFIG_PATH); + const profileBefore = readSurfaceSnapshot(CODEX_PROFILE_PATH); + if (!configBefore || !profileBefore) return false; + if (trustRestoredConfigAndProfile) { + const originalConfig = Buffer.from(expected.journal.originalConfig, "base64").toString("utf-8"); + const originalProfile = expected.journal.originalProfile === null + ? null + : Buffer.from(expected.journal.originalProfile, "base64").toString("utf-8"); + if ( + surfaceValue(configBefore, "") !== originalConfig + || surfaceValue(profileBefore, null) !== originalProfile + ) return false; + } + if (!authorizeMutation()) return false; + options.beforeRetireRevalidation?.(); + + // Re-observe every native surface after the race seam. The second full + // classifier catches catalog/cache/temp residue, while exact config/profile + // snapshots prevent a clean editor write from being mistaken for the state + // that was just authorized. + if (requireCleanNativeSurfaces) { + let classified; + try { + classified = classifyRemaining(); + } catch { + return false; + } + if (classified.kind !== "clean") return false; + } + const configFinal = readSurfaceSnapshot(CODEX_CONFIG_PATH); + const profileFinal = readSurfaceSnapshot(CODEX_PROFILE_PATH); + if ( + !configFinal + || !profileFinal + || !sameSurfaceSnapshot(configBefore, configFinal) + || !sameSurfaceSnapshot(profileBefore, profileFinal) + || !journalAuthorityStillMatches(expected, requireDeadOwner) + ) return false; + + try { + unlinkSync(JOURNAL_PATH); + return true; + } catch { + return false; + } } -function readJournal(): Journal | null { - if (!existsSync(JOURNAL_PATH)) return null; +/** + * Config/profile half of an explicit restore. Production callers MUST already + * hold N and publish their transition; this helper adds/reuses C and performs + * exact journal/surface CAS. It is deliberately named as an under-lock + * primitive so it cannot be mistaken for a standalone recovery entry point. + */ +export function restoreJournalStateUnderCoordinatedWrite(): RestoreJournalResult { try { - const journal = JSON.parse(readFileSync(JOURNAL_PATH, "utf-8")) as Journal; - if (journal.version !== 1) throw new Error("unknown version"); - return journal; + return withConfigMutationLockSync(() => { + const expected = readJournalFileSnapshot(); + if (!expected) return EMPTY_RESTORE; + const attempt = restoreJournalStateFromSnapshot( + expected, + {}, + false, + authorizeUncoordinatedMutation, + ); + if (!attempt.uncertain && attempt.result.complete) { + retireExpectedJournal( + expected, + {}, + false, + false, + false, + authorizeUncoordinatedMutation, + ); + } + return attempt.result; + }); } catch { - removeJournal(); - return null; + return EMPTY_RESTORE; } } -export function restoreJournalState(): RestoreJournalResult { - const journal = readJournal(); - if (!journal) { - return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false, complete: false }; +function reconcileJournalUnderMutationLock( + options: ReconcileJournalOptions, + authorizeMutation: AuthorizeJournalMutation = authorizeUncoordinatedMutation, +): boolean { + const expected = readJournalFileSnapshot(); + if (!expected || !journalOwnerIsProvenDead(expected.journal.pid)) return false; + const configAtStart = readSurfaceSnapshot(CODEX_CONFIG_PATH); + const profileAtStart = readSurfaceSnapshot(CODEX_PROFILE_PATH); + + // Recovery always gets first claim. A surface still equal to the journal's + // injected postimage is restored exactly as before; retirement is considered + // only for the valid journal left behind by a partial/no-op restore. + options.beforeRecoveryRevalidation?.(); + const beforeRestore = readJournalFileSnapshot(); + if ( + !beforeRestore + || !sameJournalFile(expected, beforeRestore) + || !journalOwnerIsProvenDead(beforeRestore.journal.pid) + ) return false; + // Use the captured, revalidated journal rather than re-reading the path. A + // concurrent replacement can therefore be retained, never restored/deleted + // as though it were the dead owner's recovery record. + const attempt = restoreJournalStateFromSnapshot( + expected, + options, + true, + authorizeMutation, + ); + const configAfterRestore = readSurfaceSnapshot(CODEX_CONFIG_PATH); + const profileAfterRestore = readSurfaceSnapshot(CODEX_PROFILE_PATH); + const restoredAny = configAtStart !== null + && profileAtStart !== null + && configAfterRestore !== null + && profileAfterRestore !== null + && ( + !sameSurfaceSnapshot(configAtStart, configAfterRestore) + || !sameSurfaceSnapshot(profileAtStart, profileAfterRestore) + ); + + // A replacement during normal recovery is a new authority. Do not use an + // observation made for the old journal to remove it. + const afterRestore = readJournalFileSnapshot(); + if (!afterRestore || !sameJournalFile(expected, afterRestore)) { + return incompleteRecoveryResult(restoredAny); } - const currentConfig = existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, "utf-8") : ""; - const currentProfile = existsSync(CODEX_PROFILE_PATH) - ? readFileSync(CODEX_PROFILE_PATH, "utf-8") - : null; - const configUnchanged = !journal.injectedConfigHash || sha256(currentConfig) === journal.injectedConfigHash; - const profileUnchanged = journal.injectedProfileHash === undefined || sha256(currentProfile) === (journal.injectedProfileHash ?? null); - - let configRestored = false; - let profileRestored = false; - if (configUnchanged) { - atomicWriteFile(CODEX_CONFIG_PATH, Buffer.from(journal.originalConfig, "base64").toString("utf-8")); - configRestored = true; - } - if (profileUnchanged) { - if (journal.originalProfile !== null) { - atomicWriteFile(CODEX_PROFILE_PATH, Buffer.from(journal.originalProfile, "base64").toString("utf-8")); - } else { - try { if (existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH); } catch { /* ignore */ } + if (attempt.uncertain) { + return incompleteRecoveryResult(restoredAny); + } + + // Ignore only the already-observed journal itself. Journal atomic-write temp + // files and every config/profile/catalog/cache uncertainty remain blockers, + // including after a byte-exact config/profile restore completed. + if (!retireExpectedJournal( + expected, + options, + true, + true, + attempt.result.complete, + authorizeMutation, + )) { + return incompleteRecoveryResult(restoredAny); + } + + if (restoredAny) { + reportJournalRestore(); + } else { + console.error("⚠️ A detached Codex recovery journal was retired after current native state was verified."); + } + return true; +} + +function coordinatorTargetForCurrentHome(): { readonly path: string } | null { + try { + const canonical = canonicalizeCodexHome(getCodexHome()); + if (!canonical.ok) return null; + return { + path: resolveCodexCoordinatorDatabasePath( + resolveEffectiveUserIdentity(), + canonical.home.path, + ), + }; + } catch { + return null; + } +} + +/** Formal N -> C serialization and generation publication, including bootstrap recovery. */ +function mutateJournalWithCoordinator( + coordinatorPath: string, + revalidateRecoveryAdmission: () => boolean, + validateSettledRecovery: () => boolean, + mutation: (authorizeMutation: AuthorizeJournalMutation) => boolean, +): boolean { + let transaction: ReturnType | undefined; + try { + transaction = beginCodexCoordinatorRecoveryTransaction( + coordinatorPath, + revalidateRecoveryAdmission, + validateSettledRecovery, + ); + const expectation = transaction.expectation(); + const version = transaction.version(); + const attempt = withConfigMutationLockSync(() => { + let published = false; + const authorizeMutation = (): boolean => { + if (published) return true; + const result = transaction!.capability.beginTransition( + { + nativeGeneration: expectation.nativeBefore, + currentTxId: version.currentTxId, + }, + { txId: expectation.txId }, + ); + published = result.kind === "updated"; + return published; + }; + try { + return { result: mutation(authorizeMutation), published }; + } catch { + // Atomic replacement failures can leave a hardened temp artifact. The + // journal remains, but the coordinator must still record that a native + // mutation may have occurred rather than rolling authority backwards. + return { result: false, published }; + } + }); + + if (!attempt.published) { + transaction.rollback(); + return attempt.result; } - profileRestored = true; + transaction.assertPublished(expectation); + transaction.commit(); + return attempt.result; + } catch { + transaction?.rollback(); + return false; + } finally { + transaction?.close(); } - const complete = configRestored && profileRestored; - if (complete) removeJournal(); - return { - configRestored, - profileRestored, - configChanged: !configUnchanged, - profileChanged: !profileUnchanged, - complete, - }; } -export function restoreJournal(): boolean { - return restoreJournalState().complete; +export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean { + // Avoid creating the config-mutation database on the overwhelmingly common + // no-journal path. This observation grants no authority; all evidence used + // for recovery is freshly captured after C is held. + if (!existsSync(JOURNAL_PATH)) return false; + const preliminary = readJournalFileSnapshot(); + if (!preliminary || !journalOwnerIsProvenDead(preliminary.journal.pid)) return false; + const preliminaryConfig = readSurfaceSnapshot(CODEX_CONFIG_PATH); + const preliminaryProfile = readSurfaceSnapshot(CODEX_PROFILE_PATH); + if (!preliminaryConfig || !preliminaryProfile) return false; + const coordinator = coordinatorTargetForCurrentHome(); + if (!coordinator) return false; + return mutateJournalWithCoordinator( + coordinator.path, + () => { + const journal = readJournalFileSnapshot(); + const config = readSurfaceSnapshot(CODEX_CONFIG_PATH); + const profile = readSurfaceSnapshot(CODEX_PROFILE_PATH); + return journal !== null + && sameJournalFile(preliminary, journal) + && journalOwnerIsProvenDead(journal.journal.pid) + && config !== null + && sameSurfaceSnapshot(preliminaryConfig, config) + && profile !== null + && sameSurfaceSnapshot(preliminaryProfile, profile); + }, + () => { + if (existsSync(JOURNAL_PATH)) return false; + const config = readSurfaceSnapshot(CODEX_CONFIG_PATH); + const profile = readSurfaceSnapshot(CODEX_PROFILE_PATH); + if (!config || !profile) return false; + const originalConfig = Buffer.from(preliminary.journal.originalConfig, "base64").toString("utf-8"); + const originalProfile = preliminary.journal.originalProfile === null + ? null + : Buffer.from(preliminary.journal.originalProfile, "base64").toString("utf-8"); + const restoredOriginals = surfaceValue(config, "") === originalConfig + && surfaceValue(profile, null) === originalProfile; + const classified = restoredOriginals + ? classifyNativeRoutedResidueAfterJournalRestore() + : classifyNativeRoutedResidue(); + return classified.kind === "clean"; + }, + authorizeMutation => reconcileJournalUnderMutationLock(options, authorizeMutation), + ); } -export function reconcileJournal(): boolean { - const journal = readJournal(); - if (!journal) return false; +function externalProviderFromConfig(content: string): string | null { + const provider = resolveEffectiveProjectModelProvider(content).provider; + return provider && provider !== "openai" && !isOwnedProviderId(provider) + ? provider + : null; +} + +function retireExternalProviderJournalUnderMutationLock( + expectedProvider: string, + options: ReconcileJournalOptions, + authorizeMutation: AuthorizeJournalMutation, +): boolean { + const expectedJournal = readJournalFileSnapshot(); + const configBefore = readSurfaceSnapshot(CODEX_CONFIG_PATH); + if ( + !expectedJournal + || !configBefore + || configBefore.kind !== "file" + || externalProviderFromConfig(configBefore.content) !== expectedProvider + ) return false; + + if (!authorizeMutation()) return false; + options.beforeRetireRevalidation?.(); + const configFinal = readSurfaceSnapshot(CODEX_CONFIG_PATH); + const journalFinal = readJournalFileSnapshot(); + if ( + !configFinal + || configFinal.kind !== "file" + || !sameSurfaceSnapshot(configBefore, configFinal) + || externalProviderFromConfig(configFinal.content) !== expectedProvider + || !journalFinal + || !sameJournalFile(expectedJournal, journalFinal) + ) return false; try { - process.kill(journal.pid, 0); + unlinkSync(JOURNAL_PATH); + return true; + } catch { return false; - } catch (e: unknown) { - if ((e as NodeJS.ErrnoException).code === "EPERM") { - return false; - } } - const restored = restoreJournalState(); - if (!restored.configRestored && !restored.profileRestored) return false; - console.error(`⚠️ Previous session (PID ${journal.pid}) did not shut down cleanly. Codex state restored from journal.`); - return true; +} + +/** + * Retire a stale journal only while the exact external-provider config that + * superseded it is stable under recovery N -> C. A changed provider, replaced + * journal, residue, or contention retains the authority record. + */ +export function retireJournalForExternalProvider( + expectedProvider: string, + options: ReconcileJournalOptions = {}, +): boolean { + if (!expectedProvider || !existsSync(JOURNAL_PATH)) return false; + const preliminaryJournal = readJournalFileSnapshot(); + const preliminaryConfig = readSurfaceSnapshot(CODEX_CONFIG_PATH); + if ( + !preliminaryJournal + || !preliminaryConfig + || preliminaryConfig.kind !== "file" + || externalProviderFromConfig(preliminaryConfig.content) !== expectedProvider + ) return false; + const coordinator = coordinatorTargetForCurrentHome(); + if (!coordinator) return false; + return mutateJournalWithCoordinator( + coordinator.path, + () => { + const journal = readJournalFileSnapshot(); + const config = readSurfaceSnapshot(CODEX_CONFIG_PATH); + return journal !== null + && sameJournalFile(preliminaryJournal, journal) + && config !== null + && config.kind === "file" + && sameSurfaceSnapshot(preliminaryConfig, config) + && externalProviderFromConfig(config.content) === expectedProvider; + }, + () => { + if (existsSync(JOURNAL_PATH)) return false; + const config = readSurfaceSnapshot(CODEX_CONFIG_PATH); + return config !== null + && config.kind === "file" + && sameSurfaceSnapshot(preliminaryConfig, config) + && externalProviderFromConfig(config.content) === expectedProvider + && classifyNativeRoutedResidueWithoutJournal().kind === "clean"; + }, + authorizeMutation => retireExternalProviderJournalUnderMutationLock( + expectedProvider, + options, + authorizeMutation, + ), + ); } diff --git a/src/codex/management-convergence.ts b/src/codex/management-convergence.ts index 598553a12b..05a5d04509 100644 --- a/src/codex/management-convergence.ts +++ b/src/codex/management-convergence.ts @@ -1,6 +1,11 @@ import type { CodexCommanderConfig } from "../types"; -import { captureCatalogAdmissionSnapshot } from "./catalog-admission"; +import { + captureCatalogAdmissionSnapshot, + CatalogAdmissionStaleConfigError, +} from "./catalog-admission"; import { convergeCodexCatalog } from "./convergence"; +import { primeBundledCatalogForGatherIfNeeded } from "./catalog/bundled"; +import { codexCatalogWritePolicy } from "./management-write-policy"; import type { CatalogDisposition, CatalogOnlyOutcome, @@ -42,6 +47,9 @@ function unexpectedCatalogFailure(commitBegan: boolean): CatalogDisposition { } function admissionFailure(error: unknown): CatalogDisposition { + if (error instanceof CatalogAdmissionStaleConfigError) { + return { status: "skipped", reason: "stale", retryable: true }; + } const message = error instanceof Error ? error.message : ""; if (message.includes("config generation is busy") || message.includes("config generation is database")) { return { status: "skipped", reason: "busy", retryable: true }; @@ -79,6 +87,20 @@ export function createManagementConvergeCodex( catalogRefresh: unexpectedCatalogFailure(false), }); } + // The source-prime step may persist runtime evidence, so automatic Save + // ownership is decided before admission or any priming side effect. The + // same central policy is re-evaluated by convergence at commit time. + if (!codexCatalogWritePolicy(retainedConfig, request).allowed) { + return projectCatalogOnlyOutcome({ + changed: false, + catalogRefresh: { status: "skipped", reason: "refused", retryable: false }, + }); + } + // Fail unavailable/stale config authority before any potentially slow + // runtime probe. Priming may add persisted runtime evidence, so discard + // this preflight and capture the admitted snapshot again afterwards. + captureCatalogAdmissionSnapshot(retainedConfig); + primeBundledCatalogForGatherIfNeeded(); const snapshot = captureCatalogAdmissionSnapshot(retainedConfig); const result = await convergeCodexCatalog(snapshot, request, { onCommitBegin: () => { commitBegan = true; }, diff --git a/src/codex/management-native-defaults.ts b/src/codex/management-native-defaults.ts new file mode 100644 index 0000000000..92b559f60e --- /dev/null +++ b/src/codex/management-native-defaults.ts @@ -0,0 +1,58 @@ +import type { CodexCommanderConfig } from "../types"; +import type { CatalogConfigAuthoritySnapshot } from "./catalog-admission"; +import { injectCodexConfig } from "./inject"; +import { nonDisruptiveCodexManagementWritePolicy } from "./management-write-policy"; + +export type ManagementNativeDefaultsReconcileResult = + | { + readonly status: "reconciled"; + readonly warning?: string; + } + | { + readonly status: "skipped"; + readonly reason: "integration-disabled" | "routing-not-owned"; + } + | { + readonly status: "failed"; + readonly retryable: boolean; + readonly message: string; + }; + +/** + * Re-run the canonical native injector only for an already-owned integration. + * This updates marker-owned config/profile bytes without catalog discovery and + * deliberately performs no worker signaling. + */ +export async function reconcileManagementNativeSubagentDefaults( + config: CodexCommanderConfig, + authority?: CatalogConfigAuthoritySnapshot, +): Promise { + const policy = nonDisruptiveCodexManagementWritePolicy(config); + if (!policy.allowed) return { status: "skipped", reason: policy.reason }; + + const result = await injectCodexConfig(config.port ?? 10100, config, { + expectedRoutingKind: "codexcommander-local", + ...(authority ? { expectedConfigAuthority: authority } : {}), + }); + if (result.success && result.status !== "skipped") { + return { + status: "reconciled", + ...(result.nativeSubagentDefaultsWarning + ? { warning: result.nativeSubagentDefaultsWarning } + : {}), + }; + } + if (result.success && result.status === "skipped") { + return { + status: "skipped", + reason: result.skippedReason === "desired_disabled" + ? "integration-disabled" + : "routing-not-owned", + }; + } + return { + status: "failed", + retryable: "retryable" in result && result.retryable === true, + message: result.message, + }; +} diff --git a/src/codex/management-write-policy.ts b/src/codex/management-write-policy.ts new file mode 100644 index 0000000000..4b0375a810 --- /dev/null +++ b/src/codex/management-write-policy.ts @@ -0,0 +1,56 @@ +import type { CodexCommanderConfig } from "../types"; +import { codexIntegrationEnabled } from "./desired-state"; +import { getCodexRoutingKind, type CodexRoutingKind } from "./inject"; +import type { ConvergeRequest } from "./convergence-types"; + +export type NonDisruptiveCodexManagementWritePolicy = + | { readonly allowed: true; readonly routingKind: "codexcommander-local" } + | { + readonly allowed: false; + readonly reason: "integration-disabled" | "routing-not-owned"; + readonly routingKind: CodexRoutingKind; + }; + +/** + * Automatic management Saves may maintain only an integration they already + * own. Adoption of native routing is reserved for the separately confirmed + * full Apply path, and external/custom routing is never adopted here. + */ +export function nonDisruptiveCodexManagementWritePolicy( + config: Pick, + routingKind: CodexRoutingKind = getCodexRoutingKind(), +): NonDisruptiveCodexManagementWritePolicy { + if (!codexIntegrationEnabled(config)) { + return { allowed: false, reason: "integration-disabled", routingKind }; + } + if (routingKind !== "codexcommander-local") { + return { allowed: false, reason: "routing-not-owned", routingKind }; + } + return { allowed: true, routingKind }; +} + +export function isNonDisruptiveManagementCatalogRequest( + request: ConvergeRequest, +): boolean { + return request.action === "converge" + && request.scope === "catalog" + && request.reason === "management-mutation" + && request.mode === "automatic"; +} + +/** A single policy projection shared by preflight and the commit-time fence. */ +export function codexCatalogWritePolicy( + config: Pick, + request: ConvergeRequest, + routingKind: CodexRoutingKind = getCodexRoutingKind(), +): { readonly allowed: true; readonly requiresManagedRouting: boolean } + | (Extract + & { readonly requiresManagedRouting: true }) { + if (!isNonDisruptiveManagementCatalogRequest(request)) { + return { allowed: true, requiresManagedRouting: false }; + } + const policy = nonDisruptiveCodexManagementWritePolicy(config, routingKind); + return policy.allowed + ? { allowed: true, requiresManagedRouting: true } + : { ...policy, requiresManagedRouting: true }; +} diff --git a/src/codex/native-residue.ts b/src/codex/native-residue.ts index 95239656fe..05f094787a 100644 --- a/src/codex/native-residue.ts +++ b/src/codex/native-residue.ts @@ -246,13 +246,18 @@ function inspectConfig(codexHome: string, path: string): ConfigObservation { return { classification, catalogTargets: targets }; } +/** True only for the two complete profile shapes emitted by CodexCommander. */ +export function hasGeneratedCodexProfileRouting(content: string): boolean { + const generatedFallback = content.startsWith("# CodexCommander proxy fallback config (Design B)") + && rootTomlString(content, "openai_base_url") !== null; + const generatedNamedProfile = content.startsWith("# CodexCommander proxy profile — use with:") + && hasInjectedCodexRouting(content); + return generatedFallback || generatedNamedProfile; +} + function classifyProfile(path: string): NativeRoutedResidueResult { return classifyToml("profile", path, content => { - const generatedFallback = content.startsWith("# CodexCommander proxy fallback config (Design B)") - && rootTomlString(content, "openai_base_url") !== null; - const generatedNamedProfile = content.startsWith("# CodexCommander proxy profile — use with:") - && hasInjectedCodexRouting(content); - if (generatedFallback || generatedNamedProfile) return "residue"; + if (hasGeneratedCodexProfileRouting(content)) return "residue"; return "indeterminate"; }); } @@ -343,8 +348,10 @@ function classifyPartialWrites(targetPaths: string[]): NativeRoutedResidueResult return { kind: "clean" }; } -/** Read-only, fail-closed observation of every CodexCommander-routed Codex surface. */ -export function classifyNativeRoutedResidue(): NativeRoutedResidueResult { +function classifyNativeRoutedResidueIncluding( + includeJournal: boolean, + includeConfigAndProfile = true, +): NativeRoutedResidueResult { let codexHome: string; try { codexHome = getCodexHome(); @@ -367,14 +374,44 @@ export function classifyNativeRoutedResidue(): NativeRoutedResidueResult { ]; const classifiers = [ () => classifyPartialWrites(atomicWriteTargets), - () => config.classification, - () => classifyProfile(profilePath), + ...(includeConfigAndProfile + ? [() => config.classification, () => classifyProfile(profilePath)] + : []), ...config.catalogTargets.map(target => () => classifyCatalogLike("catalog", target.path, target.configured)), () => classifyCatalogLike("models-cache", modelsCachePath), - () => classifyJournal(journalPath), + ...(includeJournal ? [() => classifyJournal(journalPath)] : []), ]; const results = classifiers.map(classify => classify()); return results.find(result => result.kind === "indeterminate") ?? results.find(result => result.kind === "residue") ?? { kind: "clean" }; } + +/** Read-only, fail-closed observation of every CodexCommander-routed Codex surface. */ +export function classifyNativeRoutedResidue(): NativeRoutedResidueResult { + return classifyNativeRoutedResidueIncluding(true); +} + +/** + * Classify the native surfaces after a dead injection journal has already been + * handled as recovery evidence. This is deliberately NOT an option on the + * public coordinator classifier: callers must opt into the narrowly named + * legacy-recovery observation, while every ordinary admission continues to + * treat a journal as residue. + * + * The journal path remains in `atomicWriteTargets`, so ignoring the completed + * journal file never ignores a concurrent `codexcommander-journal.json.ccx.*` + * replacement. + */ +export function classifyNativeRoutedResidueWithoutJournal(): NativeRoutedResidueResult { + return classifyNativeRoutedResidueIncluding(false); +} + +/** + * Post-restore observation for config/profile bytes already authenticated by an + * exact journal preimage. It still checks every atomic temp, configured/default + * catalog, and models cache; ordinary admission never uses this exception. + */ +export function classifyNativeRoutedResidueAfterJournalRestore(): NativeRoutedResidueResult { + return classifyNativeRoutedResidueIncluding(false, false); +} diff --git a/src/codex/refresh.ts b/src/codex/refresh.ts index d22c5a4c8b..5968864096 100644 --- a/src/codex/refresh.ts +++ b/src/codex/refresh.ts @@ -1,10 +1,17 @@ -import { existsSync, readFileSync } from "node:fs"; -import { invalidateCodexModelsCache, syncCatalogModels } from "./catalog"; +import { existsSync } from "node:fs"; +import { readCodexCatalogPath } from "./catalog"; +import { primeBundledCatalogForGatherIfNeeded } from "./catalog/bundled"; import type { ComboCatalogOmission } from "./catalog/aggregation"; import type { CatalogQuality } from "./catalog/sync"; -import { CODEX_MODELS_CACHE_PATH } from "./paths"; -import { atomicWriteFile } from "../config"; +import { withConfigMutationLockSync } from "../config"; import type { CodexCommanderConfig } from "../types"; +import { + captureCatalogAdmissionSnapshot, + CatalogAdmissionStaleConfigError, + type CatalogConfigAuthoritySnapshot, +} from "./catalog-admission"; +import { convergeCodexCatalog } from "./convergence"; +import type { CatalogDisposition, ConfigGeneration } from "./convergence-types"; export interface CodexCatalogRefreshResult { added: number; @@ -19,25 +26,39 @@ export interface CodexCatalogRefreshResult { rehydrated: number; /** Desired OFF observed under K during the catalog commit; no cache write either. */ skippedReason?: "desired_disabled"; + /** Internal convergence evidence used by sync response projection. */ + catalogDisposition?: CatalogDisposition; + /** Generation admitted by the canonical gather and required by subsequent injection. */ + admittedGeneration?: ConfigGeneration; + /** Exact admitted config authority required by subsequent native publication. */ + admittedConfigAuthority?: CatalogConfigAuthoritySnapshot; + /** Exact stale reason retained internally; management receives only the sanitized disposition. */ + staleReason?: "generation" | "home-selection" | "source-observation" | "process-local" | "target-identity" | "candidate-consumed"; + /** Admission could not bind config+generation; native injection must not continue. */ + catalogAdmissionFailed?: boolean; } -interface RefreshDeps { - syncCatalogModels: typeof syncCatalogModels; - invalidateCodexModelsCache: typeof invalidateCodexModelsCache; +export interface RefreshDeps { + captureCatalogAdmissionSnapshot: typeof captureCatalogAdmissionSnapshot; + convergeCodexCatalog: typeof convergeCodexCatalog; + prepareConfigGeneration: () => void; + /** Production-only orchestration: resolve/probe before the observe-only gather. */ + primeCatalogSource?: () => void; existsSync: typeof existsSync; } const defaultDeps: RefreshDeps = { - syncCatalogModels, - invalidateCodexModelsCache, + captureCatalogAdmissionSnapshot, + convergeCodexCatalog, + // Existing installations may predate the cooperating generation database. + // Preparing generation zero is orchestration, not a catalog candidate write. + prepareConfigGeneration: () => { withConfigMutationLockSync(() => undefined); }, + // The canonical gather is deliberately observe-only. Settle the runtime and + // bundled memo here so a fresh home still has a native template to converge. + primeCatalogSource: primeBundledCatalogForGatherIfNeeded, existsSync, }; -export function syncCodexModelsCacheFromCatalog(catalogPath: string): void { - const content = readFileSync(catalogPath, "utf8"); - atomicWriteFile(CODEX_MODELS_CACHE_PATH, content); -} - /** * Rebuild Codex's on-disk model catalog and force Codex's models cache stale * when a catalog file exists. The cache must keep Codex's fetched_at/client_version @@ -48,18 +69,55 @@ export async function refreshCodexModelCatalog( config: CodexCommanderConfig, deps: RefreshDeps = defaultDeps, ): Promise { - const result = await deps.syncCatalogModels(config); - const catalogExists = deps.existsSync(result.path); - const catalogWritten = result.catalogWritten === true; - const comboOmissions = result.comboOmissions ?? []; - if (result.skippedReason === "desired_disabled") { - // The commit path observed OFF under K. Invalidate nothing: rewriting the - // models cache here would be exactly the routed-cache write the skip refused. - return { ...result, catalogExists, catalogWritten: false, cacheSynced: false, comboOmissions }; - } - if (!catalogExists) { - return { ...result, catalogExists, catalogWritten: false, cacheSynced: false, comboOmissions }; + let snapshot: ReturnType; + try { + deps.prepareConfigGeneration(); + if (deps.primeCatalogSource) { + // Reject a stale caller before probing. Priming can add persisted + // runtime evidence, so this preflight is deliberately discarded. + deps.captureCatalogAdmissionSnapshot(config); + deps.primeCatalogSource(); + } + snapshot = deps.captureCatalogAdmissionSnapshot(config); + } catch (error) { + const path = readCodexCatalogPath(); + const stale = error instanceof CatalogAdmissionStaleConfigError; + return { + added: 0, + path, + catalogExists: deps.existsSync(path), + catalogWritten: false, + cacheSynced: false, + comboOmissions: [], + catalogQuality: "native-only", + rehydrated: 0, + catalogDisposition: stale + ? { status: "skipped", reason: "stale", retryable: true } + : { status: "skipped", reason: "busy", retryable: true }, + ...(stale ? { staleReason: "generation" as const } : {}), + catalogAdmissionFailed: true, + }; } - const cacheSynced = deps.invalidateCodexModelsCache(); - return { ...result, catalogExists, catalogWritten, cacheSynced, comboOmissions }; + const converged = await deps.convergeCodexCatalog(snapshot, { + action: "converge", + scope: "catalog", + reason: "api-sync", + mode: "explicit", + deadlineMs: 1_000, + }); + const projected = converged.projection; + return { + added: projected.added, + path: projected.path, + catalogExists: deps.existsSync(projected.path), + catalogWritten: projected.catalogWritten, + cacheSynced: projected.cacheSynced, + comboOmissions: [...projected.comboOmissions], + catalogQuality: projected.catalogQuality, + rehydrated: projected.rehydrated, + catalogDisposition: converged.catalogRefresh, + admittedGeneration: projected.admittedGeneration, + admittedConfigAuthority: projected.admittedConfigAuthority, + ...(projected.staleReason ? { staleReason: projected.staleReason } : {}), + }; } diff --git a/src/codex/sync.ts b/src/codex/sync.ts index f844b5ecd2..5979bbd15b 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -1,6 +1,6 @@ import { currentExternalCodexModelProvider, injectCodexConfig } from "./inject"; import { printProjectCodexConfigWarnings, groupProjectCodexConfigWarningsByPath, type ProjectCodexConfigWarning } from "./project-config-warnings"; -import { refreshCodexModelCatalog } from "./refresh"; +import { refreshCodexModelCatalog, type CodexCatalogRefreshResult } from "./refresh"; import { applyProxyEnv, loadConfig } from "../config"; import type { CodexCommanderConfig } from "../types"; import { collectOrcaCodexHomeDiagnostic } from "./home"; @@ -9,6 +9,7 @@ import { shouldSyncCodexOnStart } from "./desired-state"; import { admitCodexWrite, type CodexAdmission } from "./admission"; import { hasRoutedCapableProviders, type CatalogQuality } from "./catalog/sync"; import { readCodexTransitionState } from "./transition-state"; +import { reconcileJournal } from "./journal"; export interface CodexSyncResult { /** `skipped` is policy truth, never evidence that Codex was written. */ @@ -53,12 +54,18 @@ interface CodexSyncDeps { prepareCodexTransitionState: typeof readCodexTransitionState; currentExternalCodexModelProvider?: typeof currentExternalCodexModelProvider; collectCodexHomeDiagnostic?: typeof collectOrcaCodexHomeDiagnostic; + /** + * Production recovery boundary. Optional so isolated sync tests with fully + * synthetic files never inspect the host journal unless they opt in. + */ + reconcileJournal?: typeof reconcileJournal; } const defaultDeps: CodexSyncDeps = { refreshCodexModelCatalog, injectCodexConfig, prepareCodexTransitionState: readCodexTransitionState, + reconcileJournal, }; function coordinatorPreparationFailure( @@ -137,6 +144,13 @@ export async function syncModelsToCodex( }; } + // One canonical full sync owns legacy recovery. Startup, authenticated + // /api/sync, browser Apply, CLI, and the native companion therefore converge + // through the same fail-closed journal fence instead of relying on a prior + // app-launch side effect. A false result is neutral: coordinator preparation + // below remains the authority for a live, replaced, or ambiguous journal. + deps.reconcileJournal?.(); + // Catalog gathering precedes direct injection and can itself write the native // catalog/cache. It therefore needs the same unattended service-home veto as // the injector, before it gets a chance to create any artifact. An explicitly @@ -194,6 +208,12 @@ export async function syncModelsToCodex( let rehydrated = 0; let warning: string | undefined; let comboOmissions: ComboCatalogOmission[] = []; + let admittedGeneration: CodexCatalogRefreshResult["admittedGeneration"]; + let admittedConfigAuthority: CodexCatalogRefreshResult["admittedConfigAuthority"]; + let staleGeneration = false; + let catalogAdmissionFailed = false; + let catalogConvergenceNotCommitted = false; + let catalogConvergenceChanged = false; try { const cat = await deps.refreshCodexModelCatalog(config); @@ -203,6 +223,21 @@ export async function syncModelsToCodex( cacheSynced = cat.cacheSynced; catalogQuality = cat.catalogQuality; rehydrated = cat.rehydrated; + admittedGeneration = cat.admittedGeneration; + admittedConfigAuthority = cat.admittedConfigAuthority; + staleGeneration = cat.staleReason === "generation"; + catalogAdmissionFailed = cat.catalogAdmissionFailed === true; + catalogConvergenceChanged = (cat.catalogDisposition?.status === "committed" + && cat.catalogDisposition.changed) + || cat.catalogWritten + || cat.cacheSynced; + catalogConvergenceNotCommitted = cat.catalogDisposition !== undefined + && cat.catalogDisposition.status !== "committed" + // A proven absence is the established native-catalog fallback: continue + // config/profile reconciliation with catalogPath=null. Every retryable or + // failed canonical disposition remains a hard publication boundary. + && !(cat.catalogDisposition.status === "skipped" + && cat.catalogDisposition.reason === "catalog-unavailable"); catalogPathForInjection = cat.catalogExists ? cat.path : null; catalogPath = catalogPathForInjection; comboOmissions = cat.comboOmissions ?? []; @@ -214,13 +249,27 @@ export async function syncModelsToCodex( warning = "catalog sync skipped: no Codex catalog source found; keeping Codex's native catalog."; log?.error(warning); } + if (cat.catalogDisposition?.status === "skipped" && cat.catalogDisposition.reason !== "catalog-unavailable") { + const detail = cat.catalogDisposition.reason === "busy" + ? "catalog convergence is busy; retry" + : cat.catalogDisposition.reason === "stale" + ? "catalog inputs changed during discovery; retry" + : "catalog convergence was refused"; + const message = `catalog sync skipped: ${detail}.`; + warning = warning ? `${warning} ${message}` : message; + log?.error(message); + } else if (cat.catalogDisposition?.status === "failed") { + const message = `catalog sync skipped: catalog convergence failed during ${cat.catalogDisposition.phase}.`; + warning = warning ? `${warning} ${message}` : message; + log?.error(message); + } // A native-only commit while routed providers are configured is a degraded // state, not a success: the live gather returned nothing and there was no // retained snapshot to fall back on. Surface it so the readiness gate and // /api/sync consumers can act instead of reporting a false fully-ready sync. if ( cat.catalogQuality === "native-only" - && cat.catalogWritten + && (cat.catalogDisposition?.status === "committed" ? cat.catalogExists : cat.catalogWritten) && cat.skippedReason !== "desired_disabled" && hasRoutedCapableProviders(config) ) { @@ -240,15 +289,36 @@ export async function syncModelsToCodex( } catch (e) { warning = `catalog sync skipped: ${e instanceof Error ? e.message : String(e)}`; log?.error(warning); + // The historical injected refresh seam may deliberately throw and still + // exercise native config/profile reconciliation. Production's canonical + // funnel must never turn an unexpected catalog failure into an unadmitted + // native publish. + if (deps.refreshCodexModelCatalog === refreshCodexModelCatalog) { + catalogAdmissionFailed = true; + } } - const result = await deps.injectCodexConfig(p, config, { catalogPath: catalogPathForInjection }); - if (result.status === "skipped") { + if (staleGeneration) { + if (!shouldSyncCodexOnStart(loadConfig())) { + return { + status: "skipped", + skippedReason: "desired_disabled", + ok: true, + added: 0, + catalogPath: null, + catalogExists: false, + catalogWritten: false, + cacheSynced: false, + catalogQuality: "native-only", + rehydrated: 0, + message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.", + }; + } + const message = "Codex configuration changed during catalog discovery; no stale catalog or Codex config was published. Retry the sync."; + log?.error(message); return { - status: "skipped", - // The apply direction's only under-lock policy skip is desired OFF. - skippedReason: "desired_disabled", - ok: true, + status: "refused", + ok: false, added: 0, catalogPath: null, catalogExists: false, @@ -256,7 +326,101 @@ export async function syncModelsToCodex( cacheSynced: false, catalogQuality: "native-only", rehydrated: 0, + message, + }; + } + if (catalogAdmissionFailed) { + const message = "Codex catalog admission could not bind the current configuration; no Codex config was published. Retry the sync."; + log?.error(message); + return { + status: "refused", + ok: false, + added, + catalogPath, + catalogExists, + catalogWritten, + cacheSynced, + catalogQuality, + rehydrated, + message, + ...(warning ? { warning } : {}), + ...(comboOmissions.length > 0 ? { comboOmissions } : {}), + }; + } + if (catalogConvergenceNotCommitted) { + if (!shouldSyncCodexOnStart(loadConfig())) { + return { + status: "skipped", + skippedReason: "desired_disabled", + ok: true, + added: 0, + catalogPath: null, + catalogExists: false, + catalogWritten: false, + cacheSynced: false, + catalogQuality: "native-only", + rehydrated: 0, + message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.", + }; + } + const message = "Codex catalog convergence did not commit; no Codex config was published. Retry the sync."; + log?.error(message); + return { + status: "refused", + ok: false, + added, + catalogPath, + catalogExists, + catalogWritten, + cacheSynced, + catalogQuality, + rehydrated, + message, + ...(warning ? { warning } : {}), + ...(comboOmissions.length > 0 ? { comboOmissions } : {}), + }; + } + + const result = await deps.injectCodexConfig(p, config, { + catalogPath: catalogPathForInjection, + ...(admittedConfigAuthority ? { expectedConfigAuthority: admittedConfigAuthority } : {}), + ...(admittedGeneration ? { expectedConfigGeneration: admittedGeneration } : {}), + }); + if (result.status === "skipped") { + const message = catalogConvergenceChanged + ? "Codex integration turned OFF after catalog convergence; catalog changes from this sync were published, but native Codex config was not written." + : result.message; + return { + status: "skipped", + // The apply direction's only under-lock policy skip is desired OFF. + skippedReason: "desired_disabled", + ok: true, + added, + catalogPath, + catalogExists, + catalogWritten, + cacheSynced, + catalogQuality, + rehydrated, + message, + ...(warning ? { warning } : {}), + ...(comboOmissions.length > 0 ? { comboOmissions } : {}), + }; + } + if (result.status === "stale") { + return { + status: "refused", + ok: false, + added, + catalogPath, + catalogExists, + catalogWritten, + cacheSynced, + catalogQuality, + rehydrated, message: result.message, + ...(warning ? { warning } : {}), + ...(comboOmissions.length > 0 ? { comboOmissions } : {}), }; } log?.log(result.message); diff --git a/src/codex/transition-state.ts b/src/codex/transition-state.ts index 63cc6aa925..7c201719c9 100644 --- a/src/codex/transition-state.ts +++ b/src/codex/transition-state.ts @@ -147,31 +147,69 @@ function assertInitialStateCanBeCreated(): void { } } -function initialize(database: Database, databaseWasAbsent: boolean): void { +function databaseSchemaIsEmpty(database: Database): boolean { + return database.query, []>( + "SELECT 1 FROM sqlite_schema LIMIT 1", + ).get() === null; +} + +function initialize(database: Database, _databaseWasAbsent: boolean): void { const version = database.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version; - if (version !== 0 && version !== COORDINATOR_SCHEMA_VERSION) { - throw new CodexCoordinatorTransactionError("The coordinator database schema version is unsupported."); + if (version === 0) { + // First use and a rolled-back recovery bootstrap are intentionally + // indistinguishable only when SQLite contains literally no schema object. + // Any unversioned table/index/trigger remains ambiguous and is never + // adopted. The unchanged native-residue check still runs under BEGIN. + if (!databaseSchemaIsEmpty(database)) { + throw new CodexCoordinatorStateAmbiguousError( + "An existing unversioned coordinator database is unsupported.", + ); + } + assertInitialStateCanBeCreated(); + database.exec(CREATE_TRANSITION_TABLE); + database.query(INITIALIZE_TRANSITION_ROW).run(new Date().toISOString()); + database.exec(`PRAGMA user_version = ${COORDINATOR_SCHEMA_VERSION}`); + readState(database); + return; } - if (!databaseWasAbsent && version === 0) { - throw new CodexCoordinatorStateAmbiguousError( - "An existing unversioned coordinator database is unsupported.", - ); + if (version !== COORDINATOR_SCHEMA_VERSION) { + throw new CodexCoordinatorTransactionError("The coordinator database schema version is unsupported."); } database.exec(CREATE_TRANSITION_TABLE); const existing = database.query(SELECT_TRANSITION_ROW).get(); - if (!existing && !databaseWasAbsent) { + if (!existing) { throw new CodexCoordinatorStateAmbiguousError( "The existing coordinator database has no authoritative transition row.", ); } - if (!existing) { - assertInitialStateCanBeCreated(); - database.query(INITIALIZE_TRANSITION_ROW).run(new Date().toISOString()); - } - if (version === 0) database.exec(`PRAGMA user_version = ${COORDINATOR_SCHEMA_VERSION}`); readState(database); } +/** + * Recovery N may encounter either the authoritative v2 coordinator or the + * exact empty SQLite shell left when an earlier bootstrap rolled back/crashed. + * It never adopts an unversioned database containing any user schema. + */ +function prepareRecoveryCoordinator( + database: Database, + databaseWasAbsent: boolean, +): { deferredInitialization: boolean } { + const version = database.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version; + if (version === COORDINATOR_SCHEMA_VERSION) { + initialize(database, databaseWasAbsent); + return { deferredInitialization: false }; + } + if (version !== 0) { + throw new CodexCoordinatorTransactionError("The coordinator database schema version is unsupported."); + } + if (!databaseSchemaIsEmpty(database)) { + throw new CodexCoordinatorStateAmbiguousError( + "An unversioned coordinator database with schema cannot be adopted for recovery.", + ); + } + return { deferredInitialization: true }; +} + function createCapability( database: Database, onResult: (result: TransitionStateUpdate) => void, @@ -207,13 +245,56 @@ function createCapability( }; } -export function beginCodexCoordinatorTransaction(finalDatabasePath: string): CodexCoordinatorTransactionController { +function createDeferredRecoveryCapability( + database: Database, + onResult: (result: TransitionStateUpdate) => void, +): BrandedCodexCoordinatorTransaction { + let consumed = false; + return { + [codexCoordinatorTransactionBrand]: true, + beginTransition(expected, next) { + if (consumed) { + throw new CodexCoordinatorTransactionError("The coordinator capability has already been consumed."); + } + consumed = true; + if (expected.nativeGeneration !== 0 + || expected.currentTxId !== null + || !next.txId.trim()) { + throw new CodexCoordinatorTransactionError("The recovery bootstrap transition is malformed."); + } + database.exec(CREATE_TRANSITION_TABLE); + database.query(INITIALIZE_TRANSITION_ROW).run(new Date().toISOString()); + database.exec(`PRAGMA user_version = ${COORDINATOR_SCHEMA_VERSION}`); + const result = database.query(BEGIN_TRANSITION).run( + 1, + next.txId, + new Date().toISOString(), + 0, + null, + ); + const state = readState(database); + const update: TransitionStateUpdate = result.changes === 1 + ? { kind: "updated", state } + : { kind: "conflict", current: state }; + onResult(update); + return update; + }, + }; +} + +function beginCodexCoordinatorTransactionInternal( + finalDatabasePath: string, + recovery: boolean, + revalidateRecoveryAdmission?: () => boolean, + validateSettledRecovery?: () => boolean, +): CodexCoordinatorTransactionController { let database: Database | undefined; let transactionOpen = false; let closed = false; let lastResult: TransitionStateUpdate | undefined; let initialIdentity: string | undefined; let databaseWasAbsent = false; + let deferredRecoveryInitialization = false; try { try { @@ -277,7 +358,17 @@ export function beginCodexCoordinatorTransaction(finalDatabasePath: string): Cod initialIdentity = `${opened.dev}:${opened.ino}`; database.exec("PRAGMA busy_timeout = 0; PRAGMA locking_mode = NORMAL; BEGIN IMMEDIATE"); transactionOpen = true; - initialize(database, databaseWasAbsent); + if (recovery) { + if (!revalidateRecoveryAdmission?.()) { + throw new CodexCoordinatorStateAmbiguousError( + "The recovery admission changed before the coordinator lock was acquired.", + ); + } + deferredRecoveryInitialization = prepareRecoveryCoordinator(database, databaseWasAbsent) + .deferredInitialization; + } else { + initialize(database, databaseWasAbsent); + } } catch (cause) { if (transactionOpen) { try { database?.exec("ROLLBACK"); } catch { /* close releases the transaction */ } @@ -300,11 +391,16 @@ export function beginCodexCoordinatorTransaction(finalDatabasePath: string): Cod } }; - const capability = createCapability(db, result => { lastResult = result; }); + const capability = deferredRecoveryInitialization + ? createDeferredRecoveryCapability(db, result => { lastResult = result; }) + : createCapability(db, result => { lastResult = result; }); return { capability, expectation() { requireOpen(); + if (deferredRecoveryInitialization && lastResult === undefined) { + return { nativeBefore: 0, nativeAfter: 1, txId: randomUUID() }; + } const state = readState(db); return { nativeBefore: state.nativeGeneration, @@ -314,6 +410,9 @@ export function beginCodexCoordinatorTransaction(finalDatabasePath: string): Cod }, version() { requireOpen(); + if (deferredRecoveryInitialization && lastResult === undefined) { + return { nativeGeneration: 0, currentTxId: null }; + } const state = readState(db); return { nativeGeneration: state.nativeGeneration, currentTxId: state.currentTxId }; }, @@ -330,6 +429,20 @@ export function beginCodexCoordinatorTransaction(finalDatabasePath: string): Cod assertStablePath, commit() { requireOpen(); + // The recovery-only initializer may publish into an absent/empty shell, + // but it cannot COMMIT authority until the ordinary, unchanged clean-home + // predicate succeeds after journal recovery and retirement. + if (deferredRecoveryInitialization) { + if (validateSettledRecovery) { + if (!validateSettledRecovery()) { + throw new CodexCoordinatorStateAmbiguousError( + "The recovered native Codex state did not satisfy its final admission.", + ); + } + } else { + assertInitialStateCanBeCreated(); + } + } assertStablePath(); db.exec("COMMIT"); transactionOpen = false; @@ -351,6 +464,31 @@ export function beginCodexCoordinatorTransaction(finalDatabasePath: string): Cod }; } +export function beginCodexCoordinatorTransaction( + finalDatabasePath: string, +): CodexCoordinatorTransactionController { + return beginCodexCoordinatorTransactionInternal(finalDatabasePath, false); +} + +/** + * Recovery-only N acquisition. It shares the exact database/OS lock with every + * ordinary writer but may defer initialization over one valid dead journal. + * The controller still refuses to commit until ordinary residue admission is + * clean; callers cannot use this as a generic adoption escape hatch. + */ +export function beginCodexCoordinatorRecoveryTransaction( + finalDatabasePath: string, + revalidateRecoveryAdmission: () => boolean, + validateSettledRecovery?: () => boolean, +): CodexCoordinatorTransactionController { + return beginCodexCoordinatorTransactionInternal( + finalDatabasePath, + true, + revalidateRecoveryAdmission, + validateSettledRecovery, + ); +} + function currentCoordinatorDatabasePath(): string { const canonicalCodexHome = realpathSync.native(resolveCodexHomeDir()); return resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), canonicalCodexHome); diff --git a/src/identity.d.mts b/src/identity.d.mts index bd7a7761ef..e25afdc613 100644 --- a/src/identity.d.mts +++ b/src/identity.d.mts @@ -16,6 +16,7 @@ export const OWNER_FILE: string; export const UNINSTALL_MANIFEST: string; export const ADMIN_KEY_PREFIX: string; export const GUI_SESSION_PREFIX: string; +export const GUI_LAUNCH_TICKET_PREFIX: string; export const DATA_KEY_PREFIX: string; export const API_KEY_HEADER: string; export const GUI_ORIGIN_HEADER: string; @@ -25,5 +26,6 @@ export const ATTESTATION_PROOF_HEADER: string; export const AUTH_REQUIRED_MESSAGE: string; export const ADMIN_AUTH_REQUIRED_MESSAGE: string; export const ARTIFACT_HTTP_PREFIX: string; -export const SESSION_PATH: string; +export const GUI_LAUNCH_TICKET_PATH: string; +export const GUI_LAUNCH_EXCHANGE_PATH: string; export function readEnv(name: string, env?: NodeJS.ProcessEnv): string | undefined; diff --git a/src/identity.mjs b/src/identity.mjs index 4dd75ad97f..2b8d286a51 100644 --- a/src/identity.mjs +++ b/src/identity.mjs @@ -29,6 +29,7 @@ export const UNINSTALL_MANIFEST = ".codexcommander-uninstall.json"; export const ADMIN_KEY_PREFIX = "ccx_admin_"; export const GUI_SESSION_PREFIX = "ccx_session_"; +export const GUI_LAUNCH_TICKET_PREFIX = "ccx_launch_"; export const DATA_KEY_PREFIX = "ccx_data_"; export const API_KEY_HEADER = "x-codexcommander-api-key"; @@ -41,7 +42,8 @@ export const AUTH_REQUIRED_MESSAGE = "CodexCommander API key required"; export const ADMIN_AUTH_REQUIRED_MESSAGE = "CodexCommander admin token required"; export const ARTIFACT_HTTP_PREFIX = "/v1/codexcommander/artifacts"; -export const SESSION_PATH = "/codexcommander-session"; +export const GUI_LAUNCH_TICKET_PATH = "/api/gui-launch-ticket"; +export const GUI_LAUNCH_EXCHANGE_PATH = "/api/gui-launch-exchange"; /** Read one canonical environment variable. */ export function readEnv(name, env = process.env) { diff --git a/src/identity.ts b/src/identity.ts index 5fcb904be4..d765de04aa 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -13,6 +13,9 @@ import { CSRF_HEADER, DATA_KEY_PREFIX, GUI_ORIGIN_HEADER, + GUI_LAUNCH_EXCHANGE_PATH, + GUI_LAUNCH_TICKET_PATH, + GUI_LAUNCH_TICKET_PREFIX, GUI_SESSION_PREFIX, HEALTH_SERVICE_ID, HOME_ENV, @@ -23,7 +26,6 @@ import { REPOSITORY_URL, SERVICE_LABEL, SERVICE_TASK, - SESSION_PATH, STATE_DIR_NAME, UNINSTALL_MANIFEST, WINSW_SERVICE_ID, @@ -44,6 +46,9 @@ export { CSRF_HEADER, DATA_KEY_PREFIX, GUI_ORIGIN_HEADER, + GUI_LAUNCH_EXCHANGE_PATH, + GUI_LAUNCH_TICKET_PATH, + GUI_LAUNCH_TICKET_PREFIX, GUI_SESSION_PREFIX, HEALTH_SERVICE_ID, HOME_ENV, @@ -54,7 +59,6 @@ export { REPOSITORY_URL, SERVICE_LABEL, SERVICE_TASK, - SESSION_PATH, STATE_DIR_NAME, UNINSTALL_MANIFEST, WINSW_SERVICE_ID, diff --git a/src/lib/process-control.ts b/src/lib/process-control.ts index c2e6012383..daf95a8e96 100644 --- a/src/lib/process-control.ts +++ b/src/lib/process-control.ts @@ -1,7 +1,18 @@ import { execFileSync } from "node:child_process"; -import { loadConfig, readRuntimePort } from "../config"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { + isCodexCommanderStartCommandLine, + loadConfig, + readRuntimePort, +} from "../config"; import { API_KEY_HEADER, HOME_ENV, readEnv } from "../identity"; import { configuredAdminToken } from "./admin-secrets"; +import { isLocalAttestationSecret } from "./local-management-attestation"; +import { + attestLiveManagementProxy, + type RuntimeLivenessRecord, +} from "../server/proxy-liveness"; export function isProcessAlive(pid: number): boolean { try { @@ -25,12 +36,49 @@ export function waitForExit(pid: number, timeoutMs: number): boolean { /** Injectable seams so the graceful-stop flow is unit-testable without a live proxy. */ export interface GracefulStopIo { fetchFn?: typeof fetch; - readRuntime?: (pid: number) => { port: number; hostname?: string } | null; + readRuntime?: (pid?: number) => RuntimeLivenessRecord | null; + verifyPidFn?: (candidatePid: number) => number | null; + attestLiveManagementProxyImpl?: typeof attestLiveManagementProxy; waitExit?: (pid: number, timeoutMs: number) => boolean; env?: Record; exitTimeoutMs?: number; } +interface ProtectedRuntimeIdentity { + readonly pid: number; + readonly port: number; + readonly hostname?: string; + readonly attestationSecret: string; +} + +/** Opaque, non-logging identity used only to detect PID reuse before a signal. */ +export interface ProxySignalIdentity { + readonly pid: number; + readonly argvSha256: string; + readonly birthIdentity: string; + readonly ownerIdentity: string; +} + +export interface StopProxyIo { + platform?: NodeJS.Platform; + getuid?: () => number | undefined; + isAlive?: (pid: number) => boolean; + waitExit?: (pid: number, timeoutMs: number) => boolean; + readRuntime?: (pid?: number) => RuntimeLivenessRecord | null; + readProcessIdentity?: (pid: number) => ProxySignalIdentity | null; + gracefulStop?: (pid: number) => Promise; + signal?: (pid: number, signal: NodeJS.Signals) => void; + taskkill?: (pid: number) => void; + waitStoppedPort?: ( + runtime: { port: number; hostname?: string } | null | undefined, + ) => Promise; +} + +interface ForcedStopAuthorization { + readonly runtime: ProtectedRuntimeIdentity; + readonly process: ProxySignalIdentity; +} + /** * Host to POST /api/stop against: follow the recorded bind hostname when it names a * concrete address (a proxy bound to ::1 or a LAN IP is unreachable on 127.0.0.1); @@ -64,15 +112,22 @@ export type GracefulStopResult = boolean | "refused"; */ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): Promise { const readRuntime = io.readRuntime ?? readRuntimePort; - const runtime = readRuntime(pid); - if (!runtime?.port) return false; + const fetchFn = io.fetchFn ?? fetch; + const attest = io.attestLiveManagementProxyImpl ?? attestLiveManagementProxy; + const target = await attest({ + fetchFn, + readRuntimeFn: readRuntime, + verifyPidFn: io.verifyPidFn, + expectedPid: pid, + timeoutMs: io.exitTimeoutMs ? Math.min(io.exitTimeoutMs, 10_000) : 10_000, + }); + if (!target) return false; const env = io.env ?? process.env; const headers: Record = {}; const token = configuredAdminToken(readEnv(HOME_ENV, env as NodeJS.ProcessEnv), env as NodeJS.ProcessEnv); if (token) headers[API_KEY_HEADER] = token; - const fetchFn = io.fetchFn ?? fetch; try { - const res = await fetchFn(`http://${gracefulStopHost(runtime.hostname)}:${runtime.port}/api/stop`, { + const res = await fetchFn(`${target.baseUrl}/api/stop`, { method: "POST", headers, // Hung proxies with many CLOSE_WAIT clients can be slow to accept; give them @@ -103,11 +158,258 @@ function drainDeadlineMs(): number { } } -/** Graceful-first stop: management-API drain, then the platform kill ladder. */ -export async function stopProxy(pid: number): Promise { - if (!isProcessAlive(pid)) return; - const runtime = readRuntimePort(pid); - const graceful = await stopProxyGracefully(pid); +function sha256(value: string | Uint8Array): string { + return createHash("sha256").update(value).digest("hex"); +} + +function protectedRuntimeIdentity( + record: RuntimeLivenessRecord | null, + expectedPid: number, +): ProtectedRuntimeIdentity | null { + if (!record + || record.pid !== expectedPid + || !Number.isSafeInteger(record.pid) + || expectedPid <= 1 + || !Number.isInteger(record.port) + || record.port <= 0 + || record.port > 65_535 + || (record.hostname !== undefined && typeof record.hostname !== "string") + || !isLocalAttestationSecret(record.attestationSecret)) { + return null; + } + return { + pid: expectedPid, + port: record.port, + ...(record.hostname !== undefined ? { hostname: record.hostname } : {}), + attestationSecret: record.attestationSecret, + }; +} + +function sameProtectedRuntime( + expected: ProtectedRuntimeIdentity, + current: RuntimeLivenessRecord | null, +): boolean { + return current?.pid === expected.pid + && current.port === expected.port + && current.hostname === expected.hostname + && current.attestationSecret === expected.attestationSecret; +} + +function sameProxySignalIdentity( + expected: ProxySignalIdentity, + current: ProxySignalIdentity | null, +): boolean { + return current !== null + && current.pid === expected.pid + && current.argvSha256 === expected.argvSha256 + && current.birthIdentity === expected.birthIdentity + && current.ownerIdentity === expected.ownerIdentity; +} + +function linuxProcessBirthIdentity(pid: number): string | null { + try { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + // Field 22 follows a parenthesized comm which may itself contain spaces. + const close = stat.lastIndexOf(")"); + if (close < 0) return null; + const fields = stat.slice(close + 2).split(/\s+/); + const rawStartTicks = fields[19]; + return rawStartTicks && /^\d+$/.test(rawStartTicks) + ? `linux-ticks:${rawStartTicks}` + : null; + } catch { + return null; + } +} + +function linuxProcessIdentity( + pid: number, + getuid: () => number | undefined, +): ProxySignalIdentity | null { + try { + const expectedUid = getuid(); + if (expectedUid === undefined) return null; + const birthBefore = linuxProcessBirthIdentity(pid); + if (!birthBefore) return null; + const status = readFileSync(`/proc/${pid}/status`, "utf8"); + const uidMatch = /^Uid:\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/m.exec(status); + const uids = uidMatch ? uidMatch.slice(1).map(Number) : []; + if (uids.length !== 4 || uids.some(uid => !Number.isSafeInteger(uid) || uid !== expectedUid)) { + return null; + } + const argv = readFileSync(`/proc/${pid}/cmdline`); + if (argv.length === 0) return null; + const commandLine = argv.toString("utf8").replace(/\0/g, " ").trim(); + if (!commandLine || !isCodexCommanderStartCommandLine(commandLine)) return null; + const birthAfter = linuxProcessBirthIdentity(pid); + if (birthAfter !== birthBefore) return null; + return { + pid, + argvSha256: sha256(argv), + birthIdentity: birthAfter, + ownerIdentity: `uid:${expectedUid}`, + }; + } catch { + return null; + } +} + +function darwinProcessIdentity( + pid: number, + getuid: () => number | undefined, +): ProxySignalIdentity | null { + try { + const expectedUid = getuid(); + if (expectedUid === undefined) return null; + const output = execFileSync( + "ps", + ["-o", "pid=,uid=,lstart=,command=", "-p", String(pid)], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 2_000, + }, + ).trim(); + const match = /^(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+)$/.exec(output); + if (!match) return null; + const observedPid = Number(match[1]); + const uid = Number(match[2]); + const birth = match[3]!.trim(); + const commandLine = match[4]!.trim(); + if (observedPid !== pid + || uid !== expectedUid + || !Number.isFinite(Date.parse(birth)) + || !isCodexCommanderStartCommandLine(commandLine)) { + return null; + } + return { + pid, + argvSha256: sha256(commandLine), + birthIdentity: `darwin-lstart:${birth}`, + ownerIdentity: `uid:${uid}`, + }; + } catch { + return null; + } +} + +function windowsProcessIdentity(pid: number): ProxySignalIdentity | null { + try { + const script = [ + "$ErrorActionPreference='Stop'", + "$me=[System.Security.Principal.WindowsIdentity]::GetCurrent().Name", + `$p=Get-CimInstance Win32_Process -Filter \"ProcessId=${pid}\"`, + "if($null -eq $p -or [string]::IsNullOrWhiteSpace($p.CommandLine)){return}", + "$o=Invoke-CimMethod -InputObject $p -MethodName GetOwner -ErrorAction Stop", + "if($null -eq $o -or $o.ReturnValue -ne 0 -or [string]::IsNullOrWhiteSpace($o.User)){return}", + "$owner=if($o.Domain){\"$($o.Domain)\\$($o.User)\"}else{$o.User}", + "if($owner -ine $me){return}", + "$argv=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$p.CommandLine))", + "$born=$p.CreationDate.ToUniversalTime().Ticks.ToString([Globalization.CultureInfo]::InvariantCulture)", + "[pscustomobject]@{pid=[int]$p.ProcessId;birth=$born;argv=$argv;owner=$owner}|ConvertTo-Json -Compress", + ].join("\n"); + const output = execFileSync("powershell.exe", [ + "-NoProfile", "-NoLogo", "-NonInteractive", "-WindowStyle", "Hidden", + "-Command", script, + ], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 5_000, + windowsHide: true, + }).trim(); + if (!output) return null; + const parsed = JSON.parse(output) as Record; + if (parsed.pid !== pid + || typeof parsed.birth !== "string" + || !/^\d+$/.test(parsed.birth) + || typeof parsed.argv !== "string" + || typeof parsed.owner !== "string" + || !parsed.owner.trim()) { + return null; + } + const argv = Buffer.from(parsed.argv, "base64"); + const commandLine = argv.toString("utf8"); + if (!commandLine || !isCodexCommanderStartCommandLine(commandLine)) return null; + return { + pid, + argvSha256: sha256(argv), + birthIdentity: `windows-ticks:${parsed.birth}`, + ownerIdentity: `owner:${parsed.owner.toLowerCase()}`, + }; + } catch { + return null; + } +} + +function readCurrentUserProxyIdentity( + pid: number, + platform: NodeJS.Platform, + getuid: () => number | undefined, +): ProxySignalIdentity | null { + if (!Number.isSafeInteger(pid) || pid <= 1) return null; + if (platform === "win32") return windowsProcessIdentity(pid); + if (platform === "darwin") return darwinProcessIdentity(pid, getuid); + if (platform === "linux") return linuxProcessIdentity(pid, getuid); + return null; +} + +function captureForcedStopAuthorization( + pid: number, + io: StopProxyIo, +): ForcedStopAuthorization | null { + const readRuntime = io.readRuntime ?? readRuntimePort; + const runtime = protectedRuntimeIdentity(readRuntime(pid), pid); + if (!runtime) return null; + const platform = io.platform ?? process.platform; + const getuid = io.getuid ?? (() => { + try { + return typeof process.getuid === "function" ? process.getuid() : undefined; + } catch { + return undefined; + } + }); + const processIdentity = (io.readProcessIdentity + ?? (candidatePid => readCurrentUserProxyIdentity(candidatePid, platform, getuid)))(pid); + return processIdentity ? { runtime, process: processIdentity } : null; +} + +function forcedStopAuthorizationStillMatches( + expected: ForcedStopAuthorization, + io: StopProxyIo, +): boolean { + const readRuntime = io.readRuntime ?? readRuntimePort; + if (!sameProtectedRuntime(expected.runtime, readRuntime(expected.runtime.pid))) return false; + const platform = io.platform ?? process.platform; + const getuid = io.getuid ?? (() => { + try { + return typeof process.getuid === "function" ? process.getuid() : undefined; + } catch { + return undefined; + } + }); + const current = (io.readProcessIdentity + ?? (candidatePid => readCurrentUserProxyIdentity(candidatePid, platform, getuid)))(expected.process.pid); + return sameProxySignalIdentity(expected.process, current); +} + +function forcedStopRefusal(): Error { + return new Error( + "Forced proxy stop was refused because the protected runtime or process identity changed.", + ); +} + +/** Graceful-first stop: management-API drain, then an exact-identity kill ladder. */ +export async function stopProxy(pid: number, io: StopProxyIo = {}): Promise { + const isAlive = io.isAlive ?? isProcessAlive; + if (!isAlive(pid)) return; + // Capture before the potentially long HMAC attestation, request, drain and + // wait. A same-number replacement is never adopted as the fallback target. + const authorization = captureForcedStopAuthorization(pid, io); + const readRuntime = io.readRuntime ?? readRuntimePort; + const runtime = readRuntime(pid); + const graceful = await (io.gracefulStop + ? io.gracefulStop(pid) + : stopProxyGracefully(pid, { readRuntime })); if (graceful === "refused") { // The proxy refused on purpose (foreign service owns it). Forcing would strip shared // config while that service keeps the proxy alive. @@ -117,17 +419,17 @@ export async function stopProxy(pid: number): Promise { ); } if (graceful) { - await waitForStoppedPort(runtime, pid); + await (io.waitStoppedPort ?? waitForStoppedPort)(runtime); return; } - killProxy(pid); - await waitForStoppedPort(runtime, pid); + if (!authorization) throw forcedStopRefusal(); + killProxyWithAuthorization(pid, authorization, io); + await (io.waitStoppedPort ?? waitForStoppedPort)(runtime); } /** After stop/kill, wait for the former listen port to become bindable (Windows drain). */ async function waitForStoppedPort( runtime: { port: number; hostname?: string } | null | undefined, - stoppedPid?: number, ): Promise { if (!runtime?.port) return; try { @@ -136,31 +438,66 @@ async function waitForStoppedPort( timeoutMs: 15_000, intervalMs: 100, scanIntervalMs: 500, - // Only the process we just stopped — never kill a newly started twin proxy. - killCodexCommanderHolders: !!(stoppedPid && stoppedPid > 0), - onlyKillPids: stoppedPid && stoppedPid > 0 ? [stoppedPid] : [], + // The exact old identity is gone once the kill ladder returns. A numeric + // PID allowlist could hit a newly started replacement, so this phase only + // waits/drops orphaned TCP rows and never signals another process. + killCodexCommanderHolders: false, + onlyKillPids: [], }); } catch { /* best-effort — callers that need a hard guarantee reclaim again before bind */ } } -export function killProxy(pid: number): void { - if (!isProcessAlive(pid)) return; - if (process.platform === "win32") { +function killProxyWithAuthorization( + pid: number, + authorization: ForcedStopAuthorization, + io: StopProxyIo, +): void { + const isAlive = io.isAlive ?? isProcessAlive; + const waitExit = io.waitExit ?? waitForExit; + const platform = io.platform ?? process.platform; + if (!isAlive(pid)) return; + if (!forcedStopAuthorizationStillMatches(authorization, io)) throw forcedStopRefusal(); + if (platform === "win32") { // Windows process.kill(SIGTERM/SIGINT) is TerminateProcess — not a graceful signal. // Graceful drain happens only via stopProxyGracefully() (POST /api/stop). This path // is the hard fallback: taskkill /T /F so the process tree exits (ghost LISTEN / // CLOSE_WAIT are then cleared by reclaimListenPort / SetTcpEntry). - const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`; + const taskkill = io.taskkill ?? ((targetPid: number) => { + const executable = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`; + execFileSync(executable, ["/PID", String(targetPid), "/T", "/F"], { + stdio: "pipe", + windowsHide: true, + }); + }); try { - execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], { stdio: "pipe", windowsHide: true }); + taskkill(pid); } catch (err) { - if (isProcessAlive(pid)) throw err; + if (isAlive(pid)) throw err; } } else { - process.kill(pid, "SIGTERM"); - if (!waitForExit(pid, 5000)) process.kill(pid, "SIGKILL"); + const signal = io.signal ?? ((targetPid, value) => { process.kill(targetPid, value); }); + signal(pid, "SIGTERM"); + if (!waitExit(pid, 5000) && isAlive(pid)) { + // SIGTERM's five-second grace is another PID-reuse/exec window. Never + // escalate without the original runtime, owner, argv and birth evidence. + if (!forcedStopAuthorizationStillMatches(authorization, io)) throw forcedStopRefusal(); + signal(pid, "SIGKILL"); + } } - if (!waitForExit(pid, 5000)) throw new Error(`process ${pid} did not exit`); + if (!waitExit(pid, 5000)) throw new Error("The verified proxy process did not exit."); +} + +/** + * Immediate hard-stop API retained for callers that do not have a preceding + * graceful phase. It captures and revalidates the same protected identity + * before signaling; missing or ambiguous evidence is a refusal. + */ +export function killProxy(pid: number, io: StopProxyIo = {}): void { + const isAlive = io.isAlive ?? isProcessAlive; + if (!isAlive(pid)) return; + const authorization = captureForcedStopAuthorization(pid, io); + if (!authorization) throw forcedStopRefusal(); + killProxyWithAuthorization(pid, authorization, io); } diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index cf5c6837d5..01cb63b16d 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -343,6 +343,7 @@ export function forgetHardenedSecretPath(targetPath: string): void { * timeout memos are intentional anti-restall state and are not touched here. */ export function forgetEphemeralSecretPath(tempPath: string): void { + hardenedDirectories.delete(tempPath); hardenedPaths.delete(tempPath); timedOutPaths.delete(`required:${tempPath}`); timedOutPaths.delete(`optional:${tempPath}`); diff --git a/src/oauth/health.ts b/src/oauth/health.ts index 21240c65e4..b31cb117bf 100644 --- a/src/oauth/health.ts +++ b/src/oauth/health.ts @@ -4,14 +4,13 @@ import { isAccountNeedsReauth } from "../codex/account-runtime-state"; import { getCodexAccountCredential, listCodexAccountIds } from "../codex/account-store"; import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; import { configuredAdminToken } from "../lib/admin-secrets"; -import { readRuntimePort } from "../config"; -import { - createLocalAttestationChallenge, - verifyLocalAttestationProof, -} from "../lib/local-management-attestation"; -import { ATTESTATION_CHALLENGE_HEADER, ATTESTATION_PROOF_HEADER } from "../identity"; +import { readRuntimePort, verifyPidIdentity } from "../config"; import { maskAccountId } from "../lib/privacy"; -import { findLiveProxy, probeHostname } from "../server/proxy-liveness"; +import { + attestLiveManagementProxy, + findLiveProxy, + probeHostname, +} from "../server/proxy-liveness"; import { loadAuthStore, peekAuthStore, peekOAuthRefreshIntent, readOAuthRefreshIntent } from "./store"; import type { ProviderAccount } from "./types"; @@ -335,6 +334,7 @@ async function fetchCodexHealthFromLiveProxy( fetchImpl: typeof fetch = fetch, findLiveProxyImpl: typeof findLiveProxy = findLiveProxy, readRuntimePortImpl: typeof readRuntimePort = readRuntimePort, + verifyPidIdentityImpl: typeof verifyPidIdentity = verifyPidIdentity, ): Promise { const live = await findLiveProxyImpl(); if (!live) return { source: "unavailable", entries: null }; @@ -343,40 +343,24 @@ async function fetchCodexHealthFromLiveProxy( const token = configuredAdminToken(); const headers: Record = {}; try { + let baseUrl = `http://${probeHostname(live.hostname)}:${live.port}`; if (token) { // Public /healthz identity is intentionally forgeable enough for liveness, not // strong enough to receive a bearer. Prove the listener knows the per-process // secret stored in the protected runtime record before attaching the admin token. - if (live.source !== "runtime" || live.pid === null) { - return { source: "management-api-unavailable", entries: null }; - } - const attestedPid = live.pid; - const runtime = readRuntimePortImpl(attestedPid); - if (!runtime?.attestationSecret || runtime.port !== live.port) { - return { source: "management-api-unavailable", entries: null }; - } - const challenge = createLocalAttestationChallenge(); - const proofResponse = await fetchImpl( - `http://${probeHostname(live.hostname)}:${live.port}/healthz`, - { - headers: { [ATTESTATION_CHALLENGE_HEADER]: challenge }, - signal: AbortSignal.timeout(4000), - }, - ); - const proof = proofResponse.headers.get(ATTESTATION_PROOF_HEADER); - if (!proofResponse.ok || !verifyLocalAttestationProof( - runtime.attestationSecret, - challenge, - attestedPid, - live.port, - proof, - )) { + const target = await attestLiveManagementProxy({ + fetchFn: fetchImpl, + readRuntimeFn: readRuntimePortImpl, + verifyPidFn: verifyPidIdentityImpl, + }); + if (!target) { return { source: "management-api-unavailable", entries: null }; } + baseUrl = target.baseUrl; headers.Authorization = `Bearer ${token}`; } const res = await fetchImpl( - `http://${probeHostname(live.hostname)}:${live.port}/api/codex-auth/accounts`, + `${baseUrl}/api/codex-auth/accounts`, { headers, signal: AbortSignal.timeout(4000) }, ); if (res.status === 401 || res.status === 403) { @@ -426,6 +410,7 @@ export async function collectOAuthHealthEntriesForCli( fetchImpl?: typeof fetch; findLiveProxyImpl?: typeof findLiveProxy; readRuntimePortImpl?: typeof readRuntimePort; + verifyPidIdentityImpl?: typeof verifyPidIdentity; } = {}, ): Promise { const entries = collectOAuthHealthEntries(now, { observeOnly: true, includeLocalCodex: false }); @@ -433,6 +418,7 @@ export async function collectOAuthHealthEntriesForCli( deps.fetchImpl, deps.findLiveProxyImpl, deps.readRuntimePortImpl, + deps.verifyPidIdentityImpl, ); if (remote.entries) { for (const entry of remote.entries) entries.push(entry); diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index 3f69225094..497c6c5d6f 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -1,7 +1,10 @@ import * as readline from "node:readline"; import { openUrl } from "../lib/open-url"; import { loadConfig, saveConfig } from "../config"; -import { findLiveProxy, probeHostname } from "../server/proxy-liveness"; +import { + attestLiveManagementProxy, + type ManagementAttestationIo, +} from "../server/proxy-liveness"; import { isPublicOAuthProvider, listOAuthProviders, runLogin } from "./index"; import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey, type KeyLoginProvider } from "./key-providers"; import type { CodexCommanderProviderConfig } from "../types"; @@ -17,13 +20,17 @@ export function runningProxyUpdateHeaders(): Headers { } /** Push the new provider into a running proxy's live config so it routes without a restart. */ -export async function notifyRunningProxy(name: string, provider: unknown): Promise { - // Identity-checked runtime-port lookup: reaches a fallback-port proxy and avoids - // posting credentials-adjacent config to whatever else answers on config.port. - const live = await findLiveProxy(); - if (!live) return; +export async function notifyRunningProxy( + name: string, + provider: unknown, + io: ManagementAttestationIo = {}, +): Promise { + // Provider config may contain a durable upstream credential. Release neither the + // body nor the admin token until the exact runtime listener proves its secret. + const target = await attestLiveManagementProxy(io); + if (!target) return; try { - await fetch(`http://${probeHostname(live.hostname)}:${live.port}/api/providers`, { + await (io.fetchFn ?? fetch)(`${target.baseUrl}/api/providers`, { method: "POST", headers: runningProxyUpdateHeaders(), body: JSON.stringify({ name, provider }), @@ -40,10 +47,13 @@ export async function notifyRunningProxy(name: string, provider: unknown): Promi * Must not send `OAUTH_PROVIDERS[name].providerConfig`: POST /api/providers replaces the * live entry and saves it, which would drop the preserved key billing state. */ -export async function notifyRunningProxyAfterOAuthLogin(name: string): Promise { +export async function notifyRunningProxyAfterOAuthLogin( + name: string, + io: ManagementAttestationIo = {}, +): Promise { const provider = loadConfig().providers[name]; if (!provider) return; - await notifyRunningProxy(name, provider); + await notifyRunningProxy(name, provider, io); } export async function handleLogin(provider?: string): Promise { diff --git a/src/server/gui-static.ts b/src/server/gui-static.ts index 1a1a07533a..023d3e41c8 100644 --- a/src/server/gui-static.ts +++ b/src/server/gui-static.ts @@ -1,7 +1,6 @@ import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs"; import { extname, isAbsolute, join, relative, resolve } from "node:path"; import { browserSecurityHeaders } from "./auth-cors"; -import type { GuiSessionBootstrap } from "./management-auth"; /** CodexCommander version, read from the packaged package.json (same source as the server bootstrap). */ const VERSION = (() => { @@ -79,24 +78,6 @@ export function resolveGuiFilePath(guiDist: string, pathname: string): string | return filePath; } -/** HTML-attribute escape for values interpolated into meta tags. */ -function escapeHtmlAttribute(value: string): string { - return value - .replaceAll("&", "&") - .replaceAll('"', """) - .replaceAll("<", "<") - .replaceAll(">", ">"); -} - -/** Shared session meta-tag block, escaped for quoted attribute interpolation. */ -function sessionBootstrapMeta(session: GuiSessionBootstrap): string { - return [ - ``, - ``, - ``, - ].join(""); -} - function htmlDocumentResponse(html: string): Response { return new Response(html, { headers: { @@ -108,30 +89,13 @@ function htmlDocumentResponse(html: string): Response { }); } -function htmlResponse(path: string, session?: GuiSessionBootstrap): Response { - let html = readFileSync(path, "utf8"); - if (session) { - const bootstrap = sessionBootstrapMeta(session); - html = html.includes("") ? html.replace("", `${bootstrap}`) : `${bootstrap}${html}`; - } - return htmlDocumentResponse(html); -} - -/** - * Minimal session-bootstrap document, independent of any packaged GUI build. The dev - * GUI (Vite) proxies /codexcommander-session to the backend with the original host so the - * backend can mint an origin-bound loopback session even when gui/dist does not exist. - */ -export function serveSessionBootstrap(session: GuiSessionBootstrap): Response { - const bootstrap = sessionBootstrapMeta(session); - const html = `${bootstrap}`; - return htmlDocumentResponse(html); +function htmlResponse(path: string): Response { + return htmlDocumentResponse(readFileSync(path, "utf8")); } export function serveGuiFile( pathname: string, guiDist = findGuiDist(), - session?: GuiSessionBootstrap, ): Response | null { if (!guiDist) return null; const root = physicalGuiDist(guiDist); @@ -144,7 +108,7 @@ export function serveGuiFile( if (!extname(pathname)) { const indexPath = safeGuiFile(root, join(root, "index.html")); if (indexPath) { - return htmlResponse(indexPath, session); + return htmlResponse(indexPath); } } return null; @@ -152,7 +116,7 @@ export function serveGuiFile( const ext = extname(filePath); const contentType = MIME_TYPES[ext] || "application/octet-stream"; - if (ext === ".html") return htmlResponse(filePath, session); + if (ext === ".html") return htmlResponse(filePath); return new Response(Bun.file(filePath), { headers: { "Content-Type": contentType, ...browserSecurityHeaders() }, }); diff --git a/src/server/index.ts b/src/server/index.ts index a52c17b0ba..222ae0778d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -18,9 +18,6 @@ import { loadConfig, websocketsEnabled, } from "../config"; -import { withCatalogWriteSerialization } from "../codex/catalog-write-serialization"; -import { invalidateCodexModelsCacheWithPermit } from "../codex/catalog/sync"; -import { getCodexHome } from "../codex/paths"; import { shouldSyncCodexOnStart } from "../codex/desired-state"; import { inspectNativeCodexOwnership } from "../integrations/native/ownership-preflight"; import { registerCodexCooldownRecoveryProbeWorker } from "../codex/auth-api"; @@ -45,7 +42,7 @@ import { setStorageCleanupPolicyJobLiveApply } from "../storage/policy-job"; import { scheduleStorageCleanupStartupRun, startStorageCleanupScheduler } from "../storage/policy-scheduler"; import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; import { providerCodexAccountMode } from "../providers/registry"; -import type { StorageCleanupPolicy } from "../types"; +import type { CodexCommanderConfig, StorageCleanupPolicy } from "../types"; import { CodexAccountCooldownError, cooldownErrorMessage, @@ -59,7 +56,7 @@ export { import { formatCodexProviderForLog } from "../codex/routing"; import { CatalogGatherBusyError } from "../codex/catalog/provider-fetch"; import { registerCodexWebSocket, tryReserveCodexWebSocket, unregisterCodexWebSocket, updateCodexWebSocketAuthContext } from "../codex/websocket-registry"; -import { resolveGuiFilePath, rootFallbackPayload, serveGuiFile, serveSessionBootstrap } from "./gui-static"; +import { resolveGuiFilePath, rootFallbackPayload, serveGuiFile } from "./gui-static"; export { resolveGuiFilePath, rootFallbackPayload } from "./gui-static"; export { resolveAdapter } from "./adapter-resolve"; import { formatErrorResponse, type ResponsesTerminalStatus } from "../bridge"; @@ -132,6 +129,8 @@ import { isApiAuthRequired, isLoopbackHostname, jsonResponse, + managementRequestOrigin, + parseHttpHost, admissionFields, resolveApiAuth, resolveResponsesApiAuth, @@ -172,11 +171,13 @@ import { ATTESTATION_CHALLENGE_HEADER, ATTESTATION_PROOF_HEADER, HEALTH_SERVICE_ID, - SESSION_PATH, + GUI_LAUNCH_EXCHANGE_PATH, + GUI_LAUNCH_TICKET_PATH, } from "../identity"; import { + exchangeGuiLaunchTicket, initializeManagementAuthState, - issueGuiSession, + issueGuiLaunchTicket, managementPrincipal, requireManagementAuth, type ManagementAuthState, @@ -191,6 +192,100 @@ const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024; const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0; const LIVE_SIDEBAND_PENDING_MAX = 32; const LIVE_SIDEBAND_CLOSE_FALLBACK_MS = 1_000; +const GUI_LAUNCH_BODY_MAX_BYTES = 2 * 1024; + +type GuiLaunchBodyResult = + | { ok: true; body: Record } + | { ok: false; status: 400 | 413 | 415 }; + +function acceptsJsonBody(req: Request): boolean { + const value = req.headers.get("content-type"); + if (!value) return false; + return value.split(";", 1)[0]?.trim().toLowerCase() === "application/json"; +} + +/** + * Launch exchange is pre-authenticated by a bearer in its body, so the limit must + * be enforced while streaming. `Request.text()` would allocate the entire body + * before a post-read size check and therefore would not be a real admission cap. + */ +async function readGuiLaunchBody(req: Request): Promise { + if (!acceptsJsonBody(req)) return { ok: false, status: 415 }; + const encoding = req.headers.get("content-encoding"); + if (encoding !== null && encoding.trim().toLowerCase() !== "identity") { + return { ok: false, status: 415 }; + } + const declared = req.headers.get("content-length"); + if (declared !== null) { + const normalized = declared.trim(); + if (!/^\d+$/.test(normalized)) return { ok: false, status: 400 }; + if (BigInt(normalized) > BigInt(GUI_LAUNCH_BODY_MAX_BYTES)) { + return { ok: false, status: 413 }; + } + } + if (!req.body) return { ok: false, status: 400 }; + + const reader = req.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + length += value.byteLength; + if (length > GUI_LAUNCH_BODY_MAX_BYTES) { + try { await reader.cancel(); } catch { /* best effort */ } + return { ok: false, status: 413 }; + } + chunks.push(value); + } + } catch { + try { await reader.cancel(); } catch { /* best effort */ } + return { ok: false, status: 400 }; + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + const parsed: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + ? { ok: true, body: parsed as Record } + : { ok: false, status: 400 }; + } catch { + return { ok: false, status: 400 }; + } +} + +function guiLaunchBodyError(status: 400 | 413 | 415): Response { + const message = status === 413 + ? "GUI launch request is too large." + : status === 415 + ? "GUI launch request must be uncompressed JSON." + : "GUI launch request is invalid."; + return Response.json({ error: message }, { status }); +} + +function noStoreManagementResponse(response: Response, req: Request, config: CodexCommanderConfig): Response { + response.headers.set("Cache-Control", "no-store"); + return withManagementCors(response, req, config); +} + +function hasExactGuiLaunchOrigin(req: Request, config: CodexCommanderConfig): boolean { + const host = parseHttpHost(req.headers.get("Host")); + const requestOrigin = managementRequestOrigin(req, config); + const browserOrigin = req.headers.get("Origin"); + return !!host + && isLoopbackHostname(host.hostname) + && requestOrigin !== null + && browserOrigin === requestOrigin + && isAllowedManagementOrigin(req, config); +} type LiveSidebandWebSocketFactory = ( url: string, @@ -378,27 +473,6 @@ export interface StartServerDeps { readinessGate?: ReadinessGate; } -/* - * #1046. `startServer` rewrites the Codex models cache during boot, and an - * app-server that started earlier keeps its own in-memory model list. The stale - * warning is not emitted here: `handleStart` runs a catalog sync moments later, - * so warning now would read an mtime that write is about to move, and both sites - * calling the helper independently would warn twice. This records the fact; the - * CLI start path owns the single decision. - * - * A caller that starts a server without `handleStart` (tests, embedded use) - * deliberately gets no warning — lifecycle diagnostics belong to whoever owns - * the lifecycle. - */ -let startupCacheInvalidationWrote = false; - -/** #1046: did this process's startup cache invalidation actually write? */ -export function consumeStartupCacheInvalidationWrite(): boolean { - const wrote = startupCacheInvalidationWrote; - startupCacheInvalidationWrote = false; - return wrote; -} - export function startServer(port?: number, deps: StartServerDeps = {}): Server { const localAttestationSecret = deps.localAttestationSecret ?? createLocalAttestationSecret(); const config = loadConfig(); @@ -420,21 +494,6 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server - invalidateCodexModelsCacheWithPermit(permit, startupCodexHome)); - // A refused permit is not a write; only a completed run that returned true is. - startupCacheInvalidationWrote = outcome.kind === "completed" && outcome.value === true; - } catch { /* no readable Codex home: nothing to invalidate */ } // Arm the `claudeCode` hand-edit guard (implementation contract H1) BEFORE // the server can serve a request. Arming is eager on // purpose: a lazy "arm on first save" loses exactly the hand edit made before that @@ -659,6 +718,51 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { + async function convergeCodexCatalog( + configOverride: Readonly = config, + ): Promise { let convergenceInvoked = false; let managementConvergeCodex: ConvergeCodex | undefined; try { @@ -142,12 +145,12 @@ export async function handleManagementAPI( const factory = deps.createManagementConvergeCodex ?? (await import("../codex/management-convergence")).createManagementConvergeCodex; if (typeof factory !== "function") throw new TypeError("Catalog convergence factory is unavailable."); - let binding = managementConvergenceBindings.get(config); + let binding = managementConvergenceBindings.get(configOverride as object); if (!binding || binding.factory !== factory) { - const created = factory(config); + const created = factory(configOverride); if (typeof created !== "function") throw new TypeError("Catalog convergence factory returned no function."); binding = { factory, converge: created }; - managementConvergenceBindings.set(config, binding); + managementConvergenceBindings.set(configOverride as object, binding); } managementConvergeCodex = binding.converge; } @@ -160,7 +163,7 @@ export async function handleManagementAPI( const disposition = outcome.catalogRefresh; try { const { reconcileOpencodeIntegrationIfEnabled } = await import("./management/opencode-integration-routes"); - await reconcileOpencodeIntegrationIfEnabled(config, Number(url.port) || config.port); + await reconcileOpencodeIntegrationIfEnabled(configOverride, Number(url.port) || configOverride.port); } catch { // Optional client integration: catalog/config mutations remain successful when OpenCode // is absent or its user-owned config needs attention. @@ -216,6 +219,7 @@ export async function handleManagementAPI( ?? (await handleModelRoutes(ctx)) ?? (await handleNativeIntegrationRoutes(ctx)) ?? (await handleAgentSettingsRoutes(ctx)) + ?? (await handleCatalogActivationRoutes(ctx)) ?? (await handleOauthAccountRoutes(ctx)) ?? (await handleComboRoutes(ctx)) ?? (await handleActivityRoutes(ctx)) diff --git a/src/server/management-auth.ts b/src/server/management-auth.ts index e9caa42f4a..82724b1d0c 100644 --- a/src/server/management-auth.ts +++ b/src/server/management-auth.ts @@ -18,6 +18,7 @@ import { ADMIN_KEY_PREFIX, API_KEY_HEADER, CSRF_HEADER, + GUI_LAUNCH_TICKET_PREFIX, GUI_ORIGIN_HEADER, GUI_SESSION_PREFIX, } from "../identity"; @@ -25,32 +26,52 @@ import { forgetEphemeralSecretPath, forgetHardenedSecretPath, hardenSecretDir, h import type { CodexCommanderConfig } from "../types"; import { isAllowedManagementOrigin, - isApiAuthRequired, isDataPlaneAdmissionSecret, isLoopbackHostname, managementRequestOrigin, parseHttpHost, } from "./auth-cors"; -const GUI_SESSION_TTL_MS = 5 * 60_000; +// A confirmed launch is an explicit local-app handoff. Keep that browser useful +// for a workday without adding a renewable bearer or persisting capability. +const CONFIRMED_GUI_SESSION_TTL_MS = 8 * 60 * 60_000; const GUI_SESSION_LIMIT = 128; +const GUI_LAUNCH_TICKET_TTL_MS = 30_000; +const GUI_LAUNCH_TICKET_LIMIT = 16; +const GUI_LAUNCH_ROUTE_MAX_LENGTH = 512; interface GuiSessionRecord { csrfToken: string; origin: string; expiresAt: number; + confirmedLaunch?: true; +} + +interface GuiLaunchTicketRecord { + origin: string; + route: string; + expiresAt: number; } export interface GuiSessionBootstrap extends GuiSessionRecord { token: string; } +export interface GuiLaunchTicket { + ticket: string; + origin: string; + route: string; + expiresAt: number; +} + export type ManagementAuthState = | { available: true; token: string; source: "environment" | "file"; sessions: Map; + /** Optional for compatibility with narrow test fixtures created before launch tickets. */ + launchTickets?: Map; } | { available: false; reason: string }; @@ -180,7 +201,7 @@ function ready(token: string, source: "environment" | "file", config: CodexComma if (isDataPlaneAdmissionSecret(token, config)) { return fail("management credential conflicts with a data-plane credential"); } - return { available: true, token, source, sessions: new Map() }; + return { available: true, token, source, sessions: new Map(), launchTickets: new Map() }; } export function initializeManagementAuthState(config: CodexCommanderConfig): ManagementAuthState { @@ -217,21 +238,117 @@ function removeExpiredSessions(state: Extract, +): Map { + return state.launchTickets ??= new Map(); +} + +function removeExpiredLaunchTickets( + state: Extract, + now = Date.now(), +): void { + for (const [ticket, record] of launchTicketStore(state)) { + if (record.expiresAt <= now) launchTicketStore(state).delete(ticket); + } +} + function randomSessionSecret(): string { return `${GUI_SESSION_PREFIX}${randomBytes(32).toString("base64url")}`; } -export function issueGuiSession( +function randomLaunchTicket(): string { + return `${GUI_LAUNCH_TICKET_PREFIX}${randomBytes(32).toString("base64url")}`; +} + +/** + * Dashboard hash route accepted by the fixed launch handoff. It is deliberately + * relative and fragment-safe: launch tickets can select an in-app destination, + * never another origin or a second fragment parser. + */ +export function isGuiLaunchRoute(route: unknown): route is string { + return typeof route === "string" + && route.length > 0 + && route.length <= GUI_LAUNCH_ROUTE_MAX_LENGTH + && !route.startsWith("/") + && !route.includes("#") + && !/[\u0000-\u001f\u007f]/.test(route); +} + +/** + * Mint one short-lived browser handoff after the caller has already passed the + * raw admin-token gate. The ticket is process-memory-only and bound to the exact + * loopback origin and requested in-app route. + */ +export function issueGuiLaunchTicket( req: Request, + route: unknown, config: CodexCommanderConfig, state: ManagementAuthState, -): GuiSessionBootstrap | null { - if (isApiAuthRequired(config) || !state.available || req.method !== "GET" || !isAllowedManagementOrigin(req, config)) return null; + now = Date.now(), +): GuiLaunchTicket | null { + if (!state.available + || req.method !== "POST" + || !isAllowedManagementOrigin(req, config) + || !isGuiLaunchRoute(route)) return null; const host = parseHttpHost(req.headers.get("Host")); if (!host || !isLoopbackHostname(host.hostname)) return null; const origin = managementRequestOrigin(req, config); if (!origin) return null; - const now = Date.now(); + + removeExpiredLaunchTickets(state, now); + const tickets = launchTicketStore(state); + while (tickets.size >= GUI_LAUNCH_TICKET_LIMIT) { + const oldest = tickets.keys().next().value as string | undefined; + if (!oldest) break; + tickets.delete(oldest); + } + const ticket = randomLaunchTicket(); + const record: GuiLaunchTicketRecord = { + origin, + route, + expiresAt: now + GUI_LAUNCH_TICKET_TTL_MS, + }; + tickets.set(ticket, record); + return { ticket, ...record }; +} + +/** + * Consume a launch ticket exactly once. Once a syntactically valid ticket names + * a live record it is deleted before any route/origin check, so failed and raced + * exchanges cannot retry it. A successful exchange creates the only browser + * management session admitted by the server. + */ +export function exchangeGuiLaunchTicket( + req: Request, + ticket: unknown, + route: unknown, + config: CodexCommanderConfig, + state: ManagementAuthState, + now = Date.now(), +): GuiSessionBootstrap | null { + if (!state.available + || req.method !== "POST" + || typeof ticket !== "string" + || !ticket.startsWith(GUI_LAUNCH_TICKET_PREFIX) + || !isGuiLaunchRoute(route)) return null; + removeExpiredLaunchTickets(state, now); + const tickets = launchTicketStore(state); + const record = tickets.get(ticket); + if (!record) return null; + tickets.delete(ticket); + + const host = parseHttpHost(req.headers.get("Host")); + const requestOrigin = managementRequestOrigin(req, config); + const browserOrigin = req.headers.get("Origin"); + if (record.expiresAt <= now + || record.route !== route + || !host + || !isLoopbackHostname(host.hostname) + || requestOrigin !== record.origin + || browserOrigin !== record.origin + || !isAllowedManagementOrigin(req, config)) return null; + removeExpiredSessions(state, now); while (state.sessions.size >= GUI_SESSION_LIMIT) { const oldest = state.sessions.keys().next().value as string | undefined; @@ -241,8 +358,9 @@ export function issueGuiSession( const token = randomSessionSecret(); const session: GuiSessionRecord = { csrfToken: randomBytes(32).toString("base64url"), - origin, - expiresAt: now + GUI_SESSION_TTL_MS, + origin: record.origin, + expiresAt: now + CONFIRMED_GUI_SESSION_TTL_MS, + confirmedLaunch: true, }; state.sessions.set(token, session); return { token, ...session }; @@ -251,13 +369,14 @@ export function issueGuiSession( /** * Which credential actually authorized a management request. * - * `admin-token` is the raw token from disk/env: anything running as the user can - * read it, including a coding agent. `gui-session` is a session token this process - * minted for a browser, and it only authorizes a mutation after the origin and the - * per-session CSRF token match. Consent-bearing routes must key off this value - * rather than off request headers, which the token holder can forge freely. + * `admin-token` is the raw token from disk/env. `confirmed-gui-session` records a + * native/CLI browser handoff, and unsafe requests also require exact origin plus + * the per-session CSRF token. This blocks cross-user loopback spoofing, drive-by + * CSRF, and accidental raw-API calls; it is not user-presence proof. A malicious + * process running as the same trusted OS account can read the admin token, mint a + * launch ticket, and complete the exchange itself. */ -export type ManagementPrincipal = "admin-token" | "gui-session"; +export type ManagementPrincipal = "admin-token" | "confirmed-gui-session"; /** * The principal for a request that already passed `requireManagementAuth`. Kept as a @@ -277,7 +396,8 @@ export function managementPrincipal( if (equalSecret(actual, state.token)) return "admin-token"; if (!config) return null; removeExpiredSessions(state); - return state.sessions.has(actual) ? "gui-session" : null; + const session = state.sessions.get(actual); + return session?.confirmedLaunch === true ? "confirmed-gui-session" : null; } export function requireManagementAuth( @@ -298,7 +418,7 @@ export function requireManagementAuth( if (actual && config) { removeExpiredSessions(state); const session = state.sessions.get(actual); - if (session) { + if (session?.confirmedLaunch === true) { const requestOrigin = managementRequestOrigin(req, config); const claimedOrigin = req.headers.get(GUI_ORIGIN_HEADER); const browserOrigin = req.headers.get("Origin"); diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 16b26a9bca..a77e56df45 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1,7 +1,17 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; -import { catalogModelSlug, effectiveSubagentRoster, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { + catalogOnlyWorkerStateFromActivation, + captureCodexCatalogDesiredSnapshot, + codexCatalogDesiredRevision, + collectCodexCatalogActivationWorkerState, + inspectCodexCatalogActivation, + resetCodexCatalogActivationWorkerStateCache, +} from "../../codex/catalog-activation"; +import type { CatalogConfigAuthoritySnapshot } from "../../codex/catalog-admission"; +import { resetCodexAppServerCatalogStateCache } from "../../codex/app-server-processes"; import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, @@ -61,6 +71,7 @@ import { applySystemEnvToggle } from "../system-env"; import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels, fetchGrokCandidateModels, buildClaudeDesktopState } from "./shared"; import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import { projectCatalogActivationForPrincipal } from "./catalog-activation-routes"; const GROK_APPLY_JOIN_MS = 120_000; export const GROK_APPLY_TERMINAL_MS = 10 * 60_000; @@ -177,6 +188,52 @@ import type { ManagementContext } from "./context"; export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; + function resetCatalogWorkerObservation(): void { + (deps.resetCodexAppServerCatalogStateCache ?? resetCodexAppServerCatalogStateCache)(); + resetCodexCatalogActivationWorkerStateCache(); + } + + function collectCatalogWorkerObservation() { + if (deps.collectCodexAppServerCatalogState) { + const catalogState = deps.collectCodexAppServerCatalogState(); + return { catalogState, activationWorkers: catalogState }; + } + const activationWorkers = collectCodexCatalogActivationWorkerState(); + return { + catalogState: catalogOnlyWorkerStateFromActivation(activationWorkers), + activationWorkers, + }; + } + + function currentCatalogDesired(): { + config: CodexCommanderConfig; + revision: string; + authority?: CatalogConfigAuthoritySnapshot; + } { + if (deps.captureCatalogDesiredSnapshotForActivation) { + return deps.captureCatalogDesiredSnapshotForActivation(); + } + if (deps.loadConfigForCatalogActivation) { + const current = deps.loadConfigForCatalogActivation(); + return { config: current, revision: codexCatalogDesiredRevision(current) }; + } + // Direct route tests intentionally supply an in-memory persistence seam. + if (deps.saveConfigPreservingClaudeCode) { + return { config, revision: codexCatalogDesiredRevision(config) }; + } + // Direct management tests can inject a pure convergence factory around an + // intentionally non-persisted config fixture. Production never supplies + // this seam, so malformed/unreadable persisted state still fails closed. + if (deps.createManagementConvergeCodex) { + return { config, revision: codexCatalogDesiredRevision(config) }; + } + // A malformed or unreadable persisted config is not permission to report + // the server's older startup snapshot as current. Let the management error + // boundary surface the read failure instead of manufacturing false catalog + // or activation state. + return captureCodexCatalogDesiredSnapshot(); + } + /** Best-effort Desktop 3P config auto-reconcile when providers change. */ async function autoApplyDesktopBestEffort(): Promise { try { @@ -227,12 +284,13 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise getAgentsEnabled, getAgentsMaxDepth, getSubagentDeveloperInstructions, } = await import("../../codex/features"); const enabled = isMultiAgentV2Enabled(); + const current = currentCatalogDesired().config; return jsonResponse({ enabled, agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(), maxConcurrentThreadsPerSession: getLogicalMaxThreads(), - multiAgentMode: config.multiAgentMode ?? "default", - multiAgentV2MessageDelivery: config.multiAgentV2MessageDelivery ?? "encrypted", + multiAgentMode: current.multiAgentMode ?? "default", + multiAgentV2MessageDelivery: current.multiAgentV2MessageDelivery ?? "encrypted", agentsEnabled: getAgentsEnabled(), agentsMaxDepth: getAgentsMaxDepth(), subagentDeveloperInstructions: getSubagentDeveloperInstructions(), @@ -318,20 +376,52 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (!result.ok) return jsonResponse({ error: `multi_agent_v2 transition failed: ${result.error}` }, 502); if (result.changed && result.threadLimit !== null) warnings.push(`Thread limit ${result.threadLimit} preserved for ${targetFlag ? "v2" : "v1"}.`); } - if (wantsMode) { - if (mode === "default") delete config.multiAgentMode; - else config.multiAgentMode = mode; - warnings.push(`Multi-agent mode set to '${mode}'. Applies to new sessions.`); - } - if (wantsMessageDelivery) { - if (body.multiAgentV2MessageDelivery === "plaintext") { - config.multiAgentV2MessageDelivery = "plaintext"; + if (wantsMode || wantsMessageDelivery) { + const applyRequestedConfigFields = (current: CodexCommanderConfig): boolean => { + let changed = false; + if (wantsMode) { + const next = mode === "default" ? undefined : mode; + if (current.multiAgentMode !== next) changed = true; + if (next === undefined) delete current.multiAgentMode; + else current.multiAgentMode = next; + } + if (wantsMessageDelivery) { + const next = body.multiAgentV2MessageDelivery === "plaintext" ? "plaintext" as const : undefined; + if (current.multiAgentV2MessageDelivery !== next) changed = true; + if (next === undefined) delete current.multiAgentV2MessageDelivery; + else current.multiAgentV2MessageDelivery = next; + } + return changed; + }; + if (deps.saveConfigPreservingClaudeCode) { + applyRequestedConfigFields(config); + deps.saveConfigPreservingClaudeCode(config); } else { - delete config.multiAgentV2MessageDelivery; + const persisted = mutatePersistedConfig(current => ({ + changed: applyRequestedConfigFields(current), + value: { + multiAgentMode: current.multiAgentMode, + multiAgentV2MessageDelivery: current.multiAgentV2MessageDelivery, + }, + })); + if (persisted.status === "unavailable") { + const status = persisted.reason === "conflict" ? 503 : 409; + const response = jsonResponse({ error: `multi-agent policy could not be saved (${persisted.reason})` }, status); + if (status === 503) response.headers.set("Retry-After", "1"); + return response; + } + if (persisted.value.multiAgentMode === undefined) delete config.multiAgentMode; + else config.multiAgentMode = persisted.value.multiAgentMode; + if (persisted.value.multiAgentV2MessageDelivery === undefined) delete config.multiAgentV2MessageDelivery; + else config.multiAgentV2MessageDelivery = persisted.value.multiAgentV2MessageDelivery; + } + if (wantsMode) { + warnings.push(`Multi-agent mode set to '${mode}'. Apply the catalog to Codex, then start a new task.`); + } + if (wantsMessageDelivery) { + warnings.push("V2 task-message delivery changes affect subsequent requests. Start a new task instead of switching an active conversation."); } - warnings.push("V2 message delivery changes affect subsequent requests. Start a new session instead of switching an active conversation."); } - if (wantsMode || wantsMessageDelivery) saveConfigPreservingClaudeCode(config); // New-key scalar writes: each writer is individually atomic, so apply them in // sequence after the transition. A failure here is a persistence failure (the // writers' ok:false result or a throw from the underlying atomic write helper), @@ -362,22 +452,45 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (getAgentsEnabled() === false && isMultiAgentV2Enabled()) { warnings.push("agents.enabled = false has no effect while features.multi_agent_v2 is enabled; upstream keeps V2 active."); } - const catalogRefresh = await convergeCodexCatalog(); - if (requestedFlag !== undefined) warnings.push("Applies to new sessions; restart the Codex app or wait out its picker cache to see the ladder change."); + const catalogAffectingChange = wantsMode || requestedFlag !== undefined; + const activationAffectingChange = catalogAffectingChange + || wantsThreads + || wantsAgentsEnabled + || wantsMaxDepth + || wantsSubagentInstructions; + const desiredForCatalog = currentCatalogDesired(); + const catalogRefresh = catalogAffectingChange + ? await convergeCodexCatalog(desiredForCatalog.config) + : { status: "skipped" as const, reason: "not-requested" as const, retryable: false }; + if (requestedFlag !== undefined && !wantsMode) { + warnings.push("The collaboration protocol changed. Apply the catalog to Codex, then start a new task."); + } + if (activationAffectingChange) resetCatalogWorkerObservation(); + const { activationWorkers } = collectCatalogWorkerObservation(); + const responseDesired = currentCatalogDesired(); + const activation = inspectCodexCatalogActivation( + responseDesired.config, + activationWorkers, + catalogRefresh, + responseDesired.authority, + deps.catalogArtifactProofForActivation?.(), + deps.codexRoutingKindForActivation?.(), + ); const enabled = isMultiAgentV2Enabled(); return jsonResponse({ ok: true, enabled, agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(), maxConcurrentThreadsPerSession: getLogicalMaxThreads(), - multiAgentMode: config.multiAgentMode ?? "default", - multiAgentV2MessageDelivery: config.multiAgentV2MessageDelivery ?? "encrypted", + multiAgentMode: responseDesired.config.multiAgentMode ?? "default", + multiAgentV2MessageDelivery: responseDesired.config.multiAgentV2MessageDelivery ?? "encrypted", agentsEnabled: getAgentsEnabled(), agentsMaxDepth: getAgentsMaxDepth(), subagentDeveloperInstructions: getSubagentDeveloperInstructions(), agentsMaxDepthAppliesWhenV2Disabled: !enabled, warnings, catalogRefresh, + activation: projectCatalogActivationForPrincipal(activation, ctx.principal), }); } @@ -485,6 +598,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise let nextModel = config.injectionModel; let nextEffort = config.injectionEffort; let nextPrompt = config.injectionPrompt; + const previousSyncCodexSubagentDefaults = subagentDefaultSyncEffective(config); if ("multiAgentGuidanceEnabled" in body) { if (typeof body.multiAgentGuidanceEnabled !== "boolean") { @@ -544,6 +658,37 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise else delete config.injectionPrompt; saveConfigPreservingClaudeCode(config); + const nativeDefaultsAffectingChange = "syncCodexSubagentDefaults" in body + || (("model" in body || "effort" in body) + && (previousSyncCodexSubagentDefaults || nextSyncCodexSubagentDefaults)); + let nativeDefaultsRefresh; + let activation; + if (nativeDefaultsAffectingChange) { + const desired = currentCatalogDesired(); + try { + const reconcile = deps.reconcileManagementNativeSubagentDefaults + ?? (await import("../../codex/management-native-defaults")) + .reconcileManagementNativeSubagentDefaults; + nativeDefaultsRefresh = await reconcile(desired.config, desired.authority); + } catch (error) { + nativeDefaultsRefresh = { + status: "failed" as const, + retryable: false, + message: error instanceof Error ? error.message : "Native Codex defaults could not be reconciled.", + }; + } + if (nativeDefaultsRefresh.status === "reconciled") resetCatalogWorkerObservation(); + const { activationWorkers } = collectCatalogWorkerObservation(); + const responseDesired = currentCatalogDesired(); + activation = inspectCodexCatalogActivation( + responseDesired.config, + activationWorkers, + { status: "skipped", reason: "not-requested", retryable: false }, + responseDesired.authority, + deps.catalogArtifactProofForActivation?.(), + deps.codexRoutingKindForActivation?.(), + ); + } return jsonResponse({ ok: true, multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config), @@ -551,6 +696,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise model: config.injectionModel ?? null, effort: config.injectionEffort ?? null, prompt: config.injectionPrompt ?? null, + ...(nativeDefaultsRefresh ? { nativeDefaultsRefresh } : {}), + ...(activation + ? { activation: projectCatalogActivationForPrincipal(activation, ctx.principal) } + : {}), }); } @@ -583,12 +732,14 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } // Subagent model picker: persist up to five requested quick picks. Codex advertises the first - // five picker-visible catalog rows, so the response reports the effective V2 projection rather + // five picker-visible catalog rows, so the response reports the protocol-aware projection rather // than implying every saved choice necessarily entered that window. PUT reprioritizes the // injected catalog so eligible chosen rows lead. if (url.pathname === "/api/subagent-models" && req.method === "GET") { - const models = await fetchAllModels(config); - const disabled = new Set(config.disabledModels ?? []); + const rosterDesired = currentCatalogDesired(); + const rosterConfig = rosterDesired.config; + const models = await fetchAllModels(rosterConfig); + const disabled = new Set(rosterConfig.disabledModels ?? []); // Native gpt (passthrough) are also valid subagent picks — they're picker-visible models in the // catalog, just buried by priority. List them first so the user can feature them over routed. const { listCatalogNativeSlugs } = await import("../../codex/catalog"); @@ -601,39 +752,89 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise ]; // #857: let CLI/GUI show when a running Codex app-server keeps an older // in-memory catalog than the one on disk. - const { collectCodexAppServerCatalogState } = await import("../../codex/app-server-processes"); - const catalogState = collectCodexAppServerCatalogState(); - const effectiveV2 = effectiveSubagentRoster(config.subagentModels ?? [], "v2"); - return jsonResponse({ - chosen: config.subagentModels ?? [], + const { catalogState, activationWorkers } = collectCatalogWorkerObservation(); + const activation = inspectCodexCatalogActivation( + rosterConfig, + activationWorkers, + undefined, + rosterDesired.authority, + deps.catalogArtifactProofForActivation?.(), + deps.codexRoutingKindForActivation?.(), + ); + const response = jsonResponse({ + chosen: rosterConfig.subagentModels ?? [], available, catalogState, - advertised: effectiveV2.advertised.map(model => model.model), - excluded: effectiveV2.excluded, + advertised: activation.catalog.advertised, + excluded: activation.catalog.excluded, + activation: projectCatalogActivationForPrincipal(activation, ctx.principal), }); + response.headers.set("Cache-Control", "no-store"); + return response; } if (url.pathname === "/api/subagent-models" && req.method === "PUT") { let body: { models?: unknown }; try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } if (!Array.isArray(body.models)) return jsonResponse({ error: "models must be an array" }, 400); - const chosen = body.models.slice(0, 5); + if (body.models.length > 5) return jsonResponse({ error: "models must contain at most five selectors" }, 400); + const chosen = body.models; if (chosen.some(model => typeof model !== "string" || model.trim().length === 0 || !isCanonicalPersistedModelSelector(model.trim()))) { return jsonResponse({ error: "models must contain canonical selectors" }, 400); } const canonicalChosen = chosen.map(model => (model as string).trim()); - config.subagentModels = canonicalChosen; - const { saveConfigPreservingClaudeCode: save } = await import("../../config"); - save(config); - const catalogRefresh = await convergeCodexCatalog(); + if (new Set(canonicalChosen).size !== canonicalChosen.length) { + return jsonResponse({ error: "models must not contain duplicate selectors" }, 400); + } + if (deps.saveConfigPreservingClaudeCode) { + config.subagentModels = canonicalChosen; + deps.saveConfigPreservingClaudeCode(config); + } else { + const persisted = mutatePersistedConfig(current => { + const previous = current.subagentModels ?? []; + const changed = previous.length !== canonicalChosen.length + || previous.some((model, index) => model !== canonicalChosen[index]); + current.subagentModels = [...canonicalChosen]; + return { changed, value: [...canonicalChosen] }; + }); + if (persisted.status === "unavailable") { + const status = persisted.reason === "conflict" ? 503 : 409; + const response = jsonResponse({ error: `subagent roster could not be saved (${persisted.reason})` }, status); + if (status === 503) response.headers.set("Retry-After", "1"); + return response; + } + config.subagentModels = [...persisted.value]; + } + // The field-scoped mutation rebases onto the newest persisted config. Build + // from that same authority, not the server's older whole-config snapshot. + const catalogDesired = currentCatalogDesired(); + const catalogRefresh = await convergeCodexCatalog(catalogDesired.config); await syncClaudeAgentDefsBestEffort(); await autoApplyDesktopBestEffort(); - const effectiveV2 = effectiveSubagentRoster(canonicalChosen, "v2"); + resetCatalogWorkerObservation(); + const { catalogState, activationWorkers } = collectCatalogWorkerObservation(); + const responseDesired = currentCatalogDesired(); + const responseConfig = responseDesired.config; + const currentChosen = [...(responseConfig.subagentModels ?? [])]; + const superseded = currentChosen.length !== canonicalChosen.length + || currentChosen.some((model, index) => model !== canonicalChosen[index]); + const activation = inspectCodexCatalogActivation( + responseConfig, + activationWorkers, + catalogRefresh, + responseDesired.authority, + deps.catalogArtifactProofForActivation?.(), + deps.codexRoutingKindForActivation?.(), + ); return jsonResponse({ ok: true, - applied: canonicalChosen, + saved: true, + ...(superseded ? { superseded: true, requested: canonicalChosen } : {}), + applied: currentChosen, catalogRefresh, - advertised: effectiveV2.advertised.map(model => model.model), - excluded: effectiveV2.excluded, + catalogState, + advertised: activation.catalog.advertised, + excluded: activation.catalog.excluded, + activation: projectCatalogActivationForPrincipal(activation, ctx.principal), }); } diff --git a/src/server/management/catalog-activation-routes.ts b/src/server/management/catalog-activation-routes.ts new file mode 100644 index 0000000000..b87b6d50e4 --- /dev/null +++ b/src/server/management/catalog-activation-routes.ts @@ -0,0 +1,253 @@ +import { + captureCodexCatalogDesiredSnapshot, + codexCatalogDesiredRevision, + collectCodexCatalogActivationWorkerState, + inspectCodexCatalogArtifactProof, + inspectCodexCatalogActivation, + resetCodexCatalogActivationWorkerStateCache, + type CodexCatalogActivationState, +} from "../../codex/catalog-activation"; +import type { CatalogConfigAuthoritySnapshot } from "../../codex/catalog-admission"; +import { + resetCodexAppServerCatalogStateCache, + type CodexAppServerCatalogStatus, +} from "../../codex/app-server-processes"; +import type { CatalogDisposition } from "../../codex/convergence-types"; +import type { + CodexCatalogApplyBlockReason, + CodexCatalogApplyResult, +} from "../../codex/catalog-apply"; +import type { CodexCommanderConfig } from "../../types"; +import { getCodexRoutingKind } from "../../codex/inject"; +import { jsonResponse } from "../auth-cors"; +import type { ManagementContext } from "./context"; +import type { ManagementPrincipal } from "../management-auth"; +import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import { isPlainRecord } from "./shared"; + +let catalogApplyFlight: Promise | null = null; + +/** + * Process interruption is never advertised to an ordinary dashboard session or + * a raw admin-token API client. Only a browser session created by the one-time + * launcher handoff receives the actionable reason and permission. + */ +export function projectCatalogActivationForPrincipal( + activation: CodexCatalogActivationState, + principal?: ManagementPrincipal, +) { + if (principal === "confirmed-gui-session") return activation; + return { + ...activation, + apply: { + ...activation.apply, + allowed: false, + reason: "confirmed-launch-required" as const, + }, + }; +} + +function unknownWorkerStatus(): CodexAppServerCatalogStatus { + return { state: "unknown", processes: [], catalogMtimeMs: null }; +} + +function collectWorkers(ctx: ManagementContext): CodexAppServerCatalogStatus { + try { + return (ctx.deps.collectCodexAppServerCatalogState + ?? collectCodexCatalogActivationWorkerState)(); + } catch { + return unknownWorkerStatus(); + } +} + +interface DesiredObservation { + config: CodexCommanderConfig; + revision: string; + authority?: CatalogConfigAuthoritySnapshot; +} + +function currentDesired(ctx: ManagementContext): DesiredObservation { + if (ctx.deps.captureCatalogDesiredSnapshotForActivation) { + return ctx.deps.captureCatalogDesiredSnapshotForActivation(); + } + if (ctx.deps.loadConfigForCatalogActivation) { + const config = ctx.deps.loadConfigForCatalogActivation(); + return { config, revision: codexCatalogDesiredRevision(config) }; + } + return captureCodexCatalogDesiredSnapshot(); +} + +function collectActivation( + ctx: ManagementContext, + desired: DesiredObservation = currentDesired(ctx), + disposition?: CatalogDisposition, + workers: CodexAppServerCatalogStatus = collectWorkers(ctx), +): CodexCatalogActivationState { + const activation = inspectCodexCatalogActivation( + desired.config, + workers, + disposition, + desired.authority, + ctx.deps.catalogArtifactProofForActivation?.(), + ctx.deps.codexRoutingKindForActivation?.(), + ); + // Test seams intentionally do not touch the persisted coordinator. Keep one + // authoritative revision even if their config reader is stateful. + activation.desired.revision = desired.revision; + return activation; +} + +function resetActivationObservation(ctx: ManagementContext): void { + (ctx.deps.resetCodexAppServerCatalogStateCache + ?? resetCodexAppServerCatalogStateCache)(); + resetCodexCatalogActivationWorkerStateCache(); +} + +function noStore(response: Response): Response { + response.headers.set("Cache-Control", "no-store"); + return response; +} + +function applyMessage(outcome: string, stopped: number, surviving: number): string { + if (outcome === "applied") { + if (stopped === 0) { + return "The stale Codex workers are no longer running. The saved roster will load with the current worker state."; + } + return stopped === 1 + ? "The stale Codex background worker was stopped. The saved roster will load in its replacement." + : `${stopped} stale Codex background workers were stopped. The saved roster will load in their replacements.`; + } + if (outcome === "already_current") return "Codex is already using the saved roster."; + if (outcome === "no_workers") return "No Codex background worker is running. The saved roster will load when Codex starts."; + if (outcome === "partial") { + return `${surviving} stale Codex background worker${surviving === 1 ? " is" : "s are"} still running.`; + } + if (outcome === "superseded") return "The saved configuration changed before Apply could run. Refresh and try again."; + return "Codex worker identity could not be verified. No process was stopped."; +} + +function blockedMessage(reason: CodexCatalogApplyBlockReason | undefined): string { + if (reason === "integration-disabled") { + return "Codex integration is disabled, so Apply did not change Codex routing or stop a process."; + } + if (reason === "external-routing") { + return "Codex is using an external model provider, so CodexCommander preserved that routing and stopped no process."; + } + if (reason === "desired-superseded" || reason === "authorization-changed") { + return applyMessage("superseded", 0, 0); + } + if (reason === "artifact-not-current") { + return "The exact synchronized catalog could not be proven, so no Codex process was stopped."; + } + if (reason === "routing-not-owned") { + return "CodexCommander does not own the active Codex routing, so no Codex process was stopped."; + } + if (reason === "sync-warning") { + return "Catalog synchronization reported degraded evidence, so no Codex process was stopped."; + } + if (reason === "worker-state-unknown") { + return applyMessage("blocked", 0, 0); + } + return "Codex routing and catalog synchronization failed, so no Codex process was stopped."; +} + +async function runApply( + ctx: ManagementContext, + expectedRevision: string, +): Promise { + const { readRuntimePort } = await import("../../config"); + const { + applyCodexCatalogWorkers, + runCodexCatalogApply, + } = await import("../../codex/catalog-apply"); + const runtime = (ctx.deps.readRuntimePort ?? readRuntimePort)(process.pid); + const result: CodexCatalogApplyResult = await runCodexCatalogApply({ + expectedDesiredRevision: expectedRevision, + }, { + // Production always returns an authority-bearing snapshot. The legacy + // config-only seam exists only for direct route fixtures. + captureDesiredSnapshot: () => currentDesired(ctx) as ReturnType, + syncCatalog: async desired => { + const { syncModelsToCodex } = await import("../../codex/sync"); + return (ctx.deps.syncModelsToCodex ?? syncModelsToCodex)(runtime?.port, desired.config, null); + }, + inspectArtifactProof: desired => ctx.deps.catalogArtifactProofForActivation?.() + ?? inspectCodexCatalogArtifactProof(desired.config), + getRoutingKind: () => ctx.deps.codexRoutingKindForActivation?.() ?? getCodexRoutingKind(), + resetWorkerObservation: () => resetActivationObservation(ctx), + collectWorkerState: () => collectWorkers(ctx), + applyWorkers: (authorizeSignal, observedBefore) => ( + ctx.deps.applyCodexCatalogWorkers ?? applyCodexCatalogWorkers + )(authorizeSignal, undefined, observedBefore), + }); + const activation = collectActivation(ctx, currentDesired(ctx)); + const outcome = result.outcome; + const ok = outcome === "applied" || outcome === "already_current" || outcome === "no_workers"; + const status = outcome === "superseded" || outcome === "blocked" ? 409 : 200; + const message = (outcome === "partial" || outcome === "applied" + || outcome === "already_current" || outcome === "no_workers") + ? applyMessage(outcome, result.stoppedWorkerCount, result.survivingWorkerCount) + : result.blockReason + ? blockedMessage(result.blockReason) + : applyMessage(outcome, result.stoppedWorkerCount, result.survivingWorkerCount); + return noStore(jsonResponse({ + ok, + outcome, + activation, + staleWorkerCount: result.staleWorkerCount, + stoppedWorkerCount: result.stoppedWorkerCount, + survivingWorkerCount: result.survivingWorkerCount, + message, + }, status, ctx.req, ctx.config)); +} + +export async function handleCatalogActivationRoutes(ctx: ManagementContext): Promise { + const { req, url, config } = ctx; + if (url.pathname === "/api/codex-catalog/status" && req.method === "GET") { + return noStore(jsonResponse({ + activation: projectCatalogActivationForPrincipal(collectActivation(ctx), ctx.principal), + }, 200, req, config)); + } + if (url.pathname !== "/api/codex-catalog/apply" || req.method !== "POST") return null; + + // Keep this interruption action on the dashboard's origin/CSRF-protected path, + // which blocks remote drive-by requests. This is not an OS privilege boundary: + // a same-user local process can already signal another same-user process. + if (ctx.principal !== "confirmed-gui-session") { + return jsonResponse({ + error: "Apply to Codex requires a confirmed dashboard launch from `ccx gui` or the CodexCommander menu bar app.", + }, 403, req, config); + } + let raw: unknown; + try { + raw = await readManagementJsonBody(req); + } catch (error) { + rethrowManagementBodyTooLarge(error); + return jsonResponse({ error: "invalid JSON body" }, 400, req, config); + } + if (!isPlainRecord(raw)) return jsonResponse({ error: "body must be a JSON object" }, 400, req, config); + const keys = Object.keys(raw); + if (keys.some(key => key !== "expectedDesiredRevision" && key !== "confirmInterrupt")) { + return jsonResponse({ error: "body contains unsupported fields" }, 400, req, config); + } + if (typeof raw.expectedDesiredRevision !== "string" || raw.expectedDesiredRevision.length < 4 || raw.expectedDesiredRevision.length > 128) { + return jsonResponse({ error: "expectedDesiredRevision must be an opaque revision string" }, 400, req, config); + } + if (raw.confirmInterrupt !== true) { + return jsonResponse({ error: "confirmInterrupt must be true" }, 400, req, config); + } + if (catalogApplyFlight) { + const response = jsonResponse({ error: "catalog apply is already in progress" }, 503, req, config); + response.headers.set("Retry-After", "1"); + return response; + } + const flight = runApply(ctx, raw.expectedDesiredRevision).finally(() => { + if (catalogApplyFlight === flight) catalogApplyFlight = null; + }); + catalogApplyFlight = flight; + return flight; +} + +export function resetCatalogApplyFlightForTests(): void { + catalogApplyFlight = null; +} diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 9e1ee52ae1..3868854a01 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -287,22 +287,111 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise deps.captureCatalogDesiredSnapshotForActivation?.() + ?? captureCodexCatalogDesiredSnapshot(); + const artifactProof = (desired: ReturnType) => + deps.catalogArtifactProofForActivation?.() + ?? inspectCodexCatalogArtifactProof(desired.config); + const routingKind = () => deps.codexRoutingKindForActivation?.() + ?? getCodexRoutingKind(); + const activationDesired = captureDesired(); + const activationArtifactProof = artifactProof(activationDesired); + const activationRoutingKind = routingKind(); + const activation = inspectCodexCatalogActivation( + activationDesired.config, + activationWorkers, + undefined, + activationDesired.authority, + activationArtifactProof, + activationRoutingKind, + ); + + // An authenticated manual full sync is the only failed-readiness recovery + // boundary. Do not promote from the sync result alone: Save may race the + // request, or routing/artifact state may drift after the writer returns. + // Re-observe every relevant signal after building the response activation + // and require the two post-sync observations to describe the same desired + // generation and route. Applied integration additionally needs the exact + // process-local publication receipt and authoritative catalog on both reads. Intentional OFF and + // external-provider skips have no Commander-owned artifact by design, but + // their skip reason must agree exactly with the stable routing state. + let recoveryProven = false; + if (result.ok === true && (result.warning === undefined || result.warning === "")) { + try { + const confirmedRoutingKind = routingKind(); + const confirmedArtifactProof = result.status === "applied" + ? artifactProof(activationDesired) + : activationArtifactProof; + // Recapture desired state last. A Save that races either confirmation + // read must change this revision and keep the recovery gate closed. + const confirmedDesired = captureDesired(); + const desiredStable = confirmedDesired.revision === activationDesired.revision; + const routingStable = confirmedRoutingKind === activationRoutingKind; + const integrationDisabled = activationDesired.config.clientIntegrations?.codex === false + && confirmedDesired.config.clientIntegrations?.codex === false; + const disabledSkip = result.status === "skipped" + && result.skippedReason === "desired_disabled" + && integrationDisabled + && activation.routing.status === "not_required" + // `not_required` is derived from desired OFF and intentionally masks + // the raw route in the public activation DTO. OFF is not actually + // settled while a stale Commander-owned route remains injected, and + // unreadable routing is never positive proof. + && activationRoutingKind !== "codexcommander-local" + && activationRoutingKind !== "unknown"; + const externalSkip = result.status === "skipped" + && result.skippedReason === "external_provider" + && !integrationDisabled + && activation.routing.status === "external"; + const applied = result.status === "applied" + && !integrationDisabled + && activation.routing.status === "current" + && activationArtifactProof === "current" + && confirmedArtifactProof === "current"; + recoveryProven = desiredStable && routingStable && (disabledSkip || externalSkip || applied); + } catch { + // A torn/unreadable confirmation is not proof. Keep readiness failed; + // the structured sync/activation response remains useful diagnostics. + } + } + if (recoveryProven) deps.readinessGate?.recoverReady(); const status = result.status === "refused" ? 409 : (result.status === "skipped" || result.ok ? 200 : 500); return jsonResponse({ ...attachStaleAppServerHint(result), - catalogState: (deps.collectCodexAppServerCatalogState ?? collectCodexAppServerCatalogState)(), + catalogState, + activation, ...(result.ok ? {} : { error: result.message }), }, status); } diff --git a/src/server/management/context.ts b/src/server/management/context.ts index f50c3e9920..e3e07b8a01 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -14,6 +14,14 @@ import type { collectCodexAppServerCatalogState, resetCodexAppServerCatalogStateCache, } from "../../codex/app-server-processes"; +import type { applyCodexCatalogWorkers } from "../../codex/catalog-apply"; +import type { + CodexCatalogArtifactProof, + CodexCatalogDesiredSnapshot, +} from "../../codex/catalog-activation"; +import type { ReadinessGate } from "../readiness"; +import type { CodexRoutingKind } from "../../codex/inject"; +import type { reconcileManagementNativeSubagentDefaults } from "../../codex/management-native-defaults"; export interface ManagementApiDeps { resolveCodexRuntime?: () => ResolveCodexRuntimeResult; @@ -71,6 +79,17 @@ export interface ManagementApiDeps { syncModelsToCodex?: typeof syncModelsToCodex; resetCodexAppServerCatalogStateCache?: typeof resetCodexAppServerCatalogStateCache; collectCodexAppServerCatalogState?: typeof collectCodexAppServerCatalogState; + applyCodexCatalogWorkers?: typeof applyCodexCatalogWorkers; + /** Atomic desired-config + generation seam for Apply race tests. */ + captureCatalogDesiredSnapshotForActivation?: () => CodexCatalogDesiredSnapshot; + /** Exact catalog/cache proof seam for activation route tests. */ + catalogArtifactProofForActivation?: () => CodexCatalogArtifactProof; + /** Read-only native routing observation seam for activation route tests. */ + codexRoutingKindForActivation?: () => CodexRoutingKind; + /** Non-disruptive native-default writer seam for injection-model route tests. */ + reconcileManagementNativeSubagentDefaults?: typeof reconcileManagementNativeSubagentDefaults; + /** Fresh persisted desired-state reader for the consent/revision fence. */ + loadConfigForCatalogActivation?: () => CodexCommanderConfig; clearThreadAccountMap?: () => void; clearProviderQuotaCache?: () => void; primeCodexPoolQuotas?: (config: CodexCommanderConfig, reason: string) => Promise | void; @@ -83,6 +102,11 @@ export interface ManagementApiDeps { * leaves this unset, so the route creates its normal NativeProfileManager. */ nativeProfileApi?: NativeProfileApiDeps; + /** + * The live server's private readiness gate. Direct-dispatch tests may inject + * the same narrow capability; no diagnostic text or mutable state is exposed. + */ + readinessGate?: Pick; } @@ -100,6 +124,6 @@ export interface ManagementContext { * tests, which are treated as the untrusted `admin-token` case. */ principal?: ManagementPrincipal; - convergeCodexCatalog: () => Promise; + convergeCodexCatalog: (configOverride?: Readonly) => Promise; syncClaudeAgentDefsBestEffort: () => Promise; } diff --git a/src/server/management/sync-response.ts b/src/server/management/sync-response.ts deleted file mode 100644 index fb947df5ae..0000000000 --- a/src/server/management/sync-response.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * The single HTTP projection of ConvergeOutcome. - * - * Three phase documents once mapped /api/sync independently and one silently - * dropped Retry-After. Keeping the exhaustive switch here makes a new domain - * outcome a compile error until its management contract is chosen explicitly. - */ -import type { ConvergeOutcome } from "../../codex/convergence-types"; -import { jsonResponse } from "../auth-cors"; - -export function toSyncResponse(outcome: ConvergeOutcome): Response { - switch (outcome.kind) { - case "catalog-only": - return jsonResponse({ - ok: true, - changed: outcome.changed, - observed: outcome.observed, - catalogRefresh: outcome.catalogRefresh, - }); - case "converged": - return jsonResponse({ - ok: true, - changed: outcome.changed, - observed: outcome.observed, - catalogRefresh: outcome.catalogRefresh, - }); - case "skipped": - return jsonResponse({ - ok: true, - changed: false, - observed: outcome.observed, - catalogRefresh: outcome.catalogRefresh, - }); - case "refused": - return jsonResponse({ - ok: false, - authority: outcome.authority, - message: outcome.message, - observed: outcome.observed, - }, 409); - case "busy": { - const response = jsonResponse({ - ok: false, - surface: outcome.surface, - retryAfterMs: outcome.retryAfterMs, - }, 503); - response.headers.set("Retry-After", String(Math.ceil(outcome.retryAfterMs / 1_000))); - return response; - } - case "deferred": - return jsonResponse({ - ok: true, - changed: outcome.changed, - unresolved: outcome.unresolved, - observed: outcome.observed, - catalogRefresh: outcome.catalogRefresh, - }); - case "failed": - return jsonResponse({ error: outcome.message, surface: outcome.surface }, 500); - default: { - const exhaustive: never = outcome; - return exhaustive; - } - } -} diff --git a/src/server/proxy-liveness.ts b/src/server/proxy-liveness.ts index ceee53551d..927f97ff00 100644 --- a/src/server/proxy-liveness.ts +++ b/src/server/proxy-liveness.ts @@ -10,7 +10,16 @@ * Lives outside cli.ts (which dispatches argv at module top level) so tests can import it. */ import { loadConfig, readAlivePid, readRuntimePort, verifyPidIdentity } from "../config"; -import { isOwnedHealthService } from "../identity"; +import { + ATTESTATION_CHALLENGE_HEADER, + ATTESTATION_PROOF_HEADER, + isOwnedHealthService, +} from "../identity"; +import { + createLocalAttestationChallenge, + isLocalAttestationSecret, + verifyLocalAttestationProof, +} from "../lib/local-management-attestation"; export interface HealthzIdentity { service?: unknown; @@ -18,6 +27,15 @@ export interface HealthzIdentity { version?: unknown; uptime?: unknown; pid?: unknown; + port?: unknown; +} + +export interface RuntimeLivenessRecord { + pid?: number; + port: number; + hostname?: string; + /** Protected per-process key used only for the local management challenge. */ + attestationSecret?: string; } export interface LivenessIo { @@ -28,7 +46,7 @@ export interface LivenessIo { * Destructive callers only ever receive pids that passed this gate. */ verifyPidFn?: (candidatePid: number) => number | null; - readRuntimeFn?: (pid?: number) => { pid?: number; port: number; hostname?: string } | null; + readRuntimeFn?: (pid?: number) => RuntimeLivenessRecord | null; configFn?: () => { port?: number; hostname?: string }; timeoutMs?: number; /** @@ -66,6 +84,23 @@ export interface LiveProxy { source: "runtime" | "config"; } +export interface AttestedLiveManagementProxy extends LiveProxy { + pid: number; + source: "runtime"; + /** Canonical request root derived from the attested runtime record. */ + baseUrl: string; +} + +export interface ManagementAttestationIo { + fetchFn?: typeof fetch; + readRuntimeFn?: (pid?: number) => RuntimeLivenessRecord | null; + verifyPidFn?: (candidatePid: number) => number | null; + timeoutMs?: number; + /** Rotation recovery only. Each attempt re-discovers and re-attests from scratch. */ + attempts?: number; + expectedPid?: number; +} + /** * Host to probe for a given bind hostname: wildcards answer on IPv4 loopback, and raw * IPv6 addresses must be bracketed or the composed URL is invalid. @@ -195,6 +230,105 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise { + const fetchFn = io.fetchFn ?? fetch; + const readRuntimeFn = io.readRuntimeFn ?? readRuntimePort; + const verifyPidFn = io.verifyPidFn ?? verifyPidIdentity; + const requestedTimeoutMs = Math.trunc(io.timeoutMs ?? 4_000); + const timeoutMs = Number.isNaN(requestedTimeoutMs) + ? 4_000 + : Math.max(1, Math.min(requestedTimeoutMs, 30_000)); + const requestedAttempts = Math.trunc(io.attempts ?? 2); + const attempts = Number.isNaN(requestedAttempts) + ? 1 + : Math.max(1, Math.min(requestedAttempts, 3)); + + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + // Sensitive discovery starts from the protected record itself. Calling the + // public liveness finder here would let a spoof feed an unbounded /healthz body + // to its JSON parser before the HMAC fence. + const record = readRuntimeFn(); + if (!record + || !Number.isSafeInteger(record.pid) + || (record.pid ?? 0) <= 0 + || !Number.isInteger(record.port) + || record.port <= 0 + || record.port > 65535 + || !isLocalAttestationSecret(record.attestationSecret)) { + continue; + } + const recordPid = record.pid as number; + if (io.expectedPid !== undefined && recordPid !== io.expectedPid) continue; + if (verifyPidFn(recordPid) !== recordPid) continue; + const snapshot = { + pid: recordPid, + port: record.port, + hostname: record.hostname, + attestationSecret: record.attestationSecret, + }; + const challenge = createLocalAttestationChallenge(); + const baseUrl = `http://${probeHostname(snapshot.hostname)}:${snapshot.port}`; + const response = await fetchFn(`${baseUrl}/healthz`, { + headers: { [ATTESTATION_CHALLENGE_HEADER]: challenge }, + redirect: "error", + signal: AbortSignal.timeout(timeoutMs), + }); + const proof = response.headers.get(ATTESTATION_PROOF_HEADER); + const proved = response.ok && verifyLocalAttestationProof( + snapshot.attestationSecret, + challenge, + snapshot.pid, + snapshot.port, + proof, + ); + // The proof authenticates the exact PID/port; never parse a listener-controlled + // body on this credential-release path. Cancellation bounds both declared-huge + // and chunked/streaming spoof responses. + await response.body?.cancel().catch(() => {}); + if (!proved) continue; + + // Detect restart/rotation after the proof before a caller can attach a token + // or sensitive body. The caller must issue its request immediately on return. + if (!exactRuntimeRecord(readRuntimeFn(snapshot.pid), snapshot)) continue; + if (verifyPidFn(snapshot.pid) !== snapshot.pid) continue; + return { + pid: snapshot.pid, + port: snapshot.port, + hostname: snapshot.hostname, + source: "runtime", + baseUrl, + }; + } catch { + // Retry only by re-reading discovery state and issuing a fresh challenge. + } + } + return null; +} + // ───────────────────────────────────────────────────────────────────────────── // Readiness (/readyz) strict probe. // diff --git a/src/server/readiness.ts b/src/server/readiness.ts index 1b77addd1d..3e5497e35a 100644 --- a/src/server/readiness.ts +++ b/src/server/readiness.ts @@ -25,8 +25,10 @@ export type ReadinessStatus = "pending" | "ready" | "failed"; /** * Private per-server readiness controller. The status starts at `pending` and - * transitions at most once (to `ready` or `failed`) when the post-startup sync - * settles. The gate is owned by the listener closure that requested it. + * startup settles it at most once (to `ready` or `failed`). A later explicit + * recovery may promote only that terminal `failed` state; it can never bypass + * an in-flight startup by promoting `pending`. The gate is owned by the + * listener closure that requested it. */ export interface ReadinessGate { /** Current sanitized status. */ @@ -35,6 +37,12 @@ export interface ReadinessGate { markReady(): void; /** Mark the proxy failed. No reason is stored or exposed. */ markFailed(): void; + /** + * Promote a live proxy after an authenticated, explicit full sync succeeds. + * Startup settlement remains one-shot; this is the deliberate recovery path + * for a process whose initial sync failed for a repairable reason. + */ + recoverReady(): void; } /** @@ -51,6 +59,9 @@ export function createReadinessGate(): ReadinessGate { markFailed: () => { if (status === "pending") status = "failed"; }, + recoverReady: () => { + if (status === "failed") status = "ready"; + }, }; } diff --git a/structure/01_runtime.md b/structure/01_runtime.md index fe9fa3cfad..c4653c88f3 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -7,7 +7,7 @@ | `bin/ccx.mjs` (launcher filename; reserved package bins are `codexcommander` / `ccx`) | Node launcher used by locally linked or packaged builds. Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, then execs `src/cli/index.ts` under Bun. No registry package is currently published. | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `CCX_BUN_PATH`. | | `src/cli/index.ts` | `ccx` / `codexcommander` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, and service. Configuration: provider, account, models, combo/route, access, integrations, v2. Diagnostics: doctor, debug, observe, health. Windows adds tray. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). | -| `src/cli/macos-lifecycle.ts`, `src/cli/proxy-lifecycle.ts` | Fixed, bounded macOS companion lifecycle bridge and shared proxy ownership. The source app invokes the checkout's Bun plus `src/cli/index.ts`; a packaged app build invokes its bundled `Contents/Resources/runtime` before considering fixed global-install fallbacks. The allowlist includes the separate `applyCodexCatalog` action; it is not an arbitrary shell-command bridge. | +| `src/cli/macos-lifecycle.ts`, `src/cli/proxy-lifecycle.ts` | Fixed, bounded macOS companion lifecycle bridge and shared proxy ownership. Every built app invokes only its embedded `Contents/Resources/runtime`; it never executes checkout `src/` or a global-install fallback. The allowlist includes the separate `applyCodexCatalog` action; it is not an arbitrary shell-command bridge. | | `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, and facade re-exports for split server modules. | | `src/server/images.ts` | Standalone Images data plane: default OpenAI or explicit custom-provider selection, Codex account affinity, bounded opaque request relay, single-attempt upstream fetch, pool health recording, and safe response/cancellation relay. | | `src/config.ts` | `~/.codexcommander/config.json`, defaults, PID path, env-value resolution, `websocketsEnabled()`. | diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 74cdee9970..fee4d52dc1 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -67,12 +67,16 @@ already-active last-known-good rows, and `native-only` means no CodexCommander-a active. `native-only` with an enabled routed-capable provider is an actionable sync warning, not a fully-ready result. The macOS lifecycle waits for startup readiness, retries convergence through the live management API, and automatically synchronizes the catalog on app launch. A worker roster that -predates the committed catalog is a nonfatal, persistent **Agent catalog update ready** state; it does -not make CodexCommander appear stopped or unhealthy. The confirmed **Apply agent catalog** action performs -another sync, sends `SIGTERM` only to exact current-user `codex … app-server` and -`codex-code-mode-host` matches, verifies the old workers' exits, and leaves the CodexCommander proxy and -menu app running. The current companion does not promise idle deferral: activity is warning context, **Apply -Now** is explicit consent to possible interruption, and **Later** leaves the update pending. +predates the committed catalog is a nonfatal, persistent **Restart ChatGPT to load models** state; it +does not make CodexCommander appear stopped or unhealthy. **Show restart steps…** explains the default +reload boundary: quit ChatGPT completely, reopen it, and then start a new task. The companion does not +signal ChatGPT's background workers from this card. + +Guarded Apply remains an advanced dashboard/API fallback. It performs another sync, reconciles managed +routing when needed, sends `SIGTERM` only to exact current-user `codex … app-server` and +`codex-code-mode-host` matches, verifies the old workers' exits, and leaves the CodexCommander proxy +running. Activity is warning context rather than an idle guarantee. Because this bypasses ChatGPT's +normal app lifecycle, ChatGPT may report that it **stopped unexpectedly**. The CLI remains the advanced fallback: @@ -80,6 +84,61 @@ The CLI remains the advanced fallback: ccx sync --restart-codex ``` +## Catalog activation contract + +There are three independently observable truths, in order: (1) the saved desired configuration, +(2) the authoritative CodexCommander catalog and managed Codex boot settings on disk, and (3) the +catalog that a running Codex app-server has loaded. Catalog publication also invalidates Codex's +separate `models_cache.json`, but that file is Codex-owned and may be refreshed immediately; its +post-publication bytes are therefore not durable activation truth. A successful Save or ordinary +sync converges only the first two truths and is deliberately non-disruptive. It must not claim that +an existing worker has reloaded merely because a new task or fork was created: those are not +catalog-reload boundaries for an already-running app-server. + +`GET /api/codex-catalog/status` projects these three truths as additive management state; the +subagent-roster GET/PUT and ordinary sync response carry the same additive activation observation +for dashboard convenience. The activation DTO is an observation, not a durable receipt: there is no +persisted pending-update snapshot, auto-apply daemon, or idle queue. The retained routed-catalog +snapshot above serves provider-discovery recovery and is unrelated to worker activation. + +`POST /api/codex-catalog/apply` is the sole browser Apply action and an advanced force-restart fallback. +It accepts only +`{ expectedDesiredRevision, confirmInterrupt: true }`, re-converges and proves the disk state, then +may signal only revalidated exact current-user Codex worker identities. It never accepts a PID, +command, or path from the caller. The expected desired revision fences configuration races; +unknown worker identity blocks signaling. In-flight proxy request activity is advisory context: it +warns about possible interruption but does not represent persistent Codex agent lifecycle, and zero +activity does not prove idleness. Apply reports already-current, +no-worker, applied, partial, superseded, or blocked evidence and never escalates to `SIGKILL`. + +A manually opened loopback dashboard receives no API credential. On every loopback hostname or +address, the browser never prompts for or transmits the raw admin token; it requires a confirmed GUI +session instead. `ccx gui` and the macOS companion can use the raw admin credential outside the +browser to mint a short-lived, single-use launch ticket carried only in the URL fragment. Its one-time +exact-origin, exact-route exchange creates a process-memory-only confirmed GUI session with an +eight-hour absolute lifetime. It is never renewed: expiry or proxy restart makes the next API request +return `401`, after which the dashboard directs the user back to the launcher. The durable admin token +never enters the URL or web storage. The raw admin principal remains capable of ordinary headless API +mutations but is deliberately not accepted by the browser Apply endpoint; the companion and CLI keep +their existing narrow non-browser flows. + +The desired revision is semantic rather than a filesystem timestamp. Catalog commits are idempotent: +JSON-semantic equality ignores insignificant whitespace and object-key order while preserving array +order, so equivalent catalog/cache artifacts are not rewritten merely to manufacture a newer mtime. +Worker freshness uses the newer of the catalog mtime and a content-scoped Codex boot fence. The boot +fence hashes only parsed worker boot inputs and persists the time that hash last changed, so desktop- +owned `config.toml` churn does not manufacture a reload requirement while real boot-setting edits do. +The fence marker is seeded only for CodexCommander-managed homes (injected routing/catalog keys); +a never-managed home is observed via raw mtime and never written. + +When the catalog and managed routing are already current and only the running worker is stale, the +recommended end-user boundary is to quit ChatGPT completely, reopen it, and start a new task. A new +task or fork without that full app restart still reuses the old worker. A pending or unknown catalog, +or managed routing that is not yet injected, is different: **Apply to Codex** must first reconcile and +prove the disk/routing state; manual restart guidance must not replace that repair step. The guarded +dashboard/API action and `ccx sync --restart-codex` remain advanced callers of the same narrow +process-safety policy and may make ChatGPT show **stopped unexpectedly**. + ## Entry shape Routed entries keep Codex-required metadata such as reasoning levels, shell type, API support flags, @@ -132,7 +191,7 @@ real turn depends on it (`src/codex/warmup.ts`). | Mode | Behavior | | --- | --- | | `"v1"` | Force ALL entries to `multi_agent_version = "v1"` — overrides upstream pins (sol/terra included). | -| `"default"` (install default) | Respect upstream model pins (sol/terra=v2, luna=v1, others=null → codex feature flag decides). On sync, stale forced values are cleared and upstream pins restored. | +| `"default"` (install default) | Respect upstream model pins (sol/terra=v2, luna=v1). When the native `multi_agent_v2` flag is enabled, otherwise-unpinned entries are stamped v2; when it is disabled, they remain unpinned. On sync, stale forced values are cleared and upstream pins restored. | | `"v2"` | Force ALL entries to `multi_agent_version = "v2"` — overrides upstream pins (luna included). | The override is applied as a final pass in both `buildCatalogEntries` (live `/v1/models` path) and @@ -141,16 +200,22 @@ ensures `normalizeRoutedCatalogEntry` (which deletes `multi_agent_version` from not clobber the forced value. CLI: `ccx v2 mode v1|default|v2`. GUI: **Models → Current behavior → Collaboration**, labeled -**Classic v1**, **Follow Codex defaults**, and **Concurrent v2**. API: `GET/PUT /api/v2` with +**Reliable v1**, **Codex native**, and **Concurrent v2**. API: `GET/PUT /api/v2` with `multiAgentMode` field. The `multi_agent_v2` feature flag and the logical maximum thread count are separate from `multiAgentMode` (`src/codex/features.ts`): the mode decides which surface Codex advertises, while the flag and thread count decide what the native runtime allows. +Forcing V2 means Luna becomes V2-surface eligible in the generated catalog; it does **not** itself +make Luna live for a current worker. A usable subagent target must be selected, surface-compatible, +picker-visible and within the advertised five-row window, present in the converged on-disk catalog, +loaded by the current worker, and successfully routable by the proxy. + `CodexCommanderConfig.multiAgentV2MessageDelivery` is a separate request-time policy. `encrypted` is the default and preserves ChatGPT's reserved collaboration schema plus the unreadable-ciphertext -fail-closed guard. `plaintext` opts the whole V2 parent session into mixed-provider compatibility: +fail-closed guard. `plaintext` opts the whole V2 parent session into mixed-provider compatibility for +task-message delivery; it is not a credential or general key-encryption setting: canonical ChatGPT requests atomically alias the complete known collaboration namespace, strip only the three message encryption markers, then restore the namespace and add Codex's `encrypted_function_args: []` plaintext sentinel on the response. Routed adapters add that sentinel diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index fd44fa550c..673e598717 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -18,21 +18,23 @@ CodexCommander uses three mutually exclusive admission credential classes: | --- | --- | --- | | Data plane | `CODEXCOMMANDER_API_AUTH_TOKEN`, the `service-api-token` file loaded through `CCX_API_TOKEN_FILE`, and `config.apiKeys` | `/v1/*` HTTP endpoints and new data-plane WebSocket handshakes only | | Management plane | `CODEXCOMMANDER_ADMIN_AUTH_TOKEN` or the independent protected `admin-api-token` file | `/api/*` only | -| GUI session | A short-lived token issued only with a legitimate same-origin local dashboard page | `/api/*` only, bound to the issuing origin | +| GUI session | A confirmed local-app launch, process-memory-only and origin-bound | Full dashboard methods for up to eight hours; catalog Apply remains confirmed-session-only | The service token file remains a delivery mechanism for the data-plane environment token; it is not a fourth credential class. A management credential that equals any configured data-plane credential does not enable management access. The data plane may continue to start, but `/api/*` remains closed. -CLI health collection follows the same boundary: `ccx status` and `ccx doctor` use the configured -management credential for `/api/codex-auth/accounts`, never the service/data-plane token. Their -output distinguishes a missing proxy, rejected management authentication, and an unexpected -management response so a reachable `401` cannot be reported as "proxy not running." - -Before either CLI command attaches the management bearer, it challenges the listener and verifies -an HMAC proof bound to the proxy PID and port. The per-process proof key lives only in the protected -`runtime-port.json`; the public `/healthz` identity marker alone is never sufficient to receive a -management credential. Account-health detail is available only through a listener with an attested -runtime record. +Before any TypeScript CLI management request can release a bearer or caller body, the client +authenticates the exact protected runtime record. The same fence applies at the credential-bearing +Claude and OpenCode launch boundaries. `ccx status` and `ccx doctor` account-health collection are examples: +they use the configured management credential for `/api/codex-auth/accounts`, never the +service/data-plane token, and distinguish a missing proxy, rejected authentication, and an +unexpected response so a reachable `401` cannot be reported as "proxy not running." + +The client challenges the listener and verifies an HMAC proof bound to the proxy PID and port. The +per-process proof key lives only in the protected `runtime-port.json`; neither the public `/healthz` +identity marker nor configured-port discovery can authorize release of a management credential or +sensitive request body. Account-health detail and client credentials reach only a listener with an +attested exact runtime record. [Decision Log] - 목적과 의도: Keep a lower-privileged local process from collecting the management bearer by impersonating `/healthz` on an unused port. @@ -47,12 +49,38 @@ management token creation, validation, or permission hardening fails, every `/ap 503 while `/v1/*` and unauthenticated `/healthz` continue to operate. Windows ACL hardening results must be checked explicitly because an `icacls` timeout is a soft failure in the shared secret helper. -Local dashboard page entry requires a loopback binding, a valid parseable loopback `Host`, and an -exact request origin. A non-loopback dashboard uses the management token flow instead. The server -issues an in-memory session for five minutes, capped at 128 live sessions. The session is bound to the -exact protocol, host, and port; state-changing requests additionally require the session CSRF token. -The dashboard never attaches its management session to `/v1/*` requests, and pages containing a -session bootstrap are served with `Cache-Control: no-store`. +Opening a local dashboard page directly does not mint an API credential. The static shell may load, +but every `/api/*` request remains behind management authentication; a fresh `ccx gui`/macOS +companion launch is required. A loopback page never prompts for or transmits the durable admin token, +because the browser origin does not prove which local OS user owns the listener. There is no +lower-privilege loopback session, implicit renewal, or loopback authentication bypass. The dashboard +never attaches a management session to `/v1/*`. + +A non-loopback browser may prompt for raw admin only on a trusted HTTPS origin. Plaintext remote pages +never request or transmit that bearer; operators without trusted HTTPS use a local or SSH tunnel that +presents loopback and launch through `ccx gui`. This browser rule does not remove raw-admin support for +headless management API clients using a trusted transport. + +`ccx gui` or the macOS companion may use the durable admin credential to mint a short-lived, +single-use launch ticket bound to the exact route and origin. Only the ticket enters the URL fragment, +which the dashboard removes immediately during its one-time exchange. The exchange creates a +confirmed, CSRF-protected GUI session with an eight-hour absolute lifetime. It is process-memory-only +and never renewed. Expiry or proxy restart makes the next API request return `401`; the browser then +directs the user to relaunch. The durable admin token never enters the URL, `localStorage`, or +`sessionStorage`. The +ticket is a transient capability, not a fourth durable credential class or a general management +bypass. Its exchange endpoint is the narrow pre-authenticated exception to the `/api/*` gate: the +single-use ticket itself is the bearer and is bound to the exact origin and route. + +Confirmed launch mitigates cross-OS-user loopback listener spoofing, remote drive-by CSRF, and +accidental clients; it is not proof of user presence and is not stronger than raw admin against a +malicious process already running as the same trusted OS account described in +[`02_config-and-codex-home.md`](02_config-and-codex-home.md). In particular, it must not be described +as blocking a coding agent that already holds the raw admin token. + +The raw admin principal remains capable of ordinary management API mutations. Catalog Apply is the +narrow exception: its browser endpoint accepts only a confirmed GUI session, while the native +companion and `ccx sync --restart-codex` keep their fixed non-browser flows. Proxy admission credentials must never reach an upstream provider. The forwarding guard rejects the `ccx_data_`, `ccx_admin_`, and `ccx_session_` prefixes, both environment tokens by constant-time @@ -87,14 +115,14 @@ this document owns is which module holds which area and what invariant that area | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | | OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`08_openai-provider-tiers.md`](08_openai-provider-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | | OpenCode integration | `src/server/management/opencode-integration-routes.ts` and `src/clients/opencode-persistence.ts` exclusively own `GET /api/integrations/opencode`, Apply, auto-connect, Restore, and Desktop-open actions. The Client Apps workspace reads this dedicated status, while the generic `/api/client-integrations*` collection, mutation, journal, and restore routes exclude OpenCode so only one production writer can touch `provider.codexcommander`. Persistent mode resolves the active global JSONC/JSON target, protects the admission token in CodexCommander state, and emits an OpenCode `{file:…}` reference. JSONC mutation must preserve comments and other keys. A journal + backup enables byte-exact restore while untouched and surgical provider-only restore after user edits; full overwrite requires an explicit current-hash confirmation. `autoConnect` is default-off and only refreshes that managed provider after startup/catalog changes. `src/clients/opencode-installation.ts` detects and launches Desktop; CLI fallback remains `ccx opencode` and never writes disk config. | -| Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent CodexCommander guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When CodexCommander owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | -| Agent activity | `src/server/management/activity-routes.ts` — `GET /api/agent-activity` exposes a bounded, active-only snapshot for local status surfaces. Records contain opaque process-ephemeral ids, privacy-safe model/provider labels, `primary`/`subagent` role, and truthful `starting`/`running` phases; no prompt, path, tool, account, raw request/thread id, error, or historical transcript is retained or serialized. Parent ids are emitted only when the parent appears in the same payload. Counts describe the pre-truncation snapshot, while at most 64 deterministically ordered records are returned. The response is management-authenticated and `Cache-Control: no-store`. The macOS catalog confirmation may use a fresh count to explain interruption risk, but a zero-count observation is not an idle guarantee and release one does not defer catalog application on it. | -| V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `multiAgentV2MessageDelivery` policy (`encrypted` default or opt-in `plaintext`), and the logical maximum thread count. Selecting `v2` enables the native flag and moves `[agents] max_threads` to the v2 key; selecting `v1` disables it and moves the same value back. `default` leaves the native flag unchanged. PUT accepts any owned field independently; contradictory mode/flag pairs are rejected before writes. Mode changes apply to new sessions; delivery changes affect subsequent requests and therefore warn the user to start a new session. Every feature transition is rollback-safe and resyncs the catalog. | +| Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent CodexCommander guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. `GET /api/codex-catalog/status` exposes desired configuration, deterministic on-disk catalog evidence, and current worker activation evidence; `POST /api/codex-catalog/apply` is an authenticated, CSRF-protected, explicit interruption action accepting only `{ expectedDesiredRevision, confirmInterrupt: true }`. It accepts only the confirmed GUI session created by the exchanged single-use launch ticket; the raw admin token is mint authority, not direct browser-Apply authority. CLI and native-companion compatibility remains on their narrow non-browser flows. No caller-supplied PID, command, or path is accepted; stale targets are exact current-user identities revalidated before `SIGTERM`, unknown identity blocks, and busy work returns `503` plus `Retry-After`. The roster GET/PUT and `POST /api/sync` include the same additive activation observation. When CodexCommander owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | +| Live proxy requests | `src/server/management/activity-routes.ts` — `GET /api/agent-activity` exposes a bounded snapshot of currently admitted proxy request turns, not persistent Codex agent lifecycle. Records contain opaque process-ephemeral ids, privacy-safe model/provider labels, `primary`/`subagent` request classification, and truthful `starting`/`running` phases; a row is removed when its request lease settles even if Codex keeps a child thread alive. No prompt, path, tool, account, raw request/thread id, error, or historical transcript is retained or serialized. Parent ids are emitted only when the parent appears in the same payload. Counts describe the pre-truncation snapshot, while at most 64 deterministically ordered records are returned. The response is management-authenticated and `Cache-Control: no-store`. The macOS catalog confirmation may use a fresh count to explain interruption risk, but a zero-count observation is not an idle guarantee and release one does not defer catalog application on it. | +| V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `multiAgentV2MessageDelivery` policy (`encrypted` default or opt-in `plaintext`), and the logical maximum thread count. Selecting `v2` enables the native flag and moves `[agents] max_threads` to the v2 key; selecting `v1` disables it and moves the same value back. `default` leaves the native flag unchanged. PUT accepts any owned field independently; contradictory mode/flag pairs are rejected before writes. A mode/protocol/thread boot-config change requires **Apply agent catalog** to replace a running worker, then a new task for its session-bound tool shape. Delivery changes affect only subsequent V2 task messages: start a new task, but no catalog convergence or Apply is needed. Plaintext concerns task-message delivery only, not stored provider credentials or generic key encryption. Every feature transition is rollback-safe and resyncs the catalog only when it changes catalog/boot configuration. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. Debug tab (`/#logs/debug`): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` use the monotonic `after` cursor. CLI: `ccx debug provider|usage …` (both streams via running proxy API). | | Usage | `GET /api/usage` aggregate read-only summary derived from `~/.codexcommander/usage.jsonl`; measured / reported / unreported / unsupported / estimated counts, daily zero-filled grid, model and provider breakdowns. Never exposes prompts. | | System | `POST /api/system/restart` restarts the proxy in place. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + ordinary and plaintext-V2 eager-relay gate decisions, scalar eager-relay in-flight/cancel/abort/error/queue counters, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Scalar-only payload; rides the standard management auth gate and must never move to unauthenticated `/healthz`. Consumed by `ccx doctor`'s Memory/runtime section and the dashboard Memory observability card. | | Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. | -| Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync and reports the current Codex worker catalog state. The diagnostic reports the bypass; it does not rewrite the project file. A stale worker roster is nonfatal lifecycle state, not proxy failure. | +| Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync and returns activation evidence without interrupting workers. The diagnostic reports the bypass; it does not rewrite the project file. A stale worker roster is nonfatal lifecycle state, not proxy failure; starting a task or fork is not treated as a catalog reload. | | Sidecar/shadow-call settings | `src/server/management/config-routes.ts` — `GET/PUT /api/sidecar-settings` and `GET/PUT /api/shadow-call-settings`. PUT accepts model and backend plus optional `webSearch.reasoning` and `vision.maxDescriptionsPerTurn`; the read and PUT-response payload reports model, backend, and the vision per-turn limit. Credentials live in the provider and OAuth stores instead. Both shadow-call responses also report the resolved `sourceModels` — the prefixes the runtime actually intercepts (`src/lib/shadow-call.ts`, default `gpt-5.6-luna`; an explicit override names current custom helper ids), so no client hard-codes a helper slug that a Codex release can invalidate. | | Storage | `src/server/management/logs-usage-routes.ts` — `GET /api/storage`, `POST /api/storage/cleanup/preview` and `/api/storage/cleanup`, `GET /api/storage/trash`, `POST /api/storage/trash/restore`, and `GET/PUT /api/storage/cleanup-policy` plus `POST /api/storage/cleanup-policy/run`. `GET /api/storage/cleanup-policy/test-stream` and `GET /api/storage/trash/restore/test-stream` exist for progress-stream testing. Cleanup takes an explicit `mode`: `quarantine` moves to trash and is restorable, `permanent` is not. The caller must name the mode — there is no default that silently deletes. | | Provider quotas and tests | `src/server/management/provider-routes.ts` — `GET /api/provider-quotas`, `POST /api/providers/test`, `GET/PUT /api/provider-context-caps`, `GET /api/provider-presets`. A context-cap PUT may combine a positive integer `value` with boolean `setAll` so a staged shared policy is persisted atomically; per-provider writes keep the existing `{ provider, enabled }` shape. A quota read may be served from cache or force-refreshed; absent quota data is reported as unknown rather than as a measured zero. Its additive `availability` list has one entry per enabled quota-capable provider, with `available / stale / unavailable` and only fixed privacy-safe reasons (`reauth_required`, `local_cli_refresh_required`, or `upstream_unavailable`); raw upstream or OAuth errors never cross the management boundary. | @@ -249,9 +277,10 @@ copies that credential into Keychain. A Finder-launched app cannot inherit an en token, so that configuration is reported as unsupported instead of prompting for or persisting a duplicate secret. -The active source build is `/dist/macos/CodexCommander.app`, which discovers the checkout's Bun and -CLI rather than being copied into Application Support. A packaged bundle build instead prefers its own -`Contents/Resources/runtime`; neither path shells through an ambient `ccx`. On launch the app runs an +The active development build is `/dist/macos/CodexCommander.app`. Every built app runs the Bun +runtime and server resources embedded in its own `Contents/Resources/runtime`; it never discovers or +executes checkout `src/`, so developers rebuild the app to pick up source changes. The development app +is not copied into Application Support, and no bundle shells through an ambient `ccx`. On launch it runs an ensure lifecycle action and automatically synchronizes the Codex model catalog, but remains open and actionable after an offline or startup failure. **Quit** terminates only the AppKit UI. **Stop** and **Restart** are separate confirmation-gated operations: Stop uses the fixed lifecycle helper to @@ -260,13 +289,15 @@ and reports success only after replacement identity verification. If running Codex workers still hold the previous roster, the healthy proxy is shown with a persistent, nonfatal **Agent catalog update ready** card. **Apply agent catalog** is a third, separate -fixed lifecycle action: after confirmation it synchronizes the catalog, sends `SIGTERM` only to exact -current-user `codex … app-server` and `codex-code-mode-host` process identities, waits briefly to -verify which old workers exited, and reports incomplete survivors without escalating to `SIGKILL`. +fixed lifecycle action: it sends only the current desired revision and an explicit interruption +confirmation to the protected management API, which re-converges the catalog and signals `SIGTERM` +only to exact current-user `codex … app-server` and `codex-code-mode-host` identities. It waits briefly +to verify which old workers exited and reports incomplete survivors without escalating to `SIGKILL`. It never restarts the CodexCommander proxy or closes the menu app. The confirmation reports fresh active -request evidence when available and still warns that an answer may be interrupted; zero or unknown -activity is not presented as proof of idleness. The current companion offers **Apply Now** and **Later**, not an -Apply-when-idle automation. The advanced CLI fallback is exactly: +request evidence when available and still warns that an answer may be interrupted; zero activity is not +proof of idleness, and unknown worker identity blocks the action. The current companion offers **Apply +Now** and **Later**, not an Apply-when-idle automation or persisted pending receipt. A new task or fork +does not reload the catalog of an existing worker. The advanced CLI fallback is exactly: ```bash ccx sync --restart-codex @@ -281,7 +312,8 @@ menu controllers. This does not replace or auto-install `com.codexcommander.prox remains the optional headless/crash-supervised server path, and service children still never open the companion. -The popover is a compact status surface: active primary/subagent work from `/api/agent-activity`, one +The popover is a compact status surface: proxy liveness, public startup readiness, and Codex route are +three separate signals, in-flight primary/subagent request turns come from `/api/agent-activity`, one provider-quota accordion with ChatGPT first and expanded by default, and fixed links into the full dashboard. Provider management opens `#providers//` so login, account, model, usage, and settings work remain in the existing authenticated browser UI. Restart is an explicit confirmed diff --git a/tests/cli-account.test.ts b/tests/cli-account.test.ts index c1f3e17c03..21e7915b0f 100644 --- a/tests/cli-account.test.ts +++ b/tests/cli-account.test.ts @@ -314,7 +314,18 @@ async function mockManagementApi(req: Request): Promise { } function defaultDeps(): AccountDeps { - return { baseUrl, loadConfigImpl: fixtureConfig }; + const parsed = new URL(baseUrl); + return { + baseUrl, + loadConfigImpl: fixtureConfig, + attestLiveManagementProxyImpl: async () => ({ + pid: 4242, + port: Number(parsed.port), + hostname: parsed.hostname, + source: "runtime", + baseUrl, + }), + }; } function stdinFrom(value: string, isTTY = false): AccountStdin { diff --git a/tests/cli-catalog-activation.test.ts b/tests/cli-catalog-activation.test.ts new file mode 100644 index 0000000000..eb223a0e90 --- /dev/null +++ b/tests/cli-catalog-activation.test.ts @@ -0,0 +1,392 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + applyCodexCatalogForCompanion, + applySynchronizedCatalogWorkers, + bindCacheArtifactForApply, + bindCatalogArtifactsForApply, + cacheApplyFenceArtifactStillMatches, + catalogApplyFenceArtifactsStillMatch, + catalogSyncCanApply, + reportCatalogWorkerApply, + syncCodexCatalogForCli, + type CliCodexSyncResult, +} from "../src/cli/catalog-activation"; +import { APPLY_CODEX_CATALOG_ACTION } from "../src/codex/catalog-apply"; +import type { CodexSyncResult } from "../src/codex/sync"; +import { RuntimeApiError } from "../src/cli/runtime-api"; + +function syncResult(overrides: Partial = {}): CodexSyncResult { + return { + status: "applied", + ok: true, + added: 1, + catalogPath: "/redacted/catalog.json", + catalogExists: true, + catalogWritten: true, + cacheSynced: true, + catalogQuality: "live", + rehydrated: 0, + message: "catalog synchronized", + ...overrides, + }; +} + +function liveSyncResult(overrides: Partial = {}): CliCodexSyncResult { + return { + ...syncResult(), + activation: { catalog: { status: "current" } }, + ...overrides, + }; +} + +describe("CLI catalog activation orchestration", () => { + test("a live sync publishes through /api/sync and never falls back to a local writer", async () => { + let localCalls = 0; + const requests: Array<{ path: string; baseUrl: string | undefined }> = []; + const result = await syncCodexCatalogForCli( + { pid: 41, port: 14100, hostname: "0.0.0.0", source: "runtime" }, + { + syncModelsToCodex: async () => { + localCalls += 1; + return syncResult(); + }, + runtimeRequest: async (path, _init, deps) => { + requests.push({ path, baseUrl: deps.baseUrl }); + return liveSyncResult({ catalogWritten: false, cacheSynced: false }); + }, + }, + ); + + expect(localCalls).toBe(0); + expect(requests).toEqual([{ path: "/api/sync", baseUrl: "http://127.0.0.1:14100" }]); + expect(result.catalogWritten).toBe(false); + }); + + test("an unreachable live proxy fails closed instead of starting a competing local sync", async () => { + let localCalls = 0; + await expect(syncCodexCatalogForCli( + { pid: 41, port: 14100, source: "runtime" }, + { + syncModelsToCodex: async () => { + localCalls += 1; + return syncResult(); + }, + runtimeRequest: async () => { + throw new RuntimeApiError("unreachable", 503, null); + }, + }, + )).rejects.toThrow("unreachable"); + expect(localCalls).toBe(0); + }); + + test("offline sync uses the canonical local sync facade", async () => { + let localCalls = 0; + const result = await syncCodexCatalogForCli(null, { + syncModelsToCodex: async () => { + localCalls += 1; + return syncResult(); + }, + runtimeRequest: async () => { + throw new Error("unexpected runtime request"); + }, + }); + expect(localCalls).toBe(1); + expect(result.ok).toBe(true); + }); + + test("a public config-port identity is never treated as a management target", async () => { + let localCalls = 0; + let runtimeCalls = 0; + const result = await syncCodexCatalogForCli( + { pid: 41, port: 10100, source: "config" }, + { + syncModelsToCodex: async () => { + localCalls += 1; + return syncResult({ status: "skipped", skippedReason: "desired_disabled" }); + }, + runtimeRequest: async () => { + runtimeCalls += 1; + throw new Error("public identity must not receive a management request"); + }, + }, + ); + + expect(localCalls).toBe(1); + expect(runtimeCalls).toBe(0); + expect(result).toMatchObject({ status: "skipped", skippedReason: "desired_disabled" }); + }); + + test("the companion wires its convergence callback to a freshly verified live proxy", async () => { + const live = { pid: 71, port: 17100, source: "runtime" as const }; + let synchronizedWith: typeof live | null = null; + let findCalls = 0; + const result = await applyCodexCatalogForCompanion({ + findLiveProxy: async () => { + findCalls += 1; + return live; + }, + syncCatalog: async observed => { + synchronizedWith = observed as typeof live; + // This wiring seam does not exercise disk artifacts; a warning keeps + // the production wrapper from arming its post-sync signal fence. + return liveSyncResult({ warning: "test seam" }); + }, + applyCatalog: async deps => { + await deps.syncModelsToCodex(live.port, {} as never, null); + return { + schemaVersion: 1, + action: APPLY_CODEX_CATALOG_ACTION, + ok: true, + state: "running", + changed: true, + pid: null, + port: null, + message: "applied", + catalogUpdated: true, + codexRestartRequired: false, + staleWorkerCount: 1, + stoppedWorkerCount: 1, + survivingWorkerCount: 0, + }; + }, + }); + + expect(findCalls).toBe(1); + expect(synchronizedWith).toEqual(live); + expect(result.ok).toBe(true); + }); + + test("the companion never falls back to helper-local convergence if the proxy disappears", async () => { + const live = { pid: 71, port: 17100, source: "runtime" as const }; + let findCalls = 0; + let syncCalls = 0; + await expect(applyCodexCatalogForCompanion({ + findLiveProxy: async () => (++findCalls === 1 ? live : null), + syncCatalog: async () => { + syncCalls += 1; + return syncResult(); + }, + applyCatalog: async deps => { + expect(await deps.findLiveProxy()).toEqual(live); + await deps.syncModelsToCodex(live.port, {} as never, null); + throw new Error("unexpected local continuation"); + }, + })).rejects.toThrow("stopped before catalog synchronization"); + expect(findCalls).toBe(2); + expect(syncCalls).toBe(0); + }); + + test("the companion folds post-sync artifact drift into its per-signal desired fence", async () => { + const live = { pid: 71, port: 17100, source: "runtime" as const }; + let drifted = false; + const desired = { + config: {} as never, + authority: {} as never, + revision: "desired-revision", + }; + await applyCodexCatalogForCompanion({ + findLiveProxy: async () => live, + syncCatalog: async () => liveSyncResult(), + captureDesiredSnapshot: () => desired, + captureArtifacts: () => ({}) as never, + artifactsStillMatch: () => !drifted, + applyCatalog: async deps => { + expect(deps.captureDesiredSnapshot().revision).toBe("desired-revision"); + await deps.syncModelsToCodex(live.port, {} as never, null); + expect(deps.captureDesiredSnapshot().revision).toBe("desired-revision"); + expect(deps.inspectArtifactProof(deps.captureDesiredSnapshot())).toBe("current"); + drifted = true; + expect(deps.captureDesiredSnapshot().revision).toBe("desired-revision"); + expect(deps.inspectArtifactProof(deps.captureDesiredSnapshot())).toBe("drifted"); + return { + schemaVersion: 1, + action: APPLY_CODEX_CATALOG_ACTION, + ok: false, + state: "running", + changed: false, + pid: null, + port: null, + message: "superseded", + errorCode: "CODEX_RESTART_REQUIRED", + catalogUpdated: false, + codexRestartRequired: true, + staleWorkerCount: 1, + stoppedWorkerCount: 0, + survivingWorkerCount: 1, + }; + }, + }); + }); + + test("live Apply requires a current or degraded receipt-backed activation", () => { + expect(catalogSyncCanApply(liveSyncResult(), true)).toBe(true); + expect(catalogSyncCanApply(liveSyncResult({ + activation: { catalog: { status: "degraded" } }, + }), true)).toBe(true); + expect(catalogSyncCanApply(liveSyncResult({ + activation: { catalog: { status: "pending" } }, + }), true)).toBe(false); + expect(catalogSyncCanApply(syncResult(), true)).toBe(false); + expect(catalogSyncCanApply(syncResult(), false)).toBe(true); + expect(catalogSyncCanApply(liveSyncResult({ warning: "degraded evidence" }), true)).toBe(false); + }); + + for (const drift of ["native", "custom-remote"] as const) { + test(`the CLI signal fence blocks route drift to ${drift}`, async () => { + const desired = { + config: {} as never, + authority: { generation: { value: 1 } } as never, + revision: "cli-desired-generation-1", + }; + let routingReads = 0; + let signals = 0; + const result = await applySynchronizedCatalogWorkers( + { desired, artifacts: {} as never }, + syncResult(), + { + captureDesiredSnapshot: () => desired, + artifactFenceStillMatches: () => true, + getRoutingKind: () => ++routingReads < 3 ? "codexcommander-local" : drift, + resetWorkerObservation: () => {}, + collectWorkerState: () => ({ + state: "stale", + catalogMtimeMs: 200, + processes: [{ pid: 10, startedAtMs: 100 }], + }), + applyWorkers: async authorizeSignal => { + if (authorizeSignal()) signals += 1; + return { + outcome: signals > 0 ? "applied" : "superseded", + staleWorkerCount: 1, + stoppedWorkerCount: signals, + survivingWorkerCount: signals > 0 ? 0 : 1, + }; + }, + }, + ); + + expect(signals).toBe(0); + expect(result).toEqual({ + outcome: "superseded", + staleWorkerCount: 1, + stoppedWorkerCount: 0, + survivingWorkerCount: 1, + }); + }); + } + + test("the CLI adapter preserves canonical no-worker, current, unknown, and partial outcomes", async () => { + const desired = { + config: {} as never, + authority: { generation: { value: 1 } } as never, + revision: "cli-outcome-generation-1", + }; + const cases = [ + { + state: { state: "not_running" as const, catalogMtimeMs: null, processes: [] }, + expected: { outcome: "no_workers", staleWorkerCount: 0, stoppedWorkerCount: 0, survivingWorkerCount: 0 }, + }, + { + state: { state: "fresh" as const, catalogMtimeMs: 200, processes: [{ pid: 20, startedAtMs: 300 }] }, + expected: { outcome: "already_current", staleWorkerCount: 0, stoppedWorkerCount: 0, survivingWorkerCount: 0 }, + }, + { + state: { state: "unknown" as const, catalogMtimeMs: null, processes: [] }, + expected: { outcome: "blocked", staleWorkerCount: 0, stoppedWorkerCount: 0, survivingWorkerCount: 0 }, + }, + { + state: { state: "stale" as const, catalogMtimeMs: 200, processes: [{ pid: 10, startedAtMs: 100 }] }, + expected: { outcome: "partial", staleWorkerCount: 1, stoppedWorkerCount: 0, survivingWorkerCount: 1 }, + }, + ]; + for (const item of cases) { + const result = await applySynchronizedCatalogWorkers( + { desired, artifacts: {} as never }, + syncResult(), + { + captureDesiredSnapshot: () => desired, + artifactFenceStillMatches: () => true, + getRoutingKind: () => "codexcommander-local", + resetWorkerObservation: () => {}, + collectWorkerState: () => item.state, + applyWorkers: async () => item.expected, + }, + ); + expect(result).toEqual(item.expected); + } + }); + + test("the post-sync fence accepts Codex-native cache churn but rejects catalog byte drift", () => { + const previous = process.env.CODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ccx-cli-apply-fence-")); + process.env.CODEX_HOME = home; + try { + writeFileSync(join(home, "codexcommander-catalog.json"), '{"models":[{"slug":"a"}]}\n'); + writeFileSync(join(home, "models_cache.json"), '{"models":[{"slug":"a"}]}\n'); + const fence = bindCatalogArtifactsForApply({ + config: {} as never, + authority: {} as never, + revision: "test-revision", + }); + expect(fence).not.toBeNull(); + expect(catalogApplyFenceArtifactsStillMatch(fence!)).toBe(true); + + writeFileSync(join(home, "models_cache.json"), '{"models":[{"slug":"b"}]}\n'); + expect(catalogApplyFenceArtifactsStillMatch(fence!)).toBe(true); + rmSync(join(home, "models_cache.json")); + expect(catalogApplyFenceArtifactsStillMatch(fence!)).toBe(true); + writeFileSync(join(home, "codexcommander-catalog.json"), '{"models":[{"slug":"b"}]}\n'); + expect(catalogApplyFenceArtifactsStillMatch(fence!)).toBe(false); + } finally { + if (previous === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previous; + rmSync(home, { recursive: true, force: true }); + } + }); + + test("the advanced sync-cache fence remains exact and cache-only", () => { + const previous = process.env.CODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ccx-cli-cache-apply-fence-")); + process.env.CODEX_HOME = home; + try { + writeFileSync(join(home, "models_cache.json"), '{"models":[{"slug":"a"}]}\n'); + const fence = bindCacheArtifactForApply({ + config: {} as never, + authority: {} as never, + revision: "cache-test-revision", + }); + expect(fence).not.toBeNull(); + expect(cacheApplyFenceArtifactStillMatches(fence!)).toBe(true); + + writeFileSync(join(home, "models_cache.json"), '{"models":[{"slug":"b"}]}\n'); + expect(cacheApplyFenceArtifactStillMatches(fence!)).toBe(false); + rmSync(join(home, "models_cache.json")); + expect(cacheApplyFenceArtifactStillMatches(fence!)).toBe(false); + } finally { + if (previous === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previous; + rmSync(home, { recursive: true, force: true }); + } + }); + + test("worker outcomes remain count-only and report incomplete Apply as failure", () => { + const logs: string[] = []; + const errors: string[] = []; + const ok = reportCatalogWorkerApply({ + outcome: "partial", + staleWorkerCount: 2, + stoppedWorkerCount: 1, + survivingWorkerCount: 1, + }, { + log: value => logs.push(String(value)), + error: value => errors.push(String(value)), + }); + expect(ok).toBe(false); + expect(logs).toEqual([]); + expect(errors).toEqual(["1 verified stale Codex worker(s) are still running after SIGTERM."]); + }); +}); diff --git a/tests/cli-gui-launch.test.ts b/tests/cli-gui-launch.test.ts new file mode 100644 index 0000000000..f80ca8651e --- /dev/null +++ b/tests/cli-gui-launch.test.ts @@ -0,0 +1,168 @@ +import { EventEmitter } from "node:events"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ChildProcess, spawn } from "node:child_process"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { + buildConfirmedGuiLaunchUrl, + createGuiLaunchHandoff, + mintConfirmedGuiLaunch, + openConfirmedGuiUrl, + type GuiLaunchTicketResponse, +} from "../src/cli/gui-launch"; +import { + resetHardenedStateForTests, + setIcaclsRunnerForTests, + setPlatformForTests, +} from "../src/lib/windows-secret-acl"; + +const ticket: GuiLaunchTicketResponse = { + ticket: `ccx_launch_${"A".repeat(43)}`, + origin: "http://127.0.0.1:10100", + route: "subagents", + expiresAt: 60_000, +}; + +let temporaryRoot = ""; +const previousUsername = process.env.USERNAME; + +beforeEach(() => { + temporaryRoot = mkdtempSync(join(tmpdir(), "ccx-gui-launch-test-")); + resetHardenedStateForTests(); + setPlatformForTests(null); + setIcaclsRunnerForTests(null); +}); + +afterEach(() => { + setIcaclsRunnerForTests(null); + setPlatformForTests(null); + resetHardenedStateForTests(); + if (previousUsername === undefined) delete process.env.USERNAME; + else process.env.USERNAME = previousUsername; + rmSync(temporaryRoot, { recursive: true, force: true }); + temporaryRoot = ""; +}); + +class FakeChild extends EventEmitter { + unrefCalls = 0; + unref(): this { + this.unrefCalls += 1; + return this; + } +} + +describe("confirmed GUI launch", () => { + test("mints against the exact live origin and rejects localhost aliases", async () => { + const seen: Array<{ path: string; baseUrl?: string; body?: BodyInit | null }> = []; + const request = (async (path: string, init?: RequestInit, options?: { baseUrl?: string }) => { + seen.push({ path, baseUrl: options?.baseUrl, body: init?.body }); + return ticket; + }) as never; + const launch = await mintConfirmedGuiLaunch("http://127.0.0.1:10100", 10100, "subagents", { + runtimeRequest: request, + now: () => 30_000, + }); + expect(seen).toEqual([{ + path: "/api/gui-launch-ticket", + baseUrl: "http://127.0.0.1:10100", + body: JSON.stringify({ route: "subagents" }), + }]); + expect(launch.origin).toBe("http://127.0.0.1:10100"); + expect(new URL(launch.url).search).toBe(""); + expect(new URL(launch.url).hash).toContain(ticket.ticket); + + await expect(mintConfirmedGuiLaunch("http://127.0.0.1:10100", 10100, "subagents", { + runtimeRequest: (async () => ({ ...ticket, origin: "http://localhost:10100" })) as never, + now: () => 30_000, + })).rejects.toThrow("invalid dashboard launch confirmation"); + }); + + test("POSIX handoff is private and never places the bearer in launcher argv", () => { + const url = buildConfirmedGuiLaunchUrl(ticket); + const handoff = createGuiLaunchHandoff(url, { platform: "linux", temporaryRoot }); + try { + expect(statSync(handoff.directory).mode & 0o777).toBe(0o700); + expect(statSync(handoff.file).mode & 0o777).toBe(0o600); + expect(readFileSync(handoff.file, "utf8")).toContain(ticket.ticket); + expect([handoff.command, ...handoff.args].join(" ")).not.toContain(ticket.ticket); + expect([handoff.command, ...handoff.args].join(" ")).not.toContain(url); + } finally { + handoff.cleanup(); + } + expect(existsSync(handoff.directory)).toBe(false); + }); + + test("launcher close retains the handoff until the delayed post-TTL cleanup", () => { + const url = buildConfirmedGuiLaunchUrl(ticket); + const children: FakeChild[] = []; + const calls: Array<{ command: string; args: readonly string[] }> = []; + const spawnImpl = ((command: string, args: readonly string[]) => { + calls.push({ command, args }); + const child = new FakeChild(); + children.push(child); + return child as unknown as ChildProcess; + }) as unknown as typeof spawn; + let cleanupTimer: (() => void) | null = null; + let cleanupDelay = 0; + const setTimeoutImpl = ((callback: () => void, delay?: number) => { + cleanupTimer = callback; + cleanupDelay = delay ?? 0; + return { unref() {} } as unknown as ReturnType; + }) as typeof setTimeout; + + openConfirmedGuiUrl(url, { platform: "linux", temporaryRoot, spawnImpl, setTimeoutImpl }); + const handoffFile = calls[0]?.args[0]; + expect(handoffFile).toBeTruthy(); + expect(existsSync(String(handoffFile))).toBe(true); + children[0]?.emit("close", 0); + expect(existsSync(String(handoffFile))).toBe(true); + expect(cleanupDelay).toBe(65_000); + expect(calls.flatMap(call => [call.command, ...call.args]).join(" ")).not.toContain(ticket.ticket); + expect(calls.flatMap(call => [call.command, ...call.args]).join(" ")).not.toContain(url); + expect(cleanupTimer).not.toBeNull(); + (cleanupTimer as unknown as () => void)(); + expect(existsSync(String(handoffFile))).toBe(false); + }); + + test("launcher spawn errors remove the private handoff immediately", () => { + const url = buildConfirmedGuiLaunchUrl(ticket); + const child = new FakeChild(); + const spawnImpl = (() => child as unknown as ChildProcess) as unknown as typeof spawn; + const setTimeoutImpl = (() => ({ unref() {} }) as unknown as ReturnType) as typeof setTimeout; + openConfirmedGuiUrl(url, { platform: "linux", temporaryRoot, spawnImpl, setTimeoutImpl }); + const directory = readdirSync(temporaryRoot).map(name => join(temporaryRoot, name))[0]; + expect(directory && existsSync(directory)).toBe(true); + child.emit("error", new Error("injected launcher failure")); + expect(directory && existsSync(directory)).toBe(false); + }); + + test("Windows ACL failures for either directory or file fail closed", () => { + process.env.USERNAME = "ccx-test-user"; + setPlatformForTests("win32"); + const url = buildConfirmedGuiLaunchUrl(ticket); + + setIcaclsRunnerForTests(() => ({ success: false, exitCode: 5, timedOut: false, stdout: "" })); + expect(() => createGuiLaunchHandoff(url, { platform: "win32", temporaryRoot })) + .toThrow("private dashboard launch handoff"); + expect(readdirSync(temporaryRoot)).toEqual([]); + + resetHardenedStateForTests(); + setIcaclsRunnerForTests(args => ({ + success: !String(args[0]).endsWith("dashboard.url"), + exitCode: String(args[0]).endsWith("dashboard.url") ? 5 : 0, + timedOut: false, + stdout: "", + })); + expect(() => createGuiLaunchHandoff(url, { platform: "win32", temporaryRoot })) + .toThrow("private dashboard launch handoff"); + expect(readdirSync(temporaryRoot)).toEqual([]); + }); + + test("CLI GUI output is pinned to the base origin, never the ticket URL", async () => { + const source = await Bun.file(new URL("../src/cli/index.ts", import.meta.url)).text(); + expect(source).toContain("console.log(`Opening ${launch.origin}`)"); + expect(source).not.toContain("console.log(`Opening ${launch.url}`)"); + }); +}); diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index f1ac3fa0b8..66a7215226 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -34,7 +34,20 @@ function fakeRuntime(responder?: (req: Request, body: unknown) => unknown) { }, }); servers.push(server); - return { requests, deps: { baseUrl: `http://127.0.0.1:${server.port}` } }; + const baseUrl = `http://127.0.0.1:${server.port}`; + return { + requests, + deps: { + baseUrl, + attestLiveManagementProxyImpl: async () => ({ + pid: 4242, + port: server.port, + hostname: "127.0.0.1", + source: "runtime" as const, + baseUrl, + }), + }, + }; } function sourceFiles(root: string): string[] { @@ -126,11 +139,17 @@ describe("headless GUI parity CLI", () => { ["/api/debug", "ccx debug/observe"], ["/api/diagnostics", "ccx system"], ["/api/effort", "ccx agent"], + // The fragment exchange is the browser half of `ccx gui`; it consumes the + // one-time launcher ticket and is not a standalone headless operation. + ["/api/gui-launch-exchange", "ccx gui (browser handoff)"], ["/api/grok", "ccx grok"], ["/api/injection", "ccx agent"], ["/api/integrations/opencode", "ccx opencode"], ["/api/keys", "ccx access"], ["/api/logs", "ccx observe"], + // Short-lived proxy request leases are advisory UI telemetry. Persistent + // request history remains available through `ccx observe logs`. + ["/api/agent-activity", "ccx observe logs"], ["/api/config", "ccx config"], ["/api/settings", "ccx system"], // Routing Intelligence (RI-04..RI-10): profiles + dry-run are mirrored by @@ -144,6 +163,7 @@ describe("headless GUI parity CLI", () => { ["/api/stop", "ccx stop"], ["/api/storage", "ccx observe"], ["/api/subagent", "ccx agent"], + ["/api/codex-catalog", "ccx system sync --restart-codex"], ["/api/sync", "ccx system sync"], ["/api/system", "ccx observe/system"], ["/api/usage", "ccx observe usage"], diff --git a/tests/cli-management-auth.test.ts b/tests/cli-management-auth.test.ts index d4797dd4d0..405dc542e8 100644 --- a/tests/cli-management-auth.test.ts +++ b/tests/cli-management-auth.test.ts @@ -2,10 +2,15 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { runtimeRequest } from "../src/cli/runtime-api"; +import { RuntimeApiError, runtimeRequest } from "../src/cli/runtime-api"; import { stopProxyGracefully } from "../src/lib/process-control"; import { fetchClaudeContextWindows } from "../src/cli/claude"; -import { API_KEY_HEADER } from "../src/identity"; +import { + API_KEY_HEADER, + ATTESTATION_CHALLENGE_HEADER, + ATTESTATION_PROOF_HEADER, +} from "../src/identity"; +import { createLocalAttestationProof } from "../src/lib/local-management-attestation"; import type { CodexCommanderConfig } from "../src/types"; const previousHome = process.env.CODEXCOMMANDER_HOME; @@ -27,9 +32,27 @@ afterEach(() => { async function capturedManagementToken(): Promise { let token: string | null = null; + const pid = 4242; + const port = 10100; + const attestationSecret = "A".repeat(43); await runtimeRequest("/api/config", {}, { baseUrl: "http://127.0.0.1:10100", - fetchImpl: async (_input, init) => { + managementAttestation: { + readRuntimeFn: () => ({ pid, port, hostname: "127.0.0.1", attestationSecret }), + verifyPidFn: candidate => candidate, + }, + fetchImpl: async (input, init) => { + if (String(input).endsWith("/healthz")) { + const headers = new Headers(init?.headers); + expect(headers.get(API_KEY_HEADER)).toBeNull(); + expect(init?.body).toBeUndefined(); + const challenge = headers.get(ATTESTATION_CHALLENGE_HEADER)!; + const proof = createLocalAttestationProof(attestationSecret, challenge, pid, port)!; + return Response.json( + { service: "codexcommander", status: "ok", version: "test", uptime: 1, pid, port }, + { headers: { [ATTESTATION_PROOF_HEADER]: proof } }, + ); + } token = new Headers(init?.headers).get(API_KEY_HEADER); return Response.json({ ok: true }); }, @@ -54,16 +77,79 @@ describe("CLI management authentication", () => { expect(await capturedManagementToken()).toBe(`ccx_admin_${"a".repeat(43)}`); }); + test("a spoofed listener receives neither the admin token nor a mutating body", async () => { + process.env.CODEXCOMMANDER_ADMIN_AUTH_TOKEN = "admin-secret"; + const attestationSecret = "A".repeat(43); + let managementCalls = 0; + const seen: Array<{ token: string | null; body: BodyInit | null | undefined }> = []; + const request = runtimeRequest("/api/providers", { + method: "POST", + body: JSON.stringify({ provider: { apiKey: "upstream-secret" } }), + }, { + managementAttestation: { + attempts: 1, + readRuntimeFn: () => ({ pid: 4242, port: 10100, hostname: "127.0.0.1", attestationSecret }), + verifyPidFn: candidate => candidate, + }, + fetchImpl: async (input, init) => { + seen.push({ token: new Headers(init?.headers).get(API_KEY_HEADER), body: init?.body }); + if (!String(input).endsWith("/healthz")) managementCalls += 1; + return Response.json( + { service: "codexcommander", pid: 4242, port: 10100 }, + { headers: { [ATTESTATION_PROOF_HEADER]: "B".repeat(43) } }, + ); + }, + }); + await expect(request).rejects.toBeInstanceOf(RuntimeApiError); + expect(managementCalls).toBe(0); + expect(seen).toEqual([{ token: null, body: undefined }]); + }); + + test("GET credentials in standard bearer headers are also held behind attestation", async () => { + const home = mkdtempSync(join(tmpdir(), "ccx-cli-header-attestation-")); + homes.push(home); + process.env.CODEXCOMMANDER_HOME = home; + delete process.env.CODEXCOMMANDER_ADMIN_AUTH_TOKEN; + const attestationSecret = "A".repeat(43); + for (const [name, value] of [ + ["Authorization", "Bearer caller-secret"], + ["x-api-key", "caller-api-secret"], + ] as const) { + let calls = 0; + await expect(runtimeRequest("/api/config", { headers: { [name]: value } }, { + managementAttestation: { + attempts: 1, + readRuntimeFn: () => ({ pid: 4242, port: 10100, hostname: "127.0.0.1", attestationSecret }), + verifyPidFn: candidate => candidate, + }, + fetchImpl: async (_input, init) => { + calls += 1; + const headers = new Headers(init?.headers); + expect(headers.get(name)).toBeNull(); + return new Response("spoof", { headers: { [ATTESTATION_PROOF_HEADER]: "B".repeat(43) } }); + }, + })).rejects.toBeInstanceOf(RuntimeApiError); + expect(calls).toBe(1); + } + }); + test("graceful stop sends the management token instead of the data token", async () => { let token: string | null = null; + const attestationSecret = "A".repeat(43); const result = await stopProxyGracefully(1234, { - readRuntime: () => ({ port: 10100, hostname: "127.0.0.1" }), + readRuntime: () => ({ pid: 1234, port: 10100, hostname: "127.0.0.1", attestationSecret }), + verifyPidFn: candidate => candidate, waitExit: () => true, env: { CODEXCOMMANDER_API_AUTH_TOKEN: "data-secret", CODEXCOMMANDER_ADMIN_AUTH_TOKEN: "admin-secret", }, - fetchFn: async (_input, init) => { + fetchFn: async (input, init) => { + if (String(input).endsWith("/healthz")) { + const challenge = new Headers(init?.headers).get(ATTESTATION_CHALLENGE_HEADER)!; + const proof = createLocalAttestationProof(attestationSecret, challenge, 1234, 10100)!; + return new Response("", { headers: { [ATTESTATION_PROOF_HEADER]: proof } }); + } token = new Headers(init?.headers).get(API_KEY_HEADER); return new Response(null, { status: 200 }); }, @@ -76,10 +162,17 @@ describe("CLI management authentication", () => { process.env.CODEXCOMMANDER_API_AUTH_TOKEN = "data-secret"; process.env.CODEXCOMMANDER_ADMIN_AUTH_TOKEN = "admin-secret"; let token: string | null = null; - globalThis.fetch = (async (_input, init) => { + const attestationSecret = "A".repeat(43); + const fetchImpl = (async (input, init) => { + if (String(input).endsWith("/healthz")) { + const challenge = new Headers(init?.headers).get(ATTESTATION_CHALLENGE_HEADER)!; + const proof = createLocalAttestationProof(attestationSecret, challenge, 4242, 10100)!; + return new Response("", { headers: { [ATTESTATION_PROOF_HEADER]: proof } }); + } token = new Headers(init?.headers).get(API_KEY_HEADER); return Response.json({ contextWindows: { "gpt-test": 200_000 } }); }) as typeof fetch; + globalThis.fetch = fetchImpl; const config = { port: 10100, defaultProvider: "test", @@ -92,7 +185,11 @@ describe("CLI management authentication", () => { }], } as CodexCommanderConfig; - expect(await fetchClaudeContextWindows(config, 10100)).toEqual({ "gpt-test": 200_000 }); + expect(await fetchClaudeContextWindows(config, 10100, 3_000, { + fetchFn: fetchImpl, + readRuntimeFn: () => ({ pid: 4242, port: 10100, hostname: "127.0.0.1", attestationSecret }), + verifyPidFn: candidate => candidate, + })).toEqual({ "gpt-test": 200_000 }); expect(token).toBe("admin-secret"); }); }); diff --git a/tests/cli-native-profile.test.ts b/tests/cli-native-profile.test.ts index 08fb90f4d5..395e08eade 100644 --- a/tests/cli-native-profile.test.ts +++ b/tests/cli-native-profile.test.ts @@ -10,6 +10,20 @@ const originalLog = console.log; const originalError = console.error; const originalCodexHome = process.env.CODEX_HOME; const tempRoots: string[] = []; +const TEST_BASE_URL = "http://127.0.0.1:10100"; + +function attestedTestTransport() { + return { + baseUrl: TEST_BASE_URL, + attestLiveManagementProxyImpl: async () => ({ + pid: 4242, + port: 10100, + hostname: "127.0.0.1", + source: "runtime" as const, + baseUrl: TEST_BASE_URL, + }), + }; +} function tempConfigDir(prefix: string): string { const path = mkdtempSync(join(tmpdir(), prefix)); @@ -128,7 +142,7 @@ describe("ccx account main", () => { return Response.json({ ok: true }); }; const deps = { - baseUrl: "http://127.0.0.1:10100", + ...attestedTestTransport(), fetchImpl, spawnCodexLoginImpl: (home: string) => { loginHome = home; @@ -183,7 +197,7 @@ describe("ccx account main", () => { const operation = new URL(String(input)).pathname.split("/").at(-1)!; return Response.json(responses[operation]); }; - const deps = { baseUrl: "http://127.0.0.1:10100", fetchImpl }; + const deps = { ...attestedTestTransport(), fetchImpl }; const commands = [ { args: ["main", "register", "personal", "--json"], result: responses.register }, @@ -216,7 +230,7 @@ describe("ccx account main", () => { }; expect(await cmdAccount(["main", "add", "work"], { - baseUrl: "http://127.0.0.1:10100", + ...attestedTestTransport(), fetchImpl, spawnCodexLoginImpl: () => ({ exited: Promise.resolve(0), kill: () => {} }), })).toBe(1); @@ -244,7 +258,7 @@ describe("ccx account main", () => { }; expect(await cmdAccount(["main", "add", "work"], { - baseUrl: "http://127.0.0.1:10100", + ...attestedTestTransport(), fetchImpl, spawnCodexLoginImpl: () => ({ exited: Promise.resolve(0), kill: () => {} }), })).toBe(1); @@ -272,7 +286,7 @@ describe("ccx account main", () => { }; expect(await cmdAccount(["main", "add", "work"], { - baseUrl: "http://127.0.0.1:10100", + ...attestedTestTransport(), fetchImpl, spawnCodexLoginImpl: () => ({ exited: Promise.reject(new Error("login aborted")), @@ -317,7 +331,7 @@ describe("ccx account main", () => { }; const running = cmdAccount(["main", "add", "work"], { - baseUrl: "http://127.0.0.1:10100", + ...attestedTestTransport(), fetchImpl, stageHeartbeatIntervalMinMs: 10, spawnCodexLoginImpl: () => ({ exited, kill: () => {} }), @@ -386,7 +400,7 @@ describe("ccx account main", () => { }; const running = cmdAccount(["main", "add", "work"], { - baseUrl: "http://127.0.0.1:10100", + ...attestedTestTransport(), fetchImpl, stageHeartbeatIntervalMinMs: 10, stageLeaseClock, diff --git a/tests/codex-app-server-processes.test.ts b/tests/codex-app-server-processes.test.ts index 18d41b1393..6b2cf9c3af 100644 --- a/tests/codex-app-server-processes.test.ts +++ b/tests/codex-app-server-processes.test.ts @@ -105,6 +105,38 @@ describe("collectCodexAppServerCatalogState (#857)", () => { }); expect(status.state).toBe("stale"); }); + + test("Darwin same-second fence overlap is unknown, never proven stale", () => { + const status = collectCodexAppServerCatalogState({ + platform: "darwin", + listSnapshots: () => [{ + pid: 42, + commandLine: APP_SERVER_CMD, + // `ps lstart` reports only the containing second. The real worker may + // have started after the 1.5s artifact fence. + startedAtMs: 1_000, + }], + startTimePrecisionMs: 1_000, + catalogMtimeMs: () => 1_500, + }); + expect(status.state).toBe("unknown"); + }); + + test("Linux btime same-second fence overlap is unknown by default", () => { + const status = collectCodexAppServerCatalogState({ + platform: "linux", + listSnapshots: () => [{ + pid: 42, + commandLine: APP_SERVER_CMD, + // `/proc/stat` btime is whole-second epoch evidence. Even though the + // per-process tick offset is finer, the absolute wall clock may be up + // to one second later than this converted value. + startedAtMs: 1_000, + }], + catalogMtimeMs: () => 1_500, + }); + expect(status.state).toBe("unknown"); + }); }); describe("Codex app-server process matching (#476)", () => { @@ -220,8 +252,8 @@ describe("Codex app-server process matching (#476)", () => { const waits: number[] = []; const alive = new Set([100, 200]); const snapshots = [ - { pid: 100, commandLine: "codex app-server" }, - { pid: 200, commandLine: "codex-code-mode-host" }, + { pid: 100, commandLine: "codex app-server", startedAtMs: 100 }, + { pid: 200, commandLine: "codex-code-mode-host", startedAtMs: 200 }, ]; let now = 1_000; const result = restartCodexAppServers( @@ -252,11 +284,11 @@ describe("Codex app-server process matching (#476)", () => { expect(result.failed).toEqual([]); }); - test("restartCodexAppServers treats kill-throw on already-dead pid as stopped", () => { + test("restartCodexAppServers never claims a natural exit as a stop", () => { const result = restartCodexAppServers( - [{ pid: 9, commandLine: "codex app-server" }], + [{ pid: 9, commandLine: "codex app-server", startedAtMs: 100 }], { - listSnapshots: () => [{ pid: 9, commandLine: "codex app-server" }], + listSnapshots: () => [{ pid: 9, commandLine: "codex app-server", startedAtMs: 100 }], kill: () => { throw new Error("ESRCH"); }, @@ -264,17 +296,18 @@ describe("Codex app-server process matching (#476)", () => { waitExit: () => true, }, ); - expect(result.stopped).toEqual([9]); + expect(result.signaled).toEqual([]); + expect(result.stopped).toEqual([]); expect(result.failed).toEqual([]); expect(result.surviving).toEqual([]); }); - test("restartCodexAppServers skips PIDs whose identity changed before signal", () => { + test("restartCodexAppServers skips argv changed after the captured birth check", () => { const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; const result = restartCodexAppServers( - [{ pid: 42, commandLine: "codex app-server" }], + [{ pid: 42, commandLine: "codex app-server", startedAtMs: 1_000 }], { - listSnapshots: () => [{ pid: 42, commandLine: "vim README.md" }], + readSnapshot: () => ({ pid: 42, commandLine: "vim README.md", startedAtMs: 1_000 }), kill: (pid, signal) => { signals.push({ pid, signal }); }, @@ -291,10 +324,14 @@ describe("Codex app-server process matching (#476)", () => { test("restartCodexAppServers skips recycled PID that matches a different Codex process", () => { const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; const result = restartCodexAppServers( - [{ pid: 42, commandLine: "codex app-server --listen unix://old" }], + [{ pid: 42, commandLine: "codex app-server --listen unix://old", startedAtMs: 1_000 }], { // Same PID, still Codex-shaped, but a different process identity. - listSnapshots: () => [{ pid: 42, commandLine: "codex-code-mode-host --session 9" }], + readSnapshot: () => ({ + pid: 42, + commandLine: "codex-code-mode-host --session 9", + startedAtMs: 1_000, + }), kill: (pid, signal) => { signals.push({ pid, signal }); }, @@ -323,13 +360,119 @@ describe("Codex app-server process matching (#476)", () => { ); expect(signals).toEqual([]); - expect(result).toEqual({ requested: [42], stopped: [], surviving: [], failed: [] }); + expect(result).toEqual({ requested: [42], signaled: [], stopped: [], surviving: [], failed: [] }); + }); + + test("restartCodexAppServers rechecks caller authorization immediately before SIGTERM", () => { + const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + const result = restartCodexAppServers( + [{ pid: 42, commandLine: "codex app-server", startedAtMs: 1_000 }], + { + listSnapshots: () => [{ pid: 42, commandLine: "codex app-server" }], + readStartMs: () => 1_000, + authorizeSignal: () => false, + kill: (pid, signal) => { signals.push({ pid, signal }); }, + isAlive: () => true, + waitExit: () => false, + }, + ); + + expect(signals).toEqual([]); + expect(result).toEqual({ + requested: [42], + signaled: [], + stopped: [], + surviving: [42], + failed: [], + authorizationRefused: true, + }); + }); + + test("authorization completes before the final PID identity snapshot", () => { + const calls: string[] = []; + let liveBirth = 1_000; + const result = restartCodexAppServers( + [{ pid: 42, commandLine: "codex app-server", startedAtMs: 1_000 }], + { + authorizeSignal: () => { + calls.push("authorize"); + // Model PID reuse while a durable revision/consent read is in flight. + liveBirth = 2_000; + return true; + }, + readSnapshot: () => { + calls.push("snapshot"); + return { pid: 42, commandLine: "codex app-server", startedAtMs: liveBirth }; + }, + kill: () => { calls.push("signal"); }, + isAlive: () => true, + waitExit: () => false, + }, + ); + + expect(calls).toEqual(["authorize", "snapshot"]); + expect(result).toEqual({ requested: [42], signaled: [], stopped: [], surviving: [], failed: [] }); + }); + + test("a successful signal has authorization then final identity then SIGTERM", () => { + const calls: string[] = []; + const result = restartCodexAppServers( + [{ pid: 42, commandLine: "codex app-server", startedAtMs: 1_000 }], + { + authorizeSignal: () => { calls.push("authorize"); return true; }, + readSnapshot: () => { + calls.push("snapshot"); + return { pid: 42, commandLine: "codex app-server", startedAtMs: 1_000 }; + }, + kill: (_pid, signal) => { + calls.push(signal); + }, + isAlive: () => false, + waitExit: () => true, + }, + ); + + expect(calls).toEqual(["authorize", "snapshot", "SIGTERM"]); + expect(result.signaled).toEqual([42]); + expect(result.stopped).toEqual([42]); + }); + + test("authorization refusal stops the operation permanently after an earlier signal", () => { + const signals: number[] = []; + let authorizations = 0; + const snapshots = [ + { pid: 10, commandLine: "codex app-server --worker one", startedAtMs: 100 }, + { pid: 20, commandLine: "codex app-server --worker two", startedAtMs: 200 }, + { pid: 30, commandLine: "codex app-server --worker three", startedAtMs: 300 }, + ]; + const result = restartCodexAppServers(snapshots, { + readSnapshot: pid => snapshots.find(snapshot => snapshot.pid === pid) ?? null, + authorizeSignal: () => ++authorizations === 1, + kill: pid => { signals.push(pid); }, + isAlive: () => true, + waitExit: () => false, + }); + + expect(authorizations).toBe(2); + expect(signals).toEqual([10]); + expect(result).toEqual({ + requested: [10, 20, 30], + signaled: [10], + stopped: [], + surviving: [20, 30, 10], + failed: [], + authorizationRefused: true, + }); }); test("afterCatalogWriteHandleAppServers warns by default and restarts when requested", () => { const errors: string[] = []; const logs: string[] = []; - const snapshots = [{ pid: 7, commandLine: "codex app-server --listen unix://x" }]; + const snapshots = [{ + pid: 7, + commandLine: "codex app-server --listen unix://x", + startedAtMs: 100, + }]; const io = { listSnapshots: () => snapshots, kill: () => {}, @@ -359,26 +502,36 @@ describe("Codex app-server process matching (#476)", () => { describe("CLI /api sync wiring for stale app-servers (#476)", () => { const cliSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "index.ts"), "utf8"); + const cliActivationSource = readFileSync( + join(import.meta.dir, "..", "src", "cli", "catalog-activation.ts"), + "utf8", + ); const configRoutesSource = readFileSync( join(import.meta.dir, "..", "src", "server", "management", "config-routes.ts"), "utf8", ); - test("ccx sync only handles app-servers after a catalog/cache write and forwards --restart-codex", () => { + test("ccx sync uses live receipt convergence and revision-fenced stale-worker Apply", () => { const syncCase = cliSource.slice(cliSource.indexOf('case "sync":'), cliSource.indexOf('case "v2":')); expect(syncCase).toContain('args.slice(1).includes("--restart-codex")'); + expect(syncCase).toContain("captureCatalogRestartFence"); + expect(syncCase).toContain("syncCodexCatalogForCli(live)"); + expect(syncCase).toContain("bindCatalogArtifactsForApply(restartFence)"); + expect(syncCase).toContain("applySynchronizedCatalogWorkers("); expect(syncCase).toContain("synced.catalogWritten || synced.cacheSynced"); - expect(syncCase).toContain("afterCatalogWriteHandleAppServers"); - expect(syncCase).toContain("restart: restartCodex"); - expect(syncCase.indexOf("catalogWritten || synced.cacheSynced")) - .toBeLessThan(syncCase.indexOf("afterCatalogWriteHandleAppServers")); - // No-write path must not call the handler outside the gate. - const gatedBlock = syncCase.slice(syncCase.indexOf("if (synced.catalogWritten")); - expect(gatedBlock).toContain("afterCatalogWriteHandleAppServers"); - expect(syncCase.replace(gatedBlock, "")).not.toContain("afterCatalogWriteHandleAppServers"); + expect(syncCase.indexOf("captureCatalogRestartFence")) + .toBeLessThan(syncCase.indexOf("syncCodexCatalogForCli(live)")); + expect(syncCase).not.toContain("afterCatalogWriteHandleAppServers"); + + expect(cliActivationSource).toContain('runtimeRequest("/api/sync"'); + expect(cliActivationSource).toContain('live.source !== "runtime" || live.pid === null'); + expect(cliActivationSource).toContain("return deps.syncModelsToCodex()"); + expect(cliActivationSource).toContain("captureCodexCatalogDesiredSnapshot().revision === expected.desired.revision"); + expect(cliActivationSource).toContain("artifactFenceStillMatches: catalogApplyFenceArtifactsStillMatch"); + expect(cliActivationSource).toContain("applyCodexCatalogWorkers("); }); - test("ccx sync-cache only handles app-servers after a successful models_cache write", () => { + test("ccx sync-cache keeps its direct write gate and applies only against the cache fence", () => { const syncCacheCase = cliSource.slice( cliSource.indexOf('case "sync-cache":'), cliSource.indexOf('case "gui":'), @@ -391,12 +544,18 @@ describe("CLI /api sync wiring for stale app-servers (#476)", () => { expect(syncCacheCase).toContain("invalidateCodexModelsCacheWithPermit(permit, owningCodexHome)"); const gate = 'if (invalidated.kind === "completed" && invalidated.value)'; expect(syncCacheCase).toContain(gate); - expect(syncCacheCase).toContain("afterCatalogWriteHandleAppServers"); + expect(syncCacheCase).toContain("applyInvalidatedCacheWorkers("); + expect(syncCacheCase).toContain("bindCacheArtifactForApply(restartFence)"); expect(syncCacheCase.indexOf(gate)) - .toBeLessThan(syncCacheCase.indexOf("afterCatalogWriteHandleAppServers")); + .toBeLessThan(syncCacheCase.indexOf("applyInvalidatedCacheWorkers(")); const gatedBlock = syncCacheCase.slice(syncCacheCase.indexOf(gate)); - expect(gatedBlock).toContain("afterCatalogWriteHandleAppServers"); - expect(syncCacheCase.replace(gatedBlock, "")).not.toContain("afterCatalogWriteHandleAppServers"); + expect(gatedBlock).toContain("applyInvalidatedCacheWorkers("); + expect(syncCacheCase.replace(gatedBlock, "")).not.toContain("applyInvalidatedCacheWorkers("); + expect(syncCacheCase).not.toContain("afterCatalogWriteHandleAppServers"); + + expect(cliActivationSource).toContain("const cacheMtimeMs = () =>"); + expect(cliActivationSource).toContain("cacheApplyFenceArtifactStillMatches(expected)"); + expect(cliActivationSource).toContain("collectCodexAppServerCatalogState({ catalogMtimeMs: cacheMtimeMs })"); }); test("POST /api/sync attaches the current catalog state and never enumerates processes directly", () => { @@ -406,7 +565,8 @@ describe("CLI /api sync wiring for stale app-servers (#476)", () => { ); expect(syncHandler).toContain("attachStaleAppServerHint(result)"); expect(syncHandler).toContain("deps.resetCodexAppServerCatalogStateCache ?? resetCodexAppServerCatalogStateCache"); - expect(syncHandler).toContain("deps.collectCodexAppServerCatalogState ?? collectCodexAppServerCatalogState"); + expect(syncHandler).toContain("deps.collectCodexAppServerCatalogState"); + expect(syncHandler).toContain("collectCodexCatalogActivationWorkerState"); expect(syncHandler.indexOf("deps.resetCodexAppServerCatalogStateCache")) .toBeLessThan(syncHandler.indexOf("deps.collectCodexAppServerCatalogState")); expect(syncHandler).not.toContain("listCodexAppServerProcesses"); @@ -526,11 +686,11 @@ describe("Windows Win32_Process owner enumeration (#476)", () => { }); /* - * #1046. Service startup rewrites the catalog and the models cache while an - * app-server that booted earlier keeps its own in-memory model list, so the - * picker shows a roster that no longer exists on disk. The startup path warns; - * it must never signal, because a boot is not a user consenting to have an - * in-flight turn interrupted. + * #1046. Canonical startup convergence may rewrite the catalog and models cache + * while an app-server that booted earlier keeps its own in-memory model list, so + * the picker shows a roster that no longer exists on disk. The startup path + * warns; it must never signal, because a boot is not a user consenting to have + * an in-flight turn interrupted. */ describe("warnIfStaleCodexAppServersAfterStartupWrite (#1046)", () => { const APP_SERVER_CMD = "/usr/local/bin/codex app-server"; diff --git a/tests/codex-boot-fence.test.ts b/tests/codex-boot-fence.test.ts new file mode 100644 index 0000000000..2053b242b0 --- /dev/null +++ b/tests/codex-boot-fence.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + codexBootConfigHash, + codexBootFenceMarkerPath, + observeCodexBootFence, +} from "../src/codex/boot-fence"; + +let home = ""; +let previous: string | undefined; +const configPath = () => join(home, "config.toml"); +const marker = () => JSON.parse(readFileSync(codexBootFenceMarkerPath(), "utf8")); + +beforeEach(() => { + previous = process.env.CODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ccx-boot-fence-")); + process.env.CODEX_HOME = home; + writeFileSync(configPath(), 'openai_base_url = "http://one/v1"\n[agents]\nenabled = true\n'); +}); + +afterEach(() => { + if (previous === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previous; + rmSync(home, { recursive: true, force: true }); +}); + +describe("Codex boot fence", () => { + test("seeds a missing marker from the raw config mtime", () => { + utimesSync(configPath(), 100, 100); + expect(observeCodexBootFence().mtimeMs).toBe(100_000); + expect(marker()).toMatchObject({ schemaVersion: 1, bootHash: codexBootConfigHash(), changedAtMs: 100_000 }); + }); + + test("ignores desktop-owned content and formatting churn", () => { + const initial = observeCodexBootFence().mtimeMs; + writeFileSync(configPath(), 'openai_base_url="http://one/v1"\n\n[agents]\nenabled=true\n[marketplaces.x]\nlast_updated = 999\n'); + utimesSync(configPath(), 300, 300); + expect(observeCodexBootFence().mtimeMs).toBe(initial); + }); + + test("advances for each supported boot-key family", () => { + observeCodexBootFence(); + for (const content of [ + 'openai_base_url = "http://two/v1"\n', + '[agents]\nenabled = false\nmax_depth = 3\n', + '[features.multi_agent_v2]\nenabled = true\nmax_concurrent_threads_per_session = 9\n', + ]) { + writeFileSync(configPath(), content); + const old = marker().changedAtMs; + observeCodexBootFence(); + expect(marker().changedAtMs).toBeGreaterThanOrEqual(old); + expect(marker().bootHash).toBe(codexBootConfigHash()); + } + }); + + test("reseeds a corrupt marker", () => { + writeFileSync(codexBootFenceMarkerPath(), "not-json"); + utimesSync(configPath(), 123, 123); + expect(observeCodexBootFence().mtimeMs).toBe(123_000); + expect(marker().schemaVersion).toBe(1); + }); + + test("falls back to raw mtime when config cannot be parsed", () => { + writeFileSync(configPath(), "value = [\n"); + utimesSync(configPath(), 234, 234); + expect(codexBootConfigHash()).toBeNull(); + expect(observeCodexBootFence().mtimeMs).toBe(234_000); + }); + + test("never writes into a never-managed Codex home", () => { + writeFileSync(configPath(), 'model = "gpt-5"\n[marketplaces.x]\nlast_updated = 1\n'); + utimesSync(configPath(), 345, 345); + expect(observeCodexBootFence().mtimeMs).toBe(345_000); + expect(existsSync(codexBootFenceMarkerPath())).toBe(false); + // Pre-injection behavior is unchanged: desktop churn still moves the raw fence + // until CodexCommander manages the home and seeds the content-scoped marker. + utimesSync(configPath(), 456, 456); + expect(observeCodexBootFence().mtimeMs).toBe(456_000); + expect(existsSync(codexBootFenceMarkerPath())).toBe(false); + }); + + test("never regresses a future stored change time", () => { + const future = Date.now() + 60_000; + writeFileSync(codexBootFenceMarkerPath(), JSON.stringify({ schemaVersion: 1, bootHash: "old", changedAtMs: future })); + expect(observeCodexBootFence().mtimeMs).toBe(future); + expect(marker().changedAtMs).toBe(future); + }); +}); diff --git a/tests/codex-catalog-activation.test.ts b/tests/codex-catalog-activation.test.ts new file mode 100644 index 0000000000..9b1c0a2fa2 --- /dev/null +++ b/tests/codex-catalog-activation.test.ts @@ -0,0 +1,807 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + type CodexCatalogDesiredSnapshot, + codexCatalogDesiredRevision, + codexCatalogActivationFenceMtimeMs, + inspectCodexCatalogActivation, +} from "../src/codex/catalog-activation"; +import type { CatalogConfigAuthoritySnapshot } from "../src/codex/catalog-admission"; +import { + handleCatalogActivationRoutes, + resetCatalogApplyFlightForTests, +} from "../src/server/management/catalog-activation-routes"; +import { handleAgentSettingsRoutes } from "../src/server/management/agent-settings-routes"; +import type { ManagementContext } from "../src/server/management/context"; +import type { CodexCommanderConfig } from "../src/types"; + +let previousCodexHome: string | undefined; +let codexHome = ""; + +function config(overrides: Partial = {}): CodexCommanderConfig { + return { + port: 10100, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "http://127.0.0.1:10100/v1", + models: ["gpt-5.6-luna"], + }, + }, + subagentModels: ["gpt-5.6-luna"], + ...overrides, + } as CodexCommanderConfig; +} + +function writeCatalog(multiAgentVersion: "v1" | "v2" = "v2"): void { + writeFileSync(join(codexHome, "codexcommander-catalog.json"), JSON.stringify({ + models: [{ + slug: "gpt-5.6-luna", + display_name: "Luna", + visibility: "list", + priority: 0, + multi_agent_version: multiAgentVersion, + supported_reasoning_levels: [{ effort: "high", description: "High" }], + }], + })); +} + +function desiredSnapshot( + cfg: CodexCommanderConfig, + generation: number, + semanticIdentity = "same-semantic-config", +): CodexCatalogDesiredSnapshot { + const authority: CatalogConfigAuthoritySnapshot = { + generation: { value: generation }, + semanticIdentity, + contentIdentity: "same-config-content", + }; + return { + config: cfg, + authority, + revision: codexCatalogDesiredRevision(cfg, authority), + }; +} + +beforeEach(() => { + previousCodexHome = process.env.CODEX_HOME; + codexHome = mkdtempSync(join(tmpdir(), "ccx-catalog-activation-")); + mkdirSync(codexHome, { recursive: true }); + process.env.CODEX_HOME = codexHome; + writeFileSync(join(codexHome, "config.toml"), [ + "# Auto-injected by CodexCommander", + 'openai_base_url = "http://127.0.0.1:10100/v1"', + `model_catalog_json = "${join(codexHome, "codexcommander-catalog.json")}"`, + "", + ].join("\n")); + writeCatalog(); + resetCatalogApplyFlightForTests(); +}); + +afterEach(() => { + resetCatalogApplyFlightForTests(); + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + rmSync(codexHome, { recursive: true, force: true }); +}); + +describe("Codex catalog activation state", () => { + test("separates a current disk projection from a stale running worker", () => { + const state = inspectCodexCatalogActivation(config({ multiAgentMode: "v2" }), { + state: "stale", + catalogMtimeMs: 200, + processes: [{ pid: 10, startedAtMs: 100 }], + }, undefined, undefined, undefined, "codexcommander-local"); + + expect(state).toMatchObject({ + schemaVersion: 1, + desired: { chosen: ["gpt-5.6-luna"], protocol: "v2" }, + catalog: { status: "current", advertised: ["gpt-5.6-luna"] }, + routing: { status: "current", kind: "codexcommander-local" }, + workers: { status: "reload_required", runningCount: 1, staleCount: 1 }, + apply: { required: true, allowed: true, reason: "reload-required" }, + }); + }); + + test("reports both protocol projections instead of hardcoding V2", () => { + writeCatalog("v1"); + const state = inspectCodexCatalogActivation(config(), { + state: "not_running", + catalogMtimeMs: null, + processes: [], + }, undefined, undefined, undefined, "codexcommander-local"); + + expect(state.catalog.projections.v1.advertised).toEqual(["gpt-5.6-luna"]); + expect(state.catalog.projections.v2.advertised).toEqual([]); + expect(state.catalog.projections.v2.excluded).toMatchObject([ + { configured: "gpt-5.6-luna", reason: "surface_incompatible" }, + ]); + expect(state.catalog.advertised).toEqual(["gpt-5.6-luna"]); + expect(state.apply).toEqual({ required: false, allowed: false, reason: "no-workers" }); + }); + + test("desired revisions are semantic and opaque", () => { + const left = config(); + const right = { + providers: left.providers, + subagentModels: left.subagentModels, + defaultProvider: left.defaultProvider, + port: left.port, + } as CodexCommanderConfig; + expect(codexCatalogDesiredRevision(left)).toBe(codexCatalogDesiredRevision(right)); + expect(codexCatalogDesiredRevision(left)).toMatch(/^v1:[a-f0-9]{64}$/); + expect(codexCatalogDesiredRevision(config({ subagentModels: [] }))) + .not.toBe(codexCatalogDesiredRevision(left)); + const beforeBootChange = codexCatalogDesiredRevision(left); + writeFileSync(join(codexHome, "config.toml"), "[agents]\nmax_threads = 7\n"); + expect(codexCatalogDesiredRevision(left)).not.toBe(beforeBootChange); + }); + + test("the activation fence ignores desktop config churn but includes boot changes", () => { + const catalogPath = join(codexHome, "codexcommander-catalog.json"); + const configPath = join(codexHome, "config.toml"); + utimesSync(catalogPath, 100, 100); + utimesSync(configPath, 200, 200); + expect(codexCatalogActivationFenceMtimeMs()).toBe(200_000); + writeFileSync(configPath, `${readFileSync(configPath, "utf8")}\n[marketplaces.fixture]\nlast_updated = 1\n`); + utimesSync(configPath, 300, 300); + expect(codexCatalogActivationFenceMtimeMs()).toBe(200_000); + writeFileSync(configPath, 'openai_base_url = "http://127.0.0.1:20200/v1"\n'); + expect(codexCatalogActivationFenceMtimeMs()).toBeGreaterThan(200_000); + }); + + test("catalog status detects saved roster order that is not reflected on disk", () => { + writeFileSync(join(codexHome, "codexcommander-catalog.json"), JSON.stringify({ + models: [ + { slug: "fixture/a", visibility: "list", priority: 0, multi_agent_version: "v2" }, + { slug: "fixture/b", visibility: "list", priority: 1, multi_agent_version: "v2" }, + ], + })); + const state = inspectCodexCatalogActivation(config({ + multiAgentMode: "v2", + subagentModels: ["fixture/b", "fixture/a"], + }), { + state: "not_running", + catalogMtimeMs: null, + processes: [], + }, undefined, undefined, undefined, "codexcommander-local"); + + expect(state.catalog.advertised).toEqual(["fixture/a", "fixture/b"]); + expect(state.catalog.status).toBe("pending"); + }); + + test("a session-only no-op disposition does not dirty a current catalog", () => { + const state = inspectCodexCatalogActivation(config({ multiAgentMode: "v2" }), { + state: "not_running", + catalogMtimeMs: null, + processes: [], + }, { status: "skipped", reason: "not-requested", retryable: false }, undefined, undefined, "codexcommander-local"); + + expect(state.catalog.status).toBe("current"); + }); + + test("mutation-only degradation does not disappear from activation on immediate GET", () => { + const cfg = config({ multiAgentMode: "v2" }); + const workers = { state: "not_running" as const, catalogMtimeMs: null, processes: [] }; + const mutation = inspectCodexCatalogActivation(cfg, workers, { + status: "committed", + changed: false, + degraded: true, + notices: ["provider-network"], + }, undefined, "current", "codexcommander-local"); + const immediateGet = inspectCodexCatalogActivation( + cfg, + workers, + undefined, + undefined, + "current", + "codexcommander-local", + ); + + expect(mutation.catalog.status).toBe("current"); + expect(immediateGet.catalog.status).toBe(mutation.catalog.status); + }); + + test("artifact proof distinguishes unproven startup state from disk drift", () => { + const cfg = config({ multiAgentMode: "v2" }); + const authority = desiredSnapshot(cfg, 1).authority; + const workers = { state: "not_running" as const, catalogMtimeMs: null, processes: [] }; + + expect(inspectCodexCatalogActivation(cfg, workers, undefined, authority, "unproven", "codexcommander-local").catalog.status) + .toBe("unknown"); + expect(inspectCodexCatalogActivation(cfg, workers, undefined, authority, "drifted", "codexcommander-local").catalog.status) + .toBe("pending"); + expect(inspectCodexCatalogActivation(cfg, workers, undefined, authority, "current", "codexcommander-local").catalog.status) + .toBe("current"); + }); + + test("a non-ready catalog remains actionable even with a current or absent worker", () => { + const cfg = config({ multiAgentMode: "v2" }); + const authority = desiredSnapshot(cfg, 1).authority; + for (const workers of [ + { state: "fresh" as const, catalogMtimeMs: 200, processes: [{ pid: 10, startedAtMs: 300 }] }, + { state: "not_running" as const, catalogMtimeMs: null, processes: [] }, + ]) { + const state = inspectCodexCatalogActivation( + cfg, + workers, + undefined, + authority, + "unproven", + "codexcommander-local", + ); + expect(state.catalog.status).toBe("unknown"); + expect(state.apply).toEqual({ required: true, allowed: true, reason: "catalog-not-ready" }); + } + }); + + test("native routing makes Apply required even when no worker is running", () => { + writeFileSync(join(codexHome, "config.toml"), `model_catalog_json = "${join(codexHome, "codexcommander-catalog.json")}"\n`); + const state = inspectCodexCatalogActivation(config({ multiAgentMode: "v2" }), { + state: "not_running", + catalogMtimeMs: null, + processes: [], + }, undefined, undefined, undefined, "native"); + + expect(state.routing).toEqual({ status: "not_injected", kind: "native" }); + expect(state.apply).toEqual({ required: true, allowed: true, reason: "routing-not-injected" }); + }); +}); + +function routeContext(options: { + method: "GET" | "POST"; + path: string; + body?: unknown; + principal?: ManagementContext["principal"]; + cfg?: CodexCommanderConfig; + workerState?: "fresh" | "stale" | "not_running" | "unknown"; + apply?: ManagementContext["deps"]["applyCodexCatalogWorkers"]; + sync?: ManagementContext["deps"]["syncModelsToCodex"]; + converge?: ManagementContext["convergeCodexCatalog"]; + routing?: ManagementContext["deps"]["codexRoutingKindForActivation"]; +}): ManagementContext { + const cfg = options.cfg ?? config({ multiAgentMode: "v2" }); + const state = options.workerState ?? "stale"; + const workerStatus = state === "not_running" + ? { state, catalogMtimeMs: null, processes: [] } + : state === "unknown" + ? { state, catalogMtimeMs: null, processes: [] } + : { + state, + catalogMtimeMs: 200, + processes: [{ pid: 10, startedAtMs: state === "stale" ? 100 : 300 }], + }; + const request = new Request(`http://localhost${options.path}`, { + method: options.method, + headers: options.body === undefined ? undefined : { "content-type": "application/json" }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + return { + req: request, + url: new URL(request.url), + config: cfg, + principal: options.principal, + deps: { + collectCodexAppServerCatalogState: () => workerStatus, + resetCodexAppServerCatalogStateCache: () => {}, + loadConfigForCatalogActivation: () => cfg, + catalogArtifactProofForActivation: () => "current", + codexRoutingKindForActivation: options.routing ?? (() => "codexcommander-local"), + readRuntimePort: () => ({ pid: process.pid, port: cfg.port, hostname: "127.0.0.1", startedAt: new Date().toISOString() }), + syncModelsToCodex: options.sync ?? (async () => ({ + status: "applied", + ok: true, + added: 0, + catalogPath: join(codexHome, "codexcommander-catalog.json"), + catalogExists: true, + catalogWritten: false, + cacheSynced: false, + catalogQuality: "live", + rehydrated: 0, + message: "synchronized", + })), + ...(options.apply ? { applyCodexCatalogWorkers: options.apply } : {}), + }, + convergeCodexCatalog: options.converge ?? (async () => ({ + status: "committed", + changed: false, + degraded: false, + notices: [], + })), + syncClaudeAgentDefsBestEffort: async () => {}, + }; +} + +describe("catalog activation management routes", () => { + test("GET returns the additive no-store activation observation", async () => { + const response = await handleCatalogActivationRoutes(routeContext({ + method: "GET", + path: "/api/codex-catalog/status", + })); + expect(response?.status).toBe(200); + expect(response?.headers.get("cache-control")).toBe("no-store"); + expect(await response?.json()).toMatchObject({ + activation: { + workers: { status: "reload_required" }, + apply: { required: true, allowed: false, reason: "confirmed-launch-required" }, + }, + }); + + const confirmed = await handleCatalogActivationRoutes(routeContext({ + method: "GET", + path: "/api/codex-catalog/status", + principal: "confirmed-gui-session", + })); + expect(await confirmed?.json()).toMatchObject({ + activation: { apply: { required: true, allowed: true, reason: "reload-required" } }, + }); + }); + + test("POST requires origin-bound GUI consent even with a valid-shaped body", async () => { + const cfg = config({ multiAgentMode: "v2" }); + for (const principal of ["admin-token", undefined] as const) { + const response = await handleCatalogActivationRoutes(routeContext({ + method: "POST", + path: "/api/codex-catalog/apply", + principal, + cfg, + body: { expectedDesiredRevision: codexCatalogDesiredRevision(cfg), confirmInterrupt: true }, + })); + expect(response?.status).toBe(403); + } + }); + + test("POST converges, revalidates, and returns only count-level process results", async () => { + const cfg = config({ multiAgentMode: "v2" }); + let applied = 0; + const ctx = routeContext({ + method: "POST", + path: "/api/codex-catalog/apply", + principal: "confirmed-gui-session", + cfg, + body: { expectedDesiredRevision: codexCatalogDesiredRevision(cfg), confirmInterrupt: true }, + apply: async revalidate => { + expect(await revalidate()).toBe(true); + applied += 1; + return { outcome: "applied", staleWorkerCount: 1, stoppedWorkerCount: 1, survivingWorkerCount: 0 }; + }, + }); + let observations = 0; + ctx.deps.collectCodexAppServerCatalogState = () => observations++ === 0 + ? { state: "stale", catalogMtimeMs: 200, processes: [{ pid: 10, startedAtMs: 100 }] } + : { state: "fresh", catalogMtimeMs: 200, processes: [{ pid: 11, startedAtMs: 300 }] }; + const response = await handleCatalogActivationRoutes(ctx); + expect(applied).toBe(1); + expect(response?.status).toBe(200); + const body = await response?.json() as Record; + expect(body).toMatchObject({ + ok: true, + outcome: "applied", + stoppedWorkerCount: 1, + survivingWorkerCount: 0, + }); + expect(JSON.stringify(body)).not.toMatch(/pid|commandLine|catalog-must-not-cross/); + }); + + test("POST repairs native routing before applying and handles an absent worker", async () => { + const cfg = config({ multiAgentMode: "v2" }); + let routing: "native" | "codexcommander-local" = "native"; + let syncs = 0; + let applies = 0; + const response = await handleCatalogActivationRoutes(routeContext({ + method: "POST", + path: "/api/codex-catalog/apply", + principal: "confirmed-gui-session", + cfg, + workerState: "not_running", + body: { expectedDesiredRevision: codexCatalogDesiredRevision(cfg), confirmInterrupt: true }, + routing: () => routing, + sync: async () => { + syncs += 1; + routing = "codexcommander-local"; + return { + status: "applied", + ok: true, + added: 0, + catalogPath: join(codexHome, "codexcommander-catalog.json"), + catalogExists: true, + catalogWritten: false, + cacheSynced: false, + catalogQuality: "live", + rehydrated: 0, + message: "synchronized", + }; + }, + apply: async () => { + applies += 1; + return { outcome: "no_workers", staleWorkerCount: 0, stoppedWorkerCount: 0, survivingWorkerCount: 0 }; + }, + })); + + expect(syncs).toBe(1); + expect(applies).toBe(0); + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ + ok: true, + outcome: "no_workers", + activation: { + routing: { status: "current", kind: "codexcommander-local" }, + workers: { status: "not_running" }, + }, + }); + }); + + test("POST never signals when full sync leaves external routing in place", async () => { + const cfg = config({ multiAgentMode: "v2" }); + let applies = 0; + const response = await handleCatalogActivationRoutes(routeContext({ + method: "POST", + path: "/api/codex-catalog/apply", + principal: "confirmed-gui-session", + cfg, + body: { expectedDesiredRevision: codexCatalogDesiredRevision(cfg), confirmInterrupt: true }, + routing: () => "custom-remote", + apply: async () => { + applies += 1; + return { outcome: "applied", staleWorkerCount: 1, stoppedWorkerCount: 1, survivingWorkerCount: 0 }; + }, + })); + + expect(applies).toBe(0); + expect(response?.status).toBe(409); + expect(await response?.json()).toMatchObject({ + ok: false, + outcome: "blocked", + activation: { routing: { status: "external", kind: "custom-remote" } }, + stoppedWorkerCount: 0, + }); + }); + + test("routing drift at the per-signal fence prevents interruption", async () => { + const cfg = config({ multiAgentMode: "v2" }); + let routingReads = 0; + const response = await handleCatalogActivationRoutes(routeContext({ + method: "POST", + path: "/api/codex-catalog/apply", + principal: "confirmed-gui-session", + cfg, + body: { expectedDesiredRevision: codexCatalogDesiredRevision(cfg), confirmInterrupt: true }, + routing: () => routingReads++ === 0 ? "codexcommander-local" : "custom-local", + apply: async revalidate => revalidate() + ? { outcome: "applied", staleWorkerCount: 1, stoppedWorkerCount: 1, survivingWorkerCount: 0 } + : { outcome: "superseded", staleWorkerCount: 1, stoppedWorkerCount: 0, survivingWorkerCount: 1 }, + })); + + expect(response?.status).toBe(409); + expect(await response?.json()).toMatchObject({ + ok: false, + outcome: "superseded", + stoppedWorkerCount: 0, + activation: { routing: { status: "external", kind: "custom-local" } }, + }); + }); + + test("a failed full sync never enters the signal operation", async () => { + const cfg = config({ multiAgentMode: "v2" }); + let applies = 0; + const response = await handleCatalogActivationRoutes(routeContext({ + method: "POST", + path: "/api/codex-catalog/apply", + principal: "confirmed-gui-session", + cfg, + body: { expectedDesiredRevision: codexCatalogDesiredRevision(cfg), confirmInterrupt: true }, + sync: async () => ({ + status: "refused", + ok: false, + added: 0, + catalogPath: null, + catalogExists: false, + catalogWritten: false, + cacheSynced: false, + catalogQuality: "native-only", + rehydrated: 0, + message: "refused", + }), + apply: async () => { + applies += 1; + return { outcome: "applied", staleWorkerCount: 1, stoppedWorkerCount: 1, survivingWorkerCount: 0 }; + }, + })); + + expect(applies).toBe(0); + expect(response?.status).toBe(409); + expect(await response?.json()).toMatchObject({ ok: false, outcome: "blocked" }); + }); + + test("an intentional full-sync skip is a conflict and never enters the signal operation", async () => { + const cfg = config({ multiAgentMode: "v2" }); + let applies = 0; + const response = await handleCatalogActivationRoutes(routeContext({ + method: "POST", + path: "/api/codex-catalog/apply", + principal: "confirmed-gui-session", + cfg, + body: { expectedDesiredRevision: codexCatalogDesiredRevision(cfg), confirmInterrupt: true }, + sync: async () => ({ + status: "skipped", + skippedReason: "external_provider", + ok: true, + added: 0, + catalogPath: null, + catalogExists: false, + catalogWritten: false, + cacheSynced: false, + catalogQuality: "native-only", + rehydrated: 0, + message: "preserved external provider", + }), + apply: async () => { + applies += 1; + return { outcome: "applied", staleWorkerCount: 1, stoppedWorkerCount: 1, survivingWorkerCount: 0 }; + }, + })); + + expect(applies).toBe(0); + expect(response?.status).toBe(409); + expect(await response?.json()).toMatchObject({ + ok: false, + outcome: "blocked", + stoppedWorkerCount: 0, + message: "Codex is using an external model provider, so CodexCommander preserved that routing and stopped no process.", + }); + }); + + test("the HTTP adapter preserves the shared core's authoritative outcome and counts", async () => { + const cfg = config({ multiAgentMode: "v2" }); + const response = await handleCatalogActivationRoutes(routeContext({ + method: "POST", + path: "/api/codex-catalog/apply", + principal: "confirmed-gui-session", + cfg, + body: { expectedDesiredRevision: codexCatalogDesiredRevision(cfg), confirmInterrupt: true }, + apply: async () => ({ + outcome: "applied", + staleWorkerCount: 1, + stoppedWorkerCount: 1, + survivingWorkerCount: 0, + }), + })); + + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ + ok: true, + outcome: "applied", + staleWorkerCount: 1, + stoppedWorkerCount: 1, + survivingWorkerCount: 0, + activation: { workers: { status: "reload_required", staleCount: 1 } }, + }); + }); + + test("a completed A-B-A save is superseded by the monotonic desired revision", async () => { + const cfg = config({ multiAgentMode: "v2" }); + const initial = desiredSnapshot(cfg, 1); + const afterAba = desiredSnapshot(cfg, 3); + let converged = 0; + let applied = 0; + const ctx = routeContext({ + method: "POST", + path: "/api/codex-catalog/apply", + principal: "confirmed-gui-session", + cfg, + body: { expectedDesiredRevision: initial.revision, confirmInterrupt: true }, + converge: async () => { + converged += 1; + return { status: "committed", changed: false, degraded: false, notices: [] }; + }, + apply: async () => { + applied += 1; + return { outcome: "applied", staleWorkerCount: 1, stoppedWorkerCount: 1, survivingWorkerCount: 0 }; + }, + }); + ctx.deps.captureCatalogDesiredSnapshotForActivation = () => afterAba; + + const response = await handleCatalogActivationRoutes(ctx); + expect(response?.status).toBe(409); + expect(await response?.json()).toMatchObject({ outcome: "superseded" }); + expect(converged).toBe(0); + expect(applied).toBe(0); + }); + + test("a generation change at the per-signal fence prevents interruption", async () => { + const cfg = config({ multiAgentMode: "v2" }); + const initial = desiredSnapshot(cfg, 1); + const changed = desiredSnapshot(cfg, 2); + let reads = 0; + const ctx = routeContext({ + method: "POST", + path: "/api/codex-catalog/apply", + principal: "confirmed-gui-session", + cfg, + body: { expectedDesiredRevision: initial.revision, confirmInterrupt: true }, + apply: async revalidate => revalidate() + ? { outcome: "applied", staleWorkerCount: 1, stoppedWorkerCount: 1, survivingWorkerCount: 0 } + : { outcome: "superseded", staleWorkerCount: 1, stoppedWorkerCount: 0, survivingWorkerCount: 1 }, + }); + ctx.deps.captureCatalogDesiredSnapshotForActivation = () => { + reads += 1; + return reads <= 2 ? initial : changed; + }; + + const response = await handleCatalogActivationRoutes(ctx); + expect(response?.status).toBe(409); + expect(await response?.json()).toMatchObject({ + ok: false, + outcome: "superseded", + stoppedWorkerCount: 0, + }); + }); + + test("authoritative catalog drift at the per-signal fence prevents interruption", async () => { + const cfg = config({ multiAgentMode: "v2" }); + let artifactProof: "current" | "drifted" = "current"; + const ctx = routeContext({ + method: "POST", + path: "/api/codex-catalog/apply", + principal: "confirmed-gui-session", + cfg, + body: { expectedDesiredRevision: codexCatalogDesiredRevision(cfg), confirmInterrupt: true }, + apply: async revalidate => { + artifactProof = "drifted"; + return revalidate() + ? { outcome: "applied", staleWorkerCount: 1, stoppedWorkerCount: 1, survivingWorkerCount: 0 } + : { outcome: "superseded", staleWorkerCount: 1, stoppedWorkerCount: 0, survivingWorkerCount: 1 }; + }, + }); + ctx.deps.catalogArtifactProofForActivation = () => artifactProof; + + const response = await handleCatalogActivationRoutes(ctx); + expect(response?.status).toBe(409); + expect(await response?.json()).toMatchObject({ + ok: false, + outcome: "superseded", + stoppedWorkerCount: 0, + activation: { catalog: { status: "pending" } }, + }); + }); + + test("unknown worker identity fails closed without entering the signal operation", async () => { + const cfg = config({ multiAgentMode: "v2" }); + let applied = 0; + const response = await handleCatalogActivationRoutes(routeContext({ + method: "POST", + path: "/api/codex-catalog/apply", + principal: "confirmed-gui-session", + cfg, + workerState: "unknown", + body: { expectedDesiredRevision: codexCatalogDesiredRevision(cfg), confirmInterrupt: true }, + apply: async () => { + applied += 1; + return { outcome: "applied", staleWorkerCount: 0, stoppedWorkerCount: 0, survivingWorkerCount: 0 }; + }, + })); + expect(response?.status).toBe(409); + expect(applied).toBe(0); + expect(await response?.json()).toMatchObject({ ok: false, outcome: "blocked" }); + }); + + test("the browser adapter preserves canonical no-worker, current, unknown, and partial outcomes", async () => { + const cfg = config({ multiAgentMode: "v2" }); + const cases = [ + { workerState: "not_running" as const, outcome: "no_workers", stale: 0, surviving: 0 }, + { workerState: "fresh" as const, outcome: "already_current", stale: 0, surviving: 0 }, + { workerState: "unknown" as const, outcome: "blocked", stale: 0, surviving: 0 }, + { workerState: "stale" as const, outcome: "partial", stale: 1, surviving: 1 }, + ]; + for (const item of cases) { + const response = await handleCatalogActivationRoutes(routeContext({ + method: "POST", + path: "/api/codex-catalog/apply", + principal: "confirmed-gui-session", + cfg, + workerState: item.workerState, + body: { expectedDesiredRevision: codexCatalogDesiredRevision(cfg), confirmInterrupt: true }, + apply: async () => ({ + outcome: "partial", + staleWorkerCount: 1, + stoppedWorkerCount: 0, + survivingWorkerCount: 1, + }), + })); + expect(await response?.json()).toMatchObject({ + outcome: item.outcome, + staleWorkerCount: item.stale, + stoppedWorkerCount: 0, + survivingWorkerCount: item.surviving, + }); + } + }); + + test("a warning-bearing degraded sync never signals", async () => { + const cfg = config({ + multiAgentMode: "v2", + subagentModels: ["fixture/missing"], + }); + let applied = 0; + const response = await handleCatalogActivationRoutes(routeContext({ + method: "POST", + path: "/api/codex-catalog/apply", + principal: "confirmed-gui-session", + cfg, + body: { expectedDesiredRevision: codexCatalogDesiredRevision(cfg), confirmInterrupt: true }, + sync: async () => ({ + status: "applied", + ok: true, + added: 0, + catalogPath: join(codexHome, "codexcommander-catalog.json"), + catalogExists: true, + catalogWritten: false, + cacheSynced: false, + catalogQuality: "retained", + rehydrated: 1, + message: "synchronized with retained rows", + warning: "provider authentication unavailable", + }), + apply: async () => { + applied += 1; + return { outcome: "applied", staleWorkerCount: 1, stoppedWorkerCount: 1, survivingWorkerCount: 0 }; + }, + })); + + expect(response?.status).toBe(409); + expect(applied).toBe(0); + expect(await response?.json()).toMatchObject({ + outcome: "blocked", + activation: { catalog: { status: "pending" } }, + }); + }); + + test("a stale desired revision is superseded before convergence or signaling", async () => { + let converged = 0; + let applied = 0; + const response = await handleCatalogActivationRoutes(routeContext({ + method: "POST", + path: "/api/codex-catalog/apply", + principal: "confirmed-gui-session", + body: { expectedDesiredRevision: "v1:stale", confirmInterrupt: true }, + converge: async () => { + converged += 1; + return { status: "committed", changed: false, degraded: false, notices: [] }; + }, + apply: async () => { + applied += 1; + return { outcome: "applied", staleWorkerCount: 1, stoppedWorkerCount: 1, survivingWorkerCount: 0 }; + }, + })); + expect(response?.status).toBe(409); + expect(await response?.json()).toMatchObject({ outcome: "superseded" }); + expect(converged).toBe(0); + expect(applied).toBe(0); + }); + + test("roster validation rejects overflow and duplicates instead of silently truncating", async () => { + let saves = 0; + const request = (models: string[]) => { + const ctx = routeContext({ + method: "POST", + path: "/api/codex-catalog/status", + }); + ctx.req = new Request("http://localhost/api/subagent-models", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ models }), + }); + ctx.url = new URL(ctx.req.url); + ctx.deps.saveConfigPreservingClaudeCode = () => { saves += 1; }; + return handleAgentSettingsRoutes(ctx); + }; + + expect((await request(["a", "b", "c", "d", "e", "f"]))?.status).toBe(400); + expect((await request(["a/model", "a/model"]))?.status).toBe(400); + expect(saves).toBe(0); + }); +}); diff --git a/tests/codex-catalog-admission.test.ts b/tests/codex-catalog-admission.test.ts index ebaf30d409..51ec3c6a63 100644 --- a/tests/codex-catalog-admission.test.ts +++ b/tests/codex-catalog-admission.test.ts @@ -12,7 +12,10 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; -import { captureCatalogAdmissionSnapshot } from "../src/codex/catalog-admission"; +import { + captureCatalogAdmissionSnapshot, + captureCatalogConfigAuthority, +} from "../src/codex/catalog-admission"; import type { CatalogConditionalSourceObservations, CatalogConditionalSourceRole, @@ -20,7 +23,7 @@ import type { CatalogRequiredSourceRole, CatalogSourceEvidence, } from "../src/codex/convergence-types"; -import { saveConfig } from "../src/config"; +import { loadConfig, saveConfig } from "../src/config"; import type { CodexCommanderConfig } from "../src/types"; const CONDITIONAL_SOURCE_ROLES = [ @@ -91,8 +94,8 @@ afterEach(() => { }); test("captures the given config reference, generation, and catalog target identities", () => { - saveConfig(config(20200)); const residentConfig = config(30300); + saveConfig(residentConfig); writeFileSync(join(codexHome, "codexcommander-catalog.json"), "{}\n"); writeFileSync(join(codexHome, "models_cache.json"), "{}\n"); @@ -105,6 +108,7 @@ test("captures the given config reference, generation, and catalog target identi referenceIdentity: expect.any(String), generation: { value: 1 }, snapshotIdentity: expect.any(String), + contentIdentity: expect.any(String), }); expect(JSON.parse(snapshot.targets.catalog)).toMatchObject({ path: join(codexHome, "codexcommander-catalog.json"), @@ -142,6 +146,28 @@ test("captures the given config reference, generation, and catalog target identi } }); +test("config authority initializes generation zero for an existing pre-coordinator config", () => { + const existing = config(); + writeFileSync( + join(codexCommanderHome, "config.json"), + `${JSON.stringify(existing, null, 2)}\n`, + ); + expect(captureCatalogConfigAuthority(existing)).toEqual({ + generation: { value: 0 }, + semanticIdentity: expect.any(String), + contentIdentity: expect.any(String), + }); +}); + +test("config authority admits the valid default when config.json is absent", () => { + const defaults = loadConfig(); + expect(captureCatalogConfigAuthority(defaults)).toEqual({ + generation: { value: 0 }, + semanticIdentity: expect.any(String), + contentIdentity: expect.any(String), + }); +}); + test("captures PRESENT catalog target-selection evidence from the exact config bytes", () => { saveConfig(config()); const selectedCatalog = join(codexHome, "selected-catalog.json"); @@ -177,14 +203,15 @@ test("captures PRESENT catalog target-selection evidence from the exact config b }); test("binds opaque config identity to the exact reference, generation, and snapshot", () => { - saveConfig(config()); const firstConfig = config(20200); const equalButDistinctConfig = config(20200); + saveConfig(firstConfig); const first = captureCatalogAdmissionSnapshot(firstConfig).configIdentity; const sameReference = captureCatalogAdmissionSnapshot(firstConfig).configIdentity; const distinctReference = captureCatalogAdmissionSnapshot(equalButDistinctConfig).configIdentity; firstConfig.port = 30300; + saveConfig(firstConfig); const mutated = captureCatalogAdmissionSnapshot(firstConfig).configIdentity; expect(sameReference).toEqual(first); @@ -192,7 +219,8 @@ test("binds opaque config identity to the exact reference, generation, and snaps expect(distinctReference.snapshotIdentity).toBe(first.snapshotIdentity); expect(mutated.referenceIdentity).toBe(first.referenceIdentity); expect(mutated.snapshotIdentity).not.toBe(first.snapshotIdentity); - expect(mutated.generation).toEqual(first.generation); + expect(mutated.contentIdentity).not.toBe(first.contentIdentity); + expect(mutated.generation.value).toBeGreaterThan(first.generation.value); }); test("rejects missing home, required, and conditional evidence keys structurally", () => { diff --git a/tests/codex-catalog-apply.test.ts b/tests/codex-catalog-apply.test.ts index 0e3dac66af..309aaf394d 100644 --- a/tests/codex-catalog-apply.test.ts +++ b/tests/codex-catalog-apply.test.ts @@ -1,13 +1,18 @@ import { describe, expect, test } from "bun:test"; import { applyCodexCatalog, + applyCodexCatalogWorkers, + runCodexCatalogApply, type ApplyCodexCatalogDeps, + type ApplyCodexCatalogWorkersDeps, + type CodexCatalogApplyCoreDeps, } from "../src/codex/catalog-apply"; import type { CodexAppServerCatalogStatus, CodexAppServerProcess, RestartCodexAppServersResult, } from "../src/codex/app-server-processes"; +import { collectCodexAppServerCatalogState } from "../src/codex/app-server-processes"; import type { CodexSyncResult } from "../src/codex/sync"; const syncResult = (overrides: Partial = {}): CodexSyncResult => ({ @@ -26,6 +31,7 @@ const syncResult = (overrides: Partial = {}): CodexSyncResult = const noRestart = (): RestartCodexAppServersResult => ({ requested: [], + signaled: [], stopped: [], surviving: [], failed: [], @@ -33,6 +39,7 @@ const noRestart = (): RestartCodexAppServersResult => ({ function makeDeps(options: { live?: boolean; + captureDesiredSnapshot?: ApplyCodexCatalogDeps["captureDesiredSnapshot"]; sync?: () => Promise; states: CodexAppServerCatalogStatus[]; workers?: CodexAppServerProcess[]; @@ -45,7 +52,14 @@ function makeDeps(options: { findLiveProxy: async () => options.live === false ? null : ({ pid: 900, port: 10100, source: "runtime" }), + captureDesiredSnapshot: options.captureDesiredSnapshot ?? (() => ({ + config: {} as never, + authority: {} as never, + revision: "test-desired-revision", + })), syncModelsToCodex: async () => (options.sync ? options.sync() : syncResult()), + inspectArtifactProof: () => "current", + getRoutingKind: () => "codexcommander-local", resetCatalogStateCache: () => { calls.push("reset"); }, collectCatalogState: () => { calls.push("collect"); @@ -118,7 +132,7 @@ describe("fixed applyCodexCatalog lifecycle action", () => { commandLine: "/Applications/Codex.app/codex app-server --secret-detail", startedAtMs: 100, }]); - return { requested: [10], stopped: [10], surviving: [], failed: [] }; + return { requested: [10], signaled: [10], stopped: [10], surviving: [], failed: [] }; }, })); @@ -192,7 +206,7 @@ describe("fixed applyCodexCatalog lifecycle action", () => { }); }); - test("an intentional integration skip never restarts workers and clears update readiness", async () => { + test("an intentional integration skip is blocked by the canonical Apply policy", async () => { const calls: string[] = []; const result = await applyCodexCatalog(makeDeps({ calls, @@ -215,14 +229,14 @@ describe("fixed applyCodexCatalog lifecycle action", () => { expect(calls).toEqual(["reset", "collect"]); expect(result).toMatchObject({ - ok: true, + ok: false, + errorCode: "SYNC_FAILED", catalogUpdated: false, codexRestartRequired: false, staleWorkerCount: 1, stoppedWorkerCount: 0, - survivingWorkerCount: 0, + survivingWorkerCount: 1, }); - expect(result.errorCode).toBeUndefined(); }); test("a sync failure never signals workers even when it reports a partial catalog write", async () => { @@ -309,7 +323,7 @@ describe("fixed applyCodexCatalog lifecycle action", () => { workers: [{ pid: 10, commandLine: "/Applications/Codex.app/codex app-server" }], restart: workers => { expect(workers.map(worker => worker.pid)).toEqual([10]); - return { requested: [10], stopped: [10], surviving: [], failed: [] }; + return { requested: [10], signaled: [10], stopped: [10], surviving: [], failed: [] }; }, })); @@ -324,12 +338,13 @@ describe("fixed applyCodexCatalog lifecycle action", () => { }); }); - test("a native-only sync without a proven catalog write never signals workers", async () => { + test("a native-only sync without an existing catalog never signals workers", async () => { const calls: string[] = []; const result = await applyCodexCatalog(makeDeps({ calls, sync: async () => syncResult({ ok: true, + catalogExists: false, catalogWritten: false, cacheSynced: false, catalogQuality: "native-only", @@ -356,6 +371,47 @@ describe("fixed applyCodexCatalog lifecycle action", () => { }); }); + test("a committed native-only semantic no-op can apply an existing catalog", async () => { + const calls: string[] = []; + const result = await applyCodexCatalog(makeDeps({ + calls, + sync: async () => syncResult({ + ok: true, + catalogExists: true, + catalogWritten: false, + cacheSynced: false, + catalogQuality: "native-only", + warning: undefined, + }), + states: [ + { + state: "stale", + catalogMtimeMs: 200, + processes: [{ pid: 10, startedAtMs: 100 }], + }, + { state: "not_running", catalogMtimeMs: null, processes: [] }, + ], + workers: [{ pid: 10, commandLine: "codex app-server" }], + restart: () => ({ + requested: [10], + signaled: [10], + stopped: [10], + surviving: [], + failed: [], + }), + })); + + expect(calls).toEqual(["reset", "collect", "list", "restart", "reset", "collect"]); + expect(result).toMatchObject({ + ok: true, + catalogUpdated: false, + codexRestartRequired: false, + staleWorkerCount: 1, + stoppedWorkerCount: 1, + survivingWorkerCount: 0, + }); + }); + test("surviving stale workers keep the final restart-required state true", async () => { const stale: CodexAppServerCatalogStatus = { state: "stale", @@ -367,6 +423,7 @@ describe("fixed applyCodexCatalog lifecycle action", () => { workers: [{ pid: 10, commandLine: "codex app-server" }], restart: () => ({ requested: [10], + signaled: [], stopped: [], surviving: [10], failed: [{ pid: 10, error: "permission denied must not cross helper" }], @@ -405,4 +462,403 @@ describe("fixed applyCodexCatalog lifecycle action", () => { survivingWorkerCount: 0, }); }); + + test("companion apply refuses to signal when desired generation changes during sync", async () => { + const calls: string[] = []; + let captures = 0; + const result = await applyCodexCatalog(makeDeps({ + calls, + captureDesiredSnapshot: () => ({ + config: {} as never, + authority: {} as never, + revision: ++captures === 1 ? "desired-A-generation-1" : "desired-B-generation-2", + }), + states: [{ + state: "stale", + catalogMtimeMs: 200, + processes: [{ pid: 10, startedAtMs: 100 }], + }], + workers: [{ pid: 10, commandLine: "codex app-server" }], + restart: () => { throw new Error("must not signal superseded desired state"); }, + })); + + expect(captures).toBe(2); + expect(calls).toEqual(["reset", "collect"]); + expect(result).toMatchObject({ + ok: false, + codexRestartRequired: true, + staleWorkerCount: 1, + stoppedWorkerCount: 0, + survivingWorkerCount: 1, + }); + }); +}); + +describe("verified worker-only catalog activation", () => { + function workerDeps(options: { + states: CodexAppServerCatalogStatus[]; + workers?: CodexAppServerProcess[]; + restart?: ApplyCodexCatalogWorkersDeps["restartCodexWorkers"]; + calls?: string[]; + }): ApplyCodexCatalogWorkersDeps { + let index = 0; + const calls = options.calls ?? []; + return { + resetCatalogStateCache: () => { calls.push("reset"); }, + collectCatalogState: () => { + calls.push("collect"); + return options.states[Math.min(index++, options.states.length - 1)]!; + }, + listCodexWorkers: () => { + calls.push("list"); + return options.workers ?? []; + }, + restartCodexWorkers: (workers, io) => { + calls.push("restart"); + return options.restart?.(workers, io) ?? noRestart(); + }, + }; + } + + test("unknown observation blocks before process listing or signaling", async () => { + const calls: string[] = []; + const result = await applyCodexCatalogWorkers(() => true, workerDeps({ + calls, + states: [{ state: "unknown", catalogMtimeMs: null, processes: [] }], + })); + expect(result).toEqual({ + outcome: "blocked", + staleWorkerCount: 0, + stoppedWorkerCount: 0, + survivingWorkerCount: 0, + }); + expect(calls).toEqual(["reset", "collect"]); + }); + + test("a replacement starting after the fence in the same Darwin second is never signaled", async () => { + const calls: string[] = []; + const ambiguousReplacement = collectCodexAppServerCatalogState({ + platform: "darwin", + listSnapshots: () => [{ + pid: 10, + commandLine: "codex app-server", + startedAtMs: 1_000, + }], + startTimePrecisionMs: 1_000, + catalogMtimeMs: () => 1_500, + }); + const result = await applyCodexCatalogWorkers( + () => true, + workerDeps({ + calls, + states: [ambiguousReplacement], + restart: () => { throw new Error("same-second replacement must never be signaled"); }, + }), + ambiguousReplacement, + ); + + expect(result).toEqual({ + outcome: "blocked", + staleWorkerCount: 0, + stoppedWorkerCount: 0, + survivingWorkerCount: 0, + }); + expect(calls).toEqual([]); + }); + + test("a superseded desired revision is checked immediately before signaling", async () => { + const calls: string[] = []; + const stale: CodexAppServerCatalogStatus = { + state: "stale", + catalogMtimeMs: 200, + processes: [{ pid: 10, startedAtMs: 100 }], + }; + const result = await applyCodexCatalogWorkers(() => false, workerDeps({ + calls, + states: [stale], + workers: [{ pid: 10, commandLine: "codex app-server" }], + })); + expect(result).toEqual({ + outcome: "superseded", + staleWorkerCount: 1, + stoppedWorkerCount: 0, + survivingWorkerCount: 1, + }); + expect(calls).toEqual(["reset", "collect", "list"]); + }); + + test("signals only the birth-time-fenced stale target and verifies replacement state", async () => { + const calls: string[] = []; + const result = await applyCodexCatalogWorkers(() => true, workerDeps({ + calls, + states: [ + { + state: "stale", + catalogMtimeMs: 200, + processes: [{ pid: 10, startedAtMs: 100 }, { pid: 20, startedAtMs: 300 }], + }, + { + state: "fresh", + catalogMtimeMs: 200, + processes: [{ pid: 20, startedAtMs: 300 }], + }, + ], + workers: [ + { pid: 10, commandLine: "codex app-server" }, + { pid: 20, commandLine: "codex app-server" }, + ], + restart: workers => { + expect(workers).toEqual([{ pid: 10, commandLine: "codex app-server", startedAtMs: 100 }]); + return { requested: [10], signaled: [10], stopped: [10], surviving: [], failed: [] }; + }, + })); + expect(result).toEqual({ + outcome: "applied", + staleWorkerCount: 1, + stoppedWorkerCount: 1, + survivingWorkerCount: 0, + }); + expect(calls).toEqual(["reset", "collect", "list", "restart", "reset", "collect"]); + }); + + test("a revision change at the final signal fence is reported as superseded", async () => { + const calls: string[] = []; + let revisionChecks = 0; + const stale: CodexAppServerCatalogStatus = { + state: "stale", + catalogMtimeMs: 200, + processes: [{ pid: 10, startedAtMs: 100 }], + }; + const result = await applyCodexCatalogWorkers( + () => ++revisionChecks === 1, + workerDeps({ + calls, + states: [stale], + workers: [{ pid: 10, commandLine: "codex app-server" }], + restart: (_workers, io) => { + expect(io.authorizeSignal?.()).toBe(false); + return { + requested: [10], + signaled: [], + stopped: [], + surviving: [10], + failed: [], + authorizationRefused: true, + }; + }, + }), + ); + + expect(result).toEqual({ + outcome: "superseded", + staleWorkerCount: 1, + stoppedWorkerCount: 0, + survivingWorkerCount: 1, + }); + expect(calls).toEqual(["reset", "collect", "list", "restart", "reset", "collect"]); + }); + + test("one signal followed by authorization refusal is partial, not superseded", async () => { + const calls: string[] = []; + let revisionChecks = 0; + const result = await applyCodexCatalogWorkers( + () => ++revisionChecks < 3, + workerDeps({ + calls, + states: [ + { + state: "stale", + catalogMtimeMs: 300, + processes: [ + { pid: 10, startedAtMs: 100 }, + { pid: 20, startedAtMs: 200 }, + ], + }, + { + state: "stale", + catalogMtimeMs: 300, + processes: [{ pid: 20, startedAtMs: 200 }], + }, + ], + workers: [ + { pid: 10, commandLine: "codex app-server --worker one" }, + { pid: 20, commandLine: "codex app-server --worker two" }, + ], + restart: (_workers, io) => { + expect(io.authorizeSignal?.()).toBe(true); + expect(io.authorizeSignal?.()).toBe(false); + return { + requested: [10, 20], + signaled: [10], + stopped: [10], + surviving: [20], + failed: [], + authorizationRefused: true, + }; + }, + }), + ); + + expect(result).toEqual({ + outcome: "partial", + staleWorkerCount: 2, + stoppedWorkerCount: 1, + survivingWorkerCount: 1, + }); + expect(calls).toEqual(["reset", "collect", "list", "restart", "reset", "collect"]); + }); +}); + +describe("shared catalog Apply orchestration", () => { + const desired = { + config: {} as never, + authority: { generation: { value: 7 } } as never, + revision: "desired-generation-7", + }; + const stale: CodexAppServerCatalogStatus = { + state: "stale", + catalogMtimeMs: 200, + processes: [{ pid: 10, startedAtMs: 100 }], + }; + + function coreDeps( + overrides: Partial = {}, + ): CodexCatalogApplyCoreDeps { + return { + captureDesiredSnapshot: () => desired, + syncCatalog: async () => syncResult(), + inspectArtifactProof: () => "current", + getRoutingKind: () => "codexcommander-local", + resetWorkerObservation: () => {}, + collectWorkerState: () => stale, + applyWorkers: async () => ({ + outcome: "applied", + staleWorkerCount: 1, + stoppedWorkerCount: 1, + survivingWorkerCount: 0, + }), + ...overrides, + }; + } + + for (const drift of ["native", "custom-remote"] as const) { + test(`route drift to ${drift} between convergence and SIGTERM sends zero signals`, async () => { + let routingReads = 0; + let signals = 0; + const result = await runCodexCatalogApply({}, coreDeps({ + getRoutingKind: () => ++routingReads < 3 ? "codexcommander-local" : drift, + applyWorkers: async authorizeSignal => { + if (authorizeSignal()) signals += 1; + return { + outcome: signals > 0 ? "applied" : "superseded", + staleWorkerCount: 1, + stoppedWorkerCount: signals, + survivingWorkerCount: signals > 0 ? 0 : 1, + }; + }, + })); + + expect(signals).toBe(0); + expect(result).toMatchObject({ + outcome: "superseded", + blockReason: "authorization-changed", + staleWorkerCount: 1, + stoppedWorkerCount: 0, + survivingWorkerCount: 1, + }); + }); + } + + test("warning-bearing convergence blocks before the worker operation", async () => { + let applied = 0; + const result = await runCodexCatalogApply({}, coreDeps({ + syncCatalog: async () => syncResult({ warning: "degraded provider discovery" }), + applyWorkers: async () => { + applied += 1; + throw new Error("must not run"); + }, + })); + + expect(applied).toBe(0); + expect(result).toMatchObject({ + outcome: "blocked", + blockReason: "sync-warning", + staleWorkerCount: 1, + stoppedWorkerCount: 0, + survivingWorkerCount: 1, + }); + }); + + test("records the boot fence after proof and before worker signaling", async () => { + const calls: string[] = []; + await runCodexCatalogApply({}, coreDeps({ + inspectArtifactProof: () => { calls.push("proof"); return "current"; }, + recordBootFenceApplied: () => { calls.push("fence"); }, + applyWorkers: async () => { + calls.push("signal"); + return { outcome: "applied", staleWorkerCount: 1, stoppedWorkerCount: 1, survivingWorkerCount: 0 }; + }, + })); + expect(calls.indexOf("fence")).toBeGreaterThan(calls.indexOf("proof")); + expect(calls.indexOf("fence")).toBeLessThan(calls.indexOf("signal")); + }); + + test("does not record the boot fence on blocked or early-superseded paths", async () => { + for (const overrides of [ + { syncCatalog: async () => syncResult({ warning: "blocked" }) }, + { captureDesiredSnapshot: () => ({ ...desired, revision: "newer" }) }, + { inspectArtifactProof: () => "drifted" as const }, + { collectWorkerState: () => ({ state: "unknown", catalogMtimeMs: null, processes: [] }) as CodexAppServerCatalogStatus }, + ]) { + let records = 0; + await runCodexCatalogApply({ expectedDesiredRevision: desired.revision }, coreDeps({ + ...overrides, + recordBootFenceApplied: () => { records += 1; }, + })); + expect(records).toBe(0); + } + }); + + test("no-worker, current, unknown, and partial outcomes are canonical", async () => { + const cases = [ + { + state: { state: "not_running", catalogMtimeMs: null, processes: [] } as CodexAppServerCatalogStatus, + workerResult: undefined, + outcome: "no_workers", + }, + { + state: { state: "fresh", catalogMtimeMs: 200, processes: [{ pid: 20, startedAtMs: 300 }] } as CodexAppServerCatalogStatus, + workerResult: undefined, + outcome: "already_current", + }, + { + state: { state: "unknown", catalogMtimeMs: null, processes: [] } as CodexAppServerCatalogStatus, + workerResult: undefined, + outcome: "blocked", + }, + { + state: stale, + workerResult: { + outcome: "partial" as const, + staleWorkerCount: 1, + stoppedWorkerCount: 0, + survivingWorkerCount: 1, + }, + outcome: "partial", + }, + ]; + for (const item of cases) { + let workerCalls = 0; + const result = await runCodexCatalogApply({}, coreDeps({ + collectWorkerState: () => item.state, + applyWorkers: async () => { + workerCalls += 1; + if (!item.workerResult) throw new Error("unexpected worker operation"); + return item.workerResult; + }, + })); + expect(result.outcome).toBe(item.outcome); + expect(workerCalls).toBe(item.state.state === "stale" ? 1 : 0); + } + }); }); diff --git a/tests/codex-catalog-restore.test.ts b/tests/codex-catalog-restore.test.ts index 5f1300ce98..682e1ac9f6 100644 --- a/tests/codex-catalog-restore.test.ts +++ b/tests/codex-catalog-restore.test.ts @@ -265,16 +265,27 @@ describe("Codex catalog restore", () => { }, null, 2) + "\n"); const r = runScript(codexHome, codexCommanderHome, ` - const { syncCatalogModels } = require("./src/codex/catalog"); + const { saveConfig } = require("./src/config"); + const { captureCatalogAdmissionSnapshot } = require("./src/codex/catalog-admission"); + const { convergeCodexCatalog } = require("./src/codex/convergence"); (async () => { - const result = await syncCatalogModels({ + const config = { port: 10100, multiAgentGuidanceEnabled: true, - providers: {}, + providers: { openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + disabled: true, + } }, defaultProvider: "openai", subagentModels: ["gpt-5.5"], + }; + saveConfig(config); + const result = await convergeCodexCatalog(captureCatalogAdmissionSnapshot(config), { + action: "converge", scope: "catalog", reason: "api-sync", mode: "explicit", deadlineMs: 1000, }); - console.log(JSON.stringify(result)); + console.log(JSON.stringify(result.projection)); })(); `); @@ -310,16 +321,27 @@ describe("Codex catalog restore", () => { }, null, 2) + "\n"); const r = runScript(codexHome, codexCommanderHome, ` - const { syncCatalogModels } = require("./src/codex/catalog"); + const { saveConfig } = require("./src/config"); + const { captureCatalogAdmissionSnapshot } = require("./src/codex/catalog-admission"); + const { convergeCodexCatalog } = require("./src/codex/convergence"); (async () => { - const result = await syncCatalogModels({ + const config = { port: 10100, multiAgentGuidanceEnabled: true, - providers: {}, + providers: { openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + disabled: true, + } }, defaultProvider: "openai", subagentModels: ["gpt-5.5", "gpt-5.4", "gpt-5.3-codex-spark", "gpt-5.6-sol"], + }; + saveConfig(config); + const result = await convergeCodexCatalog(captureCatalogAdmissionSnapshot(config), { + action: "converge", scope: "catalog", reason: "api-sync", mode: "explicit", deadlineMs: 1000, }); - console.log(JSON.stringify(result)); + console.log(JSON.stringify(result.projection)); })(); `); diff --git a/tests/codex-catalog-retained.test.ts b/tests/codex-catalog-retained.test.ts index d9a9e4d684..b4ddcbb3c7 100644 --- a/tests/codex-catalog-retained.test.ts +++ b/tests/codex-catalog-retained.test.ts @@ -12,7 +12,6 @@ import { join } from "node:path"; import { resetCatalogRuntimeStateForTests, - syncCatalogModels, } from "../src/codex/catalog"; import { retainedRoutedCatalogPath } from "../src/codex/catalog/parsing"; import { @@ -20,6 +19,7 @@ import { recordOwnedConfigPath, } from "../src/lib/config-ownership"; import type { CodexCommanderConfig, CodexCommanderProviderConfig } from "../src/types"; +import { convergeCatalogForTest } from "./helpers/catalog-convergence"; const originalFetch = globalThis.fetch; @@ -39,13 +39,6 @@ function nativeCatalog(): string { return `${JSON.stringify({ models: [nativeEntry()] }, null, 2)}\n`; } -function catalogDeps() { - return { - commandCandidates: () => ["codex-fixture"], - execFileSync: () => JSON.stringify({ models: [nativeEntry()] }), - }; -} - function provider(overrides: Partial = {}): CodexCommanderProviderConfig { return { adapter: "openai-chat", @@ -67,7 +60,6 @@ function config(providers: Record, overrid function liveEmptyProvider(): CodexCommanderProviderConfig { return provider({ liveModels: true, - fetch: ((input: RequestInfo | URL) => globalThis.fetch(input)) as typeof fetch, } as Partial); } @@ -107,9 +99,9 @@ describe("retained routed Codex catalog", () => { }); test("a live routed sync creates an owned mode-600 last-known-good snapshot", async () => { - const result = await syncCatalogModels(config({ + const result = await convergeCatalogForTest(config({ vendor: provider({ liveModels: false, models: ["alpha"] }), - }), catalogDeps()); + })); expect(result.catalogQuality).toBe("live"); expect(result.rehydrated).toBe(0); @@ -128,12 +120,12 @@ describe("retained routed Codex catalog", () => { const liveConfig = config({ vendor: provider({ liveModels: false, models: ["alpha"] }), }); - await syncCatalogModels(liveConfig, catalogDeps()); + await convergeCatalogForTest(liveConfig); const retainedBefore = readFileSync(retainedRoutedCatalogPath(), "utf8"); writeFileSync(catalogPath, nativeCatalog(), "utf8"); resetCatalogRuntimeStateForTests(); - const result = await syncCatalogModels(config({ vendor: liveEmptyProvider() }), catalogDeps()); + const result = await convergeCatalogForTest(config({ vendor: liveEmptyProvider() })); expect(result.catalogQuality).toBe("retained"); expect(result.rehydrated).toBe(1); @@ -143,17 +135,17 @@ describe("retained routed Codex catalog", () => { }); test("partial discovery combines live providers with retained missing providers", async () => { - await syncCatalogModels(config({ + await convergeCatalogForTest(config({ vendor: provider({ liveModels: false, models: ["alpha"] }), peer: provider({ liveModels: false, models: ["beta"] }), - }), catalogDeps()); + })); writeFileSync(catalogPath, nativeCatalog(), "utf8"); resetCatalogRuntimeStateForTests(); - const result = await syncCatalogModels(config({ + const result = await convergeCatalogForTest(config({ vendor: provider({ liveModels: false, models: ["alpha"] }), peer: liveEmptyProvider(), - }), catalogDeps()); + })); expect(result.catalogQuality).toBe("retained"); expect(result.rehydrated).toBe(1); @@ -164,17 +156,17 @@ describe("retained routed Codex catalog", () => { }); test("removed, disabled, and intentionally empty providers are never resurrected", async () => { - await syncCatalogModels(config({ + await convergeCatalogForTest(config({ vendor: provider({ liveModels: false, models: ["alpha"] }), removed: provider({ liveModels: false, models: ["old"] }), - }), catalogDeps()); + })); writeFileSync(catalogPath, nativeCatalog(), "utf8"); resetCatalogRuntimeStateForTests(); - const disabled = await syncCatalogModels(config( + const disabled = await convergeCatalogForTest(config( { vendor: liveEmptyProvider() }, { disabledModels: ["vendor/alpha"] }, - ), catalogDeps()); + )); expect(disabled.catalogQuality).toBe("native-only"); let active = JSON.parse(readFileSync(catalogPath, "utf8")) as { models: Array<{ slug: string }> }; expect(active.models.map(model => model.slug)).not.toContain("vendor/alpha"); @@ -182,9 +174,9 @@ describe("retained routed Codex catalog", () => { writeFileSync(catalogPath, nativeCatalog(), "utf8"); resetCatalogRuntimeStateForTests(); - const intentionallyEmpty = await syncCatalogModels(config({ + const intentionallyEmpty = await convergeCatalogForTest(config({ vendor: provider({ liveModels: false, models: [] }), - }), catalogDeps()); + })); expect(intentionallyEmpty.catalogQuality).toBe("native-only"); active = JSON.parse(readFileSync(catalogPath, "utf8")) as { models: Array<{ slug: string }> }; expect(active.models.map(model => model.slug)).not.toContain("vendor/alpha"); diff --git a/tests/codex-catalog-sync-hardening.test.ts b/tests/codex-catalog-sync-hardening.test.ts index 32902dff97..023b38f7c6 100644 --- a/tests/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-catalog-sync-hardening.test.ts @@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, setDefaultTimeout, spyOn, test import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { readCodexCatalogPath, resetCatalogRuntimeStateForTests, syncCatalogModels } from "../src/codex/catalog"; +import { readCodexCatalogPath, resetCatalogRuntimeStateForTests } from "../src/codex/catalog"; +import { getDefaultConfig } from "../src/config"; import type { CodexCommanderConfig } from "../src/types"; +import { convergeCatalogForTest } from "./helpers/catalog-convergence"; setDefaultTimeout(30_000); @@ -13,11 +15,23 @@ async function syncCatalog(config: Pick & Par warnings.push(values.map(String).join(" ")); }); try { - await syncCatalogModels({ - port: 10100, - defaultProvider: Object.keys(config.providers)[0] ?? "openai", + const providers = Object.keys(config.providers).length > 0 + ? config.providers + : { openai: { ...getDefaultConfig().providers.openai!, disabled: true } }; + await convergeCatalogForTest({ + ...getDefaultConfig(), ...config, - } as CodexCommanderConfig, testCatalogDeps()); + providers, + defaultProvider: Object.keys(providers)[0]!, + subagentModels: config.subagentModels ?? [], + ...(config.codexAccounts ? { + codexAccounts: config.codexAccounts.map((account, index) => ({ + email: `${account.id}@example.test`, + logLabel: `p${index.toString(16).padStart(6, "0")}`, + ...account, + })), + } : {}), + } as CodexCommanderConfig); return warnings.join("\n"); } finally { warning.mockRestore(); @@ -36,13 +50,6 @@ function nativeEntry(slug: string, priority: number): Record { }; } -function testCatalogDeps() { - return { - commandCandidates: () => ["codex-fixture"], - execFileSync: () => JSON.stringify({ models: [nativeEntry("gpt-5.5", 0)] }), - }; -} - function routedEntry(slug: string, priority: number): Record { return { slug, @@ -273,46 +280,6 @@ describe("Codex catalog sync hardening", () => { expect(JSON.stringify(rows)).not.toContain("Private Display Name"); }); - test("a live provider row shadowed by an account selector warns once per runtime generation", async () => { - const catalogPath = join(codexHome, "catalog.json"); - writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); - writeFileSync(catalogPath, JSON.stringify({ - models: [nativeEntry("gpt-5.5", 0)], - }, null, 2) + "\n"); - - const config: Pick & Partial = { - providers: { - openai: { - adapter: "openai-responses", - baseUrl: "https://chatgpt.com/backend-api/codex", - liveModels: false, - }, - team: { - adapter: "openai-chat", - baseUrl: "https://api.example.test/v1", - liveModels: false, - models: ["gpt-5.5"], - }, - }, - codexAccounts: [{ id: "stored-team-account", isMain: false }], - codexAccountNamespaces: { team: "stored-team-account" }, - }; - const firstWarnings = await syncCatalog(config); - const secondWarnings = await syncCatalog(config); - resetCatalogRuntimeStateForTests(); - const thirdWarnings = await syncCatalog(config); - const warnings = `${firstWarnings}\n${secondWarnings}\n${thirdWarnings}`; - expect((warnings.match(/account selector collision on "team\/gpt-5\.5"/g) ?? []).length).toBe(2); - - const rows = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ - slug: string; - codexcommander_catalog_kind?: string; - }>; - expect(rows.filter(row => row.slug === "team/gpt-5.5")).toEqual([ - expect.objectContaining({ codexcommander_catalog_kind: "account-selector-v1" }), - ]); - }); - test("non-OpenAI-only sync omits account rows without reprioritizing routed models", async () => { const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 73787cfa11..46620000a3 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -1274,7 +1274,7 @@ describe("Codex catalog routed normalization", () => { test("full merge path agrees with the build on the shared native priority policy", () => { // Genuine on-disk natives (real display names — the upstream-upgrade branch is NOT - // taken) plus the featured routed rows, merged the way syncCatalogModels does it. + // taken) plus the featured routed rows, merged by canonical convergence. const nativesOnDisk = buildCatalogEntries(nativeTemplate(), ROSTER_NATIVE_SLUGS, []); const routedEntries = buildCatalogEntries(nativeTemplate(), [], ROSTER_ROUTED_MODELS, FEATURED_ROSTER); const baseline = new Map([ diff --git a/tests/codex-convergence-contract.test.ts b/tests/codex-convergence-contract.test.ts index 13bed4ffc0..c4d1454a41 100644 --- a/tests/codex-convergence-contract.test.ts +++ b/tests/codex-convergence-contract.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { createHash } from "node:crypto"; import { + chmodSync, existsSync, lstatSync, mkdirSync, @@ -10,15 +11,26 @@ import { realpathSync, renameSync, rmSync, + symlinkSync, + utimesSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; -import { captureCatalogAdmissionSnapshot } from "../src/codex/catalog-admission"; import { + captureCatalogAdmissionSnapshot, + captureCatalogConfigAuthority, + CatalogAdmissionStaleConfigError, + createCatalogConvergeRequest, +} from "../src/codex/catalog-admission"; +import { + codexCatalogConvergenceReceiptMatchesCurrent, commitCodexCatalogCandidate, + convergeCodexCatalog, gatherCodexCatalogCandidate, + readCodexCatalogConvergenceReceipt, + resetCodexCatalogConvergenceReceiptForTests, type CodexCatalogCandidate, } from "../src/codex/convergence"; import { CODEX_NATIVE_ALIAS_CATALOG_KIND, resetCatalogRuntimeStateForTests } from "../src/codex/catalog"; @@ -37,6 +49,9 @@ import { resolveEffectiveUserIdentity, } from "../src/codex/user-identity"; import { saveConfig } from "../src/config"; +import { refreshCodexModelCatalog } from "../src/codex/refresh"; +import { syncModelsToCodex } from "../src/codex/sync"; +import { createManagementConvergeCodex } from "../src/codex/management-convergence"; import { handleManagementAPI } from "../src/server/management-api"; import type { CodexCommanderConfig } from "../src/types"; import { ManagementRequest } from "./helpers/management-auth"; @@ -46,6 +61,7 @@ let codexHome = ""; let codexCommanderHome = ""; let previousCodexHome: string | undefined; let previousCodexCommanderHome: string | undefined; +let previousCodexCliPath: string | undefined; function config(port = 10100): CodexCommanderConfig { return { @@ -77,6 +93,25 @@ function sourceCatalog(marker = "original"): string { }, null, 2)}\n`; } +function explicitCatalogConvergeRequest() { + return { + action: "converge", + scope: "catalog", + reason: "api-sync", + mode: "explicit", + deadlineMs: 1_000, + } as const; +} + +function writeManagedRouting(): void { + writeFileSync(join(codexHome, "config.toml"), [ + "# Auto-injected by CodexCommander", + 'openai_base_url = "http://127.0.0.1:10100/v1"', + 'model_catalog_json = "codexcommander-catalog.json"', + "", + ].join("\n")); +} + function manifest(base: string): string[] { const out: string[] = []; const visit = (directory: string) => { @@ -106,6 +141,7 @@ async function candidate(): Promise { beforeEach(() => { previousCodexHome = process.env.CODEX_HOME; previousCodexCommanderHome = process.env.CODEXCOMMANDER_HOME; + previousCodexCliPath = process.env.CODEX_CLI_PATH; root = realpathSync.native(mkdtempSync(join(tmpdir(), "ccx-convergence-"))); codexHome = join(root, "codex"); codexCommanderHome = join(root, "codexcommander"); @@ -115,6 +151,7 @@ beforeEach(() => { process.env.CODEXCOMMANDER_HOME = codexCommanderHome; resetCatalogRuntimeStateForTests(); resetCodexRuntimeResolveCacheForTests(); + resetCodexCatalogConvergenceReceiptForTests(); saveConfig(config()); writeFileSync(join(codexHome, "codexcommander-catalog.json"), sourceCatalog()); }); @@ -127,6 +164,8 @@ afterEach(() => { else process.env.CODEX_HOME = previousCodexHome; if (previousCodexCommanderHome === undefined) delete process.env.CODEXCOMMANDER_HOME; else process.env.CODEXCOMMANDER_HOME = previousCodexCommanderHome; + if (previousCodexCliPath === undefined) delete process.env.CODEX_CLI_PATH; + else process.env.CODEX_CLI_PATH = previousCodexCliPath; rmSync(root, { recursive: true, force: true }); }); @@ -140,6 +179,67 @@ test("T1 gather performs no filesystem write and does not materialize a runtime delete process.env.CODEX_CLI_PATH; }); +test("production sync primes a bundled source before an observe-only fresh-home gather", async () => { + if (process.platform === "win32") return; + + rmSync(join(codexHome, "codexcommander-catalog.json")); + resetCatalogRuntimeStateForTests(); + resetCodexRuntimeResolveCacheForTests(); + const fakeCodex = join(root, "codex-fixture"); + writeFileSync(fakeCodex, `#!/bin/sh +if [ "$1" = "--version" ]; then + echo "codex-cli 0.146.0" + exit 0 +fi +if [ "$1" = "debug" ] && [ "$2" = "models" ]; then + cat <<'CCX_CATALOG' +${sourceCatalog("fresh-bundled")}CCX_CATALOG + exit 0 +fi +exit 1 +`); + chmodSync(fakeCodex, 0o700); + process.env.CODEX_CLI_PATH = fakeCodex; + + const live: CodexCommanderConfig = { + ...config(), + defaultProvider: "vendor", + providers: { + ...config().providers, + vendor: { + adapter: "openai-chat", + baseUrl: "https://vendor.example/v1", + liveModels: false, + models: ["alpha"], + }, + }, + }; + saveConfig(live); + const synced = await syncModelsToCodex(10100, live, null, { + admitCodexWrite: () => ({ kind: "admitted" }), + prepareCodexTransitionState: () => ({ + kind: "ready", + state: { nativeGeneration: 0, currentTxId: null }, + }), + refreshCodexModelCatalog, + injectCodexConfig: async () => ({ success: true, message: "injected" }), + currentExternalCodexModelProvider: () => null, + }); + expect(synced).toMatchObject({ + status: "applied", + ok: true, + catalogExists: true, + catalogWritten: true, + cacheSynced: true, + catalogQuality: "live", + }); + if (!synced.catalogPath) throw new Error("fresh production sync did not expose its catalog path"); + const written = JSON.parse(readFileSync(synced.catalogPath, "utf8")) as { + models: Array<{ slug?: string }>; + }; + expect(written.models.map(entry => entry.slug)).toContain("vendor/alpha"); +}); + test("commit is fixed-order, receipt-exact, and a consumed candidate cannot be replayed", async () => { const gathered = await candidate(); const first = await commitCodexCatalogCandidate(gathered, 1_000); @@ -156,6 +256,186 @@ test("commit is fixed-order, receipt-exact, and a consumed candidate cannot be r expect(manifest(root)).toEqual(after); }); +test("a semantic no-op preserves catalog and cache mtimes and reports no artifact writes", async () => { + const first = await candidate(); + expect((await commitCodexCatalogCandidate(first, 1_000)).kind).toBe("committed"); + + const catalogPath = join(codexHome, "codexcommander-catalog.json"); + const cachePath = join(codexHome, "models_cache.json"); + const catalog = JSON.parse(readFileSync(catalogPath, "utf8")) as Record; + const cache = JSON.parse(readFileSync(cachePath, "utf8")) as Record; + // Exercise semantic equality rather than byte equality: whitespace and object key order differ. + writeFileSync(catalogPath, JSON.stringify(Object.fromEntries(Object.entries(catalog).reverse()))); + writeFileSync(cachePath, JSON.stringify(Object.fromEntries(Object.entries(cache).reverse()))); + const beforeCatalogMtime = lstatSync(catalogPath, { bigint: true }).mtimeNs; + const beforeCacheMtime = lstatSync(cachePath, { bigint: true }).mtimeNs; + + const second = await candidate(); + expect(await commitCodexCatalogCandidate(second, 1_000)).toEqual({ + kind: "committed", + changed: false, + writes: { keyedBackup: "preserved", catalog: "not-written", cache: "not-written" }, + }); + expect(lstatSync(catalogPath, { bigint: true }).mtimeNs).toBe(beforeCatalogMtime); + expect(lstatSync(cachePath, { bigint: true }).mtimeNs).toBe(beforeCacheMtime); +}); + +test("a committed semantic no-op republishes its receipt, accepts native cache churn, and detects catalog drift", async () => { + const request = explicitCatalogConvergeRequest(); + const first = await convergeCodexCatalog(captureCatalogAdmissionSnapshot(config()), request); + expect(first.catalogRefresh.status).toBe("committed"); + + resetCodexCatalogConvergenceReceiptForTests(); + const noOp = await convergeCodexCatalog(captureCatalogAdmissionSnapshot(config()), request); + expect(noOp).toMatchObject({ + changed: false, + catalogRefresh: { status: "committed", changed: false }, + }); + expect(readCodexCatalogConvergenceReceipt()).toMatchObject({ + targets: { + catalogPath: join(codexHome, "codexcommander-catalog.json"), + cachePath: join(codexHome, "models_cache.json"), + }, + }); + + const catalogPath = join(codexHome, "codexcommander-catalog.json"); + const cachePath = join(codexHome, "models_cache.json"); + const matches = (current: CodexCommanderConfig) => codexCatalogConvergenceReceiptMatchesCurrent({ + config: current, + catalogPath, + }); + expect(matches({ ...config(), multiAgentV2MessageDelivery: "plaintext" })).toBe(true); + expect(matches({ ...config(), shutdownTimeoutMs: 1_234 })).toBe(true); + expect(matches({ ...config(), websockets: true })).toBe(false); + // The global cap value is a setter default, not a catalog input until one or + // more concrete providerContextCaps entries are enabled. + expect(matches({ ...config(), contextCapValue: 32_000 })).toBe(true); + expect(matches({ ...config(), providerContextCaps: { openai: 32_000 } })).toBe(false); + + const nativeConfigPath = join(codexHome, "config.toml"); + writeFileSync(nativeConfigPath, "[features]\nmulti_agent_v2 = true\n"); + expect(matches(config())).toBe(false); + rmSync(nativeConfigPath); + expect(matches(config())).toBe(true); + + const catalogBytesBefore = readFileSync(catalogPath, "utf8"); + const catalog = JSON.parse(catalogBytesBefore) as { models: Array> }; + catalog.models[0]!.display_name = "same-slug metadata drift"; + writeFileSync(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`); + expect(matches(config())).toBe(false); + + writeFileSync(catalogPath, catalogBytesBefore); + expect(matches(config())).toBe(true); + const cache = JSON.parse(readFileSync(cachePath, "utf8")) as Record; + cache.client_version = "same-roster cache drift"; + writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`); + expect(matches(config())).toBe(true); + rmSync(cachePath); + expect(matches(config())).toBe(true); +}); + +test("a partial catalog commit publishes no convergence receipt", async () => { + if (process.platform === "win32") return; + + const selectedDir = join(root, "selected-catalog-parent"); + const selectedCatalog = join(selectedDir, "catalog.json"); + mkdirSync(selectedDir); + writeFileSync(selectedCatalog, sourceCatalog("selected")); + writeFileSync( + join(codexHome, "config.toml"), + `model_catalog_json = ${JSON.stringify(selectedCatalog)}\n`, + ); + const gathered = await gatherCodexCatalogCandidate(captureCatalogAdmissionSnapshot(config())); + expect(gathered.kind).toBe("candidate"); + if (gathered.kind !== "candidate") throw new Error(JSON.stringify(gathered)); + + resetCodexCatalogConvergenceReceiptForTests(); + chmodSync(codexHome, 0o500); + try { + expect(await commitCodexCatalogCandidate(gathered.candidate, 1_000)).toEqual({ + kind: "failed", + surface: "disk", + writes: { keyedBackup: "written", catalog: "written", cache: "not-written" }, + }); + } finally { + chmodSync(codexHome, 0o700); + } + expect(readCodexCatalogConvergenceReceipt()).toBeNull(); +}); + +test("a cache write before retained-LKG failure is reported as a partial commit", async () => { + if (process.platform === "win32") return; + + const live: CodexCommanderConfig = { + ...config(), + defaultProvider: "vendor", + providers: { + ...config().providers, + vendor: { + adapter: "openai-chat", + baseUrl: "https://vendor.example/v1", + liveModels: false, + models: ["alpha"], + }, + }, + }; + saveConfig(live); + expect((await convergeCodexCatalog( + captureCatalogAdmissionSnapshot(live), + explicitCatalogConvergeRequest(), + )).catalogRefresh.status).toBe("committed"); + + const cachePath = join(codexHome, "models_cache.json"); + const retainedPath = join(codexCommanderHome, "codex-routed-retained.json"); + expect(readFileSync(retainedPath, "utf8")).toContain("vendor/alpha"); + const cache = JSON.parse(readFileSync(cachePath, "utf8")) as Record; + cache.client_version = "drifted-before-partial"; + writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`); + rmSync(retainedPath); + const blockedRetainedDir = join(root, "blocked-retained-parent"); + const blockedRetainedTarget = join(blockedRetainedDir, "retained.json"); + mkdirSync(blockedRetainedDir); + writeFileSync(blockedRetainedTarget, '{"models":[]}\n'); + symlinkSync(blockedRetainedTarget, retainedPath); + chmodSync(blockedRetainedDir, 0o500); + resetCatalogRuntimeStateForTests(); + + const result = await convergeCodexCatalog( + captureCatalogAdmissionSnapshot(live), + explicitCatalogConvergeRequest(), + ).finally(() => chmodSync(blockedRetainedDir, 0o700)); + expect(result.catalogRefresh).toEqual({ + status: "failed", + reason: "disk", + phase: "commit", + retryable: false, + partialWrite: true, + }); + expect(result.projection).toMatchObject({ catalogWritten: false, cacheSynced: true }); + expect(readCodexCatalogConvergenceReceipt()).toBeNull(); +}); + +test("artifact no-op detection is independent when only the cache has drifted", async () => { + const first = await candidate(); + expect((await commitCodexCatalogCandidate(first, 1_000)).kind).toBe("committed"); + + const catalogPath = join(codexHome, "codexcommander-catalog.json"); + const cachePath = join(codexHome, "models_cache.json"); + const catalogMtime = lstatSync(catalogPath, { bigint: true }).mtimeNs; + const cache = JSON.parse(readFileSync(cachePath, "utf8")) as Record; + cache.client_version = "drifted"; + writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`); + + const second = await candidate(); + expect(await commitCodexCatalogCandidate(second, 1_000)).toEqual({ + kind: "committed", + changed: true, + writes: { keyedBackup: "preserved", catalog: "not-written", cache: "written" }, + }); + expect(lstatSync(catalogPath, { bigint: true }).mtimeNs).toBe(catalogMtime); + expect((JSON.parse(readFileSync(cachePath, "utf8")) as { client_version: string }).client_version).toBe("0.0.0"); +}); + test("generation drift rejects before every catalog target write", async () => { const gathered = await candidate(); const before = manifest(codexHome); @@ -164,6 +444,105 @@ test("generation drift rejects before every catalog target write", async () => { expect(manifest(codexHome)).toEqual(before); }); +test("admission cannot bind stale decoded config A to already-current generation B", () => { + const stale = config(); + saveConfig(config(20200)); + expect(() => captureCatalogAdmissionSnapshot(stale)).toThrow(CatalogAdmissionStaleConfigError); +}); + +test("management convergence projects stale config authority as a retryable stale skip", async () => { + writeManagedRouting(); + const stale = config(); + saveConfig(config(20200)); + const outcome = await createManagementConvergeCodex(stale)( + createCatalogConvergeRequest({ deadlineMs: 1_000 }), + ); + expect(outcome).toMatchObject({ + kind: "catalog-only", + changed: false, + catalogRefresh: { status: "skipped", reason: "stale", retryable: true }, + }); +}); + +test("manual config-byte drift is rejected even when it did not bump generation", () => { + const admitted = config(); + writeFileSync(join(codexCommanderHome, "config.json"), `${JSON.stringify(config(30300), null, 2)}\n`); + expect(() => captureCatalogAdmissionSnapshot(admitted)).toThrow(CatalogAdmissionStaleConfigError); +}); + +test("manual equivalent-byte rewrite after gather is fenced before publication", async () => { + const gathered = await candidate(); + const configPath = join(codexCommanderHome, "config.json"); + const parsed = JSON.parse(readFileSync(configPath, "utf8")) as CodexCommanderConfig; + writeFileSync(configPath, JSON.stringify(parsed)); + expect(await commitCodexCatalogCandidate(gathered, 1_000)).toEqual({ + kind: "stale", + reason: "generation", + }); + expect(existsSync(join(codexHome, "models_cache.json"))).toBe(false); +}); + +test("config authority keeps a monotonic ABA fence while preserving semantic identity", () => { + const a = config(); + const before = captureCatalogConfigAuthority(a); + saveConfig(config(20200)); + saveConfig(a); + const after = captureCatalogConfigAuthority(a); + expect(after.semanticIdentity).toBe(before.semanticIdentity); + expect(after.generation.value).toBeGreaterThan(before.generation.value); +}); + +test("production sync cannot publish stale config A after save B wins after gather", async () => { + const a: CodexCommanderConfig = { + ...config(), + defaultProvider: "vendor", + providers: { + ...config().providers, + vendor: { + adapter: "openai-chat", + baseUrl: "https://vendor.example/v1", + liveModels: false, + models: ["alpha"], + }, + }, + }; + saveConfig(a); + const catalogPath = join(codexHome, "codexcommander-catalog.json"); + const before = readFileSync(catalogPath, "utf8"); + let injected = false; + let savedB = false; + const result = await syncModelsToCodex(10100, a, null, { + admitCodexWrite: () => ({ kind: "admitted" }), + prepareCodexTransitionState: () => ({ + kind: "ready", + state: { nativeGeneration: 0, currentTxId: null }, + }), + refreshCodexModelCatalog: current => refreshCodexModelCatalog(current, { + captureCatalogAdmissionSnapshot, + prepareConfigGeneration: () => {}, + existsSync, + convergeCodexCatalog: (snapshot, request) => convergeCodexCatalog(snapshot, request, { + onCommitBegin: () => { + savedB = true; + saveConfig({ ...a, port: 20200 }); + }, + }), + }), + injectCodexConfig: async () => { + injected = true; + return { success: true, message: "must not inject" }; + }, + currentExternalCodexModelProvider: () => null, + }); + expect(savedB).toBe(true); + expect(result).toMatchObject({ status: "refused", ok: false }); + expect(result.message).toContain("configuration changed during catalog discovery"); + expect(injected).toBe(false); + expect(readFileSync(catalogPath, "utf8")).toBe(before); + expect(existsSync(join(codexHome, "models_cache.json"))).toBe(false); + expect(existsSync(join(codexCommanderHome, "codex-routed-retained.json"))).toBe(false); +}); + test("home-selection drift rejects before every catalog target write", async () => { const gathered = await candidate(); const other = join(root, "other-codex"); @@ -393,6 +772,171 @@ test("management convergence rehydrates a missing configured provider from admit expect(slugs).toContain("peer/beta"); }, { timeout: 20_000 }); +test("canonical live convergence advances retained LKG idempotently and outage recovery uses it", async () => { + const live: CodexCommanderConfig = { + ...config(), + defaultProvider: "vendor", + providers: { + ...config().providers, + vendor: { + adapter: "openai-chat", + baseUrl: "https://vendor.example/v1", + liveModels: false, + models: ["alpha"], + }, + }, + }; + saveConfig(live); + + const first = await gatherCodexCatalogCandidate(captureCatalogAdmissionSnapshot(live)); + expect(first.kind).toBe("candidate"); + if (first.kind !== "candidate") throw new Error(JSON.stringify(first)); + expect((await commitCodexCatalogCandidate(first.candidate, 1_000)).kind).toBe("committed"); + + const retainedPath = join(codexCommanderHome, "codex-routed-retained.json"); + const retained = readFileSync(retainedPath, "utf8"); + expect((JSON.parse(retained) as { models: Array<{ slug?: string }> }).models.map(row => row.slug)) + .toContain("vendor/alpha"); + const sentinel = new Date("2000-01-01T00:00:00.000Z"); + utimesSync(retainedPath, sentinel, sentinel); + const retainedMtime = lstatSync(retainedPath, { bigint: true }).mtimeNs; + + const repeated = await gatherCodexCatalogCandidate(captureCatalogAdmissionSnapshot(live)); + expect(repeated.kind).toBe("candidate"); + if (repeated.kind !== "candidate") throw new Error(JSON.stringify(repeated)); + expect(await commitCodexCatalogCandidate(repeated.candidate, 1_000)).toMatchObject({ + kind: "committed", + changed: false, + }); + expect(readFileSync(retainedPath, "utf8")).toBe(retained); + expect(lstatSync(retainedPath, { bigint: true }).mtimeNs).toBe(retainedMtime); + + const outage: CodexCommanderConfig = { + ...live, + providers: { + ...live.providers, + vendor: { + adapter: "openai-chat", + baseUrl: "https://vendor.example/v1", + }, + }, + }; + saveConfig(outage); + writeFileSync(join(codexHome, "codexcommander-catalog.json"), sourceCatalog("restored-native")); + resetCatalogRuntimeStateForTests(); + + const recovered = await gatherCodexCatalogCandidate(captureCatalogAdmissionSnapshot(outage)); + expect(recovered.kind).toBe("candidate"); + if (recovered.kind !== "candidate") throw new Error(JSON.stringify(recovered)); + expect((await commitCodexCatalogCandidate(recovered.candidate, 1_000)).kind).toBe("committed"); + const active = JSON.parse(readFileSync(join(codexHome, "codexcommander-catalog.json"), "utf8")) as { + models: Array<{ slug?: string }>; + }; + expect(active.models.map(row => row.slug)).toContain("vendor/alpha"); +}, { timeout: 20_000 }); + +test("management Save and production sync publish identical catalog, cache, and retained bytes", async () => { + writeManagedRouting(); + const live: CodexCommanderConfig = { + ...config(), + defaultProvider: "vendor", + providers: { + ...config().providers, + vendor: { + adapter: "openai-chat", + baseUrl: "https://vendor.example/v1", + liveModels: false, + models: ["alpha"], + }, + }, + }; + saveConfig(live); + const management = await convergeCodexCatalog( + captureCatalogAdmissionSnapshot(live), + createCatalogConvergeRequest({ deadlineMs: 1_000 }), + ); + expect(management.catalogRefresh.status).toBe("committed"); + + const catalogPath = join(codexHome, "codexcommander-catalog.json"); + const cachePath = join(codexHome, "models_cache.json"); + const retainedPath = join(codexCommanderHome, "codex-routed-retained.json"); + const expected = { + catalog: readFileSync(catalogPath, "utf8"), + cache: readFileSync(cachePath, "utf8"), + retained: readFileSync(retainedPath, "utf8"), + }; + + writeFileSync(catalogPath, sourceCatalog()); + rmSync(cachePath, { force: true }); + rmSync(retainedPath, { force: true }); + const injected: Array<{ + catalogPath?: string | null; + generation?: number; + configContentIdentity?: string; + }> = []; + const synced = await syncModelsToCodex(10100, live, null, { + admitCodexWrite: () => ({ kind: "admitted" }), + prepareCodexTransitionState: () => ({ + kind: "ready", + state: { nativeGeneration: 0, currentTxId: null }, + }), + refreshCodexModelCatalog, + injectCodexConfig: async (_port, _config, options) => { + injected.push({ + catalogPath: options.catalogPath, + generation: options.expectedConfigGeneration?.value, + configContentIdentity: options.expectedConfigAuthority?.contentIdentity, + }); + return { success: true, message: "injected" }; + }, + currentExternalCodexModelProvider: () => null, + }); + + expect(synced).toMatchObject({ + status: "applied", + ok: true, + catalogWritten: true, + cacheSynced: true, + catalogQuality: "live", + }); + const admittedAuthority = captureCatalogConfigAuthority(live); + expect(injected).toEqual([{ + catalogPath, + generation: admittedAuthority.generation.value, + configContentIdentity: admittedAuthority.contentIdentity, + }]); + expect({ + catalog: readFileSync(catalogPath, "utf8"), + cache: readFileSync(cachePath, "utf8"), + retained: readFileSync(retainedPath, "utf8"), + }).toEqual(expected); + + const sentinel = new Date("2000-01-01T00:00:00.000Z"); + for (const path of [catalogPath, cachePath, retainedPath]) utimesSync(path, sentinel, sentinel); + const mtimes = [catalogPath, cachePath, retainedPath] + .map(path => lstatSync(path, { bigint: true }).mtimeNs); + const noOp = await syncModelsToCodex(10100, live, null, { + admitCodexWrite: () => ({ kind: "admitted" }), + prepareCodexTransitionState: () => ({ + kind: "ready", + state: { nativeGeneration: 0, currentTxId: null }, + }), + refreshCodexModelCatalog, + injectCodexConfig: async () => ({ success: true, message: "injected" }), + currentExternalCodexModelProvider: () => null, + }); + expect(noOp).toMatchObject({ + status: "applied", + ok: true, + added: 0, + catalogWritten: false, + cacheSynced: false, + catalogQuality: "live", + }); + expect([catalogPath, cachePath, retainedPath] + .map(path => lstatSync(path, { bigint: true }).mtimeNs)).toEqual(mtimes); +}); + test("management convergence clamps routed efforts to the admitted Codex catalog ladder", async () => { const runtime = { command: "/tmp/codex", version: "0.146.0", source: "environment" as const }; const bundled = JSON.parse(sourceCatalog("bundled-efforts")) as { @@ -466,7 +1010,7 @@ test("the route inventory contains exactly the specified 6 + 6 + 2 + 2 convergen ["agent-settings-routes.ts", 2], ].map(([file, expected]) => { const source = readFileSync(join(import.meta.dir, "..", "src", "server", "management", file as string), "utf8"); - const count = source.match(/await convergeCodexCatalog\(\)/g)?.length ?? 0; + const count = source.match(/await convergeCodexCatalog\([^)]*\)/g)?.length ?? 0; expect(count).toBe(expected); expect(source).not.toContain("refreshCodexCatalogBestEffort"); return [file, count]; diff --git a/tests/codex-inject-integration.test.ts b/tests/codex-inject-integration.test.ts index 982297b111..30e7010ed5 100644 --- a/tests/codex-inject-integration.test.ts +++ b/tests/codex-inject-integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync, readFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -75,13 +75,73 @@ describe("injectCodexConfig integration (Design B)", () => { writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5.5"\n', "utf8"); expect(runInject(codexHome, ccxHome).status).toBe(0); - const first = readFileSync(join(codexHome, "config.toml"), "utf8"); + const configPath = join(codexHome, "config.toml"); + const profilePath = join(codexHome, "codexcommander.config.toml"); + const first = readFileSync(configPath, "utf8"); + const firstProfile = readFileSync(profilePath, "utf8"); + const sentinel = new Date("2000-01-01T00:00:00.000Z"); + utimesSync(configPath, sentinel, sentinel); + utimesSync(profilePath, sentinel, sentinel); + const configMtime = lstatSync(configPath, { bigint: true }).mtimeNs; + const profileMtime = lstatSync(profilePath, { bigint: true }).mtimeNs; expect(runInject(codexHome, ccxHome).status).toBe(0); - const second = readFileSync(join(codexHome, "config.toml"), "utf8"); + const second = readFileSync(configPath, "utf8"); expect(second.match(/openai_base_url/g)?.length).toBe(1); expect(second.match(/Auto-injected by CodexCommander/g)?.length).toBe(1); expect(second).toBe(first); + expect(readFileSync(profilePath, "utf8")).toBe(firstProfile); + expect(lstatSync(configPath, { bigint: true }).mtimeNs).toBe(configMtime); + expect(lstatSync(profilePath, { bigint: true }).mtimeNs).toBe(profileMtime); + }); + + test("expected config generation fences the post-catalog injection gap", () => { + const configPath = join(codexHome, "config.toml"); + const original = 'model = "gpt-5.5"\n'; + writeFileSync(configPath, original, "utf8"); + + const result = runInject( + codexHome, + ccxHome, + "{}", + JSON.stringify({ expectedConfigGeneration: { value: 1 } }), + ); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ success: false, status: "stale" }); + expect(readFileSync(configPath, "utf8")).toBe(original); + expect(existsSync(join(codexHome, "codexcommander.config.toml"))).toBe(false); + expect(existsSync(join(codexHome, "codexcommander-journal.json"))).toBe(false); + }); + + test("exact admitted config authority fences a manual byte rewrite before injection", () => { + const configPath = join(codexHome, "config.toml"); + const original = 'model = "gpt-5.5"\n'; + writeFileSync(configPath, original, "utf8"); + const script = ` + const { writeFileSync } = require("node:fs"); + const { join } = require("node:path"); + const { loadConfig } = require("./src/config"); + const { captureCatalogConfigAuthority } = require("./src/codex/catalog-admission"); + const { injectCodexConfig } = require("./src/codex/inject"); + const config = loadConfig(); + const authority = captureCatalogConfigAuthority(config); + writeFileSync( + join(process.env.CODEXCOMMANDER_HOME, "config.json"), + JSON.stringify(config, null, 2) + "\\n", + ); + const result = await injectCodexConfig(10100, config, { expectedConfigAuthority: authority }); + console.log(JSON.stringify(result)); + `; + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { ...process.env, CODEX_HOME: codexHome, CODEXCOMMANDER_HOME: ccxHome }, + encoding: "utf8", + }); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout.trim())).toMatchObject({ success: false, status: "stale" }); + expect(readFileSync(configPath, "utf8")).toBe(original); + expect(existsSync(join(codexHome, "codexcommander.config.toml"))).toBe(false); + expect(existsSync(join(codexHome, "codexcommander-journal.json"))).toBe(false); }); test("fastMode=false forces fast_mode=false in both config and profile", () => { diff --git a/tests/codex-journal.test.ts b/tests/codex-journal.test.ts index 34bd88517d..fb2b19332f 100644 --- a/tests/codex-journal.test.ts +++ b/tests/codex-journal.test.ts @@ -1,25 +1,96 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { Database } from "bun:sqlite"; import { MANAGED_AGENTS_TABLE_MARKER, MANAGED_SUBAGENT_DEFAULT_MARKER, } from "../src/codex/subagent-defaults"; +import { + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "../src/codex/user-identity"; const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); -function runScript(codexHome: string, script: string): { stdout: string; stderr: string; status: number } { +function runScript( + codexHome: string, + script: string, + codexCommanderHome = join(codexHome, "ccx-state"), +): { stdout: string; stderr: string; status: number } { const result = spawnSync(process.execPath, ["--eval", script], { cwd: repoRoot, - env: { ...process.env, CODEX_HOME: codexHome }, + env: { + ...process.env, + CODEX_HOME: codexHome, + CODEXCOMMANDER_HOME: codexCommanderHome, + }, encoding: "utf8", }); return { stdout: result.stdout?.trim() ?? "", stderr: result.stderr?.trim() ?? "", status: result.status ?? 1 }; } +function contentHash(content: string | null): string | null { + return content === null + ? null + : createHash("sha256").update(content).digest("hex"); +} + +function coordinatorPath(codexHome: string): string { + return resolveCodexCoordinatorDatabasePath( + resolveEffectiveUserIdentity(), + realpathSync.native(codexHome), + ); +} + +function removeCoordinator(codexHome: string): void { + const path = coordinatorPath(codexHome); + for (const suffix of ["", "-journal", "-wal", "-shm"]) { + rmSync(`${path}${suffix}`, { force: true }); + } +} + +function writeRecoveryJournal( + codexHome: string, + options: { + originalConfig: string; + originalProfile?: string | null; + injectedConfig: string; + injectedProfile: string | null; + pid?: number; + timestamp?: string; + }, +): string { + const journalPath = join(codexHome, "codexcommander-journal.json"); + writeFileSync(journalPath, JSON.stringify({ + version: 1, + originalConfig: Buffer.from(options.originalConfig).toString("base64"), + originalProfile: options.originalProfile === undefined || options.originalProfile === null + ? null + : Buffer.from(options.originalProfile).toString("base64"), + injectedConfigHash: contentHash(options.injectedConfig), + injectedProfileHash: contentHash(options.injectedProfile), + pid: options.pid ?? 999999, + timestamp: options.timestamp ?? "2026-08-10T00:00:00.000Z", + }), "utf8"); + return journalPath; +} + describe("codex-journal", () => { let testDir: string; @@ -29,6 +100,7 @@ describe("codex-journal", () => { }); afterEach(() => { + removeCoordinator(testDir); rmSync(testDir, { recursive: true, force: true }); }); @@ -51,29 +123,462 @@ describe("codex-journal", () => { }); test("reconcileJournal restores config when journaled PID is dead", () => { - const journalPath = join(testDir, "codexcommander-journal.json"); const original = "# original config\nmodel_provider = \"openai\"\n"; - const modified = "# modified\nmodel_provider = \"codexcommander\"\n"; + const modified = [ + 'model_provider = "codexcommander"', + "", + "# Auto-injected by CodexCommander", + "[model_providers.codexcommander]", + 'base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n"); writeFileSync(join(testDir, "config.toml"), modified, "utf8"); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig: original, + injectedConfig: modified, + injectedProfile: null, + }); + + const r = runScript(testDir, ` + const { reconcileJournal } = require("./src/codex/journal"); + const result = reconcileJournal(); + console.log(JSON.stringify({ restored: result })); + `); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout).restored).toBe(true); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(original); + expect(existsSync(journalPath)).toBe(false); + }); + + test("writeJournal persists intended postimage hashes before any native write", () => { + const original = readFileSync(join(testDir, "config.toml"), "utf8"); + const intendedConfig = [ + 'model_provider = "codexcommander"', + "", + "# Auto-injected by CodexCommander", + "[model_providers.codexcommander]", + 'base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n"); + const written = runScript(testDir, ` + const { writeJournal } = require("./src/codex/journal"); + writeJournal({ intendedPostimage: { + config: ${JSON.stringify(intendedConfig)}, + profile: null, + } }); + console.log("written"); + `); + expect(written.status).toBe(0); + + const journalPath = join(testDir, "codexcommander-journal.json"); + const journal = JSON.parse(readFileSync(journalPath, "utf8")); + expect(journal.injectedConfigHash).toBe(contentHash(intendedConfig)); + expect(journal.injectedProfileHash).toBeNull(); + + // Crash after journal publication but before either native write, followed + // by a human edit: recovery preserves the edit and retires only the stale + // authority record. + const userEdit = 'model = "gpt-5.6-sol"\n'; + writeFileSync(join(testDir, "config.toml"), userEdit, "utf8"); + const reconciled = runScript(testDir, ` + const { reconcileJournal } = require("./src/codex/journal"); + console.log(JSON.stringify(reconcileJournal())); + `); + expect(reconciled.status).toBe(0); + expect(JSON.parse(reconciled.stdout)).toBe(true); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(userEdit); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).not.toBe(original); + expect(existsSync(journalPath)).toBe(false); + }); + + test("a legacy hashless journal never replays over divergent user bytes", () => { + const journalPath = join(testDir, "codexcommander-journal.json"); + const userEdit = 'model = "gpt-5.6-terra"\n'; + writeFileSync(join(testDir, "config.toml"), userEdit, "utf8"); writeFileSync(journalPath, JSON.stringify({ version: 1, - originalConfig: Buffer.from(original).toString("base64"), + originalConfig: Buffer.from('model = "gpt-5.4"\n').toString("base64"), originalProfile: null, pid: 999999, - timestamp: new Date().toISOString(), + timestamp: "2026-08-10T00:00:00.000Z", }), "utf8"); const r = runScript(testDir, ` const { reconcileJournal } = require("./src/codex/journal"); - const result = reconcileJournal(); - console.log(JSON.stringify({ restored: result })); + console.log(JSON.stringify(reconcileJournal())); `); expect(r.status).toBe(0); - expect(JSON.parse(r.stdout).restored).toBe(true); - expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(original); + expect(JSON.parse(r.stdout)).toBe(true); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(userEdit); expect(existsSync(journalPath)).toBe(false); }); + test("a legacy hashless routed config with a user edit is preserved and retains authority", () => { + const journalPath = join(testDir, "codexcommander-journal.json"); + const routedWithUserEdit = [ + 'model_provider = "codexcommander"', + "", + "# Auto-injected by CodexCommander", + "[model_providers.codexcommander]", + 'base_url = "http://127.0.0.1:10100/v1"', + "", + "[tools]", + "web_search = true", + "", + ].join("\n"); + writeFileSync(join(testDir, "config.toml"), routedWithUserEdit, "utf8"); + writeFileSync(journalPath, JSON.stringify({ + version: 1, + originalConfig: Buffer.from('model = "gpt-5.4"\n').toString("base64"), + originalProfile: null, + pid: 999999, + timestamp: "2026-08-10T00:00:00.000Z", + }), "utf8"); + + const r = runScript(testDir, ` + const { reconcileJournal } = require("./src/codex/journal"); + console.log(JSON.stringify(reconcileJournal())); + `); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toBe(false); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(routedWithUserEdit); + expect(existsSync(journalPath)).toBe(true); + }); + + test("a legacy hashless generated profile restores because the whole surface is CCX-owned", () => { + const originalConfig = readFileSync(join(testDir, "config.toml"), "utf8"); + const originalProfile = 'model_provider = "openai"\n'; + const injectedProfile = [ + "# CodexCommander proxy fallback config (Design B)", + 'openai_base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n"); + const journalPath = join(testDir, "codexcommander-journal.json"); + writeFileSync(join(testDir, "codexcommander.config.toml"), injectedProfile, "utf8"); + writeFileSync(journalPath, JSON.stringify({ + version: 1, + originalConfig: Buffer.from(originalConfig).toString("base64"), + originalProfile: Buffer.from(originalProfile).toString("base64"), + pid: 999999, + timestamp: "2026-08-10T00:00:00.000Z", + }), "utf8"); + + const r = runScript(testDir, ` + const { reconcileJournal } = require("./src/codex/journal"); + console.log(JSON.stringify(reconcileJournal())); + `); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toBe(true); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(originalConfig); + expect(readFileSync(join(testDir, "codexcommander.config.toml"), "utf8")).toBe(originalProfile); + expect(existsSync(journalPath)).toBe(false); + }); + + test("a config change after recovery authorization is preserved and retains the journal", () => { + const original = 'model = "gpt-5.5"\n'; + const injected = [ + 'model_provider = "codexcommander"', + "", + "# Auto-injected by CodexCommander", + "[model_providers.codexcommander]", + 'base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n"); + writeFileSync(join(testDir, "config.toml"), injected, "utf8"); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig: original, + injectedConfig: injected, + injectedProfile: null, + }); + const userEdit = 'model = "gpt-5.6-luna"\n'; + + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { reconcileJournal } = require("./src/codex/journal"); + const reconciled = reconcileJournal({ + beforeConfigMutationRevalidation: () => fs.writeFileSync( + path.join(process.env.CODEX_HOME, "config.toml"), + ${JSON.stringify(userEdit)}, + "utf8", + ), + }); + console.log(JSON.stringify(reconciled)); + `); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toBe(false); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(userEdit); + expect(existsSync(journalPath)).toBe(true); + }); + + test("a profile change after recovery authorization is preserved and retains the journal", () => { + const originalConfig = readFileSync(join(testDir, "config.toml"), "utf8"); + const originalProfile = 'model_provider = "openai"\n'; + const injectedProfile = [ + "# CodexCommander proxy fallback config (Design B)", + 'openai_base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n"); + writeFileSync(join(testDir, "codexcommander.config.toml"), injectedProfile, "utf8"); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig, + originalProfile, + injectedConfig: "different injected config", + injectedProfile, + }); + const userEdit = 'model_provider = "custom"\n'; + + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { reconcileJournal } = require("./src/codex/journal"); + const reconciled = reconcileJournal({ + beforeProfileMutationRevalidation: () => fs.writeFileSync( + path.join(process.env.CODEX_HOME, "codexcommander.config.toml"), + ${JSON.stringify(userEdit)}, + "utf8", + ), + }); + console.log(JSON.stringify(reconciled)); + `); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toBe(false); + expect(readFileSync(join(testDir, "codexcommander.config.toml"), "utf8")).toBe(userEdit); + expect(existsSync(journalPath)).toBe(true); + }); + + const symlinkTest = process.platform === "win32" ? test.skip : test; + + symlinkTest("recovery preserves a config leaf symlink and restores its captured target", () => { + const original = 'model = "gpt-5.5"\n'; + const injected = [ + 'model_provider = "codexcommander"', + "", + "# Auto-injected by CodexCommander", + "[model_providers.codexcommander]", + 'base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n"); + const target = join(testDir, "linked-config.toml"); + writeFileSync(target, injected, "utf8"); + rmSync(join(testDir, "config.toml")); + symlinkSync(target, join(testDir, "config.toml")); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig: original, + injectedConfig: injected, + injectedProfile: null, + }); + + const r = runScript(testDir, ` + const { reconcileJournal } = require("./src/codex/journal"); + console.log(JSON.stringify(reconcileJournal())); + `); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toBe(true); + expect(lstatSync(join(testDir, "config.toml")).isSymbolicLink()).toBe(true); + expect(readFileSync(target, "utf8")).toBe(original); + expect(existsSync(journalPath)).toBe(false); + }); + + symlinkTest("recovery preserves a profile leaf symlink and restores its captured target", () => { + const originalConfig = readFileSync(join(testDir, "config.toml"), "utf8"); + const originalProfile = 'model_provider = "openai"\n'; + const injectedProfile = [ + "# CodexCommander proxy fallback config (Design B)", + 'openai_base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n"); + const target = join(testDir, "linked-profile.toml"); + writeFileSync(target, injectedProfile, "utf8"); + symlinkSync(target, join(testDir, "codexcommander.config.toml")); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig, + originalProfile, + injectedConfig: "different injected config", + injectedProfile, + }); + + const r = runScript(testDir, ` + const { reconcileJournal } = require("./src/codex/journal"); + console.log(JSON.stringify(reconcileJournal())); + `); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toBe(true); + expect(lstatSync(join(testDir, "codexcommander.config.toml")).isSymbolicLink()).toBe(true); + expect(readFileSync(target, "utf8")).toBe(originalProfile); + expect(existsSync(journalPath)).toBe(false); + }); + + symlinkTest("a config link retarget after authorization never overwrites either target", () => { + const original = 'model = "gpt-5.5"\n'; + const injected = [ + 'model_provider = "codexcommander"', + "", + "# Auto-injected by CodexCommander", + "[model_providers.codexcommander]", + 'base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n"); + const firstTarget = join(testDir, "config-first.toml"); + const replacementTarget = join(testDir, "config-replacement.toml"); + const logical = join(testDir, "config.toml"); + writeFileSync(firstTarget, injected, "utf8"); + writeFileSync(replacementTarget, 'model = "user-edit"\n', "utf8"); + rmSync(logical); + symlinkSync(firstTarget, logical); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig: original, + injectedConfig: injected, + injectedProfile: null, + }); + + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { reconcileJournal } = require("./src/codex/journal"); + const logical = path.join(process.env.CODEX_HOME, "config.toml"); + const reconciled = reconcileJournal({ beforeConfigMutationRevalidation: () => { + fs.unlinkSync(logical); + fs.symlinkSync(path.join(process.env.CODEX_HOME, "config-replacement.toml"), logical); + } }); + console.log(JSON.stringify(reconciled)); + `); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toBe(false); + expect(readFileSync(firstTarget, "utf8")).toBe(injected); + expect(readFileSync(replacementTarget, "utf8")).toBe('model = "user-edit"\n'); + expect(existsSync(journalPath)).toBe(true); + }); + + symlinkTest("a profile link retarget after authorization never overwrites either target", () => { + const originalConfig = readFileSync(join(testDir, "config.toml"), "utf8"); + const originalProfile = 'model_provider = "openai"\n'; + const injectedProfile = [ + "# CodexCommander proxy fallback config (Design B)", + 'openai_base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n"); + const firstTarget = join(testDir, "profile-first.toml"); + const replacementTarget = join(testDir, "profile-replacement.toml"); + const logical = join(testDir, "codexcommander.config.toml"); + writeFileSync(firstTarget, injectedProfile, "utf8"); + writeFileSync(replacementTarget, 'model_provider = "custom"\n', "utf8"); + symlinkSync(firstTarget, logical); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig, + originalProfile, + injectedConfig: "different injected config", + injectedProfile, + }); + + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { reconcileJournal } = require("./src/codex/journal"); + const logical = path.join(process.env.CODEX_HOME, "codexcommander.config.toml"); + const reconciled = reconcileJournal({ beforeProfileMutationRevalidation: () => { + fs.unlinkSync(logical); + fs.symlinkSync(path.join(process.env.CODEX_HOME, "profile-replacement.toml"), logical); + } }); + console.log(JSON.stringify(reconciled)); + `); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toBe(false); + expect(readFileSync(firstTarget, "utf8")).toBe(injectedProfile); + expect(readFileSync(replacementTarget, "utf8")).toBe('model_provider = "custom"\n'); + expect(existsSync(journalPath)).toBe(true); + }); + + symlinkTest("an injected profile replaced by a symlink is never unlinked", () => { + const originalConfig = readFileSync(join(testDir, "config.toml"), "utf8"); + const injectedProfile = [ + "# CodexCommander proxy fallback config (Design B)", + 'openai_base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n"); + const logical = join(testDir, "codexcommander.config.toml"); + const userTarget = join(testDir, "user-profile.toml"); + writeFileSync(logical, injectedProfile, "utf8"); + writeFileSync(userTarget, injectedProfile, "utf8"); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig, + originalProfile: null, + injectedConfig: "different injected config", + injectedProfile, + }); + + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { reconcileJournal } = require("./src/codex/journal"); + const logical = path.join(process.env.CODEX_HOME, "codexcommander.config.toml"); + const reconciled = reconcileJournal({ beforeProfileMutationRevalidation: () => { + fs.unlinkSync(logical); + fs.symlinkSync(path.join(process.env.CODEX_HOME, "user-profile.toml"), logical); + } }); + console.log(JSON.stringify(reconciled)); + `); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toBe(false); + expect(lstatSync(logical).isSymbolicLink()).toBe(true); + expect(readFileSync(userTarget, "utf8")).toBe(injectedProfile); + expect(existsSync(journalPath)).toBe(true); + }); + + symlinkTest("a parent-directory symlink swap after authorization cannot redirect recovery", () => { + const aliasRoot = mkdtempSync(join(tmpdir(), "ccx-journal-alias-")); + const replacementHome = mkdtempSync(join(tmpdir(), "ccx-journal-replacement-")); + const alias = join(aliasRoot, "codex-home"); + const original = 'model = "gpt-5.5"\n'; + const injected = [ + 'model_provider = "codexcommander"', + "", + "# Auto-injected by CodexCommander", + "[model_providers.codexcommander]", + 'base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n"); + writeFileSync(join(testDir, "config.toml"), injected, "utf8"); + const replacementConfig = 'model = "replacement-user"\n'; + writeFileSync(join(replacementHome, "config.toml"), replacementConfig, "utf8"); + symlinkSync(testDir, alias); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig: original, + injectedConfig: injected, + injectedProfile: null, + }); + try { + const result = spawnSync(process.execPath, ["--eval", ` + const fs = require("node:fs"); + const path = require("node:path"); + const { reconcileJournal } = require("./src/codex/journal"); + const alias = process.env.CODEX_HOME; + const reconciled = reconcileJournal({ beforeConfigMutationRevalidation: () => { + fs.unlinkSync(alias); + fs.symlinkSync(process.env.TEST_REPLACEMENT_HOME, alias); + } }); + console.log(JSON.stringify(reconciled)); + `], { + cwd: repoRoot, + env: { + ...process.env, + CODEX_HOME: alias, + CODEXCOMMANDER_HOME: join(aliasRoot, "ccx-state"), + TEST_REPLACEMENT_HOME: replacementHome, + }, + encoding: "utf8", + }); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout.trim())).toBe(true); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(original); + expect(readFileSync(join(replacementHome, "config.toml"), "utf8")).toBe(replacementConfig); + expect(existsSync(journalPath)).toBe(false); + } finally { + rmSync(aliasRoot, { recursive: true, force: true }); + rmSync(replacementHome, { recursive: true, force: true }); + } + }); + test("reconcileJournal handles corrupt JSON gracefully", () => { const journalPath = join(testDir, "codexcommander-journal.json"); writeFileSync(journalPath, "NOT VALID JSON{{{", "utf8"); @@ -85,7 +590,7 @@ describe("codex-journal", () => { `); expect(r.status).toBe(0); expect(JSON.parse(r.stdout).restored).toBe(false); - expect(existsSync(journalPath)).toBe(false); + expect(existsSync(journalPath)).toBe(true); }); test("reconcileJournal no-ops when no journal exists", () => { @@ -98,6 +603,223 @@ describe("codex-journal", () => { expect(JSON.parse(r.stdout).restored).toBe(false); }); + test("ordinary coordinator initialization safely adopts only an exact empty v0 shell", () => { + const path = coordinatorPath(testDir); + new Database(path).close(); + + const r = runScript(testDir, ` + const { readCodexTransitionState } = require("./src/codex/transition-state"); + console.log(JSON.stringify(readCodexTransitionState())); + `); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toEqual({ + kind: "ready", + state: { nativeGeneration: 0, currentTxId: null }, + }); + const db = new Database(path, { readonly: true }); + expect(db.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version).toBe(2); + db.close(); + }); + + test("an unversioned coordinator with any schema is never adopted by ordinary or recovery paths", () => { + const path = coordinatorPath(testDir); + const db = new Database(path); + db.exec("CREATE TABLE foreign_state (value TEXT)"); + db.close(); + + const ordinary = runScript(testDir, ` + const { readCodexTransitionState } = require("./src/codex/transition-state"); + console.log(JSON.stringify(readCodexTransitionState())); + `); + expect(ordinary.status).toBe(0); + expect(JSON.parse(ordinary.stdout)).toMatchObject({ kind: "state-ambiguous" }); + + const original = readFileSync(join(testDir, "config.toml"), "utf8"); + const injected = [ + 'model_provider = "codexcommander"', + "", + "# Auto-injected by CodexCommander", + "[model_providers.codexcommander]", + 'base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n"); + writeFileSync(join(testDir, "config.toml"), injected, "utf8"); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig: original, + injectedConfig: injected, + injectedProfile: null, + }); + const recovery = runScript(testDir, ` + const { reconcileJournal } = require("./src/codex/journal"); + console.log(JSON.stringify(reconcileJournal())); + `); + expect(recovery.status).toBe(0); + expect(JSON.parse(recovery.stdout)).toBe(false); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(injected); + expect(existsSync(journalPath)).toBe(true); + }); + + test("a crash after journal unlink but before coordinator commit leaves a recoverable empty shell", () => { + const path = coordinatorPath(testDir); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig: readFileSync(join(testDir, "config.toml"), "utf8"), + injectedConfig: "different injected config", + injectedProfile: null, + }); + const crashed = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { canonicalizeCodexHome } = require("./src/codex/codex-write-lock"); + const { beginCodexCoordinatorRecoveryTransaction } = require("./src/codex/transition-state"); + const { resolveCodexCoordinatorDatabasePath, resolveEffectiveUserIdentity } = require("./src/codex/user-identity"); + const canonical = canonicalizeCodexHome(process.env.CODEX_HOME); + if (!canonical.ok) process.exit(2); + const dbPath = resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), canonical.home.path); + const tx = beginCodexCoordinatorRecoveryTransaction(dbPath, () => true); + const expectation = tx.expectation(); + const version = tx.version(); + tx.capability.beginTransition( + { nativeGeneration: expectation.nativeBefore, currentTxId: version.currentTxId }, + { txId: expectation.txId }, + ); + fs.unlinkSync(path.join(process.env.CODEX_HOME, "codexcommander-journal.json")); + process.exit(0); + `); + expect(crashed.status).toBe(0); + expect(existsSync(journalPath)).toBe(false); + + const recovered = runScript(testDir, ` + const { readCodexTransitionState } = require("./src/codex/transition-state"); + console.log(JSON.stringify(readCodexTransitionState())); + `); + expect(recovered.status).toBe(0); + expect(JSON.parse(recovered.stdout)).toEqual({ + kind: "ready", + state: { nativeGeneration: 0, currentTxId: null }, + }); + const db = new Database(path, { readonly: true }); + expect(db.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version).toBe(2); + db.close(); + }); + + test("global recovery N excludes different CodexCommander homes sharing one CODEX_HOME", () => { + const original = readFileSync(join(testDir, "config.toml"), "utf8"); + const injected = [ + 'model_provider = "codexcommander"', + "", + "# Auto-injected by CodexCommander", + "[model_providers.codexcommander]", + 'base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n"); + writeFileSync(join(testDir, "config.toml"), injected, "utf8"); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig: original, + injectedConfig: injected, + injectedProfile: null, + }); + const path = coordinatorPath(testDir); + const holder = new Database(path); + holder.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + try { + const blocked = runScript(testDir, ` + const { reconcileJournal } = require("./src/codex/journal"); + console.log(JSON.stringify(reconcileJournal())); + `, join(testDir, "ccx-state-b")); + expect(blocked.status).toBe(0); + expect(JSON.parse(blocked.stdout)).toBe(false); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(injected); + expect(existsSync(journalPath)).toBe(true); + + const initializer = runScript(testDir, ` + const { readCodexTransitionState } = require("./src/codex/transition-state"); + console.log(JSON.stringify(readCodexTransitionState())); + `, join(testDir, "ccx-state-c")); + expect(initializer.status).toBe(0); + expect(JSON.parse(initializer.stdout).kind).not.toBe("ready"); + } finally { + holder.exec("ROLLBACK"); + holder.close(); + } + + const recovered = runScript(testDir, ` + const { reconcileJournal } = require("./src/codex/journal"); + console.log(JSON.stringify(reconcileJournal())); + `, join(testDir, "ccx-state-d")); + expect(recovered.status).toBe(0); + expect(JSON.parse(recovered.stdout)).toBe(true); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(original); + expect(existsSync(journalPath)).toBe(false); + + const state = runScript(testDir, ` + const { readCodexTransitionState } = require("./src/codex/transition-state"); + console.log(JSON.stringify(readCodexTransitionState())); + `, join(testDir, "ccx-state-e")); + expect(JSON.parse(state.stdout)).toMatchObject({ + kind: "ready", + state: { nativeGeneration: 1 }, + }); + }); + + test("a paused authorized recovery keeps concurrent recovery and normal initialization excluded", () => { + const original = readFileSync(join(testDir, "config.toml"), "utf8"); + const injected = [ + 'model_provider = "codexcommander"', + "", + "# Auto-injected by CodexCommander", + "[model_providers.codexcommander]", + 'base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n"); + writeFileSync(join(testDir, "config.toml"), injected, "utf8"); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig: original, + injectedConfig: injected, + injectedProfile: null, + }); + const recoveryProbe = ` + const { reconcileJournal } = require("./src/codex/journal"); + console.log(JSON.stringify(reconcileJournal())); + `; + const initializerProbe = ` + const { readCodexTransitionState } = require("./src/codex/transition-state"); + console.log(JSON.stringify(readCodexTransitionState())); + `; + + const outer = runScript(testDir, ` + const { reconcileJournal } = require("./src/codex/journal"); + let concurrentRecovery; + let concurrentInitializer; + const reconciled = reconcileJournal({ beforeConfigMutationRevalidation: () => { + const recovery = Bun.spawnSync( + [process.execPath, "--eval", ${JSON.stringify(recoveryProbe)}], + { cwd: ${JSON.stringify(repoRoot)}, env: { + ...process.env, + CODEXCOMMANDER_HOME: process.env.CODEX_HOME + "/ccx-concurrent-recovery", + } }, + ); + concurrentRecovery = JSON.parse(new TextDecoder().decode(recovery.stdout).trim()); + const initializer = Bun.spawnSync( + [process.execPath, "--eval", ${JSON.stringify(initializerProbe)}], + { cwd: ${JSON.stringify(repoRoot)}, env: { + ...process.env, + CODEXCOMMANDER_HOME: process.env.CODEX_HOME + "/ccx-concurrent-initializer", + } }, + ); + concurrentInitializer = JSON.parse(new TextDecoder().decode(initializer.stdout).trim()); + } }); + console.log(JSON.stringify({ reconciled, concurrentRecovery, concurrentInitializer })); + `, join(testDir, "ccx-authorized-recovery")); + + expect(outer.status).toBe(0); + const result = JSON.parse(outer.stdout); + expect(result.reconciled).toBe(true); + expect(result.concurrentRecovery).toBe(false); + expect(result.concurrentInitializer.kind).not.toBe("ready"); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(original); + expect(existsSync(journalPath)).toBe(false); + }); + test("reconcileJournal skips when journaled PID is alive", () => { const journalPath = join(testDir, "codexcommander-journal.json"); const modified = "# modified by codexcommander\n"; @@ -121,19 +843,312 @@ describe("codex-journal", () => { expect(existsSync(journalPath)).toBe(true); }); - test("removeJournal cleans up", () => { - const journalPath = join(testDir, "codexcommander-journal.json"); - writeFileSync(journalPath, "{}", "utf8"); + test("external-provider retirement never unlinks a concurrently replaced journal", () => { + const externalConfig = 'model_provider = "custom"\nmodel = "third-party"\n'; + writeFileSync(join(testDir, "config.toml"), externalConfig, "utf8"); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig: 'model_provider = "openai"\n', + injectedConfig: "old injected config", + injectedProfile: null, + pid: process.pid, + }); + const replacementTimestamp = "2026-08-10T17:00:00.000Z"; + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { retireJournalForExternalProvider } = require("./src/codex/journal"); + const journalPath = path.join(process.env.CODEX_HOME, "codexcommander-journal.json"); + const replacementPath = path.join(process.env.CODEX_HOME, "replacement-journal.json"); + const replacement = { + version: 1, + originalConfig: Buffer.from('model_provider = "replacement"\\n').toString("base64"), + originalProfile: null, + injectedConfigHash: "replacement-config", + injectedProfileHash: null, + pid: process.pid, + timestamp: ${JSON.stringify(replacementTimestamp)}, + }; + const retired = retireJournalForExternalProvider("custom", { + beforeRetireRevalidation: () => { + fs.writeFileSync(replacementPath, JSON.stringify(replacement), "utf8"); + fs.renameSync(replacementPath, journalPath); + }, + }); + console.log(JSON.stringify({ retired, journal: JSON.parse(fs.readFileSync(journalPath, "utf8")) })); + `); + expect(r.status).toBe(0); + const result = JSON.parse(r.stdout); + expect(result.retired).toBe(false); + expect(result.journal.timestamp).toBe(replacementTimestamp); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(externalConfig); + expect(existsSync(journalPath)).toBe(true); + }); + + test("reconcileJournal retires a detached dead-owner journal after preserving clean current native state", () => { + const currentConfig = 'model = "gpt-5.5"\n'; + writeFileSync(join(testDir, "config.toml"), currentConfig, "utf8"); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig: 'model = "gpt-5.4"\n', + originalProfile: null, + injectedConfig: '# Auto-injected by CodexCommander\nopenai_base_url = "http://127.0.0.1:10100/v1"\n', + injectedProfile: '# CodexCommander proxy fallback config (Design B)\nopenai_base_url = "http://127.0.0.1:10100/v1"\n', + }); const r = runScript(testDir, ` - const { removeJournal } = require("./src/codex/journal"); - removeJournal(); - const fs = require("fs"); - const path = require("path"); - console.log(JSON.stringify({ exists: fs.existsSync(path.join(process.env.CODEX_HOME, "codexcommander-journal.json")) })); + const { reconcileJournal } = require("./src/codex/journal"); + console.log(JSON.stringify({ reconciled: reconcileJournal() })); + `); + + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toEqual({ reconciled: true }); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(currentConfig); + expect(existsSync(join(testDir, "codexcommander.config.toml"))).toBe(false); + expect(existsSync(journalPath)).toBe(false); + }); + + test("reconcileJournal retires the journal after restoring one unchanged postimage and preserving one clean divergent surface", () => { + const originalConfig = 'model = "gpt-5.5"\n'; + const injectedConfig = '# Auto-injected by CodexCommander\nopenai_base_url = "http://127.0.0.1:10100/v1"\n'; + writeFileSync(join(testDir, "config.toml"), injectedConfig, "utf8"); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig, + originalProfile: null, + injectedConfig, + injectedProfile: '# CodexCommander proxy fallback config (Design B)\nopenai_base_url = "http://127.0.0.1:10100/v1"\n', + }); + + const r = runScript(testDir, ` + const { reconcileJournal } = require("./src/codex/journal"); + console.log(JSON.stringify({ reconciled: reconcileJournal() })); `); + expect(r.status).toBe(0); - expect(JSON.parse(r.stdout).exists).toBe(false); + expect(JSON.parse(r.stdout)).toEqual({ reconciled: true }); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(originalConfig); + expect(existsSync(join(testDir, "codexcommander.config.toml"))).toBe(false); + expect(existsSync(journalPath)).toBe(false); + }); + + test("reconcileJournal never restores or retires a valid journal whose owner is alive", () => { + const injectedConfig = '# Auto-injected by CodexCommander\nopenai_base_url = "http://127.0.0.1:10100/v1"\n'; + writeFileSync(join(testDir, "config.toml"), injectedConfig, "utf8"); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig: 'model = "gpt-5.5"\n', + injectedConfig, + injectedProfile: null, + pid: process.pid, + }); + + const r = runScript(testDir, ` + const { reconcileJournal } = require("./src/codex/journal"); + console.log(JSON.stringify({ reconciled: reconcileJournal() })); + `); + + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toEqual({ reconciled: false }); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(injectedConfig); + expect(existsSync(journalPath)).toBe(true); + }); + + const detachedRetirementBlockers: Array<{ + name: string; + arrange: (codexHome: string) => void; + }> = [ + { + name: "routed config", + arrange: codexHome => writeFileSync(join(codexHome, "config.toml"), [ + "# Auto-injected by CodexCommander", + 'openai_base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n"), "utf8"), + }, + { + name: "generated profile", + arrange: codexHome => writeFileSync(join(codexHome, "codexcommander.config.toml"), [ + "# CodexCommander proxy fallback config (Design B)", + 'openai_base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n"), "utf8"), + }, + { + name: "routed catalog", + arrange: codexHome => writeFileSync(join(codexHome, "codexcommander-catalog.json"), JSON.stringify({ + models: [{ slug: "fixture/model", description: "Routed via CodexCommander → fixture." }], + }), "utf8"), + }, + { + name: "routed models cache", + arrange: codexHome => writeFileSync(join(codexHome, "models_cache.json"), JSON.stringify({ + models: [{ slug: "fixture/model", description: "Routed via CodexCommander → fixture." }], + }), "utf8"), + }, + { + name: "journal atomic-write temp", + arrange: codexHome => writeFileSync( + join(codexHome, "codexcommander-journal.json.ccx.42.7.tmp"), + "replacement in flight", + "utf8", + ), + }, + ]; + + for (const fixture of detachedRetirementBlockers) { + test(`reconcileJournal retains a detached journal when ${fixture.name} remains`, () => { + const currentConfig = 'model = "gpt-5.5"\n'; + writeFileSync(join(testDir, "config.toml"), currentConfig, "utf8"); + fixture.arrange(testDir); + const arrangedConfig = readFileSync(join(testDir, "config.toml"), "utf8"); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig: 'model = "gpt-5.4"\n', + injectedConfig: "different injected config", + injectedProfile: "different injected profile", + }); + + const r = runScript(testDir, ` + const { reconcileJournal } = require("./src/codex/journal"); + console.log(JSON.stringify({ reconciled: reconcileJournal() })); + `); + + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toEqual({ reconciled: false }); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(arrangedConfig); + expect(existsSync(journalPath)).toBe(true); + }); + } + + test("reconcileJournal retains a replacement installed after the clean-surface observation", () => { + const currentConfig = 'model = "gpt-5.5"\n'; + writeFileSync(join(testDir, "config.toml"), currentConfig, "utf8"); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig: 'model = "gpt-5.4"\n', + injectedConfig: "different injected config", + injectedProfile: "different injected profile", + }); + + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { reconcileJournal } = require("./src/codex/journal"); + const journalPath = path.join(process.env.CODEX_HOME, "codexcommander-journal.json"); + const replacementPath = path.join(process.env.CODEX_HOME, "replacement-journal.json"); + const replacement = { + version: 1, + originalConfig: Buffer.from('model = "replacement"\\n').toString("base64"), + originalProfile: null, + injectedConfigHash: "replacement-config-hash", + injectedProfileHash: "replacement-profile-hash", + pid: process.pid, + timestamp: "2026-08-10T12:00:00.000Z", + }; + const reconciled = reconcileJournal({ + beforeRetireRevalidation: () => { + fs.writeFileSync(replacementPath, JSON.stringify(replacement), "utf8"); + fs.renameSync(replacementPath, journalPath); + }, + }); + const current = JSON.parse(fs.readFileSync(journalPath, "utf8")); + console.log(JSON.stringify({ reconciled, pid: current.pid, timestamp: current.timestamp })); + `); + + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toEqual({ + reconciled: false, + pid: expect.any(Number), + timestamp: "2026-08-10T12:00:00.000Z", + }); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(currentConfig); + expect(existsSync(journalPath)).toBe(true); + }); + + test("reconcileJournal never restores or removes a replacement installed before recovery", () => { + const originalConfig = 'model = "gpt-5.5"\n'; + const injectedConfig = '# Auto-injected by CodexCommander\nopenai_base_url = "http://127.0.0.1:10100/v1"\n'; + writeFileSync(join(testDir, "config.toml"), injectedConfig, "utf8"); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig, + injectedConfig, + injectedProfile: null, + }); + + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { reconcileJournal } = require("./src/codex/journal"); + const journalPath = path.join(process.env.CODEX_HOME, "codexcommander-journal.json"); + const replacementPath = path.join(process.env.CODEX_HOME, "replacement-journal.json"); + const replacement = { + version: 1, + originalConfig: Buffer.from('model = "replacement"\\n').toString("base64"), + originalProfile: null, + injectedConfigHash: "replacement-config-hash", + injectedProfileHash: null, + pid: process.pid, + timestamp: "2026-08-10T13:00:00.000Z", + }; + const reconciled = reconcileJournal({ + beforeRecoveryRevalidation: () => { + fs.writeFileSync(replacementPath, JSON.stringify(replacement), "utf8"); + fs.renameSync(replacementPath, journalPath); + }, + }); + const current = JSON.parse(fs.readFileSync(journalPath, "utf8")); + console.log(JSON.stringify({ reconciled, pid: current.pid, timestamp: current.timestamp })); + `); + + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toEqual({ + reconciled: false, + pid: expect.any(Number), + timestamp: "2026-08-10T13:00:00.000Z", + }); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(injectedConfig); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).not.toBe(originalConfig); + expect(existsSync(journalPath)).toBe(true); + }); + + test("reconcileJournal retains a replacement installed after complete normal recovery", () => { + const originalConfig = 'model = "gpt-5.5"\n'; + const injectedConfig = '# Auto-injected by CodexCommander\nopenai_base_url = "http://127.0.0.1:10100/v1"\n'; + writeFileSync(join(testDir, "config.toml"), injectedConfig, "utf8"); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig, + injectedConfig, + injectedProfile: null, + }); + + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { reconcileJournal } = require("./src/codex/journal"); + const journalPath = path.join(process.env.CODEX_HOME, "codexcommander-journal.json"); + const replacementPath = path.join(process.env.CODEX_HOME, "replacement-journal.json"); + const replacement = { + version: 1, + originalConfig: Buffer.from('model = "replacement"\\n').toString("base64"), + originalProfile: null, + injectedConfigHash: "replacement-config-hash", + injectedProfileHash: null, + pid: process.pid, + timestamp: "2026-08-10T14:00:00.000Z", + }; + const reconciled = reconcileJournal({ + beforeRetireRevalidation: () => { + fs.writeFileSync(replacementPath, JSON.stringify(replacement), "utf8"); + fs.renameSync(replacementPath, journalPath); + }, + }); + const current = JSON.parse(fs.readFileSync(journalPath, "utf8")); + console.log(JSON.stringify({ reconciled, pid: current.pid, timestamp: current.timestamp })); + `); + + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toEqual({ + reconciled: false, + pid: expect.any(Number), + timestamp: "2026-08-10T14:00:00.000Z", + }); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(originalConfig); + expect(existsSync(journalPath)).toBe(true); }); test("removeCodexConfig is a successful no-op when Codex is not installed", () => { @@ -287,6 +1302,12 @@ describe("codex-journal", () => { "", ].join("\n"); writeFileSync(join(testDir, "config.toml"), originalConfig, "utf8"); + const initialized = runScript(testDir, ` + const { readCodexTransitionState } = require("./src/codex/transition-state"); + console.log(JSON.stringify(readCodexTransitionState())); + `); + expect(initialized.status).toBe(0); + expect(JSON.parse(initialized.stdout)).toMatchObject({ kind: "ready" }); writeFileSync(join(testDir, "codexcommander.config.toml"), originalProfile, "utf8"); const r = runScript(testDir, ` @@ -294,8 +1315,7 @@ describe("codex-journal", () => { const path = require("path"); const { writeJournal } = require("./src/codex/journal"); const { restoreNativeCodex } = require("./src/codex/inject"); - writeJournal(); - fs.writeFileSync(path.join(process.env.CODEX_HOME, "config.toml"), [ + const injectedConfig = [ 'model_provider = "codexcommander"', 'model = "opencode-go/glm-5.2"', '', @@ -303,8 +1323,11 @@ describe("codex-journal", () => { 'name = "CodexCommander Proxy"', 'base_url = "http://localhost:10100/v1"', '' - ].join("\\n"), "utf8"); - fs.writeFileSync(path.join(process.env.CODEX_HOME, "codexcommander.config.toml"), 'model_provider = "codexcommander"\\n', "utf8"); + ].join("\\n"); + const injectedProfile = 'model_provider = "codexcommander"\\n'; + writeJournal({ intendedPostimage: { config: injectedConfig, profile: injectedProfile } }); + fs.writeFileSync(path.join(process.env.CODEX_HOME, "config.toml"), injectedConfig, "utf8"); + fs.writeFileSync(path.join(process.env.CODEX_HOME, "codexcommander.config.toml"), injectedProfile, "utf8"); const result = restoreNativeCodex(); console.log(JSON.stringify({ success: result.success, message: result.message })); `); @@ -316,6 +1339,55 @@ describe("codex-journal", () => { expect(existsSync(join(testDir, "codexcommander-journal.json"))).toBe(false); }); + test("synchronous restore participates in global N across different CodexCommander homes", () => { + const original = readFileSync(join(testDir, "config.toml"), "utf8"); + const initialized = runScript(testDir, ` + const { readCodexTransitionState } = require("./src/codex/transition-state"); + console.log(JSON.stringify(readCodexTransitionState())); + `, join(testDir, "ccx-restore-init")); + expect(JSON.parse(initialized.stdout)).toMatchObject({ kind: "ready" }); + + const injected = [ + 'model_provider = "codexcommander"', + "", + "# Auto-injected by CodexCommander", + "[model_providers.codexcommander]", + 'base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n"); + writeFileSync(join(testDir, "config.toml"), injected, "utf8"); + const journalPath = writeRecoveryJournal(testDir, { + originalConfig: original, + injectedConfig: injected, + injectedProfile: null, + pid: process.pid, + }); + const holder = new Database(coordinatorPath(testDir)); + holder.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + try { + const blocked = runScript(testDir, ` + const { restoreNativeCodex } = require("./src/codex/inject"); + console.log(JSON.stringify(restoreNativeCodex())); + `, join(testDir, "ccx-restore-contender")); + expect(blocked.status).toBe(0); + expect(JSON.parse(blocked.stdout).success).toBe(false); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(injected); + expect(existsSync(journalPath)).toBe(true); + } finally { + holder.exec("ROLLBACK"); + holder.close(); + } + + const restored = runScript(testDir, ` + const { restoreNativeCodex } = require("./src/codex/inject"); + console.log(JSON.stringify(restoreNativeCodex())); + `, join(testDir, "ccx-restore-winner")); + expect(restored.status).toBe(0); + expect(JSON.parse(restored.stdout).success).toBe(true); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(original); + expect(existsSync(journalPath)).toBe(false); + }); + test("injectCodexConfig creates a restorable journal for direct sync/init paths", () => { const originalConfig = [ 'model = "openrouter/foo"', @@ -380,7 +1452,12 @@ describe("codex-journal", () => { test("full lifecycle: write → crash → reconcile restores", () => { const r = runScript(testDir, ` const { writeJournal } = require("./src/codex/journal"); - writeJournal(); + writeJournal({ + intendedPostimage: { + config: "# injected codexcommander config\\n", + profile: null, + }, + }); console.log("written"); `); expect(r.status).toBe(0); @@ -492,6 +1569,11 @@ describe("codex-journal", () => { 'base_url = "http://127.0.0.1:10100/v1"', "", ].join("\n"); + const initialized = runScript(testDir, ` + const { readCodexTransitionState } = require("./src/codex/transition-state"); + console.log(JSON.stringify(readCodexTransitionState())); + `); + expect(initialized.status).toBe(0); writeFileSync(join(testDir, "config.toml"), injected, "utf8"); const r = runScript(testDir, ` @@ -509,32 +1591,6 @@ describe("codex-journal", () => { expect(after).not.toContain("Auto-injected by CodexCommander"); }); - /** - * Documents why no PID-based transaction guard is needed. `ccx sync` and the - * `ccx ensure` parent legitimately inject in a process that did not write the - * journal, and the only journal a marking process ever meets is hashless — - * a refresh rebuilds the record, and a non-refresh means the previous - * transaction already completed. - */ - test("a hashless journal from another process can still be marked (#477)", () => { - runScript(testDir, `require("./src/codex/journal").writeJournal(); console.log("journaled");`); - const journalPath = join(testDir, "codexcommander-journal.json"); - const first = JSON.parse(readFileSync(journalPath, "utf8")); - expect(first.injectedConfigHash).toBeUndefined(); - - const r = runScript(testDir, ` - const { markJournalInjectedState } = require("./src/codex/journal"); - markJournalInjectedState("# injected\\n", null); - console.log(String(process.pid)); - `); - expect(r.status).toBe(0); - expect(Number(r.stdout)).not.toBe(first.pid); - - const second = JSON.parse(readFileSync(journalPath, "utf8")); - expect(second.pid).toBe(first.pid); // still the first process's record - expect(typeof second.injectedConfigHash).toBe("string"); // marked by the second - }); - test("writeJournal() with no options still snapshots a native config", () => { const r = runScript(testDir, `require("./src/codex/journal").writeJournal(); console.log("written");`); expect(r.status).toBe(0); diff --git a/tests/codex-management-convergence.test.ts b/tests/codex-management-convergence.test.ts index c4e9c6fd3b..0097b96a35 100644 --- a/tests/codex-management-convergence.test.ts +++ b/tests/codex-management-convergence.test.ts @@ -1,10 +1,19 @@ import { expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { createCatalogConvergeRequest } from "../src/codex/catalog-admission"; import { createManagementConvergeCodex, projectCatalogOnlyOutcome, } from "../src/codex/management-convergence"; +import { + codexCatalogWritePolicy, + nonDisruptiveCodexManagementWritePolicy, +} from "../src/codex/management-write-policy"; import type { CatalogDisposition } from "../src/codex/convergence-types"; import type { CodexCommanderConfig } from "../src/types"; @@ -23,7 +32,61 @@ function config(): CodexCommanderConfig { }; } -test("projects unavailable generation admission as retryable busy", async () => { +const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); + +function runUnownedRosterSave( + codexHome: string, + commanderHome: string, + mode: "off" | "native" | "external", +): { status: number; stdout: string; stderr: string } { + const script = ` + const { readFileSync } = require("node:fs"); + const { join } = require("node:path"); + const { getDefaultConfig, saveConfigPreservingClaudeCode } = require("./src/config"); + const { handleManagementAPI } = require("./src/server/management-api"); + const { ManagementRequest } = require("./tests/helpers/management-auth"); + const config = { + ...getDefaultConfig(), + ...(process.env.TEST_MODE === "off" ? { clientIntegrations: { codex: false } } : {}), + }; + saveConfigPreservingClaudeCode(config); + const catalogPath = join(process.env.CODEX_HOME, "codexcommander-catalog.json"); + const cachePath = join(process.env.CODEX_HOME, "models_cache.json"); + const before = { catalog: readFileSync(catalogPath, "utf8"), cache: readFileSync(cachePath, "utf8") }; + const req = new ManagementRequest("http://localhost/api/subagent-models", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ models: ["gpt-5.6-terra"] }), + }); + const response = await handleManagementAPI(req, new URL(req.url), config, { + syncClaudeAgentDefsBestEffort: () => {}, + collectCodexAppServerCatalogState: () => ({ state: "not_running", catalogMtimeMs: null, processes: [] }), + }); + console.log(JSON.stringify({ + status: response.status, + body: await response.json(), + before, + after: { catalog: readFileSync(catalogPath, "utf8"), cache: readFileSync(cachePath, "utf8") }, + })); + `; + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { + ...process.env, + CODEX_HOME: codexHome, + CODEXCOMMANDER_HOME: commanderHome, + TEST_MODE: mode, + }, + encoding: "utf8", + }); + return { + status: result.status ?? 1, + stdout: result.stdout?.trim() ?? "", + stderr: result.stderr?.trim() ?? "", + }; +} + +test("ownership refusal precedes catalog generation admission", async () => { const convergeCodex = createManagementConvergeCodex(config()); const outcome = await convergeCodex(createCatalogConvergeRequest({ deadlineMs: 1_000 })); @@ -31,7 +94,7 @@ test("projects unavailable generation admission as retryable busy", async () => expect(outcome).toEqual({ kind: "catalog-only", changed: false, - catalogRefresh: { status: "skipped", reason: "busy", retryable: true }, + catalogRefresh: { status: "skipped", reason: "refused", retryable: false }, observed: { aggregate: "not-evaluated", isApplied: null, @@ -93,6 +156,66 @@ test("constructs the fixed catalog request and ignores caller attempts to choose }); }); +test("automatic management catalog writes require enabled, already-owned routing", () => { + const automatic = createCatalogConvergeRequest({ deadlineMs: 1_000 }); + expect(codexCatalogWritePolicy(config(), automatic, "codexcommander-local")) + .toEqual({ allowed: true, requiresManagedRouting: true }); + expect(codexCatalogWritePolicy({ ...config(), clientIntegrations: { codex: false } }, automatic, "codexcommander-local")) + .toMatchObject({ allowed: false, reason: "integration-disabled", requiresManagedRouting: true }); + + for (const routingKind of ["native", "custom-local", "custom-remote", "unknown"] as const) { + expect(nonDisruptiveCodexManagementWritePolicy(config(), routingKind)) + .toEqual({ allowed: false, reason: "routing-not-owned", routingKind }); + expect(codexCatalogWritePolicy(config(), automatic, routingKind)) + .toMatchObject({ allowed: false, reason: "routing-not-owned", requiresManagedRouting: true }); + } +}); + +test("explicit full Apply retains authority to adopt native routing", () => { + const explicitApply = { + action: "converge", + scope: "full", + reason: "api-sync", + mode: "explicit", + deadlineMs: 1_000, + } as const; + expect(codexCatalogWritePolicy(config(), explicitApply, "native")) + .toEqual({ allowed: true, requiresManagedRouting: false }); +}); + +test.each([ + ["off", [ + "# Auto-injected by CodexCommander", + 'openai_base_url = "http://127.0.0.1:10100/v1"', + 'model_catalog_json = "codexcommander-catalog.json"', + "", + ].join("\n")], + ["native", 'model = "gpt-5.6-terra"\n'], + ["external", 'model_provider = "external"\n\n[model_providers.external]\nbase_url = "https://example.test/v1"\n'], +] as const)("%s roster Save leaves catalog and cache byte-for-byte unchanged", (mode, nativeConfig) => { + const codexHome = mkdtempSync(join(tmpdir(), `ccx-roster-${mode}-codex-`)); + const commanderHome = mkdtempSync(join(tmpdir(), `ccx-roster-${mode}-home-`)); + try { + writeFileSync(join(codexHome, "config.toml"), nativeConfig, "utf8"); + writeFileSync(join(codexHome, "codexcommander-catalog.json"), "catalog-sentinel\n", "utf8"); + writeFileSync(join(codexHome, "models_cache.json"), "cache-sentinel\n", "utf8"); + const result = runUnownedRosterSave(codexHome, commanderHome, mode); + expect(result.status, result.stderr).toBe(0); + const output = JSON.parse(result.stdout) as { + status: number; + body: { catalogRefresh: unknown }; + before: { catalog: string; cache: string }; + after: { catalog: string; cache: string }; + }; + expect(output.status).toBe(200); + expect(output.body.catalogRefresh).toEqual({ status: "skipped", reason: "refused", retryable: false }); + expect(output.after).toEqual(output.before); + } finally { + rmSync(codexHome, { recursive: true, force: true }); + rmSync(commanderHome, { recursive: true, force: true }); + } +}, 30_000); + test("rejects malformed catalog request deadlines", () => { for (const deadlineMs of [ 0, diff --git a/tests/codex-models-cache-invalidate.test.ts b/tests/codex-models-cache-invalidate.test.ts index 77cccc89ce..cb28cf9d6f 100644 --- a/tests/codex-models-cache-invalidate.test.ts +++ b/tests/codex-models-cache-invalidate.test.ts @@ -8,6 +8,7 @@ import { refreshCodexModelCatalog } from "../src/codex/refresh"; import { syncModelsToCodex } from "../src/codex/sync"; import type { CodexCommanderConfig } from "../src/types"; import { createCodexRuntimeFixture } from "./helpers/codex-runtime-fixture"; +import { saveConfig } from "../src/config"; setDefaultTimeout(30_000); @@ -69,12 +70,10 @@ describe("invalidateCodexModelsCache write gate (#476 / #518)", () => { expect(cache.models).toEqual([{ slug: "gpt-5.5" }]); }); - test("refuses the cache rewrite when desired state flipped OFF between commit and reacquisition", () => { - // The commit-path desired-state check runs under the FIRST catalog permit; - // refreshCodexModelCatalog then releases K before invalidateCodexModelsCache - // reacquires it. An OFF landing in that gap must gate this second write too — - // otherwise a routed models_cache survives a completed disable while the - // injector honestly reports status:"skipped". + test("refuses the explicit cache rewrite while desired state is OFF", () => { + // Cache-only sync remains an advanced explicit command, separate from + // canonical convergence. It must still honor durable OFF while holding its + // write permit or routed cache bytes could survive a completed disable. writeFileSync(join(codexHome, "codexcommander-catalog.json"), JSON.stringify({ models: [{ slug: "gpt-5.5" }], }, null, 2) + "\n"); @@ -159,6 +158,9 @@ describe("invalidateCodexModelsCache write gate (#476 / #518)", () => { // (A directory at the catalog path: existsSync true, load/write both fail.) writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "broken.json"\n', "utf8"); mkdirSync(join(codexHome, "broken.json")); + // Canonical production refresh binds the supplied decoded config to the + // persisted authority before inspecting catalog targets. + saveConfig(emptyConfig); const syncResult = await syncModelsToCodex(10100, emptyConfig, null, { prepareCodexTransitionState: () => ({ diff --git a/tests/codex-native-residue.test.ts b/tests/codex-native-residue.test.ts index 7e711b08f3..4e1f18c38e 100644 --- a/tests/codex-native-residue.test.ts +++ b/tests/codex-native-residue.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, setDefaultTimeout, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { chmodSync, @@ -17,16 +17,21 @@ import { basename, dirname, join, resolve } from "node:path"; import { buildCatalogEntries, readCodexCatalogPath, - syncCatalogModels, } from "../src/codex/catalog"; import { buildProfileFile } from "../src/codex/inject"; -import { classifyNativeRoutedResidue } from "../src/codex/native-residue"; +import { + classifyNativeRoutedResidue, + classifyNativeRoutedResidueWithoutJournal, +} from "../src/codex/native-residue"; import { readCodexTransitionState } from "../src/codex/transition-state"; import { resolveCodexCoordinatorDatabasePath, resolveEffectiveUserIdentity, } from "../src/codex/user-identity"; import type { CodexCommanderConfig } from "../src/types"; +import { convergeCatalogForTest } from "./helpers/catalog-convergence"; + +setDefaultTimeout(30_000); let codexHome = ""; let codexCommanderHome = ""; @@ -76,26 +81,6 @@ function routedCatalog(): string { return JSON.stringify({ models }, null, 2) + "\n"; } -function deterministicCatalogDeps() { - return { - // These tests exercise production catalog publication and residue - // classification. Keep the unrelated Codex bundled-model probe in-process - // so filesystem behavior is not coupled to subprocess scheduling. - commandCandidates: () => ["codex-fixture"], - execFileSync: () => JSON.stringify({ - models: [{ - slug: "gpt-5.5", - display_name: "gpt-5.5", - description: "native", - priority: 0, - visibility: "list", - base_instructions: "You are Codex, a coding agent based on GPT-5.", - supported_reasoning_levels: [{ effort: "medium", description: "m" }], - }], - }), - }; -} - const residueFixtures: Array<{ name: string; surface: string; @@ -149,8 +134,47 @@ for (const fixture of residueFixtures) { surface: fixture.surface, }); }); + + if (fixture.surface !== "journal") { + test(`${fixture.name} remains routed residue when only the journal is ignored`, () => { + fixture.arrange(); + expect(classifyNativeRoutedResidueWithoutJournal()).toMatchObject({ + kind: "residue", + surface: fixture.surface, + }); + }); + } } +test("the recovery observation ignores only a completed journal while the default remains fail-closed", () => { + residueFixtures.find(fixture => fixture.surface === "journal")!.arrange(); + + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "residue", + surface: "journal", + }); + expect(classifyNativeRoutedResidueWithoutJournal()).toEqual({ kind: "clean" }); + expect(readCodexTransitionState()).toEqual({ + kind: "state-ambiguous", + message: "A missing coordinator row cannot be initialized while native Codex routing residue exists.", + }); +}); + +test("the recovery observation still refuses a journal atomic-write replacement", () => { + residueFixtures.find(fixture => fixture.surface === "journal")!.arrange(); + const tempPath = pathInCodexHome("codexcommander-journal.json.ccx.42.7.tmp"); + writeFileSync(tempPath, "replacement in flight"); + + const classified = classifyNativeRoutedResidueWithoutJournal(); + expect(classified).toMatchObject({ + kind: "indeterminate", + surface: "partial-write", + reason: "CodexCommander atomic-write artifact is still present", + }); + expect(classified.kind === "indeterminate" ? basename(classified.path) : null) + .toBe(basename(tempPath)); +}); + test("an CodexCommander atomic-write artifact is indeterminate", () => { writeFileSync(pathInCodexHome("config.toml.ccx.123.1.tmp"), "partial"); expect(classifyNativeRoutedResidue()).toMatchObject({ @@ -166,6 +190,7 @@ test("a routed catalog at the configured nested path refuses coordinator initial writeFileSync(catalogPath, JSON.stringify({ models: [] })); const config: CodexCommanderConfig = { port: 10100, + multiAgentGuidanceEnabled: true, defaultProvider: "fixture", providers: { fixture: { @@ -177,7 +202,7 @@ test("a routed catalog at the configured nested path refuses coordinator initial }, }; - const sync = await syncCatalogModels(config, deterministicCatalogDeps()); + const sync = await convergeCatalogForTest(config); expect(sync).toMatchObject({ path: catalogPath, catalogWritten: true }); expect(classifyNativeRoutedResidue()).toMatchObject({ @@ -318,6 +343,7 @@ for (const shape of productionCatalogLeafShapes) { writeFileSync(catalogPath, JSON.stringify({ models: [] })); const config: CodexCommanderConfig = { port: 10100, + multiAgentGuidanceEnabled: true, defaultProvider: "fixture", providers: { fixture: { @@ -329,7 +355,7 @@ for (const shape of productionCatalogLeafShapes) { }, }; - const sync = await syncCatalogModels(config, deterministicCatalogDeps()); + const sync = await convergeCatalogForTest(config); const catalog = JSON.parse(readFileSync(catalogPath, "utf8")) as { models: Array>; }; @@ -502,6 +528,7 @@ test(`production-generated arbitrary bare combo alias ${arbitraryComboAlias} is writeFileSync(catalogPath, JSON.stringify({ models: [] })); const config: CodexCommanderConfig = { port: 10100, + multiAgentGuidanceEnabled: true, defaultProvider: "fixture", providers: { fixture: { @@ -521,7 +548,7 @@ test(`production-generated arbitrary bare combo alias ${arbitraryComboAlias} is }, }; - const sync = await syncCatalogModels(config, deterministicCatalogDeps()); + const sync = await convergeCatalogForTest(config); const catalog = JSON.parse(readFileSync(catalogPath, "utf8")) as { models: Array>; }; @@ -612,6 +639,16 @@ for (const fixture of indeterminateFixtures) { message: "A missing coordinator row cannot be initialized while native Codex routing residue exists.", }); }); + + if (fixture.surface !== "journal") { + test(`${fixture.name} remains indeterminate when only the journal is ignored`, () => { + fixture.arrange(); + expect(classifyNativeRoutedResidueWithoutJournal()).toMatchObject({ + kind: "indeterminate", + surface: fixture.surface, + }); + }); + } } const symlinkTest = process.platform === "win32" ? test.skip : test; diff --git a/tests/codex-refresh.test.ts b/tests/codex-refresh.test.ts index 31e6083e16..60f5f739d0 100644 --- a/tests/codex-refresh.test.ts +++ b/tests/codex-refresh.test.ts @@ -2,15 +2,13 @@ import { afterEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { invalidateCodexModelsCache, syncCatalogModels } from "../src/codex/catalog"; -import { refreshCodexModelCatalog } from "../src/codex/refresh"; +import { invalidateCodexModelsCache } from "../src/codex/catalog"; +import { getDefaultConfig } from "../src/config"; +import { refreshCodexModelCatalog, type RefreshDeps } from "../src/codex/refresh"; +import type { ConvergeRequest } from "../src/codex/convergence-types"; import type { CodexCommanderConfig } from "../src/types"; -const config = { - port: 10100, - defaultProvider: "openai", - providers: {}, -} as CodexCommanderConfig; +const config: CodexCommanderConfig = getDefaultConfig(); const tempHomes: string[] = []; @@ -58,92 +56,55 @@ afterEach(() => { }); describe("Codex catalog refresh", () => { - test("writes an expired Codex models cache whenever the materialized catalog exists", async () => { - let invalidated = 0; + test("projects the canonical convergence result and fixed explicit request", async () => { + let request: ConvergeRequest | undefined; const result = await refreshCodexModelCatalog(config, { - syncCatalogModels: async () => ({ - added: 0, - path: "/tmp/codexcommander-catalog.json", - catalogWritten: true, - comboOmissions: [], - }), - invalidateCodexModelsCache: () => { - invalidated += 1; - return true; + prepareConfigGeneration: () => {}, + captureCatalogAdmissionSnapshot: () => ({} as never), + convergeCodexCatalog: async (_snapshot, received) => { + request = received; + return { + changed: true, + catalogRefresh: { status: "committed", changed: true, degraded: false, notices: [] }, + projection: { + admittedGeneration: { value: 7 }, + admittedConfigAuthority: { + generation: { value: 7 }, + semanticIdentity: "semantic", + contentIdentity: "content", + }, + added: 2, + path: "/tmp/codexcommander-catalog.json", + catalogWritten: true, + cacheSynced: true, + comboOmissions: [], + catalogQuality: "live", + rehydrated: 0, + }, + }; }, existsSync: () => true, + } as RefreshDeps); + + expect(request).toEqual({ + action: "converge", + scope: "catalog", + reason: "api-sync", + mode: "explicit", + deadlineMs: 1_000, }); - - expect(result).toEqual({ - added: 0, + expect(result).toMatchObject({ + added: 2, path: "/tmp/codexcommander-catalog.json", catalogExists: true, catalogWritten: true, cacheSynced: true, - comboOmissions: [], - }); - expect(invalidated).toBe(1); - }); - - test("does not touch the cache when no Codex catalog can be materialized", async () => { - let invalidated = 0; - const result = await refreshCodexModelCatalog(config, { - syncCatalogModels: async () => ({ - added: 0, - path: "/tmp/missing-catalog.json", - catalogWritten: false, - comboOmissions: [], - }), - invalidateCodexModelsCache: () => { - invalidated += 1; - return true; - }, - existsSync: () => false, + catalogQuality: "live", + rehydrated: 0, }); - - expect(result.catalogExists).toBe(false); - expect(result.catalogWritten).toBe(false); - expect(result.cacheSynced).toBe(false); - expect(result.comboOmissions).toEqual([]); - expect(invalidated).toBe(0); - }); - - test("reports cacheSynced false when invalidate cannot write", async () => { - const result = await refreshCodexModelCatalog(config, { - syncCatalogModels: async () => ({ - added: 0, - path: "/tmp/codexcommander-catalog.json", - catalogWritten: true, - comboOmissions: [], - }), - invalidateCodexModelsCache: () => false, - existsSync: () => true, - }); - - expect(result.catalogExists).toBe(true); - expect(result.catalogWritten).toBe(true); - expect(result.cacheSynced).toBe(false); - expect(result.comboOmissions).toEqual([]); - }); - - test("preserves catalogWritten false when the catalog path exists but sync did not write", async () => { - const result = await refreshCodexModelCatalog(config, { - syncCatalogModels: async () => ({ - added: 0, - path: "/tmp/broken-catalog.json", - catalogWritten: false, - comboOmissions: [], - }), - invalidateCodexModelsCache: () => false, - existsSync: () => true, - }); - - expect(result.catalogExists).toBe(true); - expect(result.catalogWritten).toBe(false); - expect(result.cacheSynced).toBe(false); }); - test("reports catalogWritten true after syncCatalogModels rewrites a real catalog file", async () => { + test("reports catalogWritten true after canonical convergence rewrites a real catalog file", async () => { const home = installTempHomes(); try { const catalogPath = join(home.codexHome, "nested", "catalog.json"); @@ -152,10 +113,7 @@ describe("Codex catalog refresh", () => { writeFileSync(catalogPath, nativeCatalogFixture("gpt-5.6-sol"), "utf8"); const before = readFileSync(catalogPath, "utf8"); - const result = await syncCatalogModels(config, { - commandCandidates: () => ["codex-fixture"], - execFileSync: () => nativeCatalogFixture("gpt-5.5"), - }); + const result = await refreshCodexModelCatalog(config); const after = readFileSync(catalogPath, "utf8"); const rewritten = JSON.parse(after); diff --git a/tests/codex-retained-root-serialization.test.ts b/tests/codex-retained-root-serialization.test.ts index d15a7c3ad1..32524227d1 100644 --- a/tests/codex-retained-root-serialization.test.ts +++ b/tests/codex-retained-root-serialization.test.ts @@ -2,11 +2,13 @@ import { afterEach, expect, test } from "bun:test"; import { chmodSync, existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, + utimesSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -175,7 +177,12 @@ async function holdCatalogLock(sandbox: Sandbox): Promise<{ function seedCatalog(sandbox: Sandbox, bytes = catalogBytes()): string { const path = join(sandbox.codexHome, "catalog.json"); - writeFileSync(join(sandbox.codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n'); + writeFileSync(join(sandbox.codexHome, "config.toml"), [ + "# Auto-injected by CodexCommander", + 'openai_base_url = "http://127.0.0.1:10100/v1"', + 'model_catalog_json = "catalog.json"', + "", + ].join("\n")); writeFileSync(path, bytes); return path; } @@ -190,26 +197,78 @@ afterEach(async () => { } }); -test("startup and CLI sync-cache cannot write models_cache while another process owns K", async () => { +test("server listen preserves a foreign/native-owned models cache before canonical startup sync", async () => { const sandbox = makeSandbox("ccx-retained-cache-"); seedCatalog(sandbox); const cachePath = join(sandbox.codexHome, "models_cache.json"); + // This is a valid external Codex configuration, not a CodexCommander-owned + // routing surface. The old startServer-side invalidator ignored that + // ownership and rebuilt models_cache from catalog.json before the canonical + // startup sync had a chance to preserve the external route. + writeFileSync(join(sandbox.codexHome, "config.toml"), [ + 'model_provider = "external"', + 'model_catalog_json = "catalog.json"', + "", + "[model_providers.external]", + 'base_url = "https://external.example.test/v1"', + "", + ].join("\n")); + const nativeCache = '{"native_owned":true,"models":[{"slug":"native-only"}]}\n'; + writeFileSync(cachePath, nativeCache); + const sentinel = new Date("2001-02-03T04:05:06.000Z"); + utimesSync(cachePath, sentinel, sentinel); + const beforeMtime = lstatSync(cachePath, { bigint: true }).mtimeNs; + + const startupProbe = await runChild(sandbox, ` + const sentinel = new Error("TEST_LISTENER_INTERCEPTED"); + Bun.serve = () => { throw sentinel; }; + const { startServer } = await import("./src/server/index.ts"); + try { + startServer(0); + throw new Error("startServer unexpectedly reached a listener"); + } catch (error) { + if (error !== sentinel) throw error; + } + `); + expect(startupProbe.exitCode).toBe(0); + expect(readFileSync(cachePath, "utf8")).toBe(nativeCache); + expect(lstatSync(cachePath, { bigint: true }).mtimeNs).toBe(beforeMtime); +}); + +test("server listen preserves an unchanged managed cache mtime and only explicit sync-cache takes K", async () => { + const sandbox = makeSandbox("ccx-retained-cache-noop-"); + const catalogPath = seedCatalog(sandbox); + const models = (JSON.parse(readFileSync(catalogPath, "utf8")) as { models: unknown[] }).models; + const cachePath = join(sandbox.codexHome, "models_cache.json"); + const cacheBytes = `${JSON.stringify({ + fetched_at: "2000-01-01T00:00:00Z", + client_version: "0.0.0", + models, + }, null, 2)}\n`; + writeFileSync(cachePath, cacheBytes); + const sentinel = new Date("2001-02-03T04:05:06.000Z"); + utimesSync(cachePath, sentinel, sentinel); + const beforeMtime = lstatSync(cachePath, { bigint: true }).mtimeNs; + + const startupProbe = await runChild(sandbox, ` + const sentinel = new Error("TEST_LISTENER_INTERCEPTED"); + Bun.serve = () => { throw sentinel; }; + const { startServer } = await import("./src/server/index.ts"); + try { + startServer(0); + throw new Error("startServer unexpectedly reached a listener"); + } catch (error) { + if (error !== sentinel) throw error; + } + `); + expect(startupProbe.exitCode).toBe(0); + expect(readFileSync(cachePath, "utf8")).toBe(cacheBytes); + // Worker freshness uses artifact mtimes as its evidence boundary. Preserving + // this mtime proves a listener-only start cannot manufacture worker staleness. + expect(lstatSync(cachePath, { bigint: true }).mtimeNs).toBe(beforeMtime); + const holder = await holdCatalogLock(sandbox); try { - const startupProbe = await runChild(sandbox, ` - const sentinel = new Error("TEST_LISTENER_INTERCEPTED"); - Bun.serve = () => { throw sentinel; }; - const { startServer } = await import("./src/server/index.ts"); - try { - startServer(0); - throw new Error("startServer unexpectedly reached a listener"); - } catch (error) { - if (error !== sentinel) throw error; - } - `); - expect(startupProbe.exitCode).toBe(0); - expect(existsSync(cachePath)).toBe(false); - const cli = Bun.spawnSync([process.execPath, "run", "src/cli/index.ts", "sync-cache"], { cwd: repoRoot, env: sandbox.env, @@ -217,7 +276,8 @@ test("startup and CLI sync-cache cannot write models_cache while another process stderr: "pipe", }); expect(cli.exitCode).toBe(0); - expect(existsSync(cachePath)).toBe(false); + expect(readFileSync(cachePath, "utf8")).toBe(cacheBytes); + expect(lstatSync(cachePath, { bigint: true }).mtimeNs).toBe(beforeMtime); const cliSource = readFileSync(join(repoRoot, "src/cli/index.ts"), "utf8"); const cliStart = cliSource.indexOf('case "sync-cache"'); const cliRoot = cliSource.slice(cliStart, cliSource.indexOf('case "gui"', cliStart)); @@ -225,10 +285,14 @@ test("startup and CLI sync-cache cannot write models_cache while another process expect(cliRoot).toContain("invalidateCodexModelsCacheWithPermit"); const startup = readFileSync(join(repoRoot, "src/server/index.ts"), "utf8"); - const startupStart = startup.indexOf("const startupCodexHome"); - const startupRoot = startup.slice(startupStart, startup.indexOf("armClaudeCodeBaseline", startupStart)); - expect(startupRoot).toContain("withCatalogWriteSerialization(startupCodexHome"); - expect(startupRoot).toContain("invalidateCodexModelsCacheWithPermit"); + expect(startup).not.toContain("invalidateCodexModelsCacheWithPermit"); + expect(startup).not.toContain("consumeStartupCacheInvalidationWrite"); + const startupSync = cliSource.slice( + cliSource.indexOf("const startupSync = await syncCodexOnStartIfEnabled"), + cliSource.indexOf("// Build Desktop 3P alias registry"), + ); + expect(startupSync).toContain("if (startupSync.catalogWritten || startupSync.cacheSynced)"); + expect(startupSync).not.toContain("consumeStartupCacheInvalidationWrite"); } finally { holder.release(); expect(await holder.child.exited).toBe(0); @@ -260,60 +324,24 @@ test("native restore cannot read-transform-write the catalog while another proce async function runPublisher( sandbox: Sandbox, - kind: "convergence" | "retained", config: Record, ): Promise<{ exitCode: number; stdout: string; stderr: string }> { - if (kind === "retained") { - return runChild(sandbox, ` - const config = ${JSON.stringify(config)}; - // The first /api/sync request already proves the HTTP boundary and remains - // suspended in the parent-owned provider. This competing writer needs to - // exercise the retained catalog commit, not make a second request to the - // same deliberately held test server. Give it an in-process provider seam - // so the race is controlled only by K and filesystem evidence. - config.providers.fixture.fetch = async () => Response.json({ - data: [{ id: "publisher-model" }], - }); - const { syncCatalogModels } = await import("./src/codex/catalog/sync.ts"); - const result = await syncCatalogModels(config, { - // This race covers retained catalog serialization, not Codex binary - // discovery. Keep the bundled native template in-process so subprocess - // scheduling cannot consume the fixed race deadline. - commandCandidates: () => ["codex-fixture"], - execFileSync: () => JSON.stringify({ models: [{ - slug: "gpt-5.5", - display_name: "gpt-5.5", - description: "native", - priority: 0, - visibility: "list", - supported_in_api: true, - shell_type: "shell_command", - base_instructions: "You are Codex, a coding agent based on GPT-5.", - supported_reasoning_levels: [{ effort: "medium", description: "medium" }], - }] }), - }); - // Provider discovery owns bounded cache timers that are irrelevant after - // the committed bytes and result are available. Flush the diagnostic frame - // before exiting so this serialization test never waits for those timers. - await Bun.write(Bun.stdout, JSON.stringify(result) + "\\n"); - process.exit(0); - `); - } return runChild(sandbox, ` const { withConfigMutationLockSync } = await import("./src/config.ts"); - const { captureCatalogAdmissionSnapshot, createCatalogConvergeRequest } = await import("./src/codex/catalog-admission.ts"); + const { captureCatalogAdmissionSnapshot } = await import("./src/codex/catalog-admission.ts"); const { convergeCodexCatalog } = await import("./src/codex/convergence.ts"); const config = ${JSON.stringify(config)}; withConfigMutationLockSync(() => undefined); const snapshot = captureCatalogAdmissionSnapshot(config); - const result = await convergeCodexCatalog(snapshot, createCatalogConvergeRequest({ deadlineMs: 2000 })); + const result = await convergeCodexCatalog(snapshot, { + action: "converge", scope: "catalog", reason: "api-sync", mode: "explicit", deadlineMs: 2000, + }); console.log(JSON.stringify(result)); `); } -for (const publisher of ["convergence", "retained"] as const) { - test(`POST /api/sync gathered first and acquired K second does not clobber a newer ${publisher} catalog`, async () => { - const sandbox = makeSandbox(`ccx-retained-race-${publisher}-`); +test("catalog convergence gathered first and acquired K second does not clobber a newer converged catalog", async () => { + const sandbox = makeSandbox("ccx-retained-race-convergence-"); const catalogPath = seedCatalog(sandbox); const initial = readFileSync(catalogPath, "utf8"); const requested = join(sandbox.root, "provider-requested"); @@ -350,30 +378,14 @@ for (const publisher of ["convergence", "retained"] as const) { try { const sync = trackChild(Bun.spawn([process.execPath, "--eval", ` const config = ${JSON.stringify(config)}; - // Exercise the production route that owns /api/sync without cold-loading - // every unrelated management route into this deadline-bound child. - const { handleConfigRoutes } = await import("./src/server/management/config-routes.ts"); - const req = new Request("http://localhost/api/sync", { method: "POST", headers: { Host: "localhost" } }); - const response = await handleConfigRoutes({ - req, - url: new URL(req.url), - config, - deps: { - // Catalog/process staleness is covered in its own unit suite. Keep - // this filesystem race independent from the host's real Codex - // workers and bounded ps/launchctl probes. - resetCodexAppServerCatalogStateCache: () => {}, - collectCodexAppServerCatalogState: () => ({ - state: "not_running", - processes: [], - catalogMtimeMs: null, - }), - }, - convergeCodexCatalog: async () => { throw new Error("unexpected convergence route"); }, - syncClaudeAgentDefsBestEffort: async () => {}, + const { withConfigMutationLockSync } = await import("./src/config.ts"); + const { captureCatalogAdmissionSnapshot } = await import("./src/codex/catalog-admission.ts"); + const { convergeCodexCatalog } = await import("./src/codex/convergence.ts"); + withConfigMutationLockSync(() => undefined); + const result = await convergeCodexCatalog(captureCatalogAdmissionSnapshot(config), { + action: "converge", scope: "catalog", reason: "api-sync", mode: "explicit", deadlineMs: 2000, }); - if (!response) throw new Error("/api/sync was not handled"); - console.log(JSON.stringify({ status: response.status, body: await response.json() })); + console.log(JSON.stringify(result)); `], { cwd: repoRoot, env: sandbox.env, stdout: "pipe", stderr: "pipe" })); const stdoutText = new Response(sync.stdout).text(); const stderrText = new Response(sync.stderr).text(); @@ -385,9 +397,9 @@ for (const publisher of ["convergence", "retained"] as const) { throw new Error(`sync exited before provider barrier (${exitCode})\nstdout=${stdout}\nstderr=${stderr}`); }), ]); - const published = await runPublisher(sandbox, publisher, config); + const published = await runPublisher(sandbox, config); if (published.exitCode !== 0) { - throw new Error(`${publisher} publisher failed\nstdout=${published.stdout}\nstderr=${published.stderr}`); + throw new Error(`convergence publisher failed\nstdout=${published.stdout}\nstderr=${published.stderr}`); } const newer = readFileSync(catalogPath, "utf8"); expect(newer).not.toBe(initial); @@ -403,92 +415,6 @@ for (const publisher of ["convergence", "retained"] as const) { } finally { provider.stop(true); } - }, 20_000); -} - -/** - * Runtime authority can move without touching the catalog at all. - * - * The verifier's R1→R2 case: a retained sync prepares from one Codex runtime, - * and while it is awaiting its provider another process rewrites the persisted - * runtime selection. Every catalog byte is untouched, so a freshness check built - * only from catalog/backup/cache bytes sees nothing and commits a candidate that - * was derived under a runtime that is no longer selected. - * - * `codex-runtime.json` is therefore part of the pre-await filesystem evidence, - * PRESENT or ABSENT. Removing it from `retainedCatalogSyncEvidence` turns this - * test red while every other retained-root test stays green — which is exactly - * why it exists: nothing else in the suite covered that component. - */ -test("a persisted runtime selection moved by another process during the await blocks the write", async () => { - const sandbox = makeSandbox("ccx-retained-runtime-move-"); - const catalogPath = seedCatalog(sandbox); - const initial = readFileSync(catalogPath, "utf8"); - const requested = join(sandbox.root, "provider-requested"); - const release = join(sandbox.root, "provider-release"); - const runtimeStatePath = join(sandbox.codexCommanderHome, "codex-runtime.json"); - writeFileSync(runtimeStatePath, `${JSON.stringify({ - version: 1, - command: "/usr/local/bin/codex-r1", - source: "configured", - selectedVersion: "1.0.0", - updatedAt: new Date(0).toISOString(), - }, null, 2)}\n`); - - const config = { - port: 10100, - multiAgentGuidanceEnabled: true, - defaultProvider: "together", - providers: { - together: { - adapter: "openai-chat", - baseUrl: "https://api.together.xyz/v1", - apiKey: "runtime-move-key", - models: ["fallback-model"], - }, - }, - }; - - const sync = trackChild(Bun.spawn([process.execPath, "--eval", ` - import { existsSync, writeFileSync } from "node:fs"; - const config = ${JSON.stringify(config)}; - config.providers.together.fetch = async () => { - writeFileSync(${JSON.stringify(requested)}, "requested"); - while (!existsSync(${JSON.stringify(release)})) await Bun.sleep(5); - return Response.json({ data: [{ id: "runtime-move-model" }] }); - }; - const { syncCatalogModels } = await import("./src/codex/catalog/sync.ts"); - console.log(JSON.stringify(await syncCatalogModels(config))); - `], { cwd: repoRoot, env: sandbox.env, stdout: "pipe", stderr: "pipe" })); - const stdoutText = new Response(sync.stdout).text(); - const stderrText = new Response(sync.stderr).text(); - - await Promise.race([ - waitForPath(requested), - sync.exited.then(async exitCode => { - const [stdout, stderr] = await Promise.all([stdoutText, stderrText]); - throw new Error(`sync exited before provider barrier (${exitCode})\nstdout=${stdout}\nstderr=${stderr}`); - }), - ]); - - // Another process selects a different Codex runtime. No catalog byte changes. - writeFileSync(runtimeStatePath, `${JSON.stringify({ - version: 1, - command: "/usr/local/bin/codex-r2", - source: "configured", - selectedVersion: "2.0.0", - updatedAt: new Date(1).toISOString(), - }, null, 2)}\n`); - - writeFileSync(release, "release"); - const [exitCode, stdout, stderr] = await Promise.all([ - sync.exited, - stdoutText, - stderrText, - ]); - expect({ exitCode, stderr }).toMatchObject({ exitCode: 0 }); - expect(JSON.parse(stdout.trim())).toMatchObject({ catalogWritten: false }); - expect(readFileSync(catalogPath, "utf8")).toBe(initial); }, 20_000); /** diff --git a/tests/codex-sync-api.test.ts b/tests/codex-sync-api.test.ts index c114a60653..a6b0dfe188 100644 --- a/tests/codex-sync-api.test.ts +++ b/tests/codex-sync-api.test.ts @@ -4,6 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, import { tmpdir } from "node:os"; import { join } from "node:path"; import { syncModelsToCodex } from "../src/codex/sync"; +import { handleManagementAPI } from "../src/server/management-api"; import { resolveCodexCoordinatorDatabasePath, resolveEffectiveUserIdentity } from "../src/codex/user-identity"; import { MANAGED_AGENTS_TABLE_MARKER, MANAGED_SUBAGENT_DEFAULT_MARKER } from "../src/codex/subagent-defaults"; import type { CodexCommanderConfig } from "../src/types"; @@ -116,6 +117,10 @@ describe("GUI/CLI Codex sync backend", () => { const errors: string[] = []; const result = await syncModelsToCodex(12345, config, { log: line => logs.push(String(line)), error: line => errors.push(String(line)) }, { admitCodexWrite: admittedSync, + reconcileJournal: () => { + order.push("journal"); + return false; + }, prepareCodexTransitionState: () => { order.push("coordinator"); return { kind: "ready", state: { nativeGeneration: 0, currentTxId: null } }; @@ -143,7 +148,7 @@ describe("GUI/CLI Codex sync backend", () => { expect(injectedPort).toBe(12345); expect(injectedCatalogPath).toBe("/tmp/codexcommander-catalog.json"); - expect(order).toEqual(["coordinator", "catalog", "inject"]); + expect(order).toEqual(["journal", "coordinator", "catalog", "inject"]); expect(result).toEqual({ status: "applied", ok: true, @@ -253,6 +258,50 @@ describe("GUI/CLI Codex sync backend", () => { expect(injected).toBe(false); }); + test("an OFF race after catalog commit preserves truthful artifact receipts", async () => { + const result = await syncModelsToCodex(12345, config, null, { + admitCodexWrite: admittedSync, + prepareCodexTransitionState: preparedSync, + refreshCodexModelCatalog: async () => ({ + added: 2, + path: "/tmp/codexcommander-catalog.json", + catalogExists: true, + catalogWritten: true, + cacheSynced: true, + comboOmissions: [], + catalogQuality: "live" as const, + rehydrated: 0, + catalogDisposition: { + status: "committed" as const, + changed: true, + degraded: false, + notices: [], + }, + }), + injectCodexConfig: async () => ({ + success: true, + status: "skipped", + skippedReason: "desired_disabled", + message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.", + }), + currentExternalCodexModelProvider: () => null, + }); + + expect(result).toMatchObject({ + status: "skipped", + skippedReason: "desired_disabled", + ok: true, + added: 2, + catalogPath: "/tmp/codexcommander-catalog.json", + catalogExists: true, + catalogWritten: true, + cacheSynced: true, + catalogQuality: "live", + rehydrated: 0, + }); + expect(result.message).toContain("catalog changes from this sync were published"); + }); + /** * The lost-transition race, with a REAL second process. The caller's config * snapshot says ON; while provider discovery is awaited, another process @@ -527,6 +576,256 @@ describe("GUI/CLI Codex sync backend", () => { expect(payload.body.error).toContain(join(TEST_CODEX_HOME, "config.toml")); }); + test("POST /api/sync promotes failed readiness only after a clean full sync", async () => { + let recovered = 0; + const syncResult = { + status: "applied" as const, + ok: true, + added: 0, + catalogPath: null, + catalogExists: true, + catalogWritten: false, + cacheSynced: false, + catalogQuality: "live" as const, + rehydrated: 0, + message: "synced", + }; + const request = new Request("http://localhost/api/sync", { method: "POST" }); + const response = await handleManagementAPI(request, new URL(request.url), config, { + syncModelsToCodex: async () => syncResult, + readRuntimePort: () => ({ pid: process.pid, port: 10100, hostname: "127.0.0.1", startedAt: new Date().toISOString() }), + resetCodexAppServerCatalogStateCache: () => {}, + collectCodexAppServerCatalogState: () => ({ state: "not_running", processes: [], catalogMtimeMs: null }), + captureCatalogDesiredSnapshotForActivation: () => ({ + config, + authority: { + generation: { value: 1 }, + semanticIdentity: "semantic", + contentIdentity: "content", + referenceIdentity: "reference", + }, + revision: "revision", + }) as never, + catalogArtifactProofForActivation: () => "current", + codexRoutingKindForActivation: () => "codexcommander-local", + readinessGate: { recoverReady: () => { recovered += 1; } }, + }); + + expect(response?.status).toBe(200); + expect(recovered).toBe(1); + + const degradedRequest = new Request("http://localhost/api/sync", { method: "POST" }); + await handleManagementAPI(degradedRequest, new URL(degradedRequest.url), config, { + syncModelsToCodex: async () => ({ ...syncResult, warning: "provider discovery incomplete" }), + readRuntimePort: () => null, + resetCodexAppServerCatalogStateCache: () => {}, + collectCodexAppServerCatalogState: () => ({ state: "not_running", processes: [], catalogMtimeMs: null }), + captureCatalogDesiredSnapshotForActivation: () => ({ + config, + authority: { + generation: { value: 1 }, + semanticIdentity: "semantic", + contentIdentity: "content", + referenceIdentity: "reference", + }, + revision: "revision", + }) as never, + catalogArtifactProofForActivation: () => "current", + codexRoutingKindForActivation: () => "codexcommander-local", + readinessGate: { recoverReady: () => { recovered += 1; } }, + }); + expect(recovered).toBe(1); + }); + + test("POST /api/sync never promotes from a torn desired, artifact, or routing observation", async () => { + const syncResult = { + status: "applied" as const, + ok: true, + added: 0, + catalogPath: null, + catalogExists: true, + catalogWritten: false, + cacheSynced: false, + catalogQuality: "live" as const, + rehydrated: 0, + message: "synced", + }; + const snapshot = (revision: string) => ({ + config, + authority: { + generation: { value: 1 }, + semanticIdentity: "semantic", + contentIdentity: "content", + referenceIdentity: "reference", + }, + revision, + }) as never; + const dispatch = async (overrides: { + capture: () => never; + artifact: () => "current" | "drifted"; + routing: () => "codexcommander-local" | "native"; + recover: () => void; + }) => { + const request = new Request("http://localhost/api/sync", { method: "POST" }); + return handleManagementAPI(request, new URL(request.url), config, { + syncModelsToCodex: async () => syncResult, + readRuntimePort: () => null, + resetCodexAppServerCatalogStateCache: () => {}, + collectCodexAppServerCatalogState: () => ({ state: "not_running", processes: [], catalogMtimeMs: null }), + captureCatalogDesiredSnapshotForActivation: overrides.capture, + catalogArtifactProofForActivation: overrides.artifact, + codexRoutingKindForActivation: overrides.routing, + readinessGate: { recoverReady: overrides.recover }, + }); + }; + + let recovered = 0; + let desiredReads = 0; + await dispatch({ + capture: () => snapshot(++desiredReads === 1 ? "revision-a" : "revision-b"), + artifact: () => "current", + routing: () => "codexcommander-local", + recover: () => { recovered += 1; }, + }); + expect(recovered).toBe(0); + + let artifactReads = 0; + await dispatch({ + capture: () => snapshot("revision-a"), + artifact: () => ++artifactReads === 1 ? "current" : "drifted", + routing: () => "codexcommander-local", + recover: () => { recovered += 1; }, + }); + expect(recovered).toBe(0); + + let routingReads = 0; + await dispatch({ + capture: () => snapshot("revision-a"), + artifact: () => "current", + routing: () => ++routingReads === 1 ? "codexcommander-local" : "native", + recover: () => { recovered += 1; }, + }); + expect(recovered).toBe(0); + }); + + test("POST /api/sync recovery accepts only coherent intentional skip states", async () => { + const snapshot = (desiredConfig: CodexCommanderConfig) => ({ + config: desiredConfig, + authority: { + generation: { value: 1 }, + semanticIdentity: "semantic", + contentIdentity: "content", + referenceIdentity: "reference", + }, + revision: "stable-revision", + }) as never; + const disabledConfig = { + ...config, + clientIntegrations: { ...config.clientIntegrations, codex: false }, + } as CodexCommanderConfig; + let recovered = 0; + const disabledRequest = new Request("http://localhost/api/sync", { method: "POST" }); + await handleManagementAPI(disabledRequest, new URL(disabledRequest.url), config, { + syncModelsToCodex: async () => ({ + status: "skipped", + skippedReason: "desired_disabled", + ok: true, + added: 0, + catalogPath: null, + catalogExists: false, + catalogWritten: false, + cacheSynced: false, + catalogQuality: "native-only", + rehydrated: 0, + message: "disabled", + }), + readRuntimePort: () => null, + resetCodexAppServerCatalogStateCache: () => {}, + collectCodexAppServerCatalogState: () => ({ state: "not_running", processes: [], catalogMtimeMs: null }), + captureCatalogDesiredSnapshotForActivation: () => snapshot(disabledConfig), + // No Commander-owned artifact is expected while integration is OFF. + catalogArtifactProofForActivation: () => "unproven", + codexRoutingKindForActivation: () => "native", + readinessGate: { recoverReady: () => { recovered += 1; } }, + }); + expect(recovered).toBe(1); + + const staleCommanderRouteRequest = new Request("http://localhost/api/sync", { method: "POST" }); + await handleManagementAPI(staleCommanderRouteRequest, new URL(staleCommanderRouteRequest.url), config, { + syncModelsToCodex: async () => ({ + status: "skipped", + skippedReason: "desired_disabled", + ok: true, + added: 0, + catalogPath: null, + catalogExists: false, + catalogWritten: false, + cacheSynced: false, + catalogQuality: "native-only", + rehydrated: 0, + message: "disabled but stale route remains", + }), + readRuntimePort: () => null, + resetCodexAppServerCatalogStateCache: () => {}, + collectCodexAppServerCatalogState: () => ({ state: "not_running", processes: [], catalogMtimeMs: null }), + captureCatalogDesiredSnapshotForActivation: () => snapshot(disabledConfig), + catalogArtifactProofForActivation: () => "unproven", + codexRoutingKindForActivation: () => "codexcommander-local", + readinessGate: { recoverReady: () => { recovered += 1; } }, + }); + expect(recovered).toBe(1); + + const externalRequest = new Request("http://localhost/api/sync", { method: "POST" }); + await handleManagementAPI(externalRequest, new URL(externalRequest.url), config, { + syncModelsToCodex: async () => ({ + status: "skipped", + skippedReason: "external_provider", + ok: true, + added: 0, + catalogPath: null, + catalogExists: false, + catalogWritten: false, + cacheSynced: false, + catalogQuality: "native-only", + rehydrated: 0, + message: "external preserved", + }), + readRuntimePort: () => null, + resetCodexAppServerCatalogStateCache: () => {}, + collectCodexAppServerCatalogState: () => ({ state: "not_running", processes: [], catalogMtimeMs: null }), + captureCatalogDesiredSnapshotForActivation: () => snapshot(config), + catalogArtifactProofForActivation: () => "unproven", + codexRoutingKindForActivation: () => "custom-remote", + readinessGate: { recoverReady: () => { recovered += 1; } }, + }); + expect(recovered).toBe(2); + + const incoherentRequest = new Request("http://localhost/api/sync", { method: "POST" }); + await handleManagementAPI(incoherentRequest, new URL(incoherentRequest.url), config, { + syncModelsToCodex: async () => ({ + status: "skipped", + skippedReason: "external_provider", + ok: true, + added: 0, + catalogPath: null, + catalogExists: false, + catalogWritten: false, + cacheSynced: false, + catalogQuality: "native-only", + rehydrated: 0, + message: "incoherent", + }), + readRuntimePort: () => null, + resetCodexAppServerCatalogStateCache: () => {}, + collectCodexAppServerCatalogState: () => ({ state: "not_running", processes: [], catalogMtimeMs: null }), + captureCatalogDesiredSnapshotForActivation: () => snapshot(disabledConfig), + catalogArtifactProofForActivation: () => "unproven", + codexRoutingKindForActivation: () => "custom-remote", + readinessGate: { recoverReady: () => { recovered += 1; } }, + }); + expect(recovered).toBe(2); + }); + test("skips catalog refresh before preserving an external provider", async () => { let refreshed = false; let injectedCatalogPath: string | null | undefined = "unset"; diff --git a/tests/codex-sync-response.test.ts b/tests/codex-sync-response.test.ts deleted file mode 100644 index 76f9aa7783..0000000000 --- a/tests/codex-sync-response.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { - CatalogDisposition, - CodexObservedState, - ConvergeOutcome, -} from "../src/codex/convergence-types"; -import { toSyncResponse } from "../src/server/management/sync-response"; - -const catalogRefresh: CatalogDisposition = { - status: "committed", - changed: true, - degraded: false, - notices: [], -}; - -const observed: CodexObservedState = { - aggregate: "applied", - isApplied: true, - desired: "on", - converged: true, - authority: { service: "owned", externalProvider: null }, - surfaces: { - config: "applied", - profile: "applied", - catalog: "applied", - cache: "applied", - journal: "absent", - provenance: { - state: "verified", - nativeGeneration: 4, - currentTxId: "tx-1", - }, - }, -}; - -async function projection(outcome: ConvergeOutcome): Promise<{ - status: number; - body: unknown; - retryAfter: string | null; -}> { - const response = toSyncResponse(outcome); - return { - status: response.status, - body: await response.json(), - retryAfter: response.headers.get("Retry-After"), - }; -} - -describe("toSyncResponse", () => { - test("maps catalog-only", async () => { - expect(await projection({ - kind: "catalog-only", - changed: true, - observed, - catalogRefresh, - })).toEqual({ - status: 200, - body: { ok: true, changed: true, observed, catalogRefresh }, - retryAfter: null, - }); - }); - - test("maps converged, including desired OFF removal, without a separate desired-off row", async () => { - expect(await projection({ - kind: "converged", - direction: "removed", - changed: true, - observed, - nativeGeneration: 4, - currentTxId: "tx-1", - catalogRefresh, - })).toEqual({ - status: 200, - body: { ok: true, changed: true, observed, catalogRefresh }, - retryAfter: null, - }); - }); - - test("maps skipped", async () => { - expect(await projection({ - kind: "skipped", - reason: "already-converged", - observed, - catalogRefresh, - })).toEqual({ - status: 200, - body: { ok: true, changed: false, observed, catalogRefresh }, - retryAfter: null, - }); - }); - - test("maps refused", async () => { - expect(await projection({ - kind: "refused", - authority: "provenance", - message: "provenance does not authorize restore", - observed, - })).toEqual({ - status: 409, - body: { - ok: false, - authority: "provenance", - message: "provenance does not authorize restore", - observed, - }, - retryAfter: null, - }); - }); - - test("maps busy to 503 with Retry-After seconds", async () => { - expect(await projection({ - kind: "busy", - surface: "lock", - retryAfterMs: 1_250, - })).toEqual({ - status: 503, - body: { ok: false, surface: "lock", retryAfterMs: 1_250 }, - retryAfter: "2", - }); - }); - - test("maps deferred", async () => { - expect(await projection({ - kind: "deferred", - direction: "applied", - changed: true, - unresolved: ["catalog"], - nativeGeneration: 4, - currentTxId: "tx-1", - observed, - catalogRefresh, - })).toEqual({ - status: 200, - body: { - ok: true, - changed: true, - unresolved: ["catalog"], - observed, - catalogRefresh, - }, - retryAfter: null, - }); - }); - - test("maps failed", async () => { - expect(await projection({ - kind: "failed", - surface: "provenance", - message: "record write failed", - })).toEqual({ - status: 500, - body: { error: "record write failed", surface: "provenance" }, - retryAfter: null, - }); - }); -}); diff --git a/tests/codex-v2-gate.test.ts b/tests/codex-v2-gate.test.ts index 89ad039ac7..c2b7eb1b47 100644 --- a/tests/codex-v2-gate.test.ts +++ b/tests/codex-v2-gate.test.ts @@ -31,7 +31,7 @@ import { } from "../src/codex/features"; import { cmdV2, codexFeaturesInvocation, v2StatusLine, multiAgentModeLine } from "../src/cli/v2"; import { handleManagementAPI } from "../src/server/management-api"; -import { getDefaultConfig, loadConfig } from "../src/config"; +import { getDefaultConfig, loadConfig, saveConfig } from "../src/config"; import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; function template(): Record { @@ -780,6 +780,39 @@ describe("config-surface parity: agents.enabled, max_depth, subagent_developer_i }); describe("management API logical v1/v2 switching", () => { + test("policy saves preserve unrelated persisted edits made after server startup", async () => { + const path = fixtureConfig("[features.multi_agent_v2]\nenabled = false\n"); + const oldCodexHome = process.env.CODEX_HOME; + const oldCodexCommanderHome = process.env.CODEXCOMMANDER_HOME; + process.env.CODEX_HOME = dirname(path); + process.env.CODEXCOMMANDER_HOME = mkdtempSync(join(tmpdir(), "ccx-api-policy-rebase-")); + const serverConfig = getDefaultConfig(); + saveConfig(serverConfig); + const externallyEdited = structuredClone(serverConfig); + externallyEdited.port = 20200; + saveConfig(externallyEdited); + const deps = { + toggleCodexMultiAgentV2: (enabled: boolean) => { + const content = readFileSync(path, "utf8"); + writeFileSync(path, content.replace(/^enabled\s*=\s*(?:true|false)$/m, `enabled = ${enabled}`)); + }, + createManagementConvergeCodex: catalogConvergenceFactory(), + }; + try { + const request = new Request("http://localhost/api/v2", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ multiAgentMode: "v2" }), + }); + const response = await handleManagementAPI(request, new URL(request.url), serverConfig, deps); + expect(response?.status).toBe(200); + expect(loadConfig()).toMatchObject({ port: 20200, multiAgentMode: "v2" }); + } finally { + if (oldCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = oldCodexHome; + if (oldCodexCommanderHome === undefined) delete process.env.CODEXCOMMANDER_HOME; else process.env.CODEXCOMMANDER_HOME = oldCodexCommanderHome; + } + }); + test("persists and clears the explicit V2 message-delivery policy", async () => { const path = fixtureConfig("[features.multi_agent_v2]\nenabled = true\n"); const oldCodexHome = process.env.CODEX_HOME; @@ -790,7 +823,12 @@ describe("management API logical v1/v2 switching", () => { ...getDefaultConfig(), multiAgentV2MessageDelivery: "encrypted" as const, }; - const deps = { createManagementConvergeCodex: catalogConvergenceFactory() }; + let catalogConvergences = 0; + const deps = { + createManagementConvergeCodex: catalogConvergenceFactory(() => { catalogConvergences += 1; }), + saveConfigPreservingClaudeCode: saveConfig, + loadConfigForCatalogActivation: () => config, + }; try { const setPlaintext = new Request("http://localhost/api/v2", { method: "PUT", @@ -815,6 +853,7 @@ describe("management API logical v1/v2 switching", () => { expect(clearResponse?.status).toBe(200); expect(await clearResponse?.json()).toMatchObject({ multiAgentV2MessageDelivery: "encrypted" }); expect(loadConfig().multiAgentV2MessageDelivery).toBeUndefined(); + expect(catalogConvergences).toBe(0); const invalid = new Request("http://localhost/api/v2", { method: "PUT", @@ -839,7 +878,12 @@ describe("management API logical v1/v2 switching", () => { const content = readFileSync(path, "utf8"); writeFileSync(path, content.replace(/^enabled\s*=\s*(?:true|false)$/m, `enabled = ${enabled}`)); }; - const deps = { toggleCodexMultiAgentV2: toggle, createManagementConvergeCodex: catalogConvergenceFactory() }; + const deps = { + toggleCodexMultiAgentV2: toggle, + createManagementConvergeCodex: catalogConvergenceFactory(), + saveConfigPreservingClaudeCode: saveConfig, + loadConfigForCatalogActivation: () => config, + }; try { const toV2 = new Request("http://localhost/api/v2", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ multiAgentMode: "v2" }), @@ -1288,6 +1332,113 @@ describe("cli surface", () => { if (oldCodexCommanderHome === undefined) delete process.env.CODEXCOMMANDER_HOME; else process.env.CODEXCOMMANDER_HOME = oldCodexCommanderHome; } }, 15_000); + + test("mode default keeps the CLI's explicit first-run config initialization", async () => { + const path = fixtureConfig("[features.multi_agent_v2]\nenabled = false\n"); + const oldCodexHome = process.env.CODEX_HOME; + const oldCodexCommanderHome = process.env.CODEXCOMMANDER_HOME; + const firstRunHome = mkdtempSync(join(tmpdir(), "ccx-cli-v2-first-run-")); + process.env.CODEX_HOME = dirname(path); + process.env.CODEXCOMMANDER_HOME = firstRunHome; + try { + expect(await cmdV2(["mode", "default"], { + sync: async () => {}, + log: { log: () => {}, error: () => {} }, + })).toBe(0); + const stored = JSON.parse(readFileSync(join(firstRunHome, "config.json"), "utf8")) as Record; + expect(stored.multiAgentMode).toBeUndefined(); + expect(stored.providers).toBeDefined(); + } finally { + if (oldCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = oldCodexHome; + if (oldCodexCommanderHome === undefined) delete process.env.CODEXCOMMANDER_HOME; else process.env.CODEXCOMMANDER_HOME = oldCodexCommanderHome; + } + }); + + test("mode persistence rebases a roster edit that lands during the feature transition", async () => { + const path = fixtureConfig("[features.multi_agent_v2]\nenabled = false\n"); + const oldCodexHome = process.env.CODEX_HOME; + const oldCodexCommanderHome = process.env.CODEXCOMMANDER_HOME; + process.env.CODEX_HOME = dirname(path); + process.env.CODEXCOMMANDER_HOME = mkdtempSync(join(tmpdir(), "ccx-cli-v2-rebase-")); + const initial = getDefaultConfig(); + saveConfig(initial); + const concurrentRoster = ["opencode-go/deepseek-v4-flash"]; + let syncCalls = 0; + const deps = { + featuresInvocation: (action: "enable" | "disable") => ({ + file: "codex-fixture", + args: ["features", action, "multi_agent_v2"], + options: {}, + }), + execFile: (_file: string, args: string[]) => { + // This callback is the deterministic point at which the real CLI can + // block. Land an unrelated writer before allowing the flag transition + // to return; a stale whole-config save would erase this roster. + const concurrent = loadConfig(); + concurrent.port = 20200; + concurrent.subagentModels = [...concurrentRoster]; + saveConfig(concurrent); + const enabled = args[1] === "enable"; + const content = readFileSync(path, "utf8"); + writeFileSync(path, content.replace(/^enabled\s*=\s*(?:true|false)$/m, `enabled = ${enabled}`)); + }, + sync: async () => { syncCalls += 1; }, + log: { log: () => {}, error: () => {} }, + }; + try { + expect(await cmdV2(["mode", "v2"], deps)).toBe(0); + expect(loadConfig()).toMatchObject({ + port: 20200, + subagentModels: concurrentRoster, + multiAgentMode: "v2", + }); + expect(syncCalls).toBe(1); + } finally { + if (oldCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = oldCodexHome; + if (oldCodexCommanderHome === undefined) delete process.env.CODEXCOMMANDER_HOME; else process.env.CODEXCOMMANDER_HOME = oldCodexCommanderHome; + } + }); + + test("a failed blocking transition preserves its exact TOML rollback and does not persist the mode", async () => { + // The V1 limit forces transitionMultiAgentV2 to rewrite TOML before the + // injected feature command throws, so byte equality below proves rollback + // rather than merely observing an untouched file. + const originalToml = "# exact\r\n[agents]\r\nmax_threads = 100 # tuned\r\n"; + const path = fixtureConfig(originalToml); + const oldCodexHome = process.env.CODEX_HOME; + const oldCodexCommanderHome = process.env.CODEXCOMMANDER_HOME; + process.env.CODEX_HOME = dirname(path); + process.env.CODEXCOMMANDER_HOME = mkdtempSync(join(tmpdir(), "ccx-cli-v2-rollback-")); + saveConfig(getDefaultConfig()); + let syncCalls = 0; + const deps = { + featuresInvocation: (action: "enable" | "disable") => ({ + file: "codex-fixture", + args: ["features", action, "multi_agent_v2"], + options: {}, + }), + execFile: () => { + const concurrent = loadConfig(); + concurrent.subagentModels = ["opencode-go/deepseek-v4-flash"]; + saveConfig(concurrent); + throw new Error("feature transition blocked"); + }, + sync: async () => { syncCalls += 1; }, + log: { log: () => {}, error: () => {} }, + }; + try { + expect(await cmdV2(["mode", "v2"], deps)).toBe(1); + expect(readFileSync(path, "utf8")).toBe(originalToml); + expect(loadConfig()).toMatchObject({ + subagentModels: ["opencode-go/deepseek-v4-flash"], + }); + expect(loadConfig().multiAgentMode).toBeUndefined(); + expect(syncCalls).toBe(0); + } finally { + if (oldCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = oldCodexHome; + if (oldCodexCommanderHome === undefined) delete process.env.CODEXCOMMANDER_HOME; else process.env.CODEXCOMMANDER_HOME = oldCodexCommanderHome; + } + }); }); describe("mock-max wire clamp (nativeEffortClamp)", () => { diff --git a/tests/combo-management-api.test.ts b/tests/combo-management-api.test.ts index 4d55059670..bef4c33306 100644 --- a/tests/combo-management-api.test.ts +++ b/tests/combo-management-api.test.ts @@ -38,9 +38,8 @@ import { routeModel } from "../src/router"; import { handleManagementAPI } from "../src/server/management-api"; import { handleResponses } from "../src/server/responses"; import type { CodexCommanderConfig } from "../src/types"; -import { syncCatalogModels } from "../src/codex/catalog"; import { injectClaudeAgentDefs } from "../src/claude/agents-inject"; -import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; +import { catalogConvergenceFactory, convergeCatalogForTest } from "./helpers/catalog-convergence"; import { createCodexRuntimeFixture } from "./helpers/codex-runtime-fixture"; const VALID_COMBO = { targets: [{ provider: "a", model: "m1" }] }; @@ -768,7 +767,7 @@ describe("combo management API", () => { "DELETE", "/api/combos?id=free", undefined, - async () => { await syncCatalogModels(config); }, + async () => { await convergeCatalogForTest(readConfigDiagnostics().config); }, ); expect(deleted?.status).toBe(200); const catalog = JSON.parse(readFileSync(catalogPath, "utf8")) as { diff --git a/tests/combos.test.ts b/tests/combos.test.ts index e6e6adaa15..b268aad342 100644 --- a/tests/combos.test.ts +++ b/tests/combos.test.ts @@ -39,7 +39,6 @@ import { routeModel } from "../src/router"; import { handleManagementAPI } from "../src/server/management-api"; import { handleResponses } from "../src/server/responses"; import type { CodexCommanderConfig } from "../src/types"; -import { syncCatalogModels } from "../src/codex/catalog"; import { injectClaudeAgentDefs } from "../src/claude/agents-inject"; import { reconcileComboRotationState } from "../src/combos/resolve"; import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; diff --git a/tests/companion-startup-state.test.ts b/tests/companion-startup-state.test.ts index 992e4943ff..4601bae73c 100644 --- a/tests/companion-startup-state.test.ts +++ b/tests/companion-startup-state.test.ts @@ -40,7 +40,7 @@ function ownedLocalBase(overrides: Partial { const url = new URL("http://127.0.0.1:10100/api/startup-health/companion"); const req = new Request(url, { @@ -79,10 +79,10 @@ describe("PUT /api/startup-health/companion security", () => { expect(lease!.observedAt).toBeLessThanOrEqual(Date.now()); }); - test("rejects a GUI session with 403 and records nothing", async () => { + test("rejects a confirmed GUI session with 403 and records nothing", async () => { const { status } = await companionPut( { version: 1, launchAtLogin: "enabled" }, - "gui-session", + "confirmed-gui-session", ); expect(status).toBe(403); expect(currentCompanionLease()).toBeNull(); diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index 30876254d8..26a8f31293 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -395,21 +395,30 @@ describe("service memory section (#314 WP4)", () => { }; test("fetchServiceMemory: ok / unauthorized / unreachable / malformed", async () => { + const attested = { + attestLiveManagementProxyImpl: async () => ({ + pid: 4242, + port: 10100, + hostname: "127.0.0.1", + source: "runtime" as const, + baseUrl: "http://127.0.0.1:10100", + }), + }; const ok = await fetchServiceMemory("127.0.0.1", 10100, null, - (async () => Response.json(baseData)) as typeof fetch); + (async () => Response.json(baseData)) as typeof fetch, attested); expect(ok.status).toBe("ok"); if (ok.status === "ok") expect(ok.data.pid).toBe(4242); const unauthorized = await fetchServiceMemory("127.0.0.1", 10100, "wrong", - (async () => new Response("{}", { status: 401 })) as typeof fetch); + (async () => new Response("{}", { status: 401 })) as typeof fetch, attested); expect(unauthorized.status).toBe("unauthorized"); const unreachable = await fetchServiceMemory("127.0.0.1", 10100, null, - (async () => { throw new TypeError("fetch failed"); }) as typeof fetch); + (async () => { throw new TypeError("fetch failed"); }) as typeof fetch, attested); expect(unreachable.status).toBe("unreachable"); const malformed = await fetchServiceMemory("127.0.0.1", 10100, null, - (async () => Response.json({ hello: "world" })) as typeof fetch); + (async () => Response.json({ hello: "world" })) as typeof fetch, attested); expect(malformed.status).toBe("unreachable"); if (malformed.status === "unreachable") expect(malformed.error).toBe("malformed response"); }); diff --git a/tests/grok-lifecycle.test.ts b/tests/grok-lifecycle.test.ts index 8174ea0518..7bf22ce626 100644 --- a/tests/grok-lifecycle.test.ts +++ b/tests/grok-lifecycle.test.ts @@ -167,7 +167,7 @@ describe("POST /api/stop teardown", () => { const stopProxyFn = sliceFn(PROCESS_CONTROL_SOURCE, "export async function stopProxy(", "export function killProxy("); const refusedAt = stopProxyFn.indexOf('graceful === "refused"'); - const killAt = stopProxyFn.indexOf("killProxy(pid)"); + const killAt = stopProxyFn.indexOf("killProxyWithAuthorization(pid"); expect(refusedAt).toBeGreaterThan(-1); expect(refusedAt).toBeLessThan(killAt); expect(stopProxyFn).toContain("throw new Error("); diff --git a/tests/gui-management-session.test.ts b/tests/gui-management-session.test.ts index 2522af4145..ad89e65b60 100644 --- a/tests/gui-management-session.test.ts +++ b/tests/gui-management-session.test.ts @@ -1,62 +1,89 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { installApiAuthFetch, resetApiAuthFetchForTests } from "../gui/src/api"; +import { + installApiAuthFetch, + isConfirmedGuiLaunch, + resetApiAuthFetchForTests, + whenGuiLaunchCapabilitySettles, +} from "../gui/src/api"; const originalWindow = globalThis.window; -const originalDocument = globalThis.document; const originalSessionStorage = globalThis.sessionStorage; +const originalLocalStorage = globalThis.localStorage; afterEach(() => { resetApiAuthFetchForTests(); Object.assign(globalThis, { window: originalWindow, - document: originalDocument, sessionStorage: originalSessionStorage, + localStorage: originalLocalStorage, }); }); -describe("GUI management session bootstrap", () => { - test("management requests use the injected session while data requests remain untouched", async () => { - const seen: Array<{ url: string; method: string; headers: Headers }> = []; - const meta = new Map([ - ["codexcommander-session-token", "ccx_session_browser-secret"], - ["codexcommander-session-csrf", "csrf-browser-secret"], - ["codexcommander-session-origin", "http://localhost:10100"], - ]); +describe("GUI confirmed launch exchange", () => { + test("scrubs the ticket, exchanges once in memory, and leaves data requests untouched", async () => { + const ticket = `ccx_launch_${"A".repeat(43)}`; + const location = new URL(`http://localhost:10100/#ccx-launch-ticket=${ticket}&ccx-route=subagents`); + const seen: Array<{ url: string; method: string; headers: Headers; body?: BodyInit | null; hash: string }> = []; const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit): Promise => { seen.push({ url: input instanceof Request ? input.url : String(input), method: init?.method ?? (input instanceof Request ? input.method : "GET"), headers: new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)), + body: init?.body, + hash: location.hash, }); + if (String(input) === "/api/gui-launch-exchange") { + return Response.json({ + route: "subagents", + session: { + token: "ccx_session_browser-secret", + csrfToken: "csrf-browser-secret", + origin: "http://localhost:10100", + expiresAt: Date.now() + 60_000, + confirmedLaunch: true, + }, + }); + } return Response.json({ ok: true }); }; + let durableWrites = 0; + const storage = { getItem: () => null, setItem: () => { durableWrites += 1; }, removeItem: () => { durableWrites += 1; } }; Object.assign(globalThis, { - document: { - querySelector(selector: string) { - const match = selector.match(/^meta\[name="([^"]+)"\]$/); - const content = match ? meta.get(match[1] ?? "") : undefined; - return content ? { content, remove() {} } : null; - }, - }, - sessionStorage: { removeItem() {} }, + localStorage: storage, + sessionStorage: storage, window: { - location: new URL("http://localhost:10100/"), + location, + history: { + state: null, + replaceState(_state: unknown, _title: string, next: string) { + location.href = new URL(next, location).href; + }, + }, fetch: fetchImpl, prompt: () => null, }, }); installApiAuthFetch(); + expect(location.hash).toBe("#subagents"); + expect(await whenGuiLaunchCapabilitySettles()).toBe(true); + expect(isConfirmedGuiLaunch()).toBe(true); await window.fetch("/api/config"); await window.fetch("/api/settings", { method: "PUT", body: "{}" }); await window.fetch("/v1/models"); - expect(seen[0]?.headers.get("x-codexcommander-api-key")).toBe("ccx_session_browser-secret"); - expect(seen[0]?.headers.get("x-codexcommander-gui-origin")).toBe("http://localhost:10100"); - expect(seen[0]?.headers.get("x-codexcommander-csrf-token")).toBeNull(); + expect(seen).toHaveLength(4); + expect(seen[0]?.url).toBe("/api/gui-launch-exchange"); + expect(seen[0]?.hash).toBe("#subagents"); + expect(seen[0]?.headers.get("x-codexcommander-api-key")).toBeNull(); + expect(JSON.parse(String(seen[0]?.body))).toEqual({ ticket, route: "subagents" }); expect(seen[1]?.headers.get("x-codexcommander-api-key")).toBe("ccx_session_browser-secret"); - expect(seen[1]?.headers.get("x-codexcommander-csrf-token")).toBe("csrf-browser-secret"); - expect(seen[2]?.headers.get("x-codexcommander-api-key")).toBeNull(); - expect(seen[2]?.headers.get("x-codexcommander-gui-origin")).toBeNull(); + expect(seen[1]?.headers.get("x-codexcommander-gui-origin")).toBe("http://localhost:10100"); + expect(seen[1]?.headers.get("x-codexcommander-csrf-token")).toBeNull(); + expect(seen[2]?.headers.get("x-codexcommander-api-key")).toBe("ccx_session_browser-secret"); + expect(seen[2]?.headers.get("x-codexcommander-csrf-token")).toBe("csrf-browser-secret"); + expect(seen[3]?.headers.get("x-codexcommander-api-key")).toBeNull(); + expect(seen[3]?.headers.get("x-codexcommander-gui-origin")).toBeNull(); + expect(durableWrites).toBe(0); }); }); diff --git a/tests/helpers/account-login-pipe-child.ts b/tests/helpers/account-login-pipe-child.ts index 47978e68d1..9cdb9d7f45 100644 --- a/tests/helpers/account-login-pipe-child.ts +++ b/tests/helpers/account-login-pipe-child.ts @@ -28,6 +28,13 @@ const server = Bun.serve({ const deps: AccountDeps = { baseUrl: `http://127.0.0.1:${server.port}`, + attestLiveManagementProxyImpl: async () => ({ + pid: 4242, + port: server.port, + hostname: "127.0.0.1", + source: "runtime", + baseUrl: `http://127.0.0.1:${server.port}`, + }), loadConfigImpl: () => ({ providers: {} }) as ReturnType>, }; diff --git a/tests/helpers/catalog-convergence.ts b/tests/helpers/catalog-convergence.ts index 64f42afa45..9d3f45591c 100644 --- a/tests/helpers/catalog-convergence.ts +++ b/tests/helpers/catalog-convergence.ts @@ -1,3 +1,6 @@ +import { saveConfig } from "../../src/config"; +import { captureCatalogAdmissionSnapshot } from "../../src/codex/catalog-admission"; +import { convergeCodexCatalog } from "../../src/codex/convergence"; import { projectCatalogOnlyOutcome } from "../../src/codex/management-convergence"; import type { ConvergeCodex } from "../../src/codex/convergence-types"; import type { CodexCommanderConfig } from "../../src/types"; @@ -13,3 +16,19 @@ export function catalogConvergenceFactory( }); }; } + +/** Persist, admit, and run the production catalog convergence path in focused tests. */ +export async function convergeCatalogForTest(config: Readonly) { + saveConfig(config); + const result = await convergeCodexCatalog(captureCatalogAdmissionSnapshot(config), { + action: "converge", + scope: "catalog", + reason: "api-sync", + mode: "explicit", + deadlineMs: 1_000, + }); + if (result.catalogRefresh.status !== "committed") { + throw new Error(`Catalog convergence did not commit: ${JSON.stringify(result.catalogRefresh)}`); + } + return result.projection; +} diff --git a/tests/identity-contract.test.ts b/tests/identity-contract.test.ts index 0b57525cc8..dec6c6dbd0 100644 --- a/tests/identity-contract.test.ts +++ b/tests/identity-contract.test.ts @@ -23,6 +23,9 @@ describe("CodexCommander identity contract", () => { "CLI_SHORT", "CSRF_HEADER", "DATA_KEY_PREFIX", + "GUI_LAUNCH_EXCHANGE_PATH", + "GUI_LAUNCH_TICKET_PATH", + "GUI_LAUNCH_TICKET_PREFIX", "GUI_ORIGIN_HEADER", "GUI_SESSION_PREFIX", "HEALTH_SERVICE_ID", @@ -34,7 +37,6 @@ describe("CodexCommander identity contract", () => { "REPOSITORY_URL", "SERVICE_LABEL", "SERVICE_TASK", - "SESSION_PATH", "STATE_DIR_NAME", "UNINSTALL_MANIFEST", "WINSW_SERVICE_ID", diff --git a/tests/injection-model-api.test.ts b/tests/injection-model-api.test.ts index 773444f968..1ddb50a475 100644 --- a/tests/injection-model-api.test.ts +++ b/tests/injection-model-api.test.ts @@ -4,9 +4,12 @@ * model, and GET surfaces `{ effort, efforts }` next to the existing model picker. */ import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; import { getConfigPath, getDefaultConfig, loadConfig } from "../src/config"; import { refreshCodexModelCatalog } from "../src/codex/refresh"; import { handleManagementAPI } from "../src/server/management-api"; @@ -52,11 +55,141 @@ async function put(config: CodexCommanderConfig, body: unknown): Promise ({ + status: "skipped", + reason: "routing-not-owned", + }), + }); expect(res).not.toBeNull(); return res!; } +const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); + +function runManagedDefaultsSave(codexHome: string, commanderHome: string): { + status: number; + stdout: string; + stderr: string; +} { + const script = ` + const { writeFileSync, readFileSync } = require("node:fs"); + const { join } = require("node:path"); + const { getDefaultConfig, loadConfig, saveConfigPreservingClaudeCode } = require("./src/config"); + const { injectCodexConfig, getCodexRoutingKind } = require("./src/codex/inject"); + const { handleManagementAPI } = require("./src/server/management-api"); + const { ManagementRequest } = require("./tests/helpers/management-auth"); + + const config = { + ...getDefaultConfig(), + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + injectionModel: "gpt-5.6-terra", + injectionEffort: "high", + }; + saveConfigPreservingClaudeCode(config); + writeFileSync(join(process.env.CODEX_HOME, "codexcommander-catalog.json"), '{"models":[]}\\n'); + const initial = await injectCodexConfig(10100, config); + if (!initial.success || getCodexRoutingKind() !== "codexcommander-local") { + throw new Error("failed to establish managed routing: " + JSON.stringify(initial)); + } + + const req = new ManagementRequest("http://localhost/api/injection-model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ syncCodexSubagentDefaults: true }), + }); + const response = await handleManagementAPI(req, new URL(req.url), config, { + loadConfigForCatalogActivation: loadConfig, + codexRoutingKindForActivation: getCodexRoutingKind, + catalogArtifactProofForActivation: () => "current", + collectCodexAppServerCatalogState: () => ({ + state: "stale", + catalogMtimeMs: Date.now(), + processes: [{ pid: 4242, commandLine: "codex app-server", startedAtMs: 1 }], + }), + }, "confirmed-gui-session"); + console.log(JSON.stringify({ + status: response.status, + body: await response.json(), + nativeConfig: readFileSync(join(process.env.CODEX_HOME, "config.toml"), "utf8"), + })); + `; + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { + ...process.env, + CODEX_HOME: codexHome, + CODEXCOMMANDER_HOME: commanderHome, + }, + encoding: "utf8", + }); + return { + status: result.status ?? 1, + stdout: result.stdout?.trim() ?? "", + stderr: result.stderr?.trim() ?? "", + }; +} + +function runUnownedDefaultsSave( + codexHome: string, + commanderHome: string, + mode: "off" | "native" | "external", +): { status: number; stdout: string; stderr: string } { + const script = ` + const { readFileSync } = require("node:fs"); + const { getDefaultConfig, saveConfigPreservingClaudeCode } = require("./src/config"); + const { getCodexRoutingKind } = require("./src/codex/inject"); + const { handleManagementAPI } = require("./src/server/management-api"); + const { ManagementRequest } = require("./tests/helpers/management-auth"); + const config = { + ...getDefaultConfig(), + ...(process.env.TEST_MODE === "off" ? { clientIntegrations: { codex: false } } : {}), + injectionModel: "gpt-5.6-terra", + injectionEffort: "high", + }; + saveConfigPreservingClaudeCode(config); + const nativePath = process.env.CODEX_HOME + "/config.toml"; + const before = readFileSync(nativePath, "utf8"); + const req = new ManagementRequest("http://localhost/api/injection-model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ syncCodexSubagentDefaults: true }), + }); + const response = await handleManagementAPI(req, new URL(req.url), config); + console.log(JSON.stringify({ + status: response.status, + body: await response.json(), + before, + after: readFileSync(nativePath, "utf8"), + routingKind: getCodexRoutingKind(), + })); + `; + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { + ...process.env, + CODEX_HOME: codexHome, + CODEXCOMMANDER_HOME: commanderHome, + TEST_MODE: mode, + }, + encoding: "utf8", + }); + return { + status: result.status ?? 1, + stdout: result.stdout?.trim() ?? "", + stderr: result.stderr?.trim() ?? "", + }; +} + describe("/api/injection-model reasoning effort", () => { test("PUT model+effort roundtrips; GET surfaces effort + ladder", async () => { isolatedHome(); @@ -164,6 +297,68 @@ describe("/api/injection-model reasoning effort", () => { }); describe("/api/injection-model guidance kill switch + partial update", () => { + test.each([ + ["off", 'model = "gpt-5.6-terra"\n', "integration-disabled", "native"], + ["native", 'model = "gpt-5.6-terra"\n', "routing-not-owned", "native"], + ["external", 'model_provider = "external"\n\n[model_providers.external]\nbase_url = "https://example.test/v1"\n', "routing-not-owned", "custom-remote"], + ] as const)("%s Save preserves unowned native config", (mode, nativeConfig, reason, routingKind) => { + const codexHome = mkdtempSync(join(tmpdir(), `ccx-injection-${mode}-codex-`)); + const commanderHome = mkdtempSync(join(tmpdir(), `ccx-injection-${mode}-home-`)); + try { + writeFileSync(join(codexHome, "config.toml"), nativeConfig, "utf8"); + const result = runUnownedDefaultsSave(codexHome, commanderHome, mode); + expect(result.status, result.stderr).toBe(0); + const output = JSON.parse(result.stdout) as { + status: number; + body: { nativeDefaultsRefresh: { status: string; reason: string } }; + before: string; + after: string; + routingKind: string; + }; + expect(output.status).toBe(200); + expect(output.body.nativeDefaultsRefresh).toEqual({ status: "skipped", reason }); + expect(output.after).toBe(output.before); + expect(output.routingKind).toBe(routingKind); + } finally { + rmSync(codexHome, { recursive: true, force: true }); + rmSync(commanderHome, { recursive: true, force: true }); + } + }, 30_000); + + test("managed Save reconciles native defaults and reports reload-required without signaling", () => { + const codexHome = mkdtempSync(join(tmpdir(), "ccx-injection-managed-codex-")); + const commanderHome = mkdtempSync(join(tmpdir(), "ccx-injection-managed-home-")); + try { + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5.6-terra"\n', "utf8"); + const result = runManagedDefaultsSave(codexHome, commanderHome); + expect(result.status, result.stderr).toBe(0); + const output = JSON.parse(result.stdout) as { + status: number; + body: { + nativeDefaultsRefresh: { status: string }; + activation: { + workers: { status: string }; + apply: { required: boolean; allowed: boolean; reason: string }; + }; + }; + nativeConfig: string; + }; + expect(output.status).toBe(200); + expect(output.body.nativeDefaultsRefresh).toMatchObject({ status: "reconciled" }); + expect(output.nativeConfig).toContain("# Managed by CodexCommander: native subagent defaults table"); + expect(output.nativeConfig).toContain('default_subagent_model = "gpt-5.6-terra"'); + expect(output.nativeConfig).toContain('default_subagent_reasoning_effort = "high"'); + expect(output.body.activation.workers.status).toBe("reload_required"); + expect(output.body.activation.apply).toMatchObject({ + required: true, + allowed: true, + }); + } finally { + rmSync(codexHome, { recursive: true, force: true }); + rmSync(commanderHome, { recursive: true, force: true }); + } + }, 30_000); + test("flag-only PUT preserves model, effort, and prompt in memory and on disk", async () => { isolatedHome(); const config = makeConfig({ @@ -417,11 +612,30 @@ describe("/api/injection-model guidance kill switch + partial update", () => { let flagSeenBySync: boolean | undefined; await refreshCodexModelCatalog(config, { - syncCatalogModels: async syncedConfig => { + prepareConfigGeneration: () => {}, + captureCatalogAdmissionSnapshot: syncedConfig => { flagSeenBySync = syncedConfig.multiAgentGuidanceEnabled; - return { added: 0, path: join(tempHome!, "missing-catalog.json"), catalogWritten: false, comboOmissions: [] }; + return {} as never; }, - invalidateCodexModelsCache: () => false, + convergeCodexCatalog: async () => ({ + changed: false, + catalogRefresh: { status: "committed", changed: false, degraded: false, notices: [] }, + projection: { + admittedGeneration: { value: 0 }, + admittedConfigAuthority: { + generation: { value: 0 }, + semanticIdentity: "semantic", + contentIdentity: "content", + }, + added: 0, + path: join(tempHome!, "missing-catalog.json"), + catalogWritten: false, + cacheSynced: false, + comboOmissions: [], + catalogQuality: "native-only", + rehydrated: 0, + }, + }), existsSync: () => false, }); expect(flagSeenBySync).toBe(false); diff --git a/tests/management-integration-routes.test.ts b/tests/management-integration-routes.test.ts index ddd83a003a..e05529be25 100644 --- a/tests/management-integration-routes.test.ts +++ b/tests/management-integration-routes.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import type { ExportModel } from "../src/clients/config-export"; import { fileIO, type IntegrationIO } from "../src/integrations/config-io"; @@ -29,24 +28,6 @@ let home = ""; let storeRoot = ""; let store: IntegrationStateStore; -/** - * The CSRF wire test fetches the real served page, which only exists when the - * GUI build does. CI runs `bun test` BEFORE `GUI build`, so the artifact is - * absent on a fresh checkout — build it once here instead of letting the page - * fall back to the JSON root payload (which silently voids the wire test). - */ -{ - if (!existsSync(join(import.meta.dir, "..", "gui", "dist", "index.html"))) { - const build = Bun.spawnSync({ - cmd: ["bun", "run", "build:gui"], - cwd: join(import.meta.dir, ".."), - stdout: "inherit", - stderr: "inherit", - }); - if (build.exitCode !== 0) throw new Error("gui build failed for the wire-level CSRF suite"); - } -} - /** * The environment the ROUTE resolves paths with — never `process.env`. * @@ -928,7 +909,7 @@ describe("admission", () => { expect(store.listOperations("hermes")[0]!.kind).toBe("restore"); }); - test("a GUI-session mutation without CSRF is rejected before integration dispatch", async () => { + test("a confirmed GUI-session mutation without CSRF is rejected before integration dispatch", async () => { /* * Driven through a REAL listener, the way a browser reaches this route. * @@ -937,17 +918,15 @@ describe("admission", () => { * src/server/index.ts stopped calling it before `handleManagementAPI`, * which is precisely the regression that would let a CSRF-less GUI * mutation reach the writer. So the session is obtained the way the GUI - * obtains it — from the meta tags injected into the served page — and both - * requests go over the wire. + * obtains it — through the one-use confirmed launch exchange — and both + * mutation requests go over the wire. */ const previousCodexCommanderHome = process.env.CODEXCOMMANDER_HOME; const previousAdmin = process.env.CODEXCOMMANDER_ADMIN_AUTH_TOKEN; const serverHome = join(base, "csrf-server-home"); mkdirSync(serverHome, { recursive: true }); process.env.CODEXCOMMANDER_HOME = serverHome; - // A GUI session is only issued when no admin token is configured; with one - // set, `isApiAuthRequired` declines and there is no CSRF pair to test. - delete process.env.CODEXCOMMANDER_ADMIN_AUTH_TOKEN; + process.env.CODEXCOMMANDER_ADMIN_AUTH_TOKEN = "csrf-wire-admin-token"; installHermes(); const { saveConfig } = await import("../src/config"); @@ -956,38 +935,27 @@ describe("admission", () => { const server = startServer(0); try { const origin = new URL(server.url).origin; - /* - * The session pair comes from the served page's meta tags, exactly as a - * browser gets it. That page only exists once `gui/dist` is built, and - * CI runs this suite without building the GUI — which is why this test - * failed on every CI platform while passing locally against a stale - * build. The absent-bundle case is handled explicitly below rather than - * asserted away. - */ - const page = await fetch(server.url, { headers: { Accept: "text/html" } }); - const html = await page.text(); - const meta = (name: string): string => - new RegExp(``).exec(html)?.[1] ?? ""; - const token = meta("codexcommander-session-token"); - const csrf = meta("codexcommander-session-csrf"); - if (!token || !csrf) { - /* - * No built GUI, so no page to carry the session pair. The server owns - * the only session map that its own admission will accept, and there - * is no wire route to mint one without that page — issuing from a - * fresh auth state would hand us a token this server has never seen - * and the assertions below would prove nothing. - * - * The ordering claim is still covered on this platform: the - * admin-token test above drives the same listener and asserts an - * unauthenticated PUT is refused before the route writes or journals. - */ - // `fileURLToPath`, not `.pathname`: a Windows URL pathname is - // `/D:/a/...`, which never exists as a filesystem path and would make - // this guard vacuously true. - expect(existsSync(fileURLToPath(new URL("../gui/dist/index.html", import.meta.url)))).toBe(false); - return; - } + const mintedResponse = await fetch(new URL("/api/gui-launch-ticket", server.url), { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-codexcommander-api-key": "csrf-wire-admin-token", + }, + body: JSON.stringify({ route: "integrations/hermes" }), + }); + expect(mintedResponse.status).toBe(200); + const minted = await mintedResponse.json() as { ticket: string; route: string }; + const exchangeResponse = await fetch(new URL("/api/gui-launch-exchange", server.url), { + method: "POST", + headers: { Origin: origin, "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ ticket: minted.ticket, route: minted.route }), + }); + expect(exchangeResponse.status).toBe(200); + const exchanged = await exchangeResponse.json() as { + session: { token: string; csrfToken: string }; + }; + const token = exchanged.session.token; + const csrf = exchanged.session.csrfToken; const target = new URL("/api/client-integrations/hermes", server.url); const guiHeaders = { @@ -1019,7 +987,8 @@ describe("admission", () => { await server.stop(true); if (previousCodexCommanderHome === undefined) delete process.env.CODEXCOMMANDER_HOME; else process.env.CODEXCOMMANDER_HOME = previousCodexCommanderHome; - if (previousAdmin !== undefined) process.env.CODEXCOMMANDER_ADMIN_AUTH_TOKEN = previousAdmin; + if (previousAdmin === undefined) delete process.env.CODEXCOMMANDER_ADMIN_AUTH_TOKEN; + else process.env.CODEXCOMMANDER_ADMIN_AUTH_TOKEN = previousAdmin; } }); }); diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index df9ef19ab0..476f1e1f64 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -366,7 +366,10 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(rows[0]?.native).toBe(true); const subRes = await handleManagementAPI( - new Request("http://localhost/api/subagent-models"), new URL("http://localhost/api/subagent-models"), config, + new Request("http://localhost/api/subagent-models"), + new URL("http://localhost/api/subagent-models"), + config, + { loadConfigForCatalogActivation: () => config }, ); const sub = await subRes!.json() as { available: string[]; diff --git a/tests/native-profile-route-security.test.ts b/tests/native-profile-route-security.test.ts index 87f0ae5002..c8e398db71 100644 --- a/tests/native-profile-route-security.test.ts +++ b/tests/native-profile-route-security.test.ts @@ -5,7 +5,12 @@ import { join } from "node:path"; import { saveConfig } from "../src/config"; import type { NativeProfileManager } from "../src/codex/native-profile-manager"; import { startServer } from "../src/server"; -import { initializeManagementAuthState, issueGuiSession, type ManagementAuthState } from "../src/server/management-auth"; +import { + exchangeGuiLaunchTicket, + initializeManagementAuthState, + issueGuiLaunchTicket, + type ManagementAuthState, +} from "../src/server/management-auth"; import type { CodexCommanderConfig } from "../src/types"; import { SERVER_BUDGET_MS } from "./helpers/test-budget"; @@ -134,11 +139,16 @@ describe("native-main profile routes at the management admission boundary", () = managementApi: { nativeProfileApi: { manager: testManager(calls) } }, }); try { - const session = issueGuiSession(new Request(new URL("/", server.url), { + const issued = issueGuiLaunchTicket(new Request(new URL("/api/gui-launch-ticket", server.url), { + method: "POST", headers: { Host: server.url.host }, - }), config, managementAuth); + }), "startup", config, managementAuth); + const session = issued && exchangeGuiLaunchTicket(new Request(new URL("/api/gui-launch-exchange", server.url), { + method: "POST", + headers: { Host: server.url.host, Origin: server.url.origin }, + }), issued.ticket, issued.route, config, managementAuth); expect(session).not.toBeNull(); - if (!session) throw new Error("expected a loopback GUI session"); + if (!session) throw new Error("expected a confirmed GUI session"); for (const operation of operations.filter(operation => operation.method === "POST")) { const request = (csrf?: string) => fetch(new URL(operation.path, server.url), { diff --git a/tests/oauth-health.test.ts b/tests/oauth-health.test.ts index e2a146e9df..e846eac252 100644 --- a/tests/oauth-health.test.ts +++ b/tests/oauth-health.test.ts @@ -183,6 +183,7 @@ describe("collectOAuthHealthEntriesForCli", () => { const report = await collectOAuthHealthEntriesForCli(Date.now(), { findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: 4242, source: "runtime" }), readRuntimePortImpl: () => ({ pid: 4242, port: 19191, attestationSecret }), + verifyPidIdentityImpl: candidate => candidate, fetchImpl: async (input, init) => { if (String(input).endsWith("/healthz")) { const challenge = new Headers(init?.headers).get(ATTESTATION_CHALLENGE_HEADER)!; @@ -237,6 +238,7 @@ describe("collectOAuthHealthEntriesForCli", () => { const report = await collectOAuthHealthEntriesForCli(Date.now(), { findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: 4242, source: "runtime" }), readRuntimePortImpl: () => ({ pid: 4242, port: 19191, attestationSecret }), + verifyPidIdentityImpl: candidate => candidate, fetchImpl: async (input, init) => { expect(new Headers(init?.headers).get("authorization")).toBeNull(); if (!String(input).endsWith("/healthz")) apiCalls += 1; diff --git a/tests/oauth-login-cli-live-update.test.ts b/tests/oauth-login-cli-live-update.test.ts index 6fbedcdef1..8b170fb083 100644 --- a/tests/oauth-login-cli-live-update.test.ts +++ b/tests/oauth-login-cli-live-update.test.ts @@ -64,7 +64,11 @@ afterEach(() => { describe("CLI OAuth live-update credential preservation", () => { test("notify after OAuth login keeps key billing on live and disk configs", async () => { - const server = startServer(0, { managementApi: { refreshCodexCatalog: async () => {} } }); + const attestationSecret = "A".repeat(43); + const server = startServer(0, { + localAttestationSecret: attestationSecret, + managementApi: { refreshCodexCatalog: async () => {} }, + }); try { const port = server.port!; const boot = loadConfig(); @@ -78,7 +82,16 @@ describe("CLI OAuth live-update credential preservation", () => { expect(afterLogin.providers.xai!.authMode).toBe("key"); expect(afterLogin.providers.xai!.apiKey).toBe("live-update-sentinel-key"); - await notifyRunningProxyAfterOAuthLogin("xai"); + await notifyRunningProxyAfterOAuthLogin("xai", { + fetchFn: globalThis.fetch, + readRuntimeFn: () => ({ + pid: process.pid, + port, + hostname: "127.0.0.1", + attestationSecret, + }), + verifyPidFn: candidate => candidate, + }); const listed = await fetch(new URL("/api/providers", server.url)).then(r => r.json()) as Array<{ name: string; diff --git a/tests/opencode-cli.test.ts b/tests/opencode-cli.test.ts index df25fc1e63..604f5de1bc 100644 --- a/tests/opencode-cli.test.ts +++ b/tests/opencode-cli.test.ts @@ -27,6 +27,11 @@ import { } from "../src/cli/opencode"; import type { OpencodeCatalogModel } from "../src/clients/config-export"; import type { CodexCommanderConfig } from "../src/types"; +import { + ATTESTATION_CHALLENGE_HEADER, + ATTESTATION_PROOF_HEADER, +} from "../src/identity"; +import { createLocalAttestationProof } from "../src/lib/local-management-attestation"; function cfg(extra?: Partial): CodexCommanderConfig { return { @@ -260,6 +265,8 @@ describe("ccx opencode proxy model catalog", () => { } as CodexCommanderConfig; const previous = process.env[ENV_KEY]; + const previousAdmin = process.env.CODEXCOMMANDER_ADMIN_AUTH_TOKEN; + const previousData = process.env.CODEXCOMMANDER_API_AUTH_TOKEN; delete process.env[ENV_KEY]; clearModelCache(PROVIDER); try { @@ -296,13 +303,27 @@ describe("ccx opencode proxy model catalog", () => { expect(block.models[`${PROVIDER}/live-via-proxy-env`]?.limit?.context).toBe(128_000); expect(block.models[`${PROVIDER}/live-via-proxy-env`]?.name).toBe("live-via-proxy-env (proxyenv)"); + process.env.CODEXCOMMANDER_ADMIN_AUTH_TOKEN = "admin-management-secret"; + process.env.CODEXCOMMANDER_API_AUTH_TOKEN = "data-inference-secret"; + const attestationSecret = "A".repeat(43); const fetched = await fetchOpencodeProxyModels( - { port: 10100, hostname: "127.0.0.1", pid: 1 }, - "sk-mgmt", + { port: 10100, hostname: "127.0.0.1", pid: 1, source: "runtime" }, { + managementAttestation: { + readRuntimeFn: () => ({ pid: 1, port: 10100, hostname: "127.0.0.1", attestationSecret }), + verifyPidFn: candidate => candidate, + }, fetchImpl: async (url, init) => { + if (String(url).endsWith("/healthz")) { + const headers = new Headers(init?.headers); + expect(headers.get("X-CodexCommander-API-Key")).toBeNull(); + const challenge = headers.get(ATTESTATION_CHALLENGE_HEADER)!; + const proof = createLocalAttestationProof(attestationSecret, challenge, 1, 10100)!; + return new Response("ignored", { headers: { [ATTESTATION_PROOF_HEADER]: proof } }); + } expect(String(url)).toBe("http://127.0.0.1:10100/api/models"); - expect(new Headers(init?.headers).get("X-CodexCommander-API-Key")).toBe("sk-mgmt"); + expect(new Headers(init?.headers).get("X-CodexCommander-API-Key")).toBe("admin-management-secret"); + expect(new Headers(init?.headers).get("X-CodexCommander-API-Key")).not.toBe("data-inference-secret"); return new Response(JSON.stringify(rows), { status: 200 }); }, }, @@ -313,22 +334,32 @@ describe("ccx opencode proxy model catalog", () => { clearModelCache(PROVIDER); if (previous === undefined) delete process.env[ENV_KEY]; else process.env[ENV_KEY] = previous; + if (previousAdmin === undefined) delete process.env.CODEXCOMMANDER_ADMIN_AUTH_TOKEN; + else process.env.CODEXCOMMANDER_ADMIN_AUTH_TOKEN = previousAdmin; + if (previousData === undefined) delete process.env.CODEXCOMMANDER_API_AUTH_TOKEN; + else process.env.CODEXCOMMANDER_API_AUTH_TOKEN = previousData; } }); test("fetchOpencodeProxyModels aborts stalled /api/models fetch and body reads", async () => { - const live = { port: 10100, hostname: "127.0.0.1", pid: 1 }; + const live = { port: 10100, hostname: "127.0.0.1", pid: 1, source: "runtime" as const }; + const attested = async () => ({ + ...live, + baseUrl: "http://127.0.0.1:10100", + }); const stall = (init?: RequestInit) => new Promise((_, reject) => { init?.signal?.addEventListener("abort", () => reject(new DOMException("The operation was aborted.", "AbortError"))); }); - await expect(fetchOpencodeProxyModels(live, "sk-mgmt", { + await expect(fetchOpencodeProxyModels(live, { timeoutMs: 25, + attestLiveManagementProxyImpl: attested, fetchImpl: async (_url, init) => stall(init), })).rejects.toThrow("Management API timed out while fetching /api/models."); - await expect(fetchOpencodeProxyModels(live, "sk-mgmt", { + await expect(fetchOpencodeProxyModels(live, { timeoutMs: 25, + attestLiveManagementProxyImpl: attested, fetchImpl: async () => ({ ok: true, status: 200, diff --git a/tests/process-control-graceful.test.ts b/tests/process-control-graceful.test.ts index 0e5a6fdb78..e52fd6019d 100644 --- a/tests/process-control-graceful.test.ts +++ b/tests/process-control-graceful.test.ts @@ -5,6 +5,18 @@ function okResponse(): Response { return new Response(JSON.stringify({ success: true }), { status: 200 }); } +function attestedStop(pid: number, port: number, hostname?: string) { + return { + attestLiveManagementProxyImpl: async () => ({ + pid, + port, + hostname, + source: "runtime" as const, + baseUrl: `http://${hostname === "::1" ? "[::1]" : hostname ?? "127.0.0.1"}:${port}`, + }), + }; +} + describe("gracefulStopHost", () => { test("loopback aliases and wildcard binds answer on IPv4 loopback", () => { for (const host of [undefined, "", " ", "localhost", "LOCALHOST", "127.0.0.1", "0.0.0.0", "::", "[::]"]) { @@ -25,6 +37,7 @@ describe("stopProxyGracefully", () => { test("follows the recorded bind hostname when it names a concrete address", async () => { const calls: string[] = []; await stopProxyGracefully(9, { + ...attestedStop(9, 10100, "::1"), readRuntime: () => ({ port: 10100, hostname: "::1" }), fetchFn: (async (url: string | URL | Request) => { calls.push(String(url)); @@ -39,6 +52,7 @@ describe("stopProxyGracefully", () => { test("POSTs /api/stop on 127.0.0.1 with the runtime port, then waits for exit", async () => { const calls: { url: string; method?: string }[] = []; const result = await stopProxyGracefully(4242, { + ...attestedStop(4242, 10123), readRuntime: pid => (pid === 4242 ? { port: 10123 } : null), fetchFn: (async (url: string | URL | Request, init?: RequestInit) => { calls.push({ url: String(url), method: init?.method }); @@ -55,6 +69,7 @@ describe("stopProxyGracefully", () => { test("sends the management token instead of the data token", async () => { let headers: Record | undefined; await stopProxyGracefully(1, { + ...attestedStop(1, 10100), readRuntime: () => ({ port: 10100 }), fetchFn: (async (_url: string | URL | Request, init?: RequestInit) => { headers = init?.headers as Record; @@ -81,6 +96,7 @@ describe("stopProxyGracefully", () => { test("returns false when the API call fails or the process never exits", async () => { const rejected = await stopProxyGracefully(7, { + ...attestedStop(7, 10100), readRuntime: () => ({ port: 10100 }), fetchFn: (async () => { throw new Error("connection refused"); @@ -91,6 +107,7 @@ describe("stopProxyGracefully", () => { expect(rejected).toBe(false); const non200 = await stopProxyGracefully(7, { + ...attestedStop(7, 10100), readRuntime: () => ({ port: 10100 }), fetchFn: (async () => new Response("nope", { status: 401 })) as typeof fetch, waitExit: () => true, @@ -99,6 +116,7 @@ describe("stopProxyGracefully", () => { expect(non200).toBe(false); const noExit = await stopProxyGracefully(7, { + ...attestedStop(7, 10100), readRuntime: () => ({ port: 10100 }), fetchFn: (async () => okResponse()) as typeof fetch, waitExit: () => false, diff --git a/tests/process-control.test.ts b/tests/process-control.test.ts index 3669a5ad43..7fb653c0cf 100644 --- a/tests/process-control.test.ts +++ b/tests/process-control.test.ts @@ -1,5 +1,42 @@ import { describe, expect, test } from "bun:test"; -import { isProcessAlive, waitForExit } from "../src/lib/process-control"; +import { + isProcessAlive, + stopProxy, + waitForExit, + type ProxySignalIdentity, + type StopProxyIo, +} from "../src/lib/process-control"; + +const PID = 4242; +const SECRET_A = "a".repeat(43); +const SECRET_B = "b".repeat(43); + +function runtime(secret = SECRET_A) { + return { pid: PID, port: 10100, hostname: "127.0.0.1", attestationSecret: secret }; +} + +function identity(overrides: Partial = {}): ProxySignalIdentity { + return { + pid: PID, + argvSha256: "argv-a", + birthIdentity: "birth-a", + ownerIdentity: "uid:501", + ...overrides, + }; +} + +function fallbackIo(overrides: StopProxyIo = {}): StopProxyIo { + return { + platform: "linux", + isAlive: () => true, + readRuntime: () => runtime(), + readProcessIdentity: () => identity(), + gracefulStop: async () => false, + waitExit: () => true, + waitStoppedPort: async () => {}, + ...overrides, + }; +} describe("process control helpers", () => { test("reports the current process as alive", () => { @@ -13,3 +50,103 @@ describe("process control helpers", () => { expect(waitForExit(invalidPid, 1)).toBe(true); }); }); + +describe("stopProxy forced fallback identity fence", () => { + test("stable runtime, current-user argv and birth authorize SIGTERM", async () => { + const signals: NodeJS.Signals[] = []; + await stopProxy(PID, fallbackIo({ + signal: (_pid, signal) => { signals.push(signal); }, + })); + expect(signals).toEqual(["SIGTERM"]); + }); + + test("runtime rotation during graceful stop refuses before SIGTERM", async () => { + let currentRuntime = runtime(); + const signals: NodeJS.Signals[] = []; + await expect(stopProxy(PID, fallbackIo({ + readRuntime: () => currentRuntime, + gracefulStop: async () => { + currentRuntime = runtime(SECRET_B); + return false; + }, + signal: (_pid, signal) => { signals.push(signal); }, + }))).rejects.toThrow("identity changed"); + expect(signals).toEqual([]); + }); + + for (const [name, replacement] of [ + ["argv", identity({ argvSha256: "argv-b" })], + ["birth", identity({ birthIdentity: "birth-b" })], + ["owner", identity({ ownerIdentity: "uid:777" })], + ] as const) { + test(`${name} replacement during graceful stop refuses before SIGTERM`, async () => { + let currentIdentity = identity(); + const signals: NodeJS.Signals[] = []; + await expect(stopProxy(PID, fallbackIo({ + readProcessIdentity: () => currentIdentity, + gracefulStop: async () => { + currentIdentity = replacement; + return false; + }, + signal: (_pid, signal) => { signals.push(signal); }, + }))).rejects.toThrow("identity changed"); + expect(signals).toEqual([]); + }); + } + + test("unknown birth or owner evidence fails closed", async () => { + const signals: NodeJS.Signals[] = []; + await expect(stopProxy(PID, fallbackIo({ + readProcessIdentity: () => null, + signal: (_pid, signal) => { signals.push(signal); }, + }))).rejects.toThrow("identity changed"); + expect(signals).toEqual([]); + }); + + test("SIGKILL revalidates again after the SIGTERM grace window", async () => { + const signals: NodeJS.Signals[] = []; + let waits = 0; + await stopProxy(PID, fallbackIo({ + signal: (_pid, signal) => { signals.push(signal); }, + waitExit: () => ++waits > 1, + })); + expect(signals).toEqual(["SIGTERM", "SIGKILL"]); + }); + + test("PID reuse during the SIGTERM wait blocks SIGKILL", async () => { + const signals: NodeJS.Signals[] = []; + let currentIdentity = identity(); + let waits = 0; + await expect(stopProxy(PID, fallbackIo({ + readProcessIdentity: () => currentIdentity, + signal: (_pid, signal) => { signals.push(signal); }, + waitExit: () => { + waits += 1; + if (waits === 1) currentIdentity = identity({ birthIdentity: "reused-birth" }); + return false; + }, + }))).rejects.toThrow("identity changed"); + expect(signals).toEqual(["SIGTERM"]); + }); + + test("Windows taskkill runs only after the same exact fallback fence", async () => { + const killed: number[] = []; + await stopProxy(PID, fallbackIo({ + platform: "win32", + taskkill: pid => { killed.push(pid); }, + })); + expect(killed).toEqual([PID]); + + let currentIdentity = identity(); + await expect(stopProxy(PID, fallbackIo({ + platform: "win32", + readProcessIdentity: () => currentIdentity, + gracefulStop: async () => { + currentIdentity = identity({ argvSha256: "replacement" }); + return false; + }, + taskkill: pid => { killed.push(pid); }, + }))).rejects.toThrow("identity changed"); + expect(killed).toEqual([PID]); + }); +}); diff --git a/tests/proxy-liveness.test.ts b/tests/proxy-liveness.test.ts index e26cb42cce..e7c858367e 100644 --- a/tests/proxy-liveness.test.ts +++ b/tests/proxy-liveness.test.ts @@ -4,6 +4,7 @@ import { runStartupReadinessSync, } from "../src/server/readiness"; import { + attestLiveManagementProxy, findLiveProxy, isCodexCommanderHealthz, probeHostname, @@ -11,6 +12,11 @@ import { proxyIdentityAt, validateReadyzBody, } from "../src/server/proxy-liveness"; +import { createLocalAttestationProof } from "../src/lib/local-management-attestation"; +import { + ATTESTATION_CHALLENGE_HEADER, + ATTESTATION_PROOF_HEADER, +} from "../src/identity"; function healthz(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status }); @@ -131,6 +137,121 @@ describe("proxyIdentityAt", () => { }); }); +describe("attestLiveManagementProxy", () => { + const pid = 4242; + const port = 19191; + const hostname = "127.0.0.1"; + + function record(secret: string) { + return { pid, port, hostname, attestationSecret: secret }; + } + + function proofResponse(secret: string, input: string | URL | Request, init?: RequestInit): Response { + expect(String(input)).toBe(`http://${hostname}:${port}/healthz`); + const headers = new Headers(init?.headers); + expect(headers.get("authorization")).toBeNull(); + expect(headers.get("x-codexcommander-api-key")).toBeNull(); + expect(init?.body).toBeUndefined(); + const challenge = headers.get(ATTESTATION_CHALLENGE_HEADER)!; + const proof = createLocalAttestationProof(secret, challenge, pid, port)!; + return new Response("ignored", { headers: { [ATTESTATION_PROOF_HEADER]: proof } }); + } + + test("authenticates one exact protected runtime record without reading health JSON", async () => { + const secret = "A".repeat(43); + const target = await attestLiveManagementProxy({ + readRuntimeFn: () => record(secret), + verifyPidFn: candidate => candidate, + fetchFn: (async (input, init) => proofResponse(secret, input, init)) as typeof fetch, + }); + expect(target).toEqual({ pid, port, hostname, source: "runtime", baseUrl: `http://${hostname}:${port}` }); + }); + + test("rejects missing/malformed records and wrong proofs", async () => { + let fetchCalls = 0; + for (const runtime of [ + null, + { pid, port, hostname }, + record("short"), + { ...record("A".repeat(43)), pid: undefined }, + ]) { + expect(await attestLiveManagementProxy({ + attempts: 1, + readRuntimeFn: () => runtime, + verifyPidFn: candidate => candidate, + fetchFn: (async () => { + fetchCalls += 1; + return new Response("should not be reached"); + }) as typeof fetch, + })).toBeNull(); + } + expect(fetchCalls).toBe(0); + + const secret = "A".repeat(43); + expect(await attestLiveManagementProxy({ + attempts: 1, + readRuntimeFn: () => record(secret), + verifyPidFn: candidate => candidate, + fetchFn: (async () => new Response("spoof", { + headers: { [ATTESTATION_PROOF_HEADER]: "B".repeat(43) }, + })) as typeof fetch, + })).toBeNull(); + }); + + test("a record rotation retries from a fresh record and challenge", async () => { + const first = "A".repeat(43); + const second = "B".repeat(43); + let reads = 0; + let challenges = 0; + const target = await attestLiveManagementProxy({ + attempts: 2, + readRuntimeFn: () => { + reads += 1; + // attempt 1 pre=A, post=B; attempt 2 pre/post=B + return record(reads === 1 ? first : second); + }, + verifyPidFn: candidate => candidate, + fetchFn: (async (input, init) => { + challenges += 1; + return proofResponse(challenges === 1 ? first : second, input, init); + }) as typeof fetch, + }); + expect(target?.baseUrl).toBe(`http://${hostname}:${port}`); + expect(challenges).toBe(2); + }); + + test("never consumes declared-huge or unbounded streaming spoof bodies", async () => { + const secret = "A".repeat(43); + let pulls = 0; + let cancels = 0; + const body = new ReadableStream({ + pull(controller) { + pulls += 1; + controller.enqueue(new Uint8Array(1024)); + }, + cancel() { cancels += 1; }, + }); + const target = await attestLiveManagementProxy({ + attempts: 1, + readRuntimeFn: () => record(secret), + verifyPidFn: candidate => candidate, + fetchFn: (async (_input, init) => { + const challenge = new Headers(init?.headers).get(ATTESTATION_CHALLENGE_HEADER)!; + const proof = createLocalAttestationProof(secret, challenge, pid, port)!; + return new Response(body, { + headers: { + "content-length": String(1024 ** 3), + [ATTESTATION_PROOF_HEADER]: proof, + }, + }); + }) as typeof fetch, + }); + expect(target).not.toBeNull(); + expect(cancels).toBe(1); + expect(pulls).toBeLessThanOrEqual(1); + }); +}); + describe("findLiveProxy", () => { test("prefers the runtime-port record over config.port (fallback-port starts are found)", async () => { const urls: string[] = []; @@ -414,10 +535,34 @@ describe("createReadinessGate", () => { // JSON.stringify of a method-only object returns "{}", so it cannot prove // the absence of closure-held diagnostic fields. Assert the own-property // surface directly: exactly the three control methods and no data field. - expect(Object.keys(gate).sort()).toEqual(["getStatus", "markFailed", "markReady"]); + expect(Object.keys(gate).sort()).toEqual(["getStatus", "markFailed", "markReady", "recoverReady"]); // The only readable value is the fixed sanitized enum. expect(["pending", "ready", "failed"]).toContain(gate.getStatus()); }); + + test("an explicit successful-sync recovery can promote a failed gate", () => { + const gate = createReadinessGate(); + gate.markFailed(); + gate.recoverReady(); + expect(gate.getStatus()).toBe("ready"); + }); + + test("recovery cannot bypass an in-flight pending startup", () => { + const gate = createReadinessGate(); + gate.recoverReady(); + expect(gate.getStatus()).toBe("pending"); + + // Startup remains authoritative and can still settle the pending gate. + gate.markFailed(); + expect(gate.getStatus()).toBe("failed"); + }); + + test("recovery is idempotent once the gate is already ready", () => { + const gate = createReadinessGate(); + gate.markReady(); + gate.recoverReady(); + expect(gate.getStatus()).toBe("ready"); + }); }); // ── runStartupReadinessSync drives the gate from the sync outcome ────────────── diff --git a/tests/route-explainability.test.ts b/tests/route-explainability.test.ts index 83c6090e38..4f78fc9479 100644 --- a/tests/route-explainability.test.ts +++ b/tests/route-explainability.test.ts @@ -239,6 +239,13 @@ describe("route explainability (RI-09)", () => { const calls: Array<{ path: string; init?: RequestInit }> = []; const ok = await handleRoutePolicyCommand(["evaluate", "fast", "--tools", "--json"], { baseUrl: "http://cli.test", + attestLiveManagementProxyImpl: async () => ({ + pid: 4242, + port: 80, + hostname: "cli.test", + source: "runtime", + baseUrl: "http://cli.test", + }), fetchImpl: async (input, init) => { const path = String(input).replace("http://cli.test", ""); calls.push({ path, init }); diff --git a/tests/server-live.test.ts b/tests/server-live.test.ts index 01db79e77f..995b7bc4bd 100644 --- a/tests/server-live.test.ts +++ b/tests/server-live.test.ts @@ -16,6 +16,7 @@ import { } from "../src/server/readiness"; import { startServer } from "../src/server"; import { beginShutdownDrain, isDraining, resetLifecycleDrainStateForTests } from "../src/server/lifecycle"; +import type { ManagementAuthState } from "../src/server/management-auth"; import type { CodexCommanderConfig } from "../src/types"; import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; @@ -958,6 +959,142 @@ describe("GET /readyz", () => { } }); + test("authenticated full sync recovers a failed live server only after post-sync activation proof", async () => { + const desiredConfig = forwardConfig(); + saveConfig(desiredConfig); + const gate = createReadinessGate(); + gate.markFailed(); + const adminToken = "ccx_admin_live-readiness-recovery"; + const managementAuthState: ManagementAuthState = { + available: true, + token: adminToken, + source: "environment", + sessions: new Map(), + }; + const server = startServer(0, { + readinessGate: gate, + managementAuthState, + managementApi: { + syncModelsToCodex: async () => ({ + status: "applied", + ok: true, + added: 0, + catalogPath: null, + catalogExists: true, + catalogWritten: false, + cacheSynced: false, + catalogQuality: "live", + rehydrated: 0, + message: "synchronized", + }), + readRuntimePort: () => null, + resetCodexAppServerCatalogStateCache: () => {}, + collectCodexAppServerCatalogState: () => ({ + state: "not_running", + processes: [], + catalogMtimeMs: null, + }), + captureCatalogDesiredSnapshotForActivation: () => ({ + config: desiredConfig, + authority: { + generation: { value: 1 }, + semanticIdentity: "semantic", + contentIdentity: "content", + referenceIdentity: "reference", + }, + revision: "stable-live-revision", + }) as never, + catalogArtifactProofForActivation: () => "current", + codexRoutingKindForActivation: () => "codexcommander-local", + }, + }); + try { + const health = await fetch(new URL("/healthz", server.url)); + expect(health.status).toBe(200); + const failed = await fetch(new URL("/readyz", server.url)); + expect(failed.status).toBe(503); + expect(((await failed.json()) as { status: string }).status).toBe("failed"); + + const sync = await fetch(new URL("/api/sync", server.url), { + method: "POST", + headers: { "x-codexcommander-api-key": adminToken }, + }); + expect(sync.status).toBe(200); + + const recovered = await fetch(new URL("/readyz", server.url)); + expect(recovered.status).toBe(200); + expect(((await recovered.json()) as { status: string }).status).toBe("ready"); + } finally { + await server.stop(true); + } + }); + + test("manual sync cannot promote pending readiness before startup settles", async () => { + const desiredConfig = forwardConfig(); + saveConfig(desiredConfig); + const gate = createReadinessGate(); + const adminToken = "ccx_admin_pending-readiness-overlap"; + const managementAuthState: ManagementAuthState = { + available: true, + token: adminToken, + source: "environment", + sessions: new Map(), + }; + const server = startServer(0, { + readinessGate: gate, + managementAuthState, + managementApi: { + syncModelsToCodex: async () => ({ + status: "applied", + ok: true, + added: 0, + catalogPath: null, + catalogExists: true, + catalogWritten: false, + cacheSynced: false, + catalogQuality: "live", + rehydrated: 0, + message: "synchronized", + }), + readRuntimePort: () => null, + resetCodexAppServerCatalogStateCache: () => {}, + collectCodexAppServerCatalogState: () => ({ + state: "not_running", + processes: [], + catalogMtimeMs: null, + }), + captureCatalogDesiredSnapshotForActivation: () => ({ + config: desiredConfig, + authority: { + generation: { value: 1 }, + semanticIdentity: "semantic", + contentIdentity: "content", + referenceIdentity: "reference", + }, + revision: "stable-pending-revision", + }) as never, + catalogArtifactProofForActivation: () => "current", + codexRoutingKindForActivation: () => "codexcommander-local", + }, + }); + try { + const sync = await fetch(new URL("/api/sync", server.url), { + method: "POST", + headers: { "x-codexcommander-api-key": adminToken }, + }); + expect(sync.status).toBe(200); + const stillPending = await fetch(new URL("/readyz", server.url)); + expect(stillPending.status).toBe(503); + expect(((await stillPending.json()) as { status: string }).status).toBe("pending"); + + // The original startup settlement is still authoritative. + gate.markFailed(); + expect(gate.getStatus()).toBe("failed"); + } finally { + await server.stop(true); + } + }); + test("exact method/path: POST /readyz and GET /readyz/ are NOT matched", async () => { saveConfig(forwardConfig()); const gate = createReadinessGate(); diff --git a/tests/server-management-auth.test.ts b/tests/server-management-auth.test.ts index 78df2f704c..5515130308 100644 --- a/tests/server-management-auth.test.ts +++ b/tests/server-management-auth.test.ts @@ -3,14 +3,17 @@ import { SERVER_BUDGET_MS } from "./helpers/test-budget"; import { mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { createConnection } from "node:net"; import { saveConfig } from "../src/config"; import { startServer } from "../src/server"; import type { CodexCommanderConfig } from "../src/types"; -import { serveGuiFile, serveSessionBootstrap } from "../src/server/gui-static"; +import { serveGuiFile } from "../src/server/gui-static"; import { isProxyAdmissionSecret } from "../src/server/auth-cors"; import { initializeManagementAuthState, - issueGuiSession, + exchangeGuiLaunchTicket, + issueGuiLaunchTicket, + managementPrincipal, removeManagementTokenPathBestEffort, requireManagementAuth, } from "../src/server/management-auth"; @@ -74,6 +77,27 @@ function websocketHandshakeOpens(url: URL, token: string): Promise { }); } +function rawHttpRequest(port: number, request: string): Promise<{ status: number; raw: string }> { + return new Promise((resolve, reject) => { + let raw = ""; + let settled = false; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + socket.destroy(); + if (error) return reject(error); + const match = raw.match(/^HTTP\/1\.[01] (\d{3})/); + resolve({ status: match ? Number(match[1]) : 0, raw }); + }; + const socket = createConnection({ host: "127.0.0.1", port }, () => socket.write(request)); + socket.setEncoding("utf8"); + socket.setTimeout(5_000, () => finish(new Error("raw HTTP request timed out"))); + socket.on("data", chunk => { raw += chunk; }); + socket.on("end", () => finish()); + socket.on("error", error => finish(error)); + }); +} + beforeEach(() => { testHome = mkdtempSync(join(tmpdir(), "ccx-management-auth-")); process.env.CODEXCOMMANDER_HOME = testHome; @@ -386,83 +410,164 @@ describe("management and data-plane credential separation", () => { } }); - test("a local GUI page receives an origin-bound session with CSRF protection", async () => { - const config = remoteConfig(); - config.hostname = "127.0.0.1"; - const state = initializeManagementAuthState(config); - const pageRequest = new Request("http://localhost:10100/", { - headers: { Host: "localhost:10100" }, - }); - const session = issueGuiSession(pageRequest, config, state); - expect(session).not.toBeNull(); - + test("a static GUI page never embeds a management bearer", async () => { const guiDist = join(testHome, "gui"); const { mkdirSync, writeFileSync } = await import("node:fs"); mkdirSync(guiDist); writeFileSync(join(guiDist, "index.html"), ""); - const page = serveGuiFile("/", guiDist, session ?? undefined); + const page = serveGuiFile("/", guiDist); expect(page?.headers.get("cache-control")).toBe("no-store"); const html = await page?.text(); - expect(html).toContain(`name="codexcommander-session-token" content="${session?.token}"`); - expect(html).toContain(`name="codexcommander-session-csrf" content="${session?.csrfToken}"`); - - // The dev GUI fetches /codexcommander-session through Vite so the app shell stays - // Vite-owned. The backend answers that path without requiring gui/dist, so a fresh - // source checkout (no packaged build) can still mint an origin-bound session. - const bootstrapPage = serveSessionBootstrap(session!); - const bootstrapHtml = await bootstrapPage.text(); - expect(bootstrapHtml).toContain(`name="codexcommander-session-origin" content="${session?.origin}"`); - expect(bootstrapHtml).toContain(`name="codexcommander-session-token" content="${session?.token}"`); - - const sameOriginRead = new Request("http://localhost:10100/api/config", { - headers: { - Host: "localhost:10100", - "x-codexcommander-api-key": session?.token ?? "", - "x-codexcommander-gui-origin": "http://localhost:10100", - }, - }); - expect(requireManagementAuth(sameOriginRead, state, config)).toBeNull(); + expect(html).not.toContain("codexcommander-session-token"); + expect(html).not.toContain("codexcommander-session-csrf"); + expect(html).not.toContain("ccx_session_"); + }); - const crossPortRead = new Request("http://localhost:10100/api/config", { - headers: { - Host: "localhost:10100", - Origin: "http://localhost:20100", - "x-codexcommander-api-key": session?.token ?? "", - "x-codexcommander-gui-origin": "http://localhost:20100", - }, + test("launch tickets are single-use, exact-origin/route bound, and create a confirmed principal", () => { + const config = remoteConfig(); + config.hostname = "127.0.0.1"; + const state = initializeManagementAuthState(config); + const now = Date.now(); + const mintRequest = new Request("http://127.0.0.1:10100/api/gui-launch-ticket", { + method: "POST", + headers: { Host: "127.0.0.1:10100" }, }); - expect(requireManagementAuth(crossPortRead, state, config)?.status).toBe(401); + const issued = issueGuiLaunchTicket(mintRequest, "subagents", config, state, now); + expect(issued).toMatchObject({ + origin: "http://127.0.0.1:10100", + route: "subagents", + expiresAt: now + 30_000, + }); + expect(issued?.ticket).toMatch(/^ccx_launch_[A-Za-z0-9_-]{43}$/); - const mutationWithoutCsrf = new Request("http://localhost:10100/api/config", { + const wrongOrigin = new Request("http://127.0.0.1:10100/api/gui-launch-exchange", { method: "POST", - headers: { - Host: "localhost:10100", - Origin: "http://localhost:10100", - "x-codexcommander-api-key": session?.token ?? "", - "x-codexcommander-gui-origin": "http://localhost:10100", - }, + headers: { Host: "127.0.0.1:10100", Origin: "http://localhost:10100" }, }); - expect(requireManagementAuth(mutationWithoutCsrf, state, config)?.status).toBe(401); - - const mutationWithCsrf = new Request("http://localhost:10100/api/config", { + expect(exchangeGuiLaunchTicket( + wrongOrigin, + issued?.ticket, + "subagents", + config, + state, + now + 1, + )).toBeNull(); + + const exactOrigin = new Request("http://127.0.0.1:10100/api/gui-launch-exchange", { method: "POST", + headers: { Host: "127.0.0.1:10100", Origin: "http://127.0.0.1:10100" }, + }); + // The failed origin attempt consumed the bearer before comparison. + expect(exchangeGuiLaunchTicket( + exactOrigin, + issued?.ticket, + "subagents", + config, + state, + now + 2, + )).toBeNull(); + + const second = issueGuiLaunchTicket(mintRequest, "subagents", config, state, now + 10)!; + expect(exchangeGuiLaunchTicket( + exactOrigin, + second.ticket, + "wrong-route", + config, + state, + now + 11, + )).toBeNull(); + expect(exchangeGuiLaunchTicket( + exactOrigin, + second.ticket, + "subagents", + config, + state, + now + 12, + )).toBeNull(); + + const third = issueGuiLaunchTicket(mintRequest, "subagents", config, state, now + 20)!; + const session = exchangeGuiLaunchTicket( + exactOrigin, + third.ticket, + "subagents", + config, + state, + now + 21, + ); + expect(session).toMatchObject({ + origin: "http://127.0.0.1:10100", + confirmedLaunch: true, + expiresAt: now + 8 * 60 * 60_000 + 21, + }); + const authorized = new Request("http://127.0.0.1:10100/api/settings", { + method: "PUT", headers: { - Host: "localhost:10100", - Origin: "http://localhost:10100", + Host: "127.0.0.1:10100", + Origin: "http://127.0.0.1:10100", "x-codexcommander-api-key": session?.token ?? "", - "x-codexcommander-gui-origin": "http://localhost:10100", + "x-codexcommander-gui-origin": "http://127.0.0.1:10100", "x-codexcommander-csrf-token": session?.csrfToken ?? "", }, }); - expect(requireManagementAuth(mutationWithCsrf, state, config)).toBeNull(); + expect(requireManagementAuth(authorized, state, config)).toBeNull(); + expect(managementPrincipal(authorized, state, config)).toBe("confirmed-gui-session"); + }); - expect(issueGuiSession(new Request("http://attacker.test/", { - headers: { Host: "attacker.test" }, - }), config, state)).toBeNull(); - expect(issueGuiSession(new Request("http://localhost:10100/"), config, state)).toBeNull(); + test("launch tickets expire, are bounded, and disappear with process state", () => { + const config = remoteConfig(); + config.hostname = "127.0.0.1"; + const state = initializeManagementAuthState(config); + const mintRequest = new Request("http://127.0.0.1:10100/api/gui-launch-ticket", { + method: "POST", + headers: { Host: "127.0.0.1:10100" }, + }); + const exchangeRequest = new Request("http://127.0.0.1:10100/api/gui-launch-exchange", { + method: "POST", + headers: { Host: "127.0.0.1:10100", Origin: "http://127.0.0.1:10100" }, + }); + const expired = issueGuiLaunchTicket(mintRequest, "dashboard", config, state, 10_000)!; + expect(exchangeGuiLaunchTicket( + exchangeRequest, + expired.ticket, + "dashboard", + config, + state, + 40_000, + )).toBeNull(); + + const issued = Array.from({ length: 17 }, (_, index) => ( + issueGuiLaunchTicket(mintRequest, `dashboard/${index}`, config, state, 50_000 + index)! + )); + expect(exchangeGuiLaunchTicket( + exchangeRequest, + issued[0]!.ticket, + issued[0]!.route, + config, + state, + 50_100, + )).toBeNull(); + expect(exchangeGuiLaunchTicket( + exchangeRequest, + issued[16]!.ticket, + issued[16]!.route, + config, + state, + 50_100, + )?.confirmedLaunch).toBe(true); + + const beforeRestart = issueGuiLaunchTicket(mintRequest, "dashboard", config, state, 60_000)!; + const replacementState = initializeManagementAuthState(config); + expect(exchangeGuiLaunchTicket( + exchangeRequest, + beforeRestart.ticket, + "dashboard", + config, + replacementState, + 60_001, + )).toBeNull(); }); - test("GET /codexcommander-session serves the bootstrap document from a live server", async () => { + test("legacy GUI bootstrap is a no-store tombstone and never returns credentials", async () => { const config = remoteConfig(); config.hostname = "127.0.0.1"; saveConfig(config); @@ -471,29 +576,215 @@ describe("management and data-plane credential separation", () => { const response = await fetch(new URL("/codexcommander-session", server.url), { headers: { Host: server.url.host }, }); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe("text/html"); + expect(response.status).toBe(410); + expect(response.headers.get("content-type")).toContain("application/json"); expect(response.headers.get("cache-control")).toBe("no-store"); - expect(response.headers.get("pragma")).toBe("no-cache"); expect(response.headers.get("x-frame-options")).toBe("DENY"); expect(response.headers.get("content-security-policy")).toContain("frame-ancestors 'none'"); const html = await response.text(); - expect(html).toContain('name="codexcommander-session-token"'); - expect(html).toContain('name="codexcommander-session-csrf"'); - expect(html).toContain('name="codexcommander-session-origin"'); + expect(html).not.toContain("codexcommander-session-token"); + expect(html).not.toContain("ccx_session_"); + } finally { + await server.stop(true); + } + }); + + test("admin launch-ticket mint remains available when data-plane auth is enabled", async () => { + const config = remoteConfig(); + config.hostname = "127.0.0.1"; + saveConfig(config); + const server = startServer(0); + try { + const response = await fetch(new URL("/api/gui-launch-ticket", server.url), { + method: "POST", + headers: { + "content-type": "application/json", + "x-codexcommander-api-key": "admin-secret", + }, + body: JSON.stringify({ route: "dashboard" }), + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + origin: server.url.origin, + route: "dashboard", + }); } finally { await server.stop(true); } }); - test("a non-loopback binding never issues a GUI session from a forged loopback Host", () => { + test("pre-auth launch exchange rejects unbound or unbounded bodies before ticket consumption", async () => { + const config = remoteConfig(); + config.hostname = "127.0.0.1"; + saveConfig(config); + const server = startServer(0); + try { + const mint = async () => { + const response = await fetch(new URL("/api/gui-launch-ticket", server.url), { + method: "POST", + headers: { + "content-type": "application/json", + "x-codexcommander-api-key": "admin-secret", + }, + body: JSON.stringify({ route: "dashboard" }), + }); + expect(response.status).toBe(200); + return response.json() as Promise<{ ticket: string; route: string }>; + }; + const ticket = await mint(); + const exchange = new URL("/api/gui-launch-exchange", server.url); + const body = JSON.stringify({ ticket: ticket.ticket, route: ticket.route }); + + const get = await fetch(exchange); + expect(get.status).toBe(400); + expect(get.headers.get("cache-control")).toBe("no-store"); + + const missingOrigin = await fetch(exchange, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); + expect(missingOrigin.status).toBe(401); + expect(missingOrigin.headers.get("cache-control")).toBe("no-store"); + + const wrongOrigin = await fetch(exchange, { + method: "POST", + headers: { Origin: "http://localhost:65534", "content-type": "application/json" }, + body, + }); + expect(wrongOrigin.status).toBe(401); + expect(wrongOrigin.headers.get("access-control-allow-origin")).not.toBe("http://localhost:65534"); + + const hostileHost = await fetch(exchange, { + method: "POST", + headers: { Host: "attacker.test", Origin: "http://attacker.test", "content-type": "application/json" }, + body, + }); + expect(hostileHost.status).toBe(401); + expect(hostileHost.headers.get("access-control-allow-origin")).not.toBe("http://attacker.test"); + + const compressed = await fetch(exchange, { + method: "POST", + headers: { + Origin: server.url.origin, + "content-type": "application/json", + "content-encoding": "gzip", + }, + body, + }); + expect(compressed.status).toBe(415); + expect(compressed.headers.get("cache-control")).toBe("no-store"); + + const oversized = `${body.slice(0, -1)},"padding":"${"x".repeat(2_100)}"}`; + const first = oversized.slice(0, 1_000); + const second = oversized.slice(1_000); + const rawLarge = await rawHttpRequest(server.port, [ + "POST /api/gui-launch-exchange HTTP/1.1", + `Host: ${server.url.host}`, + `Origin: ${server.url.origin}`, + "Content-Type: application/json", + "Transfer-Encoding: chunked", + "Connection: close", + "", + `${Buffer.byteLength(first).toString(16)}\r\n${first}\r\n${Buffer.byteLength(second).toString(16)}\r\n${second}\r\n0`, + "", + "", + ].join("\r\n")); + expect(rawLarge.status).toBe(413); + expect(rawLarge.raw.toLowerCase()).toContain("cache-control: no-store"); + + const malformedLength = await rawHttpRequest(server.port, [ + "POST /api/gui-launch-exchange HTTP/1.1", + `Host: ${server.url.host}`, + `Origin: ${server.url.origin}`, + "Content-Type: application/json", + "Content-Length: -1", + "Connection: close", + "", + "", + ].join("\r\n")); + // Bun's HTTP parser may close malformed framing before application code + // (status 0) or synthesize 400; either outcome refuses it before body bytes. + expect([0, 400]).toContain(malformedLength.status); + + // All preflight/body refusals happened before the live ticket was looked up. + const accepted = await fetch(exchange, { + method: "POST", + headers: { Origin: server.url.origin, "Content-Type": "Application/JSON; Charset=UTF-8" }, + body, + }); + expect(accepted.status).toBe(200); + expect(accepted.headers.get("cache-control")).toBe("no-store"); + } finally { + await server.stop(true); + } + }, SERVER_BUDGET_MS); + + test("manual GUI requests have no API access while confirmed and admin principals may mutate", async () => { + delete process.env.CODEXCOMMANDER_API_AUTH_TOKEN; const config = remoteConfig(); + config.hostname = "127.0.0.1"; + saveConfig(config); const state = initializeManagementAuthState(config); - const request = new Request("http://localhost:10100/", { - headers: { Host: "localhost:10100" }, - }); - expect(issueGuiSession(request, config, state)).toBeNull(); + const server = startServer(0, { managementAuthState: state }); + try { + expect((await fetch(new URL("/api/settings", server.url))).status).toBe(401); + expect((await fetch(new URL("/api/provider-quotas?refresh=1", server.url))).status).toBe(401); + expect((await fetch(new URL("/api/providers?name=test", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ note: "confirmed authorization regression" }), + })).status).toBe(401); + expect((await fetch(new URL("/api/providers/test?name=test", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + })).status).toBe(401); + + const mintedResponse = await fetch(new URL("/api/gui-launch-ticket", server.url), { + method: "POST", + headers: { + "content-type": "application/json", + "x-codexcommander-api-key": "admin-secret", + }, + body: JSON.stringify({ route: "providers/test/settings" }), + }); + expect(mintedResponse.status).toBe(200); + const minted = await mintedResponse.json() as { ticket: string; route: string }; + const exchangeResponse = await fetch(new URL("/api/gui-launch-exchange", server.url), { + method: "POST", + headers: { Origin: server.url.origin, "content-type": "application/json" }, + body: JSON.stringify({ ticket: minted.ticket, route: minted.route }), + }); + expect(exchangeResponse.status).toBe(200); + const exchanged = await exchangeResponse.json() as { + session: { token: string; csrfToken: string; origin: string; confirmedLaunch: boolean }; + }; + expect(exchanged.session.confirmedLaunch).toBe(true); + const confirmedHeaders = { + Origin: server.url.origin, + "content-type": "application/json", + "x-codexcommander-api-key": exchanged.session.token, + "x-codexcommander-gui-origin": exchanged.session.origin, + "x-codexcommander-csrf-token": exchanged.session.csrfToken, + }; + expect((await fetch(new URL("/api/providers?name=test", server.url), { + method: "PATCH", + headers: confirmedHeaders, + body: JSON.stringify({ note: "confirmed authorization regression" }), + })).status).toBe(200); + expect((await fetch(new URL("/api/providers?name=test", server.url), { + method: "PATCH", + headers: { + "content-type": "application/json", + "x-codexcommander-api-key": "admin-secret", + }, + body: JSON.stringify({ note: "admin authorization regression" }), + })).status).toBe(200); + } finally { + await server.stop(true); + } }); test("all local credential shapes are rejected by the upstream-forwarding guard", () => { diff --git a/tests/sidebar-routes.test.ts b/tests/sidebar-routes.test.ts index 9cb4c7ef99..225f8fb295 100644 --- a/tests/sidebar-routes.test.ts +++ b/tests/sidebar-routes.test.ts @@ -12,7 +12,7 @@ async function call( method: string, pathname: string, headers: Record = {}, - principal?: "admin-token" | "gui-session", + principal?: "admin-token" | "confirmed-gui-session", ): Promise<{ status: number; body: unknown; raw: string; routed: boolean }> { // `isAllowedManagementOrigin` derives the expected origin from the Host header and // rejects the request outright when it is missing, so Host is required here. Omitting diff --git a/tests/stale-state-purge.test.ts b/tests/stale-state-purge.test.ts index 1edcaf38b3..403799820a 100644 --- a/tests/stale-state-purge.test.ts +++ b/tests/stale-state-purge.test.ts @@ -70,6 +70,6 @@ describe("snapshot-guarded stale-state purge", () => { test("gui opens the actual bind host", () => { const cliSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "index.ts"), "utf8"); - expect(cliSource).toContain("const guiHost = probeHostname(live?.hostname ?? config.hostname)"); + expect(cliSource).toContain("const guiHost = probeHostname(live.hostname)"); }); });