Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0307a1e
docs: design zero-click macOS first-run bootstrap
Aug 20, 2026
2fa2327
docs: plan zero-click macOS first run
Aug 20, 2026
2144e7b
feat(config): add create-only first-run initialization
Aug 20, 2026
d1bf525
fix(config): harden first-run initialization races
Aug 20, 2026
3d20432
fix(config): retain lossless root identities
Aug 20, 2026
757deb7
fix(config): remove mutable identity test seam
Aug 20, 2026
4f1bc4c
feat(macos): define first-run bootstrap policy
Aug 20, 2026
8f82bb2
docs: report macOS first-run policy
Aug 20, 2026
03ce052
test(macos): exercise first-run policy in temp homes
Aug 20, 2026
484e376
test(macos): harden first-run subprocess probes
Aug 20, 2026
ead09dc
feat(lifecycle): carry macOS first-run setup state
Aug 20, 2026
59e80b2
feat(macos): bootstrap direct app start
Aug 20, 2026
161d1c5
feat(macos): decode first-run setup outcomes
Aug 20, 2026
39fb835
feat(macos): show nonfatal Codex setup guidance
Aug 20, 2026
8e24503
feat(macos): make first-run location guidance actionable
Aug 21, 2026
edc0656
fix(macos): narrow app translocation detection
Aug 21, 2026
ed6d0b8
docs: explain zero-click macOS first run
Aug 21, 2026
53b0ad7
docs: align CLI and app startup guidance
Aug 21, 2026
58356e6
docs: finish macOS startup semantics
Aug 21, 2026
b7397d2
fix: wait for proxy runtime ownership after spawn
Aug 21, 2026
7880ae6
fix(config): bind first-run writes to physical root
Aug 21, 2026
aee9354
fix(config): isolate fenced initialization inputs
Aug 21, 2026
40c878f
fix(config): close final initializer races
Aug 21, 2026
c744b36
refactor(config): simplify first-run creation
Aug 21, 2026
5b538cc
docs(config): align initializer contract
Aug 21, 2026
bb27a74
docs: fix landing source quickstart
Aug 21, 2026
d699683
fix(macos): fence translocated spawn actions
Aug 21, 2026
bfb372e
test: protect macOS first-run temp cleanup
Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Task 2: macOS first-run policy

Status: complete

Commit: `4f1bc4c6b0b04e2f7cc542440c15de924a5287f1`

## Result

Added the pure `prepareMacOSAppStart` policy layer. It consumes the canonical
default config and Task 1's lossless `initializeConfigIfMissing` result,
classifies Codex config presence using `lstatSync` (only `ENOENT` is missing),
persists `clientIntegrations.codex=false` only on a newly created app config
when Codex is missing, and returns the explicit first-run setup requirement.
Existing app config and all Codex config files remain untouched. Typed
initialization refusals map to secret-free `CONFIGURATION_REQUIRED` errors.

## Validation

- `bun test tests/macos-first-run.test.ts` — 4 pass, 0 fail.
- `bun run typecheck` — pass.
- `bun run test:parallel` — reached all 596 test-file completions with no
failures observed, but was interrupted after the runner continued draining
unusually slow unrelated macOS/build and discovery tests; it did not produce
a final aggregate exit result.

## Files changed

- `src/cli/macos-first-run.ts`
- `tests/macos-first-run.test.ts`

## Concerns

No known policy concerns. Full-suite aggregate completion remains unconfirmed
because unrelated long-running tests prevented the parallel runner from
exiting in a reasonable time; focused policy tests and strict typecheck are
clean.

## Fix round 1

Addressed review feedback by replacing injected-only branch coverage with four
isolated subprocess probes. Each probe sets temporary `CODEXCOMMANDER_HOME` and
`CODEX_HOME` before importing the production policy, blocks `fetch`, runs the
real initializer/classifier, and asserts exact app config bytes, app metadata
entries, Codex config bytes, and Codex directory entries for its branch.

