openclaw/apps/macos/Tests/OpenClawIPCTests/MacNodeCodexThreadCatalogTests.swift
Peter Steinberger 5154fe08fa
feat: show Codex sessions across Gateway and paired nodes (#102586)
* feat(codex): add federated session catalog

* fix(codex): align session catalog checks

* fix(ui): translate Codex session catalog

---------

Co-authored-by: Peter Steinberger <peter@steipete.me>
2026-07-09 03:42:03 -07:00

380 lines
17 KiB
Swift
Raw Permalink Blame History

import Foundation
import Testing
@testable import OpenClaw
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"))
}
@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,
archived: false)
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,
archived: false)
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("<EFBFBD>"))
#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 first configured stdio endpoint without a shell`() async throws {
let fake = try self.makeFakeCodex(#"""
#!/bin/sh
[ "$1" = "custom-app-server" ] || exit 10
[ "$2" = "--stdio" ] || exit 11
pwd > "${0}.cwd"
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-supervisor ": [
"enabled": true,
"config": [
"endpoints": [
["transport": "websocket", "url": "unix://"],
[
"transport": "stdio-proxy",
"command": "./codex",
"args": ["custom-app-server", "--stdio"],
"cwd": fake.directory.path,
],
["transport": "stdio-proxy", "command": "/must/not/win"],
],
],
],
],
],
]
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"])
#expect(resolved.cwd == fake.directory.standardizedFileURL)
let payload = try await MacNodeCodexThreadCatalog.list(
paramsJSON: nil,
executable: resolved.executable,
arguments: resolved.arguments,
cwd: resolved.cwd)
let response = try #require(
JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any])
#expect((response["sessions"] as? [Any])?.isEmpty == true)
let capturedCwd = try String(
contentsOf: URL(fileURLWithPath: fake.executable.path + ".cwd"),
encoding: .utf8)
.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(
URL(fileURLWithPath: capturedCwd).resolvingSymlinksInPath() ==
fake.directory.resolvingSymlinksInPath())
}
@Test func `prefers the installed Codex app binary for the default endpoint`() throws {
let app = try self.makeFakeCodex("#!/bin/sh\nexit 0\n")
let pathCLI = try self.makeFakeCodex("#!/bin/sh\nexit 0\n")
defer {
try? FileManager.default.removeItem(at: app.directory)
try? FileManager.default.removeItem(at: pathCLI.directory)
}
let resolved = try MacNodeCodexThreadCatalog.resolveInvocation(
root: [:],
searchPaths: [pathCLI.directory.path],
defaultMacOSAppExecutable: app.executable.path)
#expect(resolved.executable == app.executable.path)
#expect(resolved.arguments == ["app-server", "--listen", "stdio://"])
}
@Test func `fake App Server receives handshake and bounded list request`() async throws {
let fake = try self.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":"one","preview":"One","cwd":"/other",'
printf '%s\n' '"status":{"type":"notLoaded"}}],"nextCursor":"opaque/+==","backwardsCursor":"back/+=="}}'
sleep 1
"""#)
defer { try? FileManager.default.removeItem(at: fake.directory) }
let payload = try await MacNodeCodexThreadCatalog.list(
paramsJSON: #"{"cursor":" cursor ","limit":25,"archived":true,"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"] as? String == "opaque/+==")
#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 == true)
#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 `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: 2_000))",
"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 self.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)'
sleep 1
""")
defer { try? FileManager.default.removeItem(at: fake.directory) }
let payload = try await MacNodeCodexThreadCatalog.list(
paramsJSON: #"{"limit":50}"#,
executable: fake.executable.path,
timeoutSeconds: 1)
let decoded = try #require(
JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any])
#expect((decoded["sessions"] as? [Any])?.count == 50)
}
@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"),
]
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 self.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 self.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 self.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 thread list failed")
#expect(!error.localizedDescription.contains("/Users/secret"))
}
}
}