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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions app/Sources/MenuBarCore/ProxyClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
11 changes: 10 additions & 1 deletion app/Sources/MenuBarCore/ProxySnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
66 changes: 66 additions & 0 deletions app/Sources/MenuBarCoreTests/TransportSuite.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<T>(
_ operation: () async throws -> T
) async -> ProxyError? {
Expand Down
Loading
Loading