Validation:

- `bun test tests/macos-first-run.test.ts` — 4 pass, 0 fail (22 assertions).
- `bun run typecheck` — pass.
- `git diff --check` — pass.

## Fix round 2

Added a production-path `ENOTDIR` probe: the child imports the policy while
`CODEX_HOME` is a directory, replaces that path with a sentinel-bearing file,
then runs startup so `lstatSync(CODEX_CONFIG_PATH)` receives `ENOTDIR`. The
probe verifies normal routing, no setup requirement, canonical default app
bytes, and unchanged Codex sentinel bytes.

The child harness now requires empty stderr and parses the complete stdout as
one JSON document (no last-line fallback). Fixture bytes are inspected by the
parent after the child exits, so the refusal fixture's secret sentinel can be
asserted absent from both child streams.

Validation:

- `bun test tests/macos-first-run.test.ts` — 5 pass, 0 fail (33 assertions).
- `bun run typecheck` — pass.
- `git diff --check` — pass.
39 changes: 33 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Two commands, and every one of them runs any LLM you point it at.</p>
```bash
bun install
bun run build:gui
bun run src/cli/index.ts init
bun run src/cli/index.ts start
```

Expand Down Expand Up @@ -34,6 +35,7 @@ account while existing threads stay pinned to the account that started them.
```bash
bun install
bun run build:gui
bun run src/cli/index.ts init
bun run src/cli/index.ts start # or use `service` instead of `start`
```

