Files
openclaw/apps/macos/Sources/OpenClaw/NodeMode/MacNodeCodexThreadCatalog.swift
Vincent Koc b2cba4073c improve(macos): reuse the Codex App Server client (#116325)
* perf(macos): reuse Codex app server client

* refactor(macos): split Codex app server client

* fix(macos): enforce Codex frame limits per message

* refactor(macos): remove obsolete Codex wrappers

* test(macos): lock Codex wall-clock deadlines
2026-07-31 10:40:54 +08:00

1246 lines
48 KiB
Swift

import CoreFoundation
import Foundation
enum MacNodeCodexThreadCatalogContract {
static let pluginId = "codex"
static let capability = "codex-app-server-threads"
static let listCommand = "codex.appServer.threads.list.v1"
static let turnsCommand = "codex.appServer.thread.turns.list.v1"
static let commands = [listCommand, turnsCommand]
}
enum MacNodeCodexThreadCatalog {
struct ResolvedInvocation: Equatable, Sendable {
var executable: String
var arguments: [String]
var cwd: URL?
var clearEnv: [String] = []
}
enum CatalogError: LocalizedError, Equatable {
case invalidParams(String)
case catalogDisabled
case invalidAppServerConfiguration
case codexUnavailable
case unsupportedAppServerTransport
case unsupportedAppServerHomeScope
case appServerUnavailable
case responseTooLarge
case timedOut
var errorDescription: String? {
switch self {
case let .invalidParams(message):
"INVALID_REQUEST: \(message)"
case .catalogDisabled:
"UNAVAILABLE: Codex session catalog is disabled"
case .invalidAppServerConfiguration:
"UNAVAILABLE: Codex app-server configuration is invalid"
case .codexUnavailable:
"UNAVAILABLE: Codex CLI not found"
case .unsupportedAppServerTransport:
"UNAVAILABLE: paired macOS Codex catalog supports appServer.transport stdio only"
case .unsupportedAppServerHomeScope:
"UNAVAILABLE: paired macOS Codex catalog requires appServer.homeScope user"
case .appServerUnavailable:
"UNAVAILABLE: Codex app-server request failed"
case .responseTooLarge:
"UNAVAILABLE: Codex app-server response exceeded the size limit"
case .timedOut:
"UNAVAILABLE: Codex app-server request timed out"
}
}
var isInvalidRequest: Bool {
if case .invalidParams = self {
return true
}
return false
}
}
private struct ListParams {
var cursor: String?
var limit = 50
var searchTerm: String?
var cwd: String?
}
private struct TurnParams {
var threadId: String
var cursor: String?
var limit = 20
}
private struct ConfiguredAppServer {
var transport: String?
var homeScope: String?
var command: String?
var args: [String]?
var clearEnv: [String]
}
private struct ConfiguredPlugin {
var supervisionEnabled: Bool
var appServer: ConfiguredAppServer?
}
private enum StringOverflow {
case omit
case truncate
}
private static let defaultArguments = ["app-server", "--listen", "stdio://"]
private static let commandEnvironmentKey = "OPENCLAW_CODEX_APP_SERVER_BIN"
private static let argumentsEnvironmentKey = "OPENCLAW_CODEX_APP_SERVER_ARGS"
private static let pluginConfigKeys = Set([
"codexDynamicToolsLoading",
"codexDynamicToolsExclude",
"discovery",
"computerUse",
"codexPlugins",
"supervision",
"appServer",
])
private static let appServerConfigKeys = Set([
"mode",
"transport",
"homeScope",
"command",
"args",
"url",
"authToken",
"headers",
"clearEnv",
"remoteWorkspaceRoot",
"codeModeOnly",
"requestTimeoutMs",
"turnCompletionIdleTimeoutMs",
"postToolRawAssistantCompletionIdleTimeoutMs",
"approvalPolicy",
"sandbox",
"approvalsReviewer",
"serviceTier",
"networkProxy",
"defaultWorkspaceDir",
"experimental",
])
static let defaultMacOSChatGPTAppExecutable =
"/Applications/ChatGPT.app/Contents/Resources/codex"
static let defaultUserMacOSChatGPTAppExecutable = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Applications/ChatGPT.app/Contents/Resources/codex")
.path
static let defaultMacOSAppExecutable = "/Applications/Codex.app/Contents/Resources/codex"
static let defaultUserMacOSAppExecutable = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Applications/Codex.app/Contents/Resources/codex")
.path
static let defaultMacOSBetaAppExecutable = "/Applications/Codex Beta.app/Contents/Resources/codex"
static let defaultUserMacOSBetaAppExecutable = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Applications/Codex Beta.app/Contents/Resources/codex")
.path
static let defaultTimeoutSeconds: Double = 60
static let defaultIdleTimeoutSeconds: Double = 30
private static let maxSessionIdLength = 256
private static let maxSessionNameLength = 500
private static let maxCwdLength = 4096
private static let maxStatusLength = 64
private static let maxMetadataLength = 500
private static let maxActiveFlags = 16
private static let maxActiveFlagLength = 128
private static let maxCursorLength = 4096
private static let maxSearchPageCalls = 4
private struct WireResponse: Encodable {
var sessions: [WireSession]
var nextCursor: String?
var backwardsCursor: String?
}
private struct WireSession: Encodable {
var threadId: String
var sessionId: String?
var name: String?
var cwd: String?
var status: String
var activeFlags: [String]?
var createdAt: Int64?
var updatedAt: Int64?
var recencyAt: Int64?
var source: String?
var modelProvider: String?
var cliVersion: String?
var gitBranch: String?
var archived: Bool
}
static func list(
paramsJSON: String?,
loadRoot: () -> [String: Any]) async throws -> String
{
let client = CodexAppServerThreadClient()
return try await self.withEphemeralClient(client) {
try await self.list(paramsJSON: paramsJSON, loadRoot: loadRoot, client: client)
}
}
static func list(
paramsJSON: String?,
loadRoot: () -> [String: Any],
client: CodexAppServerThreadClient) async throws -> String
{
let params = try self.decodeParams(paramsJSON)
// Keep authorization and spawn selection on one config snapshot. A second read could
// otherwise approve one command and launch another after a concurrent config rewrite.
let root = loadRoot()
guard self.shouldAdvertise(root: root) else {
throw CatalogError.catalogDisabled
}
let invocation = try self.resolveInvocation(root: root)
return try await self.list(params: params, invocation: invocation, client: client)
}
static func turns(
paramsJSON: String?,
loadRoot: () -> [String: Any],
client: CodexAppServerThreadClient) async throws -> String
{
let params = try decodeTurnParams(paramsJSON)
let root = loadRoot()
guard self.shouldAdvertise(root: root) else {
throw CatalogError.catalogDisabled
}
return try await self.turns(
params: params,
invocation: resolveInvocation(root: root),
client: client)
}
static func turns(
paramsJSON: String?,
executable: String,
arguments: [String]? = nil,
cwd: URL? = nil,
clearEnv: [String] = [],
timeoutSeconds: Double = MacNodeCodexThreadCatalog.defaultTimeoutSeconds,
maxLineBytes: Int = 20 * 1024 * 1024) async throws -> String
{
let params = try decodeTurnParams(paramsJSON)
let client = CodexAppServerThreadClient()
return try await self.withEphemeralClient(client) {
try await self.turns(
params: params,
invocation: ResolvedInvocation(
executable: executable,
arguments: arguments ?? self.defaultArguments,
cwd: cwd,
clearEnv: clearEnv),
client: client,
timeoutSeconds: timeoutSeconds,
maxLineBytes: maxLineBytes)
}
}
private static func turns(
params: TurnParams,
invocation: ResolvedInvocation,
client: CodexAppServerThreadClient,
timeoutSeconds: Double = MacNodeCodexThreadCatalog.defaultTimeoutSeconds,
maxLineBytes: Int = 20 * 1024 * 1024) async throws -> String
{
let deadline = Date().addingTimeInterval(max(0.01, timeoutSeconds))
try await self.requireCatalogThread(
params.threadId,
invocation: invocation,
client: client,
deadline: deadline)
var requestParams: [String: Any] = [
"threadId": params.threadId,
"limit": params.limit,
"sortDirection": "desc",
"itemsView": "full",
]
if let cursor = params.cursor {
requestParams["cursor"] = cursor
}
let resultData = try await client.request(
invocation: invocation,
method: "thread/turns/list",
requestParams: requestParams,
timeoutSeconds: max(0.01, deadline.timeIntervalSinceNow),
maxLineBytes: maxLineBytes)
guard let payload = String(data: resultData, encoding: .utf8) else {
throw CatalogError.appServerUnavailable
}
return payload
}
private static func requireCatalogThread(
_ threadId: String,
invocation: ResolvedInvocation,
client: CodexAppServerThreadClient,
deadline: Date) async throws
{
var cursor: String?
var seenCursors = Set<String>()
for _ in 0..<100 {
let remainingTimeout = deadline.timeIntervalSinceNow
guard remainingTimeout > 0 else { throw CatalogError.timedOut }
let payload = try await list(
params: ListParams(cursor: cursor, limit: 100),
invocation: invocation,
client: client,
timeoutSeconds: remainingTimeout)
guard let response = try JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any],
let sessions = response["sessions"] as? [[String: Any]]
else {
throw CatalogError.appServerUnavailable
}
if sessions.contains(where: { $0["threadId"] as? String == threadId }) {
return
}
guard let nextCursor = response["nextCursor"] as? String,
!nextCursor.isEmpty,
!seenCursors.contains(nextCursor)
else { break }
seenCursors.insert(nextCursor)
cursor = nextCursor
}
throw CatalogError.invalidParams(
"Codex session is not a non-archived interactive CLI or VS Code session")
}
static func shouldAdvertise(root: [String: Any]? = nil) -> Bool {
let root = root ?? OpenClawConfigFile.loadDict()
guard OpenClawConfigFile.configuredBundledPluginAllowed(
MacNodeCodexThreadCatalogContract.pluginId,
root: root)
else { return false }
let plugin: ConfiguredPlugin?
do {
plugin = try self.configuredPlugin(root: root)
} catch {
return false
}
guard plugin?.supervisionEnabled == true else { return false }
return self.supportsConfiguredTransport(plugin?.appServer) &&
self.supportsConfiguredHomeScope(plugin?.appServer)
}
static func list(
paramsJSON: String?,
executable: String,
arguments: [String]? = nil,
cwd: URL? = nil,
clearEnv: [String] = [],
timeoutSeconds: Double = MacNodeCodexThreadCatalog.defaultTimeoutSeconds,
maxLineBytes: Int = 5 * 1024 * 1024) async throws -> String
{
let params = try self.decodeParams(paramsJSON)
let client = CodexAppServerThreadClient()
return try await self.withEphemeralClient(client) {
try await self.list(
params: params,
invocation: ResolvedInvocation(
executable: executable,
arguments: arguments ?? self.defaultArguments,
cwd: cwd,
clearEnv: clearEnv),
client: client,
timeoutSeconds: timeoutSeconds,
maxLineBytes: maxLineBytes)
}
}
private static func list(
params: ListParams,
invocation: ResolvedInvocation,
client: CodexAppServerThreadClient,
timeoutSeconds: Double = MacNodeCodexThreadCatalog.defaultTimeoutSeconds,
maxLineBytes: Int = 5 * 1024 * 1024) async throws -> String
{
guard params.searchTerm != nil else {
let resultData = try await client.request(
invocation: invocation,
method: "thread/list",
requestParams: self.appServerParams(params),
timeoutSeconds: timeoutSeconds,
maxLineBytes: maxLineBytes)
return try self.normalize(listResultData: resultData)
}
// Native search also inspects transcript-derived previews. Scan a bounded
// number of unsearched pages and filter normalized titles locally instead.
let deadline = Date().addingTimeInterval(max(0.01, timeoutSeconds))
var sessions: [WireSession] = []
var cursor = params.cursor
var seenCursors = Set(cursor.map { [$0] } ?? [])
var backwardsCursor: String?
var nextCursor: String?
for pageIndex in 0..<self.maxSearchPageCalls {
let remainingLimit = params.limit - sessions.count
guard remainingLimit > 0 else { break }
let remainingTimeout = deadline.timeIntervalSinceNow
guard remainingTimeout > 0 else { throw CatalogError.timedOut }
var pageParams = params
pageParams.cursor = cursor
pageParams.limit = remainingLimit
let resultData = try await client.request(
invocation: invocation,
method: "thread/list",
requestParams: self.appServerParams(pageParams),
timeoutSeconds: remainingTimeout,
maxLineBytes: maxLineBytes)
let page = try self.normalizedResponse(
listResultData: resultData,
searchTerm: params.searchTerm)
if pageIndex == 0 {
backwardsCursor = page.backwardsCursor
}
sessions.append(contentsOf: page.sessions)
guard let candidateCursor = page.nextCursor else {
nextCursor = nil
break
}
guard !seenCursors.contains(candidateCursor) else {
// A repeated opaque cursor cannot make forward progress. Stop the
// page chain instead of handing callers a permanent load-more loop.
nextCursor = nil
break
}
nextCursor = candidateCursor
if sessions.count >= params.limit || pageIndex + 1 == self.maxSearchPageCalls {
break
}
seenCursors.insert(candidateCursor)
cursor = candidateCursor
}
return try self.encodeResponse(WireResponse(
sessions: sessions,
nextCursor: nextCursor,
backwardsCursor: backwardsCursor))
}
private static func withEphemeralClient<T>(
_ client: CodexAppServerThreadClient,
operation: () async throws -> T) async throws -> T
{
do {
let result = try await operation()
await client.shutdown()
return result
} catch {
await client.shutdown()
throw error
}
}
}
extension MacNodeCodexThreadCatalog {
static func resolveInvocation(
root: [String: Any]? = nil,
environment: [String: String] = ProcessInfo.processInfo.environment,
searchPaths: [String]? = nil,
currentDirectoryURL: URL = URL(
fileURLWithPath: FileManager.default.currentDirectoryPath,
isDirectory: true),
defaultMacOSChatGPTAppExecutable: String = MacNodeCodexThreadCatalog
.defaultMacOSChatGPTAppExecutable,
defaultUserMacOSChatGPTAppExecutable: String = MacNodeCodexThreadCatalog
.defaultUserMacOSChatGPTAppExecutable,
defaultMacOSAppExecutable: String = MacNodeCodexThreadCatalog.defaultMacOSAppExecutable,
defaultUserMacOSAppExecutable: String = MacNodeCodexThreadCatalog.defaultUserMacOSAppExecutable,
defaultMacOSBetaAppExecutable: String = MacNodeCodexThreadCatalog.defaultMacOSBetaAppExecutable,
defaultUserMacOSBetaAppExecutable: String = MacNodeCodexThreadCatalog.defaultUserMacOSBetaAppExecutable) throws
-> ResolvedInvocation
{
let root = root ?? OpenClawConfigFile.loadDict()
let appServer = try self.configuredPlugin(root: root)?.appServer
guard self.supportsConfiguredTransport(appServer) else {
throw CatalogError.unsupportedAppServerTransport
}
guard self.supportsConfiguredHomeScope(appServer) else {
throw CatalogError.unsupportedAppServerHomeScope
}
let configuredCommand = self.nonEmptyString(appServer?.command)
let environmentCommand = self.nonEmptyString(environment[self.commandEnvironmentKey])
let customCommand = configuredCommand ?? environmentCommand
let rawCommand = customCommand ?? "codex"
let command = rawCommand.trimmingCharacters(in: .whitespacesAndNewlines)
guard !command.isEmpty else { throw CatalogError.codexUnavailable }
let executable: String?
var installedAppExecutable: String?
if customCommand == nil {
installedAppExecutable = [
defaultMacOSChatGPTAppExecutable,
defaultUserMacOSChatGPTAppExecutable,
defaultMacOSAppExecutable,
defaultUserMacOSAppExecutable,
defaultMacOSBetaAppExecutable,
defaultUserMacOSBetaAppExecutable,
]
.first { FileManager.default.isExecutableFile(atPath: $0) }
}
if let installedAppExecutable {
executable = installedAppExecutable
} else if command.contains("/") || command.hasPrefix("~") {
let url = self.resolvePath(command, relativeTo: currentDirectoryURL)
executable = FileManager.default.isExecutableFile(atPath: url.path) ? url.path : nil
} else {
executable = CommandResolver.findExecutable(named: command, searchPaths: searchPaths)
}
guard let executable else { throw CatalogError.codexUnavailable }
let configuredArguments = appServer?.args ?? environment[self.argumentsEnvironmentKey].map {
self.splitShellWords($0)
}
let arguments = if let configuredArguments, !configuredArguments.isEmpty {
configuredArguments
} else {
self.defaultArguments
}
return ResolvedInvocation(
executable: executable,
arguments: arguments,
cwd: nil,
clearEnv: appServer?.clearEnv ?? [])
}
private static func supportsConfiguredTransport(_ appServer: ConfiguredAppServer?) -> Bool {
appServer?.transport == nil || appServer?.transport == "stdio"
}
private static func supportsConfiguredHomeScope(_ appServer: ConfiguredAppServer?) -> Bool {
appServer?.homeScope == nil || appServer?.homeScope == "user"
}
private static func configuredPlugin(root: [String: Any]) throws -> ConfiguredPlugin? {
guard let entry = OpenClawConfigFile.pluginEntry(
MacNodeCodexThreadCatalogContract.pluginId,
root: root)
else { return nil }
guard let rawConfig = entry["config"] else {
return ConfiguredPlugin(supervisionEnabled: false, appServer: nil)
}
guard let config = rawConfig as? [String: Any] else {
throw CatalogError.invalidAppServerConfiguration
}
try self.validateKeys(config, allowed: self.pluginConfigKeys)
try self.validateEnum(
config,
key: "codexDynamicToolsLoading",
allowed: ["searchable", "direct"])
try self.validateStringArray(config, key: "codexDynamicToolsExclude")
try self.validateDiscoveryConfig(config["discovery"])
try self.validateComputerUseConfig(config["computerUse"])
// `codexPlugins` is intentionally parsed independently by readCodexPluginConfig.
// Its validity does not decide whether supervision remains enabled.
let supervisionEnabled = try self.validateSupervisionConfig(config["supervision"])
let appServer = try self.validateAppServerConfig(config["appServer"])
return ConfiguredPlugin(
supervisionEnabled: supervisionEnabled,
appServer: appServer)
}
private static func validateAppServerConfig(_ rawValue: Any?) throws -> ConfiguredAppServer? {
guard let rawValue else { return nil }
guard let appServer = rawValue as? [String: Any] else {
throw CatalogError.invalidAppServerConfiguration
}
try self.validateKeys(appServer, allowed: self.appServerConfigKeys)
try self.validateEnum(appServer, key: "mode", allowed: ["yolo", "guardian"])
try self.validateEnum(appServer, key: "transport", allowed: ["stdio", "websocket", "unix"])
try self.validateEnum(appServer, key: "homeScope", allowed: ["agent", "user"])
try self.validateString(appServer, key: "command")
try self.validateString(appServer, key: "url")
try self.validateSecretInput(appServer["authToken"])
try self.validateHeaders(appServer["headers"])
try self.validateStringArray(appServer, key: "clearEnv")
try self.validateNonEmptyString(appServer, key: "remoteWorkspaceRoot")
try self.validateBoolean(appServer, key: "codeModeOnly")
try self.validatePositiveNumber(appServer, key: "requestTimeoutMs")
try self.validatePositiveNumber(appServer, key: "turnCompletionIdleTimeoutMs")
try self.validatePositiveNumber(
appServer,
key: "postToolRawAssistantCompletionIdleTimeoutMs")
try self.validateEnum(
appServer,
key: "approvalPolicy",
allowed: ["never", "on-request", "on-failure", "untrusted"])
try self.validateEnum(
appServer,
key: "sandbox",
allowed: ["read-only", "workspace-write", "danger-full-access"])
try self.validateEnum(
appServer,
key: "approvalsReviewer",
allowed: ["user", "auto_review", "guardian_subagent"])
try self.validateStringOrNull(appServer, key: "serviceTier")
try self.validateNetworkProxyConfig(appServer["networkProxy"])
try self.validateString(appServer, key: "defaultWorkspaceDir")
try self.validateExperimentalConfig(appServer["experimental"])
let transport = try self.optionalConfiguredString(appServer, key: "transport")
let homeScope = try self.optionalConfiguredString(appServer, key: "homeScope")
let command = try self.optionalConfiguredString(appServer, key: "command")
let args = try self.configuredArguments(appServer, key: "args")
let clearEnv = try self.configuredStringList(appServer, key: "clearEnv")
return ConfiguredAppServer(
transport: transport,
homeScope: homeScope,
command: self.nonEmptyString(command),
args: args,
clearEnv: clearEnv)
}
private static func optionalConfiguredString(
_ object: [String: Any],
key: String) throws -> String?
{
guard let value = object[key] else { return nil }
guard let value = value as? String else {
throw CatalogError.invalidAppServerConfiguration
}
return value
}
private static func configuredArguments(
_ object: [String: Any],
key: String) throws -> [String]?
{
guard let value = object[key] else { return nil }
let args: [String]
if let values = value as? [Any] {
guard values.allSatisfy({ $0 is String }) else {
throw CatalogError.invalidAppServerConfiguration
}
args = values.compactMap(self.nonEmptyString)
} else if let value = value as? String {
args = self.splitShellWords(value)
} else {
throw CatalogError.invalidAppServerConfiguration
}
return args
}
private static func configuredStringList(
_ object: [String: Any],
key: String) throws -> [String]
{
guard let value = object[key] else { return [] }
guard let values = value as? [Any], values.allSatisfy({ $0 is String }) else {
throw CatalogError.invalidAppServerConfiguration
}
return values.compactMap(self.nonEmptyString)
}
private static func validateDiscoveryConfig(_ rawValue: Any?) throws {
guard let rawValue else { return }
guard let config = rawValue as? [String: Any] else {
throw CatalogError.invalidAppServerConfiguration
}
try self.validateKeys(config, allowed: ["enabled", "timeoutMs"])
try self.validateBoolean(config, key: "enabled")
try self.validatePositiveNumber(config, key: "timeoutMs")
}
private static func validateComputerUseConfig(_ rawValue: Any?) throws {
guard let rawValue else { return }
guard let config = rawValue as? [String: Any] else {
throw CatalogError.invalidAppServerConfiguration
}
try self.validateKeys(config, allowed: [
"enabled",
"autoInstall",
"marketplaceDiscoveryTimeoutMs",
"marketplaceSource",
"marketplacePath",
"marketplaceName",
"pluginName",
"mcpServerName",
])
try self.validateBoolean(config, key: "enabled")
try self.validateBoolean(config, key: "autoInstall")
try self.validatePositiveNumber(config, key: "marketplaceDiscoveryTimeoutMs")
for key in [
"marketplaceSource",
"marketplacePath",
"marketplaceName",
"pluginName",
"mcpServerName",
] {
try self.validateString(config, key: key)
}
}
private static func validateSupervisionConfig(_ rawValue: Any?) throws -> Bool {
guard let rawValue else { return false }
guard let config = rawValue as? [String: Any] else {
throw CatalogError.invalidAppServerConfiguration
}
try self.validateKeys(config, allowed: [
"enabled",
"endpoints",
"allowRawTranscripts",
"allowWriteControls",
])
try self.validateBoolean(config, key: "enabled")
try self.validateBoolean(config, key: "allowRawTranscripts")
try self.validateBoolean(config, key: "allowWriteControls")
if let rawEndpoints = config["endpoints"] {
guard let endpoints = rawEndpoints as? [Any] else {
throw CatalogError.invalidAppServerConfiguration
}
for endpoint in endpoints {
try self.validateSupervisionEndpoint(endpoint)
}
}
return self.literalBoolean(config["enabled"]) == true
}
private static func validateSupervisionEndpoint(_ rawValue: Any) throws {
guard let endpoint = rawValue as? [String: Any] else {
throw CatalogError.invalidAppServerConfiguration
}
let transport = endpoint["transport"] as? String
if transport == nil || transport == "stdio-proxy" {
try self.validateKeys(
endpoint,
allowed: ["id", "label", "transport", "command", "args", "cwd"])
for key in ["id", "label", "command", "cwd"] {
try self.validateString(endpoint, key: key)
}
try self.validateEnum(endpoint, key: "transport", allowed: ["stdio-proxy"])
try self.validateStringArray(endpoint, key: "args")
return
}
guard transport == "websocket" else {
throw CatalogError.invalidAppServerConfiguration
}
try self.validateKeys(
endpoint,
allowed: ["id", "label", "transport", "url", "authTokenEnv"])
for key in ["id", "label", "authTokenEnv"] {
try self.validateString(endpoint, key: key)
}
guard endpoint["url"] is String else {
throw CatalogError.invalidAppServerConfiguration
}
}
private static func validateNetworkProxyConfig(_ rawValue: Any?) throws {
guard let rawValue else { return }
guard let config = rawValue as? [String: Any] else {
throw CatalogError.invalidAppServerConfiguration
}
try self.validateKeys(config, allowed: [
"enabled",
"profileName",
"baseProfile",
"mode",
"domains",
"unixSockets",
"proxyUrl",
"socksUrl",
"enableSocks5",
"enableSocks5Udp",
"allowUpstreamProxy",
"allowLocalBinding",
"dangerouslyAllowNonLoopbackProxy",
"dangerouslyAllowAllUnixSockets",
])
for key in [
"enabled",
"enableSocks5",
"enableSocks5Udp",
"allowUpstreamProxy",
"allowLocalBinding",
"dangerouslyAllowNonLoopbackProxy",
"dangerouslyAllowAllUnixSockets",
] {
try self.validateBoolean(config, key: key)
}
for key in ["profileName", "proxyUrl", "socksUrl"] {
try self.validateNonEmptyString(config, key: key)
}
try self.validateEnum(config, key: "baseProfile", allowed: ["read-only", "workspace"])
try self.validateEnum(config, key: "mode", allowed: ["limited", "full"])
try self.validateStringRecord(
config,
key: "domains",
allowedValues: ["allow", "deny"])
try self.validateStringRecord(
config,
key: "unixSockets",
allowedValues: ["allow", "none"])
}
private static func validateExperimentalConfig(_ rawValue: Any?) throws {
guard let rawValue else { return }
guard let config = rawValue as? [String: Any] else {
throw CatalogError.invalidAppServerConfiguration
}
try self.validateKeys(config, allowed: ["sandboxExecServer"])
try self.validateBoolean(config, key: "sandboxExecServer")
}
private static func validateHeaders(_ rawValue: Any?) throws {
guard let rawValue else { return }
guard let headers = rawValue as? [String: Any] else {
throw CatalogError.invalidAppServerConfiguration
}
for value in headers.values {
try self.validateSecretInput(value)
}
}
private static func validateSecretInput(_ rawValue: Any?) throws {
guard let rawValue else { return }
if rawValue is String {
return
}
guard let secret = rawValue as? [String: Any] else {
throw CatalogError.invalidAppServerConfiguration
}
try self.validateKeys(secret, allowed: ["source", "provider", "id"])
guard secret.keys.count == 3,
let source = secret["source"] as? String,
let provider = secret["provider"] as? String,
let id = secret["id"] as? String,
self.matches(provider, pattern: "^[a-z][a-z0-9_-]{0,63}$")
else {
throw CatalogError.invalidAppServerConfiguration
}
let validId = switch source {
case "env":
self.matches(id, pattern: "^[A-Z][A-Z0-9_]{0,127}$")
case "file":
self.validFileSecretId(id)
case "exec":
self.matches(id, pattern: "^[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}$") &&
!id.split(separator: "/", omittingEmptySubsequences: false)
.contains(where: { $0 == "." || $0 == ".." })
default:
false
}
guard validId else { throw CatalogError.invalidAppServerConfiguration }
}
private static func validFileSecretId(_ value: String) -> Bool {
if value == "value" {
return true
}
guard value.hasPrefix("/") else { return false }
return value.dropFirst().split(separator: "/", omittingEmptySubsequences: false)
.allSatisfy { segment in
segment.range(of: "~(?:[^01]|$)", options: .regularExpression) == nil
}
}
private static func validateKeys(
_ object: [String: Any],
allowed: Set<String>) throws
{
guard object.keys.allSatisfy(allowed.contains) else {
throw CatalogError.invalidAppServerConfiguration
}
}
private static func validateBoolean(_ object: [String: Any], key: String) throws {
guard let value = object[key] else { return }
guard self.literalBoolean(value) != nil else {
throw CatalogError.invalidAppServerConfiguration
}
}
private static func validateString(_ object: [String: Any], key: String) throws {
guard let value = object[key] else { return }
guard value is String else { throw CatalogError.invalidAppServerConfiguration }
}
private static func validateNonEmptyString(_ object: [String: Any], key: String) throws {
guard let value = object[key] else { return }
guard let value = value as? String,
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
throw CatalogError.invalidAppServerConfiguration
}
}
private static func validateStringOrNull(_ object: [String: Any], key: String) throws {
guard let value = object[key] else { return }
guard value is String || value is NSNull else {
throw CatalogError.invalidAppServerConfiguration
}
}
private static func validateEnum(
_ object: [String: Any],
key: String,
allowed: Set<String>) throws
{
guard let value = object[key] else { return }
guard let value = value as? String, allowed.contains(value) else {
throw CatalogError.invalidAppServerConfiguration
}
}
private static func validatePositiveNumber(_ object: [String: Any], key: String) throws {
guard let value = object[key] else { return }
guard let number = value as? NSNumber,
CFGetTypeID(number) != CFBooleanGetTypeID(),
number.doubleValue.isFinite,
number.doubleValue > 0
else {
throw CatalogError.invalidAppServerConfiguration
}
}
private static func validateStringArray(_ object: [String: Any], key: String) throws {
guard let value = object[key] else { return }
guard let values = value as? [Any], values.allSatisfy({ $0 is String }) else {
throw CatalogError.invalidAppServerConfiguration
}
}
private static func validateStringRecord(
_ object: [String: Any],
key: String,
allowedValues: Set<String>) throws
{
guard let value = object[key] else { return }
guard let values = value as? [String: Any],
values.values.allSatisfy({ value in
guard let value = value as? String else { return false }
return allowedValues.contains(value)
})
else {
throw CatalogError.invalidAppServerConfiguration
}
}
private static func literalBoolean(_ value: Any?) -> Bool? {
guard let number = value as? NSNumber,
CFGetTypeID(number) == CFBooleanGetTypeID()
else { return nil }
return number.boolValue
}
private static func matches(_ value: String, pattern: String) -> Bool {
value.range(of: pattern, options: .regularExpression) != nil
}
/// Match the TypeScript app-server config parser exactly: quotes only group
/// words and backslashes are ordinary characters. The result never uses a shell.
private static func splitShellWords(_ value: String) -> [String] {
var words: [String] = []
var current = ""
var activeQuote: Character?
for character in value {
if let expectedQuote = activeQuote {
if character == expectedQuote {
activeQuote = nil
} else {
current.append(character)
}
continue
}
if character == "\"" || character == "'" {
activeQuote = character
} else if character.isWhitespace {
if !current.isEmpty {
words.append(current)
current = ""
}
} else {
current.append(character)
}
}
if !current.isEmpty {
words.append(current)
}
return words
}
private static func resolvePath(
_ path: String,
relativeTo base: URL,
isDirectory: Bool = false) -> URL
{
let expanded = (path as NSString).expandingTildeInPath
if expanded.hasPrefix("/") {
return URL(fileURLWithPath: expanded, isDirectory: isDirectory).standardizedFileURL
}
return URL(fileURLWithPath: expanded, isDirectory: isDirectory, relativeTo: base)
.standardizedFileURL
}
private static func decodeParams(_ paramsJSON: String?) throws -> ListParams {
guard let paramsJSON, !paramsJSON.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return ListParams()
}
guard let data = paramsJSON.data(using: .utf8) else {
throw CatalogError.invalidParams("parameters must be valid JSON")
}
let raw: Any
do {
raw = try JSONSerialization.jsonObject(with: data)
} catch {
throw CatalogError.invalidParams("parameters must be valid JSON")
}
guard let raw = raw as? [String: Any] else {
throw CatalogError.invalidParams("parameters must be an object")
}
let allowed = Set(["cursor", "limit", "searchTerm", "cwd"])
if let unknown = raw.keys.first(where: { !allowed.contains($0) }) {
throw CatalogError.invalidParams("unknown Codex session catalog parameter: \(unknown)")
}
var params = ListParams()
params.cursor = try self.optionalString(raw, key: "cursor", maxLength: self.maxCursorLength)
params.searchTerm = try self.optionalString(
raw,
key: "searchTerm",
maxLength: self.maxSessionNameLength)
params.cwd = try self.optionalString(raw, key: "cwd", maxLength: self.maxCwdLength)
if let value = raw["limit"] {
guard let number = value as? NSNumber,
CFGetTypeID(number) != CFBooleanGetTypeID(),
number.doubleValue.rounded() == number.doubleValue,
(1...100).contains(number.intValue)
else {
throw CatalogError.invalidParams("limit must be an integer from 1 to 100")
}
params.limit = number.intValue
}
return params
}
private static func decodeTurnParams(_ paramsJSON: String?) throws -> TurnParams {
guard let paramsJSON,
let data = paramsJSON.data(using: .utf8),
let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else {
throw CatalogError.invalidParams("parameters must be a valid JSON object")
}
let allowed = Set(["threadId", "cursor", "limit"])
if let unknown = raw.keys.first(where: { !allowed.contains($0) }) {
throw CatalogError.invalidParams("unknown Codex transcript parameter: \(unknown)")
}
guard let threadId = try optionalString(
raw,
key: "threadId",
maxLength: maxSessionIdLength)
else {
throw CatalogError.invalidParams("threadId is required")
}
var params = TurnParams(threadId: threadId)
params.cursor = try self.optionalString(raw, key: "cursor", maxLength: self.maxCursorLength)
if let value = raw["limit"] {
guard let number = value as? NSNumber,
CFGetTypeID(number) != CFBooleanGetTypeID(),
number.doubleValue.rounded() == number.doubleValue,
(1...50).contains(number.intValue)
else {
throw CatalogError.invalidParams("limit must be an integer from 1 to 50")
}
params.limit = number.intValue
}
return params
}
private static func optionalString(
_ params: [String: Any],
key: String,
maxLength: Int) throws -> String?
{
guard let value = params[key] else { return nil }
guard let value = value as? String else {
throw CatalogError.invalidParams("\(key) must be a string")
}
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard trimmed.utf16.count <= maxLength else {
throw CatalogError.invalidParams("\(key) must be at most \(maxLength) characters")
}
return trimmed
}
private static func appServerParams(_ params: ListParams) -> [String: Any] {
var result: [String: Any] = [
"limit": params.limit,
"sortKey": "recency_at",
"sortDirection": "desc",
// An empty provider list means all providers. Omitting sourceKinds keeps
// Codex's stable interactive-session default.
"modelProviders": [String](),
"archived": false,
"useStateDbOnly": false,
]
if let cursor = params.cursor {
result["cursor"] = cursor
}
// Search only the normalized names below. App Server title search can
// match transcript-derived previews that the node boundary withholds.
if let cwd = params.cwd {
result["cwd"] = cwd
}
return result
}
static func normalize(
listResultData: Data,
searchTerm: String? = nil) throws -> String
{
try self.encodeResponse(self.normalizedResponse(
listResultData: listResultData,
searchTerm: searchTerm))
}
private static func normalizedResponse(
listResultData: Data,
searchTerm: String? = nil) throws -> WireResponse
{
guard let result = try JSONSerialization.jsonObject(with: listResultData) as? [String: Any],
let rawThreads = result["data"] as? [Any]
else {
throw CatalogError.appServerUnavailable
}
let sessions = rawThreads.compactMap { value -> WireSession? in
guard let thread = value as? [String: Any],
let threadId = self.boundedString(
thread["id"],
maxLength: self.maxSessionIdLength)
else { return nil }
let statusRecord = thread["status"] as? [String: Any]
let status = self.boundedString(
statusRecord?["type"],
maxLength: self.maxStatusLength) ?? "notLoaded"
let decodedActiveFlags = (statusRecord?["activeFlags"] as? [Any])?
.compactMap {
self.boundedString($0, maxLength: self.maxActiveFlagLength)
}
.prefix(self.maxActiveFlags)
let activeFlags = decodedActiveFlags?.isEmpty == false ? decodedActiveFlags : nil
let gitInfo = thread["gitInfo"] as? [String: Any]
let name = self.boundedString(
thread["name"],
maxLength: self.maxSessionNameLength,
overflow: .truncate)
if let searchTerm,
name?.range(of: searchTerm, options: [.caseInsensitive, .literal]) == nil
{
return nil
}
return WireSession(
threadId: threadId,
sessionId: self.boundedString(
thread["sessionId"],
maxLength: self.maxSessionIdLength),
name: name,
cwd: self.boundedString(thread["cwd"], maxLength: self.maxCwdLength),
status: status,
activeFlags: activeFlags.map(Array.init),
createdAt: self.integer(thread["createdAt"]),
updatedAt: self.integer(thread["updatedAt"]),
recencyAt: self.integer(thread["recencyAt"]),
source: self.sourceName(thread["source"]),
modelProvider: self.boundedString(
thread["modelProvider"],
maxLength: self.maxMetadataLength,
overflow: .truncate),
cliVersion: self.boundedString(
thread["cliVersion"],
maxLength: self.maxMetadataLength,
overflow: .truncate),
gitBranch: self.boundedString(
gitInfo?["branch"],
maxLength: self.maxMetadataLength,
overflow: .truncate),
archived: false)
}
return WireResponse(
sessions: sessions,
nextCursor: self.boundedCursor(result["nextCursor"]),
backwardsCursor: self.boundedCursor(result["backwardsCursor"]))
}
private static func encodeResponse(_ response: WireResponse) throws -> String {
let data = try JSONEncoder().encode(response)
guard let json = String(data: data, encoding: .utf8) else {
throw CatalogError.appServerUnavailable
}
return json
}
fileprivate static func nonEmptyString(_ value: Any?) -> String? {
guard let value = value as? String else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private static func boundedString(
_ value: Any?,
maxLength: Int,
overflow: StringOverflow = .omit) -> String?
{
guard let value = self.nonEmptyString(value) else { return nil }
guard value.utf16.count > maxLength else { return value }
guard case .truncate = overflow else { return nil }
return self.truncateUTF16(value, maxLength: maxLength)
}
private static func boundedCursor(_ value: Any?) -> String? {
guard let value = value as? String,
self.nonEmptyString(value) != nil,
value.utf16.count <= self.maxCursorLength
else { return nil }
// App Server cursors are opaque; do not trim or regenerate them after
// locally filtering a page by its normalized session names.
return value
}
private static func truncateUTF16(_ value: String, maxLength: Int) -> String {
var result = ""
var length = 0
for scalar in value.unicodeScalars {
let scalarLength = scalar.value > 0xFFFF ? 2 : 1
guard length + scalarLength <= maxLength else { break }
result.unicodeScalars.append(scalar)
length += scalarLength
}
return result
}
private static func integer(_ value: Any?) -> Int64? {
guard let number = value as? NSNumber,
CFGetTypeID(number) != CFBooleanGetTypeID()
else { return nil }
return number.int64Value
}
private static func sourceName(_ value: Any?) -> String? {
let raw: String? = if let source = self.nonEmptyString(value) {
source
} else if let source = value as? [String: Any],
let custom = self.nonEmptyString(source["custom"])
{
"custom:\(custom)"
} else if let source = value as? [String: Any] {
source.keys.min()
} else {
nil
}
return self.boundedString(
raw,
maxLength: self.maxMetadataLength,
overflow: .truncate)
}
}