Skip to content

Commit 77f0f0a

Browse files
author
pavelhov
authored
Merge pull request #16 from pavelhov/codex/macos-first-run-bootstrap
Codex/macos first run bootstrap
2 parents 3e4ad27 + bfb372e commit 77f0f0a

37 files changed

Lines changed: 4110 additions & 135 deletions
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Task 2: macOS first-run policy
2+
3+
Status: complete
4+
5+
Commit: `4f1bc4c6b0b04e2f7cc542440c15de924a5287f1`
6+
7+
## Result
8+
9+
Added the pure `prepareMacOSAppStart` policy layer. It consumes the canonical
10+
default config and Task 1's lossless `initializeConfigIfMissing` result,
11+
classifies Codex config presence using `lstatSync` (only `ENOENT` is missing),
12+
persists `clientIntegrations.codex=false` only on a newly created app config
13+
when Codex is missing, and returns the explicit first-run setup requirement.
14+
Existing app config and all Codex config files remain untouched. Typed
15+
initialization refusals map to secret-free `CONFIGURATION_REQUIRED` errors.
16+
17+
## Validation
18+
19+
- `bun test tests/macos-first-run.test.ts` — 4 pass, 0 fail.
20+
- `bun run typecheck` — pass.
21+
- `bun run test:parallel` — reached all 596 test-file completions with no
22+
failures observed, but was interrupted after the runner continued draining
23+
unusually slow unrelated macOS/build and discovery tests; it did not produce
24+
a final aggregate exit result.
25+
26+
## Files changed
27+
28+
- `src/cli/macos-first-run.ts`
29+
- `tests/macos-first-run.test.ts`
30+
31+
## Concerns
32+
33+
No known policy concerns. Full-suite aggregate completion remains unconfirmed
34+
because unrelated long-running tests prevented the parallel runner from
35+
exiting in a reasonable time; focused policy tests and strict typecheck are
36+
clean.
37+
38+
## Fix round 1
39+
40+
Addressed review feedback by replacing injected-only branch coverage with four
41+
isolated subprocess probes. Each probe sets temporary `CODEXCOMMANDER_HOME` and
42+
`CODEX_HOME` before importing the production policy, blocks `fetch`, runs the
43+
real initializer/classifier, and asserts exact app config bytes, app metadata
44+
entries, Codex config bytes, and Codex directory entries for its branch.
45+
46+
Validation:
47+
48+
- `bun test tests/macos-first-run.test.ts` — 4 pass, 0 fail (22 assertions).
49+
- `bun run typecheck` — pass.
50+
- `git diff --check` — pass.
51+
52+
## Fix round 2
53+
54+
Added a production-path `ENOTDIR` probe: the child imports the policy while
55+
`CODEX_HOME` is a directory, replaces that path with a sentinel-bearing file,
56+
then runs startup so `lstatSync(CODEX_CONFIG_PATH)` receives `ENOTDIR`. The
57+
probe verifies normal routing, no setup requirement, canonical default app
58+
bytes, and unchanged Codex sentinel bytes.
59+
60+
The child harness now requires empty stderr and parses the complete stdout as
61+
one JSON document (no last-line fallback). Fixture bytes are inspected by the
62+
parent after the child exits, so the refusal fixture's secret sentinel can be
63+
asserted absent from both child streams.
64+
65+
Validation:
66+
67+
- `bun test tests/macos-first-run.test.ts` — 5 pass, 0 fail (33 assertions).
68+
- `bun run typecheck` — pass.
69+
- `git diff --check` — pass.

‎README.md‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Two commands, and every one of them runs any LLM you point it at.</p>
55
```bash
66
bun install
77
bun run build:gui
8+
bun run src/cli/index.ts init
89
bun run src/cli/index.ts start
910
```
1011

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

@@ -71,13 +73,29 @@ This preview requires macOS 13 or later. It is ad-hoc signed and not notarized y
7173
3. If macOS still blocks it, open **System Settings → Privacy & Security** and choose **Open
7274
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).
7375