Expand Down Expand Up @@ -71,13 +73,29 @@ This preview requires macOS 13 or later. It is ad-hoc signed and not notarized y
3. If macOS still blocks it, open **System Settings → Privacy & Security** and choose **Open
Anyway**. Do not disable Gatekeeper. See [Apple's instructions](https://support.apple.com/guide/mac-help/open-a-mac-app-from-an-unknown-developer-mh40616/mac).

On a fresh Mac, a direct app launch creates CodexCommander's secret-free ChatGPT passthrough default
automatically. If Codex has not created `~/.codex/config.toml` yet, the proxy and dashboard still start
while Codex remains native; open Codex once, then choose **Route Codex Through Proxy** from the menu.
The app never creates Codex configuration automatically. Existing valid, invalid, unreadable, or
unsafe CodexCommander configuration is preserved and is never overwritten; repair an invalid or
inaccessible configuration before trying again. Providers, API keys, and OAuth accounts are not copied
from another Mac. Public distribution uses the universal release archive above, not the thin
development `.app` produced by a source checkout.

Applications and `~/Applications` support **Launch at Login**. A copy launched from Desktop or
Downloads is allowed to run for the current session, but the app shows neutral guidance to move it to
Applications for login startup. Quit CodexCommander before moving a running app, then reopen it from
its new location; the app never moves itself. If macOS launches the app through App Translocation,
**Start** is blocked before the proxy launches: move the app and reopen it. These location rules do not
change the ad-hoc Gatekeeper steps above.

<p align="center">
<img src="docs-site/public/macos-menu-bar.png" alt="CodexCommander macOS menu bar companion showing a confirmed Codex route, a live request, provider quotas, and proxy controls" width="387">
</p>

| Action | What it does |
|---|---|
| **Start Proxy** | Starts or attaches to the proxy, then routes Codex through it. |
| **Start Proxy** | Starts or attaches to the proxy, then routes Codex through it when Codex configuration exists; on a fresh missing-Codex start, leaves Codex native and shows setup guidance. |
| **Restore Native Codex** | Switches only Codex back to OpenAI; the proxy keeps running. |
| **Route Codex Through Proxy** | Switches only the Codex route to the already-running proxy. |
| **Stop Proxy… / Restart Proxy…** | Restores native routing before stopping; Restart then starts and routes back. |
Expand Down Expand Up @@ -120,10 +138,13 @@ Codex tasks, history, or authentication, and it does not require a repair comman
database. Generated catalogs and caches may remain on disk, but native Codex no longer references
them.

On its first launch, the app enables **Launch at Login** so the menu icon returns after sign-in.
On every new manual or Login Item launch, the app performs an explicit **Start**: it starts or
attaches to the proxy, then routes managed Codex through it. An external user-managed Codex provider
is preserved. The startup row exposes the actual mode: **Desktop**
On its first launch from **Applications**, `~/Applications`, or the supported source-build location,
the app enables **Launch at Login** so the menu icon returns after sign-in. A Desktop or Downloads copy
may run for the current session but is not eligible for login startup. On every new manual or Login Item
launch, the app performs an explicit **Start**: it starts or attaches to the proxy, then routes managed
Codex through it when Codex configuration exists. If Codex has not created its config yet, the proxy and
dashboard still run with Codex native and the app shows setup guidance to open Codex once, then choose
**Route Codex Through Proxy**. An external user-managed Codex provider is preserved. The startup row exposes the actual mode: **Desktop**
performs this app-managed start, **Headless** leaves only an installed background service at login,
and **Off** starts neither automatically. Rebuilt source apps refresh their login registration in
place; they are never copied into Application Support. Full
Expand All @@ -140,8 +161,8 @@ rewrites OpenCode config files. For plain OpenCode or the Desktop app, use the d
### For agents

```bash
bun run src/cli/index.ts start # or use `service`
bun run src/cli/index.ts init # interactive setup: writes config; can route through a proven live proxy
bun run src/cli/index.ts start # or use `service`, after init
```

`ccx init` never starts the proxy. If a current-home proxy is already running and its protected
Expand All @@ -150,6 +171,12 @@ until an explicit `ccx start`. Headless commands like `ccx provider add` and `cc
the **live** proxy and exit nonzero when it is unreachable. `ccx status` / `ccx doctor` / `ccx
health` report the running state.

The source/headless path requires `ccx init` (or the equivalent `bun run src/cli/index.ts init`) to
create CodexCommander's configuration before ordinary CLI starts. Ordinary CLI startup does not own
the macOS app's automatic bootstrap and refuses a missing configuration; it never creates a Codex
config or a hand-written JSON substitute. Existing Codex configuration, including an external provider,
is left untouched unless you explicitly choose a CodexCommander route.

## Supported platforms

| OS | Status | Service manager |
Expand Down
15 changes: 15 additions & 0 deletions app/Sources/MenuBarCore/ActionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,19 @@ public enum RestartOutcome: Equatable, Sendable {
case failed(String)
}

public enum ProxySetupRequirement: Equatable, Sendable {
case codexFirstRun
case unknown(String)

init(rawValue: String) {
self = rawValue == "codex-first-run" ? .codexFirstRun : .unknown(rawValue)
}
}

public enum ProxyControlOutcome: Equatable, Sendable {
case running
case stopped
case setupRequired(ProxySetupRequirement)
/// The proxy is healthy, but long-lived Codex workers still hold an older roster.
case catalogUpdateReady(staleWorkerCount: Int?)
case failed(String)
Expand Down Expand Up @@ -98,6 +108,11 @@ public actor ActionCoordinator {
guard result.ok, result.state == expected else {
return .failed(result.message)
}
if expected == .running,
let rawSetup = result.setupRequired,
!rawSetup.isEmpty {
return .setupRequired(ProxySetupRequirement(rawValue: rawSetup))
}
return expected == .running ? .running : .stopped
} catch let error as LifecycleHelperError {
return .failed(error.userMessage)
Expand Down
57 changes: 44 additions & 13 deletions app/Sources/MenuBarCore/LaunchAtLogin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,39 @@ public enum LaunchAtLoginStatus: String, Equatable, Sendable {
case unavailable
}

public enum LaunchAtLoginRemediation: Equatable, Sendable {
case openSystemSettings
case openApplications
}

public struct LaunchAtLoginPresentation: Equatable, Sendable {
public let status: LaunchAtLoginStatus
public let desiredEnabled: Bool
public let isToggleEnabled: Bool
public let errorMessage: String?
public let relocationRequired: Bool

public init(
status: LaunchAtLoginStatus,
desiredEnabled: Bool,
isToggleEnabled: Bool,
errorMessage: String? = nil
errorMessage: String? = nil,
relocationRequired: Bool = false
) {
self.status = status
self.desiredEnabled = desiredEnabled
self.isToggleEnabled = isToggleEnabled
self.errorMessage = errorMessage
self.relocationRequired = relocationRequired
}

public var isOn: Bool { status == .enabled }
public var needsApproval: Bool { status == .requiresApproval }
public var remediation: LaunchAtLoginRemediation? {
if relocationRequired { return .openApplications }
if needsApproval { return .openSystemSettings }
return nil
}
}

public enum DesktopStartupMode: String, Equatable, Sendable {
Expand Down Expand Up @@ -161,7 +174,7 @@ public final class LaunchAtLoginController {
status: .unavailable,
desiredEnabled: preferences.desiredEnabled ?? true,
isToggleEnabled: false,
errorMessage: "Move CodexCommander to Applications or use its repository build."
relocationRequired: true
)
}
if preferences.desiredEnabled == nil {
Expand Down Expand Up @@ -211,7 +224,7 @@ public final class LaunchAtLoginController {
status: .unavailable,
desiredEnabled: preferences.desiredEnabled ?? false,
isToggleEnabled: false,
errorMessage: "Move CodexCommander to Applications or use its repository build."
relocationRequired: true
)
}
let previousDesiredEnabled = preferences.desiredEnabled
Expand Down Expand Up @@ -259,7 +272,7 @@ public final class LaunchAtLoginController {
status: .unavailable,
desiredEnabled: preferences.desiredEnabled ?? true,
isToggleEnabled: false,
errorMessage: "Move CodexCommander to Applications or use its repository build."
relocationRequired: true
)
}
if service.status == .enabled, preferences.desiredEnabled == false {
Expand Down Expand Up @@ -315,24 +328,42 @@ public enum ExecutableFingerprint {
}
}

public enum AppBundleLocation: Equatable, Sendable {
case stable
case relocatable
case translocated
}

public enum LaunchAtLoginEligibility {
public static func isStableBundle(
public static func classify(
_ bundleURL: URL,
home: URL = FileManager.default.homeDirectoryForCurrentUser
) -> Bool {
) -> AppBundleLocation {
let bundle = bundleURL.resolvingSymlinksInPath()
let path = bundle.path
if path.hasPrefix("/private/var/folders/"),
path.contains("/AppTranslocation/") {
return .translocated
}
guard bundle.pathExtension == "app",
bundle.lastPathComponent == "CodexCommander.app",
!path.contains("/AppTranslocation/")
else { return false }
bundle.lastPathComponent == "CodexCommander.app"
else { return .relocatable }

if path.hasPrefix("/Applications/") { return true }
let userApplications = home.appendingPathComponent("Applications", isDirectory: true).path
if path.hasPrefix("\(userApplications)/") { return true }
if path.hasPrefix("/Applications/") { return .stable }
let userApplications = home.resolvingSymlinksInPath()
.appendingPathComponent("Applications", isDirectory: true).path
if path.hasPrefix("\(userApplications)/") { return .stable }

return bundle.deletingLastPathComponent().lastPathComponent == "macos"
let sourceBuild = bundle.deletingLastPathComponent().lastPathComponent == "macos"
&& bundle.deletingLastPathComponent()
.deletingLastPathComponent().lastPathComponent == "dist"
return sourceBuild ? .stable : .relocatable
}

public static func isStableBundle(
_ bundleURL: URL,
home: URL = FileManager.default.homeDirectoryForCurrentUser
) -> Bool {
classify(bundleURL, home: home) == .stable
}
}
7 changes: 7 additions & 0 deletions app/Sources/MenuBarCore/LifecycleHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ public struct LifecycleCommandResult: Decodable, Equatable, Sendable {
public let port: Int?
public let message: String
public let errorCode: String?
/// Optional setup guidance from a successful proxy start. Retain the raw string
/// so newer helper values remain forward-compatible with older app builds.
public let setupRequired: String?
/// These fields are present only for the `applyCodexCatalog` action. The app receives
/// counts rather than process identifiers.
public let catalogUpdated: Bool?
Expand All @@ -48,6 +51,7 @@ public struct LifecycleCommandResult: Decodable, Equatable, Sendable {
port: Int? = nil,
message: String,
errorCode: String? = nil,
setupRequired: String? = nil,
catalogUpdated: Bool? = nil,
codexRestartRequired: Bool? = nil,
staleWorkerCount: Int? = nil,
Expand All @@ -63,6 +67,7 @@ public struct LifecycleCommandResult: Decodable, Equatable, Sendable {
self.port = port
self.message = message
self.errorCode = errorCode
self.setupRequired = setupRequired
self.catalogUpdated = catalogUpdated
self.codexRestartRequired = codexRestartRequired
self.staleWorkerCount = staleWorkerCount
Expand All @@ -72,6 +77,7 @@ public struct LifecycleCommandResult: Decodable, Equatable, Sendable {

private enum CodingKeys: String, CodingKey {
case schemaVersion, action, ok, state, changed, pid, port, message, errorCode
case setupRequired
case catalogUpdated, codexRestartRequired, staleWorkerCount
case stoppedWorkerCount, survivingWorkerCount
}
Expand All @@ -89,6 +95,7 @@ public struct LifecycleCommandResult: Decodable, Equatable, Sendable {
port = try values.decode(Int?.self, forKey: .port)
message = try values.decode(String.self, forKey: .message)
errorCode = try values.decodeIfPresent(String.self, forKey: .errorCode)
setupRequired = try values.decodeIfPresent(String.self, forKey: .setupRequired)

if action == .applyCodexCatalog {
catalogUpdated = try values.decode(Bool.self, forKey: .catalogUpdated)
Expand Down
33 changes: 33 additions & 0 deletions app/Sources/MenuBarCoreTests/ActionSuite.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ enum ActionSuite {
changed: true, pid: 41, port: 10100,
message: "Restart ChatGPT to load the routed models.",
errorCode: "CODEX_RESTART_REQUIRED",
setupRequired: "codex-first-run",
codexRestartRequired: true,
staleWorkerCount: 2
),
Expand All @@ -188,6 +189,38 @@ enum ActionSuite {
)
}

t.test("lifecycle: known setup requirement becomes a typed outcome") {
let lifecycle = FakeLifecycleRunner(results: [
LifecycleCommandResult(
action: .start, ok: true, state: .running,
changed: true, pid: 41, port: 10100,
message: "Complete Codex setup to continue.",
setupRequired: "codex-first-run"
),
])
let coordinator = ActionCoordinator(lifecycle: lifecycle)
t.equal(
sync { await coordinator.start() },
.setupRequired(.codexFirstRun)
)
}

t.test("lifecycle: unknown setup requirement remains forward-compatible") {
let lifecycle = FakeLifecycleRunner(results: [
LifecycleCommandResult(
action: .start, ok: true, state: .running,
changed: false, pid: 41, port: 10100,
message: "Complete a future setup step.",
setupRequired: "future-setup"
),
])
let coordinator = ActionCoordinator(lifecycle: lifecycle)
t.equal(
sync { await coordinator.start() },
.setupRequired(.unknown("future-setup"))
)
}

t.test("lifecycle: restart-like error codes without the additive flag remain failures") {
let lifecycle = FakeLifecycleRunner(results: [
LifecycleCommandResult(
Expand Down
Loading
Loading