From 89775a6123716128d344d54d197c93b5fec5c3f9 Mon Sep 17 00:00:00 2001 From: massdo <46751454+massdo@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:01:42 +0800 Subject: [PATCH 1/2] fix(codex): resolve daemon socket symlinks before restart --- CHANGELOG.md | 1 + Sources/CodexBar/CodexAppServerDaemon.swift | 5 ++- .../CodexAppServerDaemonTests.swift | 37 +++++++++++++++---- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96b5955904..38d51b6cff 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). - 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..5ea497c31a 100644 --- a/Sources/CodexBar/CodexAppServerDaemon.swift +++ b/Sources/CodexBar/CodexAppServerDaemon.swift @@ -31,9 +31,12 @@ struct CodexAppServerDaemon { let output = try await self.run("version", env) let version = try JSONDecoder().decode(Version.self, from: Data(output.utf8)) // The CLI validates its PID/start-time record and probes this home's control socket. + // Codex can publish the control socket as a symlink to its protected socket directory. + let expectedSocket = home.appendingPathComponent("app-server-control/app-server-control.sock") + .resolvingSymlinksInPath().standardizedFileURL guard version.status == "running", version.backend == "pid", URL(fileURLWithPath: version.socketPath).resolvingSymlinksInPath().standardizedFileURL == - home.appendingPathComponent("app-server-control/app-server-control.sock") + expectedSocket 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..232fb47cea 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) @@ -118,8 +127,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)\"}" } } From a13046ba9be23a1e9eec60f73f60d99f17afbf47 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 26 Sep 2026 03:36:25 -0700 Subject: [PATCH 2/2] fix(codex): cover symlinked homes in daemon restart Preserve symmetric socket resolution with an inline PID guard, keeping the production change neutral in line count. Cover symlinked homes and failed probes through dangling socket aliases, and document the home/socket boundary. Refs #3990, #4018. Co-authored-by: massdo <46751454+massdo@users.noreply.github.com> --- CHANGELOG.md | 2 +- Sources/CodexBar/CodexAppServerDaemon.swift | 11 ++--- .../CodexAppServerDaemonTests.swift | 44 +++++++++++++++++++ docs/codex.md | 3 ++ 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38d51b6cff..392292c5de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Codex: resolve control socket symlinks when checking the running daemon, so System Account switches do not silently skip its restart (#3990). +- 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 5ea497c31a..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" @@ -31,12 +30,10 @@ struct CodexAppServerDaemon { let output = try await self.run("version", env) let version = try JSONDecoder().decode(Version.self, from: Data(output.utf8)) // The CLI validates its PID/start-time record and probes this home's control socket. - // Codex can publish the control socket as a symlink to its protected socket directory. - let expectedSocket = home.appendingPathComponent("app-server-control/app-server-control.sock") - .resolvingSymlinksInPath().standardizedFileURL guard version.status == "running", version.backend == "pid", URL(fileURLWithPath: version.socketPath).resolvingSymlinksInPath().standardizedFileURL == - expectedSocket + 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 232fb47cea..25d1365bbf 100644 --- a/Tests/CodexBarTests/CodexAppServerDaemonTests.swift +++ b/Tests/CodexBarTests/CodexAppServerDaemonTests.swift @@ -120,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) 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.