diff --git a/CHANGELOG.md b/CHANGELOG.md index 96b5955904..392292c5de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/CodexBar/CodexAppServerDaemon.swift b/Sources/CodexBar/CodexAppServerDaemon.swift index 422e50fdef..e7c69ec685 100644 --- a/Sources/CodexBar/CodexAppServerDaemon.swift +++ b/Sources/CodexBar/CodexAppServerDaemon.swift @@ -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" @@ -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) diff --git a/Tests/CodexBarTests/CodexAppServerDaemonTests.swift b/Tests/CodexBarTests/CodexAppServerDaemonTests.swift index a1ad6175a6..25d1365bbf 100644 --- a/Tests/CodexBarTests/CodexAppServerDaemonTests.swift +++ b/Tests/CodexBarTests/CodexAppServerDaemonTests.swift @@ -15,8 +15,10 @@ 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( @@ -24,13 +26,14 @@ struct CodexAppServerDaemonTests { 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) @@ -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) @@ -111,6 +120,50 @@ 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) @@ -118,8 +171,22 @@ struct CodexAppServerDaemonTests { .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)\"}" } } diff --git a/docs/codex.md b/docs/codex.md index 0d9289af12..43ca753b88 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -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.