From 4f159477109d47d2e75f77d7caf6551d5b89e2ab Mon Sep 17 00:00:00 2001 From: pavelhov Date: Mon, 10 Aug 2026 12:27:49 -0400 Subject: [PATCH] Add app-managed startup health and UI --- app/Sources/MenuBarCore/ProxyClient.swift | 36 ++ app/Sources/MenuBarCore/ProxySnapshot.swift | 11 +- .../MenuBarCoreTests/SnapshotStateSuite.swift | 5 +- .../MenuBarCoreTests/TransportSuite.swift | 66 +++ app/Sources/MenuBarUI/AppDelegate.swift | 152 +++++++ .../MenuBarUI/PopoverViewController.swift | 33 +- app/Sources/MenuBarUI/StatusIcon.swift | 9 +- app/Sources/MenuBarUI/Theme.swift | 2 + app/Sources/MenuBarUI/Views.swift | 19 +- app/Sources/MenuBarUITests/main.swift | 215 +++++++++- design-qa.md | 41 ++ .../src/content/docs/guides/macos-menu-bar.md | 15 +- .../src/content/docs/guides/web-dashboard.md | 5 +- .../content/docs/ja/guides/web-dashboard.md | 5 +- .../docs/ja/reference/management-api.md | 3 +- .../content/docs/ko/guides/web-dashboard.md | 5 +- .../docs/ko/reference/management-api.md | 3 +- .../content/docs/reference/management-api.md | 3 +- .../content/docs/ru/guides/web-dashboard.md | 5 +- .../docs/ru/reference/management-api.md | 3 +- .../docs/zh-cn/guides/web-dashboard.md | 5 +- .../docs/zh-cn/reference/management-api.md | 3 +- gui/src/App.tsx | 12 +- gui/src/i18n/de.ts | 31 +- gui/src/i18n/en.ts | 29 +- gui/src/i18n/ja.ts | 31 +- gui/src/i18n/ko.ts | 31 +- gui/src/i18n/ru.ts | 31 +- gui/src/i18n/zh.ts | 31 +- gui/src/pages/Startup.tsx | 24 +- gui/src/pages/dashboard-shared.ts | 2 +- gui/src/pages/startup-sections.tsx | 312 ++++++++------ gui/src/pages/startup-shared.ts | 121 +++++- gui/src/startup-health-ui.ts | 11 +- gui/src/styles.css | 63 ++- gui/tests/startup-app-managed.test.tsx | 200 +++++++++ gui/tests/startup-nav-entry.test.ts | 24 ++ gui/tests/startup-view-model.test.ts | 145 +++++++ src/codex/autostart-health.ts | 44 +- src/server/companion-startup-state.ts | 180 ++++++++ src/server/management/config-routes.ts | 31 +- src/server/startup-health-cache.ts | 8 +- structure/05_gui-and-management-api.md | 25 +- tests/companion-startup-state.test.ts | 403 ++++++++++++++++++ 44 files changed, 2216 insertions(+), 217 deletions(-) create mode 100644 design-qa.md create mode 100644 gui/tests/startup-app-managed.test.tsx create mode 100644 gui/tests/startup-nav-entry.test.ts create mode 100644 gui/tests/startup-view-model.test.ts create mode 100644 src/server/companion-startup-state.ts create mode 100644 tests/companion-startup-state.test.ts diff --git a/app/Sources/MenuBarCore/ProxyClient.swift b/app/Sources/MenuBarCore/ProxyClient.swift index c42c4e0831..6759c603da 100644 --- a/app/Sources/MenuBarCore/ProxyClient.swift +++ b/app/Sources/MenuBarCore/ProxyClient.swift @@ -95,6 +95,31 @@ public struct RestartAccepted: Decodable, Equatable, Sendable { } } +/// Body for `PUT /api/startup-health/companion`. The server accepts exactly this +/// locked shape: no client timestamps, TTLs, PIDs, paths, or bundle metadata. +public struct CompanionStartupReport: Encodable, Equatable, Sendable { + public let version: Int + public let launchAtLogin: String + + public init(launchAtLogin: String) { + self.version = 1 + self.launchAtLogin = launchAtLogin + } +} + +public extension LaunchAtLoginStatus { + /// Wire value for the companion report. The server contract is kebab-case + /// (`requires-approval`), which differs from the Swift case raw values. + var companionWireValue: String { + switch self { + case .enabled: return "enabled" + case .disabled: return "disabled" + case .requiresApproval: return "requires-approval" + case .unavailable: return "unavailable" + } + } +} + /// A management client that proves local CodexCommander identity immediately before every /// credential-bearing request. Discovery is repeated for each request and retry, so a /// restart never reuses stale descriptors, endpoint metadata, or token bytes. @@ -187,6 +212,17 @@ public actor ProxyClient { } } + /// 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 { + let payload = CompanionStartupReport(launchAtLogin: launchAtLogin.companionWireValue) + _ = try await authenticatedSend( + method: "PUT", + path: "api/startup-health/companion", + body: payload + ) + } + public func liveness(timeout: TimeInterval = 1.5) async -> Liveness { do { try rediscover() diff --git a/app/Sources/MenuBarCore/ProxySnapshot.swift b/app/Sources/MenuBarCore/ProxySnapshot.swift index d8b07068e5..87be810243 100644 --- a/app/Sources/MenuBarCore/ProxySnapshot.swift +++ b/app/Sources/MenuBarCore/ProxySnapshot.swift @@ -37,7 +37,16 @@ public enum ProxyState: Equatable, Sendable { public var tone: Tone { switch self { case .loading: return .neutral - case .running(let health): return health.isProtected ? .good : .warning + case .running(let health): + // Startup assurance and live proxy health are separate signals. The normal + // app-managed (`caution`) setup is healthy while running, so it must not + // paint the menu-bar header amber. Reserve warning for a verified at-risk + // startup state; unknown future states stay neutral instead of guessing. + switch health.status { + case "native", "protected", "caution": return .good + case "at-risk": return .warning + default: return .neutral + } case .unreachable: return .bad case .unauthorized: return .warning case .degraded: return .warning diff --git a/app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift b/app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift index bb7eb0660a..1381d399e8 100644 --- a/app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift +++ b/app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift @@ -48,9 +48,12 @@ enum SnapshotStateSuite { t.equal(ProxyState.unauthorized.title, "Authentication unavailable") } - t.test("state: an unprotected but running proxy reads as a warning, not healthy") { + t.test("state: app-managed running is healthy; only verified at-risk startup warns") { t.equal(ProxyState.running(health("protected")).tone, .good) + t.equal(ProxyState.running(health("caution")).tone, .good) + t.equal(ProxyState.running(health("native")).tone, .good) t.equal(ProxyState.running(health("at-risk")).tone, .warning) + t.equal(ProxyState.running(health("some-future-state")).tone, .neutral) t.equal(ProxyState.unreachable.tone, .bad) } diff --git a/app/Sources/MenuBarCoreTests/TransportSuite.swift b/app/Sources/MenuBarCoreTests/TransportSuite.swift index b12db45cfb..2ca2beaeb4 100644 --- a/app/Sources/MenuBarCoreTests/TransportSuite.swift +++ b/app/Sources/MenuBarCoreTests/TransportSuite.swift @@ -246,6 +246,55 @@ enum TransportSuite { t.equal(snapshot?.activities[1].parentId, "opaque-a") t.equal(snapshot?.activities[1].phase, .starting) } + + t.test("transport: companion startup report PUTs the locked body with the admin token") { + StubProtocol.reset([ + .init(status: 200, body: identity), + .init(status: 204), + ]) + let client = makeClient(credential: "admin-secret") + let outcome = sync { await proxyError { + try await client.reportCompanionStartupState(launchAtLogin: .enabled) + } } + t.isNil(outcome, "a 204 is success") + let requests = StubProtocol.recorded + t.equal(requests.count, 2) + t.equal(requests[1].httpMethod, "PUT") + t.equal(requests[1].url?.path, "/api/startup-health/companion") + 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?["version"] as? Int, 1, "locked version") + t.equal(body?["launchAtLogin"] as? String, "enabled", "wire launch-at-login value") + t.equal(body?.count ?? 0, 2, "no client timestamps, TTLs, or metadata accepted") + } + + t.test("transport: companion report maps every launch-at-login state to the wire contract") { + t.equal(LaunchAtLoginStatus.enabled.companionWireValue, "enabled") + t.equal(LaunchAtLoginStatus.disabled.companionWireValue, "disabled") + t.equal(LaunchAtLoginStatus.requiresApproval.companionWireValue, "requires-approval") + t.equal(LaunchAtLoginStatus.unavailable.companionWireValue, "unavailable") + } + + t.test("transport: companion report failure surfaces as a proxy error, never a body") { + StubProtocol.reset([ + .init(status: 200, body: identity), + .init(status: 403, body: "COMPANION-SECRET-CONFIG"), + ]) + let client = makeClient(credential: "admin-secret") + let error = sync { await proxyError { + try await client.reportCompanionStartupState(launchAtLogin: .enabled) + } } + t.equal(error, .http(403)) + t.expect( + !(error?.userMessage.contains("COMPANION") ?? false), + "error text must not echo response bodies" + ) + } } private static func makeClient(credential: String?) -> ProxyClient { @@ -258,6 +307,23 @@ enum TransportSuite { ) } + /// URLSession can move a small httpBody into httpBodyStream before the URLProtocol + /// sees the request; drain whichever representation is present. + private static func requestStreamBodyData(_ request: URLRequest) -> Data? { + guard let stream = request.httpBodyStream else { return nil } + stream.open() + defer { stream.close() } + var data = Data() + let bufferSize = 1024 + var buffer = [UInt8](repeating: 0, count: bufferSize) + while stream.hasBytesAvailable { + let read = stream.read(&buffer, maxLength: bufferSize) + if read <= 0 { break } + data.append(buffer, count: read) + } + return data + } + private static func proxyError( _ operation: () async throws -> T ) async -> ProxyError? { diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift index e406e3194c..ecac00152d 100644 --- a/app/Sources/MenuBarUI/AppDelegate.swift +++ b/app/Sources/MenuBarUI/AppDelegate.swift @@ -16,6 +16,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid private var lifecycleInFlight = false private var catalogActionInFlight = false private var catalogUpdateReady = false + private var companionHeartbeat: CompanionHeartbeat? private let launchAtLoginController = LaunchAtLoginController() private lazy var executableFingerprint = ExecutableFingerprint.current() private lazy var sourceRevision = BuildProvenance.shortRevision( @@ -57,6 +58,53 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid } } + /// Advisory launch-at-login reporting. The sample reads SMAppService freshly on + /// every tick and every transition; failures are swallowed so a missing or + /// restarting proxy can never alarm the user or block the transition. + @MainActor + private func makeCompanionHeartbeat(client: ProxyClient) -> CompanionHeartbeat { + CompanionHeartbeat( + sample: { [weak self] in + guard let self else { return .unavailable } + return self.launchAtLoginController.currentPresentation( + registrationAllowed: self.launchAtLoginRegistrationAllowed + ).status + }, + send: { [weak client] status in + guard let client else { return } + do { + try await client.reportCompanionStartupState(launchAtLogin: status) + } catch { + // Best-effort by design: never surface a heartbeat failure. + } + } + ) + } + + /// AppDelegate methods are nonisolated; these transitions are already on the main + /// thread, so a MainActor hop preserves the immediate-report contract. + private func startCompanionHeartbeat() { + Task { @MainActor [weak self] in + guard let self, let client = self.client else { return } + let heartbeat = self.companionHeartbeat ?? self.makeCompanionHeartbeat(client: client) + self.companionHeartbeat = heartbeat + heartbeat.start() + heartbeat.reportNow() + } + } + + private func reportCompanionHeartbeat() { + Task { @MainActor [weak self] in + self?.companionHeartbeat?.reportNow() + } + } + + private func stopCompanionHeartbeat() { + Task { @MainActor [weak self] in + self?.companionHeartbeat?.stop() + } + } + private func wire(controller: PopoverViewController, coordinator: PollingCoordinator) { let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) item.button?.image = StatusIcon.image(for: .loading) @@ -73,6 +121,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid controller.onStop = { [weak self] in self?.stopProxy() } controller.onRestart = { [weak self] in self?.restartProxy() } controller.onApplyCodexCatalog = { [weak self] in self?.applyCodexCatalog() } + controller.onOpenStartupOptions = { [weak self] in self?.openStartupOptions() } controller.onQuitMenuBar = { [weak self] in self?.quitMenuBar(nil) } controller.onStopAndQuit = { [weak self] in self?.stopCodexCommanderAndQuit(nil) } controller.onLaunchAtLoginChange = { [weak self] enabled in @@ -105,6 +154,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid registrationAllowed: launchAtLoginRegistrationAllowed ) ) + startCompanionHeartbeat() ensureProxyOnLaunch() } @@ -114,6 +164,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid registrationAllowed: launchAtLoginRegistrationAllowed ) ) + reportCompanionHeartbeat() } public func applicationShouldHandleReopen( @@ -126,6 +177,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid public func applicationWillTerminate(_ notification: Notification) { pollTask?.cancel() + stopCompanionHeartbeat() removeEscapeMonitor() panel.dismiss() } @@ -221,6 +273,19 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid openHash("logs") } + /// The dashboard owns startup remediation (service install/repair, shim, routing + /// 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()) + } + + /// Package-visible seam so the UI tests can pin the destination without opening a + /// browser. + package func startupOptionsURL() -> URL { + DeepLinks.url(endpoint: endpoint, hash: "startup") + } + private func openProvider(_ provider: String) { let summary = latest?.providers.first(where: { $0.name == provider }) let tab = ResourceAssets.supportsAccountsTab(provider, summary: summary) @@ -247,6 +312,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid registrationAllowed: launchAtLoginRegistrationAllowed ) controller.applyLaunchAtLogin(presentation) + reportCompanionHeartbeat() if let error = presentation.errorMessage { controller.showResult(error, isError: true) } else if presentation.needsApproval { @@ -304,7 +370,11 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid switch outcome { case .running: self.clearCatalogUpdate() + self.companionHeartbeat?.reportNow() case .catalogUpdateReady(let count): + // The proxy is running with a pending catalog refresh; report now so + // a failed pre-ensure report is retried right after startup. + self.companionHeartbeat?.reportNow() self.presentCatalogUpdate(staleWorkerCount: count) case .stopped: break @@ -334,10 +404,14 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid switch outcome { case .running: self.clearCatalogUpdate() + self.companionHeartbeat?.reportNow() self.controller.showResult("CodexCommander started.", isError: false) case .stopped: self.controller.showResult("CodexCommander did not start.", isError: true) case .catalogUpdateReady(let count): + // Start succeeded with a pending catalog refresh; report now so the + // just-started proxy gets the companion lease immediately. + self.companionHeartbeat?.reportNow() self.presentCatalogUpdate(staleWorkerCount: count) case .failed(let message): self.controller.showResult(message, isError: true) @@ -423,6 +497,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid self?.updateApplicationMenu() switch outcome { case .restarted: + self?.companionHeartbeat?.reportNow() self?.controller.showResult("CodexCommander restarted.", isError: false) case .failed(let message): self?.controller.showResult(message, isError: true) @@ -583,6 +658,83 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid } } +/// Best-effort, non-blocking reporter of the native app's launch-at-login state. +/// +/// The proxy treats the report as advisory: it stamps its own observation time and +/// expires the lease after 90s, so this app never needs to know about TTLs. Failures +/// are swallowed on purpose — a missing or restarting proxy must never alarm the user +/// or block the transition that triggered the report. A single repeating timer owns +/// the 30s cadence; repeated `start()` calls cancel any existing timer instead of +/// stacking duplicates, and `stop()` cancels it on termination. +@MainActor +public final class CompanionHeartbeat { + public typealias Sample = @Sendable () -> LaunchAtLoginStatus + public typealias SendReport = @Sendable (LaunchAtLoginStatus) async -> Void + + public nonisolated static let targetInterval: TimeInterval = 30 + private let sample: Sample + private let send: SendReport + private let interval: TimeInterval + private var timer: Timer? + private var inFlight = false + private var active = false + private var generation = 0 + + public init( + interval: TimeInterval = CompanionHeartbeat.targetInterval, + sample: @escaping Sample, + send: @escaping SendReport + ) { + self.interval = interval + self.sample = sample + self.send = send + } + + /// Starts the repeating best-effort timer. Safe to call repeatedly: any existing + /// timer is cancelled first, so concurrent duplication is impossible. + public func start() { + cancelTimer() + active = true + let timer = Timer(timeInterval: interval, repeats: true) { [weak self] _ in + Task { @MainActor in self?.tick() } + } + RunLoop.main.add(timer, forMode: .common) + self.timer = timer + } + + /// Immediately reports a freshly sampled status. Used after launch-at-login + /// reconciliation, app activation, login-item changes, and successful proxy + /// start/restart. + public func reportNow() { + tick(requiresActive: false) + } + + /// Cancels the repeating timer; call on termination. + public func stop() { + cancelTimer() + active = false + generation += 1 + inFlight = false + } + + private func cancelTimer() { + timer?.invalidate() + timer = nil + } + + private func tick(requiresActive: Bool = true) { + guard !requiresActive || active, !inFlight else { return } + inFlight = true + let status = sample() + let generationAtStart = generation + Task { @MainActor [weak self] in + guard let self, self.generation == generationAtStart else { return } + await self.send(status) + self.inFlight = false + } + } +} + /// Pure deep-link helpers shared with tests. public enum DeepLinks { public static func encodeProvider(_ provider: String) -> String { diff --git a/app/Sources/MenuBarUI/PopoverViewController.swift b/app/Sources/MenuBarUI/PopoverViewController.swift index 73a36aaf3c..11e120b2aa 100644 --- a/app/Sources/MenuBarUI/PopoverViewController.swift +++ b/app/Sources/MenuBarUI/PopoverViewController.swift @@ -37,6 +37,7 @@ public final class PopoverViewController: NSViewController { field.preferredMaxLayoutWidth = Theme.width - Theme.gutter * 2 return field }() + private let startupOptionsButton = NSButton() private let commandField = NSTextField(labelWithString: "") private let activitySeparator = makeSeparator() private let quotaSeparator = makeSeparator() @@ -48,6 +49,7 @@ public final class PopoverViewController: NSViewController { public var onStop: (() -> Void)? public var onRestart: (() -> Void)? public var onApplyCodexCatalog: (() -> Void)? + public var onOpenStartupOptions: (() -> Void)? public var onQuitMenuBar: (() -> Void)? public var onStopAndQuit: (() -> Void)? public var onLaunchAtLoginChange: ((Bool) -> Void)? @@ -67,6 +69,7 @@ public final class PopoverViewController: NSViewController { public override func loadView() { configureControls() resultBanner.isHidden = true + startupOptionsButton.isHidden = true resultBanner.lineBreakMode = .byWordWrapping resultBanner.maximumNumberOfLines = 3 resultBanner.preferredMaxLayoutWidth = Theme.width - Theme.gutter * 2 @@ -91,11 +94,11 @@ public final class PopoverViewController: NSViewController { body.alignment = .leading body.spacing = Theme.sectionGap body.setViews( - [catalogUpdate, activity, activitySeparator, quotas, resultBanner, guidanceLabel, commandField], + [catalogUpdate, activity, activitySeparator, quotas, resultBanner, guidanceLabel, startupOptionsButton, commandField], in: .top ) body.translatesAutoresizingMaskIntoConstraints = false - for item in [catalogUpdate, activity, activitySeparator, quotas, resultBanner, guidanceLabel, commandField] { + for item in [catalogUpdate, activity, activitySeparator, quotas, resultBanner, guidanceLabel, startupOptionsButton, commandField] { item.translatesAutoresizingMaskIntoConstraints = false item.widthAnchor.constraint(equalTo: body.widthAnchor).isActive = true } @@ -178,6 +181,7 @@ public final class PopoverViewController: NSViewController { styleFooterButton(dashboardButton, title: "Dashboard", symbol: "square.grid.2x2") styleFooterButton(logsButton, title: "Logs", symbol: "list.bullet.rectangle") styleFooterButton(refreshButton, title: "Refresh", symbol: "arrow.clockwise") + styleFooterButton(startupOptionsButton, title: "Startup options…", symbol: "gearshape.2") styleFooterButton(lifecycleButton, title: "Start Proxy", symbol: "play.fill") styleFooterButton(restartButton, title: "Restart Proxy…", symbol: "power") styleFooterButton(quitMenuBarButton, title: "Quit Menu Bar", symbol: "xmark.circle") @@ -191,6 +195,7 @@ public final class PopoverViewController: NSViewController { dashboardButton.action = #selector(dashboardTapped) logsButton.action = #selector(logsTapped) refreshButton.action = #selector(refreshTapped) + startupOptionsButton.action = #selector(startupOptionsTapped) lifecycleButton.action = #selector(lifecycleTapped) restartButton.action = #selector(restartTapped) quitMenuBarButton.action = #selector(quitMenuBarTapped) @@ -204,6 +209,7 @@ public final class PopoverViewController: NSViewController { dashboardButton.setAccessibilityLabel("Open dashboard") logsButton.setAccessibilityLabel("Open logs") refreshButton.setAccessibilityLabel("Refresh") + startupOptionsButton.setAccessibilityLabel("Open startup options in the dashboard") lifecycleButton.setAccessibilityLabel("Start CodexCommander proxy") restartButton.setAccessibilityLabel("Restart CodexCommander proxy") quitMenuBarButton.setAccessibilityLabel( @@ -309,12 +315,13 @@ public final class PopoverViewController: NSViewController { private func applyGuidance(_ snapshot: ProxySnapshot) { var guidance: String? var command: String? + var showStartupOptions = false switch snapshot.nextAction { case .none: - if case .running = snapshot.state, let recommended = snapshot.recommendedCommand { - guidance = "Recommended:" - command = recommended + if case .running = snapshot.state, snapshot.recommendedCommand != nil { + guidance = "Recommended startup changes are available." + showStartupOptions = true } case .runCommand(let value): guidance = "Start it again with:" @@ -330,6 +337,7 @@ public final class PopoverViewController: NSViewController { guidanceLabel.stringValue = guidance ?? "" commandField.isHidden = command == nil commandField.stringValue = command ?? "" + startupOptionsButton.isHidden = !showStartupOptions if let command { commandField.setAccessibilityLabel("Command to run: \(command)") } @@ -387,6 +395,7 @@ public final class PopoverViewController: NSViewController { @objc private func dashboardTapped() { onDashboard?() } @objc private func logsTapped() { onLogs?() } @objc private func refreshTapped() { onRefresh?() } + @objc private func startupOptionsTapped() { onOpenStartupOptions?() } @objc private func lifecycleTapped() { guard let state = snapshot?.state else { return } if lifecycleStops(state) { onStop?() } @@ -440,6 +449,20 @@ public final class PopoverViewController: NSViewController { catalogUpdate.buttonAccessibilityLabel } package func activateCatalogUpdateForTesting() { catalogUpdate.activateForTesting() } + package var guidanceText: String? { + guidanceLabel.isHidden ? nil : guidanceLabel.stringValue + } + package var commandText: String? { + commandField.isHidden ? nil : commandField.stringValue + } + package var startupOptionsVisible: Bool { !startupOptionsButton.isHidden } + package var startupOptionsTitle: String { startupOptionsButton.title } + package var startupOptionsAccessibilityLabel: String? { + startupOptionsButton.accessibilityLabel() + } + package func activateStartupOptionsForTesting() { + startupOptionsButton.performClick(nil) + } package var footerTitles: [String] { [ dashboardButton.title, diff --git a/app/Sources/MenuBarUI/StatusIcon.swift b/app/Sources/MenuBarUI/StatusIcon.swift index 3da16069d2..a4f068578f 100644 --- a/app/Sources/MenuBarUI/StatusIcon.swift +++ b/app/Sources/MenuBarUI/StatusIcon.swift @@ -23,12 +23,17 @@ public enum StatusIcon { /// Keep every operational state distinguishable while the panel is closed. The /// tooltip and VoiceOver description carry words; shape is the ambient visual cue. + /// + /// A running proxy always wears the terminal glyph: missing background-service + /// protection is a startup-quality concern for the dashboard startup page, not a + /// degraded-looking tray icon. The warning triangle is reserved for an actually + /// degraded state, and every other state keeps its own distinct shape. package static func symbolName(for state: ProxyState) -> String { switch state { case .loading: return "ellipsis.circle" - case .running(let health): - return health.isProtected ? "terminal.fill" : "exclamationmark.triangle.fill" + case .running: + return "terminal.fill" case .unreachable: return "terminal" case .unauthorized: diff --git a/app/Sources/MenuBarUI/Theme.swift b/app/Sources/MenuBarUI/Theme.swift index 266de021eb..1492d88023 100644 --- a/app/Sources/MenuBarUI/Theme.swift +++ b/app/Sources/MenuBarUI/Theme.swift @@ -36,6 +36,8 @@ enum Theme { static let rowGap: CGFloat = 8 static let tightGap: CGFloat = 5 static let sectionGap: CGFloat = 9 + /// Keep trailing row content clear of the popover's overlay scroll indicator. + static let scrollbarClearance: CGFloat = 18 static let radius: CGFloat = 12 static let cardRadius: CGFloat = 7 static let width: CGFloat = 387 diff --git a/app/Sources/MenuBarUI/Views.swift b/app/Sources/MenuBarUI/Views.swift index 8fb971560c..498ee7e082 100644 --- a/app/Sources/MenuBarUI/Views.swift +++ b/app/Sources/MenuBarUI/Views.swift @@ -314,11 +314,7 @@ public final class AgentActivityView: NSView { if visible.isEmpty { body.isHidden = true empty.isHidden = false - if activity.unattributedActiveCount > 0 { - empty.stringValue = "\(activity.unattributedActiveCount) active turn\(activity.unattributedActiveCount == 1 ? "" : "s") unattributed" - } else { - empty.stringValue = "No active agents" - } + empty.stringValue = "No active agents" setAccessibilityLabel(empty.stringValue) return } @@ -360,14 +356,6 @@ public final class AgentActivityView: NSView { let note = makeLabel("Showing active subset", font: Theme.micro, color: Theme.faint) body.addArrangedSubview(note) } - if activity.unattributedActiveCount > 0 { - let note = makeLabel( - "+\(activity.unattributedActiveCount) unattributed", - font: Theme.micro, - color: Theme.faint - ) - body.addArrangedSubview(note) - } setAccessibilityLabel("Agent activity, \(visible.count) shown") } @@ -441,7 +429,10 @@ final class AgentActivityRowView: NSView { NSLayoutConstraint.activate([ row.topAnchor.constraint(equalTo: topAnchor, constant: 1), row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: leading), - row.trailingAnchor.constraint(equalTo: trailingAnchor), + row.trailingAnchor.constraint( + equalTo: trailingAnchor, + constant: -Theme.scrollbarClearance + ), row.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -1), heightAnchor.constraint(greaterThanOrEqualToConstant: 30), ]) diff --git a/app/Sources/MenuBarUITests/main.swift b/app/Sources/MenuBarUITests/main.swift index c74c5bc6d7..ab02a2c0b8 100644 --- a/app/Sources/MenuBarUITests/main.swift +++ b/app/Sources/MenuBarUITests/main.swift @@ -47,7 +47,8 @@ func currentHealth( protection: String = "none", serviceInstalled: Bool = false, serviceEnabled: Bool = false, - diagnosticStale: Bool = false + diagnosticStale: Bool = false, + recommendedCommand: String? = nil ) -> StartupHealth { StartupHealth( status: status, @@ -69,7 +70,7 @@ func currentHealth( shimCoverage: "none", rebootSafe: serviceEnabled, diagnosticStale: diagnosticStale, - recommendedCommand: nil, + recommendedCommand: recommendedCommand, commands: .init( installService: "ccx service install", repairService: "ccx service repair", @@ -105,6 +106,7 @@ func makeSnapshot( activity: AgentActivitySnapshot? = nil, providers: [ProviderSummary] = [], health: StartupHealth = currentHealth(), + recommendedCommand: String? = nil, providersLoaded: Bool = false, quotasLoaded: Bool = true, activityLoaded: Bool = true @@ -117,12 +119,18 @@ func makeSnapshot( activity: activity, providers: providers, lastUpdated: Date(), + recommendedCommand: recommendedCommand, providersLoaded: providersLoaded, quotasLoaded: quotasLoaded, activityLoaded: activityLoaded ) } +func textFields(in view: NSView) -> [NSTextField] { + let own = (view as? NSTextField).map { [$0] } ?? [] + return own + view.subviews.flatMap(textFields(in:)) +} + // MARK: - Hierarchy / sizing runner.test("ui: panel prefers the approved width") { @@ -141,17 +149,32 @@ runner.test("ui: nonactivating panel remains key-capable for Escape and keyboard runner.equal(panel.contentViewController?.preferredContentSize.width, 387) } -runner.test("ui: menu-bar glyph distinguishes every operational state") { +runner.test("ui: running proxy keeps the terminal glyph regardless of service protection") { + runner.equal( + StatusIcon.symbolName(for: .running(currentHealth())), + "terminal.fill", + "protected running keeps the terminal glyph" + ) + runner.equal( + StatusIcon.symbolName(for: .running(currentHealth(status: "at-risk"))), + "terminal.fill", + "unprotected running must not degrade to a warning triangle" + ) + runner.equal( + StatusIcon.symbolName(for: .degraded("Unavailable")), + "exclamationmark.triangle", + "the warning triangle is reserved for an actually degraded state" + ) + let states: [ProxyState] = [ .loading, .running(currentHealth()), - .running(currentHealth(status: "at-risk")), .unreachable, .unauthorized, .degraded("Unavailable"), ] let symbols = states.map(StatusIcon.symbolName(for:)) - runner.equal(Set(symbols).count, states.count, "one symbol per state") + runner.equal(Set(symbols).count, states.count, "every other operational state stays distinct") } runner.test("ui: footer exposes navigation, proxy lifecycle, and both exit contracts") { @@ -264,6 +287,53 @@ runner.test("ui: startup control forwards explicit preference changes") { runner.equal(openedSettings, true) } +runner.test("ui: running snapshot with recommended guidance offers Startup options, not a raw command") { + let controller = PopoverViewController() + _ = controller.view + + controller.apply(makeSnapshot()) + runner.equal(controller.startupOptionsVisible, false, "no guidance without a recommendation") + runner.isNil(controller.commandText, "no raw command when healthy") + + var opened = false + controller.onOpenStartupOptions = { opened = true } + controller.apply(makeSnapshot(recommendedCommand: "ccx service install")) + runner.equal(controller.startupOptionsVisible, true, "recommendation surfaces an actionable control") + runner.equal(controller.startupOptionsTitle, "Startup options…") + runner.equal(controller.commandText, nil, "raw remediation command is not the primary UI") + runner.expect( + controller.guidanceText?.isEmpty == false, + "guidance names the recommendation without printing the command" + ) + runner.expect( + controller.startupOptionsAccessibilityLabel?.contains("startup options") == true, + "control names its dashboard destination" + ) + controller.activateStartupOptionsForTesting() + runner.equal(opened, true, "control invokes the startup handoff") + + controller.apply(makeSnapshot()) + runner.equal(controller.startupOptionsVisible, false, "control clears once guidance clears") +} + +runner.test("ui: stopped proxy keeps the raw start command as guidance") { + let controller = PopoverViewController() + _ = controller.view + var stopped = ProxySnapshot(state: .unreachable, endpoint: .default) + stopped.lastKnownStartCommand = "ccx service start" + controller.apply(stopped) + runner.equal(controller.guidanceText, "Start it again with:") + runner.equal(controller.commandText, "ccx service start", "raw command survives for the stopped case") + runner.equal(controller.startupOptionsVisible, false, "startup options are not shown while stopped") +} + +runner.test("ui: startup options handoff targets the dashboard startup page") { + let delegate = AppDelegate() + let url = delegate.startupOptionsURL() + runner.equal(url.fragment, "startup", "dashboard hash") + runner.expect(url.absoluteString.hasPrefix("http://127.0.0.1:"), "loopback") +} + // MARK: - Accordion / no duplicates runner.test("ui: ChatGPT/OpenAI expands first; Kimi and Grok stay collapsed") { @@ -509,6 +579,47 @@ runner.test("ui: activity empty and unavailable states stay compact") { runner.equal(controller.preferredContentSize.width, 387, "width stable") } +runner.test("ui: activity rows render once and elapsed timers clear the scrollbar") { + let now = Int64(Date().timeIntervalSince1970 * 1_000) + let activity = activitySnapshot( + activities: """ + {"id":"child-1","role":"subagent","provider":"kimi","model":"k3[1m]", + "phase":"running","startedAt":\(now)} + """, + unattributed: 1 + ) + let controller = PopoverViewController() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 387, height: 468), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + window.contentViewController = controller + window.orderFront(nil) + controller.apply(makeSnapshot(activity: activity)) + controller.view.layoutSubtreeIfNeeded() + + let fields = textFields(in: controller.activityView) + runner.expect( + fields.allSatisfy { !$0.stringValue.localizedCaseInsensitiveContains("unattributed") }, + "already-rendered subagents should not be counted again in a footer" + ) + + let elapsed = runner.notNil( + fields.first { $0.alignment == .right }, + "elapsed timer" + ) + if let elapsed, let container = elapsed.superview { + let frame = container.convert(elapsed.frame, to: controller.activityView) + let clearance = controller.activityView.bounds.maxX - frame.maxX + runner.expect( + clearance >= 15, + "elapsed timer should clear the overlay scrollbar (clearance: \(clearance))" + ) + } +} + runner.test("ui: accessibility labels exist on header and accordion") { let controller = PopoverViewController() _ = controller.view @@ -849,4 +960,98 @@ runner.test("ui: provider icon loader returns real SVG-backed images for known p runner.isNil(ResourceAssets.providerIcon(for: "definitely-not-a-provider"), "unknown stays nil") } +// MARK: - Companion heartbeat + +final class HeartbeatRecorder: @unchecked Sendable { + private let lock = NSLock() + private var _statuses: [LaunchAtLoginStatus] = [] + + var statuses: [LaunchAtLoginStatus] { + lock.lock() + defer { lock.unlock() } + return _statuses + } + + var count: Int { + lock.lock() + defer { lock.unlock() } + return _statuses.count + } + + func record(_ status: LaunchAtLoginStatus) { + lock.lock() + defer { lock.unlock() } + _statuses.append(status) + } +} + +func spinMainRunLoop(seconds: TimeInterval) { + let deadline = Date().addingTimeInterval(seconds) + while Date() < deadline { + RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.002)) + } +} + +@MainActor +func runCompanionHeartbeatTests(_ runner: TestRunner) { + // The first run-loop drain of this process does not service timers (verified + // empirically: the first spin always reports zero fires, the second fires + // normally). Drain once up front so the timer assertions are deterministic. + spinMainRunLoop(seconds: 0.02) + + runner.test("ui: companion heartbeat runs a single timer and stops cleanly") { + // The production cadence is 30s; a short injected interval keeps the single-timer + // and stop() guarantees observable in-process without waiting half a minute. + let recorder = HeartbeatRecorder() + let heartbeat = CompanionHeartbeat( + interval: 0.02, + sample: { .enabled }, + send: { status in recorder.record(status) } + ) + + heartbeat.start() + heartbeat.start() + heartbeat.start() + spinMainRunLoop(seconds: 0.12) + let during = recorder.count + runner.expect(during >= 1, "the repeating timer fires at least once (got \(during))") + // One 20ms timer over 120ms fires ~6 times; stacked duplicates would fire ~18. + runner.expect(during <= 9, "repeated start() must not duplicate the timer (got \(during))") + runner.equal(recorder.statuses.first, .enabled, "the sampled status is what gets reported") + + heartbeat.stop() + let stoppedCount = recorder.count + spinMainRunLoop(seconds: 0.10) + runner.equal(recorder.count, stoppedCount, "stop() cancels the timer") + } + + runner.test("ui: companion heartbeat coalesces in-flight reports and reports again after") { + let recorder = HeartbeatRecorder() + let heartbeat = CompanionHeartbeat( + interval: 60, // never fires during this test + sample: { .requiresApproval }, + send: { status in + try? await Task.sleep(nanoseconds: 30_000_000) + recorder.record(status) + } + ) + + heartbeat.reportNow() + heartbeat.reportNow() + spinMainRunLoop(seconds: 0.10) + runner.equal(recorder.count, 1, "a report while one is in flight is coalesced away") + + heartbeat.reportNow() + spinMainRunLoop(seconds: 0.10) + runner.equal(recorder.count, 2, "a later reportNow fires after the previous completed") + runner.equal(recorder.statuses[0], .requiresApproval, "the freshly sampled status is reported") + heartbeat.stop() + } +} + +// Top-level code runs on the process main thread, so the MainActor hop is safe here. +MainActor.assumeIsolated { + runCompanionHeartbeatTests(runner) +} + exit(runner.summarize()) diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000000..44c6ad689f --- /dev/null +++ b/design-qa.md @@ -0,0 +1,41 @@ +# Startup page design QA + +## Reference and implementation + +- Reference: `.tmp/product-design-selected-target/startup-app-managed-final.png` +- Final implementation capture: `.tmp/product-design-selected-target/startup-app-managed-implementation-final.png` +- Side-by-side comparison: `.tmp/product-design-selected-target/startup-app-managed-comparison-final.png` +- Viewport: 1487 × 1058 CSS pixels at DPR 1 +- State: macOS, app companion heartbeat fresh, launch at login enabled, background crash recovery off, Advanced collapsed + +## Comparison history + +### Pass 1 + +- The implementation used the dashboard's default 980 px content measure, so the Startup page was materially narrower than the selected reference. +- The hero, primary rows, icons, typography, and Advanced disclosure were too compact for the reference hierarchy. + +### Fixes + +- Added a Startup-specific 1168 px page measure while preserving the existing sidebar and global design system. +- Reworked the hero so its icon, heading, and explanatory copy share the reference alignment. +- Matched the reference's larger title, icon scale, row heights, card rhythm, action sizing, and disclosure height. +- Kept all responsive adaptations scoped to the Startup page. + +### Final comparison + +- The final side-by-side comparison was inspected at identical viewport, state, and pixel dimensions. +- Typography, spacing, mint/neutral surfaces, border radii, icon scale, button weight, copy, and hierarchy match the selected direction. +- The existing CodexCommander sidebar information architecture and icon library are intentionally retained instead of cloning the generated reference's simplified navigation. +- No photographic or generated imagery is present; all visible symbols use the repository's existing icon components and render cleanly. +- No actionable P0, P1, or P2 visual mismatches remain. + +## Functional and responsive checks + +- Advanced startup options: `aria-expanded` changed `false → true → false`; the panel content appeared and collapsed correctly. +- Enable crash recovery: visible and enabled in the app-managed state. +- Desktop overflow: 1487 px viewport and 1487 px document width; no horizontal overflow. +- Narrow layout: verified at 690 × 900; header actions wrap, hero and rows remain readable, and document width stays 690 px. +- Browser console: no warnings or errors. + +final result: passed 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 61f211ba11..4f0a311789 100644 --- a/docs-site/src/content/docs/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -18,7 +18,7 @@ checkout using [Build from source](#build-from-source). Keep the development app The panel has one **Launch at Login** switch and reports the resulting mode: - **Desktop** — the CodexCommander menu app launches when you sign in and ensures or attaches to exactly - one server. This is the default desktop experience. + one server. This is the default desktop experience and is reported as **App-managed** in Startup. - **Headless** — the menu app is not a login item, but an independently installed `ccx service` continues starting and supervising the server. - **Off** — neither the menu app nor a background service starts automatically; open the app or run @@ -32,6 +32,13 @@ therefore list CodexCommander under both **Open at Login** and **Allow in the Ba responsibilities of one installation, not duplicate app copies. Turning off Launch at Login never installs, removes, starts, or stops the background service. +App-managed startup and the background service solve different problems. The app starts the proxy at +sign-in, which is enough for normal desktop use. The optional background service additionally +supervises the proxy and restarts it after a crash, so the dashboard labels it **Background +recovery** instead of presenting it as a requirement. The companion periodically reports its current +Launch at Login state to the local proxy; that short-lived report is kept only in memory and is used +only for startup diagnostics. + If macOS requires approval, the startup row links directly to **System Settings → General → Login Items & Extensions**. CodexCommander reflects a revocation made there instead of repeatedly trying to override it. @@ -47,6 +54,8 @@ override it. 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. +- **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 @@ -73,6 +82,10 @@ stale, the row instead says **Login needs refresh** and tells you to run `grok` **Temporarily unavailable**. These states are fixed, privacy-safe reason codes from the local proxy, not raw provider errors. **View all providers** opens the complete Providers workspace. +While the proxy is running, the menu-bar item uses the normal terminal icon whether startup is +app-managed or service-managed. The warning triangle is reserved for a genuinely degraded proxy; +missing optional crash recovery does not turn a healthy running app into an alarm state. + ## Agent catalog updates Opening the app automatically synchronizes the Codex model catalog with the providers currently diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index fd0a42319f..17531311ca 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -42,7 +42,7 @@ the browser or password manager's decision. | **Sub-agent delegation** | Choose a native or routed model and optional reasoning effort shared by CodexCommander delegation guidance and the separate native-default opt-in. This is not a proxy-side per-spawn router; see below. | | **Sidecars** | Choose the web-search model and effort plus the vision-description model. Changes apply on the next request. | | **Maintenance** | Resync the Codex model catalog and inspect project-local config bypass warnings. | -| **Startup safety** | Show whether injected Codex routing survives a restart, with separate service and launcher-shim health plus exact repair commands. | +| **Startup** | Show whether routing is started by the CodexCommander app, a background service, or a launcher shim. The normal macOS app setup is **App-managed**; the service is offered separately as optional crash recovery. Advanced repair commands stay available without dominating the page. | | **Windows tray** | Install a per-user login tray for one-click proxy start, stop, restart, dashboard access, and status. The tray is a controller, not a proxy restart service. | | **Codex autostart** | Allow an already-installed Codex launcher shim to run `ccx ensure`. This toggle does not install a shim or background service. | | **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. | @@ -169,7 +169,8 @@ The GUI is a thin client over the proxy's JSON management API. Useful endpoints | --- | --- | | `GET` / `PUT /api/settings` | Read settings or toggle Codex autostart. | | `GET /api/integrations/opencode` · `POST /api/integrations/opencode/apply` · `POST /api/integrations/opencode/restore` | Inspect, safely apply, or restore the managed OpenCode connection. | -| `GET /api/startup-health` | Read secret-free routing, service, shim, and restart-safety diagnostics. | +| `GET /api/startup-health` | Read secret-free routing, startup-method, crash-recovery, service, shim, and restart-safety diagnostics. | +| `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. | 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 13998b3c35..4c64a19673 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -35,7 +35,7 @@ bun run dev:gui | **サブエージェント委任** | CodexCommander の委任ガイダンスとオプションの Codex ネイティブサブエージェント既定値で共有するネイティブ/ルーティングモデルと任意の推論強度を選びます。スポーンごとのルーターではありません。下記を参照してください。 | | **サイドカー** | ウェブ検索モデルと強度、画像説明モデルを選択します。次回リクエストから適用されます。 | | **メンテナンス** | Codex モデルカタログを再同期し、プロジェクトローカル設定のバイパス警告を確認します。 | -| **起動安全性** | 注入された Codex ルーティングが再起動後も機能するか、サービスと launcher shim の状態、正確な修復コマンドと共に表示します。 | +| **起動** | ルーティングが CodexCommander アプリ、バックグラウンドサービス、launcher shim のどれで起動されるかを表示します。通常の macOS アプリ構成は **アプリ管理** と表示され、サービスは任意のクラッシュ復旧として別に案内されます。高度な修復コマンドも利用できますが、ページの主役にはなりません。 | | **Windows トレイ** | ユーザーのログイントレイを導入し、プロキシ開始・停止・再起動・ダッシュボード・状態をクリックで操作します。トレイは再起動サービスではありません。 | | **Codex 自動起動** | インストール済み Codex launcher shim に `ccx ensure` の実行を許可します。このトグルは shim やバックグラウンドサービスをインストールしません。 | | **プロバイダー** | プロバイダーを追加、編集、既定に設定(有効なプロバイダーのみ)、有効化/無効化、削除し、対応する OAuth アカウントプールと API キープールを管理します。現在の既定を削除すると、残っている最初の有効なプロバイダーに切り替わります(存在する場合)。なければ削除は拒否され、現在の既定は保持されます。Claude(Anthropic)OAuth プールでは、ログイン済みの各アカウントに独自の 5 時間・週間レート制限バーが表示され(利用量は資格情報単位)、取得失敗時は直近の値を保持して一時利用不可と表示します。 | @@ -124,7 +124,8 @@ GUI はプロキシの JSON 管理 API を使うシンクライアントです | エンドポイント | 用途 | --- | --- | | `GET` / `PUT /api/settings` | 設定を読むか Codex 自動起動をオン/オフします。 | -| `GET /api/startup-health` | 秘密情報を含まないルーティング、サービス、shim、再起動安全性診断を読み取ります。 | +| `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 モデルキャッシュを古い状態としてマークします。 | | `GET` / `PUT /api/sidecar-settings` | 検索/ビジョンサイドカーモデル設定を読むか変えます。 | 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 c00ccb0094..d468134cf9 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -88,7 +88,8 @@ Authorization: Bearer | `GET /api/config` |編集された、管理上安全な構成 DTO を返します。 — | | `PUT /api/config` |フルコンフィグ置換ガードを無効にする | 405;代わりにフォーカスされたエンドポイントを使用してください。 | `GET, PUT /api/settings` |ランタイム/起動設定の読み取り、または自動起動、ストリーム モード、アプリ所有のメモリ バジェットの更新 | 400 無効または空の更新 | -| `GET /api/startup-health` |キャッシュされたサービス/シムの起動状態を読み取る | — | +| `GET /api/startup-health` | キャッシュされた基本起動状態に現在のコンパニオン証拠を加えて読み取る | — | +| `PUT /api/startup-health/companion` | ネイティブコンパニオンのメモリ内 Launch at Login リースを更新(raw 管理トークンのみ) | 400 無効な報告、403 GUI セッションまたは管理者以外 | | `POST /api/startup-action` |サービスまたは Codex シムをインストールまたは修復する | 400 無効なアクション。 500 アクション失敗 | | `GET, POST /api/windows-tray` | Windows トレイの状態を読み取るか、インストール/起動/停止/アンインストールする | 400 のサポートされていないプラットフォーム/アクション。 500 操作失敗 | | `GET /api/diagnostics/project-config` |キャッシュされたプロジェクト設定の読み取りに関する警告 | — | 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 142a992f23..2e55e6e3d2 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -35,7 +35,7 @@ bun run dev:gui | **Sub-agent delegation** | CodexCommander 위임 가이드와 선택적인 Codex 네이티브 서브에이전트 기본값이 함께 사용할 네이티브/라우팅 모델과 선택적 reasoning 강도를 고릅니다. 스폰별 라우터는 아닙니다. 아래 설명을 확인하세요. | | **사이드카** | 웹 검색 모델과 강도, 이미지 설명 모델을 선택합니다. 다음 요청부터 적용됩니다. | | **Maintenance** | Codex 모델 카탈로그를 다시 동기화하고 프로젝트 로컬 설정의 우회 경고를 확인합니다. | -| **시작 안전성** | 주입된 Codex 라우팅이 재부팅 후에도 유지되는지 서비스와 launcher shim 상태, 정확한 복구 명령과 함께 표시합니다. | +| **시작** | 라우팅이 CodexCommander 앱, 백그라운드 서비스 또는 launcher shim 중 무엇으로 시작되는지 표시합니다. 일반적인 macOS 앱 구성은 **앱 관리**로 표시되며, 서비스는 선택적인 충돌 복구 기능으로 따로 안내됩니다. 고급 복구 명령도 페이지를 지배하지 않으면서 계속 사용할 수 있습니다. | | **Windows 트레이** | 로그인할 때 사용자 전용 트레이를 시작하고 프록시 시작·중지·재시작·대시보드·상태를 클릭으로 제어합니다. 트레이는 재시작 서비스가 아닙니다. | | **Codex 자동 시작** | 이미 설치된 Codex launcher shim이 `ccx ensure`를 실행하도록 허용합니다. 이 토글은 shim이나 백그라운드 서비스를 설치하지 않습니다. | | **Providers** | 프로바이더를 추가, 편집, 기본으로 설정(활성만), 활성화/비활성화, 제거하고, 지원되는 OAuth 계정 풀과 API key 풀을 관리합니다. 현재 기본 프로바이더를 제거하면 남아 있는 첫 번째 활성 프로바이더로 전환됩니다(있는 경우); 없으면 삭제가 거부되고 현재 기본이 유지됩니다. Claude(Anthropic) OAuth 풀에서는 로그인한 계정마다 자체 5시간·주간 한도 막대가 표시되며(사용량은 자격 증명 단위), 조회 실패 시 마지막 값을 유지하고 일시 불가 상태로 표시합니다. | @@ -124,7 +124,8 @@ GUI는 프록시의 JSON 관리 API를 사용하는 얇은 클라이언트입니 | 엔드포인트 | 용도 | | --- | --- | | `GET` / `PUT /api/settings` | 설정을 읽거나 Codex 자동 시작을 켜고 끕니다. | -| `GET /api/startup-health` | 비밀값 없이 라우팅, 서비스, shim, 재부팅 안전성 진단을 읽습니다. | +| `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 모델 캐시를 오래된 상태로 표시합니다. | | `GET` / `PUT /api/sidecar-settings` | 검색/비전 사이드카 모델 설정을 읽거나 바꿉니다. | 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 c9d6ba0c08..fd3c191c7b 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -88,7 +88,8 @@ Authorization: Bearer | `GET /api/config` | redacted된 management-safe configuration DTO를 반환합니다 | — | | `PUT /api/config` | 전체 구성 교체 방지 기능이 비활성화되어 있습니다 | 405; 대신 집중된 엔드포인트를 사용하십시오 | | `GET, PUT /api/settings` | 런타임/시작 설정을 읽거나 auto-start, stream mode, 앱 소유 memory budget을 업데이트합니다 | 400 잘못되었거나 비어 있는 업데이트 | -| `GET /api/startup-health` | 캐시된 서비스/shim 시작 상태를 읽습니다 | — | +| `GET /api/startup-health` | 캐시된 기본 시작 상태에 현재 컴패니언 증거를 적용해 읽습니다 | — | +| `PUT /api/startup-health/companion` | 네이티브 컴패니언의 메모리 내 Launch at Login 리스를 갱신합니다(원본 관리 토큰만 허용) | 400 잘못된 보고, 403 GUI 세션 또는 비관리자 | | `POST /api/startup-action` | 서비스 또는 Codex shim을 설치하거나 복구합니다 | 400 잘못된 작업; 500 작업 실패 | | `GET, POST /api/windows-tray` | Windows tray 상태를 읽거나 설치, 시작, 중지, 제거합니다 | 400 지원되지 않는 플랫폼/작업; 500 작업 실패 | | `GET /api/diagnostics/project-config` | 캐시된 프로젝트 구성 경고를 읽습니다 | — | diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 9cd41417f1..244df6e093 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -103,7 +103,8 @@ See [Combos](/guides/combos/) for target strategies, cooldowns, aliases, and rou | `GET /api/config` | Return the redacted, management-safe configuration DTO | — | | `PUT /api/config` | Disabled full-config replacement guard | 405; use focused endpoints instead | | `GET, PUT /api/settings` | Read runtime/startup settings or update auto-start, stream mode, and app-owned memory budget | 400 invalid or empty update | -| `GET /api/startup-health` | Read cached service/shim startup health | — | +| `GET /api/startup-health` | Read cached base startup health decorated with current companion evidence | — | +| `PUT /api/startup-health/companion` | Refresh the native companion's memory-only Launch at Login lease; raw admin-token principal only | 400 invalid report; 403 GUI session or non-admin principal | | `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 | — | diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 3f1e7c898d..b8b6e1613f 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -35,7 +35,7 @@ bun run dev:gui | **Sub-agent delegation** | Выбор нативной/маршрутизируемой модели и необязательного уровня рассуждений, общих для руководства CodexCommander по делегированию и опциональных нативных значений подагентов Codex по умолчанию. Это не маршрутизатор отдельных порождений; см. ниже. | | **Сайдкары** | Выбор модели и уровня рассуждений для веб-поиска, а также модели описания изображений. Изменения применяются со следующего запроса. | | **Maintenance** | Пересинхронизация каталога моделей Codex и просмотр предупреждений об обходе через проектную локальную конфигурацию. | -| **Безопасность запуска** | Показывает, сохранит ли внедрённая маршрутизация Codex работоспособность после перезагрузки, отдельно отображая службу, launcher shim и точные команды исправления. | +| **Запуск** | Показывает, что запускает маршрутизацию: приложение CodexCommander, фоновая служба или launcher shim. Обычная конфигурация macOS отображается как **Управляется приложением**, а служба предлагается отдельно как необязательное восстановление после сбоя. Расширенные команды исправления остаются доступны, но не занимают основную часть страницы. | | **Трей Windows** | Устанавливает пользовательский значок входа для запуска, остановки, перезапуска, панели и состояния прокси одним щелчком. Трей не является службой перезапуска. | | **Автозапуск Codex** | Разрешает уже установленному launcher shim Codex выполнять `ccx ensure`. Переключатель не устанавливает shim или фоновую службу. | | **Providers** | Добавление, редактирование, назначение провайдера по умолчанию (только включённые), включение/отключение и удаление провайдеров; управление пулами OAuth-аккаунтов и пулами API-ключей там, где они поддерживаются. При удалении текущего провайдера по умолчанию выбирается первый оставшийся включённый провайдер, если он есть; иначе удаление отклоняется и текущий default сохраняется. Для пулов Claude (Anthropic) OAuth у каждого вошедшего аккаунта свои полосы 5-часового и недельного лимита (использование по учётным данным); при сбое опроса сохраняются последние известные значения с пометкой недоступности. | @@ -132,7 +132,8 @@ GUI — это тонкий клиент поверх JSON-API управлен | Эндпоинт | Назначение | | --- | --- | | `GET` / `PUT /api/settings` | Чтение настроек или переключение автозапуска Codex. | -| `GET /api/startup-health` | Чтение безопасной диагностики маршрутизации, службы, shim и устойчивости к перезагрузке. | +| `GET /api/startup-health` | Чтение безопасной диагностики маршрутизации, способа запуска, восстановления после сбоя, службы, shim и устойчивости к перезагрузке. | +| `PUT /api/startup-health/companion` | Позволяет аутентифицированному нативному компаньону обновить краткосрочное наблюдение Launch at Login, хранящееся только в памяти. Требуется исходный токен администратора; сеанс браузерного GUI отклоняется. | | `GET` / `POST /api/windows-tray` | Чтение или изменение установки и видимости трея Windows; POST поддерживает `install`, `start`, `stop`, `uninstall`. | | `POST /api/sync` | Пересборка общего каталога моделей и инвалидация кэша моделей Codex. | | `GET` / `PUT /api/sidecar-settings` | Чтение или настройка моделей сайдкаров поиска/vision. | 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 44d1e77564..9906446b0c 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -104,7 +104,8 @@ GUI-сессия в стиле loopback не выпускается. | `GET /api/config` | Вернуть redacted DTO конфигурации, безопасный для management API | — | | `PUT /api/config` | Отключённая защита от полной замены конфигурации | 405; используйте вместо этого узкие endpoint'ы | | `GET, PUT /api/settings` | Прочитать runtime/startup setting'и или обновить auto-start, stream mode и budget app-owned memory | 400 invalid or empty update | -| `GET /api/startup-health` | Прочитать кэшированное startup health службы/shim'а | — | +| `GET /api/startup-health` | Прочитать кэшированное базовое состояние запуска с текущими данными компаньона | — | +| `PUT /api/startup-health/companion` | Обновить хранящуюся в памяти аренду Launch at Login нативного компаньона; только исходный токен администратора | 400 недопустимый отчёт; 403 GUI-сессия или не администратор | | `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 | — | 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 c0b1b9e356..f61e4bfb01 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 @@ -34,7 +34,7 @@ bun run dev:gui | **Sub-agent delegation** | 选择供 CodexCommander 委派指引与可选的 Codex 原生子代理默认值共用的原生/路由模型和可选 reasoning 强度。它不是逐次生成的路由器,详见下文。 | | **Sidecar** | 选择 web-search 模型及强度,以及图像描述模型;更改从下一次请求开始生效。 | | **Maintenance** | 重新同步 Codex 模型目录并查看项目级配置绕过警告。 | -| **启动安全** | 显示注入的 Codex 路由能否在重启后继续工作,并分别显示服务、launcher shim 状态和准确的修复命令。 | +| **启动** | 显示路由由 CodexCommander 应用、后台服务还是 launcher shim 启动。常规 macOS 应用配置显示为 **应用管理**,后台服务则单独作为可选的崩溃恢复功能。高级修复命令仍然可用,但不会占据页面主体。 | | **Windows 托盘** | 安装用户登录托盘,一键控制代理启动、停止、重启、面板和状态。托盘不是代理重启服务。 | | **Codex 自动启动** | 允许已安装的 Codex launcher shim 运行 `ccx ensure`。此开关不会安装 shim 或后台服务。 | | **Providers** | 添加、编辑、设为默认(仅已启用)、启用/禁用、删除 provider,并在支持时管理 OAuth 账号池和 API key 池。删除当前默认时,会切换到剩余的第一个已启用 provider(若存在);否则拒绝删除并保留当前默认。Claude(Anthropic)OAuth 池中,每个已登录账号显示各自的 5 小时与周限额条(用量按凭证计);探测失败时保留上次已知数值并标记为暂时不可用。 | @@ -115,7 +115,8 @@ GUI 是代理 JSON 管理 API 之上的轻量客户端。常用 endpoint 包括 | Endpoint | 用途 | | --- | --- | | `GET` / `PUT /api/settings` | 读取设置或切换 Codex 自动启动。 | -| `GET /api/startup-health` | 读取不含秘密信息的路由、服务、shim 和重启安全诊断。 | +| `GET /api/startup-health` | 读取不含秘密信息的路由、启动方式、崩溃恢复、服务、shim 和重启安全诊断。 | +| `PUT /api/startup-health/companion` | 让已认证的原生伴侣应用刷新仅保存在内存中的短期“登录时启动”观测。该端点需要原始管理令牌,并拒绝浏览器 GUI 会话。 | | `GET` / `POST /api/windows-tray` | 读取或更改 Windows 托盘安装和显示状态;POST 支持 `install`、`start`、`stop`、`uninstall`。 | | `POST /api/sync` | 重建共享模型目录,并把 Codex 模型缓存标记为过期。 | | `GET` / `PUT /api/sidecar-settings` | 读取或设置 search/vision sidecar 模型。 | 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 25faf80819..91be044aa5 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 @@ -88,7 +88,8 @@ Authorization: Bearer | `GET /api/config` | 返回已脱敏、对管理安全的配置 DTO | — | | `PUT /api/config` | 禁用的完整配置替换保护 | 405;请改用聚焦端点 | | `GET, PUT /api/settings` | 读取运行时/启动设置,或更新自动启动、流模式和应用拥有的内存预算 | 400 无效或空更新 | -| `GET /api/startup-health` | 读取缓存的服务/shim 启动健康状态 | — | +| `GET /api/startup-health` | 读取缓存的基础启动状态,并应用当前伴侣应用证据 | — | +| `PUT /api/startup-health/companion` | 刷新原生伴侣应用仅保存在内存中的 Launch at Login 租约;仅允许原始管理令牌 | 400 报告无效;403 GUI 会话或非管理员 | | `POST /api/startup-action` | 安装或修复服务或 Codex shim | 400 无效动作;500 动作失败 | | `GET, POST /api/windows-tray` | 读取 Windows 托盘状态,或安装、启动、停止、卸载它 | 400 不支持的平台/动作;500 操作失败 | | `GET /api/diagnostics/project-config` | 读取缓存的项目配置警告 | — | diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 17d560d876..d95973551c 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -14,7 +14,7 @@ import Integrations from "./pages/Integrations"; import Startup from "./pages/Startup"; 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 } from "./icons"; +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"; @@ -81,7 +81,13 @@ const NAV_SECTIONS: NavSection[] = [ { id: "usage", tkey: "nav.usage", Icon: IconActivity }, ], }, - { labelKey: "nav.group.system", entries: [{ id: "storage", tkey: "nav.storage", Icon: IconHardDrive }] }, + { + labelKey: "nav.group.system", + entries: [ + { id: "startup", tkey: "nav.startup", Icon: IconTerminal }, + { id: "storage", tkey: "nav.storage", Icon: IconHardDrive }, + ], + }, ]; const NAV = NAV_SECTIONS.flatMap(section => section.entries); @@ -295,7 +301,7 @@ export default function App() {
-
+
= { "nav.dashboard": "Übersicht", - "nav.startup": "Startsicherheit", + "nav.startup": "Startup", "nav.providers": "Anbieter", "nav.models": "Modelle", "nav.combos": "Combos", @@ -88,8 +88,8 @@ export const de: Record = { "errorBoundary.message": "In diesem Bereich ist ein Darstellungsfehler aufgetreten. Lade ihn neu, um es noch einmal zu versuchen.", "errorBoundary.details": "Fehler", "errorBoundary.reload": "Neu laden", - "startup.title": "Startsicherheit", - "startup.subtitle": "Prüft, ob Codex CodexCommander nach einem Neustart erreicht, bevor lokales Proxy-Routing in einer Wiederverbindungsschleife endet.", + "startup.title": "Startup", + "startup.subtitle": "Steuere, wie CodexCommander startet und sich erholt.", "startup.refresh": "Aktualisieren", "startup.backToDashboard": "Zurück zum Dashboard", "startup.loading": "Startschutz wird geprüft…", @@ -97,10 +97,34 @@ export const de: Record = { "startup.staleData": "Die aktuelle Prüfung ist fehlgeschlagen. Die Werte unten sind veraltet und kein Nachweis für Schutz.", "startup.status.native": "Natives Routing", "startup.status.protected": "Neustartgeschützt", + "startup.status.caution": "App-verwaltet", "startup.status.atRisk": "Aktion erforderlich", "startup.summary.native": "Codex ist nicht vom lokalen Proxy abhängig", "startup.summary.protected": "CodexCommander ist nach einem Neustart verfügbar", + "startup.summary.caution": "CodexCommander startet bei der Anmeldung", "startup.summary.atRisk": "Codex kann nach einem Neustart den Modellzugriff verlieren", + "startup.hero.appManaged.badge": "App-verwaltet", + "startup.hero.appManaged.title": "CodexCommander startet bei der Anmeldung", + "startup.hero.appManaged.body": "Deine aktuelle Konfiguration funktioniert für die normale Desktop-Nutzung. Aktiviere die Absturzwiederherstellung für einen automatischen Neustart nach einem Absturz.", + "startup.method.current": "Aktuelle Startmethode", + "startup.method.native": "Natives Routing", + "startup.method.service": "Hintergrunddienst", + "startup.method.companion": "CodexCommander-App", + "startup.method.shim": "Launcher-Shim", + "startup.method.none": "Keine", + "startup.method.unknown": "Unbekannt", + "startup.state.ready": "Bereit", + "startup.state.attention": "Erfordert Aufmerksamkeit", + "startup.state.unknown": "Unbekannt", + "startup.recovery.background": "Hintergrundwiederherstellung", + "startup.recovery.on": "Ein", + "startup.recovery.off": "Aus", + "startup.recovery.enable": "Absturzwiederherstellung aktivieren", + "startup.recovery.enabling": "Absturzwiederherstellung wird aktiviert…", + "startup.recovery.enabled": "Aktiviert", + "startup.recovery.unsupported": "Auf dieser Plattform nicht unterstützt", + "startup.advanced.title": "Erweiterte Startoptionen", + "startup.advanced.hint": "Launcher-Shim, Rohbefehle und Reparaturwerkzeuge für manuelle Konfigurationen.", "startup.riskDetail": "Codex ist auf den lokalen Proxy festgelegt, aber weder ein dauerhafter Dienst noch ein intakter Launcher-Shim startet ihn erneut.", "startup.riskDetailCustomLocal": "Codex verwendet ein benutzerdefiniertes lokales Gateway. CodexCommander kann dessen Neustart-Lebenszyklus weder verwalten noch prüfen.", "startup.riskDetailWindowsShim": "Der Launcher-Shim schützt unterstützte CLI-Skripte, aber Codex Desktop und direkte codex.exe-Aufrufe können ihn unter Windows umgehen.", @@ -117,6 +141,7 @@ export const de: Record = { "startup.disabled": "Deaktiviert", "startup.protection.service": "Hintergrunddienst", "startup.protection.shim": "Launcher-Shim", + "startup.protection.companion": "CodexCommander-App", "startup.protection.none": "Nicht installiert", "startup.details": "Schutzdetails", "startup.service": "Hintergrunddienst", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index a427ca4ef7..a0cbd12572 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -97,8 +97,8 @@ export const en = { "routing.analyticsEmpty": "No analytics yet — send some requests first.", // startup health - "startup.title": "Startup safety", - "startup.subtitle": "Verify that Codex can reach CodexCommander after a restart, before local proxy routing becomes a reconnect loop.", + "startup.title": "Startup", + "startup.subtitle": "Control how CodexCommander starts and recovers.", "startup.refresh": "Refresh", "startup.backToDashboard": "Back to Dashboard", "startup.loading": "Checking startup protection…", @@ -106,10 +106,34 @@ export const en = { "startup.staleData": "The latest startup check failed. The values below are stale and must not be treated as proof of protection.", "startup.status.native": "Native routing", "startup.status.protected": "Restart protected", + "startup.status.caution": "App-managed", "startup.status.atRisk": "Action required", "startup.summary.native": "Codex does not depend on the local proxy", "startup.summary.protected": "CodexCommander will be available after restart", + "startup.summary.caution": "CodexCommander starts at login", "startup.summary.atRisk": "Codex can lose model access after restart", + "startup.hero.appManaged.badge": "App-managed", + "startup.hero.appManaged.title": "CodexCommander starts at login", + "startup.hero.appManaged.body": "Your current setup works for normal desktop use. Enable crash recovery if you want automatic restart after a crash.", + "startup.method.current": "Current startup method", + "startup.method.native": "Native routing", + "startup.method.service": "Background service", + "startup.method.companion": "CodexCommander app", + "startup.method.shim": "Launcher shim", + "startup.method.none": "None", + "startup.method.unknown": "Unknown", + "startup.state.ready": "Ready", + "startup.state.attention": "Needs attention", + "startup.state.unknown": "Unknown", + "startup.recovery.background": "Background recovery", + "startup.recovery.on": "On", + "startup.recovery.off": "Off", + "startup.recovery.enable": "Enable crash recovery", + "startup.recovery.enabling": "Enabling crash recovery…", + "startup.recovery.enabled": "Enabled", + "startup.recovery.unsupported": "Not supported on this platform", + "startup.advanced.title": "Advanced startup options", + "startup.advanced.hint": "Launcher shim, raw commands, and repair tools for manual setups.", "startup.riskDetail": "Codex is pinned to the local proxy, but no persistent service or healthy launcher shim will start it again.", "startup.riskDetailCustomLocal": "Codex points to a custom local gateway. CodexCommander cannot manage or verify that gateway's restart lifecycle.", "startup.riskDetailWindowsShim": "The launcher shim protects supported CLI scripts, but Codex Desktop and direct codex.exe launches can bypass it on Windows.", @@ -126,6 +150,7 @@ export const en = { "startup.disabled": "Disabled", "startup.protection.service": "Background service", "startup.protection.shim": "Launcher shim", + "startup.protection.companion": "CodexCommander app", "startup.protection.none": "Not installed", "startup.details": "Protection details", "startup.service": "Background service", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 39c2f461f8..19eedf1d40 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -6,7 +6,7 @@ import type { TKey } from "./en"; export const ja: Record = { // sidebar / nav / common "nav.dashboard": "ダッシュボード", - "nav.startup": "起動安全性", + "nav.startup": "起動", "nav.providers": "プロバイダー", "nav.models": "モデル", "nav.combos": "コンボ", @@ -95,8 +95,8 @@ export const ja: Record = { "errorBoundary.reload": "再読み込み", // startup health - "startup.title": "起動安全性", - "startup.subtitle": "再起動後にローカルプロキシへの接続が再接続ループになる前に、Codex が CodexCommander へ到達できるか確認します。", + "startup.title": "起動", + "startup.subtitle": "CodexCommander の起動と復旧の方法を管理します。", "startup.refresh": "更新", "startup.backToDashboard": "ダッシュボードに戻る", "startup.loading": "起動保護を確認中…", @@ -105,9 +105,33 @@ export const ja: Record = { "startup.status.native": "ネイティブルーティング", "startup.status.protected": "再起動保護済み", "startup.status.atRisk": "対応が必要", + "startup.status.caution": "アプリ管理", "startup.summary.native": "Codex はローカルプロキシに依存していません", "startup.summary.protected": "再起動後も CodexCommander を利用できます", "startup.summary.atRisk": "再起動後に Codex がモデルへ接続できなくなる可能性があります", + "startup.summary.caution": "CodexCommander がログイン時に起動します", + "startup.hero.appManaged.badge": "アプリ管理", + "startup.hero.appManaged.title": "CodexCommander がログイン時に起動します", + "startup.hero.appManaged.body": "現在の設定は通常のデスクトップ利用に対応しています。クラッシュ後に自動で再起動したい場合は、クラッシュ復旧を有効にしてください。", + "startup.method.current": "現在の起動方法", + "startup.method.native": "ネイティブルーティング", + "startup.method.service": "バックグラウンドサービス", + "startup.method.companion": "CodexCommander アプリ", + "startup.method.shim": "Launcher shim", + "startup.method.none": "なし", + "startup.method.unknown": "不明", + "startup.state.ready": "準備完了", + "startup.state.attention": "要確認", + "startup.state.unknown": "不明", + "startup.recovery.background": "バックグラウンド復旧", + "startup.recovery.on": "オン", + "startup.recovery.off": "オフ", + "startup.recovery.enable": "クラッシュ復旧を有効化", + "startup.recovery.enabling": "クラッシュ復旧を有効化しています…", + "startup.recovery.enabled": "有効", + "startup.recovery.unsupported": "このプラットフォームでは未対応", + "startup.advanced.title": "詳細な起動オプション", + "startup.advanced.hint": "手動セットアップ向けの launcher shim、コマンド、修復ツールです。", "startup.riskDetail": "Codex はローカルプロキシを参照していますが、再起動する永続サービスまたは正常な launcher shim がありません。", "startup.riskDetailCustomLocal": "Codex はカスタムローカルゲートウェイを参照しています。CodexCommander はその再起動ライフサイクルを管理・検証できません。", "startup.riskDetailWindowsShim": "Launcher shim は対応する CLI スクリプトのみを保護し、Windows の Codex Desktop と codex.exe の直接起動はこれを迂回できます。", @@ -125,6 +149,7 @@ export const ja: Record = { "startup.protection.service": "バックグラウンドサービス", "startup.protection.shim": "Launcher shim", "startup.protection.none": "未インストール", + "startup.protection.companion": "CodexCommander アプリ", "startup.details": "保護の詳細", "startup.service": "バックグラウンドサービス", "startup.serviceHint": "ログイン時に起動し、クラッシュ後にプロキシを再起動します。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 26fc007add..83b1e27dd5 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -6,7 +6,7 @@ import type { TKey } from "./en"; export const ko: Record = { // sidebar / nav / common "nav.dashboard": "대시보드", - "nav.startup": "시작 안전성", + "nav.startup": "시작", "nav.providers": "프로바이더", "nav.models": "모델", "nav.combos": "콤보", @@ -90,8 +90,8 @@ export const ko: Record = { "errorBoundary.reload": "다시 불러오기", // startup health - "startup.title": "시작 안전성", - "startup.subtitle": "재부팅 후 로컬 프록시 라우팅이 재연결 반복으로 이어지기 전에 Codex가 CodexCommander에 연결될 수 있는지 확인합니다.", + "startup.title": "시작", + "startup.subtitle": "CodexCommander가 시작되고 복구되는 방식을 제어합니다.", "startup.refresh": "새로고침", "startup.backToDashboard": "대시보드로 돌아가기", "startup.loading": "시작 보호 상태 확인 중…", @@ -100,9 +100,33 @@ export const ko: Record = { "startup.status.native": "네이티브 라우팅", "startup.status.protected": "재부팅 보호됨", "startup.status.atRisk": "조치 필요", + "startup.status.caution": "앱 관리", "startup.summary.native": "Codex가 로컬 프록시에 의존하지 않습니다", "startup.summary.protected": "재부팅 후에도 CodexCommander가 자동으로 준비됩니다", "startup.summary.atRisk": "재부팅 후 Codex 모델 연결이 끊길 수 있습니다", + "startup.summary.caution": "로그인할 때 CodexCommander가 시작됩니다", + "startup.hero.appManaged.badge": "앱 관리", + "startup.hero.appManaged.title": "로그인할 때 CodexCommander가 시작됩니다", + "startup.hero.appManaged.body": "현재 설정은 일반적인 데스크톱 사용에 적합합니다. 충돌 후 자동으로 다시 시작하려면 충돌 복구를 활성화하세요.", + "startup.method.current": "현재 시작 방법", + "startup.method.native": "네이티브 라우팅", + "startup.method.service": "백그라운드 서비스", + "startup.method.companion": "CodexCommander 앱", + "startup.method.shim": "Launcher shim", + "startup.method.none": "없음", + "startup.method.unknown": "알 수 없음", + "startup.state.ready": "준비됨", + "startup.state.attention": "확인 필요", + "startup.state.unknown": "알 수 없음", + "startup.recovery.background": "백그라운드 복구", + "startup.recovery.on": "켜짐", + "startup.recovery.off": "꺼짐", + "startup.recovery.enable": "충돌 복구 활성화", + "startup.recovery.enabling": "충돌 복구를 활성화하는 중…", + "startup.recovery.enabled": "활성화됨", + "startup.recovery.unsupported": "이 플랫폼에서는 지원되지 않음", + "startup.advanced.title": "고급 시작 옵션", + "startup.advanced.hint": "수동 설정을 위한 launcher shim, 원시 명령, 복구 도구입니다.", "startup.riskDetail": "Codex는 로컬 프록시를 바라보지만 이를 다시 시작할 영구 서비스나 정상 launcher shim이 없습니다.", "startup.riskDetailCustomLocal": "Codex가 사용자 지정 로컬 게이트웨이를 바라봅니다. CodexCommander는 해당 게이트웨이의 재시작 수명주기를 관리하거나 검증할 수 없습니다.", "startup.riskDetailWindowsShim": "Launcher shim은 지원되는 CLI 스크립트만 보호하며 Windows의 Codex Desktop과 직접 codex.exe 실행은 이를 우회할 수 있습니다.", @@ -120,6 +144,7 @@ export const ko: Record = { "startup.protection.service": "백그라운드 서비스", "startup.protection.shim": "Launcher shim", "startup.protection.none": "설치되지 않음", + "startup.protection.companion": "CodexCommander 앱", "startup.details": "보호 상태 상세", "startup.service": "백그라운드 서비스", "startup.serviceHint": "로그인할 때 시작하고 프록시가 중단되면 다시 실행합니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 2a0c8255e9..1eca5dd872 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -6,7 +6,7 @@ import type { TKey } from "./en"; export const ru: Record = { // sidebar / nav / common "nav.dashboard": "Дашборд", - "nav.startup": "Безопасность запуска", + "nav.startup": "Запуск", "nav.providers": "Провайдеры", "nav.models": "Модели", "nav.combos": "Комбо", @@ -95,8 +95,8 @@ export const ru: Record = { "errorBoundary.reload": "Перезагрузить", // startup health - "startup.title": "Безопасность запуска", - "startup.subtitle": "Проверьте, сможет ли Codex подключиться к CodexCommander после перезагрузки, прежде чем локальный прокси вызовет бесконечное переподключение.", + "startup.title": "Запуск", + "startup.subtitle": "Управление запуском и восстановлением CodexCommander.", "startup.refresh": "Обновить", "startup.backToDashboard": "Назад к панели", "startup.loading": "Проверка защиты запуска…", @@ -105,9 +105,33 @@ export const ru: Record = { "startup.status.native": "Нативная маршрутизация", "startup.status.protected": "Перезапуск защищён", "startup.status.atRisk": "Требуется действие", + "startup.status.caution": "Управляется приложением", "startup.summary.native": "Codex не зависит от локального прокси", "startup.summary.protected": "CodexCommander будет доступен после перезагрузки", "startup.summary.atRisk": "После перезагрузки Codex может потерять доступ к моделям", + "startup.summary.caution": "CodexCommander запускается при входе", + "startup.hero.appManaged.badge": "Управляется приложением", + "startup.hero.appManaged.title": "CodexCommander запускается при входе", + "startup.hero.appManaged.body": "Текущая конфигурация подходит для обычной работы. Включите восстановление после сбоев, чтобы прокси автоматически перезапускался после аварии.", + "startup.method.current": "Текущий способ запуска", + "startup.method.native": "Нативная маршрутизация", + "startup.method.service": "Фоновая служба", + "startup.method.companion": "Приложение CodexCommander", + "startup.method.shim": "Launcher shim", + "startup.method.none": "Нет", + "startup.method.unknown": "Неизвестно", + "startup.state.ready": "Готово", + "startup.state.attention": "Требует внимания", + "startup.state.unknown": "Неизвестно", + "startup.recovery.background": "Фоновое восстановление", + "startup.recovery.on": "Вкл", + "startup.recovery.off": "Выкл", + "startup.recovery.enable": "Включить восстановление после сбоев", + "startup.recovery.enabling": "Включение восстановления после сбоев…", + "startup.recovery.enabled": "Включено", + "startup.recovery.unsupported": "Не поддерживается на этой платформе", + "startup.advanced.title": "Расширенные параметры запуска", + "startup.advanced.hint": "Launcher shim, команды и средства восстановления для ручной настройки.", "startup.riskDetail": "Codex направлен на локальный прокси, но постоянная служба или исправный launcher shim не запустят его снова.", "startup.riskDetailCustomLocal": "Codex направлен на пользовательский локальный шлюз. CodexCommander не может управлять или проверять его перезапуск.", "startup.riskDetailWindowsShim": "Launcher shim защищает поддерживаемые CLI-скрипты, но Codex Desktop и прямой запуск codex.exe в Windows могут обходить его.", @@ -125,6 +149,7 @@ export const ru: Record = { "startup.protection.service": "Фоновая служба", "startup.protection.shim": "Launcher shim", "startup.protection.none": "Не установлен", + "startup.protection.companion": "Приложение CodexCommander", "startup.details": "Сведения о защите", "startup.service": "Фоновая служба", "startup.serviceHint": "Запускается при входе и перезапускает прокси после сбоя.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index b00e6855a7..29209fbdb7 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -6,7 +6,7 @@ import type { TKey } from "./en"; export const zh: Record = { // sidebar / nav / common "nav.dashboard": "仪表盘", - "nav.startup": "启动安全", + "nav.startup": "启动", "nav.providers": "提供方", "nav.models": "模型", "nav.combos": "组合", @@ -90,8 +90,8 @@ export const zh: Record = { "errorBoundary.reload": "重新加载", // startup health - "startup.title": "启动安全", - "startup.subtitle": "检查重启后 Codex 是否仍能连接 CodexCommander,避免本地代理路由陷入重复重连。", + "startup.title": "启动", + "startup.subtitle": "控制 CodexCommander 的启动与恢复方式。", "startup.refresh": "刷新", "startup.backToDashboard": "返回仪表盘", "startup.loading": "正在检查启动保护…", @@ -100,9 +100,33 @@ export const zh: Record = { "startup.status.native": "原生路由", "startup.status.protected": "重启已受保护", "startup.status.atRisk": "需要处理", + "startup.status.caution": "由应用管理", "startup.summary.native": "Codex 不依赖本地代理", "startup.summary.protected": "重启后 CodexCommander 会自动可用", "startup.summary.atRisk": "重启后 Codex 可能无法访问模型", + "startup.summary.caution": "CodexCommander 将在登录时启动", + "startup.hero.appManaged.badge": "由应用管理", + "startup.hero.appManaged.title": "CodexCommander 将在登录时启动", + "startup.hero.appManaged.body": "当前设置可满足日常桌面使用。如果希望在崩溃后自动重启,请启用崩溃恢复。", + "startup.method.current": "当前启动方式", + "startup.method.native": "原生路由", + "startup.method.service": "后台服务", + "startup.method.companion": "CodexCommander 应用", + "startup.method.shim": "Launcher shim", + "startup.method.none": "无", + "startup.method.unknown": "未知", + "startup.state.ready": "就绪", + "startup.state.attention": "需要注意", + "startup.state.unknown": "未知", + "startup.recovery.background": "后台恢复", + "startup.recovery.on": "开", + "startup.recovery.off": "关", + "startup.recovery.enable": "启用崩溃恢复", + "startup.recovery.enabling": "正在启用崩溃恢复…", + "startup.recovery.enabled": "已启用", + "startup.recovery.unsupported": "此平台不支持", + "startup.advanced.title": "高级启动选项", + "startup.advanced.hint": "用于手动配置的 launcher shim、原始命令和修复工具。", "startup.riskDetail": "Codex 已指向本地代理,但没有持久服务或正常的 launcher shim 将其重新启动。", "startup.riskDetailCustomLocal": "Codex 指向自定义本地网关。CodexCommander 无法管理或验证该网关的重启生命周期。", "startup.riskDetailWindowsShim": "Launcher shim 仅保护受支持的 CLI 脚本;Windows 上的 Codex Desktop 和直接 codex.exe 启动可以绕过它。", @@ -120,6 +144,7 @@ export const zh: Record = { "startup.protection.service": "后台服务", "startup.protection.shim": "Launcher shim", "startup.protection.none": "未安装", + "startup.protection.companion": "CodexCommander 应用", "startup.details": "保护详情", "startup.service": "后台服务", "startup.serviceHint": "登录时启动,并在代理崩溃后重新启动。", diff --git a/gui/src/pages/Startup.tsx b/gui/src/pages/Startup.tsx index 2bd470534c..168f79d965 100644 --- a/gui/src/pages/Startup.tsx +++ b/gui/src/pages/Startup.tsx @@ -6,12 +6,13 @@ import { Notice } from "../ui"; import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; import { - StartupDetailsSection, + StartupAdvancedSection, StartupHeroSection, - StartupRecoverySection, + StartupPrimarySection, StartupTraySection, } from "./startup-sections"; import { + deriveStartupView, isTrayStatusData, type StartupHealthData, type StartupInstallAction, @@ -192,6 +193,7 @@ export default function Startup({ apiBase }: { apiBase: string }) { // warm revisits where `data` is already seeded from session cache. const loading = loadState.refreshing; const failed = Boolean(data?.diagnosticStale) || loadState.showError; + const view = data ? deriveStartupView(data, failed) : null; useEffect(() => { if (!data?.diagnosticStale) return; @@ -303,15 +305,16 @@ export default function Startup({ apiBase }: { apiBase: string }) { )}
)} - - } + {view && { void runInstallAction(action, opts); }} - /> + />} {data.platform === "win32" && ( { void runTrayAction(action); }} /> )} - { void copyCommand(command); }} /> + {view && { void runInstallAction(action, opts); }} + copied={copied} + onCopy={(command) => { void copyCommand(command); }} + defaultOpen={view.advancedDefaultOpen} + />} ) : null} diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index 506d2171b6..bfa44cbc21 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -35,7 +35,7 @@ export interface SettingsData { /** IANA zone of the machine running the proxy, used to render log timestamps (#725). */ timeZone?: string; startupHealth?: { - status: "native" | "protected" | "at-risk"; + status: "native" | "protected" | "caution" | "at-risk"; routingKind: StartupRoutingKind; autostartEnabled: boolean; shimCoverage: "full" | "cli-only" | "none"; diff --git a/gui/src/pages/startup-sections.tsx b/gui/src/pages/startup-sections.tsx index 5b5577dfea..785541464e 100644 --- a/gui/src/pages/startup-sections.tsx +++ b/gui/src/pages/startup-sections.tsx @@ -1,14 +1,15 @@ +import { useState } from "react"; import { useI18n } from "../i18n/shared"; import { startupRiskDetailKey } from "../startup-health-ui"; -import { IconAlert, IconCheck, IconPower, IconTerminal } from "../icons"; +import { IconAlert, IconCheck, IconChevron, IconMonitor, IconPower, IconRefresh, IconTerminal } from "../icons"; import type { StartupHealthData, StartupInstallAction, + StartupView, TrayStatusData, } from "./startup-shared"; import { - PROTECTION_KEYS, - startupRoutingKey, + METHOD_KEYS, STATUS_KEYS, SUMMARY_KEYS, } from "./startup-shared"; @@ -20,59 +21,64 @@ function StartupStateBadge({ ok, yes, no }: { ok: boolean; yes: string; no: stri export function StartupHeroSection({ failed, data, + view, }: { failed: boolean; data: StartupHealthData; + view: StartupView; }) { const { t } = useI18n(); - const statusClass = failed + const statusClass = view.hero === "at-risk" ? "startup-hero--risk" - : data.status === "protected" - ? "startup-hero--safe" - : data.status === "at-risk" - ? "startup-hero--risk" - : "startup-hero--native"; - const StatusIcon = failed || data.status === "at-risk" ? IconAlert : IconCheck; - - const routingKey = startupRoutingKey(data.routingKind); + : view.hero === "native" + ? "startup-hero--native" + : "startup-hero--safe"; + const StatusIcon = view.hero === "at-risk" + ? IconAlert + : view.hero === "app-managed" + ? IconTerminal + : IconCheck; + const appManaged = view.hero === "app-managed"; + // Badge and title follow the derived hero, not the raw status: a caution payload + // without a verified companion lease renders as at-risk, not as app-managed. + const badgeKey = appManaged + ? "startup.hero.appManaged.badge" + : view.hero === "at-risk" + ? "startup.status.atRisk" + : STATUS_KEYS[data.status]; + const titleKey = appManaged + ? "startup.hero.appManaged.title" + : view.hero === "at-risk" + ? failed ? "startup.error" : "startup.summary.atRisk" + : SUMMARY_KEYS[data.status]; return ( - <> -
-
-
- - {t(failed ? "startup.status.atRisk" : STATUS_KEYS[data.status])} - -

{t(failed ? "startup.error" : SUMMARY_KEYS[data.status])}

-

{failed - ? t("startup.staleData") - : data.status === "at-risk" - ? t(startupRiskDetailKey(data)) - : t("startup.safeDetail")}

+
+
+ + {t(badgeKey)} + +
+
+
+

{t(titleKey)}

+

{failed + ? t("startup.staleData") + : appManaged + ? t("startup.hero.appManaged.body") + : view.hero === "at-risk" + ? t(startupRiskDetailKey(data)) + : t("startup.safeDetail")}

+
-
- -
-
-
{t("startup.routing")}
-
{t(routingKey)}
-
-
-
{t("startup.restartProtection")}
-
{t(PROTECTION_KEYS[data.protection])}
-
-
-
{t("startup.preference")}
-
{t(data.autostartEnabled ? "startup.enabled" : "startup.disabled")}
-
- +
); } -export function StartupDetailsSection({ +export function StartupPrimarySection({ data, + view, failed, loading = false, installBusy, @@ -80,6 +86,7 @@ export function StartupDetailsSection({ onInstall, }: { data: StartupHealthData; + view: StartupView; failed: boolean; loading?: boolean; installBusy: StartupInstallAction | null; @@ -87,56 +94,49 @@ export function StartupDetailsSection({ onInstall: (action: StartupInstallAction, opts?: { repair?: boolean }) => void; }) { const { t } = useI18n(); - // Repair only rewrites stale assets — conflict/disabled need uninstall/reinstall, not repair. - const serviceNeedsRepair = data.serviceSupported && data.serviceInstalled && data.serviceStale && !data.serviceConflict; - const shimNeedsRepair = data.shimInstalled && !data.shimHealthy; const actionsDisabled = installBusy !== null || failed || loading; + const enabling = installBusy === "install-service"; return ( -
-
-

{t("startup.details")}

- {data.platform} -
-
-
{t("startup.service")}{t("startup.serviceHint")}
-
- - {data.serviceSupported && !data.serviceInstalled && ( - - )} - {serviceNeedsRepair && ( - +
+
+
+
{t("startup.method.current")}
+
+ {t(view.methodState === "unknown" ? "startup.method.unknown" : METHOD_KEYS[view.method])} +
+
+ {view.methodState === "ready" ? ( + + ) : ( + + )}
-
-
{t("startup.shim")}{t("startup.shimHint")}
-
- - {!data.shimInstalled && ( - - )} - {shimNeedsRepair && ( - + ) : ( + {t("startup.recovery.unsupported")} )}
@@ -211,16 +211,30 @@ export function StartupTraySection({ ); } -export function StartupRecoverySection({ +export function StartupAdvancedSection({ data, + failed, + loading = false, + installBusy, + onInstall, copied, onCopy, + defaultOpen, }: { data: StartupHealthData; + failed: boolean; + loading?: boolean; + installBusy: StartupInstallAction | null; + onInstall: (action: StartupInstallAction, opts?: { repair?: boolean }) => void; copied: string | null; onCopy: (command: string) => void; + defaultOpen: boolean; }) { const { t } = useI18n(); + // At-risk (or stale) opens Advanced so the repair affordances stay visible. The + // user's explicit toggle wins over the derived default afterwards. + const [override, setOverride] = useState(null); + const open = override ?? defaultOpen; // An already-registered service is refreshed in place. `install` re-registers, which // needs elevation on Windows and can switch a WinSW backend to Task Scheduler, so @@ -229,50 +243,110 @@ export function StartupRecoverySection({ const serviceCommand = data.serviceInstalled && !data.serviceConflict ? data.commands.repairService : data.commands.installService; + // Repair only rewrites stale assets — conflict/disabled need uninstall/reinstall, not repair. + const serviceNeedsRepair = data.serviceSupported && data.serviceInstalled && data.serviceStale && !data.serviceConflict; + const shimNeedsRepair = data.shimInstalled && !data.shimHealthy; + const actionsDisabled = installBusy !== null || failed || loading; return ( -
-
-

{t("startup.recovery")}

- -
-

{t("startup.recoveryHint")}

-
- {data.serviceSupported && ( +
+ +
); } diff --git a/gui/src/pages/startup-shared.ts b/gui/src/pages/startup-shared.ts index c3056873c3..2fe366aa25 100644 --- a/gui/src/pages/startup-shared.ts +++ b/gui/src/pages/startup-shared.ts @@ -1,8 +1,21 @@ import type { TKey } from "../i18n/shared"; -export type StartupStatus = "native" | "protected" | "at-risk"; -export type StartupProtection = "service" | "shim" | "none"; +export type StartupStatus = "native" | "protected" | "caution" | "at-risk"; +export type StartupMethod = "native" | "service" | "companion" | "shim" | "none"; +export type StartupProtection = "service" | "shim" | "companion" | "none"; export type StartupInstallAction = "install-service" | "install-shim"; +export type CompanionLaunchAtLogin = "enabled" | "disabled" | "requires-approval" | "unavailable"; + +/** + * Login state of the native companion app as observed by the server. `observedAt` is + * the lease timestamp; a missing or zero lease means launch-at-login could not be + * verified and must render as unknown, never as "disabled". + */ +export interface StartupCompanionStatus { + launchAtLogin: CompanionLaunchAtLogin; + observedAt: number; +} + export type StartupRoutingKind = | "native" | "codexcommander-local" @@ -24,6 +37,12 @@ export function startupRoutingKey(kind: StartupRoutingKind): TKey { export interface StartupHealthData { status: StartupStatus; + /** New contract: how the proxy comes up after login. Absent on legacy payloads. */ + startupMethod?: StartupMethod; + /** New contract: crash-recovery service state. Absent on legacy payloads. */ + crashRecovery?: boolean; + /** New contract: companion app login state. `undefined` = field not sent. */ + companion?: StartupCompanionStatus | null; routingKind: StartupRoutingKind; routingInjected: boolean; localRoutingDependency: boolean; @@ -72,17 +91,115 @@ export function isTrayStatusData(value: unknown): value is TrayStatusData { export const STATUS_KEYS: Record = { native: "startup.status.native", protected: "startup.status.protected", + caution: "startup.status.caution", "at-risk": "startup.status.atRisk", }; export const SUMMARY_KEYS: Record = { native: "startup.summary.native", protected: "startup.summary.protected", + caution: "startup.summary.caution", "at-risk": "startup.summary.atRisk", }; export const PROTECTION_KEYS: Record = { service: "startup.protection.service", shim: "startup.protection.shim", + companion: "startup.protection.companion", none: "startup.protection.none", }; + +export const METHOD_KEYS: Record = { + native: "startup.method.native", + service: "startup.method.service", + companion: "startup.method.companion", + shim: "startup.method.shim", + none: "startup.method.none", +}; + +/** True only when the companion lease was actually observed (nonzero timestamp). */ +export function companionLeaseObserved(companion: StartupCompanionStatus | null | undefined): boolean { + return companion != null && Number.isFinite(companion.observedAt) && companion.observedAt > 0; +} + +/** True only when the companion verifiably launches at login. */ +export function companionLaunchEnabled(data: StartupHealthData): boolean { + return companionLeaseObserved(data.companion) && data.companion?.launchAtLogin === "enabled"; +} + +/** Startup method, preferring the new contract field with a legacy fallback. */ +export function deriveStartupMethod(data: StartupHealthData): StartupMethod { + if (data.startupMethod) return data.startupMethod; + if (data.protection === "service") return "service"; + if (data.protection === "shim") return "shim"; + if (data.protection === "companion") return "companion"; + return data.status === "native" ? "native" : "none"; +} + +export type StartupHeroKind = "app-managed" | "protected" | "native" | "at-risk"; + +export type StartupMethodState = "ready" | "attention" | "unknown"; + +export interface StartupView { + hero: StartupHeroKind; + method: StartupMethod; + methodState: StartupMethodState; + crashRecovery: "on" | "off"; + /** The crash-recovery service can be installed from the primary row. */ + canEnableCrashRecovery: boolean; + /** True while the companion lease is missing and login state cannot be verified. */ + companionLeaseMissing: boolean; + /** At-risk keeps the repair affordances visible by opening Advanced up front. */ + advancedDefaultOpen: boolean; +} + +/** + * Single derivation for the Startup page so hero, primary rows, and Advanced stay + * consistent. `failed` (stale diagnostics or fetch failure) always wins: stale data + * must never render the calm app-managed state. + */ +export function deriveStartupView(data: StartupHealthData, failed: boolean): StartupView { + const method = deriveStartupMethod(data); + // A companion object without an observed lease is unknown, not "disabled". + const companionLeaseMissing = data.companion != null && !companionLeaseObserved(data.companion); + const companionOk = companionLaunchEnabled(data); + const crashRecoveryOn = data.crashRecovery !== undefined + ? data.crashRecovery + : data.protection === "service" && data.serviceViable; + + let hero: StartupHeroKind; + if (failed || data.status === "at-risk") { + hero = "at-risk"; + } else if (data.status === "caution") { + // Caution is app-managed only with a verified companion login; anything else + // (disabled, approval pending, unavailable, or a missing lease) needs attention. + hero = companionOk ? "app-managed" : "at-risk"; + } else if (data.status === "protected") { + hero = "protected"; + } else { + hero = "native"; + } + + let methodState: StartupMethodState; + if (method === "native") { + methodState = "ready"; + } else if (method === "companion") { + methodState = companionOk ? "ready" : companionLeaseMissing ? "unknown" : "attention"; + } else if (method === "service") { + methodState = data.serviceViable || data.rebootSafe ? "ready" : "attention"; + } else if (method === "shim") { + methodState = data.shimHealthy && data.autostartEnabled ? "ready" : "attention"; + } else { + methodState = "attention"; + } + + return { + hero, + method, + methodState, + crashRecovery: crashRecoveryOn ? "on" : "off", + canEnableCrashRecovery: !failed && !crashRecoveryOn && data.serviceSupported, + companionLeaseMissing, + advancedDefaultOpen: hero === "at-risk", + }; +} diff --git a/gui/src/startup-health-ui.ts b/gui/src/startup-health-ui.ts index fc2d0c1a75..09ae532404 100644 --- a/gui/src/startup-health-ui.ts +++ b/gui/src/startup-health-ui.ts @@ -90,8 +90,13 @@ export function mapStartupHealthProbe(data: { diagnosticStale?: unknown; }): StartupHealthStatus | null { const status = data.status; - const valid = status === "native" || status === "protected" || status === "at-risk"; + const valid = status === "native" || status === "protected" || status === "caution" || status === "at-risk"; if (!valid) return null; + // "caution" is the app-managed login state (companion starts at login, no crash + // recovery). The dashboard chip only promises availability after restart, which + // app-managed login satisfies, so it collapses to "protected" here; the Startup + // page renders the full caution nuance. + if (status === "caution") return "protected"; return status; } @@ -109,13 +114,13 @@ export function probeNeedsFastRetry(probe: StartupHealthProbe | undefined | null */ export function seedStartupHealthFromSettings( previous: StartupHealthStatus | null, - seeded: { status: "native" | "protected" | "at-risk"; diagnosticStale: boolean } | null | undefined, + seeded: { status: "native" | "protected" | "caution" | "at-risk"; diagnosticStale: boolean } | null | undefined, ): StartupHealthStatus | null { if (!seeded) return previous; // A prior hard "error" (or unknown) may be replaced by a settings seed; a real // status from the dedicated probe must not be overwritten. if (previous !== null && previous !== "error") return previous; - return seeded.status; + return seeded.status === "caution" ? "protected" : seeded.status; } /** Single owner cadence for project-config diagnostics (ms). */ diff --git a/gui/src/styles.css b/gui/src/styles.css index 5e1e59971f..9453a1d8d6 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -366,6 +366,7 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } .main { min-width: 0; } .main-inner { max-width: 980px; margin: 0 auto; padding: 32px 36px 64px; } .main-inner.main-inner--client-apps { max-width: 1280px; } +.main-inner.main-inner--startup { max-width: 1168px; padding-top: 64px; } .main-inner.main-inner--combos { max-width: none; margin: 0; @@ -1601,7 +1602,9 @@ dialog.modal-overlay::backdrop { .codex-account-priority-label { font-size: var(--text-label); color: var(--muted); font-weight: var(--weight-medium); white-space: nowrap; } .codex-account-priority .select-trigger { max-width: 100%; padding: 4px 9px; font-size: var(--text-label); } -.startup-page-sub { margin-bottom: 0; } +.main-inner--startup .page-head { align-items: flex-start; margin-bottom: 28px; } +.main-inner--startup .page-head h2 { font-size: 30px; line-height: var(--leading-tight); } +.startup-page-sub { margin: 8px 0 0; font-size: 17px; } .startup-page-head-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .startup-runtime-notice-slot { margin-bottom: 12px; @@ -1645,23 +1648,44 @@ dialog.modal-overlay::backdrop { .startup-runtime-notice__fix .btn { flex: 0 0 auto; } -.startup-hero { display: flex; gap: 16px; align-items: flex-start; margin-bottom: 16px; } +.startup-hero { display: block; min-height: 228px; margin-bottom: 38px; padding: 31px; } .startup-hero--safe { border-color: color-mix(in srgb, var(--green) 34%, var(--border)); background: color-mix(in srgb, var(--green-soft) 64%, var(--surface)); } .startup-hero--risk { border-color: color-mix(in srgb, var(--amber) 40%, var(--border)); background: color-mix(in srgb, var(--amber-soft) 72%, var(--surface)); } .startup-hero--native { border-color: color-mix(in srgb, var(--accent) 20%, var(--border)); } -.startup-hero-icon { width: 42px; height: 42px; border-radius: var(--radius); display: grid; place-items: center; background: var(--raised); flex: 0 0 auto; } -.startup-hero-icon svg { width: 21px; height: 21px; } -.startup-hero-copy h3 { margin: 10px 0 4px; font-size: var(--text-title); } -.startup-hero-copy p { margin: 0; color: var(--muted); line-height: var(--leading-body); max-width: var(--prose-measure); } -.startup-state-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px; } -.startup-state-grid .value { font-size: var(--text-subtitle); } -.startup-details, .startup-actions { margin-bottom: 16px; } +.startup-hero__badge { text-transform: uppercase; font-size: var(--text-label); padding: 4px 10px; } +.startup-hero-main { display: flex; align-items: center; gap: 34px; margin-top: 20px; } +.startup-hero-text { min-width: 0; } +.startup-hero-text h3 { margin: 0 0 9px; font-size: 24px; line-height: var(--leading-tight); } +.startup-hero-icon { width: 78px; height: 78px; border-radius: var(--radius-lg); display: grid; place-items: center; background: var(--raised); flex: 0 0 auto; } +.startup-hero-icon svg { width: 34px; height: 34px; } +.startup-hero-copy p { margin: 0; color: var(--muted); font-size: 17px; line-height: var(--leading-body); max-width: 66ch; } +.startup-primary, .startup-advanced, .startup-actions { margin-bottom: 38px; } +.startup-primary { padding: 18px 31px; } +.startup-primary-row { display: flex; align-items: center; gap: 28px; min-height: 112px; padding: 12px 0; } +.startup-primary-row + .startup-primary-row { border-top: 1px solid var(--border-soft); } +.startup-primary-row-icon { width: 58px; height: 58px; border-radius: var(--radius); display: grid; place-items: center; background: var(--raised); flex: 0 0 auto; } +.startup-primary-row-icon svg { width: 26px; height: 26px; } +.startup-primary-row-label { flex: 1 1 0; min-width: 0; font-size: 17px; font-weight: var(--weight-semibold); } +.startup-primary-row-value { flex: 1 1 0; min-width: 0; font-size: 17px; overflow-wrap: anywhere; } +.startup-primary-row-actions { flex: 0 0 auto; display: flex; align-items: center; justify-content: flex-end; gap: 8px; } +.startup-primary-row-actions .btn { min-height: 48px; padding: 10px 22px; font-size: 16px; } +.startup-primary-row-actions .badge { font-size: 14px; padding: 4px 10px; } +.startup-advanced { padding: 0; } +.startup-advanced-toggle { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; min-height: 78px; padding: 22px 28px; background: none; border: none; color: var(--text); font: inherit; font-size: 17px; font-weight: var(--weight-semibold); cursor: pointer; text-align: left; border-radius: var(--radius); } +.startup-advanced-toggle:hover { background: var(--hover); } +.startup-advanced-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.startup-advanced-chevron { width: 16px; height: 16px; flex: 0 0 auto; color: var(--muted); transform: rotate(90deg); transition: transform var(--motion-fast); } +.startup-advanced-chevron--open { transform: rotate(-90deg); } +#startup-advanced-panel { padding: 0 16px 16px; } +.startup-advanced-hint { margin: 0 0 10px; font-size: var(--text-control); max-width: var(--prose-measure); } +.startup-advanced-subhead { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 14px 0 4px; font-size: var(--text-control); font-weight: var(--weight-semibold); } .startup-actions .panel-head > svg { width: 18px; height: 18px; flex: 0 0 auto; } .startup-detail-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 0; border-top: 1px solid var(--border-soft); } .startup-detail-row > div { display: flex; flex-direction: column; gap: 3px; min-width: 0; } .startup-detail-row > .startup-detail-actions { flex-direction: row; align-items: center; justify-content: flex-end; flex: 0 0 auto; gap: 8px; } .startup-detail-row span:not(.badge) { color: var(--muted); font-size: var(--text-label); line-height: var(--leading-body); } .startup-actions > .muted { margin: -4px 0 14px; font-size: var(--text-control); max-width: var(--prose-measure); } +.startup-advanced .startup-detail-row:first-of-type { border-top: none; } .startup-tray-buttons { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; min-height: 36px; } .startup-tray-error { margin-top: 12px; } .startup-command-list { border: 1px solid var(--border-soft); border-radius: var(--radius-sm); overflow: hidden; } @@ -1672,7 +1696,26 @@ dialog.modal-overlay::backdrop { .startup-command-row code { color: var(--muted); font-size: var(--text-label); overflow-wrap: anywhere; } .startup-action-notice { margin: 14px 0 0; } @media (max-width: 700px) { - .startup-state-grid { grid-template-columns: 1fr; } + .main-inner.main-inner--startup { padding-top: 28px; } + .main-inner--startup .page-head { align-items: stretch; flex-direction: column; margin-bottom: 22px; } + .main-inner--startup .page-head h2 { font-size: var(--text-display); } + .startup-page-head-actions { justify-content: flex-start; } + .startup-hero { min-height: 0; padding: 22px; margin-bottom: 20px; } + .startup-hero-main { align-items: flex-start; gap: 16px; } + .startup-hero-icon { width: 52px; height: 52px; border-radius: var(--radius); } + .startup-hero-icon svg { width: 24px; height: 24px; } + .startup-hero-text h3 { font-size: var(--text-title); } + .startup-hero-copy p { font-size: var(--text-body); } + .startup-primary, .startup-advanced, .startup-actions { margin-bottom: 20px; } + .startup-primary { padding: 10px 20px; } + .startup-primary-row { min-height: 0; gap: 14px; padding: 18px 0; } + .startup-primary-row-icon { width: 46px; height: 46px; } + .startup-primary-row-label, .startup-primary-row-value { font-size: var(--text-body); } + .startup-primary-row-actions .btn { min-height: 40px; padding: 8px 16px; font-size: var(--text-control); } + .startup-advanced-toggle { min-height: 60px; padding: 16px 20px; font-size: var(--text-body); } + .startup-primary-row { flex-wrap: wrap; row-gap: 8px; } + .startup-primary-row-value { flex-basis: 100%; order: 3; } + .startup-primary-row-actions { order: 2; margin-left: auto; } .startup-command-row { align-items: flex-start; } .startup-detail-row { align-items: flex-start; } .startup-detail-row > .startup-detail-actions { flex-direction: column; align-items: flex-end; } diff --git a/gui/tests/startup-app-managed.test.tsx b/gui/tests/startup-app-managed.test.tsx new file mode 100644 index 0000000000..bb6104c8c6 --- /dev/null +++ b/gui/tests/startup-app-managed.test.tsx @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import Startup from "../src/pages/Startup"; +import type { StartupHealthData } from "../src/pages/startup-shared"; + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +const originalFetch = globalThis.fetch; + +const API_BASE = "http://localhost"; + +function appManagedHealth(overrides: Partial = {}): StartupHealthData { + return { + status: "caution", + startupMethod: "companion", + crashRecovery: false, + companion: { launchAtLogin: "enabled", observedAt: 1_755_000_000_000 }, + routingKind: "codexcommander-local", + routingInjected: true, + localRoutingDependency: true, + autostartEnabled: true, + rebootSafe: true, + protection: "companion", + serviceInstalled: false, + serviceViable: false, + serviceEnabled: false, + serviceRunning: false, + serviceStale: false, + serviceConflict: false, + serviceSupported: true, + shimInstalled: false, + shimHealthy: false, + shimCoverage: "none", + platform: "darwin", + recommendedCommand: "ccx service install", + diagnosticStale: false, + commands: { + installService: "ccx service install", + repairService: "ccx service repair", + installShim: "ccx shim install", + restoreNative: "ccx restore", + }, + ...overrides, + }; +} + +beforeEach(() => { + clearClientResourceStoresForTests(); + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow.window }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + testWindow.sessionStorage.clear(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearClientResourceStoresForTests(); + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function renderStartup(payload: StartupHealthData, onPost?: (body: string) => void) { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (init?.method === "POST" && url.includes("/api/startup-action")) { + onPost?.(String(init.body)); + return Response.json({ ok: true }); + } + if (url.includes("/api/settings")) return Response.json({ codexRuntime: {} }); + if (url.includes("/api/startup-health")) return Response.json(payload); + return new Response(null, { status: 404 }); + }) as typeof fetch; + + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + await act(async () => { await new Promise(r => testWindow.setTimeout(r, 0)); }); + await act(async () => { await new Promise(r => testWindow.setTimeout(r, 0)); }); + return { container, root }; +} + +test("caution + companion renders the calm app-managed hero and primary rows", async () => { + const { container, root } = await renderStartup(appManagedHealth()); + + expect(container.textContent).toContain("App-managed"); + expect(container.textContent).toContain("CodexCommander starts at login"); + expect(container.textContent).toContain("Current startup method"); + expect(container.textContent).toContain("CodexCommander app"); + expect(container.textContent).toContain("Ready"); + expect(container.textContent).toContain("Background recovery"); + expect(container.textContent).toContain("Off"); + + const enable = container.querySelector('button[aria-label="Background recovery - Enable crash recovery"]'); + expect(enable).not.toBeNull(); + expect((enable as HTMLButtonElement).disabled).toBe(false); + + // Advanced stays collapsed behind an accessible disclosure. + const toggle = container.querySelector(".startup-advanced-toggle"); + expect(toggle?.getAttribute("aria-expanded")).toBe("false"); + expect(toggle?.getAttribute("aria-controls")).toBe("startup-advanced-panel"); + expect(container.querySelector("#startup-advanced-panel")?.hasAttribute("hidden")).toBe(true); + + await act(async () => { root.unmount(); }); + container.remove(); +}); + +test("the Advanced disclosure expands on demand with correct aria state", async () => { + const { container, root } = await renderStartup(appManagedHealth()); + const toggle = container.querySelector(".startup-advanced-toggle") as HTMLButtonElement; + + await act(async () => { toggle.click(); }); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + expect(container.querySelector("#startup-advanced-panel")?.hasAttribute("hidden")).toBe(false); + // Launcher shim and raw commands live inside Advanced. + expect(container.textContent).toContain("Codex launcher shim"); + expect(container.textContent).toContain("ccx shim install"); + + await act(async () => { root.unmount(); }); + container.remove(); +}); + +test("Enable crash recovery posts the install-service action", async () => { + const posts: string[] = []; + const { container, root } = await renderStartup(appManagedHealth(), body => posts.push(body)); + + const enable = container.querySelector('button[aria-label="Background recovery - Enable crash recovery"]') as HTMLButtonElement; + await act(async () => { enable.click(); }); + await act(async () => { await new Promise(r => testWindow.setTimeout(r, 0)); }); + + expect(posts.length).toBe(1); + expect(JSON.parse(posts[0])).toEqual({ action: "install-service", repair: false }); + expect(container.textContent).toContain("Background service installed successfully."); + + await act(async () => { root.unmount(); }); + container.remove(); +}); + +test("stale diagnostics never render the calm app-managed state", async () => { + const { container, root } = await renderStartup(appManagedHealth({ diagnosticStale: true })); + + expect(container.textContent).toContain("The latest startup check failed"); + expect(container.textContent).not.toContain("Your current setup works for normal desktop use"); + // Stale data is not actionable: the enable button stays disabled. + const enable = container.querySelector('button[aria-label="Background recovery - Enable crash recovery"]') as HTMLButtonElement; + expect(enable.disabled).toBe(true); + + await act(async () => { root.unmount(); }); + container.remove(); +}); + +test("a missing companion lease renders unknown, never a false disabled state", async () => { + const { container, root } = await renderStartup(appManagedHealth({ + companion: { launchAtLogin: "enabled", observedAt: 0 }, + })); + + expect(container.textContent).not.toContain("Your current setup works for normal desktop use"); + expect(container.textContent).toContain("Unknown"); + expect(container.textContent).not.toContain("App-managed"); + + await act(async () => { root.unmount(); }); + container.remove(); +}); + +test("at-risk auto-expands Advanced so the repair notice stays visible", async () => { + const { container, root } = await renderStartup(appManagedHealth({ + status: "at-risk", + startupMethod: "none", + companion: null, + protection: "none", + autostartEnabled: false, + rebootSafe: false, + })); + + const toggle = container.querySelector(".startup-advanced-toggle"); + expect(toggle?.getAttribute("aria-expanded")).toBe("true"); + expect(container.querySelector("#startup-advanced-panel")?.hasAttribute("hidden")).toBe(false); + expect(container.textContent).toContain("Recommended repair"); + + await act(async () => { root.unmount(); }); + container.remove(); +}); diff --git a/gui/tests/startup-nav-entry.test.ts b/gui/tests/startup-nav-entry.test.ts new file mode 100644 index 0000000000..cd9745856c --- /dev/null +++ b/gui/tests/startup-nav-entry.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test"; + +/** + * Startup is a permanent navigation destination under the System group, not a + * page reachable only through dashboard deep links. The selected startup UX + * keeps it next to Storage with the terminal glyph. + */ + +const src = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); +const navTable = src.slice(src.indexOf("const NAV_SECTIONS"), src.indexOf("const NAV =")); + +test("the sidebar System group contains a permanent Startup entry", () => { + const systemIndex = navTable.indexOf('labelKey: "nav.group.system"'); + expect(systemIndex).toBeGreaterThanOrEqual(0); + const systemSection = navTable.slice(systemIndex); + expect(systemSection).toContain('{ id: "startup", tkey: "nav.startup", Icon: IconTerminal }'); + // Startup leads the System group, before Storage. + expect(systemSection.indexOf('id: "startup"')).toBeLessThan(systemSection.indexOf('id: "storage"')); +}); + +test("the Startup page is still routed and rendered", () => { + expect(src).toContain('{page === "startup" && }'); + expect(src).toContain('startup: "nav.startup"'); +}); diff --git a/gui/tests/startup-view-model.test.ts b/gui/tests/startup-view-model.test.ts new file mode 100644 index 0000000000..1b07ca3e01 --- /dev/null +++ b/gui/tests/startup-view-model.test.ts @@ -0,0 +1,145 @@ +import { expect, test } from "bun:test"; +import { + companionLeaseObserved, + deriveStartupMethod, + deriveStartupView, + type StartupHealthData, +} from "../src/pages/startup-shared"; + +function health(overrides: Partial): StartupHealthData { + return { + status: "native", + routingKind: "native", + routingInjected: false, + localRoutingDependency: false, + autostartEnabled: false, + rebootSafe: false, + protection: "none", + serviceInstalled: false, + serviceViable: false, + serviceEnabled: false, + serviceRunning: false, + serviceStale: false, + serviceConflict: false, + serviceSupported: true, + shimInstalled: false, + shimHealthy: false, + shimCoverage: "none", + platform: "darwin", + recommendedCommand: null, + diagnosticStale: false, + commands: { + installService: "ccx service install", + repairService: "ccx service repair", + installShim: "ccx shim install", + restoreNative: "ccx restore", + }, + ...overrides, + }; +} + +test("caution with a verified companion lease renders calm app-managed", () => { + const view = deriveStartupView(health({ + status: "caution", + startupMethod: "companion", + crashRecovery: false, + protection: "companion", + autostartEnabled: true, + rebootSafe: true, + companion: { launchAtLogin: "enabled", observedAt: 1_755_000_000_000 }, + }), false); + expect(view.hero).toBe("app-managed"); + expect(view.method).toBe("companion"); + expect(view.methodState).toBe("ready"); + expect(view.crashRecovery).toBe("off"); + expect(view.canEnableCrashRecovery).toBe(true); + expect(view.advancedDefaultOpen).toBe(false); +}); + +test("a missing companion lease stays at-risk/unknown and is never reported disabled", () => { + const data = health({ + status: "caution", + startupMethod: "companion", + crashRecovery: false, + protection: "companion", + companion: { launchAtLogin: "enabled", observedAt: 0 }, + }); + expect(companionLeaseObserved(data.companion)).toBe(false); + const view = deriveStartupView(data, false); + expect(view.hero).toBe("at-risk"); + expect(view.methodState).toBe("unknown"); + expect(view.companionLeaseMissing).toBe(true); +}); + +test("companion disabled or pending approval needs attention, not calm", () => { + for (const launchAtLogin of ["disabled", "requires-approval", "unavailable"] as const) { + const view = deriveStartupView(health({ + status: "caution", + startupMethod: "companion", + protection: "companion", + companion: { launchAtLogin, observedAt: 1_755_000_000_000 }, + }), false); + expect(view.hero).toBe("at-risk"); + expect(view.methodState).toBe("attention"); + } +}); + +test("protected with the crash-recovery service reads as recovery on", () => { + const view = deriveStartupView(health({ + status: "protected", + startupMethod: "service", + crashRecovery: true, + protection: "service", + serviceInstalled: true, + serviceViable: true, + rebootSafe: true, + }), false); + expect(view.hero).toBe("protected"); + expect(view.method).toBe("service"); + expect(view.methodState).toBe("ready"); + expect(view.crashRecovery).toBe("on"); + expect(view.canEnableCrashRecovery).toBe(false); +}); + +test("legacy payloads derive method and recovery from the old protection fields", () => { + const legacyService = health({ + status: "protected", + protection: "service", + serviceInstalled: true, + serviceViable: true, + }); + expect(deriveStartupMethod(legacyService)).toBe("service"); + expect(deriveStartupView(legacyService, false).crashRecovery).toBe("on"); + + const legacyShim = health({ status: "protected", protection: "shim", shimInstalled: true, shimHealthy: true, autostartEnabled: true }); + expect(deriveStartupMethod(legacyShim)).toBe("shim"); + expect(deriveStartupView(legacyShim, false).methodState).toBe("ready"); + + const legacyNone = health({ status: "at-risk", routingKind: "codexcommander-local", localRoutingDependency: true }); + expect(deriveStartupMethod(legacyNone)).toBe("none"); + expect(deriveStartupView(legacyNone, false).hero).toBe("at-risk"); +}); + +test("stale diagnostics never render calm app-managed and block the enable action", () => { + const view = deriveStartupView(health({ + status: "caution", + startupMethod: "companion", + crashRecovery: false, + protection: "companion", + diagnosticStale: true, + companion: { launchAtLogin: "enabled", observedAt: 1_755_000_000_000 }, + }), true); + expect(view.hero).toBe("at-risk"); + expect(view.canEnableCrashRecovery).toBe(false); + expect(view.advancedDefaultOpen).toBe(true); +}); + +test("at-risk opens Advanced so the repair affordances stay visible", () => { + const view = deriveStartupView(health({ + status: "at-risk", + routingKind: "codexcommander-local", + localRoutingDependency: true, + }), false); + expect(view.hero).toBe("at-risk"); + expect(view.advancedDefaultOpen).toBe(true); +}); diff --git a/src/codex/autostart-health.ts b/src/codex/autostart-health.ts index 69d8645998..e03564a288 100644 --- a/src/codex/autostart-health.ts +++ b/src/codex/autostart-health.ts @@ -4,9 +4,27 @@ import type { CodexCommanderConfig } from "../types"; import { getCodexRoutingKind, type CodexRoutingKind } from "./inject"; import { diagnoseCodexShim, type CodexShimDiagnostic } from "./shim"; -export type StartupProtection = "service" | "shim" | "none"; -export type StartupHealthStatus = "native" | "protected" | "at-risk"; +export type StartupProtection = "service" | "shim" | "companion" | "none"; +export type StartupHealthStatus = "native" | "protected" | "caution" | "at-risk"; export type ShimCoverage = "full" | "cli-only" | "none"; +/** + * Which mechanism actually keeps Codex routing alive across a reboot or proxy + * crash. `companion` is advisory (a fresh native-app lease), never a substitute + * for a service diagnostic. The CLI-only shim stays conservative. + */ +export type StartupMethod = "native" | "service" | "companion" | "shim" | "none"; + +/** + * The native app's launch-at-login self-report, kebab-cased on the wire. This is + * advisory state the server never trusts for `custom-local`/`unknown` routing. + */ +export type LaunchAtLoginReport = "enabled" | "disabled" | "requires-approval" | "unavailable"; + +export interface CompanionHealthInfo { + launchAtLogin: LaunchAtLoginReport; + /** Server-side observation timestamp in epoch milliseconds; client timestamps are never accepted. */ + observedAt: number; +} export interface StartupHealthInputs { routingKind: CodexRoutingKind; @@ -26,12 +44,18 @@ export interface StartupHealthInputs { export interface StartupHealth { status: StartupHealthStatus; + /** Effective startup mechanism after response-time decoration (base keeps `none`). */ + startupMethod: StartupMethod; + /** Whether the active mechanism survives a proxy crash without user action. */ + crashRecovery: boolean; routingKind: CodexRoutingKind; routingInjected: boolean; localRoutingDependency: boolean; autostartEnabled: boolean; rebootSafe: boolean; protection: StartupProtection; + /** Fresh native-app launch-at-login lease, decorated at response time. */ + companion: CompanionHealthInfo | null; serviceInstalled: boolean; serviceViable: boolean; serviceEnabled: boolean; @@ -80,6 +104,16 @@ export function deriveStartupHealth(inputs: StartupHealthInputs): StartupHealth ? "shim" : "none"; const rebootSafe = !localRoutingDependency || (ownsLocalRouting && inputs.serviceViable); + // Base diagnosis never knows about the native companion: the server decorates the + // lease only at response time, so the cached probe output stays companion-free. + const startupMethod: StartupMethod = !localRoutingDependency + ? "native" + : ownsLocalRouting && inputs.serviceViable + ? "service" + : ownsLocalRouting && shimEffective + ? "shim" + : "none"; + const crashRecovery = ownsLocalRouting && inputs.serviceViable; const status: StartupHealthStatus = !localRoutingDependency ? "native" : rebootSafe @@ -103,8 +137,11 @@ export function deriveStartupHealth(inputs: StartupHealthInputs): StartupHealth routingInjected, localRoutingDependency, status, + startupMethod, + crashRecovery, rebootSafe, protection, + companion: null, shimCoverage, recommendedCommand, commands: { ...COMMANDS }, @@ -144,6 +181,9 @@ export function startupHealthSummary(health: StartupHealth): string { if (health.status === "native") return health.routingKind === "custom-remote" ? "custom remote Codex routing (no local restart dependency)" : "native Codex routing (no CodexCommander restart dependency)"; + if (health.status === "caution") { + return "running via the menu bar companion (launch at login is enabled; service install is optional for crash recovery)"; + } if (health.protection === "service") return "protected by background service"; const command = health.recommendedCommand ?? health.commands.restoreNative; if (health.routingKind === "unknown") return `AT RISK after restart (Codex routing could not be verified; run '${command}')`; diff --git a/src/server/companion-startup-state.ts b/src/server/companion-startup-state.ts new file mode 100644 index 0000000000..3aee13ed75 --- /dev/null +++ b/src/server/companion-startup-state.ts @@ -0,0 +1,180 @@ +import type { + CompanionHealthInfo, + LaunchAtLoginReport, + StartupHealth, +} from "../codex/autostart-health"; + +/** + * Native-app companion startup state. + * + * The menu bar app is the only writer. It PUTs its freshly sampled launch-at-login + * status using the admin token; the server stamps the observation time itself and + * keeps a short in-memory lease. Client-supplied timestamps, TTLs, PIDs, paths, and + * bundle metadata are never accepted or logged. + * + * The lease is advisory decoration only. The base service/shim diagnosis is cached + * separately (startup-health-cache.ts) and is never overridden by a fresh lease: + * every `/api/startup-health` response and settings seed is decorated at response + * time, and a viable service always wins over the companion. + */ + +export const COMPANION_BODY_VERSION = 1 as const; +export const COMPANION_HEARTBEAT_TARGET_MS = 30_000; +export const COMPANION_LEASE_TTL_MS = 90_000; + +export interface CompanionLease { + version: typeof COMPANION_BODY_VERSION; + launchAtLogin: LaunchAtLoginReport; + /** Server wall-clock timestamp (ms) when the lease was received. */ + observedAt: number; +} + +const LAUNCH_AT_LOGIN_VALUES: readonly LaunchAtLoginReport[] = [ + "enabled", + "disabled", + "requires-approval", + "unavailable", +]; + +let lease: CompanionLease | null = null; + +export function recordCompanionLease( + launchAtLogin: LaunchAtLoginReport, + now: number = Date.now(), +): void { + lease = { version: COMPANION_BODY_VERSION, launchAtLogin, observedAt: now }; +} + +/** The current fresh lease, or null once the 90s TTL has elapsed. Advisory only. */ +export function currentCompanionLease(now: number = Date.now()): CompanionLease | null { + if (!lease) return null; + const age = now - lease.observedAt; + // Fail closed on TTL expiry AND on a backwards-moving wall clock: a negative age + // must never keep a lease fresh indefinitely. + if (age < 0 || age >= COMPANION_LEASE_TTL_MS) return null; + return lease; +} + +/** + * Test seam: in-memory state is process-global, so a focused suite can isolate + * itself. Production restart semantics are covered by the fresh-module import test. + */ +export function clearCompanionLeaseForTests(): void { + lease = null; +} + +export type CompanionStartupBodyResult = + | { ok: true; launchAtLogin: LaunchAtLoginReport } + | { ok: false; error: string }; + +/** + * Strict body validation for PUT /api/startup-health/companion. Only the exact + * `{version: 1, launchAtLogin: }` shape is accepted; any extra key (including + * client timestamps, TTLs, PIDs, paths, or bundle metadata) is rejected. + */ +export function parseCompanionStartupBody(raw: unknown): CompanionStartupBodyResult { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return { ok: false, error: "body must be a JSON object" }; + } + const record = raw as Record; + const keys = Object.keys(record); + if (keys.length !== 2 || !("version" in record) || !("launchAtLogin" in record)) { + return { ok: false, error: "body must contain exactly version and launchAtLogin" }; + } + if (record.version !== COMPANION_BODY_VERSION) { + return { ok: false, error: "version must be 1" }; + } + if (!LAUNCH_AT_LOGIN_VALUES.includes(record.launchAtLogin as LaunchAtLoginReport)) { + return { + ok: false, + error: "launchAtLogin must be enabled, disabled, requires-approval, or unavailable", + }; + } + return { ok: true, launchAtLogin: record.launchAtLogin as LaunchAtLoginReport }; +} + +function companionInfo(lease: CompanionLease): CompanionHealthInfo { + return { + launchAtLogin: lease.launchAtLogin, + observedAt: lease.observedAt, + }; +} + +interface EffectiveStartup { + status: StartupHealth["status"]; + startupMethod: StartupHealth["startupMethod"]; + rebootSafe: boolean; + crashRecovery: boolean; + protection: StartupHealth["protection"]; +} + +/** + * Precedence (locked contract): + * - no local routing => native/native true/false + * - owned local + viable service => protected/service true/true (service wins) + * - owned local + fresh enabled companion => caution/companion true/false + * - shim stays conservative => at-risk/shim false/false + * - other local (custom/unknown) => at-risk/none false/false + * + * The companion is never credited for `custom-local`/`unknown` routing, and a fresh + * lease never overrides `diagnosticStale` (the cache already fails closed there). + */ +function deriveEffectiveStartup( + base: StartupHealth, + companionLease: CompanionLease | null, +): EffectiveStartup { + if (!base.localRoutingDependency) { + return { status: "native", startupMethod: "native", rebootSafe: true, crashRecovery: false, protection: "none" }; + } + if (base.diagnosticStale) { + // Preserve the cache's fail-closed answer; do not resurrect protection or credit + // a fresh companion lease while a probe is revalidating. + return { + status: base.status, + startupMethod: base.startupMethod, + rebootSafe: base.rebootSafe, + crashRecovery: base.crashRecovery, + protection: base.protection, + }; + } + const ownsRouting = base.routingKind === "codexcommander-local"; + if (ownsRouting && base.serviceViable) { + return { status: "protected", startupMethod: "service", rebootSafe: true, crashRecovery: true, protection: "service" }; + } + const companionCredited = ownsRouting + && companionLease !== null + && companionLease.launchAtLogin === "enabled"; + if (companionCredited) { + return { status: "caution", startupMethod: "companion", rebootSafe: true, crashRecovery: false, protection: "companion" }; + } + if (ownsRouting && base.shimCoverage !== "none") { + return { status: "at-risk", startupMethod: "shim", rebootSafe: false, crashRecovery: false, protection: "shim" }; + } + return { status: "at-risk", startupMethod: "none", rebootSafe: false, crashRecovery: false, protection: "none" }; +} + +/** + * Decorate a base diagnosis with the current companion lease. The lease is + * informational whenever it is fresh; it changes effective health only when the + * precedence above credits it. `recommendedCommand` stays null for the + * companion-managed case (the app owns startup), while the existing `commands` + * shape still exposes `installService` as an optional crash-recovery action. + */ +export function decorateStartupHealth( + base: StartupHealth, + now: number = Date.now(), +): StartupHealth { + const companionLease = currentCompanionLease(now); + const companion = companionLease ? companionInfo(companionLease) : null; + const effective = deriveEffectiveStartup(base, companionLease); + return { + ...base, + status: effective.status, + startupMethod: effective.startupMethod, + rebootSafe: effective.rebootSafe, + crashRecovery: effective.crashRecovery, + protection: effective.protection, + recommendedCommand: effective.status === "caution" ? null : base.recommendedCommand, + companion, + }; +} diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 18fcd2f4ab..9e1ee52ae1 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -64,6 +64,11 @@ import type { PersistedUsageAttempt } from "../../usage/log"; import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; import { applySystemEnvToggle } from "../system-env"; import { getCachedStartupHealth, invalidateStartupHealthCache } from "../startup-health-cache"; +import { + decorateStartupHealth, + parseCompanionStartupBody, + recordCompanionLease, +} from "../companion-startup-state"; import { runWindowsTrayAction } from "../windows-tray-control"; import { runStartupInstallAction, type StartupInstallAction } from "../startup-action-control"; import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortClamp, resolveCodexRuntime } from "../../codex/runtime"; @@ -129,7 +134,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise | null = null; let generation = 0; export function markStartupHealthDiagnosticStale(value: StartupHealth): StartupHealth { - if (!value.localRoutingDependency) return { ...value, diagnosticStale: true }; + if (!value.localRoutingDependency) { + return { ...value, diagnosticStale: true, companion: null }; + } return { ...value, status: "at-risk", + startupMethod: "none", rebootSafe: false, + crashRecovery: false, protection: "none", diagnosticStale: true, + // A stale probe must never be decorated back into a companion caution. + companion: null, // Mirror deriveStartupHealth's choice: an already-registered service is refreshed in // place. Hardcoding installService here silently undid that for every stale-cache // read, which is the path the dashboard hits while a probe is revalidating. diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 96e11d014a..fd44fa550c 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -175,12 +175,25 @@ mismatch stays on tee and emits a startup warning (`src/lib/bun-stream-caps.ts`) ## Startup safety -**Startup safety** is reachable by route (`/#startup`) and rendered by the app, but it is not a -sidebar entry: it is entered from the dashboard's startup-state row, which links there whether the -current state needs remediation or merely reports how routing is protected. Its warning state is derived from active -Codex routing plus the actual service and launcher-shim installation state; the -`codexAutoStart` preference alone is never presented as proof of restart protection. The page shows -copyable repair commands (`ccx service repair` for an installed service or `ccx service install` when none is registered, `ccx codex-shim install`, and `ccx restore`). On +**Startup safety** is reachable by route (`/#startup`) and is a permanent entry in the dashboard's +System navigation. The dashboard's startup-state row also links there whether the current state +needs remediation or merely reports how routing is started. Its state is derived from active Codex +routing plus the actual service and launcher-shim installation state. On macOS, the companion can +also report its current Launch at Login presentation through an admin-token-only, memory-only lease. +A fresh enabled lease identifies the normal desktop setup as `caution` + `companion`: it is restart +safe at sign-in, but it does not provide crash supervision. A viable background service wins over +the companion and reports `protected` + `service`; it is presented as the optional crash-recovery +upgrade. Stale diagnostics, custom-local routing, and unknown routing remain fail-closed and cannot +be upgraded by companion evidence. The `codexAutoStart` preference alone is never presented as proof +of restart protection. + +The base service/shim diagnosis is cached, but companion state is merged into every response from a +server-timestamped lease and is never persisted. `PUT /api/startup-health/companion` accepts only the +raw admin-token principal, not GUI sessions; reports are advisory and cannot authorize requests, +change routing, or suppress repair for routes the proxy does not own. The page keeps direct service +actions available and moves copyable advanced repair commands (`ccx service repair`, +`ccx service install`, `ccx codex-shim install`, and `ccx restore`) behind an accessible disclosure. +True at-risk repair guidance stays visible. On Windows it can also install an owned, per-user system tray. The resident tray owns only its icon, home-scoped singleton, and HKCU Run registration; fixed proxy actions delegate to the CLI so drain, service conflict handling, native restore, and PID identity remain centralized. Tray presence never diff --git a/tests/companion-startup-state.test.ts b/tests/companion-startup-state.test.ts new file mode 100644 index 0000000000..992e4943ff --- /dev/null +++ b/tests/companion-startup-state.test.ts @@ -0,0 +1,403 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { deriveStartupHealth, type StartupHealth } from "../src/codex/autostart-health"; +import { handleManagementAPI } from "../src/server/management-api"; +import { + COMPANION_HEARTBEAT_TARGET_MS, + COMPANION_LEASE_TTL_MS, + clearCompanionLeaseForTests, + currentCompanionLease, + decorateStartupHealth, + parseCompanionStartupBody, + recordCompanionLease, +} from "../src/server/companion-startup-state"; +import { invalidateStartupHealthCache } from "../src/server/startup-health-cache"; +import type { CodexCommanderConfig } from "../src/types"; +import { ManagementRequest as Request } from "./helpers/management-auth"; + +const config = { + port: 10100, + defaultProvider: "openai", + providers: {}, +} as CodexCommanderConfig; + +function ownedLocalBase(overrides: Partial[0]> = {}): StartupHealth { + return deriveStartupHealth({ + routingKind: "codexcommander-local", + autostartEnabled: true, + serviceInstalled: false, + serviceViable: false, + serviceEnabled: false, + serviceRunning: false, + serviceStale: false, + serviceConflict: false, + serviceSupported: true, + shimInstalled: false, + shimHealthy: false, + platform: "darwin", + ...overrides, + }); +} + +async function companionPut( + body: unknown, + principal?: "admin-token" | "gui-session", +): Promise<{ status: number; raw: string }> { + const url = new URL("http://127.0.0.1:10100/api/startup-health/companion"); + const req = new Request(url, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); + const res = await handleManagementAPI(req, url, config, {}, principal); + expect(res).not.toBeNull(); + return { status: res!.status, raw: await res!.text() }; +} + +beforeEach(() => { + clearCompanionLeaseForTests(); + invalidateStartupHealthCache(); +}); + +afterEach(() => { + clearCompanionLeaseForTests(); + invalidateStartupHealthCache(); +}); + +describe("PUT /api/startup-health/companion security", () => { + test("accepts the raw admin token and records a server-timestamped lease", async () => { + const before = Date.now(); + const { status, raw } = await companionPut( + { version: 1, launchAtLogin: "enabled" }, + "admin-token", + ); + expect(status).toBe(204); + expect(raw).toBe(""); + const lease = currentCompanionLease(Date.now()); + expect(lease).not.toBeNull(); + expect(lease!.launchAtLogin).toBe("enabled"); + expect(lease!.observedAt).toBeGreaterThanOrEqual(before); + expect(lease!.observedAt).toBeLessThanOrEqual(Date.now()); + }); + + test("rejects a GUI session with 403 and records nothing", async () => { + const { status } = await companionPut( + { version: 1, launchAtLogin: "enabled" }, + "gui-session", + ); + expect(status).toBe(403); + expect(currentCompanionLease()).toBeNull(); + }); + + test("fails closed when no explicit principal is resolved", async () => { + const { status } = await companionPut({ version: 1, launchAtLogin: "enabled" }); + expect(status).toBe(403); + expect(currentCompanionLease()).toBeNull(); + }); + + test("rejects malformed and extra input without recording a lease", async () => { + for (const body of [ + null, + [], + ["enabled"], + "enabled", + {}, + { launchAtLogin: "enabled" }, + { version: 1 }, + { version: 2, launchAtLogin: "enabled" }, + { version: "1", launchAtLogin: "enabled" }, + { version: 1, launchAtLogin: "sometimes" }, + { version: 1, launchAtLogin: "enabled", timestamp: 1_700_000_000_000 }, + { version: 1, launchAtLogin: "enabled", ttl: 90 }, + { version: 1, launchAtLogin: "enabled", pid: 123 }, + { version: 1, launchAtLogin: "enabled", path: "/Applications/CodexCommander.app" }, + { version: 1, launchAtLogin: "enabled", bundle: { id: "com.codexcommander.menubar" } }, + ] as unknown[]) { + const { status } = await companionPut(body, "admin-token"); + expect(status).toBe(400); + } + expect(currentCompanionLease()).toBeNull(); + }); + + test("the route and lease module never log bodies or tokens", async () => { + const configRoutes = await Bun.file( + new URL("../src/server/management/config-routes.ts", import.meta.url), + ).text(); + const section = configRoutes.slice(configRoutes.indexOf("api/startup-health/companion")); + expect(section).not.toMatch(/console\./); + expect(section).not.toMatch(/logger\./); + const module = await Bun.file( + new URL("../src/server/companion-startup-state.ts", import.meta.url), + ).text(); + expect(module).not.toMatch(/console\./); + expect(module).not.toMatch(/logger\./); + }); +}); + +describe("companion lease TTL", () => { + test("the lease stays fresh for the 90s window and expires at the boundary", () => { + const now = 1_000_000; + recordCompanionLease("enabled", now); + expect(currentCompanionLease(now + COMPANION_LEASE_TTL_MS - 1)).not.toBeNull(); + expect(currentCompanionLease(now + COMPANION_LEASE_TTL_MS)).toBeNull(); + expect(currentCompanionLease(now + COMPANION_LEASE_TTL_MS + 1)).toBeNull(); + // The heartbeat target is one third of the lease so a single dropped beat + // cannot expire protection by accident. + expect(COMPANION_HEARTBEAT_TARGET_MS * 3).toBe(COMPANION_LEASE_TTL_MS); + }); + + test("a backwards-moving wall clock expires the lease instead of keeping it fresh", () => { + const now = 1_500_000; + recordCompanionLease("enabled", now); + // Any observation in the "future" relative to now must fail closed. + expect(currentCompanionLease(now - 1)).toBeNull(); + expect(currentCompanionLease(now - 60_000)).toBeNull(); + const decorated = decorateStartupHealth(ownedLocalBase(), now - 1); + expect(decorated.status).toBe("at-risk"); + expect(decorated.startupMethod).toBe("none"); + expect(decorated.companion).toBeNull(); + }); + + test("decoration credits a fresh lease and drops credit once expired", () => { + const now = 2_000_000; + const base = ownedLocalBase(); + recordCompanionLease("enabled", now); + const fresh = decorateStartupHealth(base, now); + expect(fresh.status).toBe("caution"); + expect(fresh.startupMethod).toBe("companion"); + const expired = decorateStartupHealth(base, now + COMPANION_LEASE_TTL_MS); + expect(expired.status).toBe("at-risk"); + expect(expired.startupMethod).toBe("none"); + expect(expired.companion).toBeNull(); + }); +}); + +describe("companion precedence", () => { + test("no local routing stays native/native even with a fresh lease", () => { + const base = deriveStartupHealth({ + routingKind: "native", + autostartEnabled: false, + serviceInstalled: false, + serviceViable: false, + serviceEnabled: false, + serviceRunning: false, + serviceStale: false, + serviceConflict: false, + serviceSupported: true, + shimInstalled: false, + shimHealthy: false, + platform: "darwin", + }); + recordCompanionLease("enabled", 3_000_000); + const decorated = decorateStartupHealth(base, 3_000_000); + expect(decorated).toMatchObject({ + status: "native", + startupMethod: "native", + rebootSafe: true, + crashRecovery: false, + protection: "none", + }); + // The lease is informational even when it grants no credit. + expect(decorated.companion).toEqual({ launchAtLogin: "enabled", observedAt: 3_000_000 }); + }); + + test("a viable service wins over a fresh companion lease", () => { + const base = ownedLocalBase({ + serviceInstalled: true, + serviceViable: true, + serviceEnabled: true, + serviceRunning: true, + }); + recordCompanionLease("enabled", 4_000_000); + const decorated = decorateStartupHealth(base, 4_000_000); + expect(decorated).toMatchObject({ + status: "protected", + startupMethod: "service", + rebootSafe: true, + crashRecovery: true, + protection: "service", + recommendedCommand: null, + }); + expect(decorated.companion).not.toBeNull(); + }); + + test("a fresh enabled companion gives caution without alarming repair guidance", () => { + const base = ownedLocalBase(); + recordCompanionLease("enabled", 5_000_000); + const decorated = decorateStartupHealth(base, 5_000_000); + expect(decorated).toMatchObject({ + status: "caution", + startupMethod: "companion", + rebootSafe: true, + crashRecovery: false, + protection: "companion", + recommendedCommand: null, + }); + // Service install stays available as an optional crash-recovery action. + expect(decorated.commands.installService).toBe("ccx service install"); + }); + + test("disabled or approval-required leases are never credited", () => { + for (const launchAtLogin of ["disabled", "requires-approval", "unavailable"] as const) { + const now = 6_000_000; + recordCompanionLease(launchAtLogin, now); + const decorated = decorateStartupHealth(ownedLocalBase(), now); + expect(decorated.status).toBe("at-risk"); + expect(decorated.startupMethod).toBe("none"); + expect(decorated.protection).toBe("none"); + expect(decorated.companion?.launchAtLogin).toBe(launchAtLogin); + } + }); + + test("a fresh lease never overrides a stale diagnostic", () => { + const base = { + ...ownedLocalBase({ serviceInstalled: true, serviceViable: true, serviceEnabled: true, serviceRunning: true }), + diagnosticStale: true, + status: "at-risk" as const, + rebootSafe: false, + protection: "none" as const, + startupMethod: "none" as const, + crashRecovery: false, + }; + recordCompanionLease("enabled", 7_000_000); + const decorated = decorateStartupHealth(base, 7_000_000); + expect(decorated.status).toBe("at-risk"); + expect(decorated.startupMethod).toBe("none"); + expect(decorated.rebootSafe).toBe(false); + expect(decorated.crashRecovery).toBe(false); + expect(decorated.protection).toBe("none"); + // Informational lease still surfaces; effective health is untouched. + expect(decorated.companion?.launchAtLogin).toBe("enabled"); + }); + + test("custom-local and unknown routing never credit the companion", () => { + for (const routingKind of ["custom-local", "unknown"] as const) { + const now = 8_000_000; + const base = deriveStartupHealth({ + routingKind, + autostartEnabled: true, + serviceInstalled: true, + serviceViable: true, + serviceEnabled: true, + serviceRunning: true, + serviceStale: false, + serviceConflict: false, + serviceSupported: true, + shimInstalled: false, + shimHealthy: false, + platform: "darwin", + }); + recordCompanionLease("enabled", now); + const decorated = decorateStartupHealth(base, now); + expect(decorated).toMatchObject({ + status: "at-risk", + startupMethod: "none", + rebootSafe: false, + crashRecovery: false, + protection: "none", + recommendedCommand: "ccx restore", + }); + expect(decorated.companion?.launchAtLogin).toBe("enabled"); + } + }); + + test("a shim stays conservative even with a fresh companion lease", () => { + const base = ownedLocalBase({ shimInstalled: true, shimHealthy: true }); + const now = 9_000_000; + recordCompanionLease("disabled", now); + const decorated = decorateStartupHealth(base, now); + expect(decorated).toMatchObject({ + status: "at-risk", + startupMethod: "shim", + rebootSafe: false, + crashRecovery: false, + protection: "shim", + }); + }); + + test("an owned-local case with no mechanism stays at-risk/none", () => { + const now = 10_000_000; + recordCompanionLease("disabled", now); + const decorated = decorateStartupHealth(ownedLocalBase(), now); + expect(decorated).toMatchObject({ + status: "at-risk", + startupMethod: "none", + rebootSafe: false, + crashRecovery: false, + protection: "none", + }); + }); +}); + +describe("companion decoration at response time", () => { + test("the settings seed is decorated with the fresh lease", async () => { + const deps = { + resolveCodexRuntime: () => ({ + runtime: { command: "codex-fixture", version: "0.999.0", source: "environment" as const }, + failures: [], + }), + getCachedStartupHealth: async () => ownedLocalBase(), + }; + recordCompanionLease("enabled", Date.now()); + const req = new Request("http://127.0.0.1:10100/api/settings"); + const res = await handleManagementAPI(req, new URL(req.url), config, deps); + expect(res?.status).toBe(200); + const body = await res!.json() as { startupHealth: StartupHealth }; + expect(body.startupHealth.status).toBe("caution"); + expect(body.startupHealth.startupMethod).toBe("companion"); + expect(body.startupHealth.recommendedCommand).toBeNull(); + expect(body.startupHealth.companion).not.toBeNull(); + expect(body.startupHealth.companion!.launchAtLogin).toBe("enabled"); + expect(typeof body.startupHealth.companion!.observedAt).toBe("number"); + }); + + test("GET /api/startup-health carries an internally consistent decorated payload", async () => { + recordCompanionLease("enabled", Date.now()); + const url = new URL("http://127.0.0.1:10100/api/startup-health"); + const req = new Request(url); + const res = await handleManagementAPI(req, url, config); + expect(res?.status).toBe(200); + const body = await res!.json() as Record; + expect(["native", "protected", "caution", "at-risk"]).toContain(body.status); + expect(["native", "service", "companion", "shim", "none"]).toContain(body.startupMethod); + expect(typeof body.rebootSafe).toBe("boolean"); + expect(typeof body.crashRecovery).toBe("boolean"); + expect((body.status === "caution") === (body.startupMethod === "companion")).toBe(true); + expect(body.crashRecovery === (body.startupMethod === "service")).toBe(true); + expect(body.companion).toMatchObject({ launchAtLogin: "enabled" }); + expect(typeof (body.companion as { observedAt: unknown }).observedAt).toBe("number"); + }); + + test("without a lease the companion field is null", async () => { + const url = new URL("http://127.0.0.1:10100/api/startup-health"); + const req = new Request(url); + const res = await handleManagementAPI(req, url, config); + expect(res?.status).toBe(200); + const body = await res!.json() as { companion: unknown }; + expect(body.companion).toBeNull(); + }); +}); + +describe("server restart loses the lease", () => { + test("a fresh module instance has no companion state", async () => { + const now = 11_000_000; + recordCompanionLease("enabled", now); + expect(currentCompanionLease(now)).not.toBeNull(); + + const restarted = await import("../src/server/companion-startup-state.ts?restart=1"); + expect(restarted.currentCompanionLease(now)).toBeNull(); + const decorated = restarted.decorateStartupHealth(ownedLocalBase(), now); + expect(decorated.status).toBe("at-risk"); + expect(decorated.startupMethod).toBe("none"); + expect(decorated.companion).toBeNull(); + }); +}); + +describe("strict body parser", () => { + test("accepts only the locked shape", () => { + expect(parseCompanionStartupBody({ version: 1, launchAtLogin: "enabled" })) + .toEqual({ ok: true, launchAtLogin: "enabled" }); + expect(parseCompanionStartupBody({ version: 1, launchAtLogin: "requires-approval" })) + .toEqual({ ok: true, launchAtLogin: "requires-approval" }); + expect(parseCompanionStartupBody({ version: 1, launchAtLogin: "disabled" }).ok).toBe(true); + }); +});