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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Fixed

- Codex: resolve control socket symlinks when checking the running daemon, so System Account switches do not silently skip its restart (#3990, #4018). Thanks @massdo!
- Nous Portal: include Nous-billed OpenCodex ledger activity in Usage & Spend, preserving estimated or unpriced costs separately from Portal credits. Fixes #4008. Thanks @Reztahla!
- Muse Code: optionally show the explicitly selected dev.meta.ai browser team’s quota when the login omits quotas, with cookies Off by default and team choices in settings (#4011). Fixes #4002. Thanks @enieuwy!
### Added
Expand Down
6 changes: 3 additions & 3 deletions Sources/CodexBar/CodexAppServerDaemon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,13 @@ struct CodexAppServerDaemon {
func restartIfRunning(homeURL: URL, environment: [String: String]) async -> String? {
let home = homeURL.resolvingSymlinksInPath().standardizedFileURL
// Codex uses separate PID records for legacy and daemon-owned installations.
let running = ["daemon.pid", "app-server.pid"].contains { name in
guard ["daemon.pid", "app-server.pid"].contains(where: { name in
let url = home.appendingPathComponent("app-server-daemon/\(name)")
guard let data = try? Data(contentsOf: url),
let record = try? JSONDecoder().decode(PIDRecord.self, from: data)
else { return false }
return self.isAppServerProcess(record.pid)
}
guard running else { return nil }
}) else { return nil }
let env = CodexHomeScope.scopedEnvironment(base: environment, codexHome: home.path)
let log = CodexBarLog.logger("codex-account-promotion")
var phase = "detect"
Expand All @@ -34,6 +33,7 @@ struct CodexAppServerDaemon {
guard version.status == "running", version.backend == "pid",
URL(fileURLWithPath: version.socketPath).resolvingSymlinksInPath().standardizedFileURL ==
home.appendingPathComponent("app-server-control/app-server-control.sock")
.resolvingSymlinksInPath().standardizedFileURL
else { return nil }
phase = "restart"
_ = try await self.run("restart", env)
Expand Down
81 changes: 74 additions & 7 deletions Tests/CodexBarTests/CodexAppServerDaemonTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,25 @@ struct CodexAppServerDaemonTests {
#expect(!CodexHomeScope.isAppServer(arguments: []))
}

@Test(arguments: ["daemon.pid", "app-server.pid"])
func `promotion restarts the live home daemon once after publishing auth`(_ filename: String) async throws {
@Test(arguments: ["daemon.pid", "app-server.pid"], ["plain", "symlink", "resolved-symlink"])
func `promotion restarts the live home daemon once after publishing auth`(
_ filename: String, _ socketPath: String) async throws
{
let container = try CodexAccountPromotionTestContainer(suiteName: "daemon-promotion")
defer { container.tearDown() }
let target = try container.createManagedAccount(
persistedEmail: "managed@example.com", authAccountID: "acct-managed")
try container.persistAccounts([target])
_ = try container.writeLiveOAuthAuthFile(email: "live@example.com", accountID: "acct-live")
try Self.writePID(home: container.liveHomeURL, filename: filename)
if socketPath != "plain" { try Self.writeSocketSymlink(home: container.liveHomeURL) }
var calls: [String] = []
let daemon = CodexAppServerDaemon(isAppServerProcess: { $0 == 123 }, run: { command, env in
calls.append(command)
#expect(env["CODEX_HOME"] == container.liveHomeURL.resolvingSymlinksInPath().path)
let identity = try container.identityReader.loadAccountIdentity(homePath: container.liveHomeURL.path)
#expect(identity.email == "managed@example.com")
return Self.version(home: container.liveHomeURL)
return Self.version(home: container.liveHomeURL, resolveSocket: socketPath == "resolved-symlink")
})
let result = try await container.makeService(daemon: daemon).promoteManagedAccount(id: target.id)
#expect(result.outcome == .promoted)
Expand Down Expand Up @@ -90,11 +93,17 @@ struct CodexAppServerDaemonTests {
#expect(calls == (failure == "version" ? ["version"] : ["version", "restart"]))
}

@Test(arguments: ["other-home", "unmanaged", "stopped"])
func `only a managed daemon answering for the promoted home can restart`(_ mismatch: String) async throws {
@Test(arguments: ["other-home", "unmanaged", "stopped"], [false, true])
func `only a managed daemon answering for the promoted home can restart`(
_ mismatch: String, _ symlinkedSocket: Bool) async throws
{
let container = try CodexAccountPromotionTestContainer(suiteName: "daemon-home-match")
defer { container.tearDown() }
try Self.writePID(home: container.liveHomeURL)
if symlinkedSocket {
try Self.writeSocketSymlink(home: container.liveHomeURL)
try Self.writeSocketSymlink(home: container.managedHomesURL)
}
var calls: [String] = []
let daemon = CodexAppServerDaemon(isAppServerProcess: { _ in true }, run: { command, env in
calls.append(command)
Expand All @@ -111,15 +120,73 @@ struct CodexAppServerDaemonTests {
#expect(calls == ["version"])
}

@Test(arguments: [false, true])
func `symlinked home and outside home socket retain the destination scope`(_ resolveSocket: Bool) async throws {
let container = try CodexAccountPromotionTestContainer(suiteName: "daemon-home-symlink")
defer { container.tearDown() }
let alias = container.rootURL.appendingPathComponent("home-alias", isDirectory: true)
try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: container.liveHomeURL)
try Self.writePID(home: alias)
try Self.writeSocketSymlink(home: container.liveHomeURL)
var calls: [String] = []
let daemon = CodexAppServerDaemon(isAppServerProcess: { $0 == 123 }, run: { command, env in
calls.append(command)
#expect(env["CODEX_HOME"] == container.liveHomeURL.resolvingSymlinksInPath().path)
#expect(env["HOME"] == "/synthetic-user")
return Self.version(home: alias, resolveSocket: resolveSocket)
})
let note = await daemon.restartIfRunning(
homeURL: alias, environment: ["HOME": "/synthetic-user", "CODEX_HOME": "/wrong-home"])
#expect(note == nil)
#expect(calls == ["version", "restart"])
}

@Test(arguments: [false, true])
func `dangling socket link cannot override a failed CLI probe`(_ probeThrows: Bool) async throws {
let container = try CodexAccountPromotionTestContainer(suiteName: "daemon-dangling-socket")
defer { container.tearDown() }
try Self.writePID(home: container.liveHomeURL)
try Self.writeSocketSymlink(home: container.liveHomeURL)
let socket = container.liveHomeURL.appendingPathComponent("app-server-control/app-server-control.sock")
try FileManager.default.removeItem(at: socket.resolvingSymlinksInPath())
#expect(!FileManager.default.fileExists(atPath: socket.path))
#expect(try FileManager.default.destinationOfSymbolicLink(atPath: socket.path).hasSuffix("liveHome.sock"))
var calls: [String] = []
let daemon = CodexAppServerDaemon(isAppServerProcess: { _ in true }, run: { command, _ in
calls.append(command)
if probeThrows {
throw SubprocessRunnerError.nonZeroExit(code: 1, stderr: "synthetic socket unavailable")
}
return Self.version(home: container.liveHomeURL, status: "notRunning")
})
let note = await daemon.restartIfRunning(homeURL: container.liveHomeURL, environment: [:])
#expect((note != nil) == probeThrows)
#expect(calls == ["version"])
}

private static func writePID(home: URL, filename: String = "daemon.pid") throws {
let directory = home.appendingPathComponent("app-server-daemon")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try Data(#"{"pid":123,"processStartTime":"synthetic"}"#.utf8)
.write(to: directory.appendingPathComponent(filename))
}

private static func version(home: URL, backend: String = "pid", status: String = "running") -> String {
let socket = home.resolvingSymlinksInPath().appendingPathComponent("app-server-control/app-server-control.sock")
private static func writeSocketSymlink(home: URL) throws {
let socket = home.appendingPathComponent("app-server-control/app-server-control.sock")
let target = home.deletingLastPathComponent().appendingPathComponent("\(home.lastPathComponent).sock")
// The CLI probe is injected, but path resolution must follow a real filesystem symlink.
try Data().write(to: target)
try FileManager.default.createDirectory(
at: socket.deletingLastPathComponent(),
withIntermediateDirectories: true)
try FileManager.default.createSymbolicLink(at: socket, withDestinationURL: target)
}

private static func version(
home: URL, backend: String = "pid", status: String = "running", resolveSocket: Bool = false) -> String
{
var socket = home.resolvingSymlinksInPath().appendingPathComponent("app-server-control/app-server-control.sock")
if resolveSocket { socket = socket.resolvingSymlinksInPath().standardizedFileURL }
return "{\"status\":\"\(status)\",\"backend\":\"\(backend)\",\"socketPath\":\"\(socket.path)\"}"
}
}
3 changes: 3 additions & 0 deletions docs/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ Usage source picker:
- After a successful System Account promotion, CodexBar restarts an already-running managed `codex app-server`
daemon for the destination Codex home so it reloads the selected account. It checks the daemon PID, process command,
and home-scoped control socket before running `codex app-server daemon restart` with that home's `CODEX_HOME`.
Both socket paths are resolved before comparison, including when the Codex home itself is a symlink. Codex's
control socket may point outside the home into its protected socket directory; it must match the destination
home's resolved socket. A dangling link does not bypass the CLI's running-daemon probe.
Homes without a running daemon are left alone. If the installed CLI cannot verify or restart it (including older
CLIs without daemon commands), the account remains switched and the menu/settings show a manual-restart note.
Restarting the background server can interrupt its active work; no login flow runs.
Expand Down
Loading