76+
On a fresh Mac, a direct app launch creates CodexCommander's secret-free ChatGPT passthrough default
77+
automatically. If Codex has not created `~/.codex/config.toml` yet, the proxy and dashboard still start
78+
while Codex remains native; open Codex once, then choose **Route Codex Through Proxy** from the menu.
79+
The app never creates Codex configuration automatically. Existing valid, invalid, unreadable, or
80+
unsafe CodexCommander configuration is preserved and is never overwritten; repair an invalid or
81+
inaccessible configuration before trying again. Providers, API keys, and OAuth accounts are not copied
82+
from another Mac. Public distribution uses the universal release archive above, not the thin
83+
development `.app` produced by a source checkout.
84+
85+
Applications and `~/Applications` support **Launch at Login**. A copy launched from Desktop or
86+
Downloads is allowed to run for the current session, but the app shows neutral guidance to move it to
87+
Applications for login startup. Quit CodexCommander before moving a running app, then reopen it from
88+
its new location; the app never moves itself. If macOS launches the app through App Translocation,
89+
**Start** is blocked before the proxy launches: move the app and reopen it. These location rules do not
90+
change the ad-hoc Gatekeeper steps above.
91+
7492
<p align="center">
7593
<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">
7694
</p>
7795

7896
| Action | What it does |
7997
|---|---|
80-
| **Start Proxy** | Starts or attaches to the proxy, then routes Codex through it. |
98+
| **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. |
8199
| **Restore Native Codex** | Switches only Codex back to OpenAI; the proxy keeps running. |
82100
| **Route Codex Through Proxy** | Switches only the Codex route to the already-running proxy. |
83101
| **Stop Proxy… / Restart Proxy…** | Restores native routing before stopping; Restart then starts and routes back. |
@@ -120,10 +138,13 @@ Codex tasks, history, or authentication, and it does not require a repair comman
120138
database. Generated catalogs and caches may remain on disk, but native Codex no longer references
121139
them.
122140

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

142163
```bash
143-
bun run src/cli/index.ts start # or use `service`
144164
bun run src/cli/index.ts init # interactive setup: writes config; can route through a proven live proxy
165+
bun run src/cli/index.ts start # or use `service`, after init
145166
```
146167

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

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

155182
| OS | Status | Service manager |

‎app/Sources/MenuBarCore/ActionCoordinator.swift‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,19 @@ public enum RestartOutcome: Equatable, Sendable {
55
case failed(String)
66
}
77

