mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
1257279de6
Writing to a pipe whose reader has already exited raises SIGPIPE, which kills the whole process instead of throwing. MacNodeHostWorker already guarded its stdin pipe with F_SETNOSIGPIPE; the codex App Server client and the MLX TTS helper transport did not, so a child exiting mid-write could take down the app. Suppressing the signal exposed that an undelivered App Server request write was reported as appServerUnavailable even though the frame was provably never sent, so it now requeues once onto a fresh child instead of failing the caller. Test-side pipe write ends whose readers are spawned children (or a readability handler that can close the pipe mid-test) get the same suppression so a racing reader exit fails the assertion instead of killing swiftpm-testing-helper with signal 13, which is what caused the macos-swift CI lane's intermittent unrelated-test crashes (e.g. PR #126559, run 32341197738 job 96340683947).
1527 lines
64 KiB
Swift
1527 lines
64 KiB
Swift
import ConcurrencyExtras
|
|
import Darwin
|
|
import Foundation
|
|
import OpenClawKit
|
|
import Testing
|
|
@testable import OpenClaw
|
|
|
|
@Suite(.serialized)
|
|
struct MacNodeCodexThreadCatalogTests {
|
|
private final class FakeCodex: Sendable {
|
|
let directory: URL
|
|
let executable: URL
|
|
var capture: URL {
|
|
URL(fileURLWithPath: self.executable.path + ".requests")
|
|
}
|
|
|
|
var eof: URL {
|
|
URL(fileURLWithPath: self.executable.path + ".eof")
|
|
}
|
|
|
|
var exited: URL {
|
|
URL(fileURLWithPath: self.executable.path + ".exited")
|
|
}
|
|
|
|
var exitGate: URL {
|
|
URL(fileURLWithPath: self.executable.path + ".exit-gate")
|
|
}
|
|
|
|
init(directory: URL, executable: URL) {
|
|
self.directory = directory
|
|
self.executable = executable
|
|
}
|
|
|
|
deinit {
|
|
try? FileManager.default.removeItem(at: self.directory)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
private func makeAppServer(
|
|
preamble: String = "",
|
|
initializeResult: String = "{}",
|
|
captureHandshake: Bool = false,
|
|
body: String) throws -> FakeCodex
|
|
{
|
|
let captureCommand = captureHandshake ? "printf" : ":"
|
|
return try self.makeFakeCodex(#"""
|
|
#!/bin/sh
|
|
\#(preamble)
|
|
IFS= read -r initialize || exit 2
|
|
\#(captureCommand) '%s\n' "$initialize" >> "${0}.requests"
|
|
id=$(printf '%s\n' "$initialize" | /usr/bin/sed -E 's/.*"id":([0-9]+).*/\1/')
|
|
printf '{"id":%s,"result":\#(initializeResult)}\n' "$id"
|
|
IFS= read -r initialized || exit 3
|
|
\#(captureCommand) '%s\n' "$initialized" >> "${0}.requests"
|
|
\#(body)
|
|
"""#)
|
|
}
|
|
|
|
private func makeEmptyListServer(
|
|
tracksLaunches: Bool = false,
|
|
recordsEOFExit: Bool = false,
|
|
blocksEOFExit: Bool = false,
|
|
captureHandshake: Bool = false,
|
|
exitsAfterResponse: Bool = false) throws -> FakeCodex
|
|
{
|
|
var preamble: [String] = []
|
|
if tracksLaunches {
|
|
preamble.append(#"""
|
|
count=0
|
|
[ ! -f "${0}.processes" ] || count=$(cat "${0}.processes")
|
|
printf '%s\n' "$((count + 1))" > "${0}.processes"
|
|
"""#)
|
|
}
|
|
if blocksEOFExit {
|
|
preamble.append(#"mkfifo "${0}.exit-gate""#)
|
|
preamble.append(#"trap '' TERM"#)
|
|
}
|
|
let eofExit = blocksEOFExit ? #"""
|
|
touch "${0}.eof"
|
|
IFS= read -r _ < "${0}.exit-gate"
|
|
touch "${0}.exited"
|
|
"""# : recordsEOFExit ? #"""
|
|
touch "${0}.eof"
|
|
touch "${0}.exited"
|
|
"""# : ""
|
|
let body = exitsAfterResponse ? #"""
|
|
IFS= read -r request || exit 4
|
|
id=$(printf '%s\n' "$request" | /usr/bin/sed -E 's/.*"id":([0-9]+).*/\1/')
|
|
printf '{"id":%s,"result":{"data":[]}}\n' "$id"
|
|
"""# : #"""
|
|
while IFS= read -r request; do
|
|
printf '%s\n' "$request" >> "${0}.requests"
|
|
id=$(printf '%s\n' "$request" | /usr/bin/sed -E 's/.*"id":([0-9]+).*/\1/')
|
|
printf '{"id":%s,"result":{"data":[],"nextCursor":null,"backwardsCursor":null}}\n' "$id"
|
|
done
|
|
\#(eofExit)
|
|
"""#
|
|
return try self.makeAppServer(
|
|
preamble: preamble.joined(separator: "\n"),
|
|
captureHandshake: captureHandshake,
|
|
body: body)
|
|
}
|
|
|
|
private func makeBlockedFirstRequestServer() throws -> FakeCodex {
|
|
try self.makeAppServer(
|
|
preamble: #"""
|
|
count=0
|
|
[ ! -f "${0}.processes" ] || count=$(cat "${0}.processes")
|
|
count=$((count + 1))
|
|
printf '%s\n' "$count" > "${0}.processes"
|
|
"""#,
|
|
body: #"""
|
|
IFS= read -r request || exit 4
|
|
if [ "$count" = 1 ]; then
|
|
touch "${0}.request-started"
|
|
sleep 5
|
|
exit 0
|
|
fi
|
|
id=$(printf '%s\n' "$request" | /usr/bin/sed -E 's/.*"id":([0-9]+).*/\1/')
|
|
printf '{"id":%s,"result":{"data":[]}}\n' "$id"
|
|
"""#)
|
|
}
|
|
|
|
private func codexRoot(
|
|
config: Any = ["supervision": ["enabled": true]],
|
|
entryKey: String = "codex",
|
|
enabled: Bool? = true,
|
|
pluginPolicy: [String: Any] = [:]) -> [String: Any]
|
|
{
|
|
var entry: [String: Any] = ["config": config]
|
|
if let enabled {
|
|
entry["enabled"] = enabled
|
|
}
|
|
var plugins = pluginPolicy
|
|
plugins["entries"] = [entryKey: entry]
|
|
return ["plugins": plugins]
|
|
}
|
|
|
|
private func codexRoot(
|
|
appServer: [String: Any],
|
|
pluginPolicy: [String: Any] = [:]) -> [String: Any]
|
|
{
|
|
self.codexRoot(
|
|
config: [
|
|
"supervision": ["enabled": true],
|
|
"appServer": appServer,
|
|
],
|
|
pluginPolicy: pluginPolicy)
|
|
}
|
|
|
|
private func listResponseJSON(
|
|
id: Int = 2,
|
|
names: [String],
|
|
nextCursor: String?) throws -> String
|
|
{
|
|
let encodedNextCursor: Any = nextCursor.map { $0 as Any } ?? 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": id,
|
|
"result": [
|
|
"data": threads,
|
|
"nextCursor": encodedNextCursor,
|
|
"backwardsCursor": NSNull(),
|
|
],
|
|
])
|
|
return try #require(String(data: data, encoding: .utf8))
|
|
}
|
|
|
|
private func waitForFile(_ url: URL, timeout: Duration = .seconds(2)) async -> Bool {
|
|
let clock = ContinuousClock()
|
|
let deadline = clock.now.advanced(by: timeout)
|
|
while !FileManager.default.fileExists(atPath: url.path), clock.now < deadline {
|
|
try? await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
return FileManager.default.fileExists(atPath: url.path)
|
|
}
|
|
|
|
private func readTrimmed(_ url: URL) throws -> String {
|
|
try String(contentsOf: url, encoding: .utf8)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
private func openFIFOForWriting(_ url: URL) async throws -> FileHandle {
|
|
let handle = try await Task.detached {
|
|
try FileHandle(forWritingTo: url)
|
|
}.value
|
|
// The FIFO reader is a spawned fake child; if it exits before the exit
|
|
// gate write, an unsuppressed SIGPIPE kills the whole test harness.
|
|
try TestProcessSupport.suppressSIGPIPE(handle)
|
|
return handle
|
|
}
|
|
|
|
private func requestEmptyList(
|
|
client: CodexAppServerThreadClient,
|
|
executable: URL,
|
|
timeoutSeconds: Double = 2,
|
|
maxLineBytes: Int = 1024 * 1024) async throws -> Data
|
|
{
|
|
try await client.request(
|
|
invocation: MacNodeCodexThreadCatalog.ResolvedInvocation(
|
|
executable: executable.path,
|
|
arguments: [],
|
|
cwd: nil),
|
|
method: "thread/list",
|
|
requestParams: ["limit": 1],
|
|
timeoutSeconds: timeoutSeconds,
|
|
maxLineBytes: maxLineBytes)
|
|
}
|
|
|
|
@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 makeAppServer(
|
|
preamble: #"""
|
|
[ "$1" = "custom-app-server" ] || exit 10
|
|
[ "$2" = "--stdio" ] || exit 11
|
|
[ -z "${OPENCLAW_CODEX_CATALOG_CLEAR_ENV_SENTINEL+x}" ] || exit 12
|
|
"""#,
|
|
body: #"""
|
|
IFS= read -r list || exit 4
|
|
printf '%s\n' '{"id":2,"result":{"data":[],"nextCursor":null,"backwardsCursor":null}}'
|
|
sleep 1
|
|
"""#)
|
|
defer { withExtendedLifetime(fake) {} }
|
|
let root = self.codexRoot(
|
|
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) ", ""],
|
|
],
|
|
],
|
|
entryKey: " codex ")
|
|
|
|
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")
|
|
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")
|
|
let root = self.codexRoot(
|
|
config: ["appServer": ["command": configured.executable.path]],
|
|
enabled: nil)
|
|
|
|
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")
|
|
let root = self.codexRoot(
|
|
config: ["appServer": ["command": " \n "]],
|
|
enabled: nil)
|
|
|
|
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")
|
|
let root = self.codexRoot(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")
|
|
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 = self.codexRoot(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 makeAppServer(body: #"""
|
|
IFS= read -r list || exit 4
|
|
printf '%s\n' '{"id":2,"result":{"data":[]}}'
|
|
sleep 1
|
|
"""#)
|
|
defer { withExtendedLifetime(fake) {} }
|
|
let enabled = self.codexRoot(appServer: [
|
|
"transport": "stdio",
|
|
"homeScope": "user",
|
|
"command": fake.executable.path,
|
|
"args": ["app-server", "--listen", "stdio://"],
|
|
])
|
|
let revoked = self.codexRoot(pluginPolicy: ["deny": ["codex"]])
|
|
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 = self.codexRoot(pluginPolicy: ["allow": ["discord"]])
|
|
|
|
#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")
|
|
let root = self.codexRoot(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")
|
|
|
|
for transport in ["websocket", "unix"] {
|
|
let root = self.codexRoot(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")
|
|
|
|
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")
|
|
|
|
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")
|
|
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")
|
|
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 makeAppServer(
|
|
initializeResult: #"{"codexHome":"/Users/private/.codex","platformFamily":"unix","platformOs":"macos","userAgent":"fake"}"#,
|
|
captureHandshake: true,
|
|
body: #"""
|
|
IFS= read -r list || exit 4
|
|
printf '%s\n' "$list" >> "${0}.requests"
|
|
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
|
|
"""#)
|
|
|
|
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 `Mac node runtime reuses its owned App Server across invokes`() async throws {
|
|
let fake = try makeEmptyListServer(
|
|
tracksLaunches: true,
|
|
captureHandshake: true)
|
|
let root = self.codexRoot(appServer: [
|
|
"transport": "stdio",
|
|
"homeScope": "user",
|
|
"command": fake.executable.path,
|
|
])
|
|
let client = MacNodeCodexThreadCatalogClient(
|
|
idleTimeoutSeconds: 10,
|
|
loadRoot: { root })
|
|
let runtime = MacNodeRuntime(
|
|
codexThreadCatalogEnabled: { true },
|
|
codexThreadCatalogClient: client)
|
|
|
|
let first = await runtime.handleInvoke(BridgeInvokeRequest(
|
|
id: "first",
|
|
command: MacNodeCodexThreadCatalogContract.listCommand))
|
|
let second = await runtime.handleInvoke(BridgeInvokeRequest(
|
|
id: "second",
|
|
command: MacNodeCodexThreadCatalogContract.listCommand))
|
|
|
|
#expect(first.ok)
|
|
#expect(second.ok)
|
|
#expect(try self.readTrimmed(
|
|
URL(fileURLWithPath: fake.executable.path + ".processes")) == "1")
|
|
let captured = try String(contentsOf: fake.capture, encoding: .utf8)
|
|
.split(whereSeparator: \.isNewline)
|
|
.map { try #require(
|
|
JSONSerialization.jsonObject(with: Data($0.utf8)) as? [String: Any]) }
|
|
#expect(captured.map { $0["method"] as? String } == [
|
|
"initialize",
|
|
"initialized",
|
|
"thread/list",
|
|
"thread/list",
|
|
])
|
|
#expect(captured.compactMap { ($0["id"] as? NSNumber)?.intValue } == [1, 2, 3])
|
|
await client.shutdown()
|
|
}
|
|
|
|
@Test func `reads one paginated transcript turn page from App Server`() async throws {
|
|
let fake = try makeAppServer(captureHandshake: true, body: #"""
|
|
IFS= read -r list || exit 4
|
|
printf '%s\n' "$list" >> "${0}.requests"
|
|
printf '%s\n' '{"id":2,"result":{"data":[{"id":"thread-1","name":"Task","status":{"type":"notLoaded"}}],"nextCursor":null,"backwardsCursor":null}}'
|
|
IFS= read -r turns || exit 5
|
|
printf '%s\n' "$turns" >> "${0}.requests"
|
|
printf '%s\n' '{"id":3,"result":{"data":[{"id":"turn-1","items":[{"id":"item-1","type":"agentMessage","text":"full answer"}]}],"nextCursor":"turns-2","backwardsCursor":null}}'
|
|
sleep 1
|
|
"""#)
|
|
|
|
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 == 4)
|
|
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[3]?["method"] as? String == "thread/turns/list")
|
|
#expect(captured.compactMap { ($0?["id"] as? NSNumber)?.intValue } == [1, 2, 3])
|
|
let params = try #require(captured[3]?["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(
|
|
id: 2,
|
|
names: ["Target one", "Other one", "Other two"],
|
|
nextCursor: "cursor-1")
|
|
let second = try listResponseJSON(
|
|
id: 3,
|
|
names: ["Other three", "Other four"],
|
|
nextCursor: "cursor-2")
|
|
let third = try listResponseJSON(
|
|
id: 4,
|
|
names: ["Target two", "Target three"],
|
|
nextCursor: "cursor-3")
|
|
let fake = try makeAppServer(body: #"""
|
|
count=0
|
|
while IFS= read -r list; do
|
|
count=$((count + 1))
|
|
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 0 ;;
|
|
*) exit 9 ;;
|
|
esac
|
|
done
|
|
"""#)
|
|
|
|
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(
|
|
id: page + 1,
|
|
names: names,
|
|
nextCursor: "cursor-\(page)")
|
|
}
|
|
let fake = try makeAppServer(body: #"""
|
|
count=0
|
|
while IFS= read -r list; do
|
|
count=$((count + 1))
|
|
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 0 ;;
|
|
*) exit 9 ;;
|
|
esac
|
|
done
|
|
"""#)
|
|
|
|
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(id: 2, names: ["Other one"], nextCursor: "same")
|
|
let second = try listResponseJSON(id: 3, names: ["Other two"], nextCursor: "same")
|
|
let fake = try makeAppServer(body: #"""
|
|
count=0
|
|
while IFS= read -r list; do
|
|
count=$((count + 1))
|
|
printf '%s\n' "$list" >> "${0}.requests"
|
|
case "$count" in
|
|
1) printf '%s\n' '\#(first)' ;;
|
|
2) printf '%s\n' '\#(second)'; exit 0 ;;
|
|
*) exit 9 ;;
|
|
esac
|
|
done
|
|
"""#)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
extension MacNodeCodexThreadCatalogTests {
|
|
@Test func `restarts the lifecycle client when the resolved invocation changes`() async throws {
|
|
let first = try makeEmptyListServer(
|
|
tracksLaunches: true,
|
|
blocksEOFExit: true)
|
|
let second = try makeEmptyListServer(tracksLaunches: true)
|
|
let client = CodexAppServerThreadClient(idleTimeoutSeconds: 10)
|
|
|
|
_ = try await self.requestEmptyList(client: client, executable: first.executable)
|
|
let replacement = Task {
|
|
try await self.requestEmptyList(client: client, executable: second.executable)
|
|
}
|
|
let exitGate = try await self.openFIFOForWriting(first.exitGate)
|
|
|
|
#expect(FileManager.default.fileExists(atPath: first.eof.path))
|
|
#expect(!FileManager.default.fileExists(atPath: second.executable.path + ".processes"))
|
|
try exitGate.write(contentsOf: Data("exit\n".utf8))
|
|
try exitGate.close()
|
|
_ = try await replacement.value
|
|
|
|
#expect(try self.readTrimmed(
|
|
URL(fileURLWithPath: first.executable.path + ".processes")) == "1")
|
|
#expect(try self.readTrimmed(
|
|
URL(fileURLWithPath: second.executable.path + ".processes")) == "1")
|
|
#expect(FileManager.default.fileExists(atPath: first.exited.path))
|
|
await client.shutdown()
|
|
}
|
|
|
|
@Test func `restarts the lifecycle client after the child exits`() async throws {
|
|
let fake = try makeEmptyListServer(
|
|
tracksLaunches: true,
|
|
exitsAfterResponse: true)
|
|
let client = CodexAppServerThreadClient(idleTimeoutSeconds: 10)
|
|
|
|
_ = try await self.requestEmptyList(client: client, executable: fake.executable)
|
|
try await Task.sleep(for: .milliseconds(50))
|
|
_ = try await self.requestEmptyList(client: client, executable: fake.executable)
|
|
|
|
#expect(try self.readTrimmed(
|
|
URL(fileURLWithPath: fake.executable.path + ".processes")) == "2")
|
|
await client.shutdown()
|
|
}
|
|
|
|
@Test func `explicit shutdown reaps a graceful App Server and its descendants`() async throws {
|
|
let fake = try makeAppServer(
|
|
preamble: #"""
|
|
printf '%s\n' "$$" > "${0}.leader.pid"
|
|
trap 'touch "${0}.term"; exit 0' TERM
|
|
"""#,
|
|
body: #"""
|
|
/bin/sh -c 'trap "" HUP TERM; printf "%s\n" "$$" > "$1"; while :; do /bin/sleep 1; done' \
|
|
descendant "${0}.descendant.pid" </dev/null >/dev/null 2>&1 &
|
|
while [ ! -s "${0}.descendant.pid" ]; do /bin/sleep 0.01; done
|
|
while IFS= read -r request; do
|
|
id=$(printf '%s\n' "$request" | /usr/bin/sed -E 's/.*"id":([0-9]+).*/\1/')
|
|
printf '{"id":%s,"result":{"data":[]}}\n' "$id"
|
|
done
|
|
touch "${0}.eof"
|
|
trap 'touch "${0}.term"' TERM
|
|
/bin/sleep 0.35
|
|
touch "${0}.graceful-exit"
|
|
"""#)
|
|
let leaderPIDFile = URL(fileURLWithPath: fake.executable.path + ".leader.pid")
|
|
let descendantPIDFile = URL(fileURLWithPath: fake.executable.path + ".descendant.pid")
|
|
defer { TestProcessSupport.killLeakedProcesses(in: [descendantPIDFile, leaderPIDFile]) }
|
|
let client = CodexAppServerThreadClient(idleTimeoutSeconds: 10)
|
|
|
|
_ = try await self.requestEmptyList(client: client, executable: fake.executable)
|
|
let leaderPID = try await TestProcessSupport.waitForPID(in: leaderPIDFile)
|
|
let descendantPID = try await TestProcessSupport.waitForPID(in: descendantPIDFile)
|
|
|
|
await client.shutdown()
|
|
|
|
#expect(FileManager.default.fileExists(atPath: fake.executable.path + ".eof"))
|
|
#expect(FileManager.default.fileExists(atPath: fake.executable.path + ".graceful-exit"))
|
|
#expect(!FileManager.default.fileExists(atPath: fake.executable.path + ".term"))
|
|
#expect(TestProcessSupport.processIsGone(leaderPID))
|
|
#expect(TestProcessSupport.processIsGone(descendantPID))
|
|
}
|
|
|
|
@Test func `shuts down an idle lifecycle client`() async throws {
|
|
let fake = try makeEmptyListServer(blocksEOFExit: true)
|
|
let client = CodexAppServerThreadClient(idleTimeoutSeconds: 0.05)
|
|
|
|
_ = try await self.requestEmptyList(client: client, executable: fake.executable)
|
|
let exitGate = try await self.openFIFOForWriting(fake.exitGate)
|
|
let shutdown = Task { await client.shutdown() }
|
|
|
|
#expect(FileManager.default.fileExists(atPath: fake.eof.path))
|
|
try exitGate.write(contentsOf: Data("exit\n".utf8))
|
|
try exitGate.close()
|
|
await shutdown.value
|
|
#expect(FileManager.default.fileExists(atPath: fake.exited.path))
|
|
}
|
|
|
|
@Test func `explicit shutdown waits for EOF driven child exit`() async throws {
|
|
let fake = try makeEmptyListServer(blocksEOFExit: true)
|
|
let client = CodexAppServerThreadClient(idleTimeoutSeconds: 10)
|
|
let shutdownStarted = AsyncTestGate()
|
|
let shutdownReturned = LockIsolated(false)
|
|
|
|
_ = try await self.requestEmptyList(
|
|
client: client,
|
|
executable: fake.executable)
|
|
let shutdown = Task.detached(priority: .high) {
|
|
shutdownStarted.open()
|
|
await client.shutdown()
|
|
shutdownReturned.withValue { $0 = true }
|
|
}
|
|
await shutdownStarted.wait()
|
|
let exitGate = try await self.openFIFOForWriting(fake.exitGate)
|
|
|
|
#expect(FileManager.default.fileExists(atPath: fake.eof.path))
|
|
#expect(!shutdownReturned.withValue { $0 })
|
|
try exitGate.write(contentsOf: Data("exit\n".utf8))
|
|
try exitGate.close()
|
|
await shutdown.value
|
|
#expect(shutdownReturned.withValue { $0 })
|
|
#expect(FileManager.default.fileExists(atPath: fake.exited.path))
|
|
}
|
|
|
|
@Test func `explicit shutdown force kills an unresponsive child`() async throws {
|
|
let fake = try makeAppServer(
|
|
preamble: #"""
|
|
trap '' TERM
|
|
mkfifo "${0}.descendant-gate"
|
|
(
|
|
trap '' TERM
|
|
IFS= read -r _ < "${0}.descendant-gate"
|
|
) &
|
|
printf '%s\n' "$$" > "${0}.pid"
|
|
printf '%s\n' "$!" > "${0}.descendant-pid"
|
|
"""#,
|
|
body: #"""
|
|
IFS= read -r request || exit 4
|
|
id=$(printf '%s\n' "$request" | /usr/bin/sed -E 's/.*"id":([0-9]+).*/\1/')
|
|
printf '{"id":%s,"result":{"data":[]}}\n' "$id"
|
|
wait
|
|
"""#)
|
|
let client = CodexAppServerThreadClient(idleTimeoutSeconds: 10)
|
|
|
|
_ = try await self.requestEmptyList(
|
|
client: client,
|
|
executable: fake.executable)
|
|
let pid = try #require(Int32(self.readTrimmed(
|
|
URL(fileURLWithPath: fake.executable.path + ".pid"))))
|
|
let descendantPID = try #require(Int32(self.readTrimmed(
|
|
URL(fileURLWithPath: fake.executable.path + ".descendant-pid"))))
|
|
defer { _ = Darwin.kill(descendantPID, SIGKILL) }
|
|
let shutdown = Task { await client.shutdown() }
|
|
let watchdog = Task {
|
|
try? await Task.sleep(for: .seconds(10))
|
|
guard !Task.isCancelled else { return }
|
|
Issue.record("timed out waiting for Codex child shutdown")
|
|
shutdown.cancel()
|
|
_ = Darwin.kill(pid, SIGKILL)
|
|
_ = Darwin.kill(descendantPID, SIGKILL)
|
|
}
|
|
defer {
|
|
watchdog.cancel()
|
|
shutdown.cancel()
|
|
}
|
|
await shutdown.value
|
|
watchdog.cancel()
|
|
|
|
errno = 0
|
|
#expect(Darwin.kill(pid, 0) == -1)
|
|
#expect(errno == ESRCH)
|
|
}
|
|
|
|
@Test @MainActor func `coordinator terminal stop awaits its runtime owned Codex child`() async throws {
|
|
let fake = try makeEmptyListServer(recordsEOFExit: true)
|
|
let root = self.codexRoot(appServer: [
|
|
"transport": "stdio",
|
|
"homeScope": "user",
|
|
"command": fake.executable.path,
|
|
])
|
|
let client = MacNodeCodexThreadCatalogClient(
|
|
idleTimeoutSeconds: 10,
|
|
loadRoot: { root })
|
|
let runtime = MacNodeRuntime(
|
|
codexThreadCatalogEnabled: { true },
|
|
codexThreadCatalogClient: client)
|
|
let coordinator = MacNodeModeCoordinator(
|
|
session: GatewayNodeSession(),
|
|
runtime: runtime)
|
|
|
|
let response = await runtime.handleInvoke(BridgeInvokeRequest(
|
|
id: "list",
|
|
command: MacNodeCodexThreadCatalogContract.listCommand))
|
|
#expect(response.ok)
|
|
|
|
await coordinator.stopAndWait()
|
|
|
|
#expect(FileManager.default.fileExists(atPath: fake.eof.path))
|
|
#expect(FileManager.default.fileExists(atPath: fake.exited.path))
|
|
}
|
|
|
|
@Test func `oversized idle output resets the lifecycle client`() async throws {
|
|
let fake = try makeAppServer(
|
|
preamble: #"""
|
|
count=0
|
|
[ ! -f "${0}.processes" ] || count=$(cat "${0}.processes")
|
|
count=$((count + 1))
|
|
printf '%s\n' "$count" > "${0}.processes"
|
|
"""#,
|
|
body: #"""
|
|
while IFS= read -r request; do
|
|
id=$(printf '%s\n' "$request" | /usr/bin/sed -E 's/.*"id":([0-9]+).*/\1/')
|
|
printf '{"id":%s,"result":{"data":[]}}\n' "$id"
|
|
if [ "$count" = 1 ]; then
|
|
sleep 0.1
|
|
printf '%512s\n' x
|
|
fi
|
|
done
|
|
"""#)
|
|
let client = CodexAppServerThreadClient(
|
|
idleTimeoutSeconds: 10,
|
|
idleReadLimit: 128)
|
|
|
|
_ = try await self.requestEmptyList(
|
|
client: client,
|
|
executable: fake.executable,
|
|
maxLineBytes: 128)
|
|
try await Task.sleep(for: .milliseconds(300))
|
|
_ = try await self.requestEmptyList(
|
|
client: client,
|
|
executable: fake.executable,
|
|
maxLineBytes: 128)
|
|
|
|
#expect(try self.readTrimmed(
|
|
URL(fileURLWithPath: fake.executable.path + ".processes")) == "2")
|
|
await client.shutdown()
|
|
}
|
|
|
|
@Test func `timeout restarts the client without dropping the next request`() async throws {
|
|
let fake = try makeBlockedFirstRequestServer()
|
|
let client = CodexAppServerThreadClient(idleTimeoutSeconds: 10)
|
|
|
|
let first = Task {
|
|
try await self.requestEmptyList(
|
|
client: client,
|
|
executable: fake.executable,
|
|
timeoutSeconds: 0.5)
|
|
}
|
|
#expect(await self.waitForFile(
|
|
URL(fileURLWithPath: fake.executable.path + ".request-started")))
|
|
let second = Task {
|
|
try await self.requestEmptyList(client: client, executable: fake.executable)
|
|
}
|
|
|
|
await #expect(throws: MacNodeCodexThreadCatalog.CatalogError.timedOut) {
|
|
try await first.value
|
|
}
|
|
let result = try await second.value
|
|
#expect(try (JSONSerialization.jsonObject(with: result) as? [String: Any])?["data"] != nil)
|
|
#expect(try self.readTrimmed(
|
|
URL(fileURLWithPath: fake.executable.path + ".processes")) == "2")
|
|
await client.shutdown()
|
|
}
|
|
|
|
@Test func `queued request consumes its wall-clock deadline`() async throws {
|
|
let fake = try makeAppServer(body: #"""
|
|
IFS= read -r request || exit 4
|
|
touch "${0}.request-started"
|
|
sleep 5
|
|
"""#)
|
|
let client = CodexAppServerThreadClient(idleTimeoutSeconds: 10)
|
|
|
|
let first = Task {
|
|
try await self.requestEmptyList(
|
|
client: client,
|
|
executable: fake.executable,
|
|
timeoutSeconds: 0.5)
|
|
}
|
|
#expect(await self.waitForFile(
|
|
URL(fileURLWithPath: fake.executable.path + ".request-started")))
|
|
let second = Task {
|
|
try await self.requestEmptyList(
|
|
client: client,
|
|
executable: fake.executable,
|
|
timeoutSeconds: 0.05)
|
|
}
|
|
|
|
await #expect(throws: MacNodeCodexThreadCatalog.CatalogError.timedOut) {
|
|
try await second.value
|
|
}
|
|
await #expect(throws: MacNodeCodexThreadCatalog.CatalogError.timedOut) {
|
|
try await first.value
|
|
}
|
|
await client.shutdown()
|
|
}
|
|
|
|
@Test func `cancellation restarts the client without dropping the next request`() async throws {
|
|
let fake = try makeBlockedFirstRequestServer()
|
|
let client = CodexAppServerThreadClient(idleTimeoutSeconds: 10)
|
|
let first = Task {
|
|
try await self.requestEmptyList(
|
|
client: client,
|
|
executable: fake.executable,
|
|
timeoutSeconds: 5)
|
|
}
|
|
#expect(await self.waitForFile(
|
|
URL(fileURLWithPath: fake.executable.path + ".request-started")))
|
|
let second = Task {
|
|
try await self.requestEmptyList(client: client, executable: fake.executable)
|
|
}
|
|
|
|
first.cancel()
|
|
await #expect(throws: CancellationError.self) {
|
|
try await first.value
|
|
}
|
|
let result = try await second.value
|
|
#expect(try (JSONSerialization.jsonObject(with: result) as? [String: Any])?["data"] != nil)
|
|
#expect(try self.readTrimmed(
|
|
URL(fileURLWithPath: fake.executable.path + ".processes")) == "2")
|
|
await client.shutdown()
|
|
}
|
|
|
|
@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 makeAppServer(body: #"""
|
|
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 { withExtendedLifetime(fake) {} }
|
|
|
|
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 `drains a large final frame before handling process exit`() async throws {
|
|
let threads: [[String: Any]] = (0..<100).map { index in
|
|
[
|
|
"id": "thread-\(index)",
|
|
"name": "Final catalog \(index)",
|
|
"cwd": "/workspace/\(String(repeating: "x", count: 3500))",
|
|
"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 > 256 * 1024)
|
|
let fake = try makeAppServer(body: #"""
|
|
IFS= read -r list || exit 4
|
|
printf '%s\n' '\#(response)'
|
|
"""#)
|
|
defer { withExtendedLifetime(fake) {} }
|
|
|
|
let payload = try await MacNodeCodexThreadCatalog.list(
|
|
paramsJSON: #"{"limit":100}"#,
|
|
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 == 100)
|
|
}
|
|
|
|
@Test func `accepts coalesced JSONL frames within the per-frame limit`() async throws {
|
|
let fake = try makeAppServer(body: #"""
|
|
IFS= read -r list || exit 4
|
|
printf '%s\n%s\n' \
|
|
'{"method":"thread/started","params":{"padding":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}}' \
|
|
'{"id":2,"result":{"data":[]}}'
|
|
sleep 1
|
|
"""#)
|
|
defer { withExtendedLifetime(fake) {} }
|
|
let client = CodexAppServerThreadClient(idleTimeoutSeconds: 10)
|
|
|
|
let result = try await self.requestEmptyList(
|
|
client: client,
|
|
executable: fake.executable,
|
|
maxLineBytes: 128)
|
|
|
|
#expect(try (JSONSerialization.jsonObject(with: result) as? [String: Any])?["data"] != nil)
|
|
await client.shutdown()
|
|
}
|
|
|
|
@Test func `uses the active request frame limit after advancing the queue`() async throws {
|
|
let fake = try makeAppServer(body: #"""
|
|
IFS= read -r first || exit 4
|
|
first_id=$(printf '%s\n' "$first" | /usr/bin/sed -E 's/.*"id":([0-9]+).*/\1/')
|
|
touch "${0}.first-started"
|
|
sleep 0.1
|
|
printf '{"id":%s,"result":{"data":[]}}\n' "$first_id"
|
|
IFS= read -r second || exit 5
|
|
second_id=$(printf '%s\n' "$second" | /usr/bin/sed -E 's/.*"id":([0-9]+).*/\1/')
|
|
padding=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
printf '{"id":%s,"result":{"data":[],"padding":"%s"}}\n' "$second_id" "$padding"
|
|
sleep 1
|
|
"""#)
|
|
let client = CodexAppServerThreadClient(idleTimeoutSeconds: 10)
|
|
|
|
let first = Task {
|
|
try await self.requestEmptyList(
|
|
client: client,
|
|
executable: fake.executable,
|
|
maxLineBytes: 1024)
|
|
}
|
|
#expect(await self.waitForFile(
|
|
URL(fileURLWithPath: fake.executable.path + ".first-started")))
|
|
let second = Task {
|
|
try await self.requestEmptyList(
|
|
client: client,
|
|
executable: fake.executable,
|
|
maxLineBytes: 64)
|
|
}
|
|
|
|
_ = try await first.value
|
|
await #expect(throws: MacNodeCodexThreadCatalog.CatalogError.responseTooLarge) {
|
|
try await second.value
|
|
}
|
|
await client.shutdown()
|
|
}
|
|
|
|
@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 { withExtendedLifetime(oversized) {} }
|
|
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 { withExtendedLifetime(stalled) {} }
|
|
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 makeAppServer(
|
|
initializeResult: #"{"codexHome":"/private"}"#,
|
|
body: #"""
|
|
IFS= read -r list || exit 4
|
|
printf '%s\n' '{"id":2,"error":{"code":-32000,"message":"private /Users/secret/path"}}'
|
|
sleep 1
|
|
"""#)
|
|
defer { withExtendedLifetime(fake) {} }
|
|
|
|
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"))
|
|
}
|
|
}
|
|
}
|