mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 09:56:06 +00:00
* feat: show Codex transcripts in sidebar * fix: clear Codex sidebar on Gateway switch * chore: leave release notes to release workflow * chore: refresh native i18n inventory * fix: satisfy macOS Codex node lint * fix: refresh native localization inventory * docs: refresh documentation map * chore: refresh UI locale metadata
1503 lines
58 KiB
Swift
1503 lines
58 KiB
Swift
import CoreFoundation
|
|
import Darwin
|
|
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 {
|
|
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
|
|
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?) async throws -> String {
|
|
try await self.list(paramsJSON: paramsJSON) {
|
|
OpenClawConfigFile.loadDict()
|
|
}
|
|
}
|
|
|
|
static func turns(paramsJSON: String?) async throws -> String {
|
|
let params = try decodeTurnParams(paramsJSON)
|
|
let root = OpenClawConfigFile.loadDict()
|
|
guard self.shouldAdvertise(root: root) else {
|
|
throw CatalogError.catalogDisabled
|
|
}
|
|
return try await self.turns(params: params, invocation: resolveInvocation(root: root))
|
|
}
|
|
|
|
static func list(
|
|
paramsJSON: String?,
|
|
loadRoot: () -> [String: Any]) 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)
|
|
}
|
|
|
|
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)
|
|
return try await self.turns(
|
|
params: params,
|
|
invocation: ResolvedInvocation(
|
|
executable: executable,
|
|
arguments: arguments ?? self.defaultArguments,
|
|
cwd: cwd,
|
|
clearEnv: clearEnv),
|
|
timeoutSeconds: timeoutSeconds,
|
|
maxLineBytes: maxLineBytes)
|
|
}
|
|
|
|
private static func turns(
|
|
params: TurnParams,
|
|
invocation: ResolvedInvocation,
|
|
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,
|
|
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 session = try CodexAppServerThreadRequestSession(
|
|
invocation: invocation,
|
|
method: "thread/turns/list",
|
|
requestParams: requestParams,
|
|
timeoutSeconds: max(0.01, deadline.timeIntervalSinceNow),
|
|
maxLineBytes: maxLineBytes)
|
|
let output = try await session.run()
|
|
guard let payload = String(data: output.resultData, encoding: .utf8) else {
|
|
throw CatalogError.appServerUnavailable
|
|
}
|
|
return payload
|
|
}
|
|
|
|
private static func requireCatalogThread(
|
|
_ threadId: String,
|
|
invocation: ResolvedInvocation,
|
|
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,
|
|
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)
|
|
return try await self.list(
|
|
params: params,
|
|
invocation: ResolvedInvocation(
|
|
executable: executable,
|
|
arguments: arguments ?? self.defaultArguments,
|
|
cwd: cwd,
|
|
clearEnv: clearEnv),
|
|
timeoutSeconds: timeoutSeconds,
|
|
maxLineBytes: maxLineBytes)
|
|
}
|
|
|
|
private static func list(
|
|
params: ListParams,
|
|
invocation: ResolvedInvocation,
|
|
timeoutSeconds: Double = MacNodeCodexThreadCatalog.defaultTimeoutSeconds,
|
|
maxLineBytes: Int = 5 * 1024 * 1024) async throws -> String
|
|
{
|
|
guard params.searchTerm != nil else {
|
|
let session = try CodexAppServerThreadRequestSession(
|
|
invocation: invocation,
|
|
method: "thread/list",
|
|
requestParams: self.appServerParams(params),
|
|
timeoutSeconds: timeoutSeconds,
|
|
maxLineBytes: maxLineBytes)
|
|
let output = try await session.run()
|
|
return try self.normalize(listResultData: output.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 session = try CodexAppServerThreadRequestSession(
|
|
invocation: invocation,
|
|
method: "thread/list",
|
|
requestParams: self.appServerParams(pageParams),
|
|
timeoutSeconds: remainingTimeout,
|
|
maxLineBytes: maxLineBytes)
|
|
let output = try await session.run()
|
|
let page = try self.normalizedResponse(
|
|
listResultData: output.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))
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
private final class CodexAppServerThreadRequestSession: @unchecked Sendable {
|
|
struct Output {
|
|
var resultData: Data
|
|
}
|
|
|
|
private enum Phase {
|
|
case initialize
|
|
case request
|
|
}
|
|
|
|
private let process = Process()
|
|
private let stdinPipe = Pipe()
|
|
private let stdoutPipe = Pipe()
|
|
private let stderrPipe = Pipe()
|
|
private let queue = DispatchQueue(label: "ai.openclaw.codex-thread-catalog")
|
|
private let requestData: Data
|
|
private let timeoutSeconds: Double
|
|
private let maxLineBytes: Int
|
|
private var continuation: CheckedContinuation<Output, Error>?
|
|
private var timer: DispatchSourceTimer?
|
|
private var stdoutBuffer = Data()
|
|
private var phase = Phase.initialize
|
|
private var finished = false
|
|
private var launched = false
|
|
|
|
private struct ReadChunk {
|
|
var data: Data
|
|
var reachedEOF: Bool
|
|
}
|
|
|
|
init(
|
|
invocation: MacNodeCodexThreadCatalog.ResolvedInvocation,
|
|
method: String,
|
|
requestParams: [String: Any],
|
|
timeoutSeconds: Double,
|
|
maxLineBytes: Int) throws
|
|
{
|
|
self.process.executableURL = URL(fileURLWithPath: invocation.executable)
|
|
self.process.arguments = invocation.arguments
|
|
self.process.currentDirectoryURL = invocation.cwd
|
|
var environment = ProcessInfo.processInfo.environment
|
|
environment["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
|
for key in invocation.clearEnv {
|
|
environment.removeValue(forKey: key)
|
|
}
|
|
self.process.environment = environment
|
|
self.process.standardInput = self.stdinPipe
|
|
self.process.standardOutput = self.stdoutPipe
|
|
self.process.standardError = self.stderrPipe
|
|
self.timeoutSeconds = max(0.01, timeoutSeconds)
|
|
self.maxLineBytes = max(1, maxLineBytes)
|
|
self.requestData = try Self.jsonData([
|
|
"id": 2,
|
|
"method": method,
|
|
"params": requestParams,
|
|
])
|
|
}
|
|
|
|
func run() async throws -> Output {
|
|
try await withTaskCancellationHandler {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
self.queue.async {
|
|
self.start(continuation)
|
|
}
|
|
}
|
|
} onCancel: {
|
|
self.queue.async {
|
|
self.finish(.failure(CancellationError()))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func start(_ continuation: CheckedContinuation<Output, Error>) {
|
|
guard !self.finished else {
|
|
continuation.resume(throwing: CancellationError())
|
|
return
|
|
}
|
|
self.continuation = continuation
|
|
// DispatchSource readability callbacks may be followed by future drain
|
|
// loops. Keep both pipes non-blocking so an open App Server cannot stall
|
|
// the catalog handshake after emitting one JSON-RPC frame.
|
|
Self.setNonBlocking(self.stdoutPipe.fileHandleForReading)
|
|
Self.setNonBlocking(self.stderrPipe.fileHandleForReading)
|
|
self.stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
|
guard let session = self else { return }
|
|
session.queue.async { [session] in
|
|
session.drainStdout(from: handle)
|
|
}
|
|
}
|
|
// Drain stderr so the child cannot block. App Server stderr is deliberately
|
|
// not forwarded over the Gateway because it may contain local paths.
|
|
self.stderrPipe.fileHandleForReading.readabilityHandler = { handle in
|
|
if Self.drainAvailable(from: handle) {
|
|
handle.readabilityHandler = nil
|
|
}
|
|
}
|
|
self.process.terminationHandler = { [weak self] _ in
|
|
guard let session = self else { return }
|
|
session.queue.async { [session] in
|
|
// A short-lived App Server can exit before its readability callback
|
|
// is admitted. Drain its final frame before projecting termination.
|
|
session.drainStdout(from: session.stdoutPipe.fileHandleForReading)
|
|
guard !session.finished else { return }
|
|
session.finish(.failure(MacNodeCodexThreadCatalog.CatalogError.appServerUnavailable))
|
|
}
|
|
}
|
|
|
|
let timer = DispatchSource.makeTimerSource(queue: self.queue)
|
|
timer.schedule(deadline: .now() + self.timeoutSeconds)
|
|
timer.setEventHandler { [weak self] in
|
|
self?.finish(.failure(MacNodeCodexThreadCatalog.CatalogError.timedOut))
|
|
}
|
|
self.timer = timer
|
|
timer.resume()
|
|
|
|
do {
|
|
try self.process.run()
|
|
self.launched = true
|
|
try self.write(Self.initializeRequestData())
|
|
} catch {
|
|
self.finish(.failure(MacNodeCodexThreadCatalog.CatalogError.appServerUnavailable))
|
|
}
|
|
}
|
|
|
|
private func drainStdout(from handle: FileHandle) {
|
|
guard !self.finished else { return }
|
|
let chunk = Self.readAvailable(from: handle, maxBytes: self.maxLineBytes)
|
|
if chunk.reachedEOF {
|
|
handle.readabilityHandler = nil
|
|
}
|
|
guard !chunk.data.isEmpty else { return }
|
|
self.consumeStdout(chunk.data)
|
|
}
|
|
|
|
private func consumeStdout(_ data: Data) {
|
|
guard !self.finished else { return }
|
|
self.stdoutBuffer.append(data)
|
|
guard self.stdoutBuffer.count <= self.maxLineBytes else {
|
|
self.finish(.failure(MacNodeCodexThreadCatalog.CatalogError.responseTooLarge))
|
|
return
|
|
}
|
|
|
|
while let newline = self.stdoutBuffer.firstIndex(of: 0x0A) {
|
|
let line = self.stdoutBuffer.prefix(upTo: newline)
|
|
self.stdoutBuffer.removeSubrange(...newline)
|
|
guard !line.isEmpty else { continue }
|
|
self.handleLine(Data(line))
|
|
if self.finished {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleLine(_ data: Data) {
|
|
guard let message = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let id = (message["id"] as? NSNumber)?.intValue
|
|
else { return }
|
|
|
|
if message["error"] is [String: Any] {
|
|
self.finish(.failure(MacNodeCodexThreadCatalog.CatalogError.appServerUnavailable))
|
|
return
|
|
}
|
|
|
|
switch (self.phase, id) {
|
|
case (.initialize, 1):
|
|
guard message["result"] is [String: Any] else {
|
|
self.finish(.failure(MacNodeCodexThreadCatalog.CatalogError.appServerUnavailable))
|
|
return
|
|
}
|
|
self.phase = .request
|
|
do {
|
|
try self.write(Self.initializedNotificationData())
|
|
try self.write(self.requestData)
|
|
} catch {
|
|
self.finish(.failure(MacNodeCodexThreadCatalog.CatalogError.appServerUnavailable))
|
|
}
|
|
case (.request, 2):
|
|
guard let result = message["result"] as? [String: Any],
|
|
let resultData = try? Self.jsonData(result)
|
|
else {
|
|
self.finish(.failure(MacNodeCodexThreadCatalog.CatalogError.appServerUnavailable))
|
|
return
|
|
}
|
|
self.finish(.success(Output(resultData: resultData)))
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func write(_ data: Data) throws {
|
|
var frame = data
|
|
frame.append(0x0A)
|
|
try self.stdinPipe.fileHandleForWriting.write(contentsOf: frame)
|
|
}
|
|
|
|
private func finish(_ result: Result<Output, Error>) {
|
|
guard !self.finished else { return }
|
|
self.finished = true
|
|
self.timer?.cancel()
|
|
self.timer = nil
|
|
self.stdoutPipe.fileHandleForReading.readabilityHandler = nil
|
|
self.stderrPipe.fileHandleForReading.readabilityHandler = nil
|
|
try? self.stdinPipe.fileHandleForWriting.close()
|
|
if self.launched, self.process.isRunning {
|
|
self.process.terminate()
|
|
}
|
|
guard let continuation = self.continuation else { return }
|
|
self.continuation = nil
|
|
continuation.resume(with: result)
|
|
}
|
|
|
|
private static func initializeRequestData() throws -> Data {
|
|
try self.jsonData([
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": [
|
|
"clientInfo": [
|
|
"name": "openclaw_macos",
|
|
"title": "OpenClaw macOS Node",
|
|
"version": GatewayEnvironment.appVersionString() ?? "unknown",
|
|
],
|
|
"capabilities": ["experimentalApi": true],
|
|
],
|
|
])
|
|
}
|
|
|
|
private static func initializedNotificationData() throws -> Data {
|
|
try self.jsonData(["method": "initialized"])
|
|
}
|
|
|
|
private static func jsonData(_ object: Any) throws -> Data {
|
|
try JSONSerialization.data(withJSONObject: object)
|
|
}
|
|
|
|
private static func readAvailable(from handle: FileHandle, maxBytes: Int) -> ReadChunk {
|
|
// FileHandle.read(upToCount:) can wait for EOF despite a readability callback.
|
|
// The descriptor is non-blocking, so drain one complete JSONL frame (or
|
|
// the response cap plus one byte) without waiting for the App Server to exit.
|
|
var data = Data()
|
|
let captureLimit = maxBytes == Int.max ? Int.max : maxBytes + 1
|
|
var buffer = [UInt8](repeating: 0, count: 64 * 1024)
|
|
while true {
|
|
let count = buffer.withUnsafeMutableBytes { bytes in
|
|
Darwin.read(handle.fileDescriptor, bytes.baseAddress, bytes.count)
|
|
}
|
|
if count > 0 {
|
|
let remaining = max(0, captureLimit - data.count)
|
|
data.append(contentsOf: buffer.prefix(min(count, remaining)))
|
|
if data.count > maxBytes {
|
|
return ReadChunk(data: data, reachedEOF: false)
|
|
}
|
|
continue
|
|
}
|
|
if count == 0 {
|
|
return ReadChunk(data: data, reachedEOF: true)
|
|
}
|
|
if errno == EINTR { continue }
|
|
if errno == EAGAIN || errno == EWOULDBLOCK {
|
|
return ReadChunk(data: data, reachedEOF: false)
|
|
}
|
|
return ReadChunk(data: data, reachedEOF: true)
|
|
}
|
|
}
|
|
|
|
private static func drainAvailable(from handle: FileHandle) -> Bool {
|
|
var buffer = [UInt8](repeating: 0, count: 64 * 1024)
|
|
while true {
|
|
let count = buffer.withUnsafeMutableBytes { bytes in
|
|
Darwin.read(handle.fileDescriptor, bytes.baseAddress, bytes.count)
|
|
}
|
|
if count > 0 {
|
|
continue
|
|
}
|
|
if count == 0 {
|
|
return true
|
|
}
|
|
if errno == EINTR { continue }
|
|
if errno == EAGAIN || errno == EWOULDBLOCK {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
private static func setNonBlocking(_ handle: FileHandle) {
|
|
let descriptor = handle.fileDescriptor
|
|
let flags = Darwin.fcntl(descriptor, F_GETFL)
|
|
if flags >= 0 {
|
|
_ = Darwin.fcntl(descriptor, F_SETFL, flags | O_NONBLOCK)
|
|
}
|
|
}
|
|
}
|