8+
public enum ProxySetupRequirement: Equatable, Sendable {
9+
case codexFirstRun
10+
case unknown(String)
11+
12+
init(rawValue: String) {
13+
self = rawValue == "codex-first-run" ? .codexFirstRun : .unknown(rawValue)
14+
}
15+
}
16+
817
public enum ProxyControlOutcome: Equatable, Sendable {
918
case running
1019
case stopped
20+
case setupRequired(ProxySetupRequirement)
1121
/// The proxy is healthy, but long-lived Codex workers still hold an older roster.
1222
case catalogUpdateReady(staleWorkerCount: Int?)
1323
case failed(String)
@@ -98,6 +108,11 @@ public actor ActionCoordinator {
98108
guard result.ok, result.state == expected else {
99109
return .failed(result.message)
100110
}
111+
if expected == .running,
112+
let rawSetup = result.setupRequired,
113+
!rawSetup.isEmpty {
114+
return .setupRequired(ProxySetupRequirement(rawValue: rawSetup))
115+
}
101116
return expected == .running ? .running : .stopped
102117
} catch let error as LifecycleHelperError {
103118
return .failed(error.userMessage)

‎app/Sources/MenuBarCore/LaunchAtLogin.swift‎

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,26 +9,39 @@ public enum LaunchAtLoginStatus: String, Equatable, Sendable {
99
case unavailable
1010
}
1111

12+
public enum LaunchAtLoginRemediation: Equatable, Sendable {
13+
case openSystemSettings
14+
case openApplications
15+
}
16+
1217
public struct LaunchAtLoginPresentation: Equatable, Sendable {
1318
public let status: LaunchAtLoginStatus
1419
public let desiredEnabled: Bool
1520
public let isToggleEnabled: Bool
1621
public let errorMessage: String?
22+
public let relocationRequired: Bool
1723

1824
public init(
1925
status: LaunchAtLoginStatus,
2026
desiredEnabled: Bool,
2127
isToggleEnabled: Bool,
22-
errorMessage: String? = nil
28+
errorMessage: String? = nil,
29+
relocationRequired: Bool = false
2330
) {
2431
self.status = status
2532
self.desiredEnabled = desiredEnabled
2633
self.isToggleEnabled = isToggleEnabled
2734
self.errorMessage = errorMessage
35+
self.relocationRequired = relocationRequired
2836
}
2937

3038
public var isOn: Bool { status == .enabled }
3139
public var needsApproval: Bool { status == .requiresApproval }
40+
public var remediation: LaunchAtLoginRemediation? {
41+
if relocationRequired { return .openApplications }
42+
if needsApproval { return .openSystemSettings }
43+
return nil
44+
}
3245
}
3346

3447
public enum DesktopStartupMode: String, Equatable, Sendable {
@@ -161,7 +174,7 @@ public final class LaunchAtLoginController {
161174
status: .unavailable,
162175
desiredEnabled: preferences.desiredEnabled ?? true,
163176
isToggleEnabled: false,
164-
errorMessage: "Move CodexCommander to Applications or use its repository build."
177+
relocationRequired: true
165178
)
166179
}
167180
if preferences.desiredEnabled == nil {
@@ -211,7 +224,7 @@ public final class LaunchAtLoginController {
211224
status: .unavailable,
212225
desiredEnabled: preferences.desiredEnabled ?? false,
213226
isToggleEnabled: false,
214-
errorMessage: "Move CodexCommander to Applications or use its repository build."
227+
relocationRequired: true
215228
)
216229
}
217230
let previousDesiredEnabled = preferences.desiredEnabled
@@ -259,7 +272,7 @@ public final class LaunchAtLoginController {
259272
status: .unavailable,
260273
desiredEnabled: preferences.desiredEnabled ?? true,
261274
isToggleEnabled: false,
262-
errorMessage: "Move CodexCommander to Applications or use its repository build."
275+
relocationRequired: true
263276
)
264277
}
265278
if service.status == .enabled, preferences.desiredEnabled == false {
@@ -315,24 +328,42 @@ public enum ExecutableFingerprint {
315328
}
316329
}
317330

331+
public enum AppBundleLocation: Equatable, Sendable {
332+
case stable
333+
case relocatable
334+
case translocated
335+
}
336+
318337
public enum LaunchAtLoginEligibility {
319-
public static func isStableBundle(
338+
public static func classify(
320339
_ bundleURL: URL,
321340
home: URL = FileManager.default.homeDirectoryForCurrentUser
322-
) -> Bool {
341+
) -> AppBundleLocation {
323342
let bundle = bundleURL.resolvingSymlinksInPath()
324343
let path = bundle.path
344+
if path.hasPrefix("/private/var/folders/"),
345+
path.contains("/AppTranslocation/") {
346+
return .translocated
347+
}
325348
guard bundle.pathExtension == "app",
326-
bundle.lastPathComponent == "CodexCommander.app",
327-
!path.contains("/AppTranslocation/")
328-
else { return false }
349+
bundle.lastPathComponent == "CodexCommander.app"
350+
else { return .relocatable }
329351

330-
if path.hasPrefix("/Applications/") { return true }
331-
let userApplications = home.appendingPathComponent("Applications", isDirectory: true).path
332-
if path.hasPrefix("\(userApplications)/") { return true }
352+
if path.hasPrefix("/Applications/") { return .stable }
353+
let userApplications = home.resolvingSymlinksInPath()
354+
.appendingPathComponent("Applications", isDirectory: true).path
355+
if path.hasPrefix("\(userApplications)/") { return .stable }
333356

334-
return bundle.deletingLastPathComponent().lastPathComponent == "macos"
357+
let sourceBuild = bundle.deletingLastPathComponent().lastPathComponent == "macos"
335358
&& bundle.deletingLastPathComponent()
336359
.deletingLastPathComponent().lastPathComponent == "dist"
360+
return sourceBuild ? .stable : .relocatable
361+
}
362+
363+
public static func isStableBundle(
364+
_ bundleURL: URL,
365+
home: URL = FileManager.default.homeDirectoryForCurrentUser
366+
) -> Bool {
367+
classify(bundleURL, home: home) == .stable
337368
}
338369
}

‎app/Sources/MenuBarCore/LifecycleHelper.swift‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ public struct LifecycleCommandResult: Decodable, Equatable, Sendable {
3030
public let port: Int?
3131
public let message: String
3232
public let errorCode: String?
33+
/// Optional setup guidance from a successful proxy start. Retain the raw string
34+
/// so newer helper values remain forward-compatible with older app builds.
35+
public let setupRequired: String?
3336
/// These fields are present only for the `applyCodexCatalog` action. The app receives
3437
/// counts rather than process identifiers.
3538
public let catalogUpdated: Bool?
@@ -48,6 +51,7 @@ public struct LifecycleCommandResult: Decodable, Equatable, Sendable {
4851
port: Int? = nil,
4952
message: String,
5053
errorCode: String? = nil,
54+
setupRequired: String? = nil,
5155
catalogUpdated: Bool? = nil,
5256
codexRestartRequired: Bool? = nil,
5357
staleWorkerCount: Int? = nil,
@@ -63,6 +67,7 @@ public struct LifecycleCommandResult: Decodable, Equatable, Sendable {
6367
self.port = port
6468
self.message = message
6569
self.errorCode = errorCode
70+
self.setupRequired = setupRequired
6671
self.catalogUpdated = catalogUpdated
6772
self.codexRestartRequired = codexRestartRequired
6873
self.staleWorkerCount = staleWorkerCount
@@ -72,6 +77,7 @@ public struct LifecycleCommandResult: Decodable, Equatable, Sendable {
7277

7378
private enum CodingKeys: String, CodingKey {
7479
case schemaVersion, action, ok, state, changed, pid, port, message, errorCode
80+
case setupRequired
7581
case catalogUpdated, codexRestartRequired, staleWorkerCount
7682
case stoppedWorkerCount, survivingWorkerCount
7783
}
@@ -89,6 +95,7 @@ public struct LifecycleCommandResult: Decodable, Equatable, Sendable {
8995
port = try values.decode(Int?.self, forKey: .port)
9096
message = try values.decode(String.self, forKey: .message)
9197
errorCode = try values.decodeIfPresent(String.self, forKey: .errorCode)
98+
setupRequired = try values.decodeIfPresent(String.self, forKey: .setupRequired)
9299

93100
if action == .applyCodexCatalog {
94101
catalogUpdated = try values.decode(Bool.self, forKey: .catalogUpdated)

‎app/Sources/MenuBarCoreTests/ActionSuite.swift‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ enum ActionSuite {
177177
changed: true, pid: 41, port: 10100,
178178
message: "Restart ChatGPT to load the routed models.",
179179
errorCode: "CODEX_RESTART_REQUIRED",
180+
setupRequired: "codex-first-run",
180181
codexRestartRequired: true,
181182
staleWorkerCount: 2
182183
),
@@ -188,6 +189,38 @@ enum ActionSuite {
188189
)
189190
}
190191

192+
t.test("lifecycle: known setup requirement becomes a typed outcome") {
193+
let lifecycle = FakeLifecycleRunner(results: [
194+
LifecycleCommandResult(
195+
action: .start, ok: true, state: .running,
196+
changed: true, pid: 41, port: 10100,
197+
message: "Complete Codex setup to continue.",
198+
setupRequired: "codex-first-run"
199+
),
200+
])
201+
let coordinator = ActionCoordinator(lifecycle: lifecycle)
202+
t.equal(
203+
sync { await coordinator.start() },
204+
.setupRequired(.codexFirstRun)
205+
)
206+
}
207+
208+
t.test("lifecycle: unknown setup requirement remains forward-compatible") {
209+
let lifecycle = FakeLifecycleRunner(results: [
210+
LifecycleCommandResult(
211+
action: .start, ok: true, state: .running,
212+
changed: false, pid: 41, port: 10100,
213+
message: "Complete a future setup step.",
214+
setupRequired: "future-setup"
215+
),
216+
])
217+
let coordinator = ActionCoordinator(lifecycle: lifecycle)
218+
t.equal(
219+
sync { await coordinator.start() },
220+
.setupRequired(.unknown("future-setup"))
221+
)
222+
}
223+
191224
t.test("lifecycle: restart-like error codes without the additive flag remain failures") {
192225
let lifecycle = FakeLifecycleRunner(results: [
193226
LifecycleCommandResult(

0 commit comments

Comments
 (0)