import Darwin import Foundation import Testing @testable import OpenClaw @Suite(.serialized) struct MacNodeCodexThreadCatalogTests { private struct FakeCodex { var directory: URL var executable: URL var capture: URL } private func makeFakeCodex(_ script: String) throws -> FakeCodex { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("openclaw-fake-codex-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let executable = directory.appendingPathComponent("codex") try script.write(to: executable, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) return FakeCodex( directory: directory, executable: executable, capture: URL(fileURLWithPath: executable.path + ".requests")) } private func listResponseJSON(names: [String], nextCursor: String?) throws -> String { let encodedNextCursor: Any = if let nextCursor { nextCursor } else { NSNull() } let threads: [[String: Any]] = names.enumerated().map { index, name in [ "id": "thread-\(name)-\(index)", "name": name, "status": ["type": "notLoaded"], ] } let data = try JSONSerialization.data(withJSONObject: [ "id": 2, "result": [ "data": threads, "nextCursor": encodedNextCursor, "backwardsCursor": NSNull(), ], ]) return try #require(String(data: data, encoding: .utf8)) } @Test func `normalizes App Server metadata and drops sensitive thread fields`() throws { let raw: [String: Any] = [ "data": [[ "id": "thread-1", "sessionId": "session-1", "name": "Current task", "preview": "Build the catalog", "cwd": "/Users/example/project", "status": [ "type": "active", "activeFlags": ["waitingOnUserInput"], ], "createdAt": 100, "updatedAt": 200, "recencyAt": 190, "source": ["custom": "chatgpt"], "modelProvider": "openai", "cliVersion": "0.143.0", "gitInfo": [ "branch": "codex/feature", "sha": "secret-sha", "originUrl": "git@example.test:private/repo.git", ], "path": "/Users/example/.codex/sessions/private.jsonl", "turns": [["items": [["text": "private transcript"]]]], ]], "nextCursor": "next-page", "backwardsCursor": "previous-page", ] let data = try JSONSerialization.data(withJSONObject: raw) let json = try MacNodeCodexThreadCatalog.normalize(listResultData: data) let decoded = try #require( JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) let sessions = try #require(decoded["sessions"] as? [[String: Any]]) let session = try #require(sessions.first) #expect(decoded["codexHome"] == nil) #expect(decoded["nextCursor"] as? String == "next-page") #expect(decoded["backwardsCursor"] as? String == "previous-page") #expect(session["threadId"] as? String == "thread-1") #expect(session["status"] as? String == "active") #expect(session["source"] as? String == "custom:chatgpt") #expect(session["gitBranch"] as? String == "codex/feature") #expect(session["archived"] as? Bool == false) #expect(session["preview"] == nil) #expect(session["path"] == nil) #expect(session["turns"] == nil) #expect(session["sha"] == nil) #expect(session["originUrl"] == nil) } @Test func `bounds normalized metadata to the Gateway catalog contract`() throws { let longName = String(repeating: "😀", count: 251) let longMetadata = String(repeating: "m", count: 501) let longId = String(repeating: "i", count: 257) let raw: [String: Any] = [ "data": [ ["id": longId, "name": "dropped"], [ "id": "thread-1", "sessionId": longId, "name": longName, "cwd": String(repeating: "c", count: 4097), "status": [ "type": String(repeating: "s", count: 65), "activeFlags": [String(repeating: "f", count: 129)] + (0..<17).map { "flag-\($0)" }, ], "source": ["custom": longMetadata], "modelProvider": longMetadata, "cliVersion": longMetadata, "gitInfo": ["branch": longMetadata], ], ], "nextCursor": String(repeating: "n", count: 4097), "backwardsCursor": "opaque-backwards", ] let data = try JSONSerialization.data(withJSONObject: raw) let json = try MacNodeCodexThreadCatalog.normalize(listResultData: data) let decoded = try #require( JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) let sessions = try #require(decoded["sessions"] as? [[String: Any]]) let session = try #require(sessions.first) #expect(sessions.count == 1) #expect((session["name"] as? String)?.utf16.count == 500) #expect(!(session["name"] as? String ?? "").contains("�")) #expect(session["sessionId"] == nil) #expect(session["cwd"] == nil) #expect(session["status"] as? String == "notLoaded") #expect((session["activeFlags"] as? [String])?.count == 16) #expect((session["source"] as? String)?.utf16.count == 500) #expect((session["modelProvider"] as? String)?.utf16.count == 500) #expect((session["cliVersion"] as? String)?.utf16.count == 500) #expect((session["gitBranch"] as? String)?.utf16.count == 500) #expect(decoded["nextCursor"] == nil) #expect(decoded["backwardsCursor"] as? String == "opaque-backwards") } @Test func `resolves and runs the configured Codex App Server without a shell`() async throws { let clearEnvSentinel = "OPENCLAW_CODEX_CATALOG_CLEAR_ENV_SENTINEL" _ = setenv(clearEnvSentinel, "present", 1) defer { _ = unsetenv(clearEnvSentinel) } let fake = try makeFakeCodex(#""" #!/bin/sh [ "$1" = "custom-app-server" ] || exit 10 [ "$2" = "--stdio" ] || exit 11 [ -z "${OPENCLAW_CODEX_CATALOG_CLEAR_ENV_SENTINEL+x}" ] || exit 12 IFS= read -r initialize || exit 2 printf '%s\n' '{"id":1,"result":{}}' IFS= read -r initialized || exit 3 IFS= read -r list || exit 4 printf '%s\n' '{"id":2,"result":{"data":[],"nextCursor":null,"backwardsCursor":null}}' sleep 1 """#) defer { try? FileManager.default.removeItem(at: fake.directory) } let root: [String: Any] = [ "plugins": [ "entries": [ " codex ": [ "enabled": true, "config": [ "supervision": ["enabled": true], "appServer": [ "transport": "stdio", "homeScope": "user", "command": fake.executable.path, "args": #"custom-app-server "--stdio" workspace\ path "C:\\Codex" 'literal\slash' tail\"#, "clearEnv": [" \(clearEnvSentinel) ", ""], ], ], ], ], ], ] let resolved = try MacNodeCodexThreadCatalog.resolveInvocation( root: root, searchPaths: [], currentDirectoryURL: FileManager.default.temporaryDirectory) #expect(resolved.executable == fake.executable.standardizedFileURL.path) #expect(resolved.arguments == [ "custom-app-server", "--stdio", "workspace\\", "path", "C:\\\\Codex", "literal\\slash", "tail\\", ]) #expect(resolved.cwd == nil) #expect(resolved.clearEnv == [clearEnvSentinel]) let payload = try await MacNodeCodexThreadCatalog.list( paramsJSON: nil, executable: resolved.executable, arguments: resolved.arguments, cwd: resolved.cwd, clearEnv: resolved.clearEnv) let response = try #require( JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]) #expect((response["sessions"] as? [Any])?.isEmpty == true) } @Test func `uses official environment command and argument fallbacks`() throws { let fake = try makeFakeCodex("#!/bin/sh\nexit 0\n") let chatGPTApp = try makeFakeCodex("#!/bin/sh\nexit 0\n") defer { try? FileManager.default.removeItem(at: fake.directory) try? FileManager.default.removeItem(at: chatGPTApp.directory) } let missing = fake.directory.appendingPathComponent("missing").path let resolved = try MacNodeCodexThreadCatalog.resolveInvocation( root: [:], environment: [ "OPENCLAW_CODEX_APP_SERVER_BIN": " \(fake.executable.path) ", "OPENCLAW_CODEX_APP_SERVER_ARGS": #"custom-app-server "--listen" 'stdio://'"#, ], searchPaths: [], defaultMacOSChatGPTAppExecutable: chatGPTApp.executable.path, defaultUserMacOSChatGPTAppExecutable: missing, defaultMacOSAppExecutable: missing, defaultUserMacOSAppExecutable: missing, defaultMacOSBetaAppExecutable: missing, defaultUserMacOSBetaAppExecutable: missing) #expect(resolved.executable == fake.executable.standardizedFileURL.path) #expect(resolved.arguments == ["custom-app-server", "--listen", "stdio://"]) } @Test func `configured command stays ahead of an installed ChatGPT app`() throws { let configured = try makeFakeCodex("#!/bin/sh\nexit 0\n") let chatGPTApp = try makeFakeCodex("#!/bin/sh\nexit 0\n") defer { try? FileManager.default.removeItem(at: configured.directory) try? FileManager.default.removeItem(at: chatGPTApp.directory) } let root: [String: Any] = [ "plugins": [ "entries": [ "codex": [ "config": [ "appServer": ["command": configured.executable.path], ], ], ], ], ] let resolved = try MacNodeCodexThreadCatalog.resolveInvocation( root: root, searchPaths: [], defaultMacOSChatGPTAppExecutable: chatGPTApp.executable.path) #expect(resolved.executable == configured.executable.path) } @Test func `blank configured command falls back to the environment command`() throws { let fallback = try makeFakeCodex("#!/bin/sh\nexit 0\n") defer { try? FileManager.default.removeItem(at: fallback.directory) } let root: [String: Any] = [ "plugins": [ "entries": [ "codex": [ "config": [ "appServer": ["command": " \n "], ], ], ], ], ] let resolved = try MacNodeCodexThreadCatalog.resolveInvocation( root: root, environment: ["OPENCLAW_CODEX_APP_SERVER_BIN": fallback.executable.path], searchPaths: []) #expect(resolved.executable == fallback.executable.path) } @Test func `complete official plugin config remains eligible for the catalog`() throws { let app = try makeFakeCodex("#!/bin/sh\nexit 0\n") defer { try? FileManager.default.removeItem(at: app.directory) } let root: [String: Any] = [ "plugins": [ "entries": [ "codex": [ "enabled": true, "config": [ "codexDynamicToolsLoading": "direct", "codexDynamicToolsExclude": ["private_tool"], "discovery": ["enabled": true, "timeoutMs": 1000], "computerUse": [ "enabled": false, "autoInstall": false, "marketplaceDiscoveryTimeoutMs": 1000, "marketplaceSource": "source", "marketplacePath": "path", "marketplaceName": "marketplace", "pluginName": "plugin", "mcpServerName": "server", ], // The TypeScript parser treats this subtree independently. "codexPlugins": 42, "supervision": [ "enabled": true, "allowRawTranscripts": false, "allowWriteControls": false, "endpoints": [ [ "id": "local", "label": "Local", "transport": "stdio-proxy", "command": "codex", "args": ["app-server"], "cwd": "/tmp", ], [ "id": "remote", "label": "Remote", "transport": "websocket", "url": "wss://codex.example.test", "authTokenEnv": "CODEX_TOKEN", ], ], ], "appServer": [ "mode": "guardian", "transport": "stdio", "homeScope": "user", "command": app.executable.path, "args": ["app-server", "--listen", "stdio://"], "url": "", "authToken": [ "source": "env", "provider": "default", "id": "CODEX_TOKEN", ], "headers": [ "x-file": [ "source": "file", "provider": "mounted-json", "id": "/codex/token~1value", ], "x-exec": [ "source": "exec", "provider": "vault", "id": "codex/token#value", ], ], "clearEnv": ["OPENAI_API_KEY"], "remoteWorkspaceRoot": "/workspaces", "codeModeOnly": true, "requestTimeoutMs": 1000, "turnCompletionIdleTimeoutMs": 1000, "postToolRawAssistantCompletionIdleTimeoutMs": 1000, "approvalPolicy": "on-failure", "sandbox": "workspace-write", "approvalsReviewer": "user", "serviceTier": "priority", "networkProxy": [ "enabled": true, "profileName": "openclaw", "baseProfile": "workspace", "mode": "limited", "domains": ["example.test": "allow"], "unixSockets": ["/tmp/service.sock": "allow"], "proxyUrl": "http://127.0.0.1:8080", "socksUrl": "socks5://127.0.0.1:1080", "enableSocks5": true, "enableSocks5Udp": false, "allowUpstreamProxy": false, "allowLocalBinding": false, "dangerouslyAllowNonLoopbackProxy": false, "dangerouslyAllowAllUnixSockets": false, ], "defaultWorkspaceDir": "", "experimental": ["sandboxExecServer": false], ], ], ], ], ], ] #expect(MacNodeCodexThreadCatalog.shouldAdvertise(root: root)) let invocation = try MacNodeCodexThreadCatalog.resolveInvocation(root: root, searchPaths: []) #expect(invocation.executable == app.executable.path) #expect(invocation.clearEnv == ["OPENAI_API_KEY"]) } @Test func `malformed or unknown official plugin config fails closed`() throws { let app = try makeFakeCodex("#!/bin/sh\nexit 0\n") defer { try? FileManager.default.removeItem(at: app.directory) } var malformedConfigs: [Any] = [ "enabled", ["supervision": ["enabled": true], "unknown": true] as [String: Any], ["supervision": ["enabled": true], "codexDynamicToolsLoading": "lazy"] as [String: Any], ["supervision": ["enabled": true], "codexDynamicToolsExclude": ["tool", 42]] as [String: Any], ["supervision": ["enabled": true], "discovery": ["enabled": true, "unknown": true]] as [String: Any], ["supervision": ["enabled": true], "computerUse": ["timeoutMs": 1000]] as [String: Any], ["supervision": "enabled"] as [String: Any], ["supervision": ["enabled": true, "unknown": true]] as [String: Any], ["supervision": ["enabled": true, "allowRawTranscripts": 1]] as [String: Any], ["supervision": ["enabled": true, "endpoints": true]] as [String: Any], [ "supervision": [ "enabled": true, "endpoints": [["transport": "websocket", "url": "wss://example.test", "cwd": "/tmp"]], ], ] as [String: Any], ] let malformedAppServers: [Any] = [ "stdio", ["unknown": true] as [String: Any], ["mode": "automatic"] as [String: Any], ["command": 42] as [String: Any], ["args": 42] as [String: Any], ["args": ["app-server", 42]] as [String: Any], ["url": 42] as [String: Any], ["authToken": ["source": "env", "provider": "default", "id": "lowercase"]] as [String: Any], ["headers": ["authorization": ["source": "exec", "provider": "vault", "id": "../token"]]] as [String: Any], ["clearEnv": true] as [String: Any], ["clearEnv": ["OPENAI_API_KEY", false]] as [String: Any], ["remoteWorkspaceRoot": " "] as [String: Any], ["codeModeOnly": "true"] as [String: Any], ["requestTimeoutMs": 0] as [String: Any], ["turnCompletionIdleTimeoutMs": "1000"] as [String: Any], ["postToolRawAssistantCompletionIdleTimeoutMs": false] as [String: Any], ["approvalPolicy": "always"] as [String: Any], ["sandbox": "full"] as [String: Any], ["approvalsReviewer": "agent"] as [String: Any], ["serviceTier": false] as [String: Any], ["networkProxy": ["unknown": true]] as [String: Any], ["networkProxy": ["domains": ["example.test": "prompt"]]] as [String: Any], ["networkProxy": ["proxyUrl": " "]] as [String: Any], ["defaultWorkspaceDir": 42] as [String: Any], ["experimental": ["unknown": true]] as [String: Any], ["experimental": ["sandboxExecServer": "true"]] as [String: Any], ["transport": true] as [String: Any], ["homeScope": 42] as [String: Any], ] malformedConfigs.append(contentsOf: malformedAppServers.map { appServer in [ "supervision": ["enabled": true], "appServer": appServer, ] as [String: Any] }) for config in malformedConfigs { let root: [String: Any] = [ "plugins": [ "entries": [ "codex": [ "enabled": true, "config": config, ], ], ], ] #expect(!MacNodeCodexThreadCatalog.shouldAdvertise(root: root)) #expect(throws: MacNodeCodexThreadCatalog.CatalogError.invalidAppServerConfiguration) { try MacNodeCodexThreadCatalog.resolveInvocation( root: root, searchPaths: [], defaultMacOSAppExecutable: app.executable.path) } } } @Test func `list authorizes and resolves one config snapshot`() async throws { let fake = try makeFakeCodex(#""" #!/bin/sh IFS= read -r initialize || exit 2 printf '%s\n' '{"id":1,"result":{}}' IFS= read -r initialized || exit 3 IFS= read -r list || exit 4 printf '%s\n' '{"id":2,"result":{"data":[]}}' sleep 1 """#) defer { try? FileManager.default.removeItem(at: fake.directory) } let enabled: [String: Any] = [ "plugins": [ "entries": [ "codex": [ "enabled": true, "config": [ "supervision": ["enabled": true], "appServer": [ "transport": "stdio", "homeScope": "user", "command": fake.executable.path, "args": ["app-server", "--listen", "stdio://"], ], ], ], ], ], ] let revoked: [String: Any] = [ "plugins": [ "deny": ["codex"], "entries": [ "codex": [ "enabled": true, "config": ["supervision": ["enabled": true]], ], ], ], ] var loadCount = 0 let payload = try await MacNodeCodexThreadCatalog.list(paramsJSON: nil) { loadCount += 1 return loadCount == 1 ? enabled : revoked } let response = try #require( JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]) #expect(loadCount == 1) #expect((response["sessions"] as? [Any])?.isEmpty == true) } @Test func `does not advertise when the plugin allowlist excludes Codex`() { let root: [String: Any] = [ "plugins": [ "allow": ["discord"], "entries": [ "codex": [ "enabled": true, "config": ["supervision": ["enabled": true]], ], ], ], ] #expect(!MacNodeCodexThreadCatalog.shouldAdvertise(root: root)) } @Test func `rejects agent home scope instead of exposing the user Codex home`() throws { let app = try makeFakeCodex("#!/bin/sh\nexit 0\n") defer { try? FileManager.default.removeItem(at: app.directory) } let root: [String: Any] = [ "plugins": [ "entries": [ "codex": [ "enabled": true, "config": [ "supervision": ["enabled": true], "appServer": [ "transport": "stdio", "homeScope": "agent", ], ], ], ], ], ] #expect(!MacNodeCodexThreadCatalog.shouldAdvertise(root: root)) #expect(throws: MacNodeCodexThreadCatalog.CatalogError.unsupportedAppServerHomeScope) { try MacNodeCodexThreadCatalog.resolveInvocation( root: root, searchPaths: [], defaultMacOSAppExecutable: app.executable.path) } } @Test func `rejects configured non-stdio transports instead of spawning a local fallback`() throws { let app = try makeFakeCodex("#!/bin/sh\nexit 0\n") let pathCLI = try makeFakeCodex("#!/bin/sh\nexit 0\n") defer { try? FileManager.default.removeItem(at: app.directory) try? FileManager.default.removeItem(at: pathCLI.directory) } for transport in ["websocket", "unix"] { let root: [String: Any] = [ "plugins": [ "entries": [ "codex": [ "enabled": true, "config": [ "supervision": ["enabled": true], "appServer": [ "transport": transport, "command": "/must/not/win", "args": ["must-not-win"], ], ], ], ], ], ] #expect(!MacNodeCodexThreadCatalog.shouldAdvertise(root: root)) #expect(throws: MacNodeCodexThreadCatalog.CatalogError.unsupportedAppServerTransport) { try MacNodeCodexThreadCatalog.resolveInvocation( root: root, searchPaths: [pathCLI.directory.path], defaultMacOSAppExecutable: app.executable.path) } } } @Test func `finds a Codex app installed in the user Applications directory`() throws { let userApp = try makeFakeCodex("#!/bin/sh\nexit 0\n") let pathCLI = try makeFakeCodex("#!/bin/sh\nexit 0\n") defer { try? FileManager.default.removeItem(at: userApp.directory) try? FileManager.default.removeItem(at: pathCLI.directory) } let resolved = try MacNodeCodexThreadCatalog.resolveInvocation( root: [:], searchPaths: [pathCLI.directory.path], defaultMacOSChatGPTAppExecutable: userApp.directory.appendingPathComponent("missing").path, defaultUserMacOSChatGPTAppExecutable: userApp.directory.appendingPathComponent("missing").path, defaultMacOSAppExecutable: userApp.directory.appendingPathComponent("missing").path, defaultUserMacOSAppExecutable: userApp.executable.path) #expect(resolved.executable == userApp.executable.path) #expect(resolved.arguments == ["app-server", "--listen", "stdio://"]) } @Test func `finds a Codex Beta app when stable app bundles are absent`() throws { let betaApp = try makeFakeCodex("#!/bin/sh\nexit 0\n") let pathCLI = try makeFakeCodex("#!/bin/sh\nexit 0\n") defer { try? FileManager.default.removeItem(at: betaApp.directory) try? FileManager.default.removeItem(at: pathCLI.directory) } let missing = betaApp.directory.appendingPathComponent("missing").path let resolved = try MacNodeCodexThreadCatalog.resolveInvocation( root: [:], searchPaths: [pathCLI.directory.path], defaultMacOSChatGPTAppExecutable: missing, defaultUserMacOSChatGPTAppExecutable: missing, defaultMacOSAppExecutable: missing, defaultUserMacOSAppExecutable: missing, defaultMacOSBetaAppExecutable: betaApp.executable.path, defaultUserMacOSBetaAppExecutable: missing) #expect(resolved.executable == betaApp.executable.path) #expect(resolved.arguments == ["app-server", "--listen", "stdio://"]) } @Test func `finds ChatGPT app in the user Applications directory`() throws { let chatGPTApp = try makeFakeCodex("#!/bin/sh\nexit 0\n") let pathCLI = try makeFakeCodex("#!/bin/sh\nexit 0\n") defer { try? FileManager.default.removeItem(at: chatGPTApp.directory) try? FileManager.default.removeItem(at: pathCLI.directory) } let missing = chatGPTApp.directory.appendingPathComponent("missing").path let resolved = try MacNodeCodexThreadCatalog.resolveInvocation( root: [:], searchPaths: [pathCLI.directory.path], defaultMacOSChatGPTAppExecutable: missing, defaultUserMacOSChatGPTAppExecutable: chatGPTApp.executable.path, defaultMacOSAppExecutable: missing, defaultUserMacOSAppExecutable: missing, defaultMacOSBetaAppExecutable: missing, defaultUserMacOSBetaAppExecutable: missing) #expect(resolved.executable == chatGPTApp.executable.path) } @Test func `prefers ChatGPT app before legacy Codex app bundles`() throws { let chatGPTApp = try makeFakeCodex("#!/bin/sh\nexit 0\n") let codexApp = try makeFakeCodex("#!/bin/sh\nexit 0\n") let codexBetaApp = try makeFakeCodex("#!/bin/sh\nexit 0\n") defer { try? FileManager.default.removeItem(at: chatGPTApp.directory) try? FileManager.default.removeItem(at: codexApp.directory) try? FileManager.default.removeItem(at: codexBetaApp.directory) } let missing = chatGPTApp.directory.appendingPathComponent("missing").path let resolved = try MacNodeCodexThreadCatalog.resolveInvocation( root: [:], searchPaths: [], defaultMacOSChatGPTAppExecutable: chatGPTApp.executable.path, defaultUserMacOSChatGPTAppExecutable: missing, defaultMacOSAppExecutable: codexApp.executable.path, defaultUserMacOSAppExecutable: missing, defaultMacOSBetaAppExecutable: codexBetaApp.executable.path, defaultUserMacOSBetaAppExecutable: missing) #expect(resolved.executable == chatGPTApp.executable.path) } @Test func `fake App Server receives handshake and bounded list request`() async throws { let fake = try makeFakeCodex(#""" #!/bin/sh capture="${0}.requests" IFS= read -r initialize || exit 2 printf '%s\n' "$initialize" > "$capture" printf '%s' '{"id":1,"result":{"codexHome":"/Users/private/.codex",' printf '%s\n' '"platformFamily":"unix","platformOs":"macos","userAgent":"fake"}}' IFS= read -r initialized || exit 3 printf '%s\n' "$initialized" >> "$capture" IFS= read -r list || exit 4 printf '%s\n' "$list" >> "$capture" printf '%s' '{"id":2,"result":{"data":[{"id":"thread-1","sessionId":"session-1",' printf '%s' '"name":"One","preview":"private transcript","cwd":"/work",' printf '%s' '"status":{"type":"notLoaded"},"source":{"custom":"chatgpt"},' printf '%s' '"path":"/private/rollout.jsonl","turns":[]},{"id":"thread-2",' printf '%s' '"name":"Two","preview":"One","cwd":"/other",' printf '%s\n' '"status":{"type":"notLoaded"}}],"nextCursor":null,"backwardsCursor":"back/+=="}}' sleep 1 """#) defer { try? FileManager.default.removeItem(at: fake.directory) } let payload = try await MacNodeCodexThreadCatalog.list( paramsJSON: #"{"cursor":" cursor ","limit":25,"searchTerm":" oNe ","cwd":" /work "}"#, executable: fake.executable.path) let response = try #require( JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]) let sessions = try #require(response["sessions"] as? [[String: Any]]) #expect(response["codexHome"] == nil) #expect(response["nextCursor"] == nil) #expect(response["backwardsCursor"] as? String == "back/+==") #expect(sessions.count == 1) #expect(sessions.first?["threadId"] as? String == "thread-1") #expect(sessions.first?["preview"] == nil) #expect(sessions.first?["path"] == nil) let captured = try String(contentsOf: fake.capture, encoding: .utf8) .split(whereSeparator: \.isNewline) .map { try JSONSerialization.jsonObject(with: Data($0.utf8)) as? [String: Any] } #expect(captured.count == 3) #expect(captured[0]?["method"] as? String == "initialize") #expect(captured[1]?["method"] as? String == "initialized") #expect(captured[1]?["id"] == nil) #expect(captured[2]?["method"] as? String == "thread/list") let listParams = try #require(captured[2]?["params"] as? [String: Any]) #expect(listParams["cursor"] as? String == "cursor") #expect(listParams["limit"] as? Int == 25) #expect(listParams["archived"] as? Bool == false) #expect(listParams["searchTerm"] == nil) #expect(listParams["cwd"] as? String == "/work") #expect(listParams["sortKey"] as? String == "recency_at") #expect(listParams["sortDirection"] as? String == "desc") #expect((listParams["modelProviders"] as? [Any])?.isEmpty == true) #expect(listParams["sourceKinds"] == nil) #expect(listParams["useStateDbOnly"] as? Bool == false) } @Test func `reads one paginated transcript turn page from App Server`() async throws { let fake = try makeFakeCodex(#""" #!/bin/sh capture="${0}.requests" counter="${0}.counter" count=0 [ ! -f "$counter" ] || count=$(cat "$counter") count=$((count + 1)) printf '%s\n' "$count" > "$counter" IFS= read -r initialize || exit 2 printf '%s\n' "$initialize" >> "$capture" printf '%s\n' '{"id":1,"result":{}}' IFS= read -r initialized || exit 3 printf '%s\n' "$initialized" >> "$capture" IFS= read -r request || exit 4 printf '%s\n' "$request" >> "$capture" case "$count" in 1) printf '%s\n' '{"id":2,"result":{"data":[{"id":"thread-1","name":"Task","status":{"type":"notLoaded"}}],"nextCursor":null,"backwardsCursor":null}}' ;; 2) printf '%s\n' '{"id":2,"result":{"data":[{"id":"turn-1","items":[{"id":"item-1","type":"agentMessage","text":"full answer"}]}],"nextCursor":"turns-2","backwardsCursor":null}}' ;; *) exit 5 ;; esac sleep 1 """#) defer { try? FileManager.default.removeItem(at: fake.directory) } let payload = try await MacNodeCodexThreadCatalog.turns( paramsJSON: #"{"threadId":" thread-1 ","cursor":" turns-1 ","limit":25}"#, executable: fake.executable.path) let response = try #require( JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]) let firstTurn = try #require((response["data"] as? [[String: Any]])?.first) #expect((firstTurn["items"] as? [[String: Any]])?.first?["text"] as? String == "full answer") #expect(response["nextCursor"] as? String == "turns-2") let captured = try String(contentsOf: fake.capture, encoding: .utf8) .split(whereSeparator: \.isNewline) .map { try JSONSerialization.jsonObject(with: Data($0.utf8)) as? [String: Any] } #expect(captured.count == 6) let initializeParams = try #require(captured[0]?["params"] as? [String: Any]) let capabilities = try #require(initializeParams["capabilities"] as? [String: Any]) #expect(capabilities["experimentalApi"] as? Bool == true) #expect(captured[2]?["method"] as? String == "thread/list") #expect(captured[5]?["method"] as? String == "thread/turns/list") let params = try #require(captured[5]?["params"] as? [String: Any]) #expect(params["threadId"] as? String == "thread-1") #expect(params["cursor"] as? String == "turns-1") #expect(params["limit"] as? Int == 25) #expect(params["sortDirection"] as? String == "desc") #expect(params["itemsView"] as? String == "full") } @Test func `title search fills one result page across bounded native pages`() async throws { let first = try listResponseJSON( names: ["Target one", "Other one", "Other two"], nextCursor: "cursor-1") let second = try listResponseJSON( names: ["Other three", "Other four"], nextCursor: "cursor-2") let third = try listResponseJSON( names: ["Target two", "Target three"], nextCursor: "cursor-3") let fake = try makeFakeCodex(#""" #!/bin/sh counter="${0}.counter" count=0 [ ! -f "$counter" ] || count=$(cat "$counter") count=$((count + 1)) printf '%s\n' "$count" > "$counter" IFS= read -r initialize || exit 2 printf '%s\n' '{"id":1,"result":{}}' IFS= read -r initialized || exit 3 IFS= read -r list || exit 4 printf '%s\n' "$list" >> "${0}.requests" case "$count" in 1) printf '%s\n' '\#(first)' ;; 2) printf '%s\n' '\#(second)' ;; 3) printf '%s\n' '\#(third)' ;; *) exit 9 ;; esac """#) defer { try? FileManager.default.removeItem(at: fake.directory) } let payload = try await MacNodeCodexThreadCatalog.list( paramsJSON: #"{"limit":3,"searchTerm":"target"}"#, executable: fake.executable.path) let response = try #require( JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]) let sessions = try #require(response["sessions"] as? [[String: Any]]) #expect(sessions.compactMap { $0["name"] as? String } == [ "Target one", "Target two", "Target three", ]) #expect(response["nextCursor"] as? String == "cursor-3") let requests = try String(contentsOf: fake.capture, encoding: .utf8) .split(whereSeparator: \.isNewline) .map { try #require( JSONSerialization.jsonObject(with: Data($0.utf8)) as? [String: Any]) } let params = try requests.map { request in try #require(request["params"] as? [String: Any]) } #expect(params.count == 3) #expect(params.compactMap { $0["limit"] as? Int } == [3, 2, 2]) #expect(params[0]["cursor"] == nil) #expect(params[1]["cursor"] as? String == "cursor-1") #expect(params[2]["cursor"] as? String == "cursor-2") #expect(params.allSatisfy { $0["searchTerm"] == nil }) } @Test func `title search scans at most four pages and returns the continuation cursor`() async throws { let names = (0..<40).map { "Other \($0)" } let responses = try (1...4).map { page in try self.listResponseJSON(names: names, nextCursor: "cursor-\(page)") } let fake = try makeFakeCodex(#""" #!/bin/sh counter="${0}.counter" count=0 [ ! -f "$counter" ] || count=$(cat "$counter") count=$((count + 1)) printf '%s\n' "$count" > "$counter" IFS= read -r initialize || exit 2 printf '%s\n' '{"id":1,"result":{}}' IFS= read -r initialized || exit 3 IFS= read -r list || exit 4 printf '%s\n' "$list" >> "${0}.requests" case "$count" in 1) printf '%s\n' '\#(responses[0])' ;; 2) printf '%s\n' '\#(responses[1])' ;; 3) printf '%s\n' '\#(responses[2])' ;; 4) printf '%s\n' '\#(responses[3])' ;; *) exit 9 ;; esac """#) defer { try? FileManager.default.removeItem(at: fake.directory) } let payload = try await MacNodeCodexThreadCatalog.list( paramsJSON: #"{"limit":40,"searchTerm":"target"}"#, executable: fake.executable.path) let response = try #require( JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]) #expect((response["sessions"] as? [Any])?.isEmpty == true) #expect(response["nextCursor"] as? String == "cursor-4") let requests = try String(contentsOf: fake.capture, encoding: .utf8) .split(whereSeparator: \.isNewline) .map { try #require( JSONSerialization.jsonObject(with: Data($0.utf8)) as? [String: Any]) } #expect(requests.count == 4) #expect(requests.allSatisfy { request in let params = request["params"] as? [String: Any] return params?["limit"] as? Int == 40 && params?["searchTerm"] == nil }) } @Test func `title search stops a native cursor cycle`() async throws { let first = try listResponseJSON(names: ["Other one"], nextCursor: "same") let second = try listResponseJSON(names: ["Other two"], nextCursor: "same") let fake = try makeFakeCodex(#""" #!/bin/sh counter="${0}.counter" count=0 [ ! -f "$counter" ] || count=$(cat "$counter") count=$((count + 1)) printf '%s\n' "$count" > "$counter" IFS= read -r initialize || exit 2 printf '%s\n' '{"id":1,"result":{}}' IFS= read -r initialized || exit 3 IFS= read -r list || exit 4 printf '%s\n' "$list" >> "${0}.requests" case "$count" in 1) printf '%s\n' '\#(first)' ;; 2) printf '%s\n' '\#(second)' ;; *) exit 9 ;; esac """#) defer { try? FileManager.default.removeItem(at: fake.directory) } let payload = try await MacNodeCodexThreadCatalog.list( paramsJSON: #"{"limit":40,"searchTerm":"target"}"#, executable: fake.executable.path) let response = try #require( JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]) #expect(response["nextCursor"] == nil) let requests = try String(contentsOf: fake.capture, encoding: .utf8) .split(whereSeparator: \.isNewline) #expect(requests.count == 2) } @Test func `drains App Server frames larger than one pipe read while server stays open`() async throws { let threads: [[String: Any]] = (0..<50).map { index in [ "id": "thread-\(index)", "name": "Large catalog \(index)", "cwd": "/workspace/\(String(repeating: "x", count: 2000))", "status": ["type": "notLoaded"], ] } let responseData = try JSONSerialization.data(withJSONObject: [ "id": 2, "result": ["data": threads], ]) let response = try #require(String(data: responseData, encoding: .utf8)) #expect(response.utf8.count > 64 * 1024) let fake = try makeFakeCodex(""" #!/bin/sh IFS= read -r initialize || exit 2 printf '%s\n' '{"id":1,"result":{}}' IFS= read -r initialized || exit 3 IFS= read -r list || exit 4 printf '%s\n' '\(response)' # Keep stdout open until the client closes stdin; completion must come # from draining the full JSONL frame, never from observing process EOF. IFS= read -r keep_open || exit 0 """) defer { try? FileManager.default.removeItem(at: fake.directory) } let payload = try await MacNodeCodexThreadCatalog.list( paramsJSON: #"{"limit":50}"#, executable: fake.executable.path, timeoutSeconds: 10) let decoded = try #require( JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]) #expect((decoded["sessions"] as? [Any])?.count == 50) } @Test func `default deadline allows cold large catalog scans`() { #expect(MacNodeCodexThreadCatalog.defaultTimeoutSeconds == 60) } @Test func `rejects unknown and out of range params before launch`() async { let cases = [ (#"{"extra":true}"#, "unknown Codex session catalog parameter: extra"), (#"{"limit":0}"#, "limit must be an integer from 1 to 100"), (#"{"limit":101}"#, "limit must be an integer from 1 to 100"), (#"{"limit":1.5}"#, "limit must be an integer from 1 to 100"), (#"{"archived":true}"#, "unknown Codex session catalog parameter: archived"), ] for (paramsJSON, expected) in cases { do { _ = try await MacNodeCodexThreadCatalog.list( paramsJSON: paramsJSON, executable: "/path/that/must/not/launch") Issue.record("expected invalid params for \(paramsJSON)") } catch let error as MacNodeCodexThreadCatalog.CatalogError { #expect(error.localizedDescription.contains(expected)) } catch { Issue.record("unexpected error: \(error)") } } } @Test func `bounds fake App Server output and wait time`() async throws { let oversized = try makeFakeCodex(#""" #!/bin/sh IFS= read -r initialize || exit 2 printf '%512s\n' x sleep 1 """#) defer { try? FileManager.default.removeItem(at: oversized.directory) } do { _ = try await MacNodeCodexThreadCatalog.list( paramsJSON: nil, executable: oversized.executable.path, maxLineBytes: 128) Issue.record("expected oversized App Server response to fail") } catch let error as MacNodeCodexThreadCatalog.CatalogError { #expect(error == .responseTooLarge) } let stalled = try makeFakeCodex(#""" #!/bin/sh IFS= read -r initialize || exit 2 sleep 1 """#) defer { try? FileManager.default.removeItem(at: stalled.directory) } do { _ = try await MacNodeCodexThreadCatalog.list( paramsJSON: nil, executable: stalled.executable.path, timeoutSeconds: 0.05) Issue.record("expected stalled App Server response to time out") } catch let error as MacNodeCodexThreadCatalog.CatalogError { #expect(error == .timedOut) } } @Test func `App Server error details stay on node`() async throws { let fake = try makeFakeCodex(#""" #!/bin/sh IFS= read -r initialize || exit 2 printf '%s\n' '{"id":1,"result":{"codexHome":"/private"}}' IFS= read -r initialized || exit 3 IFS= read -r list || exit 4 printf '%s\n' '{"id":2,"error":{"code":-32000,"message":"private /Users/secret/path"}}' sleep 1 """#) defer { try? FileManager.default.removeItem(at: fake.directory) } do { _ = try await MacNodeCodexThreadCatalog.list( paramsJSON: nil, executable: fake.executable.path) Issue.record("expected fake App Server error") } catch let error as MacNodeCodexThreadCatalog.CatalogError { #expect(error == .appServerUnavailable) #expect(error.localizedDescription == "UNAVAILABLE: Codex app-server request failed") #expect(!error.localizedDescription.contains("/Users/secret")) } } }