mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-16 23:52:40 -06:00
1355 lines
57 KiB
Swift
1355 lines
57 KiB
Swift
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")
|
|
}
|
|
|
|
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,
|
|
terminatesOnSignal: 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 terminatesOnSignal {
|
|
preamble.append(#"trap 'touch "${0}.terminated"; exit 0' TERM"#)
|
|
}
|
|
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
|
|
"""#
|
|
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 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,
|
|
terminatesOnSignal: true)
|
|
let second = try makeEmptyListServer(tracksLaunches: true)
|
|
let client = CodexAppServerThreadClient(idleTimeoutSeconds: 10)
|
|
|
|
_ = try await self.requestEmptyList(client: client, executable: first.executable)
|
|
_ = try await self.requestEmptyList(client: client, executable: second.executable)
|
|
|
|
#expect(try self.readTrimmed(
|
|
URL(fileURLWithPath: first.executable.path + ".processes")) == "1")
|
|
#expect(try self.readTrimmed(
|
|
URL(fileURLWithPath: second.executable.path + ".processes")) == "1")
|
|
#expect(await self.waitForFile(
|
|
URL(fileURLWithPath: first.executable.path + ".terminated")))
|
|
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 `shuts down an idle lifecycle client`() async throws {
|
|
let fake = try makeEmptyListServer(terminatesOnSignal: true)
|
|
let client = CodexAppServerThreadClient(idleTimeoutSeconds: 0.05)
|
|
|
|
_ = try await self.requestEmptyList(client: client, executable: fake.executable)
|
|
|
|
#expect(await self.waitForFile(
|
|
URL(fileURLWithPath: fake.executable.path + ".terminated")))
|
|
await client.shutdown()
|
|
}
|
|
|
|
@Test func `client deinit terminates its owned child`() async throws {
|
|
let fake = try makeEmptyListServer(terminatesOnSignal: true)
|
|
var client: CodexAppServerThreadClient? = CodexAppServerThreadClient(
|
|
idleTimeoutSeconds: 10)
|
|
|
|
_ = try await self.requestEmptyList(
|
|
client: #require(client),
|
|
executable: fake.executable)
|
|
client = nil
|
|
|
|
#expect(await self.waitForFile(
|
|
URL(fileURLWithPath: fake.executable.path + ".terminated")))
|
|
}
|
|
|
|
@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"))
|
|
}
|
|
}
|
|
}
|