diff --git a/.github/codeql/codeql-network-runtime-boundary-critical-quality.yml b/.github/codeql/codeql-network-runtime-boundary-critical-quality.yml index 95fccf6cbb0b..db48c398884e 100644 --- a/.github/codeql/codeql-network-runtime-boundary-critical-quality.yml +++ b/.github/codeql/codeql-network-runtime-boundary-critical-quality.yml @@ -14,7 +14,7 @@ paths: - src/infra/push-apns-http2.ts - src/infra/ssh-tunnel.ts - src/proxy-capture - - extensions/codex-supervisor/src/json-rpc-client.ts + - extensions/codex/src/app-server/transport-websocket.ts - extensions/irc/src - extensions/qa-lab/src - packages/net-policy/src diff --git a/.github/codeql/codeql-process-exec-boundary-critical-security.yml b/.github/codeql/codeql-process-exec-boundary-critical-security.yml index 0a2bdae7451b..3041aa3d6e0a 100644 --- a/.github/codeql/codeql-process-exec-boundary-critical-security.yml +++ b/.github/codeql/codeql-process-exec-boundary-critical-security.yml @@ -28,7 +28,6 @@ paths: - extensions/codex/src/app-server/sandbox-exec-server - extensions/codex/src/app-server/transport-stdio.ts - extensions/codex/src/node-cli-sessions.ts - - extensions/codex-supervisor/src/json-rpc-client.ts - extensions/file-transfer/src - extensions/google-meet/src - extensions/imessage/src diff --git a/.github/codeql/openclaw-boundary/queries/raw-socket-callsite-classification.ql b/.github/codeql/openclaw-boundary/queries/raw-socket-callsite-classification.ql index 2360cabd1382..8c5e7176097b 100644 --- a/.github/codeql/openclaw-boundary/queries/raw-socket-callsite-classification.ql +++ b/.github/codeql/openclaw-boundary/queries/raw-socket-callsite-classification.ql @@ -76,7 +76,7 @@ predicate allowedRawSocketClientCall(Expr call) { or allowedOwnerScope(call, "src/proxy-capture/proxy-server.ts", "startDebugProxyServer") or - allowedOwnerScope(call, "extensions/codex-supervisor/src/json-rpc-client.ts", "connectCodexSupervisorUnixSocket") + allowedOwnerScope(call, "extensions/codex/src/app-server/transport-websocket.ts", "connectCodexAppServerUnixSocket") or allowedOwnerScope(call, "extensions/irc/src/client.ts", "connectIrcClient") or diff --git a/.github/labeler.yml b/.github/labeler.yml index 5e507fdf807b..81e96db72432 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -444,12 +444,11 @@ - changed-files: - any-glob-to-any-file: - "extensions/codex/**" -"extensions: codex-supervisor": - - changed-files: - - any-glob-to-any-file: - - "extensions/codex-supervisor/**" - - "docs/plugins/reference/codex-supervisor.md" - - "docs/specs/claw-supervisor.md" + - "docs/plugins/codex-harness.md" + - "docs/plugins/codex-harness-reference.md" + - "docs/plugins/codex-harness-runtime.md" + - "docs/plugins/codex-supervision.md" + - "docs/specs/codex-supervision.md" "extensions: copilot": - changed-files: - any-glob-to-any-file: diff --git a/.github/workflows/codeql-critical-quality.yml b/.github/workflows/codeql-critical-quality.yml index 1c5444470193..2d84fef1877d 100644 --- a/.github/workflows/codeql-critical-quality.yml +++ b/.github/workflows/codeql-critical-quality.yml @@ -306,7 +306,7 @@ jobs: case "${file}" in src/**/*.test.ts|src/**/*.test.tsx|extensions/**/*.test.ts|extensions/**/*.test.tsx) ;; - packages/net-policy/src/*|packages/net-policy/src/**/*|src/cli/gateway-cli/run-loop.ts|src/infra/net/*|src/infra/net/**/*|src/infra/ssh-tunnel.ts|src/infra/gateway-lock.ts|src/infra/jsonl-socket.ts|src/infra/push-apns-http2.ts|src/proxy-capture/*|src/proxy-capture/**/*|extensions/codex-supervisor/src/json-rpc-client.ts|extensions/irc/src/*|extensions/qa-lab/src/*) + packages/net-policy/src/*|packages/net-policy/src/**/*|src/cli/gateway-cli/run-loop.ts|src/infra/net/*|src/infra/net/**/*|src/infra/ssh-tunnel.ts|src/infra/gateway-lock.ts|src/infra/jsonl-socket.ts|src/infra/push-apns-http2.ts|src/proxy-capture/*|src/proxy-capture/**/*|extensions/codex/src/app-server/transport-websocket.ts|extensions/irc/src/*|extensions/qa-lab/src/*) network_runtime=true ;; esac @@ -434,6 +434,7 @@ jobs: submodules: false - name: Fast PR network boundary diff scan + id: network-diff-scan if: ${{ github.event_name == 'pull_request' }} env: GH_TOKEN: ${{ github.token }} @@ -442,10 +443,15 @@ jobs: run: | set -euo pipefail + changed_files="$(mktemp)" added_lines="$(mktemp)" + raw_socket_scan_lines="$(mktemp)" + codex_transport="extensions/codex/src/app-server/transport-websocket.ts" + + gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" --jq '.[].filename' > "$changed_files" gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" --jq ' .[] - | select(.filename | test("^(src/cli/gateway-cli/run-loop\\.ts|src/infra/(gateway-lock|jsonl-socket|push-apns-http2|ssh-tunnel)\\.ts|src/infra/net/|src/proxy-capture/|extensions/codex-supervisor/src/json-rpc-client\\.ts|extensions/irc/src/|extensions/qa-lab/src/|packages/net-policy/src/)")) + | select(.filename | test("^(src/cli/gateway-cli/run-loop\\.ts|src/infra/(gateway-lock|jsonl-socket|push-apns-http2|ssh-tunnel)\\.ts|src/infra/net/|src/proxy-capture/|extensions/codex/src/app-server/transport-websocket\\.ts|extensions/irc/src/|extensions/qa-lab/src/|packages/net-policy/src/)")) | select(.filename | test("(^|/)[^/]+\\.(?:e2e\\.)?test\\.tsx?$") | not) | .filename as $file | (.patch // "") @@ -454,13 +460,35 @@ jobs: | "\($file): \(.)" ' > "$added_lines" - if grep -En '(from|require\().*["'\''](node:)?(net|tls|http2)["'\'']|\b(net|tls|http2)\.(connect|createConnection)\b|new Socket\(|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|GLOBAL_AGENT_|OPENCLAW_PROXY_' "$added_lines"; then + gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" --jq ' + .[] + | select(.filename | test("^(src/cli/gateway-cli/run-loop\\.ts|src/infra/(gateway-lock|jsonl-socket|push-apns-http2|ssh-tunnel)\\.ts|src/infra/net/|src/proxy-capture/|extensions/codex/src/app-server/transport-websocket\\.ts|extensions/irc/src/|extensions/qa-lab/src/|packages/net-policy/src/)")) + | select(.filename != "extensions/codex/src/app-server/transport-websocket.ts") + | select(.filename | test("(^|/)[^/]+\\.(?:e2e\\.)?test\\.tsx?$") | not) + | .filename as $file + | (.patch // "") + | split("\n")[] + | select(startswith("+") and (startswith("+++") | not)) + | "\($file): \(.)" + ' > "$raw_socket_scan_lines" + + if grep -Fxq "$codex_transport" "$changed_files"; then + # This transport has an exact owner/function allowlist in the raw-socket + # CodeQL query, so its raw socket calls run there. Proxy policy tokens + # remain in the fast scan below. + echo "full_codeql=true" >> "$GITHUB_OUTPUT" + else + echo "full_codeql=false" >> "$GITHUB_OUTPUT" + fi + + if grep -En 'HTTP_PROXY|HTTPS_PROXY|NO_PROXY|GLOBAL_AGENT_|OPENCLAW_PROXY_' "$added_lines" || \ + grep -En '(from|require\().*["'\''](node:)?(net|tls|http2)["'\'']|\b(net|tls|http2)\.(connect|createConnection)\b|new Socket\(' "$raw_socket_scan_lines"; then echo "Network runtime boundary-sensitive added lines require full CodeQL review." >&2 exit 1 fi - name: Initialize CodeQL - if: ${{ github.event_name != 'pull_request' }} + if: ${{ github.event_name != 'pull_request' || steps.network-diff-scan.outputs.full_codeql == 'true' }} uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: javascript-typescript @@ -468,14 +496,14 @@ jobs: - name: Analyze id: analyze - if: ${{ github.event_name != 'pull_request' }} + if: ${{ github.event_name != 'pull_request' || steps.network-diff-scan.outputs.full_codeql == 'true' }} uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: output: sarif-results category: "/codeql-critical-quality/network-runtime-boundary" - name: Fail on network runtime boundary findings - if: ${{ github.event_name != 'pull_request' }} + if: ${{ github.event_name != 'pull_request' || steps.network-diff-scan.outputs.full_codeql == 'true' }} env: SARIF_OUTPUT: sarif-results run: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 38354270be8f..afb3d200ab10 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -25,7 +25,6 @@ on: - "extensions/codex/src/app-server/sandbox-exec-server/**" - "extensions/codex/src/app-server/transport-stdio.ts" - "extensions/codex/src/node-cli-sessions.ts" - - "extensions/codex-supervisor/src/json-rpc-client.ts" - "extensions/file-transfer/src/**" - "extensions/google-meet/src/**" - "extensions/imessage/src/**" diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 1731b20c44e1..f042cafe1e56 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -18907,7 +18907,7 @@ }, { "kind": "conditional-branch", - "line": 936, + "line": 935, "path": "apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift", "source": "approvalSource requires matching systemRunPlan", "surface": "apple", @@ -18915,7 +18915,7 @@ }, { "kind": "conditional-branch", - "line": 936, + "line": 935, "path": "apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift", "source": "explicit approval requires matching systemRunPlan", "surface": "apple", @@ -18963,7 +18963,7 @@ }, { "kind": "conditional-branch", - "line": 246, + "line": 250, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "The Gateway setup request failed.", "surface": "apple", @@ -18971,7 +18971,7 @@ }, { "kind": "conditional-branch", - "line": 246, + "line": 250, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "The Gateway setup request failed. Show details to inspect or copy the error.", "surface": "apple", @@ -18979,7 +18979,7 @@ }, { "kind": "conditional-branch", - "line": 601, + "line": 619, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "\\(label) couldn’t complete the test.", "surface": "apple", @@ -18987,7 +18987,7 @@ }, { "kind": "conditional-branch", - "line": 601, + "line": 619, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "\\(label) couldn’t complete the test. Show details to inspect or copy the error.", "surface": "apple", @@ -18995,7 +18995,7 @@ }, { "kind": "ui-call", - "line": 716, + "line": 734, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Looking for AI you already use…", "surface": "apple", @@ -19003,7 +19003,7 @@ }, { "kind": "ui-call", - "line": 718, + "line": 736, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Checking for Claude Code, Codex, Gemini, and saved API keys.", "surface": "apple", @@ -19011,7 +19011,7 @@ }, { "kind": "ui-named-argument", - "line": 748, + "line": 766, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Couldn’t check this Mac for AI accounts", "surface": "apple", @@ -19019,7 +19019,7 @@ }, { "kind": "ui-named-argument", - "line": 760, + "line": 778, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Couldn’t load the full provider list", "surface": "apple", @@ -19027,7 +19027,7 @@ }, { "kind": "ui-named-argument", - "line": 763, + "line": 781, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Try again", "surface": "apple", @@ -19035,7 +19035,7 @@ }, { "kind": "ui-named-argument", - "line": 771, + "line": 789, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "None of the found options worked", "surface": "apple", @@ -19043,7 +19043,7 @@ }, { "kind": "ui-named-argument-multiline", - "line": 772, + "line": 790, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "The details are listed on each option above. You can fix the login and retry, or connect with an API key or token below.", "surface": "apple", @@ -19051,7 +19051,7 @@ }, { "kind": "ui-call", - "line": 793, + "line": 811, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Need help? Chat with Crestodian", "surface": "apple", @@ -19059,7 +19059,7 @@ }, { "kind": "ui-call", - "line": 808, + "line": 826, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Your AI is ready", "surface": "apple", @@ -19067,7 +19067,7 @@ }, { "kind": "ui-call", - "line": 819, + "line": 837, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Setup details", "surface": "apple", @@ -19075,7 +19075,7 @@ }, { "kind": "ui-call", - "line": 833, + "line": 851, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Copy setup details", "surface": "apple", @@ -19083,7 +19083,7 @@ }, { "kind": "ui-call", - "line": 848, + "line": 866, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "No AI accounts found on this Mac", "surface": "apple", @@ -19091,7 +19091,7 @@ }, { "kind": "ui-call-concatenated", - "line": 850, + "line": 868, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "That’s fine — you can connect one with an API key or token. If you use Claude Code, Codex, or the Gemini CLI on this Mac, sign in there first and hit “Check again”.", "surface": "apple", @@ -19099,7 +19099,7 @@ }, { "kind": "ui-named-argument", - "line": 985, + "line": 1003, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "No key-based providers are available", "surface": "apple", @@ -19107,7 +19107,7 @@ }, { "kind": "ui-named-argument", - "line": 986, + "line": 1004, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Enable or install a text-inference provider plugin on this Gateway, then check again.", "surface": "apple", @@ -19115,7 +19115,7 @@ }, { "kind": "ui-named-argument", - "line": 988, + "line": 1006, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Check again", "surface": "apple", @@ -19123,7 +19123,7 @@ }, { "kind": "ui-call", - "line": 1000, + "line": 1018, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Connect with an API key or token instead…", "surface": "apple", @@ -19131,7 +19131,7 @@ }, { "kind": "ui-call", - "line": 1011, + "line": 1029, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Connect with an API key or token", "surface": "apple", @@ -19139,7 +19139,7 @@ }, { "kind": "ui-call", - "line": 1014, + "line": 1032, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Provider", "surface": "apple", @@ -19147,7 +19147,7 @@ }, { "kind": "ui-call", - "line": 1022, + "line": 1040, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "API key or token", "surface": "apple", @@ -19155,7 +19155,7 @@ }, { "kind": "ui-call", - "line": 1034, + "line": 1052, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Connect", "surface": "apple", @@ -19163,7 +19163,7 @@ }, { "kind": "ui-named-argument", - "line": 1047, + "line": 1065, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "That key didn’t work", "surface": "apple", @@ -19171,7 +19171,7 @@ }, { "kind": "ui-call", - "line": 1073, + "line": 1091, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Crestodian — setup helper", "surface": "apple", @@ -19179,7 +19179,7 @@ }, { "kind": "ui-call", - "line": 1076, + "line": 1094, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Done", "surface": "apple", @@ -19187,7 +19187,7 @@ }, { "kind": "ui-call", - "line": 1137, + "line": 1155, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Open help…", "surface": "apple", @@ -19195,7 +19195,7 @@ }, { "kind": "conditional-branch", - "line": 1176, + "line": 1194, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Hide details", "surface": "apple", @@ -19203,7 +19203,7 @@ }, { "kind": "conditional-branch", - "line": 1176, + "line": 1194, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Show details", "surface": "apple", @@ -19211,7 +19211,7 @@ }, { "kind": "ui-call", - "line": 1199, + "line": 1217, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Copy error", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeCodexThreadCatalog.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeCodexThreadCatalog.swift index ce04c581b2af..684e99790e7f 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeCodexThreadCatalog.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeCodexThreadCatalog.swift @@ -3,7 +3,7 @@ import Darwin import Foundation enum MacNodeCodexThreadCatalogContract { - static let pluginId = "codex-supervisor" + static let pluginId = "codex" static let capability = "codex-app-server-threads" static let listCommand = "codex.appServer.threads.list.v1" } @@ -13,11 +13,16 @@ enum MacNodeCodexThreadCatalog { 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 @@ -26,8 +31,16 @@ enum MacNodeCodexThreadCatalog { 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 thread list failed" case .responseTooLarge: @@ -48,15 +61,21 @@ enum MacNodeCodexThreadCatalog { private struct ListParams { var cursor: String? var limit = 50 - var archived = false var searchTerm: String? var cwd: String? } - private struct ConfiguredStdioEndpoint { + private struct ConfiguredAppServer { + var transport: String? + var homeScope: String? var command: String? var args: [String]? - var cwd: String? + var clearEnv: [String] + } + + private struct ConfiguredPlugin { + var supervisionEnabled: Bool + var appServer: ConfiguredAppServer? } private enum StringOverflow { @@ -65,7 +84,53 @@ enum MacNodeCodexThreadCatalog { } 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 private static let maxSessionIdLength = 256 private static let maxSessionNameLength = 500 private static let maxCwdLength = 4096 @@ -74,6 +139,7 @@ enum MacNodeCodexThreadCatalog { 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] @@ -99,16 +165,49 @@ enum MacNodeCodexThreadCatalog { } static func list(paramsJSON: String?) async throws -> String { + try await self.list(paramsJSON: paramsJSON) { + OpenClawConfigFile.loadDict() + } + } + + static func list( + paramsJSON: String?, + loadRoot: () -> [String: Any]) async throws -> String + { let params = try self.decodeParams(paramsJSON) - let invocation = try self.resolveInvocation() + // 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 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 = 12, maxLineBytes: Int = 5 * 1024 * 1024) async throws -> String { @@ -118,7 +217,8 @@ enum MacNodeCodexThreadCatalog { invocation: ResolvedInvocation( executable: executable, arguments: arguments ?? self.defaultArguments, - cwd: cwd), + cwd: cwd, + clearEnv: clearEnv), timeoutSeconds: timeoutSeconds, maxLineBytes: maxLineBytes) } @@ -129,74 +229,598 @@ enum MacNodeCodexThreadCatalog { timeoutSeconds: Double = 12, maxLineBytes: Int = 5 * 1024 * 1024) async throws -> String { - let session = try CodexAppServerThreadListSession( - invocation: invocation, - listParams: self.appServerParams(params), - timeoutSeconds: timeoutSeconds, - maxLineBytes: maxLineBytes) - let output = try await session.run() - return try self.normalize( - listResultData: output.listResultData, - archived: params.archived, - searchTerm: params.searchTerm) - } + guard params.searchTerm != nil else { + let session = try CodexAppServerThreadListSession( + invocation: invocation, + listParams: self.appServerParams(params), + timeoutSeconds: timeoutSeconds, + maxLineBytes: maxLineBytes) + let output = try await session.run() + return try self.normalize(listResultData: output.listResultData) + } + // 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.. 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 CodexAppServerThreadListSession( + invocation: invocation, + listParams: self.appServerParams(pageParams), + timeoutSeconds: remainingTimeout, + maxLineBytes: maxLineBytes) + let output = try await session.run() + let page = try self.normalizedResponse( + listResultData: output.listResultData, + 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), - defaultMacOSAppExecutable: String = MacNodeCodexThreadCatalog.defaultMacOSAppExecutable) throws + 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 endpoint = self.configuredStdioEndpoint(root: root) - let cwd = endpoint?.cwd.map { - self.resolvePath($0, relativeTo: currentDirectoryURL, isDirectory: true) + let appServer = try self.configuredPlugin(root: root)?.appServer + guard self.supportsConfiguredTransport(appServer) else { + throw CatalogError.unsupportedAppServerTransport } - let configuredCommand = endpoint?.command - let rawCommand = configuredCommand ?? "codex" + 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? - if configuredCommand == nil, - FileManager.default.isExecutableFile(atPath: defaultMacOSAppExecutable) - { - executable = defaultMacOSAppExecutable + 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: cwd ?? currentDirectoryURL) + 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: endpoint?.args ?? self.defaultArguments, - cwd: cwd) + arguments: arguments, + cwd: nil, + clearEnv: appServer?.clearEnv ?? []) } - private static func configuredStdioEndpoint(root: [String: Any]) -> ConfiguredStdioEndpoint? { + 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), - let config = entry["config"] as? [String: Any], - let endpoints = config["endpoints"] as? [Any] + root: root) else { return nil } - - for value in endpoints { - guard let endpoint = value as? [String: Any] else { continue } - let transport = endpoint["transport"] as? String - guard transport == nil || transport == "stdio-proxy" else { continue } - let args = (endpoint["args"] as? [Any])?.compactMap { $0 as? String } - return ConfiguredStdioEndpoint( - command: endpoint["command"] as? String, - args: args?.isEmpty == false ? args : nil, - cwd: endpoint["cwd"] as? String) + guard let rawConfig = entry["config"] else { + return ConfiguredPlugin(supervisionEnabled: false, appServer: nil) } - return 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) 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) 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) 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( @@ -228,7 +852,7 @@ enum MacNodeCodexThreadCatalog { guard let raw = raw as? [String: Any] else { throw CatalogError.invalidParams("parameters must be an object") } - let allowed = Set(["cursor", "limit", "archived", "searchTerm", "cwd"]) + 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)") } @@ -250,14 +874,6 @@ enum MacNodeCodexThreadCatalog { } params.limit = number.intValue } - if let value = raw["archived"] { - guard CFGetTypeID(value as CFTypeRef) == CFBooleanGetTypeID(), - let archived = value as? Bool - else { - throw CatalogError.invalidParams("archived must be a boolean") - } - params.archived = archived - } return params } @@ -286,7 +902,7 @@ enum MacNodeCodexThreadCatalog { // An empty provider list means all providers. Omitting sourceKinds keeps // Codex's stable interactive-session default. "modelProviders": [String](), - "archived": params.archived, + "archived": false, "useStateDbOnly": false, ] if let cursor = params.cursor { @@ -302,8 +918,16 @@ enum MacNodeCodexThreadCatalog { static func normalize( listResultData: Data, - archived: Bool, 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] @@ -333,7 +957,7 @@ enum MacNodeCodexThreadCatalog { maxLength: self.maxSessionNameLength, overflow: .truncate) if let searchTerm, - name?.range(of: searchTerm, options: [.literal]) == nil + name?.range(of: searchTerm, options: [.caseInsensitive, .literal]) == nil { return nil } @@ -362,13 +986,16 @@ enum MacNodeCodexThreadCatalog { gitInfo?["branch"], maxLength: self.maxMetadataLength, overflow: .truncate), - archived: archived) + archived: false) } - let response = WireResponse( + 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 @@ -482,6 +1109,9 @@ private final class CodexAppServerThreadListSession: @unchecked Sendable { 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 @@ -522,13 +1152,8 @@ private final class CodexAppServerThreadListSession: @unchecked Sendable { Self.setNonBlocking(self.stderrPipe.fileHandleForReading) self.stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in guard let session = self else { return } - let chunk = Self.readAvailable(from: handle, maxBytes: session.maxLineBytes) - if chunk.reachedEOF { - handle.readabilityHandler = nil - } - guard !chunk.data.isEmpty else { return } session.queue.async { [session] in - session.consumeStdout(chunk.data) + session.drainStdout(from: handle) } } // Drain stderr so the child cannot block. App Server stderr is deliberately @@ -541,6 +1166,9 @@ private final class CodexAppServerThreadListSession: @unchecked Sendable { 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)) } @@ -563,6 +1191,16 @@ private final class CodexAppServerThreadListSession: @unchecked Sendable { } } + 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) diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift index 82cb1e6c43af..53e91827fd14 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift @@ -52,6 +52,7 @@ final class MacNodeModeCoordinator: NSObject { private struct ConnectionAttempt { let endpointGeneration: UInt64 let routeAuthorityGeneration: UInt64 + let codexThreadCatalogAdvertised: Bool let config: GatewayConnection.Config let options: GatewayConnectOptions let sessionBox: WebSocketSessionBox? @@ -335,6 +336,13 @@ final class MacNodeModeCoordinator: NSObject { !isPaused } + nonisolated static func routeSnapshotAllowsCodexCatalogInvoke( + command: String, + catalogAdvertised: Bool) -> Bool + { + command != MacNodeCodexThreadCatalogContract.listCommand || catalogAdvertised + } + nonisolated static func stalePostConnectRequiresDisconnect( capturedRouteAuthorityGeneration: UInt64, currentRouteAuthorityGeneration: UInt64, @@ -394,8 +402,7 @@ final class MacNodeModeCoordinator: NSObject { let cameraEnabled = defaults.object(forKey: cameraEnabledKey) as? Bool ?? false let browserControlEnabled = OpenClawConfigFile.browserControlEnabled() - let codexThreadCatalogEnabled = OpenClawConfigFile.explicitlyEnabledPlugin( - MacNodeCodexThreadCatalogContract.pluginId) + let codexThreadCatalogEnabled = MacNodeCodexThreadCatalog.shouldAdvertise() var attemptedURL: URL? do { @@ -505,6 +512,8 @@ final class MacNodeModeCoordinator: NSObject { return ConnectionAttempt( endpointGeneration: endpointGeneration, routeAuthorityGeneration: routeAuthorityGeneration, + codexThreadCatalogAdvertised: commands.contains( + MacNodeCodexThreadCatalogContract.listCommand), config: config, options: options, sessionBox: sessionBox) @@ -560,6 +569,20 @@ final class MacNodeModeCoordinator: NSObject { code: .unavailable, message: "UNAVAILABLE: node route changed before dispatch")) } + // The connect options are this route's capability lease. A later + // config enable must not broaden an already-admitted connection; + // MacNodeRuntime separately rechecks current config to fail closed. + guard Self.routeSnapshotAllowsCodexCatalogInvoke( + command: req.command, + catalogAdvertised: attempt.codexThreadCatalogAdvertised) + else { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: "UNAVAILABLE: Codex session catalog was not advertised for this route")) + } return await self.runtime.handleInvoke(req) }, onRouteInvalidated: { [weak self] in @@ -678,7 +701,9 @@ final class MacNodeModeCoordinator: NSObject { caps.append(OpenClawCapability.computer.rawValue) } if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) } - if codexThreadCatalogEnabled { + // A local Gateway already catalogs this user's Codex home. Advertise the + // node-owned catalog only when this Mac supplies it to a remote Gateway. + if codexThreadCatalogEnabled, connectionMode == .remote { caps.append(MacNodeCodexThreadCatalogContract.capability) } return caps diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift index 52d8e3a7c951..7ab4fed8673e 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift @@ -60,8 +60,7 @@ actor MacNodeRuntime { }, refreshCanvasSurfaceUrl: @escaping @Sendable () async -> String? = { nil }, codexThreadCatalogEnabled: @escaping @Sendable () -> Bool = { - OpenClawConfigFile.explicitlyEnabledPlugin( - MacNodeCodexThreadCatalogContract.pluginId) + MacNodeCodexThreadCatalog.shouldAdvertise() }, codexThreadListRequest: @escaping @Sendable (String?) async throws -> String = { paramsJSON in try await MacNodeCodexThreadCatalog.list(paramsJSON: paramsJSON) diff --git a/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift b/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift index b69bcf809d85..6ee13215873d 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift @@ -77,6 +77,7 @@ final class OnboardingAISetupModel { private(set) var connectedModelRef: String? private(set) var connectedLatencyMs: Int? private(set) var connectedSetupLines: [String] = [] + private(set) var codexAppServerDetected = false private(set) var detectError: Failure? /// Set once every detected candidate failed; opens the manual key form. private(set) var exhaustedAutoCandidates = false @@ -124,6 +125,7 @@ final class OnboardingAISetupModel { } let candidates: [DetectedCandidate] + let codexAppServerDetected: Bool? let manualProviders: [ManualProvider]? let workspace: String let configuredModel: String? @@ -171,6 +173,7 @@ final class OnboardingAISetupModel { self.connectedModelRef = nil self.connectedLatencyMs = nil self.connectedSetupLines = [] + self.codexAppServerDetected = false self.detectError = nil self.exhaustedAutoCandidates = false self.lastDetectedActivationState = nil @@ -201,6 +204,7 @@ final class OnboardingAISetupModel { let result = try JSONDecoder().decode(DetectResult.self, from: data) self.lastDetectedActivationState = result.persistedActivationState let manualProviders = result.manualProviders ?? [] + self.codexAppServerDetected = result.codexAppServerDetected ?? false self.candidates = result.candidates.map { detected in Candidate( kind: detected.kind, @@ -247,16 +251,24 @@ final class OnboardingAISetupModel { : "The Gateway setup request failed. Show details to inspect or copy the error." } - static func activationRequestTimeoutMs(for kind: String) -> Double { + static func activationRequestTimeoutMs( + for kind: String, + provisionsCodexSupervision: Bool = false) -> Double + { // Codex can spend 305s installing its runtime plugin before the 90s live probe. // Keep a bounded client deadline with room for registry refresh and finalization. - kind == "codex-cli" ? 480_000 : 150_000 + kind == "codex-cli" || provisionsCodexSupervision ? 480_000 : 150_000 } - static func activationOutcomeDeadlineMs(for kind: String) -> Double { + static func activationOutcomeDeadlineMs( + for kind: String, + provisionsCodexSupervision: Bool = false) -> Double + { // A request timeout removes only the client waiter. Keep a short final window // to observe config that the still-running Gateway operation just persisted. - self.activationRequestTimeoutMs(for: kind) + 30000 + self.activationRequestTimeoutMs( + for: kind, + provisionsCodexSupervision: provisionsCodexSupervision) + 30000 } static func activationTransitionWasPersisted( @@ -326,8 +338,12 @@ final class OnboardingAISetupModel { let token = self.attemptToken let persistedStateBeforeActivation = self.lastDetectedActivationState let clock = ContinuousClock() - let requestTimeoutMs = Self.activationRequestTimeoutMs(for: kind) - let outcomeDeadlineMs = Self.activationOutcomeDeadlineMs(for: kind) + let requestTimeoutMs = Self.activationRequestTimeoutMs( + for: kind, + provisionsCodexSupervision: self.codexAppServerDetected) + let outcomeDeadlineMs = Self.activationOutcomeDeadlineMs( + for: kind, + provisionsCodexSupervision: self.codexAppServerDetected) let reconciliationDeadline = clock.now.advanced(by: .milliseconds(Int64(outcomeDeadlineMs))) self.selectedKind = kind self.phase = .testing @@ -500,7 +516,9 @@ final class OnboardingAISetupModel { "authChoice": AnyCodable(provider.id), "apiKey": AnyCodable(key), ], - timeoutMs: 150_000, + timeoutMs: Self.activationRequestTimeoutMs( + for: "api-key", + provisionsCodexSupervision: self.codexAppServerDetected), ifCurrentServerLease: serverLease) guard token == self.attemptToken else { return } let result = try JSONDecoder().decode(ActivateResult.self, from: data) diff --git a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift index 087386ef96ad..4e2008e78e41 100644 --- a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift +++ b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift @@ -1,3 +1,4 @@ +import CoreFoundation import CryptoKit import Foundation import OpenClawProtocol @@ -206,30 +207,47 @@ enum OpenClawConfigFile { let browser = root["browser"] as? [String: Any] return browser?["enabled"] as? Bool ?? defaultValue } +} +extension OpenClawConfigFile { private static func normalizedPluginConfigId(_ value: Any?) -> String? { guard let value = value as? String else { return nil } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed + return trimmed.isEmpty ? nil : trimmed.lowercased() + } + + private static func literalBoolean(_ value: Any?) -> Bool? { + guard let number = value as? NSNumber, + CFGetTypeID(number) == CFBooleanGetTypeID() + else { return nil } + return number.boolValue } static func pluginEntry(_ pluginId: String, root: [String: Any]? = nil) -> [String: Any]? { let root = root ?? self.loadDict() + guard let pluginId = normalizedPluginConfigId(pluginId) else { return nil } guard let plugins = root["plugins"] as? [String: Any], let entries = plugins["entries"] as? [String: Any] else { return nil } - return entries.first(where: { key, _ in + let matches = entries.filter { key, _ in self.normalizedPluginConfigId(key) == pluginId - })?.value as? [String: Any] + } + // Core merges normalized aliases in source order. JSON dictionaries do not + // expose a portable source-order contract here, so ambiguous aliases fail closed. + guard matches.count == 1 else { return nil } + return matches.first?.value as? [String: Any] } static func explicitlyEnabledPlugin(_ pluginId: String, root: [String: Any]? = nil) -> Bool { let root = root ?? self.loadDict() + guard let pluginId = normalizedPluginConfigId(pluginId) else { return false } guard let plugins = root["plugins"] as? [String: Any], - plugins["enabled"] as? Bool != false, - let entry = self.pluginEntry(pluginId, root: root), - entry["enabled"] as? Bool == true + let entry = pluginEntry(pluginId, root: root), + literalBoolean(entry["enabled"]) == true else { return false } + if let enabled = plugins["enabled"], literalBoolean(enabled) != true { + return false + } let deny = (plugins["deny"] as? [Any] ?? []).compactMap(self.normalizedPluginConfigId) if deny.contains(pluginId) { return false } @@ -239,6 +257,52 @@ enum OpenClawConfigFile { return true } + /// Mirrors configured-root activation for bundled plugins: a declared config path may + /// activate the plugin unless global policy, an entry opt-out, or deny disables it. + static func configuredBundledPluginAllowed( + _ pluginId: String, + root: [String: Any]? = nil) -> Bool + { + let root = root ?? self.loadDict() + guard let pluginId = normalizedPluginConfigId(pluginId), + let plugins = root["plugins"] as? [String: Any], + let entry = pluginEntry(pluginId, root: root) + else { return false } + if let enabled = plugins["enabled"], literalBoolean(enabled) != true { + return false + } + if let enabled = entry["enabled"], literalBoolean(enabled) != true { + return false + } + + let deny = (plugins["deny"] as? [Any] ?? []).compactMap(self.normalizedPluginConfigId) + if deny.contains(pluginId) { return false } + + let allow = (plugins["allow"] as? [Any] ?? []).compactMap(self.normalizedPluginConfigId) + return allow.isEmpty || allow.contains(pluginId) + } + + static func explicitlyEnabledPluginConfigFlag( + _ pluginId: String, + path: [String], + root: [String: Any]? = nil) -> Bool + { + let root = root ?? self.loadDict() + guard self.explicitlyEnabledPlugin(pluginId, root: root), + let entry = pluginEntry(pluginId, root: root), + let config = entry["config"] + else { return false } + + var value = config + for key in path { + guard let object = value as? [String: Any], let next = object[key] else { + return false + } + value = next + } + return self.literalBoolean(value) == true + } + static func setBrowserControlEnabled(_ enabled: Bool) { var root = self.loadDict() var browser = root["browser"] as? [String: Any] ?? [:] @@ -287,7 +351,7 @@ enum OpenClawConfigFile { } static func remoteGatewayPort() -> Int? { - guard let url = self.remoteGatewayUrl(), + guard let url = remoteGatewayUrl(), let port = url.port, port > 0 else { return nil } @@ -296,7 +360,7 @@ enum OpenClawConfigFile { static func remoteGatewayPort(matchingHost sshHost: String) -> Int? { guard let normalizedSshHost = canonicalHostForComparison(sshHost), - let url = self.remoteGatewayUrl(), + let url = remoteGatewayUrl(), let port = url.port, port > 0, let urlHost = url.host, @@ -462,7 +526,7 @@ enum OpenClawConfigFile { allowGatewayAuthMutation: Bool) -> Bool { guard !allowGatewayAuthMutation, - let previousAuth = self.gatewayAuth(previousRoot) + let previousAuth = gatewayAuth(previousRoot) else { return false } @@ -552,7 +616,7 @@ enum OpenClawConfigFile { } private static func posixMode(_ value: Any?) -> Int? { - guard let mode = self.fileAttributeInt(value) else { return nil } + guard let mode = fileAttributeInt(value) else { return nil } return mode & 0o777 } @@ -615,7 +679,7 @@ enum OpenClawConfigFile { if (lastKnownGood["gatewayMode"] as? String) != nil, self.gatewayMode(root) == nil { reasons.append("gateway-mode-missing-vs-last-good") } - if let root, (lastKnownGood["gatewayMode"] as? String) != nil, self.isUpdateChannelOnlyRoot(root) { + if let root, (lastKnownGood["gatewayMode"] as? String) != nil, isUpdateChannelOnlyRoot(root) { reasons.append("update-channel-only-root") } return reasons diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeCodexThreadCatalogTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeCodexThreadCatalogTests.swift index 18059554163a..8bd304cdfb7a 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeCodexThreadCatalogTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeCodexThreadCatalogTests.swift @@ -1,7 +1,9 @@ +import Darwin import Foundation import Testing @testable import OpenClaw +@Suite(.serialized) struct MacNodeCodexThreadCatalogTests { private struct FakeCodex { var directory: URL @@ -22,6 +24,30 @@ struct MacNodeCodexThreadCatalogTests { capture: URL(fileURLWithPath: executable.path + ".requests")) } + private func listResponseJSON(names: [String], nextCursor: String?) throws -> String { + let encodedNextCursor: Any = if let nextCursor { + nextCursor + } else { + 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": 2, + "result": [ + "data": threads, + "nextCursor": encodedNextCursor, + "backwardsCursor": NSNull(), + ], + ]) + return try #require(String(data: data, encoding: .utf8)) + } + @Test func `normalizes App Server metadata and drops sensitive thread fields`() throws { let raw: [String: Any] = [ "data": [[ @@ -53,9 +79,7 @@ struct MacNodeCodexThreadCatalogTests { ] let data = try JSONSerialization.data(withJSONObject: raw) - let json = try MacNodeCodexThreadCatalog.normalize( - listResultData: data, - archived: false) + 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]]) @@ -104,9 +128,7 @@ struct MacNodeCodexThreadCatalogTests { ] let data = try JSONSerialization.data(withJSONObject: raw) - let json = try MacNodeCodexThreadCatalog.normalize( - listResultData: data, - archived: false) + 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]]) @@ -127,12 +149,15 @@ struct MacNodeCodexThreadCatalogTests { #expect(decoded["backwardsCursor"] as? String == "opaque-backwards") } - @Test func `resolves and runs the first configured stdio endpoint without a shell`() async throws { - let fake = try self.makeFakeCodex(#""" + @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 makeFakeCodex(#""" #!/bin/sh [ "$1" = "custom-app-server" ] || exit 10 [ "$2" = "--stdio" ] || exit 11 - pwd > "${0}.cwd" + [ -z "${OPENCLAW_CODEX_CATALOG_CLEAR_ENV_SENTINEL+x}" ] || exit 12 IFS= read -r initialize || exit 2 printf '%s\n' '{"id":1,"result":{}}' IFS= read -r initialized || exit 3 @@ -144,18 +169,16 @@ struct MacNodeCodexThreadCatalogTests { let root: [String: Any] = [ "plugins": [ "entries": [ - " codex-supervisor ": [ + " codex ": [ "enabled": true, "config": [ - "endpoints": [ - ["transport": "websocket", "url": "unix://"], - [ - "transport": "stdio-proxy", - "command": "./codex", - "args": ["custom-app-server", "--stdio"], - "cwd": fake.directory.path, - ], - ["transport": "stdio-proxy", "command": "/must/not/win"], + "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) ", ""], ], ], ], @@ -169,45 +192,523 @@ struct MacNodeCodexThreadCatalogTests { currentDirectoryURL: FileManager.default.temporaryDirectory) #expect(resolved.executable == fake.executable.standardizedFileURL.path) - #expect(resolved.arguments == ["custom-app-server", "--stdio"]) - #expect(resolved.cwd == fake.directory.standardizedFileURL) + #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) + 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) - let capturedCwd = try String( - contentsOf: URL(fileURLWithPath: fake.executable.path + ".cwd"), - encoding: .utf8) - .trimmingCharacters(in: .whitespacesAndNewlines) - #expect( - URL(fileURLWithPath: capturedCwd).resolvingSymlinksInPath() == - fake.directory.resolvingSymlinksInPath()) } - @Test func `prefers the installed Codex app binary for the default endpoint`() throws { - let app = try self.makeFakeCodex("#!/bin/sh\nexit 0\n") - let pathCLI = try self.makeFakeCodex("#!/bin/sh\nexit 0\n") + @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") + defer { + try? FileManager.default.removeItem(at: fake.directory) + try? FileManager.default.removeItem(at: chatGPTApp.directory) + } + 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") + defer { + try? FileManager.default.removeItem(at: configured.directory) + try? FileManager.default.removeItem(at: chatGPTApp.directory) + } + let root: [String: Any] = [ + "plugins": [ + "entries": [ + "codex": [ + "config": [ + "appServer": ["command": configured.executable.path], + ], + ], + ], + ], + ] + + 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") + defer { try? FileManager.default.removeItem(at: fallback.directory) } + let root: [String: Any] = [ + "plugins": [ + "entries": [ + "codex": [ + "config": [ + "appServer": ["command": " \n "], + ], + ], + ], + ], + ] + + 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") + defer { try? FileManager.default.removeItem(at: app.directory) } + let root: [String: Any] = [ + "plugins": [ + "entries": [ + "codex": [ + "enabled": true, + "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") + defer { try? FileManager.default.removeItem(at: app.directory) } + 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: [String: Any] = [ + "plugins": [ + "entries": [ + "codex": [ + "enabled": true, + "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 makeFakeCodex(#""" + #!/bin/sh + IFS= read -r initialize || exit 2 + printf '%s\n' '{"id":1,"result":{}}' + IFS= read -r initialized || exit 3 + IFS= read -r list || exit 4 + printf '%s\n' '{"id":2,"result":{"data":[]}}' + sleep 1 + """#) + defer { try? FileManager.default.removeItem(at: fake.directory) } + let enabled: [String: Any] = [ + "plugins": [ + "entries": [ + "codex": [ + "enabled": true, + "config": [ + "supervision": ["enabled": true], + "appServer": [ + "transport": "stdio", + "homeScope": "user", + "command": fake.executable.path, + "args": ["app-server", "--listen", "stdio://"], + ], + ], + ], + ], + ], + ] + let revoked: [String: Any] = [ + "plugins": [ + "deny": ["codex"], + "entries": [ + "codex": [ + "enabled": true, + "config": ["supervision": ["enabled": true]], + ], + ], + ], + ] + 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: [String: Any] = [ + "plugins": [ + "allow": ["discord"], + "entries": [ + "codex": [ + "enabled": true, + "config": ["supervision": ["enabled": true]], + ], + ], + ], + ] + + #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") + defer { try? FileManager.default.removeItem(at: app.directory) } + let root: [String: Any] = [ + "plugins": [ + "entries": [ + "codex": [ + "enabled": true, + "config": [ + "supervision": ["enabled": true], + "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") defer { try? FileManager.default.removeItem(at: app.directory) try? FileManager.default.removeItem(at: pathCLI.directory) } + for transport in ["websocket", "unix"] { + let root: [String: Any] = [ + "plugins": [ + "entries": [ + "codex": [ + "enabled": true, + "config": [ + "supervision": ["enabled": true], + "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") + defer { + try? FileManager.default.removeItem(at: userApp.directory) + try? FileManager.default.removeItem(at: pathCLI.directory) + } + let resolved = try MacNodeCodexThreadCatalog.resolveInvocation( root: [:], searchPaths: [pathCLI.directory.path], - defaultMacOSAppExecutable: app.executable.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 == app.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") + defer { + try? FileManager.default.removeItem(at: betaApp.directory) + try? FileManager.default.removeItem(at: pathCLI.directory) + } + + 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") + defer { + try? FileManager.default.removeItem(at: chatGPTApp.directory) + try? FileManager.default.removeItem(at: pathCLI.directory) + } + 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") + defer { + try? FileManager.default.removeItem(at: chatGPTApp.directory) + try? FileManager.default.removeItem(at: codexApp.directory) + try? FileManager.default.removeItem(at: codexBetaApp.directory) + } + 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 self.makeFakeCodex(#""" + let fake = try makeFakeCodex(#""" #!/bin/sh capture="${0}.requests" IFS= read -r initialize || exit 2 @@ -222,20 +723,20 @@ struct MacNodeCodexThreadCatalogTests { printf '%s' '"name":"One","preview":"private transcript","cwd":"/work",' printf '%s' '"status":{"type":"notLoaded"},"source":{"custom":"chatgpt"},' printf '%s' '"path":"/private/rollout.jsonl","turns":[]},{"id":"thread-2",' - printf '%s' '"name":"one","preview":"One","cwd":"/other",' - printf '%s\n' '"status":{"type":"notLoaded"}}],"nextCursor":"opaque/+==","backwardsCursor":"back/+=="}}' + printf '%s' '"name":"Two","preview":"One","cwd":"/other",' + printf '%s\n' '"status":{"type":"notLoaded"}}],"nextCursor":null,"backwardsCursor":"back/+=="}}' sleep 1 """#) defer { try? FileManager.default.removeItem(at: fake.directory) } let payload = try await MacNodeCodexThreadCatalog.list( - paramsJSON: #"{"cursor":" cursor ","limit":25,"archived":true,"searchTerm":" One ","cwd":" /work "}"#, + 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"] as? String == "opaque/+==") + #expect(response["nextCursor"] == nil) #expect(response["backwardsCursor"] as? String == "back/+==") #expect(sessions.count == 1) #expect(sessions.first?["threadId"] as? String == "thread-1") @@ -253,7 +754,7 @@ struct MacNodeCodexThreadCatalogTests { let listParams = try #require(captured[2]?["params"] as? [String: Any]) #expect(listParams["cursor"] as? String == "cursor") #expect(listParams["limit"] as? Int == 25) - #expect(listParams["archived"] as? Bool == true) + #expect(listParams["archived"] as? Bool == false) #expect(listParams["searchTerm"] == nil) #expect(listParams["cwd"] as? String == "/work") #expect(listParams["sortKey"] as? String == "recency_at") @@ -263,12 +764,151 @@ struct MacNodeCodexThreadCatalogTests { #expect(listParams["useStateDbOnly"] as? Bool == false) } + @Test func `title search fills one result page across bounded native pages`() async throws { + let first = try listResponseJSON( + names: ["Target one", "Other one", "Other two"], + nextCursor: "cursor-1") + let second = try listResponseJSON( + names: ["Other three", "Other four"], + nextCursor: "cursor-2") + let third = try listResponseJSON( + names: ["Target two", "Target three"], + nextCursor: "cursor-3") + let fake = try makeFakeCodex(#""" + #!/bin/sh + counter="${0}.counter" + count=0 + [ ! -f "$counter" ] || count=$(cat "$counter") + count=$((count + 1)) + printf '%s\n' "$count" > "$counter" + IFS= read -r initialize || exit 2 + printf '%s\n' '{"id":1,"result":{}}' + IFS= read -r initialized || exit 3 + IFS= read -r list || exit 4 + printf '%s\n' "$list" >> "${0}.requests" + case "$count" in + 1) printf '%s\n' '\#(first)' ;; + 2) printf '%s\n' '\#(second)' ;; + 3) printf '%s\n' '\#(third)' ;; + *) exit 9 ;; + esac + """#) + defer { try? FileManager.default.removeItem(at: fake.directory) } + + 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(names: names, nextCursor: "cursor-\(page)") + } + let fake = try makeFakeCodex(#""" + #!/bin/sh + counter="${0}.counter" + count=0 + [ ! -f "$counter" ] || count=$(cat "$counter") + count=$((count + 1)) + printf '%s\n' "$count" > "$counter" + IFS= read -r initialize || exit 2 + printf '%s\n' '{"id":1,"result":{}}' + IFS= read -r initialized || exit 3 + IFS= read -r list || exit 4 + printf '%s\n' "$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 9 ;; + esac + """#) + defer { try? FileManager.default.removeItem(at: fake.directory) } + + 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(names: ["Other one"], nextCursor: "same") + let second = try listResponseJSON(names: ["Other two"], nextCursor: "same") + let fake = try makeFakeCodex(#""" + #!/bin/sh + counter="${0}.counter" + count=0 + [ ! -f "$counter" ] || count=$(cat "$counter") + count=$((count + 1)) + printf '%s\n' "$count" > "$counter" + IFS= read -r initialize || exit 2 + printf '%s\n' '{"id":1,"result":{}}' + IFS= read -r initialized || exit 3 + IFS= read -r list || exit 4 + printf '%s\n' "$list" >> "${0}.requests" + case "$count" in + 1) printf '%s\n' '\#(first)' ;; + 2) printf '%s\n' '\#(second)' ;; + *) exit 9 ;; + esac + """#) + defer { try? FileManager.default.removeItem(at: fake.directory) } + + 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) + } + @Test func `drains App Server frames larger than one pipe read while server stays open`() async throws { let threads: [[String: Any]] = (0..<50).map { index in [ "id": "thread-\(index)", "name": "Large catalog \(index)", - "cwd": "/workspace/\(String(repeating: "x", count: 2_000))", + "cwd": "/workspace/\(String(repeating: "x", count: 2000))", "status": ["type": "notLoaded"], ] } @@ -278,7 +918,7 @@ struct MacNodeCodexThreadCatalogTests { ]) let response = try #require(String(data: responseData, encoding: .utf8)) #expect(response.utf8.count > 64 * 1024) - let fake = try self.makeFakeCodex(""" + let fake = try makeFakeCodex(""" #!/bin/sh IFS= read -r initialize || exit 2 printf '%s\n' '{"id":1,"result":{}}' @@ -306,6 +946,7 @@ struct MacNodeCodexThreadCatalogTests { (#"{"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 { @@ -322,7 +963,7 @@ struct MacNodeCodexThreadCatalogTests { } @Test func `bounds fake App Server output and wait time`() async throws { - let oversized = try self.makeFakeCodex(#""" + let oversized = try makeFakeCodex(#""" #!/bin/sh IFS= read -r initialize || exit 2 printf '%512s\n' x @@ -339,7 +980,7 @@ struct MacNodeCodexThreadCatalogTests { #expect(error == .responseTooLarge) } - let stalled = try self.makeFakeCodex(#""" + let stalled = try makeFakeCodex(#""" #!/bin/sh IFS= read -r initialize || exit 2 sleep 1 @@ -357,7 +998,7 @@ struct MacNodeCodexThreadCatalogTests { } @Test func `App Server error details stay on node`() async throws { - let fake = try self.makeFakeCodex(#""" + let fake = try makeFakeCodex(#""" #!/bin/sh IFS= read -r initialize || exit 2 printf '%s\n' '{"id":1,"result":{"codexHome":"/private"}}' diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift index 10295d65ce5f..870b3e496ed9 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift @@ -108,7 +108,9 @@ struct MacNodeModeCoordinatorTests { let clock = ContinuousClock() let deadline = clock.now.advanced(by: timeout) while clock.now < deadline { - if await condition() { return } + if await condition() { + return + } await Task.yield() } Issue.record("timed out waiting for \(description)") @@ -328,7 +330,7 @@ struct MacNodeModeCoordinatorTests { onDisconnected: { _ in }, onInvoke: { request in BridgeInvokeResponse(id: request.id, ok: true) }) } - try await self.waitUntil("successor captured first invalidation") { + try await waitUntil("successor captured first invalidation") { await drainSnapshot.hasCaptured() } coordinator.enqueueRouteInvalidationForTesting() @@ -446,7 +448,21 @@ struct MacNodeModeCoordinatorTests { #expect(commands.contains(OpenClawBrowserCommand.proxy.rawValue)) } - @Test func `codex supervisor config advertises native thread catalog`() { + @Test func `local mode omits native Codex thread catalog`() { + let caps = MacNodeModeCoordinator.resolvedCaps( + browserControlEnabled: false, + cameraEnabled: false, + computerControlEnabled: false, + locationMode: .off, + connectionMode: .local, + codexThreadCatalogEnabled: true) + let commands = MacNodeModeCoordinator.resolvedCommands(caps: caps) + + #expect(!caps.contains(MacNodeCodexThreadCatalogContract.capability)) + #expect(!commands.contains(MacNodeCodexThreadCatalogContract.listCommand)) + } + + @Test func `remote mode advertises native Codex thread catalog`() { let caps = MacNodeModeCoordinator.resolvedCaps( browserControlEnabled: false, cameraEnabled: false, @@ -458,53 +474,257 @@ struct MacNodeModeCoordinatorTests { #expect(caps.contains(MacNodeCodexThreadCatalogContract.capability)) #expect(commands.contains(MacNodeCodexThreadCatalogContract.listCommand)) + #expect(MacNodeModeCoordinator.routeSnapshotAllowsCodexCatalogInvoke( + command: MacNodeCodexThreadCatalogContract.listCommand, + catalogAdvertised: true)) + #expect(!MacNodeModeCoordinator.routeSnapshotAllowsCodexCatalogInvoke( + command: MacNodeCodexThreadCatalogContract.listCommand, + catalogAdvertised: false)) + #expect(MacNodeModeCoordinator.routeSnapshotAllowsCodexCatalogInvoke( + command: OpenClawSystemCommand.notify.rawValue, + catalogAdvertised: false)) } - @Test func `codex supervisor plugin activation respects global policy`() { + @Test func `Codex supervision activation respects the plugin flag and global policy`() { let enabled: [String: Any] = [ "plugins": [ - "entries": ["codex-supervisor": ["enabled": true]], + "entries": [ + "codex": [ + "enabled": true, + "config": ["supervision": ["enabled": true]], + ], + ], ], ] - #expect(OpenClawConfigFile.explicitlyEnabledPlugin("codex-supervisor", root: enabled)) + #expect(OpenClawConfigFile.explicitlyEnabledPluginConfigFlag( + "codex", + path: ["supervision", "enabled"], + root: enabled)) + #expect(MacNodeCodexThreadCatalog.shouldAdvertise(root: enabled)) + + let enabledByConfigPath: [String: Any] = [ + "plugins": [ + "entries": [ + "codex": [ + "config": ["supervision": ["enabled": true]], + ], + ], + ], + ] + #expect(OpenClawConfigFile.configuredBundledPluginAllowed( + "codex", + root: enabledByConfigPath)) + #expect(MacNodeCodexThreadCatalog.shouldAdvertise(root: enabledByConfigPath)) + + let numericPluginEnable: [String: Any] = [ + "plugins": [ + "entries": [ + "codex": [ + "enabled": NSNumber(value: 1), + "config": ["supervision": ["enabled": true]], + ], + ], + ], + ] + #expect(!OpenClawConfigFile.explicitlyEnabledPluginConfigFlag( + "codex", + path: ["supervision", "enabled"], + root: numericPluginEnable)) + + let numericNestedEnable: [String: Any] = [ + "plugins": [ + "entries": [ + "codex": [ + "enabled": true, + "config": ["supervision": ["enabled": NSNumber(value: 1)]], + ], + ], + ], + ] + #expect(!OpenClawConfigFile.explicitlyEnabledPluginConfigFlag( + "codex", + path: ["supervision", "enabled"], + root: numericNestedEnable)) + + let numericGlobalEnable: [String: Any] = [ + "plugins": [ + "enabled": NSNumber(value: 1), + "entries": [ + "codex": [ + "enabled": true, + "config": ["supervision": ["enabled": true]], + ], + ], + ], + ] + #expect(!OpenClawConfigFile.explicitlyEnabledPluginConfigFlag( + "codex", + path: ["supervision", "enabled"], + root: numericGlobalEnable)) + + for transport in ["websocket", "unix"] { + let unsupported: [String: Any] = [ + "plugins": [ + "entries": [ + "codex": [ + "enabled": true, + "config": [ + "supervision": ["enabled": true], + "appServer": ["transport": transport], + ], + ], + ], + ], + ] + #expect(!MacNodeCodexThreadCatalog.shouldAdvertise(root: unsupported)) + } + + let agentHome: [String: Any] = [ + "plugins": [ + "entries": [ + "codex": [ + "enabled": true, + "config": [ + "supervision": ["enabled": true], + "appServer": ["transport": "stdio", "homeScope": "agent"], + ], + ], + ], + ], + ] + #expect(!MacNodeCodexThreadCatalog.shouldAdvertise(root: agentHome)) + + let supervisionDisabled: [String: Any] = [ + "plugins": [ + "entries": [ + "codex": [ + "enabled": true, + "config": ["supervision": ["enabled": false]], + ], + ], + ], + ] + #expect(!OpenClawConfigFile.explicitlyEnabledPluginConfigFlag( + "codex", + path: ["supervision", "enabled"], + root: supervisionDisabled)) + + let pluginDisabled: [String: Any] = [ + "plugins": [ + "entries": [ + "codex": [ + "enabled": false, + "config": ["supervision": ["enabled": true]], + ], + ], + ], + ] + #expect(!OpenClawConfigFile.configuredBundledPluginAllowed( + "codex", + root: pluginDisabled)) + #expect(!MacNodeCodexThreadCatalog.shouldAdvertise(root: pluginDisabled)) let denied: [String: Any] = [ "plugins": [ - "deny": ["codex-supervisor"], - "entries": ["codex-supervisor": ["enabled": true]], + "deny": ["codex"], + "entries": [ + "codex": [ + "enabled": true, + "config": ["supervision": ["enabled": true]], + ], + ], ], ] - #expect(!OpenClawConfigFile.explicitlyEnabledPlugin("codex-supervisor", root: denied)) + #expect(!OpenClawConfigFile.explicitlyEnabledPluginConfigFlag( + "codex", + path: ["supervision", "enabled"], + root: denied)) let omittedByAllowlist: [String: Any] = [ "plugins": [ "allow": ["other-plugin"], - "entries": ["codex-supervisor": ["enabled": true]], + "entries": [ + "codex": [ + "enabled": true, + "config": ["supervision": ["enabled": true]], + ], + ], ], ] - #expect(!OpenClawConfigFile.explicitlyEnabledPlugin( - "codex-supervisor", + #expect(!OpenClawConfigFile.configuredBundledPluginAllowed( + "codex", root: omittedByAllowlist)) + #expect(!MacNodeCodexThreadCatalog.shouldAdvertise(root: omittedByAllowlist)) let paddedIds: [String: Any] = [ "plugins": [ - "allow": [" codex-supervisor "], - "entries": [" codex-supervisor ": ["enabled": true]], + "allow": [" codex "], + "entries": [ + " codex ": [ + "enabled": true, + "config": ["supervision": ["enabled": true]], + ], + ], ], ] - #expect(OpenClawConfigFile.explicitlyEnabledPlugin( - "codex-supervisor", + #expect(OpenClawConfigFile.explicitlyEnabledPluginConfigFlag( + "codex", + path: ["supervision", "enabled"], root: paddedIds)) let paddedDeny: [String: Any] = [ "plugins": [ - "deny": [" codex-supervisor "], - "entries": ["codex-supervisor": ["enabled": true]], + "deny": [" codex "], + "entries": [ + "codex": [ + "enabled": true, + "config": ["supervision": ["enabled": true]], + ], + ], ], ] - #expect(!OpenClawConfigFile.explicitlyEnabledPlugin( - "codex-supervisor", + #expect(!OpenClawConfigFile.explicitlyEnabledPluginConfigFlag( + "codex", + path: ["supervision", "enabled"], root: paddedDeny)) + + let mixedCaseDeny: [String: Any] = [ + "plugins": [ + "deny": [" CoDeX "], + "entries": [ + "CODEX": [ + "enabled": true, + "config": ["supervision": ["enabled": true]], + ], + ], + ], + ] + #expect(!OpenClawConfigFile.explicitlyEnabledPluginConfigFlag( + "codex", + path: ["supervision", "enabled"], + root: mixedCaseDeny)) + #expect(!MacNodeCodexThreadCatalog.shouldAdvertise(root: mixedCaseDeny)) + + let ambiguousEntryAliases: [String: Any] = [ + "plugins": [ + "entries": [ + "CODEX": [ + "enabled": true, + "config": ["supervision": ["enabled": true]], + ], + "codex": [ + "enabled": false, + "config": ["supervision": ["enabled": false]], + ], + ], + ], + ] + #expect(OpenClawConfigFile.pluginEntry("codex", root: ambiguousEntryAliases) == nil) + #expect(!OpenClawConfigFile.explicitlyEnabledPluginConfigFlag( + "codex", + path: ["supervision", "enabled"], + root: ambiguousEntryAliases)) + #expect(!MacNodeCodexThreadCatalog.shouldAdvertise(root: ambiguousEntryAliases)) } @Test func `computer control cap gates the computer.act command`() { diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift index 569b886e2cd6..a96ff2df08ee 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift @@ -66,6 +66,9 @@ struct OnboardingAISetupTests { @Test func `codex activation covers install probe and finalization`() { #expect(OnboardingAISetupModel.activationRequestTimeoutMs(for: "codex-cli") == 480_000) #expect(OnboardingAISetupModel.activationRequestTimeoutMs(for: "claude-cli") == 150_000) + #expect(OnboardingAISetupModel.activationRequestTimeoutMs( + for: "api-key", + provisionsCodexSupervision: true) == 480_000) #expect(OnboardingAISetupModel.activationRequestTimeoutMs(for: "codex-cli") >= (305 + 90) * 1000) #expect(OnboardingAISetupModel.activationOutcomeDeadlineMs(for: "codex-cli") == 510_000) } diff --git a/config/knip.config.ts b/config/knip.config.ts index 484e85beb63b..5793c875c343 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -18,6 +18,7 @@ const rootEntries = [ "src/infra/kysely-node-sqlite.ts!", "src/infra/warning-filter.ts!", "src/infra/command-explainer/index.ts!", + "src/mcp/codex-supervision-tools-serve.ts!", bundledPluginFile("telegram", "src/audit.ts", "!"), bundledPluginFile("telegram", "src/token.ts", "!"), "src/hooks/bundled/*/handler.ts!", diff --git a/docs/cli/crestodian.md b/docs/cli/crestodian.md index 79b4088d7160..572587abe1d7 100644 --- a/docs/cli/crestodian.md +++ b/docs/cli/crestodian.md @@ -159,7 +159,16 @@ The first existing explicit model always wins, so setup preserves an existing If none are available, setup still writes the workspace and Gateway configuration, then asks whether to configure a model provider. Accepting opens the normal onboarding provider/auth and default-model steps. Declining leaves Crestodian in deterministic mode; exact setup and repair commands still work, but the normal agent cannot answer until a provider and default model are configured. Run `configure model provider` later to reopen the provider flow. -The macOS app drives the same ladder through the `crestodian.setup.detect` and `crestodian.setup.activate` gateway methods: detect lists every reusable backend it finds, activate live-tests one candidate (a real "reply with OK" completion) and only persists the model, workspace, and gateway defaults after the test passes. A failing candidate never persists a broken model or setup state; Codex may still record a managed plugin installation before its live test. The app automatically walks down the ladder and finally offers a manual key/token step populated from the Gateway's active text-inference provider plugins. The selected provider owns its starter model and config, and the credential is verified the same way before it is saved. +The macOS app drives the same ladder through the `crestodian.setup.detect` and `crestodian.setup.activate` gateway methods: detect lists every reusable backend it finds, activate live-tests one candidate (a real "reply with OK" completion), and only selects the model, workspace, Gateway defaults, and supervision after the test passes. A failed candidate does not select those defaults; an opportunistic managed Codex plugin install can remain recorded even when a later live probe fails. The app automatically walks down the ladder and finally offers a manual key/token step populated from the Gateway's active text-inference provider plugins. The selected provider owns its starter model and config, and the credential is verified the same way before it is saved. + +When setup detects a native Codex installation, successful backend activation +also attempts to install and enable the official `codex` plugin and its +supervision capability, even when another inference backend wins the ladder. +Native, non-archived Codex sessions become visible in both apps when that +opportunistic plugin activation succeeds. App Server availability is checked +when supervision first connects. An explicit Codex plugin disable or policy +block prevents opportunistic activation, and an existing +`plugins.entries.codex.config.supervision.enabled: false` remains an opt-out. ## AI conversation diff --git a/docs/docs.json b/docs/docs.json index ed83ac235f97..8a476f2c5fa0 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -132,6 +132,14 @@ "source": "/plugins/agent-tools", "destination": "/plugins/building-plugins#registering-agent-tools" }, + { + "source": "/plugins/reference/codex-supervisor", + "destination": "/plugins/codex-supervision" + }, + { + "source": "/specs/claw-supervisor", + "destination": "/specs/codex-supervision" + }, { "source": "/gateway/admin-http-rpc", "destination": "/plugins/admin-http-rpc" @@ -1269,9 +1277,10 @@ ] }, { - "group": "Bundled plugin guides", + "group": "Plugin guides", "pages": [ "plugins/codex-harness", + "plugins/codex-supervision", "plugins/codex-native-plugins", "plugins/codex-computer-use", "plugins/logbook", diff --git a/docs/docs_map.md b/docs/docs_map.md index a22b7182a7e8..8edd52b07404 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -5386,6 +5386,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Route: /plugins/codex-harness-reference - Headings: - H2: Plugin config surface + - H2: Supervision - H2: App-server transport - H2: Approval and sandbox modes - H2: Sandboxed native execution @@ -5403,6 +5404,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Headings: - H2: Overview - H2: Thread bindings and model changes + - H2: Supervision and safe continuation - H2: Visible replies and heartbeats - H2: Hook boundaries - H2: V1 support contract @@ -5420,6 +5422,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Requirements - H2: Quickstart - H2: Share threads with Codex Desktop and CLI + - H2: Supervise Codex sessions - H2: Configuration - H3: Compaction - H2: Verify Codex runtime @@ -5459,6 +5462,21 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Troubleshooting - H2: Related +## plugins/codex-supervision.md + +- Route: /plugins/codex-supervision +- Headings: + - H2: Before you begin + - H2: Enable supervision + - H2: Use the operator CLI + - H2: Branch from a local session + - H2: Archive a local session + - H2: Understand paired-node limits + - H2: Metadata and permissions + - H3: Compatibility tools + - H2: Troubleshooting + - H2: Related + ## plugins/community.md - Route: /plugins/community @@ -5927,20 +5945,6 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Surface - H2: Related docs -## plugins/reference/codex-supervisor.md - -- Route: /plugins/reference/codex-supervisor -- Headings: - - H1: Codex Supervisor plugin - - H2: Distribution - - H2: Surface - - H2: Enable the plugin - - H2: App Server selection - - H2: List sessions from the CLI - - H2: Use the Control UI - - H2: Metadata and security boundary - - H2: Supervisor agent session listing - ## plugins/reference/codex.md - Route: /plugins/reference/codex @@ -6969,6 +6973,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: What core still owns - H3: Harness-owned auth bootstrap - H2: Register a harness + - H3: Delegated execution - H2: Selection policy - H2: Provider plus harness pairing - H3: Tool-result middleware @@ -8765,25 +8770,24 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Recommended blocked destinations - H2: Limits -## specs/claw-supervisor.md +## specs/codex-supervision.md -- Route: /specs/claw-supervisor +- Route: /specs/codex-supervision - Headings: - - H1: Claw Supervisor + - H1: Codex supervision - H2: Goal - - H2: Product Model - - H2: Architecture - - H2: Federated Session Catalog - - H2: Codex App-Server Contract - - H2: Session Registry - - H2: MCP Surface For Codex - - H2: Claw Control Surface - - H2: Launch Flow - - H2: Deployment - - H2: Security - - H2: Implementation Plan - - H2: Acceptance Tests - - H2: Open Questions + - H2: Product boundary + - H2: Ownership + - H2: Catalog flow + - H2: Operator CLI boundary + - H2: Local continuation + - H2: Archive behavior + - H2: Active thread safety + - H2: Paired-node boundary + - H2: Permissions + - H2: Compatibility + - H2: Future work + - H2: Acceptance tests ## start/bootstrapping.md diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 986aac4e2dfc..6f25a9149933 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -713,18 +713,18 @@ The Control UI needs a secure context (HTTPS or localhost) to generate device id Assume anything under `~/.openclaw/` (or `$OPENCLAW_STATE_DIR/`) may contain secrets or private data: -| Path | Contents | -| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `openclaw.json` | Config may include tokens (gateway, remote gateway), provider settings, and allowlists. | -| `credentials/**` | Channel credentials (for example WhatsApp creds), pairing allowlists, legacy OAuth imports. | -| `agents//agent/auth-profiles.json` | API keys, token profiles, OAuth tokens, optional `keyRef`/`tokenRef`. | -| `agents//agent/codex-home/**` | Per-agent Codex app-server account, config, skills, plugins, native thread state, diagnostics (default). | -| `$CODEX_HOME/**` or `~/.codex/**` | Opt-in shared Codex runtime state, only when `plugins.entries.codex.config.appServer.homeScope` is `"user"`. Uses the native Codex account, config, plugins, and thread store; enable only for an owner-controlled local Gateway. See [Codex harness](/plugins/codex-harness#share-threads-with-codex-desktop-and-cli). | -| `secrets.json` (optional) | File-backed secret payload used by `file` SecretRef providers (`secrets.providers`). | -| `agents//agent/auth.json` | Legacy compatibility file; static `api_key` entries are scrubbed when discovered. | -| `agents//sessions/**` | Session transcripts (`*.jsonl`) + routing metadata (`sessions.json`) that can contain private messages and tool output. | -| bundled plugin packages | Installed plugins (plus their `node_modules/`). | -| `sandboxes/**` | Tool sandbox workspaces; can accumulate copies of files read/written inside the sandbox. | +| Path | Contents | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `openclaw.json` | Config may include tokens (gateway, remote gateway), provider settings, and allowlists. | +| `credentials/**` | Channel credentials (for example WhatsApp creds), pairing allowlists, legacy OAuth imports. | +| `agents//agent/auth-profiles.json` | API keys, token profiles, OAuth tokens, optional `keyRef`/`tokenRef`. | +| `agents//agent/codex-home/**` | Per-agent Codex app-server account, config, skills, plugins, native thread state, diagnostics (default). | +| `$CODEX_HOME/**` or `~/.codex/**` | Native Codex runtime state. The ordinary harness accesses it only with explicit `plugins.entries.codex.config.appServer.homeScope: "user"`. The separate supervision connection accesses it when its resolved home scope is `"user"`, which is the default for stdio or Unix when unset. Contains the native Codex account, config, plugins, and thread store. Supervision lists source metadata and keeps a continued Chat's canonical native branch and later turns on that connection; branching copies bounded persisted user and assistant history into an authenticated, model-locked OpenClaw Chat. Enable only for an owner-controlled Gateway. See [Codex harness](/plugins/codex-harness#share-threads-with-codex-desktop-and-cli) and [Codex supervision](/plugins/codex-supervision). | +| `secrets.json` (optional) | File-backed secret payload used by `file` SecretRef providers (`secrets.providers`). | +| `agents//agent/auth.json` | Legacy compatibility file; static `api_key` entries are scrubbed when discovered. | +| `agents//sessions/**` | Session transcripts (`*.jsonl`) + routing metadata (`sessions.json`) that can contain private messages and tool output. | +| bundled plugin packages | Installed plugins (plus their `node_modules/`). | +| `sandboxes/**` | Tool sandbox workspaces; can accumulate copies of files read/written inside the sandbox. | ### Credential storage map diff --git a/docs/nodes/index.md b/docs/nodes/index.md index c914fc1d8b25..195399ee8d30 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -193,18 +193,28 @@ A desktop or server node can expose chat-capable models from an Ollama server ru ### Codex session catalog -The opt-in `codex-supervisor` plugin lets a headless node host or the native -macOS node expose metadata for its local interactive Codex sessions. Enable the -plugin independently in the node's local config and on the Gateway. The node -setting is local consent; enabling only the Gateway cannot read another -computer's Codex state. +The official `codex` plugin can expose metadata for non-archived Codex sessions +on a headless node host or native macOS node. +Enable `plugins.entries.codex.config.supervision.enabled` independently in the +node's local config and on the Gateway. The node setting is local consent; +enabling only the Gateway cannot read another computer's Codex state. The node advertises the versioned read-only `codex.appServer.threads.list.v1` command. Approve the node pairing upgrade when that command first appears. The Gateway invokes it through the normal plugin -node policy and isolates failures by host. See the [Codex Supervisor plugin -reference](/plugins/reference/codex-supervisor) for configuration, CLI and -Control UI use, pagination, and the metadata security boundary. +node policy and isolates failures by host. + +Paired-node rows are visible in **Codex Sessions**, but remain read-only in the +initial release. The node invoke transport is request/response only and cannot +carry the streaming turns, live events, or approvals required to continue a +native thread through the Codex harness. **Continue** and **Archive** are +therefore unavailable for remote rows. On the Gateway computer, stored and idle +rows can start a distinct model-locked Chat branch. Either can be archived only +after the operator confirms that no other Codex client is using it; a stored +row's live activity remains unknown. Active rows cannot branch or archive. + +See [Supervise Codex sessions](/plugins/codex-supervision) for setup, +pagination, local continuation, and the metadata security boundary. ## Invoking commands diff --git a/docs/plugins/codex-harness-reference.md b/docs/plugins/codex-harness-reference.md index 097b109757ba..b74a145bc8df 100644 --- a/docs/plugins/codex-harness-reference.md +++ b/docs/plugins/codex-harness-reference.md @@ -7,7 +7,7 @@ read_when: - You are debugging Codex harness startup, model discovery, or environment isolation --- -This reference covers detailed configuration for the bundled `codex` plugin. +This reference covers detailed configuration for the official `codex` plugin. For setup and routing decisions, start with [Codex harness](/plugins/codex-harness). @@ -41,25 +41,114 @@ Top-level fields: | Field | Default | Meaning | | -------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `discovery` | enabled | Model discovery settings for Codex app-server `model/list`. | -| `appServer` | managed stdio app-server | Transport, command, auth, approval, sandbox, and timeout settings. | +| `appServer` | managed stdio app-server | Transport, command, auth, approval, sandbox, and timeout settings. The ordinary harness defaults to agent-scoped state. | | `codexDynamicToolsLoading` | `"searchable"` | Use `"direct"` to put OpenClaw dynamic tools directly in the initial Codex tool context. | | `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. | | `codexPlugins` | disabled | Native Codex plugin/app support, including opt-in access to connected account apps. See [Native Codex plugins](/plugins/codex-native-plugins). | | `computerUse` | disabled | Codex Computer Use setup. See [Codex Computer Use](/plugins/codex-computer-use). | +| `supervision` | disabled | Non-archived native-session catalog, local branch continuation, and agent-tool policy. See [Codex supervision](/plugins/codex-supervision). | + +## Supervision + +Supervision lists non-archived Codex sessions from the Gateway computer and +opted-in paired nodes. Enable it independently from the agent harness: + +```json5 +{ + plugins: { + entries: { + codex: { + enabled: true, + config: { + supervision: { + enabled: true, + }, + }, + }, + }, + }, +} +``` + +`supervision` fields: + +| Field | Default | Meaning | +| --------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | `false` | Advertise the local session catalog and, on the Gateway, aggregate opted-in paired-node catalogs for the Codex Sessions page. | +| `endpoints` | built-in local endpoint | Compatibility and advanced endpoint targets for the retained Codex supervision agent and standalone MCP tools. The human catalog and branch flow ignore these targets and use the supervision App Server resolved from `appServer`. | +| `allowRawTranscripts` | `false` | With supervision enabled, allow autonomous agent or standalone MCP transcript reads and transcript-derived list fields. `codex_threads` metadata-only reads remain available. Does not control authenticated Control UI continuation. | +| `allowWriteControls` | `false` | With supervision enabled, allow autonomous `codex_threads` fork, rename, archive, and unarchive mutations plus standalone MCP send, steer, and interrupt operations. Does not bypass other binding, host, status, or confirmation checks. | + +Endpoint entries accept these fields: + +| Field | Applies to | Meaning | +| -------------- | ------------- | --------------------------------------------------------------------- | +| `id` | all | Stable endpoint id. | +| `label` | all | Optional display label. | +| `transport` | all | `"stdio-proxy"` or `"websocket"`. | +| `command` | `stdio-proxy` | Optional App Server command. | +| `args` | `stdio-proxy` | Optional command arguments. | +| `cwd` | `stdio-proxy` | Optional child-process working directory. | +| `url` | `websocket` | Required WebSocket or supported local socket URL. | +| `authTokenEnv` | `websocket` | Optional environment variable whose value authenticates the endpoint. | + +The **Codex Sessions** page uses the plugin's supervision App Server and shows +only non-archived sessions. Without explicit `appServer` connection settings, +that connection is managed user-home stdio. Stored or idle local rows can create +a model-locked Chat with bounded user and assistant history through the last +terminal persisted source turn. Its private binding keeps the snapshot fork, +canonical `appServer`-source branch, history injection, and later turns on that +connection. The first canonical start uses the pair returned by the fork. Later +resumes omit OpenClaw model and provider overrides so Codex restores the +canonical thread's persisted pair; a separate native change can update that +pair, but the outer model and fallback chain never replace it. Stored and idle +rows can be archived after no-other-runner confirmation, unless another active +OpenClaw binding owns the exact target or one of its non-archived spawned +descendants. OpenClaw follows Codex's descendant pagination and fails closed on +enumeration errors, cycles, or safety-limit exhaustion. Confirmation still +covers unknown native clients and the status-to-archive race. A supervised +model-locked Chat cannot be deleted while it protects the native binding. +Active sources cannot create a branch or be archived, but an existing supervised +Chat can still be opened. Every paired-node row stays read-only; the node +transport does not yet provide the streaming lifecycle needed by the harness. + +`appServer.homeScope: "user"` alone changes which Codex home a managed harness +process uses; it does not publish the fleet catalog. Enabling supervision does +not change the harness default. Instead, the separate supervision connection +defaults to managed user-home stdio when no explicit `appServer` +connection settings exist. Explicit settings are honored for that connection. +Pending and committed supervised bindings retain that connection for every turn; +disabled supervision or connection/lifecycle drift fails closed instead of +falling back to the agent-home harness. The default connection shares stored +sessions with native Codex clients, not their process-local activity state. + +Legacy `plugins.entries.codex-supervisor` settings are retired. Run +`openclaw doctor --fix` to migrate the old entry, endpoint definitions, policy +flags, and plugin allow/deny references into this block. Explicit canonical +`codex.config.supervision` values win conflicts. ## App-server transport -By default OpenClaw starts the managed Codex binary shipped with the bundled -plugin (currently `@openai/codex` `0.144.1`): +For ordinary harness turns, OpenClaw starts the managed Codex binary shipped +with the official plugin (currently `@openai/codex` `0.144.1`): ```bash codex app-server --listen stdio:// ``` -This keeps the app-server version tied to the bundled `codex` plugin instead of +This keeps the app-server version tied to the official `codex` plugin instead of whichever separate Codex CLI happens to be installed locally. Set `appServer.command` only when you intentionally want a different executable. +Supervision resolves a separate connection. With no explicit +`appServer` connection settings, it uses managed stdio with `homeScope: "user"`; +the ordinary harness remains managed stdio with `homeScope: "agent"`. Explicit +connection settings are honored by both paths. Set `homeScope: "user"` +explicitly when the ordinary harness should share `$CODEX_HOME` (or `~/.codex`) +with native clients. A private supervised binding uses the supervision +connection regardless of the ordinary harness default. Independent App Server +processes retain separate live status and approval state. + For an already-running app-server, use WebSocket transport: ```json5 @@ -86,11 +175,11 @@ For an already-running app-server, use WebSocket transport: | Field | Default | Meaning | | --------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. | -| `homeScope` | `"agent"` | `"agent"` isolates Codex state per OpenClaw agent. `"user"` shares the native `$CODEX_HOME` or `~/.codex`, uses native auth, and enables owner-only thread management. User scope requires stdio. | +| `transport` | `"stdio"` | `"stdio"` spawns Codex; explicit `"unix"` connects to the local control socket; `"websocket"` connects to `url`. | +| `homeScope` | `"agent"` | `"agent"` isolates ordinary harness state per OpenClaw agent. `"user"` is an explicit opt-in that shares the native `$CODEX_HOME` or `~/.codex`, uses native auth, and enables owner-only thread management. User scope supports local stdio or Unix transport. For the separate supervision connection, an unset value resolves to `"user"` for stdio or Unix and `"agent"` for WebSocket. | | `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary. | | `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. | -| `url` | unset | WebSocket app-server URL. | +| `url` | unset | WebSocket App Server URL or `unix://` URL. An empty explicit Unix path selects the canonical user-home control socket. | | `authToken` | unset | Bearer token for WebSocket transport. Accepts a literal string or SecretInput such as `${CODEX_APP_SERVER_TOKEN}`. | | `headers` | `{}` | Extra WebSocket headers. Header values accept literal strings or SecretInput values, for example `x-codex-client-session-token: "${CODEX_CLIENT_SESSION_TOKEN}"`. | | `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. | @@ -296,14 +385,28 @@ config, accounts, plugin cache/data, and thread state scoped to the OpenClaw agent instead of leaking in from the operator's personal `~/.codex` home. Set `appServer.homeScope: "user"` to share native Codex state with Codex -Desktop and the CLI. This local-stdio-only mode uses `$CODEX_HOME` when set -and `~/.codex` otherwise, including native auth, config, plugins, and threads. +Desktop and the CLI. This local user-home mode supports managed stdio and +explicit Unix transport. It uses `$CODEX_HOME` when set and `~/.codex` +otherwise, including native auth, config, plugins, and threads. OpenClaw skips its auth-profile bridge for the app-server. Verified owner turns can use `codex_threads` to list (with an optional `search` filter), read, fork, rename, archive, and unarchive those threads. Fork a thread before continuing it in OpenClaw; independent Codex processes do not coordinate concurrent writers for the same thread. +That `homeScope` opt-in applies to ordinary harness sessions. A Chat created +through Codex Sessions uses its private supervision connection instead, which +preserves the native connection's auth and provider configuration for the +canonical branch and future resumes. + +In a model-locked supervised Chat, `codex_threads` cannot attach a different +fork or archive the Chat's bound native thread. List and metadata-only read +remain available. Raw transcript reads require `allowRawTranscripts`; when it +is disabled, list search is also rejected because native search can match +transcript previews. Rename, unarchive, detached fork, and archive of an +unrelated thread not owned by another OpenClaw Chat require +`allowWriteControls`. Neither option bypasses a locked binding. + OpenClaw does not rewrite `HOME` for normal local app-server launches. Codex-run subprocesses such as `openclaw`, `gh`, `git`, cloud CLIs, and shell commands see the normal process home and can find user-home config and @@ -590,6 +693,7 @@ the same reviewed file as the rest of the Codex harness setup. - [Codex harness](/plugins/codex-harness) - [Codex harness runtime](/plugins/codex-harness-runtime) +- [Codex supervision](/plugins/codex-supervision) - [Native Codex plugins](/plugins/codex-native-plugins) - [Codex Computer Use](/plugins/codex-computer-use) - [OpenAI provider](/providers/openai) diff --git a/docs/plugins/codex-harness-runtime.md b/docs/plugins/codex-harness-runtime.md index 1f6330230903..e6f9a2ff40ce 100644 --- a/docs/plugins/codex-harness-runtime.md +++ b/docs/plugins/codex-harness-runtime.md @@ -49,6 +49,69 @@ approvals reviewer, and service tier to app-server. Switching from `openai/gpt-5.5` to `openai/gpt-5.2` keeps the thread binding but asks Codex to continue with the newly selected model. +Supervised bindings are the exception. The OpenClaw model picker stays locked, +and resumes omit model and provider overrides so Codex restores the canonical +thread's persisted model and provider. A separate native Codex control can +change that persisted pair, and the initial snapshot can produce Codex's normal +model-difference warning; the outer OpenClaw model and fallback chain never +substitute for either. + +## Supervision and safe continuation + +Codex supervision is an opt-in capability of the same `codex` plugin. It discovers +native threads through a separate connection and projects only non-archived +sessions into the Gateway catalog. Without explicit `appServer` connection +settings, that connection uses managed user-home stdio while the ordinary +harness remains agent-scoped. Listing and metadata reads are passive: they do +not resume a thread, subscribe OpenClaw to its live events, or answer its +approvals. + +For a stored or idle session on the Gateway computer, **Continue as branch** +creates a normal, model-locked Chat and mirrors bounded user and assistant +history through the source's last terminal persisted turn. The first normal +Chat turn installs the real approval handlers and uses a temporary native fork +to pin the snapshot without a model or provider override. Codex App Server uses +its current native configuration and returns the selected pair; it emits its +normal warning if that model differs from the source's last recorded model. +On the same supervision connection, OpenClaw starts the canonical +`appServer`-source Codex harness thread under its cwd and runtime policy with +exactly the returned model and provider for that initial start, injects the +bounded visible history, and archives the temporary fork. The source is never +resumed. The canonical thread has the full OpenClaw harness tool surface; +reasoning, tool calls, and tool results from the source are not cloned into it. +The private connection scope survives pending and committed binding states, so +every later turn remains on that connection with native auth and provider +configuration. Disabled supervision or binding/connection drift fails closed +rather than switching to the ordinary agent-home harness. + +The original CLI or VS Code source remains eligible for both catalogs. The +canonical branch is a native Codex thread, but its source kind is `appServer`; +native clients may filter that source kind, so its appearance in Codex Desktop +is not guaranteed. + +Active sources cannot start a new branch or be archived; an existing supervised +Chat can still be opened. `notLoaded` means activity is unknown, not idle; +OpenClaw allows archive for a local `idle` or `notLoaded` row only after explicit +no-other-runner confirmation and a fresh process-local status read. Codex +serializes thread mutations within one App Server process but does not provide +an exclusive cross-process runner or approval-owner lease, so that read cannot +prove that another process is not using the thread. OpenClaw blocks a known +active binding owner for the exact target or any non-archived spawned descendant +returned by Codex's paginated descendant query. Enumeration errors, cycles, and +safety-limit exhaustion fail closed. Native archive can still race a new turn +in another process, so confirmation covers unknown clients and the gap between +status read and archive. A supervised model-locked Chat cannot be deleted while +it protects the native binding. + +Paired-node catalogs stay metadata-only in the initial release. The current +node invoke boundary is request/response and cannot carry the long-lived turn +events, approval requests, or streaming output required by a real Codex harness +binding. Remote **Continue** and **Archive** therefore remain unavailable even +when the row is idle. + +See [Codex supervision](/plugins/codex-supervision) for operator setup and the +visible Control UI behavior. + ## Visible replies and heartbeats Direct/source chat turns through the Codex harness default to automatic final @@ -262,6 +325,7 @@ path even if the Codex turn has no assistant text. - [Codex harness](/plugins/codex-harness) - [Codex harness reference](/plugins/codex-harness-reference) +- [Codex supervision](/plugins/codex-supervision) - [Native Codex plugins](/plugins/codex-native-plugins) - [Plugin hooks](/plugins/hooks) - [Agent harness plugins](/plugins/sdk-agent-harness) diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 9aa8fa2e4a9d..5ea77a4509ea 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -1,13 +1,13 @@ --- -summary: "Run OpenClaw embedded agent turns through the bundled Codex app-server harness" +summary: "Run OpenClaw embedded agent turns through the official Codex app-server harness" title: "Codex harness" read_when: - - You want to use the bundled Codex app-server harness + - You want to use the official Codex app-server harness - You need Codex harness config examples - You want Codex-only deployments to fail instead of falling back to OpenClaw --- -The bundled `codex` plugin runs embedded OpenAI agent turns through Codex +The official `codex` plugin runs embedded OpenAI agent turns through Codex app-server instead of the built-in OpenClaw harness. Codex owns the low-level agent session: native thread resume, native tool continuation, native compaction, and app-server execution. OpenClaw still owns chat @@ -42,7 +42,7 @@ channel is the communication surface. ## Requirements -- OpenClaw with the bundled `codex` plugin available. Include `codex` in +- The official `@openclaw/codex` plugin installed. Include `codex` in `plugins.allow` if your config uses an allowlist. - Codex app-server `0.143.0` or newer. The plugin manages a compatible binary by default, so a `codex` command on `PATH` does not affect normal @@ -57,13 +57,14 @@ model discovery, and the full config field list, see ## Quickstart -Sign in with Codex OAuth: +Install the official plugin, then sign in with Codex OAuth: ```bash +openclaw plugins install @openclaw/codex openclaw models auth login --provider openai ``` -Enable the bundled `codex` plugin and select an OpenAI agent model: +Enable the `codex` plugin and select an OpenAI agent model: ```json5 { @@ -125,27 +126,55 @@ user Codex home: } ``` -User-home mode requires local stdio transport. It uses `$CODEX_HOME` when -set and `~/.codex` otherwise, including that home's native Codex auth, -config, plugins, and thread store. OpenClaw does not inject an OpenClaw auth -profile into this app-server. +User-home mode supports a local managed stdio process or the shared Unix-socket +transport. It uses `$CODEX_HOME` when set and `~/.codex` otherwise, including +that home's native Codex auth, config, plugins, and thread store. OpenClaw does +not inject an OpenClaw auth profile into this app-server. Owner turns gain the `codex_threads` tool: list, search, read, fork, rename, archive, and restore native threads. Fork a thread to continue it in OpenClaw; the fork attaches to the current OpenClaw session and stays visible to other native Codex clients. Archiving requires explicit -confirmation that the thread is closed elsewhere. +confirmation that the thread is closed elsewhere. When supervision is also +enabled, transcript fields and mutations require the matching +`supervision.allowRawTranscripts` or `supervision.allowWriteControls` opt-in. -Do not resume or write the same thread concurrently from OpenClaw and -another Codex client. Codex coordinates live writers inside one app-server -process, not across independent Desktop, CLI, and OpenClaw processes. -Forking is the safe coexistence path. +Do not resume or write the same thread concurrently through independent managed +stdio App Servers. Codex coordinates live writers inside one App Server, not +across separate processes. Forking is the safe coexistence path for ordinary +user-home stdio sessions. + +`appServer.homeScope: "user"` alone does not enable the fleet catalog. Use +`supervision.enabled: true` when you want native sessions to appear in the +OpenClaw sidebar. Supervision uses a separate supervision connection; without +explicit `appServer` connection settings, that connection defaults to managed +user-home stdio while the ordinary harness stays agent-scoped. Explicit +`appServer` settings are honored by both paths. Set `homeScope: "user"` +explicitly, as above, when the ordinary harness should also share native state. + +## Supervise Codex sessions + +The same `codex` plugin can list non-archived Codex sessions from the Gateway +computer and opted-in paired nodes. A stored or idle Gateway-local session can +create a model-locked Chat that mirrors its bounded persisted user and assistant +history. Its private binding uses the supervision connection for the native +snapshot, canonical branch, and later turns while ordinary Codex sessions remain +agent-scoped. The first canonical start uses exactly the model and provider that +Codex returns for the snapshot fork. Later resumes leave selection to Codex's +native configuration; the outer OpenClaw model and fallback chain never replace +it. Stored and idle rows can be archived after explicit no-other-runner +confirmation. Active sources cannot create a branch or be archived; an existing +supervised Chat can still be opened. Paired-node sessions remain metadata-only. + +See [Supervise Codex sessions](/plugins/codex-supervision) for setup, branching +rules, paired-node limits, metadata exposure, and troubleshooting. ## Configuration | Need | Set | Where | | -------------------------------------- | -------------------------------------------------------------------------------- | ---------------------------------- | | Enable the harness | `plugins.entries.codex.enabled: true` | OpenClaw config | +| Show non-archived Codex sessions | `plugins.entries.codex.config.supervision.enabled: true` | Codex plugin config | | Keep an allowlisted plugin install | Include `codex` in `plugins.allow` | OpenClaw config | | Route OpenAI agent turns through Codex | `agents.defaults.model` or `agents.list[].model` as `openai/gpt-*` | OpenClaw agent config | | Sign in with ChatGPT/Codex OAuth | `openclaw models auth login --provider openai` | CLI auth profile | @@ -248,7 +277,8 @@ Keep provider refs and runtime policy separate: | List or filter Codex threads | `/codex threads [filter]` | | List native Codex plugins | `/codex plugins list` | | Enable or disable a configured native Codex plugin | `/codex plugins enable `, `/codex plugins disable ` | -| Attach an existing Codex CLI session on a paired node | `/codex sessions --host [filter]`, then `/codex resume --host --bind here` | +| Resume a stored Codex CLI session as a paired-node turn | `/codex sessions --host [filter]`, then `/codex resume --host --bind here` | +| View non-archived Codex sessions across computers | Enable Codex supervision and open **Codex Sessions** | | Change the bound thread's model, fast-mode, or permissions | `/codex model `, `/codex fast [on\|off\|status]`, `/codex permissions [default\|yolo\|status]` | | Stop or steer the active turn | `/codex stop`, `/codex steer ` | | Detach the current binding | `/codex detach` (alias `/codex unbind`) | @@ -436,7 +466,7 @@ see [Codex harness reference](/plugins/codex-harness-reference). ## Commands and diagnostics -The bundled plugin registers `/codex` as a slash command on any channel that +The `codex` plugin registers `/codex` as a slash command on any channel that supports OpenClaw text commands. Native execution and control require an owner or an `operator.admin` @@ -625,16 +655,17 @@ Supported top-level Codex plugin fields: | `codexDynamicToolsLoading` | `"searchable"` | Use `"direct"` to put OpenClaw dynamic tools directly in the initial Codex tool context. | | `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. | | `codexPlugins` | disabled | Native Codex plugin/app support for migrated source-installed curated plugins. | +| `supervision` | disabled | Non-archived native-session catalog, local branch continuation, and agent-tool policy. | Supported `appServer` fields: | Field | Default | Meaning | | --------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. | -| `homeScope` | `"agent"` | `"agent"` isolates Codex state per OpenClaw agent. `"user"` shares the native `$CODEX_HOME` or `~/.codex`, uses native auth, and enables owner-only thread management. User scope requires stdio. | +| `transport` | `"stdio"` | `"stdio"` spawns Codex; explicit `"unix"` connects to the local control socket; `"websocket"` connects to `url`. | +| `homeScope` | `"agent"` | `"agent"` isolates ordinary harness state per OpenClaw agent. `"user"` is an explicit opt-in that shares the native `$CODEX_HOME` or `~/.codex`, uses native auth, and enables owner-only thread management. User scope supports local stdio or Unix transport. For the separate supervision connection, an unset value resolves to `"user"` for stdio or Unix and `"agent"` for WebSocket. | | `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. | | `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. | -| `url` | unset | WebSocket app-server URL. | +| `url` | unset | WebSocket App Server URL or `unix://` URL. An empty explicit Unix path selects the canonical user-home control socket. | | `authToken` | unset | Bearer token for WebSocket transport. Accepts a literal string or SecretInput such as `${CODEX_APP_SERVER_TOKEN}`. | | `headers` | `{}` | Extra WebSocket headers. Header values accept literal strings or SecretInput values, for example `x-codex-client-session-token: "${CODEX_CLIENT_SESSION_TOKEN}"`. | | `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. OpenClaw keeps the selected `CODEX_HOME` and inherited `HOME` for local launches. | @@ -916,7 +947,7 @@ Same-version prereleases or build-suffixed versions such as `0.143.0-alpha.2` or `0.143.0+custom` are rejected because OpenClaw tests the stable `0.143.0` protocol floor. -**`/codex status` cannot connect:** check that the bundled `codex` plugin +**`/codex status` cannot connect:** check that the `codex` plugin is enabled, that `plugins.allow` includes it when an allowlist is configured, and that any custom `appServer.command`, `url`, `authToken`, or headers are valid. @@ -953,6 +984,7 @@ See [Codex Computer Use](/plugins/codex-computer-use#troubleshooting). - [Codex harness reference](/plugins/codex-harness-reference) - [Codex harness runtime](/plugins/codex-harness-runtime) +- [Codex supervision](/plugins/codex-supervision) - [Native Codex plugins](/plugins/codex-native-plugins) - [Codex Computer Use](/plugins/codex-computer-use) - [Agent runtimes](/concepts/agent-runtimes) diff --git a/docs/plugins/codex-supervision.md b/docs/plugins/codex-supervision.md new file mode 100644 index 000000000000..36cd18a44e8d --- /dev/null +++ b/docs/plugins/codex-supervision.md @@ -0,0 +1,399 @@ +--- +summary: "List non-archived native Codex sessions and branch from eligible local sessions in OpenClaw" +title: "Supervise Codex sessions" +sidebarTitle: "Codex supervision" +read_when: + - You want Codex Desktop or CLI sessions to appear in OpenClaw + - You need to branch from or archive a stored or idle local Codex session + - You are exposing Codex session metadata from paired nodes +--- + +Codex supervision is an opt-in capability of the official `codex` plugin. It +shows non-archived Codex Desktop and CLI source sessions from the Gateway +computer and opted-in paired computers in one **Codex Sessions** page. + +The initial release deliberately keeps ownership narrow: + +- A stored or idle local session can create a model-locked OpenClaw Chat from + its bounded persisted user and assistant history. The first message starts a + native snapshot fork, then starts the full Codex harness thread with exactly + the model and provider that Codex App Server selected for that fork. Later + turns restore the canonical native thread's persisted pair while the + supervised binding prevents OpenClaw from substituting another runtime, + model, or fallback. A separate native Codex control can still change that + persisted pair. An already-created branch opens its existing Chat. +- A stored session discovered from another Codex process has unknown live + activity. It can branch, or it can be archived only after the operator + confirms that no other Codex client is using it. +- An active source stays visible but cannot create a branch or be archived until + its current turn finishes. If it already has a supervised Chat, **Open Chat** + remains available. +- A session on a paired node stays visible as metadata only. Remote continuation + requires a future streaming node bridge; remote archive additionally requires + a runner-ownership lease or equivalent fencing. +- Archived sessions are not listed. A stored or idle local session can be + archived only after the operator confirms that no other Codex client is using + it. + +## Before you begin + +- Install the official `@openclaw/codex` plugin on the Gateway. The OpenClaw + macOS app can install it when you enable Codex features; CLI installations can + run `openclaw plugins install @openclaw/codex`. +- Install and sign in to Codex Desktop or the Codex CLI on each computer whose + sessions you want to list. +- Pair remote computers as OpenClaw nodes. Each computer must opt in locally; + enabling supervision only on the Gateway does not authorize another node. +- Use an owner-controlled Gateway. Session titles, working directories, and Git + branches can reveal sensitive project information. + +## Enable supervision + +Guided `openclaw onboard` and macOS first-run setup attempt to install and +enable Codex supervision after detecting a native Codex installation and +successfully activating the selected inference backend. Codex does not need to +be the primary backend. Supervision becomes available when that opportunistic +plugin activation succeeds. App Server availability is checked when +supervision first connects. An explicit Codex plugin disable or policy block +prevents opportunistic activation, and an existing explicit +`supervision.enabled: false` remains an opt-out. +Existing installations can enable the same capability manually: + +Enable the `codex` plugin and its supervision capability in `openclaw.json`: + +```json5 +{ + plugins: { + entries: { + codex: { + enabled: true, + config: { + supervision: { + enabled: true, + }, + }, + }, + }, + }, +} +``` + +If `plugins.allow` is present, include `codex`. Restart the Gateway after +changing plugin activation. + +With no explicit `appServer` connection settings, supervision uses a separate +managed stdio supervision connection against the native user Codex home. The +ordinary Codex harness remains agent-scoped by default. This makes native +sessions visible in both apps without making ordinary OpenClaw turns share +native Codex state. Set `appServer.homeScope: "user"` explicitly if the harness +should share that state too. Supervision honors explicit `appServer` connection +settings instead of replacing them with its local user-home default. + +A Chat created through **Codex Sessions** is not an ordinary harness session. +Its private supervision binding uses the supervision connection for source +reads, canonical branch creation, history injection, and every later turn. With +the default local connection, that preserves the native user Codex home, auth, +and provider configuration without changing the default for other sessions. + +For the default local supervision connection, the store is shared with native +Codex clients. OpenClaw does not assume that another client shares the same live +App Server process, and native status ownership is process-local. It therefore +treats a thread that its supervision App Server reports as `notLoaded` as +**Stored / activity unknown**, not as idle. + +Apply the same opt-in on every headless node host whose sessions should appear. +The native OpenClaw macOS app reads the same local setting when it advertises +its Codex catalog to the paired Gateway. That paired native Mac catalog supports +only the default or explicit `appServer.transport: "stdio"` with an unset or +explicit `appServer.homeScope: "user"`. `command`, `args`, and `clearEnv` are +honored for that stdio process. If the Mac config selects `"unix"`, +`"websocket"`, or `homeScope: "agent"`, the app does not advertise the catalog +capability or command, and a stale direct invocation fails instead of exposing +the user Codex home or spawning a different local stdio App Server. + +A newly advertised node command changes the node's approved command surface. +Approve the update from the Gateway host: + +```bash +openclaw nodes pending +openclaw nodes approve +``` + +Open **Codex Sessions** in the Control UI. The page lists non-archived sessions +grouped by host. Search matches normalized session titles; refresh and per-host +pagination preserve healthy hosts when another host is offline or unavailable. +Each returned search page scans a bounded number of native pages per host +rather than sending the query to App Server, because native search can also +match transcript previews. Use **Load more** to continue older results. + +Host availability and thread status are separate. **Offline** or **Unavailable** +describes a host refresh; an unavailable host returns no fresh session rows and +does not change a thread's native status to `offline`. Session rows use Codex +statuses such as `idle`, `active`, `notLoaded`, or error. A failed host does not +hide results from healthy hosts. + +## Use the operator CLI + +The terminal CLI exposes the same non-archived catalog and Gateway-local branch +and archive actions: + +```bash +openclaw codex sessions [--search ] [--host ] [--limit ] [--cursor ] [--json] [--url ] [--token ] [--timeout ] [--expect-final] +openclaw codex continue [--json] [--url ] [--token ] [--timeout ] [--expect-final] +openclaw codex archive --confirm-no-other-runner [--json] [--url ] [--token ] [--timeout ] [--expect-final] +``` + +`openclaw codex sessions` options: + +- `--search ` searches session titles case-insensitively. +- `--host ` limits the response to one stable catalog host, such as + `gateway:local` or `node:`. +- `--limit ` sets 1 through 100 rows per host; the default is 50. +- `--cursor ` continues one host page and therefore requires `--host`. +- `--json` prints the structured Gateway response. + +All three commands inherit `--url`, `--token`, and `--timeout ` from the +Gateway client; the default timeout is 30,000 ms. They also expose the shared +`--expect-final` switch, which does not change these unary supervision RPCs. +Each command requires the `operator.write` Gateway scope. +Standard `-h, --help` output is available on each subcommand. +There is no archived or include-archived option. `sessions` can list paired +hosts, but `continue` and `archive` always target `gateway:local`; paired rows +are list-only. Archive always requires `--confirm-no-other-runner`. + +These shell commands are distinct from the in-chat `/codex` runtime commands. +`/codex threads [filter]` lists App Server threads available to the current +conversation connection. `/codex sessions --host ` lists resumable Codex +CLI session files on one node, not the supervision fleet catalog. `/codex +resume` and `/codex bind` attach the current conversation instead of creating a +safe supervised branch, and a model-locked supervised Chat rejects those +binding mutations. There is no `/codex continue` or `/codex archive` runtime +command. + +## Branch from a local session + +Choose **Continue as branch** on a stored or idle row from the Gateway computer. +OpenClaw creates a normal Chat entry, mirrors bounded user and assistant history +through the source's last terminal persisted turn (completed, interrupted, or +failed), records a pending harness branch, and opens the Chat. The generic model +picker is locked, but no concrete model or provider has been selected yet. The +source is not resumed, and the canonical harness thread is not started yet. +Repeating the action opens the existing Chat instead of creating another +branch. + +The mirror keeps the newest visible tail that fits all three limits: at most 200 +user or assistant messages, 512 KiB of UTF-8 text in total, and 64 KiB per +message. Oversized messages are truncated with a marker, and older messages are +omitted when a cap is reached. An image or local-image input becomes the literal +`[Image attachment]` placeholder; image data and local paths are not copied. + +Send the first normal Chat message to begin work. The Codex harness installs the +real approval, elicitation, event, and delivery handlers. It uses a temporary +native fork on the supervision connection to pin the source snapshot without +supplying a model or provider override. Codex App Server selects both from its +current native configuration and returns the actual selection. On that same +connection, OpenClaw starts the canonical `appServer`-source full harness thread +under its cwd and runtime policy with exactly that returned pair, injects the +bounded visible history, and archives the temporary fork. The canonical thread +has the full OpenClaw harness tool surface. This is a visible-history branch, not +a full native rollout clone: source reasoning, tool calls, and tool results are +omitted. This and every later turn stays on the supervised Codex connection +rather than another OpenClaw model runtime or the ordinary agent-home harness. + +The returned selection is not proof of the source's historical model. If the +current native configuration differs from the model recorded for the source's +last turn, Codex emits its normal model-difference warning. OpenClaw uses the +returned pair for the canonical thread start. Codex persists that canonical +thread's native model and provider, and later resumes preserve them because +OpenClaw omits model and provider overrides. If the canonical thread is changed +through a separate native Codex control, OpenClaw accepts Codex's persisted +selection. OpenClaw never substitutes its outer model or fallback chain. + +The supervised model-locked Chat cannot be deleted, switch models, use `/new` +or `/reset`, invoke the Gateway session-reset action, or use the generic +**Fork session** action. Mutating `/codex model `, `/codex +bind`, `/codex resume` (including a node session with `--bind here`), and +`/codex detach` or `/codex unbind` are also rejected because they would replace +or clear the locked native binding. The `/codex model` query and `/codex fast`, +`/codex permissions`, and `/codex threads` remain available. Start another +ordinary session when you want a different model or fresh thread. + +Keep supervision enabled for this Chat. If supervision is disabled or its +stored connection binding becomes unavailable or inconsistent, the turn fails +closed instead of moving to an ordinary agent-home session. + +Disabling or uninstalling the `codex` plugin does not release that ownership or +make the Chat eligible for another model. The locked Chat remains preserved but +unavailable; reinstall or re-enable the same plugin and restart the Gateway to +resume it. This deliberate fail-closed behavior prevents retention cleanup or a +temporary plugin outage from silently orphaning the native binding. + +The `codex_threads` agent tool follows the same boundary. It cannot attach a +different fork or archive the Chat's bound native thread. List and metadata-only +read remain available. Raw transcript reads require `allowRawTranscripts`. +When raw access is disabled, `codex_threads` also rejects list search because +native search includes transcript previews; the Control UI and operator CLI +still provide bounded title-only search. Rename, unarchive, detached fork, and +archive of an unrelated unowned thread require +`allowWriteControls`. Neither option bypasses the locked binding. + +OpenClaw does not subscribe to or answer approval requests while merely listing +the source thread or displaying the pending Chat. Starting a distinct canonical +harness thread on the first turn lets another Codex process keep owning the +source without creating competing rollout writers. + +The original CLI or VS Code source remains visible to native clients and the +OpenClaw catalog. The canonical branch is stored as a native Codex thread, but +its source kind is `appServer`; Codex Desktop or another native client may filter +that source kind, so the branch itself is not guaranteed to appear in every +native history view. + +An active row reported by OpenClaw's App Server cannot start a new branch. Wait +for the current turn to finish and refresh the catalog. Codex App Server +serializes mutations within one process, but it does not provide an exclusive +cross-process runner or approval-owner lease. + +For a **Stored / activity unknown** row, the Chat mirror and first-turn snapshot +pin use Codex's state through the last terminal persisted turn. The source +thread is not resumed, interrupted, or archived. If another process has an +in-progress turn, its latest in-flight work might not be present in the branch. + +## Archive a local session + +Choose **Archive** on a stored or idle Gateway-local row, then confirm that no +other Codex client or OpenClaw runner is using that thread or its spawned +descendants. OpenClaw freshly reads the process-local status, proceeds only for +`idle` or `notLoaded`, calls the native Codex archive operation, and removes the +session from the non-archived list. Native Codex also attempts to archive the +thread's spawned descendants. + +Archive is unavailable when the fresh read reports the session active or in an +error state, when it belongs to a paired node, or while a newly created +supervised Chat still has a pending branch from that source. Send the Chat's +first message to materialize its canonical branch before archiving the source. +Archive is also blocked when OpenClaw knows that an active binding owns the +exact target thread or any non-archived spawned descendant. OpenClaw follows the +experimental Codex descendant query through every page; an invalid response, +request failure, repeated cursor or thread, or safety-limit exhaustion rejects +archive. + +The read, descendant enumeration, and archive requests are not one conditional +operation, so a turn can still start between them. App Server status is also +not shared across independent processes. The confirmation is therefore the +safety boundary for unknown clients and that race: quit or otherwise verify +every other client before confirming. Restore an archived thread with Codex +Desktop, the Codex CLI, or an owner-authorized native thread-management flow; +it reappears after unarchive. + +```bash +codex unarchive +``` + +## Understand paired-node limits + +Paired nodes expose the versioned read-only +`codex.appServer.threads.list.v1` command. The Gateway receives normalized +metadata, not raw App Server endpoints or transcripts. The current node invoke +transport is request/response only, so it cannot carry the long-lived event, +approval, and streaming lifecycle required by the Codex harness. + +For that reason, remote rows remain visible but do not offer **Continue** or +**Archive**, even when the remote thread is idle. Use Codex on that computer +until a node-side streaming runner bridge exists for continuation and a safe +runner-ownership boundary exists for archive. + +## Metadata and permissions + +Catalog rows may include: + +- thread and session identifiers +- title and working directory +- current status and active wait flags +- created, updated, and activity timestamps +- source, model provider, Codex CLI version, and Git branch + +The paired-node projection excludes transcript previews, turns, rollout paths, +the Codex home path, Git remotes, commit SHAs, and raw App Server errors. Catalog +access requires the `operator.write` Gateway scope because fleet aggregation +uses the standard `node.invoke` path, even though the node command is read-only. + +`supervision.allowRawTranscripts` and `supervision.allowWriteControls` govern +autonomous agent and standalone MCP tools. Both default to `false`. With +supervision enabled, `codex_threads` removes transcript previews and turns from +list and metadata-only read results unless raw transcripts are allowed; a +turn-inclusive read fails closed. Every fork, rename, archive, and unarchive +requires write controls. These options do not grant additional Control UI +actions or bypass binding, host, status, or confirmation checks. + +### Compatibility tools + +The official `codex` plugin retains the five shipped Supervisor tool names for +existing agent and standalone MCP clients: + +- `codex_endpoint_probe` +- `codex_sessions_list` +- `codex_session_read` +- `codex_session_send` +- `codex_session_interrupt` + +`codex_sessions_list` is loaded-only by default; there is no `loaded_only` +parameter. Set `include_stored: true` to also read non-archived stored rows from +Codex's state database. The optional `max_stored_sessions` cap defaults to 200 +and accepts 1 through 1,000 rows per endpoint. It does not cap loaded rows. +Without raw-transcript permission, list results omit transcript-derived names, +previews, and detailed endpoint errors. +`codex_session_read` requires `allowRawTranscripts`; `include_turns: true` +additionally asks Codex for turns. + +`codex_session_send` and `codex_session_interrupt` require +`allowWriteControls`. Send accepts `mode: "auto" | "start" | "steer"`, but +`"start"` is always refused and both `"auto"` and `"steer"` can only steer a +readable active turn. An idle thread is refused with guidance to use **Codex +Sessions**, where the full harness installs approval and tool handlers before +continuation. Interrupt likewise requires an active readable turn. These tools +do not resume or start an idle source thread. + +`openclaw doctor --fix` moves a retired `codex-supervisor` entry, its endpoint +and permission fields, and plugin allow/deny policy references into the official +`codex` plugin without overwriting explicit canonical settings. The standalone +compatibility MCP adapter continues to load the same five tools from that +plugin; legacy policy environment variables apply only inside that trusted +adapter. + +For every supervision config field, see +[Codex harness reference](/plugins/codex-harness-reference#supervision). + +## Troubleshooting + +**No sessions appear:** verify that `@openclaw/codex` is installed, both the +plugin and `supervision.enabled` are true, the current plugin allowlist permits +`codex`, and the sessions are not archived. Restart the Gateway or node after +changing activation. + +**Continue is disabled:** an unmapped row is active, belongs to a paired node, +its host is offline, or another action is pending. Gateway-local stored and idle +rows offer **Continue as branch** instead of unsafe exact-thread takeover. A row +that already has a supervised Chat offers **Open Chat**. + +**Archive is disabled:** archive is available for stored/activity-unknown and +idle Gateway-local rows after no-other-runner confirmation. Active, error, +offline, paired-node, pending-branch, and known exact-binding-owner rows remain +read-only for archive. + +**An archived session disappeared:** this is expected. The supervision page has +no archived view. Run `codex unarchive ` or use Codex Desktop to show +it again. + +**Old `codex-supervisor` config remains:** run `openclaw doctor --fix`. Doctor +moves the retired plugin entry and related plugin-policy references into +`plugins.entries.codex.config.supervision` without overwriting explicit Codex +settings. + +## Related + +- [Codex harness](/plugins/codex-harness) +- [Codex harness reference](/plugins/codex-harness-reference) +- [Codex harness runtime](/plugins/codex-harness-runtime) +- [Codex supervision architecture](/specs/codex-supervision) +- [Nodes](/nodes) +- [Gateway security](/gateway/security) diff --git a/docs/plugins/plugin-inventory.md b/docs/plugins/plugin-inventory.md index ccfe63591781..46537e2ffae1 100644 --- a/docs/plugins/plugin-inventory.md +++ b/docs/plugins/plugin-inventory.md @@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description. ## Core npm package -63 plugins +62 plugins - **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint. @@ -71,8 +71,6 @@ Each entry lists the package, distribution route, and description. - **[clawrouter](/plugins/reference/clawrouter)** (`@openclaw/clawrouter`) - included in OpenClaw. Adds ClawRouter model provider support to OpenClaw. -- **[codex-supervisor](/plugins/reference/codex-supervisor)** (`@openclaw/codex-supervisor`) - included in OpenClaw. Supervise Codex app-server sessions from OpenClaw. - - **[cohere](/plugins/reference/cohere)** (`@openclaw/cohere-provider`) - included in OpenClaw; npm; ClawHub: `clawhub:@openclaw/cohere-provider`. OpenClaw Cohere provider plugin. - **[comfy](/plugins/reference/comfy)** (`@openclaw/comfy-provider`) - included in OpenClaw. Adds ComfyUI model provider support to OpenClaw. @@ -203,7 +201,7 @@ Each entry lists the package, distribution route, and description. - **[cloudflare-ai-gateway](/plugins/reference/cloudflare-ai-gateway)** (`@openclaw/cloudflare-ai-gateway-provider`) - npm; ClawHub: `clawhub:@openclaw/cloudflare-ai-gateway-provider`. Adds Cloudflare AI Gateway model provider support to OpenClaw. -- **[codex](/plugins/reference/codex)** (`@openclaw/codex`) - npm; ClawHub. OpenClaw Codex app-server harness and model provider plugin with a Codex-managed GPT catalog. +- **[codex](/plugins/reference/codex)** (`@openclaw/codex`) - npm; ClawHub. OpenClaw Codex app-server harness, model provider, and native session supervision plugin. - **[copilot](/plugins/reference/copilot)** (`@openclaw/copilot`) - npm; ClawHub: `clawhub:@openclaw/copilot`. Registers the GitHub Copilot agent runtime. diff --git a/docs/plugins/reference.md b/docs/plugins/reference.md index 1f1fb404bd77..e2c4191acac5 100644 --- a/docs/plugins/reference.md +++ b/docs/plugins/reference.md @@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and pnpm plugins:inventory:gen ``` -Use [Plugin inventory](/plugins/plugin-inventory) to browse all 135 +Use [Plugin inventory](/plugins/plugin-inventory) to browse all 134 generated plugin reference pages by distribution, package, and description. diff --git a/docs/plugins/reference/codex-supervisor.md b/docs/plugins/reference/codex-supervisor.md deleted file mode 100644 index 6a16bf73f31e..000000000000 --- a/docs/plugins/reference/codex-supervisor.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -summary: "Supervise Codex app-server sessions from OpenClaw." -read_when: - - You are installing, configuring, or auditing the codex-supervisor plugin -title: "Codex Supervisor plugin" ---- - -# Codex Supervisor plugin - -Supervise Codex app-server sessions from OpenClaw. - -## Distribution - -- Package: `@openclaw/codex-supervisor` -- Install route: included in OpenClaw - -## Surface - -contracts: tools - - - -## Enable the plugin - -The plugin is disabled by default. Enable it independently on the Gateway and -on every computer whose Codex sessions should appear in the federated catalog: - -```json5 -{ - plugins: { - entries: { - "codex-supervisor": { - enabled: true, - }, - }, - }, -} -``` - -Each entry belongs to the OpenClaw process on that computer: - -| Entry location | What it enables | -| ---------------------------------------- | ------------------------------------------------------------------------------------------------ | -| Gateway `openclaw.json` | The Gateway-local catalog, fleet aggregation, `openclaw codex sessions`, and the Control UI page | -| Headless node host `openclaw.json` | The node-local catalog command advertised by `openclaw node run` or the installed node service | -| Native macOS app's local `openclaw.json` | The same node-local catalog command, implemented by the app | - -The node entry is local consent to share session metadata with its paired -Gateway. Enabling only the Gateway does not authorize access to another -computer. This setting belongs under `plugins.entries`, not `gateway.nodes`. - -Restart the Gateway, node host, or macOS app after changing plugin activation. -A newly advertised catalog command changes the node's approved command surface. -Approve that update from the Gateway host: - -```bash -openclaw nodes pending -openclaw nodes approve -``` - -## App Server selection - -The catalog opens a dedicated local Codex App Server over stdio. By default it -runs `codex app-server --listen stdio://`. If -`plugins.entries.codex-supervisor.config.endpoints` contains a `stdio-proxy` -endpoint, the catalog uses the first one and honors its `command`, `args`, and -`cwd`. Raw App Server endpoints are never exposed through the Gateway or node -connection. - -The plugin's configured endpoints also back its supervisor agent tools. The -catalog uses a separate App Server connection so session listing does not -replace or attach to the live-control connection. - -## List sessions from the CLI - -The command queries the Gateway and groups results by stable host id: - -```bash -openclaw codex sessions -openclaw codex sessions --json -openclaw codex sessions --search "Fix tests" -openclaw codex sessions --archived -``` - -Options: - -| Option | Behavior | -| ------------------- | ------------------------------------------------------------------------------------ | -| `--search ` | Case-sensitive session-title substring search. Transcript previews are not searched. | -| `--archived` | List archived sessions instead of active history. | -| `--host ` | Query one exact `gateway:` or `node:` host id. | -| `--limit ` | Return 1-100 sessions per host. The default is 50. | -| `--cursor ` | Continue one host's page. Requires `--host`. | -| `--json` | Print the structured host-grouped response. | - -Cursors are opaque and host-specific. Reuse the same search and archive -filters when continuing a page. Search is applied to the normalized titles in -each fetched page, so a matching page can contain fewer than `--limit` entries -while still returning another cursor. Gateway endpoint ids that exceed the -catalog's routing limit receive a stable `gateway:sha256:` host id. - -## Use the Control UI - -Open the **Codex Sessions** plugin page in the Control UI. It provides active -and archived views, title search, refresh, host status, and per-host **Load -more** controls. Background refresh preserves pages that you already loaded. - -Catalog failures are isolated by host. An offline node, unavailable local App -Server, timeout, or malformed node response produces an error on that host; -sessions from healthy hosts remain available in the same response and page. - -## Metadata and security boundary - -The node command is read-only and returns normalized metadata only: - -- thread and session identifiers -- session name and working directory -- status, active status flags, and archive state -- created, updated, and activity timestamps -- source, model provider, Codex CLI version, and Git branch - -It does not return transcript previews, turns, rollout paths, the Codex home -path, Git remotes, commit SHAs, or raw App Server errors. Title search is -performed after normalization so query results cannot reveal matches from -transcript-derived previews. `notLoaded` means the thread is stored but is not -loaded in the catalog's dedicated App Server process; it does not mean that no -other Codex process is using the thread. - -Working directories, titles, and branch names can still be sensitive. Enable -the plugin only on nodes paired with a trusted Gateway. Catalog access requires -the `operator.write` Gateway scope because federation uses the standard -`node.invoke` path, even though the catalog command itself is read-only. - -The separate `allowRawTranscripts` and `allowWriteControls` plugin settings -apply to supervisor agent tools and default to `false`. They do not expand the -metadata-only CLI or Control UI catalog. - -## Supervisor agent session listing - -`codex_sessions_list` defaults to loaded Codex sessions only. Set -`include_stored` to include stored history; the plugin uses Codex App Server's -state-DB-only listing path and caps stored results at 200 by default. Pass -`max_stored_sessions` to lower or raise that cap, up to 1000. - - diff --git a/docs/plugins/reference/codex.md b/docs/plugins/reference/codex.md index 846f34392f14..00492f4800f3 100644 --- a/docs/plugins/reference/codex.md +++ b/docs/plugins/reference/codex.md @@ -1,5 +1,5 @@ --- -summary: "OpenClaw Codex app-server harness and model provider plugin with a Codex-managed GPT catalog." +summary: "OpenClaw Codex app-server harness, model provider, and native session supervision plugin." read_when: - You are installing, configuring, or auditing the codex plugin title: "Codex plugin" @@ -7,7 +7,7 @@ title: "Codex plugin" # Codex plugin -OpenClaw Codex app-server harness and model provider plugin with a Codex-managed GPT catalog. +OpenClaw Codex app-server harness, model provider, and native session supervision plugin. ## Distribution diff --git a/docs/plugins/sdk-agent-harness.md b/docs/plugins/sdk-agent-harness.md index 733552558f81..80ff7f8d8dc1 100644 --- a/docs/plugins/sdk-agent-harness.md +++ b/docs/plugins/sdk-agent-harness.md @@ -115,6 +115,19 @@ export default definePluginEntry({ `authBootstrap` is intentionally absent from this generic example. Add `authBootstrap: "harness"` only when the harness meets the contract above. +### Delegated execution + +A harness owner may set `delegatedExecutionPluginIds` to the ids of trusted +plugins that need to execute an existing model-locked session, such as a voice +transport continuing a Codex-backed conversation. This is static owner consent, +not a core allowlist. Keep it narrow. + +Delegates receive only work admission and embedded execution. OpenClaw requires +the exact stored session key, store path, and session id; `modelSelectionLocked: +true`; and matching `agentHarnessId` and `agentHarnessRuntimeOverride` values. +The run is then scoped through the harness owner. Session creation, patching, +reset, deletion, archive, and Gateway mutation remain owner-only. + ## Selection policy OpenClaw chooses a harness after provider/model resolution: diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index 57268378bfee..de3c439638e5 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -160,6 +160,19 @@ two-party event loops that do not go through the shared inbound reply runner. update: (entry) => ({ thinkingLevel: "high" }), }); + const created = await api.runtime.agent.session.createSessionEntry({ + cfg, + key: "agent:main:my-plugin:task-1", + initialEntry: { + agentHarnessId: "my-harness", + modelSelectionLocked: true, + pluginExtensions: { "my-plugin": { phase: "initializing" } }, + }, + afterCreate: async () => ({ + pluginExtensions: { "my-plugin": { phase: "ready" } }, + }), + }); + const storePath = api.runtime.agent.session.resolveStorePath(cfg.session?.store, { agentId }); await api.runtime.agent.session.runWithWorkAdmission( { storePath, sessionKey }, @@ -171,7 +184,11 @@ two-party event loops that do not go through the shared inbound reply runner. Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted. - Use `runWithWorkAdmission(...)` when a plugin starts work on a persisted session. The callback rejects archived or concurrently replaced sessions, keeps archive/reset/delete mutations coordinated through completion, and receives an `AbortSignal` that must be forwarded to the agent run. + `createSessionEntry(...)` creates a new canonical session row and transcript. Its trusted `initialEntry` surface is deliberately narrow: a non-empty `agentHarnessId`, optional `modelSelectionLocked: true`, and optional `pluginExtensions`. The injected runtime accepts only harness ids owned by the calling plugin through `registerAgentHarness(...)`; this is an ownership invariant, not a sandbox between in-process plugins. It rejects an existing row; `label` and `spawnedCwd` are separate creation fields rather than trusted-entry patches. + + Creation holds the session lifecycle mutation fence through `afterCreate`, so new work waits for plugin-owned initialization to finish and pre-existing admitted work makes creation fail. The callback receives a clone of the created state. If it returns a patch, that patch may contain only `pluginExtensions`, and its value is the complete final `pluginExtensions` field. A callback or final-persistence failure rolls back the unchanged new row and transcript; guarded rollback preserves a row changed or claimed concurrently. `recoverMatchingInitialEntry: true` is only for retrying interrupted initialization when the persisted trusted fields match exactly, and recovery requires `afterCreate` to return a final patch. + + Use `runWithWorkAdmission(...)` when a plugin starts work on a persisted session. The callback rejects archived or concurrently replaced sessions, keeps archive/reset/delete mutations coordinated through completion, and receives an `AbortSignal` that must be forwarded to the agent run. A harness may explicitly name trusted execution delegates through its experimental `delegatedExecutionPluginIds` registration field. Delegates can admit and run only an exact existing model-locked session; all session mutations remain restricted to the harness owner. See [Agent harness plugins](/plugins/sdk-agent-harness#delegated-execution). For transcript reads and writes, import `openclaw/plugin-sdk/session-transcript-runtime` and use `resolveSessionTranscriptIdentity(...)`, `resolveSessionTranscriptTarget(...)`, `readSessionTranscriptEvents(...)`, `appendSessionTranscriptMessageByIdentity(...)`, `publishSessionTranscriptUpdateByIdentity(...)`, or `withSessionTranscriptWriteLock(...)` with `{ agentId, sessionKey, sessionId }`. These APIs let plugins identify a transcript, read its events, append messages, publish updates, and run related operations under the same transcript write lock. Passing `sessionFile`, using `resolveSessionTranscriptLegacyFileTarget(...)`, or importing low-level `appendSessionTranscriptMessage(...)` / `emitSessionTranscriptUpdate(...)` from `openclaw/plugin-sdk/agent-harness-runtime` is deprecated; those paths exist only for legacy code that already receives an active transcript artifact. diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index 3a5dedc43082..79c736449847 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -115,7 +115,8 @@ The value type is `SessionEntry` in `src/config/sessions.ts`. Key fields (not ex - `updatedAt`: last store-row mutation timestamp, used for listing/pruning/bookkeeping - not the daily/idle freshness authority. - `archivedAt`: optional archive timestamp. Archived sessions stay in the store with their transcript intact and are excluded from normal active listings. - `pinnedAt`: optional pin timestamp. Active pinned sessions sort ahead of unpinned sessions; archiving a session clears its pin. -- Codex thread interop: both fields follow the Codex thread-management shape - the `archived`/`pinned` booleans on the wire are always derived from the timestamp and stamped server-side, matching Codex `threads.archived_at` semantics and camelCase serialization. OpenClaw timestamps are epoch milliseconds while Codex uses epoch seconds, so bridges convert at the codex plugin seam. Codex has no pin API yet (`thread/archive`/`thread/unarchive` only); pinned state stays OpenClaw-side until one exists, at which point the matching shape lets bound sessions round-trip pin state mechanically. +- Codex thread interop: both fields follow the Codex thread-management shape - the `archived`/`pinned` booleans on the wire are always derived from the timestamp and stamped server-side, matching Codex `threads.archived_at` semantics and camelCase serialization. OpenClaw timestamps are epoch milliseconds while Codex uses epoch seconds, so bridges convert at the `codex` plugin seam. Codex has no pin API yet (`thread/archive`/`thread/unarchive` only); pinned state stays OpenClaw-side until one exists, at which point the matching shape lets bound sessions round-trip pin state mechanically. +- Codex supervision lists only non-archived native threads. A Gateway-local `idle` or `notLoaded` activity-unknown thread can be archived through native `thread/archive` only after the operator explicitly confirms that no other Codex process owns it; the plugin performs a fresh process-local status read first, and the thread then disappears from the catalog. That read cannot prove that another App Server process is not using the thread. OpenClaw refuses to archive active and error rows, and paired-node archive is unavailable until the node bridge can own the full streamed thread lifecycle. Unarchiving in a native Codex client makes the thread eligible to appear again. - `lastReadAt` / `markedUnreadAt`: read-state timestamps stamped server-side by `sessions.patch { unread }` - `unread: false` records a read (sets `lastReadAt`, clears `markedUnreadAt`); `unread: true` marks the session unread until the next read. Session rows expose a derived `unread` boolean: explicitly marked unread, or read before the latest activity. Sessions never marked read stay `unread: false`, so existing installs do not light up on upgrade. - `lastActivityAt`: timestamp of the last completed agent run that counts as unread-worthy activity (user, channel, and cron runs). Heartbeat and internal-event turns, plus metadata patches, do not update it; `updatedAt` is not an activity signal. - `sessionFile`: optional explicit transcript path override diff --git a/docs/specs/claw-supervisor.md b/docs/specs/claw-supervisor.md deleted file mode 100644 index 683470c9f7d2..000000000000 --- a/docs/specs/claw-supervisor.md +++ /dev/null @@ -1,270 +0,0 @@ ---- -title: Claw Supervisor -summary: "Fleet supervision plan for Codex app-server sessions controlled by OpenClaw." -read_when: - - Designing Codex fleet supervision - - Building OpenClaw tools that read, steer, or spawn Codex sessions - - Choosing between local, Cloudflare, and VPS deployment for supervised Codex ---- - -# Claw Supervisor - -## Goal - -Claw Supervisor lets one always-on OpenClaw instance monitor and drive a fleet of Codex sessions without changing the normal Codex user experience. A user can SSH into a host, start Codex, work in the TUI, and still have the supervisor read the session, steer it, interrupt it, spawn related sessions, and accept handoffs. Codex sessions can also call back into OpenClaw through MCP. - -## Product Model - -Codex remains the primary work surface. OpenClaw supervises Codex rather than hiding Codex inside an opaque OpenClaw subagent. - -The OpenClaw plugin is named `codex-supervisor`. `crabfleet` remains the deployment -and host-fleet profile for CRAB machines rather than the reusable plugin name. - -The model has three roles: - -- Human-attached Codex: a normal interactive Codex TUI launched through a shared app-server. -- Autonomous Codex: a Codex app-server thread spawned by the supervisor that a human can later attach to. -- Supervisor Claw: an always-on OpenClaw agent with tools for fleet state, transcript reads, steering, interruption, spawning, and handoff. - -OpenClaw may use its existing subagent machinery internally, but the external contract is an attachable Codex session with a Codex thread id. - -## Architecture - -```text -user SSH session - -> codex --remote unix://... or ws://... - -> local codex app-server daemon - <-> host sidecar / supervisor connector - <-> OpenClaw fleet supervisor - <-> supervisor MCP exposed back to Codex -``` - -Each Codex-capable host runs: - -- Codex app-server daemon. -- A launcher that always starts interactive Codex with `--remote`. -- A connector that registers app-server endpoints and live threads with the supervisor. - -The supervisor runs: - -- Endpoint registry. -- Session registry. -- Codex app-server JSON-RPC client pool. -- MCP server for Codex-to-Claw calls. -- OpenClaw tools for Claw-to-Codex control. -- Policy engine for autonomous actions, approvals, and loop prevention. - -## Federated Session Catalog - -The plugin's current read-only catalog lists interactive Codex sessions from -the Gateway host and opted-in paired computers. Each computer queries a -dedicated local Codex App Server over stdio, so raw App Server endpoints remain -local. - -The catalog flow is: - -```text -Control UI or openclaw codex sessions - -> codex-supervisor.sessions.list on the Gateway - -> dedicated local Codex App Server over stdio - -> node.invoke for each connected catalog-capable node - -> dedicated node-local Codex App Server over stdio -``` - -This catalog is a metadata projection, not the durable registry or live-control -plane described below. See the [Codex Supervisor plugin -reference](/plugins/reference/codex-supervisor) for activation ownership, node -consent, CLI and Control UI use, pagination, partial failures, and the exact -security boundary. - -## Codex App-Server Contract - -Use Codex app-server APIs as the canonical control plane: - -- `initialize`, `initialized` -- `thread/loaded/list` -- `thread/list` -- `thread/read` -- `thread/resume` -- `thread/start` -- `turn/start` -- `turn/steer` -- `turn/interrupt` -- `model/list` - -Interactive Codex must be launched with `codex --remote ` so the TUI and supervisor connect to the same app-server. Standalone `codex exec` is not a live-shared session today; use app-server APIs for autonomous work until Codex supports `exec --remote`. - -## Session Registry - -Supervisor stores one record per observed Codex thread: - -```json -{ - "sessionId": "codex-thread-id", - "endpointId": "host-a", - "host": "host-a.example", - "workspace": "/workspace/repo", - "repo": "owner/repo", - "branch": "feature/example", - "source": "vscode", - "status": "idle", - "humanAttached": true, - "lastSeenAt": "2026-05-28T10:00:00.000Z", - "summary": "Short working-state summary" -} -``` - -The local implementation can derive most fields from Codex thread metadata. Fleet deployment should enrich records with host identity, user attachment state, git state, and sidecar health. - -## MCP Surface For Codex - -Every supervised Codex gets an MCP server named `openclaw-codex-supervisor`. - -Tools: - -- `codex_sessions_list`: list visible Codex sessions. -- `codex_session_read`: read one transcript. -- `codex_session_send`: send a message to an idle thread or steer an active thread. -- `codex_session_interrupt`: interrupt the active turn. -- `codex_endpoint_probe`: verify endpoint connectivity. -- `claw_report_progress`: publish current task state to the supervisor. -- `claw_ask`: ask the supervisor for help or delegation. -- `codex_spawn`: create a new autonomous Codex session. -- `codex_handoff`: request human or peer takeover. - -Resources: - -- `codex://sessions` -- `codex://sessions/{sessionId}` -- `codex://sessions/{sessionId}/transcript` - -## Claw Control Surface - -The always-on Claw gets the same primitives as internal tools: - -- list sessions and endpoints -- read transcripts -- send/steer text -- interrupt active work -- spawn new sessions -- summarize and assign sessions -- broadcast instructions to a filtered group -- mark sessions blocked, done, or abandoned - -Tool behavior: - -- If a target thread is idle, `codex_session_send` maps to `turn/start`. -- If a target thread is active and an in-progress turn id is visible, it maps to `turn/steer`. -- If the active turn cannot be identified, the tool fails closed instead of creating an unrelated turn. -- Codex-exposed MCP write controls stay disabled unless a trusted supervisor-only policy enables them. -- Raw transcript reads stay disabled unless a trusted supervisor-only policy enables them. -- Autonomous approval defaults deny tool/file approvals unless an explicit policy says otherwise. - -## Launch Flow - -Interactive host login: - -1. User SSHes into a CRAB host. -2. SSH service starts or verifies `codex app-server daemon start`. -3. Login wrapper launches `codex --remote unix:// --cd `. -4. Host connector registers endpoint and loaded thread. -5. Supervisor emits a high-priority fleet event: new Codex session, workspace, human-attached state, current task preview. -6. Supervisor Claw can read and steer immediately. - -Autonomous spawn: - -1. Supervisor selects host and workspace. -2. Host connector opens or resumes a Codex app-server thread. -3. Supervisor starts the first turn with task text and MCP config. -4. Session registry marks it autonomous and attachable. -5. Human can later attach with `codex --remote resume ` once Codex supports that exact UX, or via current resume flow on the same app-server. - -## Deployment - -Preferred control plane: - -- Host connectors keep outbound WebSocket connections to the supervisor. -- Supervisor state lives in OpenClaw Gateway storage. -- Codex app-server remains local to each host; never expose a raw unauthenticated app-server to the public internet. - -Cloudflare viability: - -- Good for registry, durable objects, WebSocket fan-in, lightweight event routing, and public MCP/gateway endpoints. -- Not enough by itself for direct private host control because Workers cannot dial arbitrary private Unix sockets or local loopback app-servers. -- Use Cloudflare when every host connector phones home over outbound WebSocket. - -VPS fallback: - -- Use a Hetzner service when long-lived process control, SSH tunnels, private network routing, or local filesystem access is needed. -- Keep the same protocol: host connectors outbound, supervisor registry central, Codex app-server local. - -## Security - -- Default bind is local Unix socket. -- Remote app-server uses token or signed bearer auth. -- Host connector authenticates to supervisor with a scoped host token. -- Supervisor tools enforce per-session policy: read, steer, interrupt, spawn, approval. -- Cross-agent messages include `originSessionId`; self-echo is dropped. -- Broadcast requires an explicit filter and bounded target count. -- Transcript reads redact secrets at OpenClaw boundary. -- Approval requests default to deny for supervisor-originated turns unless policy allows them. - -## Implementation Plan - -Phase 1: Local supervisor MVP - -- Add Codex app-server JSON-RPC client for stdio proxy and WebSocket endpoints. -- Add supervisor endpoint/session registry. -- Add MCP tools: list, read, send, interrupt, probe. -- Add local env config for endpoints. -- Add fake app-server tests and one live local app-server smoke. - -Phase 2: OpenClaw integration - -- Register supervisor tools in the `codex-supervisor` plugin. -- Inject supervisor MCP into Codex thread config. -- Add session summaries to agent context. -- Add event notifications when new Codex threads appear. -- Add policy config for autonomous send/interrupt/spawn. - -Phase 3: Fleet connector - -- Host sidecar registers app-server endpoint, host metadata, git/workspace metadata, and human attachment state. -- Add outbound WebSocket connector for Cloudflare or VPS control plane. -- Add reconnect, heartbeat, and stale-session cleanup. -- Add CRAB SSH launcher wrapper. - -Phase 4: Autonomous operation - -- Add spawn/resume/takeover flows. -- Add broadcast and delegation. -- Add progress reports and task-state summaries. -- Add loop prevention and rate limits. -- Add dashboard views. - -Phase 5: Multi-Claw - -- Shard sessions by group. -- Add leadership/lease for each session. -- Add audit log and replay. -- Add escalation between Claw groups. - -## Acceptance Tests - -- A human launches Codex TUI through a shared app-server. -- Supervisor lists the live thread via `thread/loaded/list`. -- Supervisor reads transcript via `thread/read`. -- Supervisor sends text to an idle thread via `turn/start`. -- Supervisor steers an active thread via `turn/steer`. -- Supervisor interrupt stops an active turn via `turn/interrupt`. -- Codex calls supervisor MCP and lists peer sessions. -- An autonomous Codex is spawned and later human-attached. -- Lost host connector marks sessions stale without deleting history. - -## Open Questions - -- Exact Codex TUI attach UX for an app-server thread spawned without a TUI. -- Whether Codex should add `exec --remote` for headless live-shared runs. -- Durable state owner: OpenClaw Gateway DB, Cloudflare Durable Object, or VPS database. -- Approval policy granularity for supervisor-originated turns. -- How much transcript summary should be injected into the always-on Claw context versus kept as a tool/resource. diff --git a/docs/specs/codex-supervision.md b/docs/specs/codex-supervision.md new file mode 100644 index 000000000000..2ca0925a9d00 --- /dev/null +++ b/docs/specs/codex-supervision.md @@ -0,0 +1,412 @@ +--- +title: Codex supervision +summary: "Architecture and product boundary for supervising native Codex sessions from OpenClaw." +read_when: + - Designing Codex session discovery, continuation, or archive behavior + - Changing the Codex Sessions Control UI or Gateway RPCs + - Extending Codex supervision across paired nodes +--- + +# Codex supervision + +## Goal + +Codex supervision lets an OpenClaw operator discover native Codex sessions and, +when safe, create a local branch through the normal OpenClaw Chat surface. +Codex App Server remains the thread and model-loop owner. OpenClaw supplies the +fleet catalog, authenticated operator UI, session binding, and channel delivery. + +The feature belongs to the official `codex` plugin. There is no separate +Supervisor plugin or second Codex protocol implementation. + +## Product boundary + +Enable the feature with: + +```text +plugins.entries.codex.config.supervision.enabled = true +``` + +The active initial product is intentionally smaller than the long-term fleet +plan: + +- List only non-archived Codex threads. +- Group local and opted-in paired-node rows by stable host identity. +- Create a normal, model-locked Chat branch from a stored or idle Gateway-local + thread, start its full Codex harness thread on the first turn, or open the Chat + created for an earlier branch. +- Archive a stored or idle Gateway-local thread only after explicit + no-other-runner confirmation. +- Show active local sources without new-branch or archive controls while still + allowing an existing supervised Chat to open. +- Show paired-node rows as read-only metadata. +- Isolate catalog failures by host. + +The catalog is the non-archived collection. A row within it can still have an +idle, active, `notLoaded`, or error turn status. + +Supervision remains opt-in. Guided onboarding attempts to install and enable it +after native Codex installation detection succeeds and the selected inference +backend passes its live check, independently of which primary backend the user +selects. Supervision activates only when that opportunistic plugin setup +succeeds. An explicit disabled plugin, policy block, or +`supervision.enabled: false` remains authoritative. + +## Ownership + +The `codex` plugin owns all Codex App Server behavior: + +- endpoint discovery and connection lifecycle +- protocol initialization and version checks +- thread list, read, resume, archive, and event handling +- approval and user-input bridges +- native thread bindings to OpenClaw sessions +- Codex-only model and harness enforcement after continuation + +The Control UI and Gateway consume that plugin-owned service. They do not read +Codex rollout files directly and do not implement another App Server client. + +The default local topology is: + +```text +Codex Desktop -> private stdio App Server -> user Codex home + ^ +OpenClaw Codex plugin -> supervision App Server connection + (defaults to managed user-home stdio; explicit appServer settings are honored) + -> passive source catalog and read + -> snapshot pin -> canonical appServer-source branch + -> visible-history injection and every later supervised Chat turn + +Ordinary OpenClaw Codex sessions -> managed agent-home stdio by default + -> ordinary full harness threads -> OpenClaw Chat and channel delivery +``` + +Enabling supervision does not change the ordinary Codex harness: it remains +agent-scoped by default. The separate supervision connection defaults +to managed user-home stdio, so its catalog and snapshot operations see native +stored threads. Explicit `appServer` connection settings are honored. When +`homeScope` is unset, the supervision connection resolves it to `"user"` for stdio +or Unix and `"agent"` for WebSocket. Set `appServer.homeScope: "user"` +explicitly only when the ordinary harness should also share the native Codex +home. A Chat created through Codex Sessions is the exception: its private +supervision binding keeps source reads, canonical branch creation, and later +turns on the supervision connection. Live status and ownership remain +process-local; a thread unknown to OpenClaw's supervision process is `notLoaded` +even when Codex Desktop is actively running it. + +Codex has an experimental canonical local daemon with a separate +installer-managed bootstrap contract. This feature must not bootstrap, claim, +or assume that daemon implicitly. + +## Catalog flow + +The Gateway method `codex.sessions.list` always requests `archived: false` and +the interactive `cli` and `vscode` source kinds. It combines: + +1. Gateway-local `thread/list` results from the supervision App Server, + which defaults to managed user-home stdio. +2. `codex.appServer.threads.list.v1` results from each connected, opted-in node. + +The native macOS paired-node implementation supports only an unset/default or +explicit `appServer.transport: "stdio"` with unset/default supervision scope or +explicit `appServer.homeScope: "user"`. It carries configured `command`, `args`, +and normalized `clearEnv` into the child process. With `"unix"`, `"websocket"`, +or explicit `homeScope: "agent"`, it advertises neither the catalog capability +nor command; direct invocation also fails closed. It must never expose the user +Codex home for an agent-scoped configuration or substitute local stdio for an +explicit endpoint. + +The projection normalizes identifiers, title, cwd, status, active wait flags, +timestamps, source, model provider, Codex version, and Git branch. Paired nodes +do not return transcript previews, turns, rollout paths, Codex home paths, Git +remotes, commit SHAs, raw endpoints, or raw App Server errors. + +Host failures remain local to each host result. An offline node or unavailable +local App Server does not erase healthy hosts from the page. Connectivity is a +host property, not a thread status: a failed host result contains no fresh +session rows and does not project `offline` onto native threads. + +Catalog discovery is passive. Listing or reading metadata must not call +`thread/resume`, subscribe the OpenClaw client to live thread requests, or +answer an approval. + +Search is title-only and case-insensitive. For each returned catalog page, the +Gateway and paired Mac scan a bounded number of native pages without passing +the query to App Server, because native search can also match transcript +previews. The returned native cursor lets callers continue the scan. + +## Operator CLI boundary + +The plugin registers three Gateway-backed shell commands: + +```text +openclaw codex sessions [--search ] [--host ] [--limit ] [--cursor ] [--json] [gateway-options] +openclaw codex continue [--json] [gateway-options] +openclaw codex archive --confirm-no-other-runner [--json] [gateway-options] +``` + +`[gateway-options]` is `--url `, `--token `, `--timeout `, and +the inherited `--expect-final` switch. The timeout defaults to 30,000 ms; +`--expect-final` has no additional effect for these unary RPCs. Session search +is title-only and case-insensitive; each response scans a bounded native page +chain, and `--cursor` continues older results. The limit defaults to 50 per host +and accepts 1 through 100, and a cursor requires one stable `--host` +destination. No command accepts +an archived/include-archived option. Only `sessions` can target paired hosts; +`continue` and `archive` always send `hostId: "gateway:local"`, and archive +requires the explicit confirmation flag. + +The shell namespace is not the in-chat `/codex` runtime namespace. In +particular, `/codex sessions --host ` lists Codex CLI session files on one +node, `/codex threads` lists App Server threads for the current conversation +connection, and `/codex resume` or `/codex bind` mutates that conversation's +binding. Those commands do not replace `codex.sessions.continue`, and there is +no `/codex continue` or `/codex archive` runtime command. + +## Local continuation + +For a stored or idle Gateway-local row, the UI calls +`codex.sessions.continue` with the host and thread ids. The plugin: + +1. Reuses the existing supervised Chat when the source already has one. +2. Otherwise projects bounded user and assistant history through the source's + last terminal persisted turn (completed, interrupted, or failed) into a new + OpenClaw Chat and records a pending harness branch. +3. Stores the pending Codex-only model-lock policy, not a concrete model or + provider selection, plus the private supervision connection scope, and + returns the OpenClaw `sessionKey`. + +The history projection selects the newest tail of visible user and assistant +messages, with hard limits of 200 messages, 512 KiB of UTF-8 text in total, and +64 KiB per message. It replaces image and local-image inputs with +`[Image attachment]`, never copies image payloads or paths, and omits reasoning, +tool calls, and tool results. + +The UI navigates to normal Chat with that session key. No canonical harness +thread exists yet. On the first normal Chat turn, the harness installs the real +Codex approval, elicitation, event, and delivery handlers, then: + +1. Uses the supervision connection to call native `thread/fork` without a model + or provider override and pin the persisted source snapshot. Codex's current + `ConfigManager` state selects the model and provider, and the fork response + reports the actual pair. If the model differs from the last model recorded + in the source, Codex emits its normal model-difference warning. +2. On that same connection, starts the canonical full Codex harness thread with + `threadSource: "appServer"`, OpenClaw's cwd, policy, config, environment, the + full OpenClaw harness tool surface, and exactly the model and provider + returned by the fork for this initial start. +3. Injects the bounded visible user and assistant history through that + connection, commits the canonical binding without dropping its supervision + scope, runs the turn, and archives the temporary fork. + +Before the first turn, the Chat is a locked pending branch with a visible +history mirror; afterward, every model turn runs through the canonical Codex +harness thread on the supervision connection. The branch is not a full native +rollout clone: source reasoning, tool calls, and tool results are deliberately +omitted. If snapshot pinning or canonical thread creation fails, the pending +branch remains retryable. A binding race, disabled supervision, or an unavailable +or mismatched supervision connection fails closed before the turn runs instead +of falling back to the ordinary agent-home harness. + +This guarantees Codex-owned selection, not preservation of the source's +historical model. The fork's returned pair is used for the canonical thread +start, and Codex persists that thread's native model and provider. Later resumes +omit OpenClaw model and provider overrides, so Codex restores the persisted pair. +If a separate native Codex control changes the canonical thread, OpenClaw accepts +that native persisted selection. The outer OpenClaw model and fallback chain +never substitute for it. + +Model changes, session deletion, and session reset/new operations fail closed +for the supervised model-locked Chat. Mutating `/codex model `, `/codex +bind`, `/codex resume` (including node `--bind here`), and `/codex detach` or +`/codex unbind` also fail closed because they replace or clear the binding. The +`/codex model` query and `/codex fast`, `/codex permissions`, and `/codex +threads` remain available. The `codex_threads` agent tool cannot attach a new +fork or archive the bound native thread. List and metadata-only read remain +available; transcript fields require `supervision.allowRawTranscripts`, while +rename, unarchive, detached fork, and archive of an unrelated thread require +`supervision.allowWriteControls`. Neither option can replace the locked binding. +Deleting or resetting the OpenClaw entry would otherwise discard the native +binding and create or permit a generic thread behind a Codex-looking session. +Retention maintenance therefore preserves model-locked entries even when they +exceed ordinary age, count, or disk-budget limits. Disabling or uninstalling the +owning plugin also retains the lock and plugin ownership marker. The Chat stays +unavailable and fails closed until the same plugin is re-enabled; cleanup never +converts it into an ordinary model session. + +The source is never resumed or mutated by this action. The temporary fork pins a +snapshot; it is not the durable continuation thread. Starting a distinct +canonical harness thread on the first turn prevents OpenClaw from becoming a +competing source writer merely because process-local status failed to see a +Desktop-owned turn. The visible-history mirror and pinned snapshot may omit work +that has not yet completed in an active source. The original CLI or VS Code +source remains eligible for both native and OpenClaw catalogs. The canonical +branch remains a native Codex thread in the supervision store, but native clients +may filter its `appServer` source kind, so Codex Desktop visibility is not a +contract. + +## Archive behavior + +For a stored or idle Gateway-local row, `codex.sessions.archive` requires +explicit `confirmNoOtherRunner: true`, freshly reads current process-local +status, proceeds only for `idle` or `notLoaded`, calls native `thread/archive`, +and returns success only after Codex accepts the operation. The row then leaves +the non-archived catalog. + +An active or error status from the fresh read rejects archive. So does an +initializing or pending supervised branch from the source: the first Chat turn +must materialize its canonical branch before the source can be archived. A +known active OpenClaw binding owner for the exact target or any non-archived +spawned descendant also rejects archive. OpenClaw paginates Codex's experimental +`thread/list ancestorThreadId` relation and fails closed on request or response +errors, cursor or thread cycles, and safety-limit exhaustion. Native archive can +shut down loaded parent and descendant work, so archive is not an interrupt +shortcut. The read, descendant enumeration, and archive calls are not atomic. +An independent client can still own or start work on a row that appears idle or +`notLoaded` locally. The no-other-runner confirmation covers unknown clients and +that race until Codex has a conditional archive or cross-process lease. +Paired-node archive is prohibited. + +There is no archived view in Codex Sessions. A thread restored with +`thread/unarchive` in another owner-authorized Codex surface becomes eligible +for the non-archived catalog again. + +## Active thread safety + +Codex serializes mutations for a thread among clients of one App Server, but it +does not expose an exclusive cross-process runner or approval-owner lease. +Independent stdio App Servers can append to the same rollout, while each sees +only its own in-memory status. Approval requests can also reach every subscriber +of one server, with the first valid response completing the request. + +Therefore: + +- passive catalog clients do not subscribe or auto-deny approvals +- rows currently reported active expose neither a new branch nor Archive +- an unmapped source becomes a visible-history branch whose canonical harness + thread never resumes the source +- `notLoaded` is shown as activity unknown and can be archived only after + informed no-other-runner confirmation +- local archive requires that confirmation plus a fresh `idle` or `notLoaded` + read, while acknowledging the protocol race between read and archive + +Interrupt and multi-client handoff are future product decisions. They are not +implied by showing an active row. + +## Paired-node boundary + +Node invoke is currently request/response only. It can safely return bounded +catalog metadata, but it cannot carry the long-lived event stream, approval +requests, tool calls, cancellation, and assistant deltas required by a Codex +harness run. + +The initial node contract is therefore listing only. Remote rows stay visible +but **Continue** and **Archive** are unavailable, regardless of idle status. A +real remote continuation requires a node-side runner and streaming bridge that +preserves the same approval and binding invariants as the local harness. + +## Permissions + +Each computer opts in locally. Enabling the Gateway does not authorize another +node to read its Codex metadata. The node capability must pass normal pairing +and command-policy approval. + +Fleet listing uses the `operator.write` Gateway scope because it invokes paired +nodes. Local continuation and archive are authenticated operator actions and +remain subject to host and status checks. + +Autonomous agent and standalone MCP access is separate. The shipped +`codex_endpoint_probe`, `codex_sessions_list`, `codex_session_read`, +`codex_session_send`, and `codex_session_interrupt` tool contracts remain owned +by the `codex` plugin. With supervision enabled, raw `codex_threads` transcript +reads and transcript-derived list fields also require +`supervision.allowRawTranscripts`; every `codex_threads` fork, rename, archive, +or unarchive requires `supervision.allowWriteControls`. Both policies default to +disabled. + +## Compatibility + +`openclaw doctor --fix` migrates shipped `plugins.entries.codex-supervisor` +configuration, including endpoints and transcript/write policies, plus plugin +allow/deny references into +`plugins.entries.codex.config.supervision`. Explicit canonical destination +values win conflicts. Runtime code uses only the canonical `codex` plugin +shape after migration. + +The official plugin retains exactly five Supervisor compatibility tools: +`codex_endpoint_probe`, `codex_sessions_list`, `codex_session_read`, +`codex_session_send`, and `codex_session_interrupt`. Session list is loaded-only +by default; there is no `loaded_only` parameter. `include_stored: true` adds +non-archived state-database rows, bounded per endpoint by `max_stored_sessions` +(default 200, accepted range 1 through 1,000); loaded rows are uncapped by that +setting. Transcript-derived fields and reads remain gated by +`allowRawTranscripts`; send and interrupt remain gated by `allowWriteControls`. + +Compatibility send never starts or resumes an idle thread. `mode: "start"` is +always refused; `"auto"` and `"steer"` steer only a readable active turn. +Interrupt likewise requires an active readable turn. Idle continuation routes +to Codex Sessions so the full harness owns approvals, tools, and the binding. +The standalone legacy MCP adapter resolves these same tools from the official +plugin and is the only path that honors the retained legacy policy environment +variables. + +The July catalog UI, Gateway method, node capability, and CLI registration had +not shipped under the old plugin id. They move directly to `codex` ownership +without a second runtime facade. + +## Future work + +- node-side streaming runner and event bridge for remote continuation +- explicit runner and approval-owner leases for simultaneous client handoff +- remote archive after a runner-ownership lease or equivalent fencing exists +- interrupt and richer active-session observation +- audited handoff between Codex Desktop, CLI, and OpenClaw + +Archived browsing is not part of the planned supervision sidebar. Native Codex +surfaces remain the recovery path for archived threads. + +## Acceptance tests + +- Enabling supervision lists non-archived local sessions. +- Archived sessions never appear in the catalog response or UI. +- Healthy hosts remain visible when another host fails; an unavailable host + returns no fresh rows instead of inventing an offline session status. +- A stored or idle local row creates a Chat mirror with a Codex-only + model/runtime lock; the first turn pins a temporary snapshot and starts the + canonical full harness thread, and repeating Continue opens the existing Chat. +- The first turn omits model/provider overrides on the snapshot fork and pins + the canonical start to the exact pair returned by Codex, even when Codex warns + that its current model differs from the source's last recorded model. +- Pending and committed supervised bindings use the supervision connection for + source access, canonical branch creation, and every later turn; ordinary + Codex sessions remain agent-scoped. +- Later resumes omit OpenClaw model/provider overrides, preserve Codex's + canonical persisted selection, accept separate native changes to that thread, + and never substitute the outer OpenClaw model or fallback chain. +- Disabling supervision or losing the binding/connection lifecycle fails closed + instead of moving the Chat to the ordinary agent-home harness. +- A supervised model-locked Chat cannot be deleted while it protects the native + binding. +- The Chat mirrors at most 200 user and assistant messages, 512 KiB total, and + 64 KiB per message. Images become placeholders; source reasoning, tool calls, + tool results, image payloads, and local paths are not cloned. +- The branch flow never resumes the source thread. +- The original source remains eligible for both catalogs. The canonical native + branch uses the `appServer` source kind and is not guaranteed to appear in + Codex Desktop. +- Active local sources cannot create a branch or be archived; an existing + supervised Chat can still open. +- Activity-unknown rows can branch without confirmation; archiving requires + explicit no-other-runner confirmation. +- A source with an initializing or pending supervised branch cannot be archived + until the first Chat turn materializes the canonical branch. +- A known active binding owner for the exact target or any non-archived spawned + descendant blocks archive; descendant enumeration failures fail closed, and + explicit confirmation remains responsible for unknown clients and the + status-to-archive race. +- Confirmed stored or idle local archive removes the row after native success. +- Paired-node rows remain visible without Continue or Archive. +- Passive listing never subscribes to or answers thread approvals. +- Legacy Supervisor config migrates to the canonical Codex config shape. +- Legacy list is loaded-only by default, stored enumeration obeys its per-endpoint + cap, and compatibility send never starts or resumes an idle thread. diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index 1b6d9621cbae..fd603aba7b3c 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -878,6 +878,45 @@ describe("active-memory plugin", () => { expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); + it.each([ + { + label: "a reserved Codex harness key", + sessionKey: "agent:main:harness:codex:supervision:thread-1", + entry: { sessionId: "codex-1", updatedAt: 1 }, + }, + { + label: "a model-locked session", + sessionKey: "agent:main:locked-codex-session", + entry: { + sessionId: "codex-2", + updatedAt: 1, + modelSelectionLocked: true, + agentHarnessId: "codex", + }, + }, + ])( + "skips recall before state or model side effects for $label", + async ({ sessionKey, entry }) => { + hoisted.sessionStore[sessionKey] = entry; + const openKeyedStore = vi.spyOn(api.runtime.state, "openKeyedStore"); + + const result = await hooks.before_prompt_build( + { prompt: "continue this native Codex task", messages: [] }, + { + agentId: "main", + trigger: "user", + sessionKey, + messageProvider: "webchat", + }, + ); + + expect(result).toBeUndefined(); + expect(openKeyedStore).not.toHaveBeenCalled(); + expect(hoisted.updateSessionStore).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); + }, + ); + it("does not rewrite session state for skipped turns with no active-memory entry to clear", async () => { const result = await hooks.before_prompt_build( { prompt: "what wings should i order?", messages: [] }, diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index 00a0b5909510..2b11d0d4de9e 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -1154,6 +1154,40 @@ function isEnabledForAgent( return config.agents.includes(agentId); } +function isAgentHarnessSessionKey(sessionKey: string): boolean { + const normalized = sessionKey.trim().toLowerCase(); + const rest = parseAgentSessionKey(normalized)?.rest ?? normalized; + return rest.startsWith("harness:"); +} + +function shouldSkipActiveMemoryForHarnessSession(params: { + api: OpenClawPluginApi; + agentId?: string; + sessionKey?: string; +}): boolean { + const sessionKey = params.sessionKey?.trim(); + if (!sessionKey) { + return false; + } + try { + const entry = params.api.runtime.agent.session.getSessionEntry({ + ...(params.agentId ? { agentId: params.agentId } : {}), + sessionKey, + readConsistency: "latest", + }); + // A missing reserved key must not synthesize work, while unlocked rows are + // grandfathered user sessions from before the namespace was introduced. + return ( + entry?.modelSelectionLocked === true || + (entry === undefined && isAgentHarnessSessionKey(sessionKey)) + ); + } catch { + // Recall is optional. If durable ownership cannot be checked, do not risk + // crossing a harness/model boundary with an independently selected model. + return true; + } +} + function isEligibleInteractiveSession(ctx: { trigger?: string; sessionKey?: string; @@ -3635,6 +3669,15 @@ export default definePluginEntry({ : undefined); const effectiveAgentId = resolvedAgentId || resolveStatusUpdateAgentId({ sessionKey: resolvedSessionKey }); + if ( + shouldSkipActiveMemoryForHarnessSession({ + api, + agentId: effectiveAgentId, + sessionKey: resolvedSessionKey, + }) + ) { + return undefined; + } const sessionDisabled = await isSessionActiveMemoryDisabled({ api, sessionKey: resolvedSessionKey, diff --git a/extensions/codex-supervisor/cli-metadata.ts b/extensions/codex-supervisor/cli-metadata.ts deleted file mode 100644 index d8ccc014d3ed..000000000000 --- a/extensions/codex-supervisor/cli-metadata.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Codex Supervisor CLI metadata stays lightweight until the command runs. -import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; - -export function registerCodexSupervisorCliMetadata(api: OpenClawPluginApi): void { - api.registerCli( - async ({ program }) => { - const { registerCodexSupervisorCli } = await import("./src/cli.js"); - registerCodexSupervisorCli(program); - }, - { - descriptors: [ - { - name: "codex", - description: "Inspect Codex sessions across the Gateway and paired nodes", - hasSubcommands: true, - }, - ], - }, - ); -} - -export default definePluginEntry({ - id: "codex-supervisor", - name: "Codex Supervisor", - description: "Supervise Codex app-server sessions from OpenClaw.", - register: registerCodexSupervisorCliMetadata, -}); diff --git a/extensions/codex-supervisor/index.test.ts b/extensions/codex-supervisor/index.test.ts deleted file mode 100644 index 8d22f83f808f..000000000000 --- a/extensions/codex-supervisor/index.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -// Codex Supervisor tests cover index plugin behavior. -import { createCapturedPluginRegistration } from "openclaw/plugin-sdk/plugin-test-runtime"; -import { describe, expect, it, vi } from "vitest"; -import entry from "./index.js"; - -describe("codex-supervisor plugin entry", () => { - it("registers supervisor tools from plugin config", () => { - const captured = createCapturedPluginRegistration({ id: "codex-supervisor" }); - const registerGatewayMethod = vi.fn(); - const registerNodeHostCommand = vi.fn(); - const registerNodeInvokePolicy = vi.fn(); - captured.api.registerGatewayMethod = registerGatewayMethod; - captured.api.registerNodeHostCommand = registerNodeHostCommand; - captured.api.registerNodeInvokePolicy = registerNodeInvokePolicy; - captured.api.pluginConfig = { - endpoints: [ - { - id: "test", - transport: "websocket", - url: "ws://127.0.0.1:12345", - }, - ], - allowRawTranscripts: true, - allowWriteControls: true, - }; - - entry.register(captured.api); - - expect(captured.tools.map((tool) => tool.name).toSorted()).toEqual([ - "codex_endpoint_probe", - "codex_session_interrupt", - "codex_session_read", - "codex_session_send", - "codex_sessions_list", - ]); - expect(captured.runtimeLifecycles).toHaveLength(1); - expect(captured.runtimeLifecycles[0]).toMatchObject({ - id: "codex-supervisor", - description: "Close Codex supervisor app-server connections.", - }); - expect(captured.controlUiDescriptors).toEqual([ - { - surface: "tab", - id: "sessions", - label: "Codex Sessions", - description: "Codex sessions on this Gateway and paired nodes.", - icon: "terminal", - group: "control", - requiredScopes: ["operator.write"], - }, - ]); - expect(captured.cliRegistrars[0]).toMatchObject({ - descriptors: [ - { - name: "codex", - description: "Inspect Codex sessions across the Gateway and paired nodes", - hasSubcommands: true, - }, - ], - }); - expect(registerGatewayMethod).toHaveBeenCalledWith( - "codex-supervisor.sessions.list", - expect.any(Function), - { scope: "operator.write" }, - ); - expect(registerNodeHostCommand).toHaveBeenCalledWith( - expect.objectContaining({ - command: "codex.appServer.threads.list.v1", - cap: "codex-app-server-threads", - dangerous: false, - }), - ); - expect(registerNodeInvokePolicy).toHaveBeenCalledWith( - expect.objectContaining({ - commands: ["codex.appServer.threads.list.v1"], - defaultPlatforms: ["macos", "linux", "windows"], - }), - ); - expect(entry.configSchema.jsonSchema).toMatchObject({ - type: "object", - properties: { - endpoints: { type: "array" }, - allowRawTranscripts: { type: "boolean" }, - allowWriteControls: { type: "boolean" }, - }, - }); - }); -}); diff --git a/extensions/codex-supervisor/index.ts b/extensions/codex-supervisor/index.ts deleted file mode 100644 index 3b61d6859632..000000000000 --- a/extensions/codex-supervisor/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Bundled plugin entry that exposes Codex app-server supervisor tools to - * OpenClaw agents. - */ -import { buildJsonPluginConfigSchema, definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { registerCodexSupervisorCliMetadata } from "./cli-metadata.js"; -import { - CodexSupervisorPluginConfigSchema, - resolveCodexSupervisorPluginConfig, -} from "./src/config.js"; -import { createCodexSupervisorTools } from "./src/plugin-tools.js"; -import { - createCodexSessionCatalogNodeHostCommands, - createCodexSessionCatalogNodeInvokePolicies, - createCodexSessionCatalogSupervisor, - registerCodexSessionCatalogGateway, -} from "./src/session-catalog.js"; -import { CodexSupervisor } from "./src/supervisor.js"; - -export default definePluginEntry({ - id: "codex-supervisor", - name: "Codex Supervisor", - description: "Supervise Codex app-server sessions from OpenClaw.", - configSchema: buildJsonPluginConfigSchema( - CodexSupervisorPluginConfigSchema as unknown as Parameters< - typeof buildJsonPluginConfigSchema - >[0], - ), - register(api) { - const config = resolveCodexSupervisorPluginConfig(api.pluginConfig); - const supervisor = new CodexSupervisor(config.endpoints); - // Catalog reads use a dedicated stdio app-server, so enabling the plugin - // works without replacing the live-control daemon endpoint contract. - const catalogSupervisor = createCodexSessionCatalogSupervisor(config.endpoints); - api.lifecycle.registerRuntimeLifecycle({ - id: "codex-supervisor", - description: "Close Codex supervisor app-server connections.", - cleanup: async () => { - await Promise.all([supervisor.close(), catalogSupervisor.close()]); - }, - }); - for (const command of createCodexSessionCatalogNodeHostCommands(catalogSupervisor)) { - api.registerNodeHostCommand(command); - } - for (const policy of createCodexSessionCatalogNodeInvokePolicies()) { - api.registerNodeInvokePolicy(policy); - } - registerCodexSessionCatalogGateway({ api, supervisor: catalogSupervisor }); - registerCodexSupervisorCliMetadata(api); - for (const tool of createCodexSupervisorTools({ - supervisor, - policy: { - allowRawTranscripts: config.allowRawTranscripts, - allowWriteControls: config.allowWriteControls, - }, - })) { - api.registerTool(tool); - } - }, -}); diff --git a/extensions/codex-supervisor/openclaw.plugin.json b/extensions/codex-supervisor/openclaw.plugin.json deleted file mode 100644 index e18bef631d63..000000000000 --- a/extensions/codex-supervisor/openclaw.plugin.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "id": "codex-supervisor", - "enabledByDefault": false, - "activation": { - "onStartup": true, - "onCommands": ["codex"] - }, - "name": "Codex Supervisor", - "description": "Supervise Codex app-server sessions from OpenClaw.", - "contracts": { - "tools": [ - "codex_endpoint_probe", - "codex_sessions_list", - "codex_session_read", - "codex_session_send", - "codex_session_interrupt" - ] - }, - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "endpoints": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "object", - "additionalProperties": false, - "properties": { - "id": { - "type": "string" - }, - "label": { - "type": "string" - }, - "transport": { - "const": "stdio-proxy" - }, - "command": { - "type": "string" - }, - "args": { - "type": "array", - "items": { - "type": "string" - } - }, - "cwd": { - "type": "string" - } - } - }, - { - "type": "object", - "additionalProperties": false, - "required": ["transport", "url"], - "properties": { - "id": { - "type": "string" - }, - "label": { - "type": "string" - }, - "transport": { - "const": "websocket" - }, - "url": { - "type": "string" - }, - "authTokenEnv": { - "type": "string" - } - } - } - ] - } - }, - "allowRawTranscripts": { - "type": "boolean", - "default": false - }, - "allowWriteControls": { - "type": "boolean", - "default": false - } - } - } -} diff --git a/extensions/codex-supervisor/package.json b/extensions/codex-supervisor/package.json deleted file mode 100644 index 8b2a2eabce9d..000000000000 --- a/extensions/codex-supervisor/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "@openclaw/codex-supervisor", - "version": "2026.7.2", - "private": true, - "description": "OpenClaw Codex app-server fleet supervision plugin.", - "type": "module", - "dependencies": { - "@modelcontextprotocol/sdk": "1.29.0", - "typebox": "1.3.3", - "ws": "8.21.0", - "zod": "4.4.3" - }, - "devDependencies": { - "@openclaw/plugin-sdk": "workspace:*" - }, - "openclaw": { - "extensions": [ - "./index.ts" - ] - } -} diff --git a/extensions/codex-supervisor/src/api.ts b/extensions/codex-supervisor/src/api.ts deleted file mode 100644 index cebb4e1edce6..000000000000 --- a/extensions/codex-supervisor/src/api.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Public Codex Supervisor API barrel for plugin tools, MCP serving, config, and - * session types. - */ -export { - CodexSupervisorPluginConfigSchema, - loadCodexSupervisorEndpoints, - resolveCodexSupervisorPluginConfig, -} from "./config.js"; -export { CodexSupervisor } from "./supervisor.js"; -export { registerCodexSupervisorCli } from "./cli.js"; -export { - CODEX_APP_SERVER_THREADS_LIST_COMMAND, - CODEX_SESSION_CATALOG_METHOD, - CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT, - createCodexSessionCatalogNodeHostCommands, - createCodexSessionCatalogNodeInvokePolicies, - createCodexSessionCatalogSupervisor, - listCodexSessionCatalog, - parseCodexSessionCatalogResult, - registerCodexSessionCatalogGateway, -} from "./session-catalog.js"; -export { createCodexSupervisorTools } from "./plugin-tools.js"; -export { createCodexSupervisorMcpServer, serveCodexSupervisorMcp } from "./mcp-server.js"; -export type { CodexSupervisorPluginConfig, ResolvedCodexSupervisorPluginConfig } from "./config.js"; -export type { - CodexJsonRpcConnection, - CodexSessionCatalogError, - CodexSessionCatalogHost, - CodexSessionCatalogPage, - CodexSessionCatalogPageParams, - CodexSessionCatalogParams, - CodexSessionCatalogResult, - CodexSessionCatalogSession, - CodexSupervisorEndpoint, - CodexSupervisorEndpointHealth, - CodexSupervisorSendResult, - CodexSupervisorSession, - CodexSupervisorSessionListResult, - CodexSupervisorThreadStatus, - CodexSupervisorTurnMode, -} from "./types.js"; diff --git a/extensions/codex-supervisor/src/cli.test.ts b/extensions/codex-supervisor/src/cli.test.ts deleted file mode 100644 index 32219d76ce83..000000000000 --- a/extensions/codex-supervisor/src/cli.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -// Codex Supervisor tests cover CLI catalog filters, output, and Gateway authorization. -import { Command } from "commander"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { registerCodexSupervisorCli } from "./cli.js"; - -const gatewayRuntime = vi.hoisted(() => ({ - callGatewayFromCli: vi.fn(), -})); - -vi.mock("openclaw/plugin-sdk/gateway-runtime", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/gateway-runtime", - ); - return { - ...actual, - callGatewayFromCli: gatewayRuntime.callGatewayFromCli, - }; -}); - -const catalog = { - hosts: [ - { - hostId: "gateway:local", - label: "MacBook Pro", - kind: "gateway", - connected: true, - endpointId: "local", - sessions: [ - { - threadId: "00000000-0000-4000-8000-000000000002", - name: "Build Codex fleet sessions", - cwd: "/Users/test/Projects/openclaw", - status: "idle", - activeFlags: [], - updatedAt: 1_788_805_800, - recencyAt: 1_788_805_800, - source: "vscode", - modelProvider: "openai", - gitBranch: "codex/codex-session-fleet", - archived: false, - }, - ], - nextCursor: "gateway-next", - }, - { - hostId: "node:devbox", - label: "Dev Box", - kind: "node", - connected: false, - nodeId: "devbox", - sessions: [], - error: { code: "NODE_OFFLINE", message: "node is not connected" }, - }, - ], -}; - -function createProgram(): Command { - const program = new Command(); - program.exitOverride(); - program.configureOutput({ writeErr: () => {}, writeOut: () => {} }); - registerCodexSupervisorCli(program); - return program; -} - -async function captureStdout(run: () => Promise): Promise { - const chunks: string[] = []; - const write = vi.spyOn(process.stdout, "write").mockImplementation((chunk): boolean => { - chunks.push(String(chunk)); - return true; - }); - try { - await run(); - return chunks.join(""); - } finally { - write.mockRestore(); - } -} - -describe("registerCodexSupervisorCli", () => { - beforeEach(() => { - gatewayRuntime.callGatewayFromCli.mockReset(); - gatewayRuntime.callGatewayFromCli.mockResolvedValue(catalog); - }); - - it("maps filters and a host cursor to the catalog Gateway method", async () => { - const program = createProgram(); - const output = await captureStdout(async () => { - await program.parseAsync( - [ - "codex", - "sessions", - "--search", - " openclaw ", - "--archived", - "--host", - "node:devbox", - "--limit", - "25", - "--cursor", - "node-next", - "--url", - "ws://gateway.test", - "--token", - "secret", - "--timeout", - "1234", - "--json", - ], - { from: "user" }, - ); - }); - - expect(gatewayRuntime.callGatewayFromCli).toHaveBeenCalledWith( - "codex-supervisor.sessions.list", - { - url: "ws://gateway.test", - token: "secret", - timeout: "1234", - json: true, - }, - { - search: "openclaw", - archived: true, - limitPerHost: 25, - hostIds: ["node:devbox"], - cursors: { "node:devbox": "node-next" }, - }, - { mode: "cli", scopes: ["operator.write"] }, - ); - expect(JSON.parse(output)).toEqual({ hosts: [catalog.hosts[1]] }); - }); - - it("renders connected sessions, metadata, pagination, and offline host errors", async () => { - const program = createProgram(); - const output = await captureStdout(async () => { - await program.parseAsync(["codex", "sessions"], { from: "user" }); - }); - - expect(output).toContain("MacBook Pro (gateway · gateway:local) — connected — 1 session"); - expect(output).toContain("00000000-0000-4000-8000-000000000002"); - expect(output).toContain("Build Codex fleet sessions"); - expect(output).toContain("/Users/test/Projects/openclaw"); - expect(output).toContain("branch codex/codex-session-fleet"); - expect(output).toContain( - "repeat the same filters with --host 'gateway:local' --cursor 'gateway-next'", - ); - expect(output).toContain("Dev Box (node · node:devbox · devbox) — offline — 0 sessions"); - expect(output).toContain("Error [NODE_OFFLINE]: node is not connected"); - }); - - it("neutralizes terminal controls in human-readable host and session metadata", async () => { - const program = createProgram(); - gatewayRuntime.callGatewayFromCli.mockResolvedValueOnce({ - hosts: [ - { - hostId: "gateway:local\u001b", - label: "Mac\u001b[31m\nBook", - kind: "gateway", - connected: true, - sessions: [ - { - threadId: "thread\u001b[2J", - name: "Fleet\u0007\nSession", - cwd: "/tmp/work\u001b]0;owned\u0007", - status: "idle\u001b", - activeFlags: ["turn\u001b"], - gitBranch: "main\u0000branch", - archived: false, - }, - ], - error: { code: "WARN\u001b", message: "first\nsecond\u0007" }, - nextCursor: "next\u001b[2J", - }, - ], - }); - - const output = await captureStdout(async () => { - await program.parseAsync(["codex", "sessions"], { from: "user" }); - }); - - expect(output).not.toContain("\u001b"); - expect(output).not.toContain("\u0007"); - expect(output).not.toContain("\u0000"); - expect(output).toContain("Fleet\\nSession"); - expect(output).toContain("mainbranch"); - expect(output).toContain("first\\nsecond"); - }); - - it("rejects an unroutable cursor before calling the Gateway", async () => { - const program = createProgram(); - - await expect( - program.parseAsync(["codex", "sessions", "--cursor", "next"], { from: "user" }), - ).rejects.toThrow("--cursor requires --host"); - expect(gatewayRuntime.callGatewayFromCli).not.toHaveBeenCalled(); - }); - - it("rejects invalid per-host limits before calling the Gateway", async () => { - const program = createProgram(); - - await expect( - program.parseAsync(["codex", "sessions", "--limit", "1.5"], { from: "user" }), - ).rejects.toThrow("--limit must be an integer between 1 and 100"); - await expect( - program.parseAsync(["codex", "sessions", "--limit", "101"], { from: "user" }), - ).rejects.toThrow("--limit must be an integer between 1 and 100"); - expect(gatewayRuntime.callGatewayFromCli).not.toHaveBeenCalled(); - }); - - it("rejects malformed catalog responses", async () => { - const program = createProgram(); - gatewayRuntime.callGatewayFromCli.mockResolvedValueOnce({ hosts: null }); - - await expect(program.parseAsync(["codex", "sessions"], { from: "user" })).rejects.toThrow( - "Codex session catalog returned an invalid result", - ); - }); -}); diff --git a/extensions/codex-supervisor/src/config.ts b/extensions/codex-supervisor/src/config.ts deleted file mode 100644 index db13e2ac6496..000000000000 --- a/extensions/codex-supervisor/src/config.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -/** - * Config parsing for Codex Supervisor endpoints and safety gates. - */ -import { Type, type Static } from "typebox"; -import type { CodexSupervisorEndpoint } from "./types.js"; - -const ENDPOINTS_ENV = "OPENCLAW_CODEX_SUPERVISOR_ENDPOINTS"; - -const StdioEndpointSchema = Type.Object( - { - id: Type.Optional(Type.String()), - label: Type.Optional(Type.String()), - transport: Type.Optional(Type.Literal("stdio-proxy")), - command: Type.Optional(Type.String()), - args: Type.Optional(Type.Array(Type.String())), - cwd: Type.Optional(Type.String()), - }, - { additionalProperties: false }, -); - -const WebSocketEndpointSchema = Type.Object( - { - id: Type.Optional(Type.String()), - label: Type.Optional(Type.String()), - transport: Type.Literal("websocket"), - url: Type.String(), - authTokenEnv: Type.Optional(Type.String()), - }, - { additionalProperties: false }, -); - -/** - * Plugin config schema accepted by the bundled plugin manifest. - */ -export const CodexSupervisorPluginConfigSchema = Type.Object( - { - endpoints: Type.Optional( - Type.Array(Type.Union([StdioEndpointSchema, WebSocketEndpointSchema])), - ), - allowRawTranscripts: Type.Optional(Type.Boolean({ default: false })), - allowWriteControls: Type.Optional(Type.Boolean({ default: false })), - }, - { additionalProperties: false }, -); - -/** Raw plugin config shape accepted from OpenClaw config. */ -export type CodexSupervisorPluginConfig = Static; - -/** Normalized config consumed by plugin registration and MCP serving. */ -export type ResolvedCodexSupervisorPluginConfig = { - endpoints: CodexSupervisorEndpoint[]; - allowRawTranscripts: boolean; - allowWriteControls: boolean; -}; - -function normalizeEndpointId(value: string, index: number): string { - const trimmed = value.trim(); - if (trimmed) { - return trimmed.replace(/[^a-zA-Z0-9_.:-]/g, "-"); - } - return `endpoint-${index + 1}`; -} - -function parseEndpointRecord(value: unknown, index: number): CodexSupervisorEndpoint | undefined { - if (!isRecord(value)) { - return undefined; - } - const transport = typeof value.transport === "string" ? value.transport : undefined; - const id = - typeof value.id === "string" - ? normalizeEndpointId(value.id, index) - : normalizeEndpointId(typeof value.label === "string" ? value.label : "", index); - const label = typeof value.label === "string" ? value.label : undefined; - if (transport === "websocket" && typeof value.url === "string") { - return { - id, - transport, - url: value.url, - ...(label ? { label } : {}), - ...(typeof value.authTokenEnv === "string" ? { authTokenEnv: value.authTokenEnv } : {}), - }; - } - if (transport === "stdio-proxy" || transport === undefined) { - const args = Array.isArray(value.args) - ? value.args.filter((entry): entry is string => typeof entry === "string") - : undefined; - return { - id, - transport: "stdio-proxy", - ...(label ? { label } : {}), - ...(typeof value.command === "string" ? { command: value.command } : {}), - ...(args && args.length > 0 ? { args } : {}), - ...(typeof value.cwd === "string" ? { cwd: value.cwd } : {}), - }; - } - return undefined; -} - -function requireUniqueEndpointIds(endpoints: CodexSupervisorEndpoint[]): CodexSupervisorEndpoint[] { - const seen = new Set(); - for (const endpoint of endpoints) { - if (seen.has(endpoint.id)) { - throw new Error(`duplicate Codex supervisor endpoint id: ${endpoint.id}`); - } - seen.add(endpoint.id); - } - return endpoints; -} - -function endpointFromToken(token: string, index: number): CodexSupervisorEndpoint | undefined { - const trimmed = token.trim(); - if (!trimmed) { - return undefined; - } - if ( - trimmed.startsWith("ws://") || - trimmed.startsWith("wss://") || - trimmed.startsWith("unix://") - ) { - return { - id: normalizeEndpointId("", index), - transport: "websocket", - url: trimmed, - }; - } - if (trimmed === "local" || trimmed === "proxy" || trimmed === "stdio") { - return { - id: "local", - label: "local Codex app-server daemon", - transport: "websocket", - url: "unix://", - }; - } - const separatorIndex = trimmed.indexOf("="); - const id = separatorIndex >= 0 ? trimmed.slice(0, separatorIndex) : trimmed; - const url = separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : undefined; - if (url?.startsWith("ws://") || url?.startsWith("wss://") || url?.startsWith("unix://")) { - return { - id: normalizeEndpointId(id ?? "", index), - transport: "websocket", - url, - }; - } - return undefined; -} - -/** - * Loads endpoint definitions from environment, defaulting to the local Codex - * app-server unix socket. - */ -export function loadCodexSupervisorEndpoints( - env: Pick = process.env, -): CodexSupervisorEndpoint[] { - const raw = env[ENDPOINTS_ENV]?.trim(); - if (!raw) { - return requireUniqueEndpointIds([ - { - id: "local", - label: "local Codex app-server daemon", - transport: "websocket", - url: "unix://", - }, - ]); - } - if (raw.startsWith("[")) { - const parsed = JSON.parse(raw) as unknown; - if (!Array.isArray(parsed)) { - throw new Error(`${ENDPOINTS_ENV} must be a JSON array`); - } - return requireUniqueEndpointIds( - parsed - .map((entry, index) => parseEndpointRecord(entry, index)) - .filter((entry): entry is CodexSupervisorEndpoint => Boolean(entry)), - ); - } - return requireUniqueEndpointIds( - raw - .split(",") - .map(endpointFromToken) - .filter((entry): entry is CodexSupervisorEndpoint => Boolean(entry)), - ); -} - -function normalizeConfiguredEndpoints( - endpoints: CodexSupervisorPluginConfig["endpoints"], -): CodexSupervisorEndpoint[] | undefined { - if (!endpoints || endpoints.length === 0) { - return undefined; - } - const normalized = endpoints - .map((entry, index) => parseEndpointRecord(entry, index)) - .filter((entry): entry is CodexSupervisorEndpoint => Boolean(entry)); - return normalized.length > 0 ? requireUniqueEndpointIds(normalized) : undefined; -} - -/** - * Resolves raw plugin config and env endpoints into validated runtime config. - */ -export function resolveCodexSupervisorPluginConfig( - rawConfig: unknown, - env: Pick = process.env, -): ResolvedCodexSupervisorPluginConfig { - const config = - rawConfig && typeof rawConfig === "object" && !Array.isArray(rawConfig) - ? (rawConfig as CodexSupervisorPluginConfig) - : {}; - return { - endpoints: normalizeConfiguredEndpoints(config.endpoints) ?? loadCodexSupervisorEndpoints(env), - allowRawTranscripts: config.allowRawTranscripts === true, - allowWriteControls: config.allowWriteControls === true, - }; -} diff --git a/extensions/codex-supervisor/src/json-rpc-client.ts b/extensions/codex-supervisor/src/json-rpc-client.ts deleted file mode 100644 index 424262ffbd1b..000000000000 --- a/extensions/codex-supervisor/src/json-rpc-client.ts +++ /dev/null @@ -1,429 +0,0 @@ -/** - * JSON-RPC transports for Codex app-server connections over stdio proxies or - * websocket/unix-socket endpoints. - */ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { accessSync, constants as fsConstants } from "node:fs"; -import * as net from "node:net"; -import * as os from "node:os"; -import * as path from "node:path"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { - materializeWindowsSpawnProgram, - resolveWindowsSpawnProgram, -} from "openclaw/plugin-sdk/windows-spawn"; -import WebSocket from "ws"; -import type { CodexJsonRpcConnection, CodexSupervisorEndpoint } from "./types.js"; - -type PendingRequest = { - reject: (error: Error) => void; - resolve: (value: unknown) => void; - timeout: NodeJS.Timeout; -}; - -type CodexSupervisorSpawnRuntime = { - platform: NodeJS.Platform; - env: NodeJS.ProcessEnv; - execPath: string; - isExecutable: (filePath: string) => boolean; -}; - -const MACOS_DESKTOP_CODEX_COMMAND = "/Applications/Codex.app/Contents/Resources/codex"; - -function isExecutable(filePath: string): boolean { - try { - accessSync(filePath, fsConstants.X_OK); - return true; - } catch { - return false; - } -} - -const DEFAULT_SPAWN_RUNTIME: CodexSupervisorSpawnRuntime = { - platform: process.platform, - env: process.env, - execPath: process.execPath, - isExecutable, -}; - -/** Resolves an installed Codex app/CLI into a cross-platform stdio invocation. */ -export function resolveCodexSupervisorStdioSpawnInvocation( - endpoint: Extract, - runtime: CodexSupervisorSpawnRuntime = DEFAULT_SPAWN_RUNTIME, -): { command: string; args: string[]; shell?: boolean; windowsHide?: boolean } { - // The bundled supervisor consumes an installed Codex app/CLI; owning the - // managed package here would make every OpenClaw install download Codex. - const command = - endpoint.command ?? - (runtime.platform === "darwin" && runtime.isExecutable(MACOS_DESKTOP_CODEX_COMMAND) - ? MACOS_DESKTOP_CODEX_COMMAND - : "codex"); - const args = endpoint.args ?? ["app-server", "--listen", "stdio://"]; - const program = resolveWindowsSpawnProgram({ - command, - platform: runtime.platform, - env: runtime.env, - execPath: runtime.execPath, - packageName: "@openai/codex", - }); - const invocation = materializeWindowsSpawnProgram(program, args); - return { - command: invocation.command, - args: invocation.argv, - shell: invocation.shell, - windowsHide: invocation.windowsHide, - }; -} - -function formatJsonRpcError(message: Record): Error { - const error = isRecord(message.error) ? message.error : {}; - const detail = - typeof error.message === "string" ? error.message : "Codex app-server request failed"; - return new Error(detail); -} - -function formatMalformedMessageError(error: unknown): Error { - const detail = error instanceof Error ? error.message : String(error); - return new Error(`Malformed Codex app-server message: ${detail}`); -} - -/** - * Produces denial responses for app-server approval requests the supervisor - * deliberately cannot grant. - */ -export function resolveSafeApprovalResult(method: string): Record | undefined { - if (method === "item/tool/call") { - return { - contentItems: [ - { - type: "inputText", - text: "OpenClaw Codex supervisor did not register a handler for this app-server tool call.", - }, - ], - success: false, - }; - } - if (method === "item/commandExecution/requestApproval") { - return { decision: "decline" }; - } - if (method === "item/fileChange/requestApproval") { - return { decision: "decline" }; - } - if (method === "item/permissions/requestApproval") { - return { permissions: {}, scope: "turn" }; - } - if (method.endsWith("/requestApproval")) { - return { - decision: "decline", - reason: "OpenClaw Codex supervisor does not grant native approvals.", - }; - } - if (method === "item/tool/requestUserInput") { - return { answers: {} }; - } - if (method === "mcpServer/elicitation/request") { - return { action: "decline" }; - } - return undefined; -} - -abstract class BaseCodexJsonRpcConnection implements CodexJsonRpcConnection { - private readonly pending = new Map(); - private closedError: Error | undefined; - - abstract close(): Promise; - protected abstract sendRaw(line: string): void; - - async initialize(): Promise { - await this.request("initialize", { - clientInfo: { - name: "openclaw-codex-supervisor", - title: "OpenClaw Codex Supervisor", - version: "0.1.0", - }, - capabilities: { - experimentalApi: true, - }, - }); - this.notify("initialized"); - } - - request(method: string, params?: Record): Promise { - if (this.closedError) { - return Promise.reject(this.closedError); - } - const id = randomUUID(); - const payload: Record = { id, method, params: params ?? {} }; - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.pending.delete(id); - reject(new Error(`Codex app-server request timed out: ${method}`)); - }, 60_000); - this.pending.set(id, { resolve, reject, timeout }); - try { - this.sendRaw(JSON.stringify(payload)); - } catch (error) { - clearTimeout(timeout); - this.pending.delete(id); - reject(error instanceof Error ? error : new Error(String(error))); - } - }); - } - - notify(method: string, params?: Record): void { - const payload: Record = { method, params: params ?? null }; - this.sendRaw(JSON.stringify(payload)); - } - - protected handleMessage(message: unknown): void { - if (!isRecord(message)) { - return; - } - const id = - typeof message.id === "string" || typeof message.id === "number" ? message.id : undefined; - const method = typeof message.method === "string" ? message.method : undefined; - if (id !== undefined && method) { - const result = resolveSafeApprovalResult(method); - // The supervisor is read/steer tooling, not a native approval delegate; - // unknown app-server requests fail closed with either a denial or -32601. - this.sendRaw( - JSON.stringify( - result === undefined - ? { - id, - error: { - code: -32601, - message: `OpenClaw Codex supervisor cannot handle app-server request: ${method}`, - }, - } - : { id, result }, - ), - ); - return; - } - if (id !== undefined) { - const pending = this.pending.get(String(id)); - if (!pending) { - return; - } - clearTimeout(pending.timeout); - this.pending.delete(String(id)); - if ("error" in message) { - pending.reject(formatJsonRpcError(message)); - return; - } - pending.resolve(message.result); - } - } - - protected rejectAll(error: Error): void { - for (const [id, pending] of this.pending) { - clearTimeout(pending.timeout); - this.pending.delete(id); - pending.reject(error); - } - } - - protected fail(error: Error): void { - this.closedError ??= error; - this.rejectAll(this.closedError); - } -} - -class StdioCodexJsonRpcConnection extends BaseCodexJsonRpcConnection { - private buffer = ""; - private readonly proc: ChildProcessWithoutNullStreams; - private readonly stderrTail: string[] = []; - - constructor(endpoint: Extract) { - super(); - const invocation = resolveCodexSupervisorStdioSpawnInvocation(endpoint); - this.proc = spawn(invocation.command, invocation.args, { - cwd: endpoint.cwd, - shell: invocation.shell, - stdio: "pipe", - windowsHide: invocation.windowsHide, - }); - this.proc.stdout.setEncoding("utf8"); - this.proc.stderr.setEncoding("utf8"); - this.proc.stdout.on("data", (chunk: string) => this.handleStdout(chunk)); - this.proc.stderr.on("data", (chunk: string) => { - this.stderrTail.push(...chunk.split(/\r?\n/).filter(Boolean)); - this.stderrTail.splice(0, Math.max(0, this.stderrTail.length - 40)); - }); - this.proc.stdin.once("error", (error) => this.fail(error)); - this.proc.once("error", (error) => this.fail(error)); - this.proc.once("close", () => - this.fail( - new Error( - `Codex app-server stdio transport closed. stderr_tail=${truncateUtf16Safe(this.stderrTail.join("\n"), 1200)}`, - ), - ), - ); - } - - protected sendRaw(line: string): void { - this.proc.stdin.write(`${line}\n`, (error) => { - if (error) { - this.fail(error); - } - }); - } - - async close(): Promise { - this.proc.stdin.end(); - this.proc.kill("SIGTERM"); - } - - private handleStdout(chunk: string): void { - this.buffer += chunk; - for (;;) { - const index = this.buffer.indexOf("\n"); - if (index < 0) { - return; - } - const line = this.buffer.slice(0, index).trim(); - this.buffer = this.buffer.slice(index + 1); - if (!line) { - continue; - } - try { - this.handleMessage(JSON.parse(line) as unknown); - } catch (error) { - this.fail(formatMalformedMessageError(error)); - void this.close(); - return; - } - } - } -} - -function defaultCodexControlSocketPath(): string { - const codexHome = process.env.CODEX_HOME?.trim() || path.join(os.homedir(), ".codex"); - return path.join(codexHome, "app-server-control", "app-server-control.sock"); -} - -function resolveUnixWebSocketPath(url: string): string { - const suffix = url.slice("unix://".length); - return suffix || defaultCodexControlSocketPath(); -} - -function connectCodexSupervisorUnixSocket(url: string): net.Socket { - return net.createConnection(resolveUnixWebSocketPath(url)); -} - -function websocketMessageToString(data: WebSocket.RawData): string { - if (typeof data === "string") { - return data; - } - if (Buffer.isBuffer(data)) { - return data.toString("utf8"); - } - if (Array.isArray(data)) { - return Buffer.concat(data).toString("utf8"); - } - return Buffer.from(data).toString("utf8"); -} - -class WebSocketCodexJsonRpcConnection extends BaseCodexJsonRpcConnection { - private readonly ws: WebSocket; - private readonly openPromise: Promise; - private closing = false; - - constructor(endpoint: Extract) { - super(); - const headers: Record = {}; - if (endpoint.authTokenEnv) { - const token = process.env[endpoint.authTokenEnv]; - if (token) { - headers.authorization = `Bearer ${token}`; - } - } - this.ws = endpoint.url.startsWith("unix://") - ? new WebSocket("ws://localhost/", { - headers, - createConnection: () => connectCodexSupervisorUnixSocket(endpoint.url), - }) - : new WebSocket(endpoint.url, { headers }); - this.openPromise = new Promise((resolve, reject) => { - this.ws.once("open", resolve); - this.ws.once("error", reject); - }); - this.ws.on("message", (data) => { - const text = websocketMessageToString(data); - try { - this.handleMessage(JSON.parse(text) as unknown); - } catch (error) { - this.fail(formatMalformedMessageError(error)); - void this.close(); - } - }); - this.ws.once("error", (error) => this.fail(error)); - this.ws.once("close", () => { - if (!this.closing) { - this.fail(new Error("Codex app-server websocket closed")); - } - }); - } - - async ready(): Promise { - await this.openPromise; - } - - protected sendRaw(line: string): void { - this.ws.send(line, (error) => { - if (error) { - this.fail(error); - } - }); - } - - async close(): Promise { - this.closing = true; - this.fail(new Error("Codex app-server websocket closed")); - if (this.ws.readyState === WebSocket.CLOSED) { - return; - } - await new Promise((resolve) => { - const timeout = setTimeout(() => { - this.ws.terminate(); - resolve(); - }, 1000); - this.ws.once("close", () => { - clearTimeout(timeout); - resolve(); - }); - if (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN) { - this.ws.close(); - } else { - clearTimeout(timeout); - resolve(); - } - }); - } -} - -/** - * Opens, initializes, and returns a JSON-RPC connection for one supervisor - * endpoint. - */ -export async function connectCodexAppServerEndpoint( - endpoint: CodexSupervisorEndpoint, -): Promise { - const connection = - endpoint.transport === "websocket" - ? new WebSocketCodexJsonRpcConnection(endpoint) - : new StdioCodexJsonRpcConnection(endpoint); - try { - if ("ready" in connection && typeof connection.ready === "function") { - await connection.ready(); - } - await connection.initialize(); - return connection; - } catch (error) { - await connection.close().catch(() => undefined); - throw error; - } -} diff --git a/extensions/codex-supervisor/src/mcp-serve.ts b/extensions/codex-supervisor/src/mcp-serve.ts deleted file mode 100644 index bb9a233d1f0f..000000000000 --- a/extensions/codex-supervisor/src/mcp-serve.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Standalone MCP server for OpenClaw Codex supervision. - * - * Run via: node --import tsx extensions/codex-supervisor/src/mcp-serve.ts - */ -import { pathToFileURL } from "node:url"; -import { serveCodexSupervisorMcp } from "./mcp-server.js"; - -function formatErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { - serveCodexSupervisorMcp().catch((err: unknown) => { - process.stderr.write(`codex-supervisor-serve: ${formatErrorMessage(err)}\n`); - process.exit(1); - }); -} diff --git a/extensions/codex-supervisor/src/mcp-server.ts b/extensions/codex-supervisor/src/mcp-server.ts deleted file mode 100644 index 2c570889fd59..000000000000 --- a/extensions/codex-supervisor/src/mcp-server.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Standalone MCP stdio server for exposing Codex Supervisor tools to trusted - * MCP clients. - */ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { loadCodexSupervisorEndpoints } from "./config.js"; -import { - registerCodexSupervisorMcpTools, - type CodexSupervisorMcpToolOptions, -} from "./mcp-tools.js"; -import { CodexSupervisor } from "./supervisor.js"; - -const VERSION = "0.1.0"; - -function routeLogsToStderr(): void { - const methods = ["log", "info", "warn", "error", "debug"] as const; - for (const method of methods) { - console[method] = (...args: unknown[]) => { - process.stderr.write(`${args.map(String).join(" ")}\n`); - }; - } -} - -/** Options for creating or serving a Codex Supervisor MCP server. */ -export type CodexSupervisorMcpServeOptions = { - supervisor?: CodexSupervisor; - toolOptions?: CodexSupervisorMcpToolOptions; -}; - -/** - * Creates an MCP server and owns the supervisor instance unless one is supplied. - */ -export function createCodexSupervisorMcpServer(opts: CodexSupervisorMcpServeOptions = {}): { - server: McpServer; - supervisor: CodexSupervisor; - close: () => Promise; -} { - const supervisor = opts.supervisor ?? new CodexSupervisor(loadCodexSupervisorEndpoints()); - const server = new McpServer({ name: "openclaw-codex-supervisor", version: VERSION }); - registerCodexSupervisorMcpTools(server, supervisor, opts.toolOptions); - return { - server, - supervisor, - close: async () => { - await supervisor.close(); - await server.close(); - }, - }; -} - -/** - * Serves Codex Supervisor tools over MCP stdio until transport or process - * shutdown. - */ -export async function serveCodexSupervisorMcp( - opts: CodexSupervisorMcpServeOptions = {}, -): Promise { - routeLogsToStderr(); - const { server, close } = createCodexSupervisorMcpServer(opts); - const transport = new StdioServerTransport(); - - let shuttingDown = false; - let resolveClosed!: () => void; - const closed = new Promise((resolve) => { - resolveClosed = resolve; - }); - - const shutdown = () => { - if (shuttingDown) { - return; - } - shuttingDown = true; - process.stdin.off("end", shutdown); - process.stdin.off("close", shutdown); - process.off("SIGINT", shutdown); - process.off("SIGTERM", shutdown); - // The SDK exposes this callback slot but not a stable setter; clear it so - // close() cannot recursively re-enter shutdown. - transport["onclose"] = undefined; - close().then(resolveClosed, resolveClosed); - }; - - transport["onclose"] = shutdown; - process.stdin.once("end", shutdown); - process.stdin.once("close", shutdown); - process.once("SIGINT", shutdown); - process.once("SIGTERM", shutdown); - - try { - await server.connect(transport); - await closed; - } finally { - shutdown(); - await closed; - } -} diff --git a/extensions/codex-supervisor/src/mcp-tools.test.ts b/extensions/codex-supervisor/src/mcp-tools.test.ts deleted file mode 100644 index 22d2d71277b4..000000000000 --- a/extensions/codex-supervisor/src/mcp-tools.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -// Codex Supervisor tests cover mcp tools plugin behavior. -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { describe, expect, it } from "vitest"; -import { - redactCodexSupervisorEndpoint, - redactCodexSupervisorValue, - registerCodexSupervisorMcpTools, - sanitizeCodexSupervisorSessionListResult, -} from "./mcp-tools.js"; -import type { CodexSupervisor } from "./supervisor.js"; - -describe("redactCodexSupervisorValue", () => { - it("redacts sensitive keys and common bearer-like secrets", () => { - expect( - redactCodexSupervisorValue({ - authorization: "Bearer abcdefghijklmnopqrstuvwxyz012345", - nested: { - apiKey: "sk-abcdefghijklmnopqrstuvwxyz012345", - text: "token ghp_abcdefghijklmnopqrstuvwxyz012345 remains hidden", - }, - }), - ).toEqual({ - authorization: "[redacted]", - nested: { - apiKey: "[redacted]", - text: "token [redacted] remains hidden", - }, - }); - }); -}); - -describe("redactCodexSupervisorEndpoint", () => { - it("removes websocket credentials and query values", () => { - expect( - redactCodexSupervisorEndpoint({ - id: "prod", - transport: "websocket", - url: "wss://user:secret@example.invalid/control?token=a=b", - }), - ).toEqual({ - id: "prod", - transport: "websocket", - url: "wss://example.invalid/control?[redacted]", - }); - }); -}); - -describe("sanitizeCodexSupervisorSessionListResult", () => { - it("omits transcript-derived fields unless explicitly trusted", () => { - const result = { - sessions: [ - { - endpointId: "local", - threadId: "thread-1", - status: "idle", - preview: "first prompt", - name: "thread title", - }, - ], - errors: [{ endpointId: "down", ok: false, detail: "stderr secret" }], - }; - - expect(sanitizeCodexSupervisorSessionListResult(result, false)).toEqual({ - sessions: [{ endpointId: "local", threadId: "thread-1", status: "idle" }], - errors: [{ endpointId: "down", ok: false }], - }); - expect(sanitizeCodexSupervisorSessionListResult(result, true)).toEqual(result); - }); -}); - -describe("registerCodexSupervisorMcpTools", () => { - it("uses per-server transcript policy when listing sessions", async () => { - const handlers = new Map) => Promise>(); - const server = { - tool(name: string, _description: string, _schema: unknown, handler: unknown) { - handlers.set(name, handler as (params: Record) => Promise); - }, - } as unknown as McpServer; - const supervisor = { - listSessionSnapshot: async () => ({ - sessions: [ - { - endpointId: "local", - threadId: "thread-1", - status: "idle", - preview: "first prompt", - name: "thread title", - }, - ], - errors: [{ endpointId: "down", ok: false, detail: "stderr secret" }], - }), - } as unknown as CodexSupervisor; - - registerCodexSupervisorMcpTools(server, supervisor, { - rawTranscriptReadsAllowed: () => false, - }); - - await expect(handlers.get("codex_sessions_list")?.({})).resolves.toMatchObject({ - structuredContent: { - sessions: [{ endpointId: "local", threadId: "thread-1", status: "idle" }], - errors: [{ endpointId: "down", ok: false }], - }, - }); - }); -}); diff --git a/extensions/codex-supervisor/src/mcp-tools.ts b/extensions/codex-supervisor/src/mcp-tools.ts deleted file mode 100644 index 7e1ec4130f39..000000000000 --- a/extensions/codex-supervisor/src/mcp-tools.ts +++ /dev/null @@ -1,281 +0,0 @@ -/** - * MCP tool registration plus redaction helpers for Codex Supervisor sessions - * and endpoint metadata. - */ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import type { CodexSupervisor } from "./supervisor.js"; -import type { - CodexSupervisorEndpoint, - CodexSupervisorSession, - CodexSupervisorSessionListResult, -} from "./types.js"; - -/** Env gate for exposing transcript-derived fields through standalone MCP. */ -export const RAW_TRANSCRIPTS_ENV = "OPENCLAW_CODEX_SUPERVISOR_ALLOW_RAW_TRANSCRIPTS"; -/** Env gate for mutating/steering Codex sessions through standalone MCP. */ -export const WRITE_CONTROLS_ENV = "OPENCLAW_CODEX_SUPERVISOR_ALLOW_WRITE_CONTROLS"; - -/** Optional policy callbacks for standalone MCP tool exposure. */ -export type CodexSupervisorMcpToolOptions = { - rawTranscriptReadsAllowed?: () => boolean; - writeControlsAllowed?: () => boolean; -}; - -function textResult(text: string, structuredContent?: Record) { - return { - content: [{ type: "text" as const, text }], - ...(structuredContent ? { structuredContent } : {}), - }; -} - -function errorResult(message: string) { - return { - content: [{ type: "text" as const, text: message }], - isError: true, - }; -} - -function redactString(value: string): string { - return value - .replace(/\b(?:sk|glpat|xox[baprs])-[-_a-zA-Z0-9]{12,}\b/g, "[redacted]") - .replace(/\b(?:ghp|gho|ghu|ghs)_[-_a-zA-Z0-9]{12,}\b/g, "[redacted]") - .replace(/\bBearer\s+[-._~+/a-zA-Z0-9]+=*/g, "Bearer [redacted]"); -} - -/** - * Redacts common secret-bearing fields and token-like substrings before tool - * results leave the supervisor. - */ -export function redactCodexSupervisorValue(value: unknown, key = ""): unknown { - if (typeof value === "string") { - if (/authorization|password|secret|token|api[-_]?key/i.test(key)) { - return "[redacted]"; - } - return redactString(value); - } - if (Array.isArray(value)) { - return value.map((entry) => redactCodexSupervisorValue(entry)); - } - if (!value || typeof value !== "object") { - return value; - } - return Object.fromEntries( - Object.entries(value as Record).map(([entryKey, entryValue]) => [ - entryKey, - redactCodexSupervisorValue(entryValue, entryKey), - ]), - ); -} - -function redactEndpointUrl(value: string): string { - if (value.startsWith("unix://")) { - return "unix://"; - } - try { - const url = new URL(value); - url.username = ""; - url.password = ""; - if (url.search) { - url.search = "?[redacted]"; - } - return url.toString(); - } catch { - return "[redacted]"; - } -} - -/** Returns endpoint metadata safe for tool results. */ -export function redactCodexSupervisorEndpoint( - endpoint: CodexSupervisorEndpoint, -): Record { - return { - id: endpoint.id, - transport: endpoint.transport, - ...(endpoint.label ? { label: endpoint.label } : {}), - ...(endpoint.transport === "websocket" ? { url: redactEndpointUrl(endpoint.url) } : {}), - }; -} - -function rawTranscriptReadsAllowed(): boolean { - return process.env[RAW_TRANSCRIPTS_ENV] === "1"; -} - -function writeControlsAllowed(): boolean { - return process.env[WRITE_CONTROLS_ENV] === "1"; -} - -function rawTranscriptReadsAllowedFor(opts: CodexSupervisorMcpToolOptions): boolean { - return opts.rawTranscriptReadsAllowed - ? opts.rawTranscriptReadsAllowed() - : rawTranscriptReadsAllowed(); -} - -function writeControlsAllowedFor(opts: CodexSupervisorMcpToolOptions): boolean { - return opts.writeControlsAllowed ? opts.writeControlsAllowed() : writeControlsAllowed(); -} - -function sanitizeSessionForMcp( - session: CodexSupervisorSession, - includeTranscriptDerivedFields: boolean, -): Record { - const sanitized = redactCodexSupervisorValue(session) as Record; - if (!includeTranscriptDerivedFields) { - delete sanitized.preview; - delete sanitized.name; - } - return sanitized; -} - -/** - * Sanitizes session-list output, optionally including transcript-derived - * preview/name fields only when the caller has opted in. - */ -export function sanitizeCodexSupervisorSessionListResult( - result: CodexSupervisorSessionListResult, - includeTranscriptDerivedFields = rawTranscriptReadsAllowed(), -): Record { - return { - sessions: result.sessions.map((session) => - sanitizeSessionForMcp(session, includeTranscriptDerivedFields), - ), - errors: includeTranscriptDerivedFields - ? redactCodexSupervisorValue(result.errors) - : result.errors.map(({ endpointId, ok }) => ({ endpointId, ok })), - }; -} - -/** - * Registers MCP tools for endpoint probing, session listing, reads, sends, and - * interrupts. - */ -export function registerCodexSupervisorMcpTools( - server: McpServer, - supervisor: CodexSupervisor, - opts: CodexSupervisorMcpToolOptions = {}, -): void { - server.tool( - "codex_endpoint_probe", - "Check configured Codex app-server endpoints.", - {}, - async () => { - const endpoints = supervisor.listEndpoints().map(redactCodexSupervisorEndpoint); - const health = (await supervisor.probeEndpoints()).map(({ endpointId, ok }) => ({ - endpointId, - ok, - })); - return textResult( - `codex endpoints: ${health.filter((entry) => entry.ok).length}/${health.length} ok`, - { - endpoints, - health, - }, - ); - }, - ); - - server.tool( - "codex_sessions_list", - "List Codex sessions visible to the OpenClaw supervisor.", - { - include_stored: z.boolean().optional(), - max_stored_sessions: z.number().int().min(1).max(1000).optional(), - }, - async ({ include_stored, max_stored_sessions }) => { - const result = await supervisor.listSessionSnapshot({ - includeStored: include_stored ?? false, - maxStoredSessions: max_stored_sessions, - }); - return textResult( - `codex sessions: ${result.sessions.length}`, - sanitizeCodexSupervisorSessionListResult(result, rawTranscriptReadsAllowedFor(opts)), - ); - }, - ); - - server.tool( - "codex_session_read", - "Read one Codex session transcript from app-server.", - { - endpoint_id: z.string().optional(), - thread_id: z.string().min(1), - include_turns: z.boolean().optional(), - }, - async ({ endpoint_id, thread_id, include_turns }) => { - if (!rawTranscriptReadsAllowedFor(opts)) { - return errorResult( - `Codex session reads are disabled; set ${RAW_TRANSCRIPTS_ENV}=1 for a trusted supervisor-only MCP`, - ); - } - const includeTurns = include_turns ?? false; - try { - const response = await supervisor.readSession({ - endpointId: endpoint_id, - threadId: thread_id, - includeTurns, - }); - return textResult(`codex session: ${thread_id}`, { - response: redactCodexSupervisorValue(response), - }); - } catch (error) { - return errorResult(error instanceof Error ? error.message : String(error)); - } - }, - ); - - server.tool( - "codex_session_send", - "Send text to a Codex session. Idle sessions start a turn; active sessions are steered.", - { - endpoint_id: z.string().optional(), - thread_id: z.string().min(1), - text: z.string().min(1), - mode: z.enum(["auto", "start", "steer"]).optional(), - }, - async ({ endpoint_id, thread_id, text, mode }) => { - if (!writeControlsAllowedFor(opts)) { - return errorResult( - `Codex write controls are disabled; set ${WRITE_CONTROLS_ENV}=1 for a trusted supervisor-only MCP`, - ); - } - try { - const result = await supervisor.sendToSession({ - endpointId: endpoint_id, - threadId: thread_id, - text, - mode, - }); - return textResult(`codex ${result.mode}: ${result.turnId ?? thread_id}`, { result }); - } catch (error) { - return errorResult(error instanceof Error ? error.message : String(error)); - } - }, - ); - - server.tool( - "codex_session_interrupt", - "Interrupt an active Codex turn.", - { - endpoint_id: z.string().optional(), - thread_id: z.string().min(1), - turn_id: z.string().optional(), - }, - async ({ endpoint_id, thread_id, turn_id }) => { - if (!writeControlsAllowedFor(opts)) { - return errorResult( - `Codex write controls are disabled; set ${WRITE_CONTROLS_ENV}=1 for a trusted supervisor-only MCP`, - ); - } - try { - const result = await supervisor.interruptSession({ - endpointId: endpoint_id, - threadId: thread_id, - turnId: turn_id, - }); - return textResult(`codex interrupted: ${result.turnId}`, { result }); - } catch (error) { - return errorResult(error instanceof Error ? error.message : String(error)); - } - }, - ); -} diff --git a/extensions/codex-supervisor/src/plugin-tools.test.ts b/extensions/codex-supervisor/src/plugin-tools.test.ts deleted file mode 100644 index dfa037bec3f5..000000000000 --- a/extensions/codex-supervisor/src/plugin-tools.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -// Codex Supervisor tests cover plugin tools plugin behavior. -import { describe, expect, it } from "vitest"; -import { createCodexSupervisorTools } from "./plugin-tools.js"; -import type { CodexSupervisor } from "./supervisor.js"; - -function createSupervisorStub() { - const calls: string[] = []; - const supervisor = { - listEndpoints: () => [ - { - id: "prod", - transport: "websocket", - url: "wss://user:secret@example.invalid/control?token=hidden", - }, - ], - probeEndpoints: async () => [{ endpointId: "prod", ok: true }], - listSessionSnapshot: async () => ({ - sessions: [ - { - endpointId: "prod", - threadId: "thread-1", - status: "idle", - preview: "secret prompt", - name: "secret title", - }, - ], - errors: [{ endpointId: "down", ok: false, detail: "secret stderr" }], - }), - readSession: async () => ({ - thread: { - id: "thread-1", - authorization: "Bearer abcdefghijklmnopqrstuvwxyz012345", - }, - }), - sendToSession: async (params: { mode?: string }) => { - calls.push(`send:${params.mode ?? "auto"}`); - return { - endpointId: "prod", - threadId: "thread-1", - mode: "start" as const, - turnId: "turn-1", - }; - }, - interruptSession: async () => { - calls.push("interrupt"); - return { - endpointId: "prod", - threadId: "thread-1", - turnId: "turn-1", - }; - }, - } satisfies Pick< - CodexSupervisor, - | "interruptSession" - | "listEndpoints" - | "listSessionSnapshot" - | "probeEndpoints" - | "readSession" - | "sendToSession" - >; - return { calls, supervisor: supervisor as unknown as CodexSupervisor }; -} - -function toolByName(tools: ReturnType, name: string) { - const tool = tools.find((entry) => entry.name === name); - if (!tool) { - throw new Error(`missing tool: ${name}`); - } - return tool; -} - -describe("createCodexSupervisorTools", () => { - it("registers redacted read-only supervisor tools by default", async () => { - const { supervisor } = createSupervisorStub(); - const tools = createCodexSupervisorTools({ - supervisor, - policy: { allowRawTranscripts: false, allowWriteControls: false }, - }); - - const probe = await toolByName(tools, "codex_endpoint_probe").execute("call-1", {}); - expect(probe.details).toMatchObject({ - summary: "codex endpoints: 1/1 ok", - endpoints: [ - { id: "prod", transport: "websocket", url: "wss://example.invalid/control?[redacted]" }, - ], - }); - - const list = await toolByName(tools, "codex_sessions_list").execute("call-2", {}); - expect(list.details).toEqual({ - summary: "codex sessions: 1", - sessions: [{ endpointId: "prod", threadId: "thread-1", status: "idle" }], - errors: [{ endpointId: "down", ok: false }], - }); - }); - - it("gates transcript reads and write controls", async () => { - const { supervisor } = createSupervisorStub(); - const tools = createCodexSupervisorTools({ - supervisor, - policy: { allowRawTranscripts: false, allowWriteControls: false }, - }); - - await expect( - toolByName(tools, "codex_session_read").execute("call-1", { thread_id: "thread-1" }), - ).rejects.toThrow("Codex session reads are disabled"); - await expect( - toolByName(tools, "codex_session_send").execute("call-2", { - thread_id: "thread-1", - text: "continue", - }), - ).rejects.toThrow("Codex write controls are disabled"); - }); - - it("rejects stored session limits outside the runtime bounds", async () => { - const { supervisor } = createSupervisorStub(); - const tools = createCodexSupervisorTools({ - supervisor, - policy: { allowRawTranscripts: false, allowWriteControls: false }, - }); - - await expect( - toolByName(tools, "codex_sessions_list").execute("call-1", { - include_stored: true, - max_stored_sessions: "2", - }), - ).rejects.toThrow("max_stored_sessions must be an integer"); - - await expect( - toolByName(tools, "codex_sessions_list").execute("call-2", { - include_stored: true, - max_stored_sessions: 1001, - }), - ).rejects.toThrow("max_stored_sessions must be between 1 and 1000"); - await expect( - toolByName(tools, "codex_sessions_list").execute("call-2", { - include_stored: true, - max_stored_sessions: null, - }), - ).rejects.toThrow("max_stored_sessions must be an integer"); - await expect( - toolByName(tools, "codex_sessions_list").execute("call-3", { - include_stored: true, - max_stored_sessions: Number.MAX_SAFE_INTEGER + 1, - }), - ).rejects.toThrow("max_stored_sessions must be between 1 and 1000"); - }); - - it("allows trusted read and write tools when policy enables them", async () => { - const { calls, supervisor } = createSupervisorStub(); - const tools = createCodexSupervisorTools({ - supervisor, - policy: { allowRawTranscripts: true, allowWriteControls: true }, - }); - - const read = await toolByName(tools, "codex_session_read").execute("call-1", { - thread_id: "thread-1", - }); - expect(read.details).toEqual({ - summary: "codex session: thread-1", - response: { thread: { id: "thread-1", authorization: "[redacted]" } }, - }); - - const sent = await toolByName(tools, "codex_session_send").execute("call-2", { - thread_id: "thread-1", - text: "continue", - mode: "start", - }); - expect(sent.details).toMatchObject({ - summary: "codex start: turn-1", - result: { turnId: "turn-1" }, - }); - expect(calls).toEqual(["send:start"]); - }); -}); diff --git a/extensions/codex-supervisor/src/plugin-tools.ts b/extensions/codex-supervisor/src/plugin-tools.ts deleted file mode 100644 index 83ea18fc011b..000000000000 --- a/extensions/codex-supervisor/src/plugin-tools.ts +++ /dev/null @@ -1,223 +0,0 @@ -/** - * OpenClaw agent-tool definitions for Codex Supervisor endpoint and session - * controls. - */ -import { jsonResult, readStringParam, type AnyAgentTool } from "openclaw/plugin-sdk/core"; -import { Type } from "typebox"; -import { - redactCodexSupervisorEndpoint, - redactCodexSupervisorValue, - sanitizeCodexSupervisorSessionListResult, -} from "./mcp-tools.js"; -import type { CodexSupervisor } from "./supervisor.js"; -import type { CodexSupervisorTurnMode } from "./types.js"; - -const EmptyParamsSchema = Type.Object({}, { additionalProperties: false }); - -const SessionsListParamsSchema = Type.Object( - { - include_stored: Type.Optional(Type.Boolean()), - max_stored_sessions: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000 })), - }, - { additionalProperties: false }, -); - -const SessionReadParamsSchema = Type.Object( - { - endpoint_id: Type.Optional(Type.String()), - thread_id: Type.String(), - include_turns: Type.Optional(Type.Boolean()), - }, - { additionalProperties: false }, -); - -const SessionSendParamsSchema = Type.Object( - { - endpoint_id: Type.Optional(Type.String()), - thread_id: Type.String(), - text: Type.String(), - mode: Type.Optional( - Type.Union([Type.Literal("auto"), Type.Literal("start"), Type.Literal("steer")]), - ), - }, - { additionalProperties: false }, -); - -const SessionInterruptParamsSchema = Type.Object( - { - endpoint_id: Type.Optional(Type.String()), - thread_id: Type.String(), - turn_id: Type.Optional(Type.String()), - }, - { additionalProperties: false }, -); - -/** Policy flags controlling transcript reads and write operations. */ -export type CodexSupervisorToolPolicy = { - allowRawTranscripts: boolean; - allowWriteControls: boolean; -}; - -/** Dependencies needed to build OpenClaw agent tools. */ -export type CodexSupervisorToolOptions = { - supervisor: CodexSupervisor; - policy: CodexSupervisorToolPolicy; -}; - -function asRecord(params: unknown): Record { - return params && typeof params === "object" && !Array.isArray(params) - ? (params as Record) - : {}; -} - -function readBooleanParam(params: Record, key: string): boolean { - return params[key] === true; -} - -function readIntegerParam(params: Record, key: string): number | undefined { - const value = params[key]; - if (value === undefined) { - return undefined; - } - if (typeof value !== "number" || !Number.isInteger(value)) { - throw new Error(`${key} must be an integer`); - } - if (value < 1 || value > 1000) { - throw new Error(`${key} must be between 1 and 1000`); - } - return value; -} - -function readModeParam(params: Record): CodexSupervisorTurnMode | undefined { - const mode = readStringParam(params, "mode"); - if (!mode) { - return undefined; - } - if (mode === "auto" || mode === "start" || mode === "steer") { - return mode; - } - throw new Error("mode must be auto, start, or steer"); -} - -function requireRawTranscriptAccess(policy: CodexSupervisorToolPolicy): void { - if (!policy.allowRawTranscripts) { - throw new Error("Codex session reads are disabled for this codex-supervisor plugin config."); - } -} - -function requireWriteAccess(policy: CodexSupervisorToolPolicy): void { - if (!policy.allowWriteControls) { - throw new Error("Codex write controls are disabled for this codex-supervisor plugin config."); - } -} - -/** - * Creates the OpenClaw tools that expose Codex endpoint health and session - * controls. - */ -export function createCodexSupervisorTools({ - supervisor, - policy, -}: CodexSupervisorToolOptions): AnyAgentTool[] { - return [ - { - name: "codex_endpoint_probe", - label: "Codex Endpoint Probe", - description: "Check configured Codex app-server endpoints.", - parameters: EmptyParamsSchema, - execute: async () => { - const endpoints = supervisor.listEndpoints().map(redactCodexSupervisorEndpoint); - const health = (await supervisor.probeEndpoints()).map(({ endpointId, ok }) => ({ - endpointId, - ok, - })); - return jsonResult({ - summary: `codex endpoints: ${health.filter((entry) => entry.ok).length}/${health.length} ok`, - endpoints, - health, - }); - }, - }, - { - name: "codex_sessions_list", - label: "Codex Sessions List", - description: "List Codex sessions visible to the OpenClaw supervisor.", - parameters: SessionsListParamsSchema, - execute: async (_toolCallId, rawParams) => { - const params = asRecord(rawParams); - const result = await supervisor.listSessionSnapshot({ - includeStored: readBooleanParam(params, "include_stored"), - maxStoredSessions: readIntegerParam(params, "max_stored_sessions"), - }); - return jsonResult({ - summary: `codex sessions: ${result.sessions.length}`, - ...sanitizeCodexSupervisorSessionListResult(result, policy.allowRawTranscripts), - }); - }, - }, - { - name: "codex_session_read", - label: "Codex Session Read", - description: "Read one Codex session transcript from app-server.", - parameters: SessionReadParamsSchema, - execute: async (_toolCallId, rawParams) => { - // Raw transcript access is opt-in because app-server sessions can hold - // secrets, private files, and user-authenticated browser context. - requireRawTranscriptAccess(policy); - const params = asRecord(rawParams); - const threadId = readStringParam(params, "thread_id", { required: true }); - const response = await supervisor.readSession({ - endpointId: readStringParam(params, "endpoint_id"), - threadId, - includeTurns: readBooleanParam(params, "include_turns"), - }); - return jsonResult({ - summary: `codex session: ${threadId}`, - response: redactCodexSupervisorValue(response), - }); - }, - }, - { - name: "codex_session_send", - label: "Codex Session Send", - description: - "Send text to a Codex session. Idle sessions start a turn; active sessions are steered.", - parameters: SessionSendParamsSchema, - execute: async (_toolCallId, rawParams) => { - // Session write controls can steer or interrupt a human-visible Codex - // turn, so they remain behind an explicit plugin policy gate. - requireWriteAccess(policy); - const params = asRecord(rawParams); - const result = await supervisor.sendToSession({ - endpointId: readStringParam(params, "endpoint_id"), - threadId: readStringParam(params, "thread_id", { required: true }), - text: readStringParam(params, "text", { required: true, allowEmpty: false }), - mode: readModeParam(params), - }); - return jsonResult({ - summary: `codex ${result.mode}: ${result.turnId ?? result.threadId}`, - result, - }); - }, - }, - { - name: "codex_session_interrupt", - label: "Codex Session Interrupt", - description: "Interrupt an active Codex turn.", - parameters: SessionInterruptParamsSchema, - execute: async (_toolCallId, rawParams) => { - requireWriteAccess(policy); - const params = asRecord(rawParams); - const result = await supervisor.interruptSession({ - endpointId: readStringParam(params, "endpoint_id"), - threadId: readStringParam(params, "thread_id", { required: true }), - turnId: readStringParam(params, "turn_id"), - }); - return jsonResult({ - summary: `codex interrupted: ${result.turnId}`, - result, - }); - }, - }, - ]; -} diff --git a/extensions/codex-supervisor/src/session-catalog.test.ts b/extensions/codex-supervisor/src/session-catalog.test.ts deleted file mode 100644 index ec4de88eb828..000000000000 --- a/extensions/codex-supervisor/src/session-catalog.test.ts +++ /dev/null @@ -1,653 +0,0 @@ -import type { GatewayRequestHandlerOptions } from "openclaw/plugin-sdk/gateway-runtime"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; -import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; -import { describe, expect, it, vi } from "vitest"; -import { - CODEX_APP_SERVER_THREADS_LIST_COMMAND, - CODEX_SESSION_CATALOG_METHOD, - createCodexSessionCatalogNodeHostCommands, - createCodexSessionCatalogNodeInvokePolicies, - createCodexSessionCatalogSupervisor, - listCodexSessionCatalog, - parseCodexSessionCatalogResult, - registerCodexSessionCatalogGateway, -} from "./session-catalog.js"; -import { CodexSupervisor } from "./supervisor.js"; -import type { CodexJsonRpcConnection, CodexSupervisorEndpoint } from "./types.js"; - -class CatalogConnection implements CodexJsonRpcConnection { - readonly calls: Array<{ method: string; params?: Record }> = []; - - constructor(private readonly response: unknown) {} - - async request(method: string, params?: Record): Promise { - this.calls.push({ method, params }); - return this.response; - } - - notify(): void {} - - async close(): Promise {} -} - -const localEndpoint: CodexSupervisorEndpoint = { - id: "local", - label: "Local Codex", - transport: "stdio-proxy", -}; - -function createRuntime(params: { - nodes: Awaited>["nodes"]; - invoke?: PluginRuntime["nodes"]["invoke"]; -}): PluginRuntime { - return { - nodes: { - list: vi.fn(async () => ({ nodes: params.nodes })), - invoke: params.invoke ?? vi.fn(async () => ({})), - }, - } as unknown as PluginRuntime; -} - -describe("Codex session catalog node command", () => { - it("uses a dedicated local stdio endpoint when live control uses the daemon socket", () => { - const supervisor = createCodexSessionCatalogSupervisor([ - { id: "live", transport: "websocket", url: "unix://" }, - ]); - - expect(supervisor.listEndpoints()).toEqual([ - { id: "local", label: "Local Codex", transport: "stdio-proxy" }, - ]); - }); - - it("registers a versioned, read-only command and default invoke policy", async () => { - const connection = new CatalogConnection({ - data: [{ id: "thread-1", name: "One", status: { type: "notLoaded" } }], - nextCursor: "next", - backwardsCursor: null, - }); - const supervisor = new CodexSupervisor([localEndpoint], async () => connection); - const [command] = createCodexSessionCatalogNodeHostCommands(supervisor); - - expect(command).toMatchObject({ - command: CODEX_APP_SERVER_THREADS_LIST_COMMAND, - cap: "codex-app-server-threads", - dangerous: false, - }); - const result = await command.handle( - JSON.stringify({ cursor: "cursor", limit: 25, archived: true, searchTerm: "One" }), - ); - expect(JSON.parse(result)).toEqual({ - sessions: [{ threadId: "thread-1", name: "One", status: "notLoaded", archived: true }], - nextCursor: "next", - }); - expect(connection.calls[0]).toMatchObject({ - method: "thread/list", - params: { - cursor: "cursor", - limit: 25, - archived: true, - }, - }); - expect(connection.calls[0]?.params).not.toHaveProperty("searchTerm"); - - const [policy] = createCodexSessionCatalogNodeInvokePolicies(); - expect(policy).toMatchObject({ - commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND], - defaultPlatforms: ["macos", "linux", "windows"], - }); - }); - - it("rejects malformed or unbounded node parameters", async () => { - const supervisor = new CodexSupervisor( - [localEndpoint], - async () => new CatalogConnection({ data: [] }), - ); - const [command] = createCodexSessionCatalogNodeHostCommands(supervisor); - - await expect(command.handle("not-json")).rejects.toThrow("must be valid JSON"); - await expect(command.handle(JSON.stringify({ limit: 101 }))).rejects.toThrow( - "limit must be an integer from 1 to 100", - ); - await expect(command.handle(JSON.stringify({ extra: true }))).rejects.toThrow( - "unknown Codex session catalog parameter", - ); - }); - - it("omits an oversized App Server cursor at the node boundary", async () => { - const supervisor = new CodexSupervisor( - [localEndpoint], - async () => new CatalogConnection({ data: [], nextCursor: "x".repeat(4097) }), - ); - const [command] = createCodexSessionCatalogNodeHostCommands(supervisor); - - await expect(command.handle("{}")).resolves.toBe(JSON.stringify({ sessions: [] })); - }); -}); - -describe("Codex session catalog aggregation", () => { - it("groups local and paired-node sessions, applies per-host cursors, and keeps failures local", async () => { - const local = new CatalogConnection({ - data: [ - { - id: "local-thread", - name: "Local task", - preview: "must not leave the Gateway", - cwd: "/local/workspace", - status: { type: "idle" }, - }, - ], - nextCursor: "local-next", - }); - const supervisor = new CodexSupervisor([localEndpoint], async () => local); - const invoke = vi.fn(async ({ nodeId }) => { - if (nodeId === "node-failed") { - throw new Error("private app-server stderr and transcript fragment"); - } - return { - payloadJSON: JSON.stringify({ - sessions: [ - { - threadId: "remote-thread", - name: "Remote task", - preview: "must be stripped even from a compromised node", - cwd: "/remote/workspace", - status: "active", - activeFlags: ["waitingOnApproval"], - updatedAt: 40, - archived: false, - turns: [{ private: true }], - }, - { - threadId: "preview-only", - name: "Remote other", - preview: "task appears only in private preview text", - status: "idle", - archived: false, - }, - ], - nextCursor: "remote-next", - codexHome: "/Users/private/.codex", - }), - }; - }); - const command = CODEX_APP_SERVER_THREADS_LIST_COMMAND; - const runtime = createRuntime({ - nodes: [ - { nodeId: "node-a", displayName: "Dev Box", connected: true, commands: [command] }, - { nodeId: "node-offline", connected: false, commands: [command] }, - { nodeId: "node-failed", connected: true, commands: [command] }, - { nodeId: "node-unrelated", connected: true, commands: ["system.run"] }, - ], - invoke, - }); - - await expect( - listCodexSessionCatalog({ - runtime, - supervisor, - query: { - search: "task", - limitPerHost: 25, - cursors: { - "gateway:local": "local-cursor", - "node:node-a": "remote-cursor", - "node:unknown": "ignored-cursor", - }, - }, - }), - ).resolves.toEqual({ - hosts: [ - { - hostId: "gateway:local", - label: "Local Codex", - kind: "gateway", - connected: true, - endpointId: "local", - sessions: [ - { - threadId: "local-thread", - name: "Local task", - cwd: "/local/workspace", - status: "idle", - archived: false, - }, - ], - nextCursor: "local-next", - }, - { - hostId: "node:node-a", - label: "Dev Box", - kind: "node", - connected: true, - nodeId: "node-a", - sessions: [ - { - threadId: "remote-thread", - name: "Remote task", - cwd: "/remote/workspace", - status: "active", - activeFlags: ["waitingOnApproval"], - updatedAt: 40, - archived: false, - }, - ], - nextCursor: "remote-next", - }, - { - hostId: "node:node-failed", - label: "node-failed", - kind: "node", - connected: true, - nodeId: "node-failed", - sessions: [], - error: { - code: "NODE_INVOKE_FAILED", - message: "The paired node could not return its Codex session catalog", - }, - }, - { - hostId: "node:node-offline", - label: "node-offline", - kind: "node", - connected: false, - nodeId: "node-offline", - sessions: [], - error: { code: "NODE_OFFLINE", message: "Paired node is offline" }, - }, - ], - }); - expect(local.calls[0]).toMatchObject({ params: { cursor: "local-cursor", limit: 25 } }); - expect(local.calls[0]?.params).not.toHaveProperty("searchTerm"); - expect(invoke).toHaveBeenCalledWith({ - nodeId: "node-a", - command, - params: { - cursor: "remote-cursor", - limit: 25, - archived: false, - searchTerm: "task", - }, - timeoutMs: 20_000, - }); - expect(JSON.stringify(await listCodexSessionCatalog({ runtime, supervisor }))).not.toContain( - "private", - ); - }); - - it("strictly bounds Gateway catalog queries", async () => { - const supervisor = new CodexSupervisor( - [localEndpoint], - async () => new CatalogConnection({ data: [] }), - ); - const runtime = createRuntime({ nodes: [] }); - - await expect( - listCodexSessionCatalog({ - runtime, - supervisor, - query: { search: "x".repeat(501) }, - }), - ).rejects.toThrow("search must be at most 500 characters"); - await expect( - listCodexSessionCatalog({ - runtime, - supervisor, - query: { cursors: { invalid: "cursor" } }, - }), - ).rejects.toThrow("invalid Codex session catalog host id"); - await expect( - listCodexSessionCatalog({ - runtime, - supervisor, - query: { hostIds: ["invalid"] }, - }), - ).rejects.toThrow("invalid Codex session catalog host id"); - }); - - it("queries only a selected Gateway host without enumerating paired nodes", async () => { - const local = new CatalogConnection({ data: [] }); - const connector = vi.fn(async () => local); - const supervisor = new CodexSupervisor([localEndpoint], connector); - const runtime = createRuntime({ - nodes: [ - { - nodeId: "node-a", - connected: true, - commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND], - }, - ], - }); - - const result = await listCodexSessionCatalog({ - runtime, - supervisor, - query: { hostIds: ["gateway:local"] }, - }); - - expect(result.hosts.map((host) => host.hostId)).toEqual(["gateway:local"]); - expect(connector).toHaveBeenCalledOnce(); - expect(runtime.nodes.list).not.toHaveBeenCalled(); - expect(runtime.nodes.invoke).not.toHaveBeenCalled(); - }); - - it("keeps long configured Gateway endpoint ids page-addressable", async () => { - const endpointId = "endpoint-".repeat(40); - const connection = new CatalogConnection({ - data: [], - nextCursor: "next-page", - }); - const supervisor = new CodexSupervisor( - [{ id: endpointId, label: "Long endpoint", transport: "stdio-proxy" }], - async () => connection, - ); - const runtime = createRuntime({ nodes: [] }); - - const first = await listCodexSessionCatalog({ runtime, supervisor }); - const hostId = first.hosts[0]?.hostId; - expect(hostId).toMatch(/^gateway:sha256:[0-9a-f]{64}$/); - expect(hostId?.length).toBeLessThanOrEqual(256); - - const next = await listCodexSessionCatalog({ - runtime, - supervisor, - query: { hostIds: [hostId!], cursors: { [hostId!]: "next-page" } }, - }); - - expect(next.hosts[0]).toMatchObject({ hostId, endpointId }); - expect(connection.calls.at(-1)?.params).toMatchObject({ cursor: "next-page" }); - }); - - it("queries only a selected paired node without touching the local App Server", async () => { - const connector = vi.fn(async () => { - throw new Error("local connector must not run"); - }); - const supervisor = new CodexSupervisor([localEndpoint], connector); - const invoke = vi.fn(async ({ nodeId }) => ({ - payloadJSON: JSON.stringify({ - sessions: [{ threadId: `${nodeId}-thread`, status: "idle", archived: false }], - }), - })); - const runtime = createRuntime({ - nodes: [ - { - nodeId: "node-a", - connected: true, - commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND], - }, - { - nodeId: "node-b", - connected: true, - commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND], - }, - ], - invoke, - }); - - const result = await listCodexSessionCatalog({ - runtime, - supervisor, - query: { hostIds: ["node:node-a"] }, - }); - - expect(result.hosts.map((host) => host.hostId)).toEqual(["node:node-a"]); - expect(connector).not.toHaveBeenCalled(); - expect(invoke).toHaveBeenCalledOnce(); - expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ nodeId: "node-a" })); - }); - - it("keeps the Gateway connected when its local Codex App Server is unavailable", async () => { - const supervisor = new CodexSupervisor([localEndpoint], async () => { - throw new Error("private local transport failure"); - }); - const runtime = createRuntime({ nodes: [] }); - - const result = await listCodexSessionCatalog({ - runtime, - supervisor, - query: { hostIds: ["gateway:local"] }, - }); - - expect(result.hosts).toEqual([ - { - hostId: "gateway:local", - label: "Local Codex", - kind: "gateway", - connected: true, - endpointId: "local", - sessions: [], - error: { - code: "APP_SERVER_UNAVAILABLE", - message: "Codex app-server is unavailable on this host", - }, - }, - ]); - expect(JSON.stringify(result)).not.toContain("private local transport failure"); - }); - - it("normalizes oversized Gateway-local App Server cursors at the source", async () => { - const supervisor = new CodexSupervisor( - [localEndpoint], - async () => new CatalogConnection({ data: [], nextCursor: "x".repeat(4097) }), - ); - const runtime = createRuntime({ nodes: [] }); - - const result = await listCodexSessionCatalog({ - runtime, - supervisor, - query: { hostIds: ["gateway:local"] }, - }); - - expect(result.hosts[0]).toEqual({ - hostId: "gateway:local", - label: "Local Codex", - kind: "gateway", - connected: true, - endpointId: "local", - sessions: [], - }); - expect(JSON.stringify(result)).not.toContain("x".repeat(4097)); - }); - - it("keeps the local host healthy when an App Server title exceeds the wire bound", async () => { - const supervisor = new CodexSupervisor( - [localEndpoint], - async () => - new CatalogConnection({ - data: [{ id: "long-title", name: "😀".repeat(251), status: { type: "idle" } }], - }), - ); - const runtime = createRuntime({ nodes: [] }); - - const result = await listCodexSessionCatalog({ - runtime, - supervisor, - query: { hostIds: ["gateway:local"] }, - }); - - expect(result.hosts[0]?.error).toBeUndefined(); - expect(result.hosts[0]?.sessions[0]?.name).toBe("😀".repeat(250)); - }); - - it("reports malformed node payloads without exposing their contents", async () => { - const supervisor = new CodexSupervisor( - [localEndpoint], - async () => new CatalogConnection({ data: [] }), - ); - const runtime = createRuntime({ - nodes: [ - { - nodeId: "malformed", - connected: true, - commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND], - }, - ], - invoke: vi.fn(async () => ({ - payloadJSON: JSON.stringify({ sessions: null, private: "transcript fragment" }), - })), - }); - - const result = await listCodexSessionCatalog({ runtime, supervisor }); - - expect(result.hosts[1]).toEqual({ - hostId: "node:malformed", - label: "malformed", - kind: "node", - connected: true, - nodeId: "malformed", - sessions: [], - error: { - code: "NODE_INVOKE_FAILED", - message: "The paired node could not return its Codex session catalog", - }, - }); - expect(JSON.stringify(result)).not.toContain("transcript fragment"); - }); - - it("keeps the local catalog when the paired-node registry fails", async () => { - const supervisor = new CodexSupervisor( - [localEndpoint], - async () => new CatalogConnection({ data: [] }), - ); - const runtime = createRuntime({ nodes: [] }); - vi.mocked(runtime.nodes.list).mockRejectedValueOnce(new Error("private registry detail")); - - const result = await listCodexSessionCatalog({ runtime, supervisor }); - - expect(result.hosts).toEqual([ - { - hostId: "gateway:local", - label: "Local Codex", - kind: "gateway", - connected: true, - endpointId: "local", - sessions: [], - }, - { - hostId: "node:registry", - label: "Paired nodes", - kind: "node", - connected: false, - sessions: [], - error: { code: "NODE_LIST_FAILED", message: "Paired nodes could not be listed" }, - }, - ]); - expect(JSON.stringify(result)).not.toContain("private registry detail"); - }); -}); - -describe("Codex session catalog Gateway registration", () => { - it("registers a write-scoped method and strips unknown response fields", () => { - const registerControlUiDescriptor = vi.fn(); - const registerGatewayMethod = vi.fn(); - const runtime = createRuntime({ nodes: [] }); - const api = { - runtime, - session: { controls: { registerControlUiDescriptor } }, - registerGatewayMethod, - } as unknown as OpenClawPluginApi; - const supervisor = new CodexSupervisor([localEndpoint]); - - registerCodexSessionCatalogGateway({ api, supervisor }); - - expect(registerControlUiDescriptor).toHaveBeenCalledWith({ - surface: "tab", - id: "sessions", - label: "Codex Sessions", - description: "Codex sessions on this Gateway and paired nodes.", - icon: "terminal", - group: "control", - requiredScopes: ["operator.write"], - }); - expect(registerGatewayMethod).toHaveBeenCalledWith( - CODEX_SESSION_CATALOG_METHOD, - expect.any(Function), - { scope: "operator.write" }, - ); - - expect( - parseCodexSessionCatalogResult({ - hosts: [ - { - hostId: "node:one", - label: "One", - kind: "node", - connected: true, - sessions: [ - { - threadId: "thread-1", - status: "idle", - archived: false, - preview: "private", - turns: ["private"], - }, - ], - codexHome: "/private/.codex", - }, - ], - }), - ).toEqual({ - hosts: [ - { - hostId: "node:one", - label: "One", - kind: "node", - connected: true, - sessions: [{ threadId: "thread-1", status: "idle", archived: false }], - }, - ], - }); - - for (const field of ["nextCursor", "backwardsCursor"] as const) { - expect(() => - parseCodexSessionCatalogResult({ - hosts: [ - { - hostId: "node:one", - label: "One", - kind: "node", - connected: true, - sessions: [], - [field]: "x".repeat(4097), - }, - ], - }), - ).toThrow(`invalid ${field === "nextCursor" ? "next" : "backwards"} cursor`); - } - }); - - it("returns invalid-request errors for malformed Gateway parameters", async () => { - const registerGatewayMethod = vi.fn(); - const api = { - runtime: createRuntime({ nodes: [] }), - session: { controls: { registerControlUiDescriptor: vi.fn() } }, - registerGatewayMethod, - } as unknown as OpenClawPluginApi; - registerCodexSessionCatalogGateway({ api, supervisor: new CodexSupervisor([localEndpoint]) }); - const handler = registerGatewayMethod.mock.calls[0]?.[1] as ( - params: GatewayRequestHandlerOptions, - ) => Promise; - const respond = vi.fn(); - - await handler({ - params: { limitPerHost: 101 }, - respond, - } as unknown as GatewayRequestHandlerOptions); - - expect(respond).toHaveBeenCalledWith( - false, - { error: "limitPerHost must be an integer from 1 to 100" }, - expect.objectContaining({ code: "INVALID_REQUEST" }), - ); - - respond.mockClear(); - await handler({ - params: { hostIds: [42] }, - respond, - } as unknown as GatewayRequestHandlerOptions); - expect(respond).toHaveBeenCalledWith( - false, - { error: "Codex session catalog host ids must be strings" }, - expect.objectContaining({ code: "INVALID_REQUEST" }), - ); - }); -}); diff --git a/extensions/codex-supervisor/src/session-catalog.ts b/extensions/codex-supervisor/src/session-catalog.ts deleted file mode 100644 index 35c95c67e605..000000000000 --- a/extensions/codex-supervisor/src/session-catalog.ts +++ /dev/null @@ -1,644 +0,0 @@ -import { createHash } from "node:crypto"; -import { - ErrorCodes, - errorShape, - type GatewayRequestHandlerOptions, -} from "openclaw/plugin-sdk/gateway-runtime"; -import type { - OpenClawPluginApi, - OpenClawPluginNodeHostCommand, - OpenClawPluginNodeInvokePolicy, -} from "openclaw/plugin-sdk/plugin-entry"; -import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { CodexSupervisor } from "./supervisor.js"; -import type { - CodexSessionCatalogError, - CodexSessionCatalogHost, - CodexSessionCatalogPage, - CodexSessionCatalogPageParams, - CodexSessionCatalogParams, - CodexSessionCatalogResult, - CodexSessionCatalogSession, - CodexSupervisorEndpoint, -} from "./types.js"; - -export const CODEX_APP_SERVER_THREADS_LIST_COMMAND = "codex.appServer.threads.list.v1"; -export const CODEX_SESSION_CATALOG_METHOD = "codex-supervisor.sessions.list"; - -const CODEX_APP_SERVER_THREADS_CAPABILITY = "codex-app-server-threads"; -const DEFAULT_PAGE_LIMIT = 50; -export const CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT = 100; -const NODE_INVOKE_TIMEOUT_MS = 20_000; -const MAX_SEARCH_LENGTH = 500; -const MAX_CURSOR_LENGTH = 4096; -const MAX_CURSOR_COUNT = 100; -const MAX_HOST_COUNT = 100; -const MAX_HOST_ID_LENGTH = 256; -const MAX_CWD_LENGTH = 4096; -const MAX_SESSION_ID_LENGTH = 256; -const MAX_SESSION_NAME_LENGTH = 500; -const MAX_METADATA_LENGTH = 500; -const MAX_ACTIVE_FLAGS = 16; - -class CatalogParamsError extends Error {} - -type CatalogNode = Awaited>["nodes"][number]; - -const DEFAULT_LOCAL_CATALOG_ENDPOINT: CodexSupervisorEndpoint = { - id: "local", - label: "Local Codex", - transport: "stdio-proxy", -}; - -/** Creates the dedicated local stdio connection used only for catalog metadata. */ -export function createCodexSessionCatalogSupervisor( - configuredEndpoints: CodexSupervisorEndpoint[] = [], -): CodexSupervisor { - const configuredStdio = configuredEndpoints.find( - (endpoint) => endpoint.transport === "stdio-proxy", - ); - return new CodexSupervisor([configuredStdio ?? DEFAULT_LOCAL_CATALOG_ENDPOINT]); -} - -function normalizeLimit(value: unknown, key: string): number { - if (value === undefined) { - return DEFAULT_PAGE_LIMIT; - } - if ( - !Number.isInteger(value) || - (value as number) < 1 || - (value as number) > CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT - ) { - throw new CatalogParamsError( - `${key} must be an integer from 1 to ${CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT}`, - ); - } - return value as number; -} - -function readOptionalString(params: Record, key: string, maxLength: number) { - const value = params[key]; - if (value === undefined) { - return undefined; - } - if (typeof value !== "string") { - throw new CatalogParamsError(`${key} must be a string`); - } - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - if (trimmed.length > maxLength) { - throw new CatalogParamsError(`${key} must be at most ${maxLength} characters`); - } - return trimmed; -} - -function readArchived(params: Record): boolean { - if (params.archived !== undefined && typeof params.archived !== "boolean") { - throw new CatalogParamsError("archived must be a boolean"); - } - return params.archived === true; -} - -function requireOnlyKeys(params: Record, allowed: ReadonlySet): void { - const unknown = Object.keys(params).find((key) => !allowed.has(key)); - if (unknown) { - throw new CatalogParamsError(`unknown Codex session catalog parameter: ${unknown}`); - } -} - -function readPageParams(value: unknown): CodexSessionCatalogPageParams { - if (!isRecord(value)) { - throw new CatalogParamsError("Codex session catalog parameters must be an object"); - } - const params = value; - requireOnlyKeys(params, new Set(["cursor", "limit", "archived", "searchTerm", "cwd"])); - const cursor = readOptionalString(params, "cursor", MAX_CURSOR_LENGTH); - const searchTerm = readOptionalString(params, "searchTerm", MAX_SEARCH_LENGTH); - const cwd = readOptionalString(params, "cwd", MAX_CWD_LENGTH); - return { - limit: normalizeLimit(params.limit, "limit"), - archived: readArchived(params), - ...(cursor ? { cursor } : {}), - ...(searchTerm ? { searchTerm } : {}), - ...(cwd ? { cwd } : {}), - }; -} - -function readGatewayParams(value: unknown): CodexSessionCatalogParams { - if (value !== undefined && !isRecord(value)) { - throw new CatalogParamsError("Codex session catalog parameters must be an object"); - } - const params = isRecord(value) ? value : {}; - requireOnlyKeys(params, new Set(["search", "archived", "limitPerHost", "hostIds", "cursors"])); - const search = readOptionalString(params, "search", MAX_SEARCH_LENGTH); - let hostIds: string[] | undefined; - if (params.hostIds !== undefined) { - if (!Array.isArray(params.hostIds) || params.hostIds.length > MAX_HOST_COUNT) { - throw new CatalogParamsError(`hostIds must contain at most ${MAX_HOST_COUNT} host ids`); - } - hostIds = [...new Set(params.hostIds.map((hostId) => readHostId(hostId)))]; - } - let cursors: Record | undefined; - if (params.cursors !== undefined) { - if (!isRecord(params.cursors)) { - throw new CatalogParamsError("cursors must be an object"); - } - const entries = Object.entries(params.cursors); - if (entries.length > MAX_CURSOR_COUNT) { - throw new CatalogParamsError(`cursors may contain at most ${MAX_CURSOR_COUNT} hosts`); - } - cursors = {}; - for (const [hostId, cursor] of entries) { - const normalizedHostId = hostId.trim(); - if ( - normalizedHostId.length === 0 || - normalizedHostId.length > MAX_HOST_ID_LENGTH || - (!normalizedHostId.startsWith("gateway:") && !normalizedHostId.startsWith("node:")) - ) { - throw new CatalogParamsError(`invalid Codex session catalog host id: ${hostId}`); - } - if ( - typeof cursor !== "string" || - !cursor.trim() || - cursor.trim().length > MAX_CURSOR_LENGTH - ) { - throw new CatalogParamsError(`invalid cursor for Codex session catalog host: ${hostId}`); - } - cursors[normalizedHostId] = cursor.trim(); - } - } - return { - limitPerHost: normalizeLimit(params.limitPerHost, "limitPerHost"), - archived: readArchived(params), - ...(search ? { search } : {}), - ...(hostIds && hostIds.length > 0 ? { hostIds } : {}), - ...(cursors && Object.keys(cursors).length > 0 ? { cursors } : {}), - }; -} - -function readHostId(value: unknown): string { - if (typeof value !== "string") { - throw new CatalogParamsError("Codex session catalog host ids must be strings"); - } - const hostId = value.trim(); - if ( - hostId.length === 0 || - hostId.length > MAX_HOST_ID_LENGTH || - (!hostId.startsWith("gateway:") && !hostId.startsWith("node:")) - ) { - throw new CatalogParamsError(`invalid Codex session catalog host id: ${value}`); - } - return hostId; -} - -function parseJsonParams(paramsJSON?: string | null): unknown { - if (!paramsJSON?.trim()) { - return {}; - } - try { - return JSON.parse(paramsJSON) as unknown; - } catch (error) { - throw new Error("Codex session catalog parameters must be valid JSON", { cause: error }); - } -} - -function readFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function parseOptionalCatalogString( - value: unknown, - field: string, - maxLength: number, -): string | undefined { - if (value === undefined) { - return undefined; - } - if (typeof value !== "string" || value.length > maxLength) { - throw new Error(`Codex session catalog returned an invalid ${field}`); - } - return value; -} - -function parseCatalogSession(value: unknown): CodexSessionCatalogSession { - if ( - !isRecord(value) || - typeof value.threadId !== "string" || - !value.threadId.trim() || - value.threadId.length > MAX_SESSION_ID_LENGTH || - typeof value.archived !== "boolean" - ) { - throw new Error("Codex session catalog returned an invalid session"); - } - const status = parseOptionalCatalogString(value.status, "status", 64); - if (!status?.trim()) { - throw new Error("Codex session catalog returned an invalid status"); - } - if (value.activeFlags !== undefined && !Array.isArray(value.activeFlags)) { - throw new Error("Codex session catalog returned invalid active flags"); - } - if (Array.isArray(value.activeFlags) && value.activeFlags.length > MAX_ACTIVE_FLAGS) { - throw new Error("Codex session catalog returned too many active flags"); - } - const activeFlags = Array.isArray(value.activeFlags) - ? value.activeFlags.map((entry) => { - const flag = parseOptionalCatalogString(entry, "active flag", 128); - if (flag === undefined) { - throw new Error("Codex session catalog returned an invalid active flag"); - } - return flag; - }) - : undefined; - const sessionId = parseOptionalCatalogString( - value.sessionId, - "session id", - MAX_SESSION_ID_LENGTH, - ); - const name = - value.name === null - ? null - : parseOptionalCatalogString(value.name, "session name", MAX_SESSION_NAME_LENGTH); - const cwd = parseOptionalCatalogString(value.cwd, "cwd", MAX_CWD_LENGTH); - const source = parseOptionalCatalogString(value.source, "source", MAX_METADATA_LENGTH); - const modelProvider = parseOptionalCatalogString( - value.modelProvider, - "model provider", - MAX_METADATA_LENGTH, - ); - const cliVersion = parseOptionalCatalogString( - value.cliVersion, - "CLI version", - MAX_METADATA_LENGTH, - ); - const gitBranch = parseOptionalCatalogString(value.gitBranch, "Git branch", MAX_METADATA_LENGTH); - const createdAt = readFiniteNumber(value.createdAt); - const updatedAt = readFiniteNumber(value.updatedAt); - const recencyAt = value.recencyAt === null ? null : readFiniteNumber(value.recencyAt); - return { - threadId: value.threadId, - status, - archived: value.archived, - ...(sessionId !== undefined ? { sessionId } : {}), - ...(name !== undefined ? { name } : {}), - ...(cwd !== undefined ? { cwd } : {}), - ...(activeFlags && activeFlags.length > 0 ? { activeFlags } : {}), - ...(createdAt !== undefined ? { createdAt } : {}), - ...(updatedAt !== undefined ? { updatedAt } : {}), - ...(recencyAt !== undefined ? { recencyAt } : {}), - ...(source !== undefined ? { source } : {}), - ...(modelProvider !== undefined ? { modelProvider } : {}), - ...(cliVersion !== undefined ? { cliVersion } : {}), - ...(gitBranch !== undefined ? { gitBranch } : {}), - }; -} - -function parseCatalogError(value: unknown): CodexSessionCatalogError | undefined { - if (!isRecord(value) || typeof value.code !== "string" || typeof value.message !== "string") { - return undefined; - } - return { code: value.code, message: value.message }; -} - -function parseCatalogPage(value: unknown): CodexSessionCatalogPage { - if ( - !isRecord(value) || - !Array.isArray(value.sessions) || - value.sessions.length > CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT - ) { - throw new Error("Codex session catalog returned an invalid page"); - } - const nextCursor = parseOptionalCatalogString(value.nextCursor, "next cursor", MAX_CURSOR_LENGTH); - const backwardsCursor = parseOptionalCatalogString( - value.backwardsCursor, - "backwards cursor", - MAX_CURSOR_LENGTH, - ); - return { - sessions: value.sessions.map(parseCatalogSession), - ...(nextCursor ? { nextCursor } : {}), - ...(backwardsCursor ? { backwardsCursor } : {}), - }; -} - -function filterCatalogPageByTitle( - page: CodexSessionCatalogPage, - searchTerm: string | undefined, -): CodexSessionCatalogPage { - if (!searchTerm) { - return page; - } - return { - ...page, - sessions: page.sessions.filter((session) => session.name?.includes(searchTerm)), - }; -} - -function parseCatalogHost(value: unknown): CodexSessionCatalogHost { - if ( - !isRecord(value) || - typeof value.hostId !== "string" || - typeof value.label !== "string" || - (value.kind !== "gateway" && value.kind !== "node") || - typeof value.connected !== "boolean" || - !Array.isArray(value.sessions) - ) { - throw new Error("Codex session catalog returned an invalid host"); - } - const page = parseCatalogPage(value); - const error = parseCatalogError(value.error); - return { - hostId: value.hostId, - label: value.label, - kind: value.kind, - connected: value.connected, - sessions: page.sessions, - ...(typeof value.nodeId === "string" ? { nodeId: value.nodeId } : {}), - ...(typeof value.endpointId === "string" ? { endpointId: value.endpointId } : {}), - ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}), - ...(page.backwardsCursor ? { backwardsCursor: page.backwardsCursor } : {}), - ...(error ? { error } : {}), - }; -} - -/** Validates and strips unknown fields from a Gateway catalog response. */ -export function parseCodexSessionCatalogResult(value: unknown): CodexSessionCatalogResult { - if (!isRecord(value) || !Array.isArray(value.hosts)) { - throw new Error("Codex session catalog returned an invalid result"); - } - return { hosts: value.hosts.map(parseCatalogHost) }; -} - -function unwrapNodeInvokePayload(value: unknown): unknown { - if (!isRecord(value)) { - return value; - } - if (typeof value.payloadJSON === "string" && value.payloadJSON.trim()) { - try { - return JSON.parse(value.payloadJSON) as unknown; - } catch (error) { - throw new Error("Codex node returned malformed session catalog JSON", { cause: error }); - } - } - return "payload" in value ? value.payload : value; -} - -function catalogError(code: string, _error: unknown): CodexSessionCatalogError { - const messages: Record = { - APP_SERVER_UNAVAILABLE: "Codex app-server is unavailable on this host", - NODE_INVOKE_FAILED: "The paired node could not return its Codex session catalog", - NODE_LIST_FAILED: "Paired nodes could not be listed", - }; - return { code, message: messages[code] ?? "Codex session catalog request failed" }; -} - -function endpointLabel(endpoint: CodexSupervisorEndpoint): string { - return endpoint.label?.trim() || endpoint.id; -} - -function gatewayHostId(endpointId: string): string { - const direct = `gateway:${endpointId}`; - if (direct.length <= MAX_HOST_ID_LENGTH) { - return direct; - } - // Existing supervisor endpoint ids are not length-bounded. Keep that public - // config contract while giving catalog cursors a bounded, stable routing id. - const digest = createHash("sha256").update(endpointId).digest("hex"); - return `gateway:sha256:${digest}`; -} - -async function listGatewayEndpoint(params: { - supervisor: CodexSupervisor; - endpoint: CodexSupervisorEndpoint; - query: CodexSessionCatalogParams; -}): Promise { - const hostId = gatewayHostId(params.endpoint.id); - try { - const page = filterCatalogPageByTitle( - parseCatalogPage( - await params.supervisor.listSessionCatalogPage(params.endpoint.id, { - cursor: params.query.cursors?.[hostId], - limit: params.query.limitPerHost, - archived: params.query.archived, - searchTerm: params.query.search, - }), - ), - params.query.search, - ); - return { - hostId, - label: endpointLabel(params.endpoint), - kind: "gateway", - connected: true, - endpointId: params.endpoint.id, - ...page, - }; - } catch (error) { - return { - hostId, - label: endpointLabel(params.endpoint), - kind: "gateway", - connected: true, - endpointId: params.endpoint.id, - sessions: [], - error: catalogError("APP_SERVER_UNAVAILABLE", error), - }; - } -} - -function nodeLabel(node: CatalogNode): string { - return node.displayName?.trim() || node.remoteIp?.trim() || node.nodeId; -} - -function compareNodeLabels(left: CatalogNode, right: CatalogNode): number { - const leftLabel = nodeLabel(left); - const rightLabel = nodeLabel(right); - if (leftLabel < rightLabel) { - return -1; - } - if (leftLabel > rightLabel) { - return 1; - } - return 0; -} - -async function listPairedNode(params: { - runtime: PluginRuntime; - node: CatalogNode; - query: CodexSessionCatalogParams; -}): Promise { - const hostId = `node:${params.node.nodeId}`; - const common = { - hostId, - label: nodeLabel(params.node), - kind: "node" as const, - nodeId: params.node.nodeId, - }; - if (params.node.connected !== true) { - return { - ...common, - connected: false, - sessions: [], - error: { code: "NODE_OFFLINE", message: "Paired node is offline" }, - }; - } - try { - const raw = await params.runtime.nodes.invoke({ - nodeId: params.node.nodeId, - command: CODEX_APP_SERVER_THREADS_LIST_COMMAND, - params: { - cursor: params.query.cursors?.[hostId], - limit: params.query.limitPerHost, - archived: params.query.archived, - searchTerm: params.query.search, - }, - timeoutMs: NODE_INVOKE_TIMEOUT_MS, - }); - const page = filterCatalogPageByTitle( - parseCatalogPage(unwrapNodeInvokePayload(raw)), - params.query.search, - ); - return { - ...common, - connected: true, - ...page, - }; - } catch (error) { - return { - ...common, - connected: true, - sessions: [], - error: catalogError("NODE_INVOKE_FAILED", error), - }; - } -} - -/** Lists Gateway-local and paired-node Codex sessions with per-host failures. */ -export async function listCodexSessionCatalog(params: { - runtime: PluginRuntime; - supervisor: CodexSupervisor; - query?: CodexSessionCatalogParams; -}): Promise { - const query = readGatewayParams(params.query); - const requestedHostIds = query.hostIds ? new Set(query.hostIds) : undefined; - const localHosts = params.supervisor - .listEndpoints() - .filter((endpoint) => !requestedHostIds || requestedHostIds.has(gatewayHostId(endpoint.id))) - .map((endpoint) => listGatewayEndpoint({ supervisor: params.supervisor, endpoint, query })); - if (requestedHostIds && !query.hostIds?.some((hostId) => hostId.startsWith("node:"))) { - return { hosts: await Promise.all(localHosts) }; - } - let nodes: CatalogNode[]; - try { - nodes = (await params.runtime.nodes.list()).nodes.filter( - (node) => - node.commands?.includes(CODEX_APP_SERVER_THREADS_LIST_COMMAND) && - (!requestedHostIds || requestedHostIds.has(`node:${node.nodeId}`)), - ); - } catch (error) { - return { - hosts: [ - ...(await Promise.all(localHosts)), - { - hostId: "node:registry", - label: "Paired nodes", - kind: "node", - connected: false, - sessions: [], - error: catalogError("NODE_LIST_FAILED", error), - }, - ], - }; - } - const nodeHosts = nodes - .toSorted(compareNodeLabels) - .map((node) => listPairedNode({ runtime: params.runtime, node, query })); - return { hosts: await Promise.all([...localHosts, ...nodeHosts]) }; -} - -/** Builds the node-local read-only Codex app-server catalog command. */ -export function createCodexSessionCatalogNodeHostCommands( - supervisor: CodexSupervisor, -): OpenClawPluginNodeHostCommand[] { - return [ - { - command: CODEX_APP_SERVER_THREADS_LIST_COMMAND, - cap: CODEX_APP_SERVER_THREADS_CAPABILITY, - dangerous: false, - handle: async (paramsJSON) => { - const pageParams = readPageParams(parseJsonParams(paramsJSON)); - const endpoint = supervisor.listEndpoints()[0]; - if (!endpoint) { - throw new Error("Codex app-server catalog is unavailable"); - } - try { - const page = filterCatalogPageByTitle( - parseCatalogPage(await supervisor.listSessionCatalogPage(endpoint.id, pageParams)), - pageParams.searchTerm, - ); - return JSON.stringify(page); - } catch { - // App-server stderr and transport details stay on the node boundary. - throw new Error("Codex app-server catalog is unavailable"); - } - }, - }, - ]; -} - -/** Allows the metadata-only catalog command on supported paired-node platforms. */ -export function createCodexSessionCatalogNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] { - return [ - { - commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND], - defaultPlatforms: ["macos", "linux", "windows"], - handle: (context) => context.invokeNode(), - }, - ]; -} - -/** Registers the Control UI descriptor and host-grouped Gateway catalog method. */ -export function registerCodexSessionCatalogGateway(params: { - api: OpenClawPluginApi; - supervisor: CodexSupervisor; -}): void { - params.api.session.controls.registerControlUiDescriptor({ - surface: "tab", - id: "sessions", - label: "Codex Sessions", - description: "Codex sessions on this Gateway and paired nodes.", - icon: "terminal", - group: "control", - requiredScopes: ["operator.write"], - }); - params.api.registerGatewayMethod( - CODEX_SESSION_CATALOG_METHOD, - async ({ params: requestParams, respond }: GatewayRequestHandlerOptions) => { - try { - respond( - true, - await listCodexSessionCatalog({ - runtime: params.api.runtime, - supervisor: params.supervisor, - query: readGatewayParams(requestParams), - }), - ); - } catch (error) { - if (error instanceof CatalogParamsError) { - respond( - false, - { error: error.message }, - errorShape(ErrorCodes.INVALID_REQUEST, error.message), - ); - return; - } - const message = "Codex session catalog request failed"; - respond(false, { error: message }, errorShape(ErrorCodes.UNAVAILABLE, message)); - } - }, - // Core node.invoke is a write-scoped method even for read-only plugin commands. - { scope: "operator.write" }, - ); -} diff --git a/extensions/codex-supervisor/src/supervisor.test.ts b/extensions/codex-supervisor/src/supervisor.test.ts deleted file mode 100644 index 6282edc1e4eb..000000000000 --- a/extensions/codex-supervisor/src/supervisor.test.ts +++ /dev/null @@ -1,1230 +0,0 @@ -// Codex Supervisor tests cover supervisor plugin behavior. -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; -import { describe, expect, it } from "vitest"; -import { WebSocketServer } from "ws"; -import { loadCodexSupervisorEndpoints, resolveCodexSupervisorPluginConfig } from "./config.js"; -import { - connectCodexAppServerEndpoint, - resolveCodexSupervisorStdioSpawnInvocation, - resolveSafeApprovalResult, -} from "./json-rpc-client.js"; -import { CodexSupervisor } from "./supervisor.js"; -import type { CodexJsonRpcConnection, CodexSupervisorEndpoint } from "./types.js"; - -class FakeCodexConnection implements CodexJsonRpcConnection { - readonly calls: Array<{ method: string; params?: Record }> = []; - closeCount = 0; - - constructor( - private thread: Record, - private readonly failIncludeTurnsUntilMaterialized = false, - ) {} - - async request(method: string, params?: Record): Promise { - this.calls.push({ method, params }); - if (method === "thread/loaded/list") { - return { data: [this.thread.id].filter((id) => typeof id === "string"), nextCursor: null }; - } - if (method === "thread/list") { - return { threads: [this.thread] }; - } - if (method === "thread/read") { - if (this.failIncludeTurnsUntilMaterialized && params?.includeTurns === true) { - throw new Error( - "thread is not materialized yet; includeTurns is unavailable before first user message", - ); - } - return { thread: this.thread }; - } - if (method === "turn/start") { - return { turn: { id: "turn-started", status: "inProgress" } }; - } - if (method === "turn/steer") { - return {}; - } - if (method === "turn/interrupt") { - return {}; - } - throw new Error(`unexpected method: ${method}`); - } - - notify(): void {} - - async close(): Promise { - this.closeCount += 1; - } -} - -const endpoint: CodexSupervisorEndpoint = { - id: "local", - transport: "stdio-proxy", -}; - -describe("loadCodexSupervisorEndpoints", () => { - it("defaults to the local app-server Unix websocket", () => { - expect(loadCodexSupervisorEndpoints({})).toEqual([ - { - id: "local", - label: "local Codex app-server daemon", - transport: "websocket", - url: "unix://", - }, - ]); - }); - - it("parses websocket shorthand endpoints", () => { - expect( - loadCodexSupervisorEndpoints({ - OPENCLAW_CODEX_SUPERVISOR_ENDPOINTS: "crab=ws://127.0.0.1:18080,local", - }), - ).toEqual([ - { - id: "crab", - transport: "websocket", - url: "ws://127.0.0.1:18080", - }, - { - id: "local", - label: "local Codex app-server daemon", - transport: "websocket", - url: "unix://", - }, - ]); - }); - - it("keeps equals signs inside endpoint URLs", () => { - expect( - loadCodexSupervisorEndpoints({ - OPENCLAW_CODEX_SUPERVISOR_ENDPOINTS: "prod=wss://example.invalid/control?token=a=b&next=c", - }), - ).toEqual([ - { - id: "prod", - transport: "websocket", - url: "wss://example.invalid/control?token=a=b&next=c", - }, - ]); - }); - - it("does not derive generated endpoint ids from secret-bearing URLs", () => { - expect( - loadCodexSupervisorEndpoints({ - OPENCLAW_CODEX_SUPERVISOR_ENDPOINTS: "wss://user:secret@example.invalid/control?token=a=b", - }), - ).toEqual([ - { - id: "endpoint-1", - transport: "websocket", - url: "wss://user:secret@example.invalid/control?token=a=b", - }, - ]); - expect( - loadCodexSupervisorEndpoints({ - OPENCLAW_CODEX_SUPERVISOR_ENDPOINTS: JSON.stringify([ - { - transport: "websocket", - url: "wss://example.invalid/control?token=secret", - }, - ]), - }), - ).toEqual([ - { - id: "endpoint-1", - transport: "websocket", - url: "wss://example.invalid/control?token=secret", - }, - ]); - }); - - it("rejects duplicate normalized endpoint ids", () => { - expect(() => - loadCodexSupervisorEndpoints({ - OPENCLAW_CODEX_SUPERVISOR_ENDPOINTS: "fleet/a=ws://one.invalid,fleet-a=ws://two.invalid", - }), - ).toThrow("duplicate Codex supervisor endpoint id: fleet-a"); - expect(() => - resolveCodexSupervisorPluginConfig({ - endpoints: [ - { id: "fleet/a", transport: "websocket", url: "ws://one.invalid" }, - { id: "fleet-a", transport: "websocket", url: "ws://two.invalid" }, - ], - }), - ).toThrow("duplicate Codex supervisor endpoint id: fleet-a"); - }); - - it("prefers plugin-configured endpoints over environment defaults", () => { - expect( - resolveCodexSupervisorPluginConfig( - { - endpoints: [ - { - id: "fleet", - transport: "websocket", - url: "wss://fleet.example.invalid/codex", - }, - ], - allowRawTranscripts: true, - allowWriteControls: true, - }, - { - OPENCLAW_CODEX_SUPERVISOR_ENDPOINTS: "local", - }, - ), - ).toEqual({ - endpoints: [ - { - id: "fleet", - transport: "websocket", - url: "wss://fleet.example.invalid/codex", - }, - ], - allowRawTranscripts: true, - allowWriteControls: true, - }); - }); -}); - -describe("CodexSupervisor", () => { - it("lists a metadata-only App Server catalog page across all providers", async () => { - const fake = new FakeCodexConnection({ id: "unused" }); - fake.request = async (method, params) => { - fake.calls.push({ method, params }); - if (method !== "thread/list") { - throw new Error(`unexpected method: ${method}`); - } - return { - data: [ - { - id: "thread-1", - sessionId: "session-1", - name: "Catalog work", - preview: "private first user message", - cwd: "/workspace", - status: { type: "active", activeFlags: ["waitingOnUserInput"] }, - createdAt: 10, - updatedAt: 20, - recencyAt: 21, - source: { custom: "codexDesktop" }, - modelProvider: "openai", - cliVersion: "0.143.0", - gitInfo: { - branch: "main", - sha: "secret-sha", - originUrl: "https://example.invalid/private.git", - }, - path: "/workspace/.codex/private-rollout.jsonl", - turns: [{ id: "private-turn" }], - }, - { - id: "preview-only", - name: "Other work", - preview: "Catalog appears only in private transcript-derived preview text", - status: { type: "idle" }, - }, - ], - nextCursor: "next-page", - backwardsCursor: "previous-page", - }; - }; - const supervisor = new CodexSupervisor([endpoint], async () => fake); - - await expect( - supervisor.listSessionCatalogPage("local", { - cursor: "cursor-1", - limit: 25, - archived: true, - searchTerm: "Catalog", - cwd: "/workspace", - }), - ).resolves.toEqual({ - sessions: [ - { - threadId: "thread-1", - sessionId: "session-1", - name: "Catalog work", - cwd: "/workspace", - status: "active", - activeFlags: ["waitingOnUserInput"], - createdAt: 10, - updatedAt: 20, - recencyAt: 21, - source: "custom:codexDesktop", - modelProvider: "openai", - cliVersion: "0.143.0", - gitBranch: "main", - archived: true, - }, - ], - nextCursor: "next-page", - backwardsCursor: "previous-page", - }); - expect(fake.calls).toEqual([ - { - method: "thread/list", - params: { - limit: 25, - sortKey: "recency_at", - sortDirection: "desc", - modelProviders: [], - archived: true, - useStateDbOnly: false, - cursor: "cursor-1", - cwd: "/workspace", - }, - }, - ]); - }); - - it("preserves App Server notLoaded status for stored catalog sessions", async () => { - const fake = new FakeCodexConnection({ id: "unused" }); - fake.request = async () => ({ - data: [{ id: "thread-stored", status: { type: "notLoaded" } }], - nextCursor: null, - backwardsCursor: null, - }); - const supervisor = new CodexSupervisor([endpoint], async () => fake); - - await expect(supervisor.listSessionCatalogPage("local")).resolves.toEqual({ - sessions: [{ threadId: "thread-stored", status: "notLoaded", archived: false }], - }); - }); - - it("normalizes and bounds App Server metadata without poisoning the catalog page", async () => { - const longName = "😀".repeat(251); - const longMetadata = "m".repeat(501); - const longId = "i".repeat(257); - const fake = new FakeCodexConnection({ id: "unused" }); - fake.request = async () => ({ - data: [ - { id: longId, name: "dropped" }, - { - id: " thread-1 ", - sessionId: longId, - name: longName, - cwd: "c".repeat(4097), - status: { - type: "s".repeat(65), - activeFlags: ["f".repeat(129), ...Array.from({ length: 17 }, (_, i) => `flag-${i}`)], - }, - createdAt: Number.POSITIVE_INFINITY, - updatedAt: 20, - source: { custom: longMetadata }, - modelProvider: longMetadata, - cliVersion: longMetadata, - gitInfo: { branch: longMetadata }, - }, - ], - nextCursor: "n".repeat(4097), - backwardsCursor: "opaque-backwards", - }); - const supervisor = new CodexSupervisor([endpoint], async () => fake); - - const page = await supervisor.listSessionCatalogPage("local"); - - expect(page).toEqual({ - sessions: [ - { - threadId: "thread-1", - name: "😀".repeat(250), - status: "notLoaded", - activeFlags: Array.from({ length: 16 }, (_, i) => `flag-${i}`), - updatedAt: 20, - source: `custom:${"m".repeat(493)}`, - modelProvider: "m".repeat(500), - cliVersion: "m".repeat(500), - gitBranch: "m".repeat(500), - archived: false, - }, - ], - backwardsCursor: "opaque-backwards", - }); - expect(page.sessions[0]?.name).toHaveLength(500); - }); - - it("does not permanently cache failed endpoint connections", async () => { - const fake = new FakeCodexConnection({ - id: "thread-1", - status: { type: "idle" }, - turns: [], - }); - let attempts = 0; - const supervisor = new CodexSupervisor([endpoint], async () => { - attempts += 1; - if (attempts === 1) { - throw new Error("daemon unavailable"); - } - return fake; - }); - - await expect(supervisor.probeEndpoints()).resolves.toEqual([ - { endpointId: "local", ok: false, detail: "daemon unavailable" }, - ]); - await expect(supervisor.probeEndpoints()).resolves.toEqual([{ endpointId: "local", ok: true }]); - expect(attempts).toBe(2); - }); - - it("lists loaded sessions", async () => { - const fake = new FakeCodexConnection({ - id: "thread-1", - cwd: "/workspace", - preview: "work", - sessionId: "session-1", - source: "vscode", - status: { type: "idle" }, - updatedAt: 10, - turns: [], - }); - const supervisor = new CodexSupervisor([endpoint], async () => fake); - - await expect(supervisor.listSessions()).resolves.toEqual([ - { - endpointId: "local", - threadId: "thread-1", - cwd: "/workspace", - preview: "work", - sessionId: "session-1", - source: "vscode", - status: "idle", - updatedAt: 10, - humanAttached: true, - }, - ]); - }); - - it("lists loaded sessions from real app-server data responses", async () => { - const fake = new FakeCodexConnection({ - id: "thread-1", - cwd: "/workspace", - status: { type: "idle" }, - turns: [], - }); - fake.request = async (method, params) => { - fake.calls.push({ method, params }); - if (method === "thread/loaded/list") { - return { data: ["thread-1"], nextCursor: null }; - } - if (method === "thread/read") { - return { - thread: { id: "thread-1", cwd: "/workspace", status: { type: "idle" }, turns: [] }, - }; - } - throw new Error(`unexpected method: ${method}`); - }; - const supervisor = new CodexSupervisor([endpoint], async () => fake); - - await expect(supervisor.listSessions()).resolves.toEqual([ - { - endpointId: "local", - threadId: "thread-1", - cwd: "/workspace", - status: "idle", - humanAttached: true, - }, - ]); - }); - - it("hydrates loaded-only sessions without stored history", async () => { - const fake = new FakeCodexConnection({ - id: "thread-live", - cwd: "/workspace", - status: { type: "active", activeFlags: [] }, - turns: [], - }); - fake.request = async (method, params) => { - fake.calls.push({ method, params }); - if (method === "thread/loaded/list") { - return { data: ["thread-live"], nextCursor: null }; - } - if (method === "thread/read") { - return { - thread: { - id: "thread-live", - cwd: "/workspace", - status: { type: "active", activeFlags: [] }, - turns: [], - }, - }; - } - throw new Error(`unexpected method: ${method}`); - }; - const supervisor = new CodexSupervisor([endpoint], async () => fake); - - await expect(supervisor.listSessions()).resolves.toEqual([ - { - endpointId: "local", - threadId: "thread-live", - cwd: "/workspace", - status: "active", - humanAttached: true, - }, - ]); - expect(fake.calls.map((call) => call.method)).toEqual(["thread/loaded/list", "thread/read"]); - }); - - it("does not enumerate stored sessions unless requested", async () => { - const fake = new FakeCodexConnection({ - id: "thread-1", - status: { type: "notLoaded" }, - turns: [], - }); - fake.request = async (method, params) => { - fake.calls.push({ method, params }); - if (method === "thread/loaded/list") { - return { data: [], nextCursor: null }; - } - throw new Error(`unexpected method: ${method}`); - }; - const supervisor = new CodexSupervisor([endpoint], async () => fake); - - await expect(supervisor.listSessions()).resolves.toEqual([]); - expect(fake.calls.map((call) => call.method)).toEqual(["thread/loaded/list"]); - }); - - it("reads stored sessions from real app-server data responses", async () => { - const fake = new FakeCodexConnection({ - id: "thread-1", - status: { type: "idle" }, - turns: [], - }); - fake.request = async (method, params) => { - fake.calls.push({ method, params }); - if (method === "thread/loaded/list") { - return { data: [], nextCursor: null }; - } - if (method === "thread/list") { - return { - data: [{ id: "thread-1", status: { type: "notLoaded" }, turns: [] }], - nextCursor: null, - }; - } - throw new Error(`unexpected method: ${method}`); - }; - const supervisor = new CodexSupervisor([endpoint], async () => fake); - - await expect(supervisor.listSessions({ includeStored: true })).resolves.toEqual([ - { - endpointId: "local", - threadId: "thread-1", - status: "notLoaded", - }, - ]); - expect(fake.calls.find((call) => call.method === "thread/list")?.params).toMatchObject({ - sourceKinds: [ - "cli", - "vscode", - "exec", - "appServer", - "subAgent", - "subAgentReview", - "subAgentCompact", - "subAgentThreadSpawn", - "subAgentOther", - "unknown", - ], - modelProviders: [], - sortKey: "recency_at", - sortDirection: "desc", - useStateDbOnly: true, - }); - }); - - it("reads every stored session page", async () => { - const fake = new FakeCodexConnection({ - id: "thread-1", - status: { type: "idle" }, - turns: [], - }); - fake.request = async (method, params) => { - fake.calls.push({ method, params }); - if (method === "thread/loaded/list") { - return { data: [], nextCursor: null }; - } - if (method === "thread/list") { - if (params?.cursor === "page-2") { - return { - data: [{ id: "thread-2", status: { type: "notLoaded" }, turns: [] }], - nextCursor: null, - }; - } - return { - data: [{ id: "thread-1", status: { type: "notLoaded" }, turns: [] }], - nextCursor: "page-2", - }; - } - throw new Error(`unexpected method: ${method}`); - }; - const supervisor = new CodexSupervisor([endpoint], async () => fake); - - await expect(supervisor.listSessions({ includeStored: true })).resolves.toEqual([ - { - endpointId: "local", - threadId: "thread-1", - status: "notLoaded", - }, - { - endpointId: "local", - threadId: "thread-2", - status: "notLoaded", - }, - ]); - }); - - it("bounds stored session pagination for large real Codex homes", async () => { - const fake = new FakeCodexConnection({ - id: "thread-1", - status: { type: "idle" }, - turns: [], - }); - fake.request = async (method, params) => { - fake.calls.push({ method, params }); - if (method === "thread/loaded/list") { - return { data: [], nextCursor: null }; - } - if (method === "thread/list") { - return { - data: [ - { id: "thread-1", status: { type: "notLoaded" }, turns: [] }, - { id: "thread-2", status: { type: "notLoaded" }, turns: [] }, - ], - nextCursor: "page-2", - }; - } - throw new Error(`unexpected method: ${method}`); - }; - const supervisor = new CodexSupervisor([endpoint], async () => fake); - - await expect( - supervisor.listSessions({ includeStored: true, maxStoredSessions: 1 }), - ).resolves.toEqual([ - { - endpointId: "local", - threadId: "thread-1", - status: "notLoaded", - }, - ]); - expect(fake.calls.filter((call) => call.method === "thread/list")).toEqual([ - { - method: "thread/list", - params: { - limit: 1, - sourceKinds: [ - "cli", - "vscode", - "exec", - "appServer", - "subAgent", - "subAgentReview", - "subAgentCompact", - "subAgentThreadSpawn", - "subAgentOther", - "unknown", - ], - modelProviders: [], - sortKey: "recency_at", - sortDirection: "desc", - useStateDbOnly: true, - }, - }, - ]); - }); - - it("closes settled connections when evicting them", async () => { - const fake = new FakeCodexConnection({ - id: "thread-1", - status: { type: "idle" }, - turns: [], - }); - fake.request = async (method, params) => { - fake.calls.push({ method, params }); - if (method === "thread/read") { - throw new Error("transport closed"); - } - throw new Error(`unexpected method: ${method}`); - }; - const supervisor = new CodexSupervisor([endpoint], async () => fake); - - await expect( - supervisor.readSession({ endpointId: "local", threadId: "thread-1" }), - ).rejects.toThrow("transport closed"); - await Promise.resolve(); - expect(fake.closeCount).toBe(1); - }); - - it("keeps listing healthy endpoints when one endpoint is down", async () => { - const downEndpoint: CodexSupervisorEndpoint = { - id: "down", - transport: "stdio-proxy", - }; - const upEndpoint: CodexSupervisorEndpoint = { - id: "up", - transport: "stdio-proxy", - }; - const fake = new FakeCodexConnection({ - id: "thread-1", - status: { type: "idle" }, - turns: [], - }); - const supervisor = new CodexSupervisor([downEndpoint, upEndpoint], async (target) => { - if (target.id === "down") { - throw new Error("host offline"); - } - return fake; - }); - - await expect(supervisor.listSessionSnapshot()).resolves.toEqual({ - sessions: [ - { - endpointId: "up", - threadId: "thread-1", - status: "idle", - humanAttached: true, - }, - ], - errors: [{ endpointId: "down", ok: false, detail: "host offline" }], - }); - }); - - it("starts a new turn for idle sessions", async () => { - const fake = new FakeCodexConnection({ - id: "thread-1", - status: { type: "idle" }, - turns: [], - }); - const supervisor = new CodexSupervisor([endpoint], async () => fake); - - await expect( - supervisor.sendToSession({ endpointId: "local", threadId: "thread-1", text: "continue" }), - ).resolves.toMatchObject({ - endpointId: "local", - threadId: "thread-1", - mode: "start", - turnId: "turn-started", - }); - expect(fake.calls.at(-1)).toEqual({ - method: "turn/start", - params: { - threadId: "thread-1", - input: [{ type: "text", text: "continue", text_elements: [] }], - }, - }); - }); - - it("resolves omitted endpoint ids from loaded-only sessions", async () => { - const fake = new FakeCodexConnection({ - id: "thread-1", - status: { type: "idle" }, - turns: [], - }); - fake.request = async (method, params) => { - fake.calls.push({ method, params }); - if (method === "thread/loaded/list") { - return { data: ["thread-1"], nextCursor: null }; - } - if (method === "thread/read") { - return { thread: { id: "thread-1", status: { type: "idle" }, turns: [] } }; - } - if (method === "thread/list") { - return { data: [], nextCursor: null }; - } - if (method === "turn/start") { - return { turn: { id: "turn-started", status: "inProgress" } }; - } - throw new Error(`unexpected method: ${method}`); - }; - const supervisor = new CodexSupervisor([endpoint], async () => fake); - - await expect( - supervisor.sendToSession({ threadId: "thread-1", text: "continue" }), - ).resolves.toMatchObject({ - endpointId: "local", - threadId: "thread-1", - mode: "start", - }); - }); - - it("uses a unique loaded endpoint match even when another endpoint is down", async () => { - const upEndpoint: CodexSupervisorEndpoint = { id: "up", transport: "stdio-proxy" }; - const downEndpoint: CodexSupervisorEndpoint = { id: "down", transport: "stdio-proxy" }; - const fake = new FakeCodexConnection({ - id: "thread-1", - status: { type: "idle" }, - turns: [], - }); - const supervisor = new CodexSupervisor([upEndpoint, downEndpoint], async (target) => { - if (target.id === "down") { - throw new Error("host offline"); - } - return fake; - }); - - await expect( - supervisor.sendToSession({ threadId: "thread-1", text: "continue" }), - ).resolves.toMatchObject({ - endpointId: "up", - threadId: "thread-1", - mode: "start", - }); - }); - - it("resolves omitted endpoint ids by exact thread read without scanning stored pages", async () => { - const fake = new FakeCodexConnection({ - id: "thread-old", - status: { type: "notLoaded" }, - turns: [], - }); - fake.request = async (method, params) => { - fake.calls.push({ method, params }); - if (method === "thread/loaded/list") { - return { data: [], nextCursor: null }; - } - if (method === "thread/read" && params?.threadId === "thread-old") { - return { thread: { id: "thread-old", status: { type: "notLoaded" }, turns: [] } }; - } - throw new Error(`unexpected method: ${method}`); - }; - const supervisor = new CodexSupervisor([endpoint], async () => fake); - - await expect(supervisor.readSession({ threadId: "thread-old" })).resolves.toEqual({ - thread: { id: "thread-old", status: { type: "notLoaded" }, turns: [] }, - }); - expect(fake.calls.map((call) => call.method)).toEqual([ - "thread/loaded/list", - "thread/read", - "thread/read", - ]); - }); - - it("resolves stored threads on healthy endpoints when another endpoint is down", async () => { - const downEndpoint: CodexSupervisorEndpoint = { id: "down", transport: "stdio-proxy" }; - const upEndpoint: CodexSupervisorEndpoint = { id: "up", transport: "stdio-proxy" }; - const fake = new FakeCodexConnection({ - id: "thread-old", - status: { type: "notLoaded" }, - turns: [], - }); - fake.request = async (method, params) => { - fake.calls.push({ method, params }); - if (method === "thread/loaded/list") { - return { data: [], nextCursor: null }; - } - if (method === "thread/read" && params?.threadId === "thread-old") { - return { thread: { id: "thread-old", status: { type: "notLoaded" }, turns: [] } }; - } - throw new Error(`unexpected method: ${method}`); - }; - const supervisor = new CodexSupervisor([downEndpoint, upEndpoint], async (target) => { - if (target.id === "down") { - throw new Error("host offline"); - } - return fake; - }); - - await expect(supervisor.readSession({ threadId: "thread-old" })).resolves.toEqual({ - thread: { id: "thread-old", status: { type: "notLoaded" }, turns: [] }, - }); - }); - - it("steers active sessions when the in-progress turn is readable", async () => { - const fake = new FakeCodexConnection({ - id: "thread-1", - status: { type: "active", activeFlags: [] }, - turns: [ - { id: "turn-old", status: "completed", items: [] }, - { id: "turn-active", status: "inProgress", items: [] }, - ], - }); - const supervisor = new CodexSupervisor([endpoint], async () => fake); - - await expect( - supervisor.sendToSession({ endpointId: "local", threadId: "thread-1", text: "heads up" }), - ).resolves.toEqual({ - endpointId: "local", - threadId: "thread-1", - mode: "steer", - turnId: "turn-active", - status: "active", - }); - expect(fake.calls.at(-1)).toEqual({ - method: "turn/steer", - params: { - threadId: "thread-1", - expectedTurnId: "turn-active", - input: [{ type: "text", text: "heads up", text_elements: [] }], - }, - }); - }); - - it("steers active sessions through the live turns list fallback", async () => { - const fake = new FakeCodexConnection({ - id: "thread-1", - status: { type: "active", activeFlags: [] }, - turns: [], - }); - fake.request = async (method, params) => { - fake.calls.push({ method, params }); - if (method === "thread/list") { - return { - data: [{ id: "thread-1", status: { type: "active", activeFlags: [] }, turns: [] }], - nextCursor: null, - }; - } - if (method === "thread/read") { - return { - thread: { - id: "thread-1", - status: { type: "active", activeFlags: [] }, - turns: [], - }, - }; - } - if (method === "thread/turns/list") { - return { - data: [{ id: "turn-active", status: "inProgress", items: [] }], - nextCursor: null, - }; - } - if (method === "turn/steer") { - return {}; - } - throw new Error(`unexpected method: ${method}`); - }; - const supervisor = new CodexSupervisor([endpoint], async () => fake); - - await expect( - supervisor.sendToSession({ endpointId: "local", threadId: "thread-1", text: "heads up" }), - ).resolves.toEqual({ - endpointId: "local", - threadId: "thread-1", - mode: "steer", - turnId: "turn-active", - status: "active", - }); - }); - - it("fails closed when active turn id is not readable", async () => { - const fake = new FakeCodexConnection({ - id: "thread-1", - status: { type: "active", activeFlags: [] }, - turns: [], - }); - const supervisor = new CodexSupervisor([endpoint], async () => fake); - - await expect( - supervisor.sendToSession({ endpointId: "local", threadId: "thread-1", text: "heads up" }), - ).rejects.toThrow("active but no in-progress turn is readable"); - }); - - it("falls back to reading empty unmaterialized threads without turns", async () => { - const fake = new FakeCodexConnection( - { - id: "thread-1", - status: { type: "idle" }, - turns: [], - }, - true, - ); - const supervisor = new CodexSupervisor([endpoint], async () => fake); - - await expect( - supervisor.readSession({ endpointId: "local", threadId: "thread-1", includeTurns: true }), - ).resolves.toEqual({ - thread: { - id: "thread-1", - status: { type: "idle" }, - turns: [], - }, - }); - expect( - fake.calls.filter((call) => call.method === "thread/read").map((call) => call.params), - ).toEqual([ - { threadId: "thread-1", includeTurns: true }, - { threadId: "thread-1", includeTurns: false }, - ]); - }); -}); - -describe("resolveSafeApprovalResult", () => { - it("returns a valid fail-closed permissions response", () => { - expect(resolveSafeApprovalResult("item/permissions/requestApproval")).toEqual({ - permissions: {}, - scope: "turn", - }); - }); - - it("returns valid fail-closed responses for non-approval server requests", () => { - expect(resolveSafeApprovalResult("item/tool/call")).toEqual({ - contentItems: [ - { - type: "inputText", - text: "OpenClaw Codex supervisor did not register a handler for this app-server tool call.", - }, - ], - success: false, - }); - expect(resolveSafeApprovalResult("item/tool/requestUserInput")).toEqual({ answers: {} }); - expect(resolveSafeApprovalResult("mcpServer/elicitation/request")).toEqual({ - action: "decline", - }); - expect(resolveSafeApprovalResult("unknown/request")).toBeUndefined(); - }); -}); - -async function waitForFile(filePath: string): Promise { - for (let attempt = 0; attempt < 50; attempt += 1) { - try { - return await fs.readFile(filePath, "utf8"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - await new Promise((resolve) => { - setTimeout(resolve, 20); - }); - } - } - throw new Error(`timed out waiting for ${filePath}`); -} - -describe("resolveCodexSupervisorStdioSpawnInvocation", () => { - it("uses the installed macOS Codex app for the default catalog endpoint", () => { - expect( - resolveCodexSupervisorStdioSpawnInvocation( - { id: "local", transport: "stdio-proxy" }, - { - platform: "darwin", - env: {}, - execPath: "/usr/bin/node", - isExecutable: (filePath) => - filePath === "/Applications/Codex.app/Contents/Resources/codex", - }, - ), - ).toEqual({ - command: "/Applications/Codex.app/Contents/Resources/codex", - args: ["app-server", "--listen", "stdio://"], - shell: undefined, - windowsHide: undefined, - }); - }); - - it("resolves an installed Windows Codex npm shim without a shell", async () => { - const binDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-supervisor-win-bin-")); - const entrypoint = path.join(binDir, "codex.js"); - const shim = path.join(binDir, "codex.cmd"); - await fs.writeFile(entrypoint, "", "utf8"); - await fs.writeFile(shim, '@ECHO off\r\n"%~dp0\\codex.js" %*\r\n', "utf8"); - - expect( - resolveCodexSupervisorStdioSpawnInvocation( - { id: "local", transport: "stdio-proxy" }, - { - platform: "win32", - env: { PATH: binDir, PATHEXT: ".CMD;.EXE;.BAT" }, - execPath: "C:\\node\\node.exe", - isExecutable: () => false, - }, - ), - ).toEqual({ - command: "C:\\node\\node.exe", - args: [entrypoint, "app-server", "--listen", "stdio://"], - shell: undefined, - windowsHide: true, - }); - }); - - it("preserves an explicit stdio command instead of selecting the macOS app", () => { - expect( - resolveCodexSupervisorStdioSpawnInvocation( - { - id: "custom", - transport: "stdio-proxy", - command: "/opt/codex-custom", - args: ["serve"], - }, - { - platform: "darwin", - env: {}, - execPath: "/usr/bin/node", - isExecutable: () => true, - }, - ), - ).toMatchObject({ command: "/opt/codex-custom", args: ["serve"] }); - }); -}); - -describe("connectCodexAppServerEndpoint", () => { - it("rejects pending websocket requests when the supervisor closes intentionally", async () => { - const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); - const port = await new Promise((resolve) => { - server.once("listening", () => { - const address = server.address(); - resolve(typeof address === "object" && address ? address.port : 0); - }); - }); - const sawProbeRequest = new Promise((resolve) => { - server.once("connection", (socket) => { - socket.on("message", (data) => { - const messageText = - typeof data === "string" - ? data - : Array.isArray(data) - ? Buffer.concat(data).toString("utf8") - : data instanceof ArrayBuffer - ? Buffer.from(new Uint8Array(data)).toString("utf8") - : Buffer.from(data).toString("utf8"); - const request = JSON.parse(messageText) as Record; - if (request.method === "initialize") { - socket.send(JSON.stringify({ id: request.id, result: {} })); - } - if (request.method === "thread/loaded/list") { - resolve(); - } - }); - }); - }); - const supervisor = new CodexSupervisor( - [{ id: "ws", transport: "websocket", url: `ws://127.0.0.1:${port}` }], - connectCodexAppServerEndpoint, - ); - - const probe = supervisor.probeEndpoints(); - await sawProbeRequest; - await supervisor.close(); - - await expect( - Promise.race([ - probe, - new Promise((_, reject) => { - setTimeout(() => reject(new Error("probe timed out")), 500); - }), - ]), - ).resolves.toMatchObject([{ endpointId: "ws", ok: false }]); - await new Promise((resolve) => { - server.close(() => resolve()); - }); - }); - - it("rejects malformed stdio frames instead of throwing out of band", async () => { - const markerDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-supervisor-malformed-")); - const marker = path.join(markerDir, "closed"); - const script = ` - const fs = require("node:fs"); - const readline = require("node:readline"); - process.on("SIGTERM", () => { - fs.writeFileSync(${JSON.stringify(marker)}, "closed"); - process.exit(0); - }); - readline.createInterface({ input: process.stdin }).on("line", () => { - process.stdout.write("not-json\\n"); - }); - setTimeout(() => {}, 10_000); - `; - - await expect( - connectCodexAppServerEndpoint({ - id: "bad", - transport: "stdio-proxy", - command: process.execPath, - args: ["-e", script], - }), - ).rejects.toThrow("Malformed Codex app-server message"); - await expect(waitForFile(marker)).resolves.toBe("closed"); - }); - - it("closes stdio connections when initialization fails", async () => { - const markerDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-supervisor-init-")); - const marker = path.join(markerDir, "closed"); - const script = ` - const fs = require("node:fs"); - const readline = require("node:readline"); - process.on("SIGTERM", () => { - fs.writeFileSync(${JSON.stringify(marker)}, "closed"); - process.exit(0); - }); - readline.createInterface({ input: process.stdin }).on("line", (line) => { - const request = JSON.parse(line); - process.stdout.write(JSON.stringify({ - id: request.id, - error: { code: -32000, message: "init failed" } - }) + "\\n"); - }); - setTimeout(() => {}, 10_000); - `; - - await expect( - connectCodexAppServerEndpoint({ - id: "bad", - transport: "stdio-proxy", - command: process.execPath, - args: ["-e", script], - }), - ).rejects.toThrow("init failed"); - await expect(waitForFile(marker)).resolves.toBe("closed"); - }); - - it("keeps stdio close errors UTF-16 safe at the stderr tail boundary", async () => { - const prefix = "x".repeat(1_199); - const script = ` - process.stderr.write(${JSON.stringify(`${prefix}😀`)}); - process.exit(0); - `; - let closeError: unknown; - - try { - await connectCodexAppServerEndpoint({ - id: "exits", - transport: "stdio-proxy", - command: process.execPath, - args: ["-e", script], - }); - } catch (error) { - closeError = error; - } - - expect(closeError).toBeInstanceOf(Error); - expect((closeError as Error).message).toBe( - `Codex app-server stdio transport closed. stderr_tail=${prefix}`, - ); - }); - - it("fails a cached stdio connection cleanly after the child exits", async () => { - const script = ` - const readline = require("node:readline"); - readline.createInterface({ input: process.stdin }).on("line", (line) => { - const request = JSON.parse(line); - if (request.method === "initialize") { - process.stdout.write(JSON.stringify({ id: request.id, result: {} }) + "\\n"); - return; - } - if (request.method === "thread/loaded/list") { - process.stdout.write(JSON.stringify({ id: request.id, result: { threads: [] } }) + "\\n"); - setTimeout(() => process.exit(0), 0); - } - }); - `; - const supervisor = new CodexSupervisor( - [ - { - id: "exits", - transport: "stdio-proxy", - command: process.execPath, - args: ["-e", script], - }, - ], - connectCodexAppServerEndpoint, - ); - - await expect(supervisor.probeEndpoints()).resolves.toEqual([{ endpointId: "exits", ok: true }]); - await new Promise((resolve) => { - setTimeout(resolve, 50); - }); - await expect(supervisor.probeEndpoints()).resolves.toMatchObject([ - { - endpointId: "exits", - ok: false, - }, - ]); - await supervisor.close(); - }); -}); diff --git a/extensions/codex-supervisor/src/supervisor.ts b/extensions/codex-supervisor/src/supervisor.ts deleted file mode 100644 index a874f1cd1da3..000000000000 --- a/extensions/codex-supervisor/src/supervisor.ts +++ /dev/null @@ -1,723 +0,0 @@ -/** - * Codex app-server supervisor that lists sessions, reads transcripts, and - * starts/steers/interrupts turns across configured endpoints. - */ -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { connectCodexAppServerEndpoint } from "./json-rpc-client.js"; -import type { - CodexJsonRpcConnection, - CodexSessionCatalogPage, - CodexSessionCatalogPageParams, - CodexSessionCatalogSession, - CodexSupervisorEndpoint, - CodexSupervisorEndpointHealth, - CodexSupervisorSendResult, - CodexSupervisorSession, - CodexSupervisorSessionListResult, - CodexSupervisorThreadStatus, - CodexSupervisorTurnMode, -} from "./types.js"; - -type EndpointConnector = (endpoint: CodexSupervisorEndpoint) => Promise; - -const ALL_CODEX_THREAD_SOURCE_KINDS = [ - "cli", - "vscode", - "exec", - "appServer", - "subAgent", - "subAgentReview", - "subAgentCompact", - "subAgentThreadSpawn", - "subAgentOther", - "unknown", -]; -const DEFAULT_MAX_STORED_SESSIONS = 200; -const DEFAULT_CATALOG_PAGE_LIMIT = 50; -const MAX_CATALOG_PAGE_LIMIT = 100; -const MAX_CATALOG_SESSION_ID_LENGTH = 256; -const MAX_CATALOG_SESSION_NAME_LENGTH = 500; -const MAX_CATALOG_CWD_LENGTH = 4096; -const MAX_CATALOG_STATUS_LENGTH = 64; -const MAX_CATALOG_METADATA_LENGTH = 500; -const MAX_CATALOG_ACTIVE_FLAGS = 16; -const MAX_CATALOG_ACTIVE_FLAG_LENGTH = 128; -const MAX_CATALOG_CURSOR_LENGTH = 4096; - -function asRecordArray(value: unknown): Record[] { - if (!Array.isArray(value)) { - return []; - } - return value.filter(isRecord); -} - -function extractThread(value: unknown): Record | undefined { - if (!isRecord(value)) { - return undefined; - } - if (isRecord(value.thread)) { - return value.thread; - } - return undefined; -} - -function extractThreadList(value: unknown): Record[] { - if (!isRecord(value)) { - return []; - } - if (Array.isArray(value.data)) { - return asRecordArray(value.data); - } - if (Array.isArray(value.threads)) { - return asRecordArray(value.threads); - } - if (Array.isArray(value.loadedThreads)) { - return asRecordArray(value.loadedThreads); - } - return []; -} - -function extractStringList(value: unknown): string[] { - if (!isRecord(value) || !Array.isArray(value.data)) { - return []; - } - return value.data.filter((entry) => typeof entry === "string"); -} - -function getStatusType(thread: Record): CodexSupervisorThreadStatus { - const status = thread.status; - if (isRecord(status) && typeof status.type === "string") { - return status.type; - } - if (typeof status === "string") { - return status; - } - return "unknown"; -} - -function boundedCatalogString( - value: unknown, - maxLength: number, - overflow: "omit" | "truncate" = "omit", -): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const normalized = value.trim(); - if (!normalized) { - return undefined; - } - if (normalized.length <= maxLength) { - return normalized; - } - return overflow === "truncate" ? truncateUtf16Safe(normalized, maxLength) : undefined; -} - -function getCatalogStatus(thread: Record): string { - const status = thread.status; - const value = isRecord(status) ? status.type : status; - return boundedCatalogString(value, MAX_CATALOG_STATUS_LENGTH) ?? "notLoaded"; -} - -function getCatalogActiveFlags(thread: Record): string[] | undefined { - const status = thread.status; - if (!isRecord(status) || !Array.isArray(status.activeFlags)) { - return undefined; - } - const flags = status.activeFlags - .flatMap((entry) => { - const flag = boundedCatalogString(entry, MAX_CATALOG_ACTIVE_FLAG_LENGTH); - return flag ? [flag] : []; - }) - .slice(0, MAX_CATALOG_ACTIVE_FLAGS); - return flags.length > 0 ? flags : undefined; -} - -function getCatalogSourceLabel(value: unknown): string | undefined { - let source: string | undefined; - if (typeof value === "string") { - source = value; - } else if (isRecord(value)) { - const custom = typeof value.custom === "string" ? value.custom.trim() : undefined; - source = custom ? `custom:${custom}` : Object.keys(value).toSorted()[0]; - } - return boundedCatalogString(source, MAX_CATALOG_METADATA_LENGTH, "truncate"); -} - -function boundedCatalogCursor(value: unknown): string | undefined { - if (typeof value !== "string" || !value.trim() || value.length > MAX_CATALOG_CURSOR_LENGTH) { - return undefined; - } - // App Server cursors are opaque, so preserve their bytes after validation. - return value; -} - -function getFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function toCatalogSession( - thread: Record, - archived: boolean, -): CodexSessionCatalogSession | undefined { - const threadId = boundedCatalogString(thread.id, MAX_CATALOG_SESSION_ID_LENGTH); - if (!threadId) { - return undefined; - } - const activeFlags = getCatalogActiveFlags(thread); - const source = getCatalogSourceLabel(thread.source); - const gitInfo = isRecord(thread.gitInfo) ? thread.gitInfo : undefined; - const createdAt = getFiniteNumber(thread.createdAt); - const updatedAt = getFiniteNumber(thread.updatedAt); - const recencyAt = thread.recencyAt === null ? null : getFiniteNumber(thread.recencyAt); - const sessionId = boundedCatalogString(thread.sessionId, MAX_CATALOG_SESSION_ID_LENGTH); - const name = boundedCatalogString(thread.name, MAX_CATALOG_SESSION_NAME_LENGTH, "truncate"); - const cwd = boundedCatalogString(thread.cwd, MAX_CATALOG_CWD_LENGTH); - const modelProvider = boundedCatalogString( - thread.modelProvider, - MAX_CATALOG_METADATA_LENGTH, - "truncate", - ); - const cliVersion = boundedCatalogString( - thread.cliVersion, - MAX_CATALOG_METADATA_LENGTH, - "truncate", - ); - const gitBranch = boundedCatalogString(gitInfo?.branch, MAX_CATALOG_METADATA_LENGTH, "truncate"); - return { - threadId, - status: getCatalogStatus(thread), - archived, - ...(sessionId ? { sessionId } : {}), - ...(name ? { name } : {}), - ...(cwd ? { cwd } : {}), - ...(activeFlags ? { activeFlags } : {}), - ...(createdAt !== undefined ? { createdAt } : {}), - ...(updatedAt !== undefined ? { updatedAt } : {}), - ...(recencyAt !== undefined ? { recencyAt } : {}), - ...(source ? { source } : {}), - ...(modelProvider ? { modelProvider } : {}), - ...(cliVersion ? { cliVersion } : {}), - ...(gitBranch ? { gitBranch } : {}), - }; -} - -function normalizeCatalogLimit(value: number | undefined): number { - if (value === undefined || !Number.isFinite(value)) { - return DEFAULT_CATALOG_PAGE_LIMIT; - } - return Math.min(MAX_CATALOG_PAGE_LIMIT, Math.max(1, Math.floor(value))); -} - -function toSession( - endpointId: string, - thread: Record, - humanAttached?: boolean, -): CodexSupervisorSession | undefined { - if (typeof thread.id !== "string") { - return undefined; - } - return { - endpointId, - threadId: thread.id, - status: getStatusType(thread), - ...(typeof thread.sessionId === "string" ? { sessionId: thread.sessionId } : {}), - ...(typeof thread.cwd === "string" ? { cwd: thread.cwd } : {}), - ...(typeof thread.preview === "string" ? { preview: thread.preview } : {}), - ...("name" in thread && (typeof thread.name === "string" || thread.name === null) - ? { name: thread.name } - : {}), - ...(typeof thread.source === "string" ? { source: thread.source } : {}), - ...(typeof thread.updatedAt === "number" ? { updatedAt: thread.updatedAt } : {}), - ...(humanAttached !== undefined ? { humanAttached } : {}), - }; -} - -function findInProgressTurnId(thread: Record): string | undefined { - const turns = asRecordArray(thread.turns); - for (const turn of turns.toReversed()) { - if (turn.status === "inProgress" && typeof turn.id === "string") { - return turn.id; - } - } - return undefined; -} - -function isLoadedThreadReadMiss(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return message.includes("thread not found") || message.includes("thread not loaded"); -} - -/** High-level supervisor facade used by OpenClaw tools and MCP tools. */ -export class CodexSupervisor { - private readonly connections = new Map>(); - - constructor( - private readonly endpoints: CodexSupervisorEndpoint[], - private readonly connector: EndpointConnector = connectCodexAppServerEndpoint, - ) {} - - /** Returns configured endpoint definitions without opening connections. */ - listEndpoints(): CodexSupervisorEndpoint[] { - return this.endpoints; - } - - /** Closes all open app-server connections owned by this supervisor. */ - async close(): Promise { - const settled = await Promise.allSettled(this.connections.values()); - this.connections.clear(); - await Promise.all( - settled.map(async (entry) => { - if (entry.status === "fulfilled") { - await entry.value.close(); - } - }), - ); - } - - /** Checks whether each endpoint can service a lightweight thread list call. */ - async probeEndpoints(): Promise { - return await Promise.all( - this.endpoints.map(async (endpoint) => { - try { - const connection = await this.connectionFor(endpoint.id); - await connection.request("thread/loaded/list", { limit: 1 }); - return { endpointId: endpoint.id, ok: true }; - } catch (error) { - this.forgetEndpoint(endpoint.id); - return { - endpointId: endpoint.id, - ok: false, - detail: error instanceof Error ? error.message : String(error), - }; - } - }), - ); - } - - /** Lists sessions, returning only the session array for agent-tool callers. */ - async listSessions( - params: { includeStored?: boolean; maxStoredSessions?: number } = {}, - ): Promise { - return (await this.listSessionSnapshot(params)).sessions; - } - - /** Lists sessions plus endpoint errors for structured tool output. */ - async listSessionSnapshot( - params: { includeStored?: boolean; maxStoredSessions?: number } = {}, - ): Promise { - const sessions: CodexSupervisorSession[] = []; - const errors: CodexSupervisorEndpointHealth[] = []; - for (const endpoint of this.endpoints) { - try { - sessions.push(...(await this.listEndpointSessions(endpoint, params))); - } catch (error) { - this.forgetEndpoint(endpoint.id); - errors.push({ - endpointId: endpoint.id, - ok: false, - detail: error instanceof Error ? error.message : String(error), - }); - } - } - return { sessions, errors }; - } - - /** Lists one metadata-only page from one configured Codex app-server endpoint. */ - async listSessionCatalogPage( - endpointId: string, - params: CodexSessionCatalogPageParams = {}, - ): Promise { - const connection = await this.connectionFor(endpointId); - const archived = params.archived === true; - const limit = normalizeCatalogLimit(params.limit); - try { - const listed = await connection.request("thread/list", { - limit, - sortKey: "recency_at", - sortDirection: "desc", - modelProviders: [], - archived, - useStateDbOnly: false, - ...(params.cursor?.trim() ? { cursor: params.cursor.trim() } : {}), - ...(params.cwd?.trim() ? { cwd: params.cwd.trim() } : {}), - }); - if (!isRecord(listed) || !Array.isArray(listed.data)) { - throw new Error("Codex thread/list returned an invalid response"); - } - const searchTerm = params.searchTerm?.trim(); - const sessions = asRecordArray(listed.data) - .slice(0, limit) - .flatMap((thread) => { - const session = toCatalogSession(thread, archived); - return session ? [session] : []; - }) - // Codex's state query also searches transcript-derived preview text. - // Filter normalized titles here so catalog search cannot probe previews. - .filter((session) => !searchTerm || session.name?.includes(searchTerm)); - const nextCursor = boundedCatalogCursor(listed.nextCursor); - const backwardsCursor = boundedCatalogCursor(listed.backwardsCursor); - return { - sessions, - ...(nextCursor ? { nextCursor } : {}), - ...(backwardsCursor ? { backwardsCursor } : {}), - }; - } catch (error) { - this.forgetEndpoint(endpointId); - throw error; - } - } - - /** Reads a single Codex session transcript from the resolved endpoint. */ - async readSession(params: { - endpointId?: string; - threadId: string; - includeTurns?: boolean; - }): Promise> { - const endpointId = await this.resolveEndpointId(params); - const connection = await this.connectionFor(endpointId); - try { - const result = await this.readThread( - connection, - params.threadId, - params.includeTurns === true, - ); - if (!isRecord(result)) { - throw new Error("Codex thread/read returned a non-object response"); - } - return result; - } catch (error) { - this.forgetEndpoint(endpointId); - throw error; - } - } - - /** Starts a new turn or steers an active turn depending on requested mode. */ - async sendToSession(params: { - endpointId?: string; - threadId: string; - text: string; - mode?: CodexSupervisorTurnMode; - }): Promise { - const endpointId = await this.resolveEndpointId(params); - const connection = await this.connectionFor(endpointId); - try { - const mode = params.mode ?? "auto"; - if (mode === "start") { - return await this.startTurn(connection, endpointId, params.threadId, params.text); - } - - const read = await this.readThread(connection, params.threadId, false); - const thread = extractThread(read); - if (!thread) { - throw new Error(`Codex thread not found: ${params.threadId}`); - } - const status = getStatusType(thread); - if (mode === "steer" || status === "active") { - const detailed = await this.readThread(connection, params.threadId, true); - const detailedThread = extractThread(detailed); - // Active-turn ids may appear in full thread turns or the summary API; - // try both before failing so steering handles materialized and lazy turns. - const turnId = - (detailedThread ? findInProgressTurnId(detailedThread) : undefined) ?? - findInProgressTurnId(thread) ?? - (await this.readActiveTurnId(connection, params.threadId)); - if (!turnId) { - throw new Error( - `Codex thread ${params.threadId} is active but no in-progress turn is readable`, - ); - } - await connection.request("turn/steer", { - threadId: params.threadId, - expectedTurnId: turnId, - input: [{ type: "text", text: params.text, text_elements: [] }], - }); - return { endpointId, threadId: params.threadId, mode: "steer", turnId, status }; - } - return await this.startTurn(connection, endpointId, params.threadId, params.text); - } catch (error) { - this.forgetEndpoint(endpointId); - throw error; - } - } - - /** Interrupts an active Codex turn, resolving the turn id when omitted. */ - async interruptSession(params: { - endpointId?: string; - threadId: string; - turnId?: string; - }): Promise<{ endpointId: string; threadId: string; turnId: string }> { - const endpointId = await this.resolveEndpointId(params); - const connection = await this.connectionFor(endpointId); - try { - let turnId = params.turnId; - if (!turnId) { - const read = await this.readThread(connection, params.threadId, true); - const thread = extractThread(read); - turnId = - (thread ? findInProgressTurnId(thread) : undefined) ?? - (await this.readActiveTurnId(connection, params.threadId)); - } - if (!turnId) { - throw new Error(`Codex thread ${params.threadId} has no readable in-progress turn`); - } - await connection.request("turn/interrupt", { threadId: params.threadId, turnId }); - return { endpointId, threadId: params.threadId, turnId }; - } catch (error) { - this.forgetEndpoint(endpointId); - throw error; - } - } - - private async listEndpointSessions( - endpoint: CodexSupervisorEndpoint, - params: { includeStored?: boolean; maxStoredSessions?: number }, - ): Promise { - if (params.includeStored === true) { - const loaded = await this.listLoadedThreadSessions(endpoint); - const sessions = [...loaded]; - for (const stored of await this.listStoredThreadSessions( - endpoint, - params.maxStoredSessions, - )) { - // Loaded sessions are authoritative for attachment/status; append stored - // history only for threads that are not already live. - if (!sessions.some((session) => session.threadId === stored.threadId)) { - sessions.push(stored); - } - } - return sessions; - } - return await this.listLoadedThreadSessions(endpoint); - } - - private async listLoadedThreadSessions( - endpoint: CodexSupervisorEndpoint, - ): Promise { - const sessions: CodexSupervisorSession[] = []; - const connection = await this.connectionFor(endpoint.id); - let cursor: string | undefined; - do { - const listed = await connection.request("thread/loaded/list", { - limit: 100, - ...(cursor ? { cursor } : {}), - }); - for (const threadId of extractStringList(listed)) { - if (sessions.some((entry) => entry.threadId === threadId)) { - continue; - } - const read = await this.readOptionalLoadedThread(connection, threadId); - const thread = extractThread(read); - const session = thread ? toSession(endpoint.id, thread, true) : undefined; - if (session) { - sessions.push(session); - } - } - cursor = - isRecord(listed) && typeof listed.nextCursor === "string" ? listed.nextCursor : undefined; - } while (cursor); - return sessions; - } - - private async listStoredThreadSessions( - endpoint: CodexSupervisorEndpoint, - maxStoredSessions = DEFAULT_MAX_STORED_SESSIONS, - ): Promise { - const sessionLimit = Number.isFinite(maxStoredSessions) - ? Math.min(1000, Math.max(1, Math.floor(maxStoredSessions))) - : DEFAULT_MAX_STORED_SESSIONS; - const sessions: CodexSupervisorSession[] = []; - const connection = await this.connectionFor(endpoint.id); - let cursor: string | undefined; - do { - const remaining = sessionLimit - sessions.length; - if (remaining <= 0) { - break; - } - const listed = await connection.request("thread/list", { - limit: Math.min(100, remaining), - sourceKinds: ALL_CODEX_THREAD_SOURCE_KINDS, - modelProviders: [], - sortKey: "recency_at", - sortDirection: "desc", - useStateDbOnly: true, - ...(cursor ? { cursor } : {}), - }); - for (const thread of extractThreadList(listed)) { - if (typeof thread.id !== "string") { - continue; - } - if ( - sessions.some((entry) => entry.endpointId === endpoint.id && entry.threadId === thread.id) - ) { - continue; - } - const session = toSession(endpoint.id, thread); - if (session) { - sessions.push(session); - if (sessions.length >= sessionLimit) { - break; - } - } - } - cursor = - isRecord(listed) && typeof listed.nextCursor === "string" ? listed.nextCursor : undefined; - } while (cursor); - return sessions; - } - - private async readOptionalLoadedThread( - connection: CodexJsonRpcConnection, - threadId: string, - ): Promise { - try { - return await this.readLoadedThread(connection, threadId, false); - } catch (error) { - if (isLoadedThreadReadMiss(error)) { - return undefined; - } - throw error; - } - } - - private async readLoadedThread( - connection: CodexJsonRpcConnection, - threadId: string, - includeTurns: boolean, - ): Promise { - try { - return await connection.request("thread/read", { threadId, includeTurns }); - } catch (error) { - if (!includeTurns) { - throw error; - } - const message = error instanceof Error ? error.message : String(error); - if (!message.includes("not materialized yet")) { - throw error; - } - return await connection.request("thread/read", { threadId, includeTurns: false }); - } - } - - private async startTurn( - connection: CodexJsonRpcConnection, - endpointId: string, - threadId: string, - text: string, - ): Promise { - const result = await connection.request("turn/start", { - threadId, - input: [{ type: "text", text, text_elements: [] }], - }); - const turn = isRecord(result) && isRecord(result.turn) ? result.turn : undefined; - return { - endpointId, - threadId, - mode: "start", - ...(typeof turn?.id === "string" ? { turnId: turn.id } : {}), - ...(typeof turn?.status === "string" ? { status: turn.status } : {}), - }; - } - - private async readThread( - connection: CodexJsonRpcConnection, - threadId: string, - includeTurns: boolean, - ): Promise { - return await this.readLoadedThread(connection, threadId, includeTurns); - } - - private async readActiveTurnId( - connection: CodexJsonRpcConnection, - threadId: string, - ): Promise { - try { - const response = await connection.request("thread/turns/list", { - threadId, - limit: 10, - sortDirection: "desc", - itemsView: "summary", - }); - return extractThreadList(response).find( - (turn) => turn.status === "inProgress" && typeof turn.id === "string", - )?.id as string | undefined; - } catch { - return undefined; - } - } - - private async resolveEndpointId(params: { - endpointId?: string; - threadId: string; - }): Promise { - if (params.endpointId) { - return params.endpointId; - } - const sessions = await this.listSessions(); - const matches = sessions.filter((session) => session.threadId === params.threadId); - if (matches.length === 1) { - return matches[0].endpointId; - } - if (matches.length > 1) { - throw new Error(`Codex thread id is ambiguous across endpoints: ${params.threadId}`); - } - const endpointIds = new Set(matches.map((match) => match.endpointId)); - for (const endpoint of this.endpoints) { - if (endpointIds.has(endpoint.id)) { - continue; - } - try { - const connection = await this.connectionFor(endpoint.id); - const read = await this.readThread(connection, params.threadId, false); - const thread = extractThread(read); - if (thread?.id === params.threadId) { - endpointIds.add(endpoint.id); - } - } catch (error) { - if (isLoadedThreadReadMiss(error)) { - continue; - } - this.forgetEndpoint(endpoint.id); - continue; - } - } - if (endpointIds.size === 1) { - for (const endpointId of endpointIds) { - return endpointId; - } - } - if (endpointIds.size > 1) { - throw new Error(`Codex thread id is ambiguous across endpoints: ${params.threadId}`); - } - throw new Error(`Codex thread not found: ${params.threadId}`); - } - - private async connectionFor(endpointId: string): Promise { - const endpoint = this.endpoints.find((entry) => entry.id === endpointId); - if (!endpoint) { - throw new Error(`Unknown Codex supervisor endpoint: ${endpointId}`); - } - const existing = this.connections.get(endpoint.id); - if (existing) { - return await existing; - } - const created = this.connector(endpoint); - this.connections.set(endpoint.id, created); - void created.catch(() => { - if (this.connections.get(endpoint.id) === created) { - this.connections.delete(endpoint.id); - } - }); - return await created; - } - - private forgetEndpoint(endpointId: string): void { - const existing = this.connections.get(endpointId); - if (!existing) { - return; - } - this.connections.delete(endpointId); - void existing.then((connection) => connection.close()).catch(() => undefined); - } -} diff --git a/extensions/codex-supervisor/src/types.ts b/extensions/codex-supervisor/src/types.ts deleted file mode 100644 index 88f1b81ab524..000000000000 --- a/extensions/codex-supervisor/src/types.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Public Codex Supervisor endpoint, session, and JSON-RPC connection types. - */ -/** Configured transport target for a Codex app-server endpoint. */ -export type CodexSupervisorEndpoint = - | { - id: string; - label?: string; - transport: "stdio-proxy"; - command?: string; - args?: string[]; - cwd?: string; - } - | { - id: string; - label?: string; - transport: "websocket"; - url: string; - authTokenEnv?: string; - }; - -/** Send behavior requested by supervisor write tools. */ -export type CodexSupervisorTurnMode = "auto" | "start" | "steer"; - -/** App-server thread status string, preserved for forward compatibility. */ -export type CodexSupervisorThreadStatus = string; - -/** Normalized session summary returned by supervisor list operations. */ -export type CodexSupervisorSession = { - endpointId: string; - threadId: string; - sessionId?: string; - cwd?: string; - preview?: string; - name?: string | null; - source?: string; - status: CodexSupervisorThreadStatus; - updatedAt?: number; - humanAttached?: boolean; -}; - -/** Result returned after starting or steering a Codex turn. */ -export type CodexSupervisorSendResult = { - endpointId: string; - threadId: string; - mode: "start" | "steer"; - turnId?: string; - status?: string; -}; - -/** Minimal JSON-RPC connection contract used by the supervisor. */ -export type CodexJsonRpcConnection = { - request(method: string, params?: Record): Promise; - notify(method: string, params?: Record): void; - close(): Promise; -}; - -/** Health result for one configured supervisor endpoint. */ -export type CodexSupervisorEndpointHealth = { - endpointId: string; - ok: boolean; - detail?: string; -}; - -/** Session list plus endpoint errors for tool-friendly structured output. */ -export type CodexSupervisorSessionListResult = { - sessions: CodexSupervisorSession[]; - errors: CodexSupervisorEndpointHealth[]; -}; - -/** Read-only metadata for one Codex app-server thread. */ -export type CodexSessionCatalogSession = { - threadId: string; - sessionId?: string; - name?: string | null; - cwd?: string; - status: CodexSupervisorThreadStatus; - activeFlags?: string[]; - createdAt?: number; - updatedAt?: number; - recencyAt?: number | null; - source?: string; - modelProvider?: string; - cliVersion?: string; - gitBranch?: string; - archived: boolean; -}; - -/** One page returned by a node-local Codex app-server catalog command. */ -export type CodexSessionCatalogPage = { - sessions: CodexSessionCatalogSession[]; - nextCursor?: string; - backwardsCursor?: string; -}; - -/** Parameters accepted by the node-local Codex app-server catalog command. */ -export type CodexSessionCatalogPageParams = { - cursor?: string; - limit?: number; - archived?: boolean; - searchTerm?: string; - cwd?: string; -}; - -/** Per-origin error exposed by the read-only session catalog. */ -export type CodexSessionCatalogError = { - code: string; - message: string; -}; - -/** Gateway-local or paired-node session catalog origin. */ -export type CodexSessionCatalogHost = { - hostId: string; - label: string; - kind: "gateway" | "node"; - connected: boolean; - nodeId?: string; - endpointId?: string; - sessions: CodexSessionCatalogSession[]; - nextCursor?: string; - backwardsCursor?: string; - error?: CodexSessionCatalogError; -}; - -/** Host-grouped result returned by the Gateway session catalog method. */ -export type CodexSessionCatalogResult = { - hosts: CodexSessionCatalogHost[]; -}; - -/** Parameters accepted by the Gateway session catalog method. */ -export type CodexSessionCatalogParams = { - search?: string; - archived?: boolean; - limitPerHost?: number; - hostIds?: string[]; - cursors?: Record; -}; diff --git a/extensions/codex-supervisor/tsconfig.json b/extensions/codex-supervisor/tsconfig.json deleted file mode 100644 index b8a85a99ac3d..000000000000 --- a/extensions/codex-supervisor/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../tsconfig.package-boundary.base.json", - "compilerOptions": { - "rootDir": "." - }, - "include": ["./*.ts", "./src/**/*.ts"], - "exclude": [ - "./**/*.test.ts", - "./dist/**", - "./node_modules/**", - "./src/test-support/**", - "./src/**/*test-helpers.ts", - "./src/**/*test-harness.ts", - "./src/**/*test-support.ts" - ] -} diff --git a/extensions/codex/README.md b/extensions/codex/README.md index b60739ac021a..4fecf0b152a1 100644 --- a/extensions/codex/README.md +++ b/extensions/codex/README.md @@ -1,11 +1,35 @@ # OpenClaw Codex -Official OpenClaw provider and harness plugin for OpenAI Codex app-server integration. It exposes the Codex-managed GPT model catalog and the Codex runtime surfaces used by OpenClaw agents. +Official OpenClaw plugin for OpenAI Codex app-server integration. It exposes the Codex-managed GPT model catalog, the Codex runtime surfaces used by OpenClaw agents, and opt-in supervision of native Codex sessions. Install from OpenClaw: ```bash -openclaw plugin add @openclaw/codex +openclaw plugins install @openclaw/codex ``` -Use this plugin when you want OpenClaw to run Codex-backed model turns, media understanding, and prompt overlays through the Codex app-server harness. +Use this plugin when you want OpenClaw to run Codex-backed model turns, media understanding, and prompt overlays through the Codex app-server harness, or to list non-archived Codex Desktop and CLI source sessions and branch from eligible local sessions in OpenClaw Chat. + +Guided onboarding attempts to install and enable supervision after it detects a native Codex installation and the selected inference backend passes its live check; Codex does not need to be the primary backend. Supervision activates when that opportunistic plugin setup succeeds. App Server availability is checked when supervision connects. An explicit Codex plugin disable, plugin-policy block, or `supervision.enabled: false` prevents opportunistic enablement. Manual setups enable `plugins.entries.codex.config.supervision.enabled`. Without explicit App Server connection settings, supervision uses a managed user-home stdio connection; explicit `appServer` settings are honored. + +The Gateway-backed operator CLI is: + +```bash +openclaw codex sessions [--search ] [--host ] [--limit ] [--cursor ] [--json] [--url ] [--token ] [--timeout ] [--expect-final] +openclaw codex continue [--json] [--url ] [--token ] [--timeout ] [--expect-final] +openclaw codex archive --confirm-no-other-runner [--json] [--url ] [--token ] [--timeout ] [--expect-final] +``` + +The catalog never includes archived threads and has no archived or include-archived option. `--limit` defaults to 50 sessions per host, `--cursor` requires `--host`, and the Gateway timeout defaults to 30,000 ms. All three commands require `operator.write`. Paired-node rows are list-only; continue and archive operate only on the Gateway-local host, and archive requires the no-other-runner confirmation. + +A supervised OpenClaw Chat cannot be deleted while its model-selection lock protects the native binding. Before native archive, OpenClaw checks the exact target and every non-archived spawned descendant reported by Codex; any active OpenClaw binding blocks the operation. Descendant pagination errors, cycles, and safety-limit exhaustion also fail closed. Codex still does not expose a conditional archive operation or cross-process runner lease, so the confirmation covers unknown native clients and the race between the status read and archive request. + +Disabling or uninstalling the plugin leaves supervised Chats locked and unavailable rather than rerouting them. Reinstall or re-enable the same plugin and restart the Gateway to resume those Chats. + +These shell commands differ from the in-chat `/codex` runtime commands. In particular, `/codex sessions --host ` lists Codex CLI session files on one node, `/codex threads` uses the current conversation's App Server connection, and `/codex resume` or `/codex bind` changes that conversation's binding. There is no `/codex archive` runtime command. + +For a supervised branch, Codex App Server selects the snapshot fork's model and provider from its current native configuration. OpenClaw starts the canonical harness thread with exactly that returned pair. Codex persists the canonical thread's native selection, and later resumes preserve it because OpenClaw omits model and provider overrides. OpenClaw cannot substitute its outer runtime, model, or fallback. The returned initial pair can differ from the source's last recorded model. + +The visible-history mirror keeps at most 200 user or assistant messages, 512 KiB total, and 64 KiB per message. Image inputs become `[Image attachment]`; image data and local paths are not copied. + +See the [Codex harness](https://docs.openclaw.ai/plugins/codex-harness) and [Codex supervision](https://docs.openclaw.ai/plugins/codex-supervision) guides. diff --git a/extensions/codex-supervisor/cli-metadata.test.ts b/extensions/codex/cli-metadata.test.ts similarity index 54% rename from extensions/codex-supervisor/cli-metadata.test.ts rename to extensions/codex/cli-metadata.test.ts index 755c8e245852..2436a1024ee9 100644 --- a/extensions/codex-supervisor/cli-metadata.test.ts +++ b/extensions/codex/cli-metadata.test.ts @@ -1,24 +1,24 @@ -// Codex Supervisor tests cover lightweight CLI discovery and lazy registration. +// Codex CLI metadata tests cover lightweight discovery and lazy registration. import { Command } from "commander"; import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; import { describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - registerCodexSupervisorCli: vi.fn(), + registerCodexSessionCli: vi.fn(), })); -vi.mock("./src/cli.js", () => ({ - registerCodexSupervisorCli: mocks.registerCodexSupervisorCli, +vi.mock("./src/session-cli.js", () => ({ + registerCodexSessionCli: mocks.registerCodexSessionCli, })); import entry from "./cli-metadata.js"; -describe("codex-supervisor CLI metadata entry", () => { - it("advertises codex and loads its registrar only when invoked", async () => { +describe("codex CLI metadata entry", () => { + it("advertises codex and loads its session registrar only when invoked", async () => { const registerCli = vi.fn(); const api = createTestPluginApi({ - id: "codex-supervisor", - name: "Codex Supervisor", + id: "codex", + name: "Codex", registerCli, }); @@ -28,16 +28,16 @@ describe("codex-supervisor CLI metadata entry", () => { descriptors: [ { name: "codex", - description: "Inspect Codex sessions across the Gateway and paired nodes", + description: "Inspect and branch from Codex sessions through the Gateway", hasSubcommands: true, }, ], }); - expect(mocks.registerCodexSupervisorCli).not.toHaveBeenCalled(); + expect(mocks.registerCodexSessionCli).not.toHaveBeenCalled(); const registrar = registerCli.mock.calls[0]?.[0]; if (typeof registrar !== "function") { - throw new Error("expected codex-supervisor CLI registrar"); + throw new Error("expected Codex CLI registrar"); } const program = new Command(); await registrar({ @@ -48,6 +48,6 @@ describe("codex-supervisor CLI metadata entry", () => { logger: api.logger, }); - expect(mocks.registerCodexSupervisorCli).toHaveBeenCalledWith(program); + expect(mocks.registerCodexSessionCli).toHaveBeenCalledWith(program); }); }); diff --git a/extensions/codex/cli-metadata.ts b/extensions/codex/cli-metadata.ts new file mode 100644 index 000000000000..da3cd02a6db9 --- /dev/null +++ b/extensions/codex/cli-metadata.ts @@ -0,0 +1,27 @@ +// Codex CLI metadata stays lightweight until the command runs. +import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; + +export function registerCodexCliMetadata(api: OpenClawPluginApi): void { + api.registerCli( + async ({ program }) => { + const { registerCodexSessionCli } = await import("./src/session-cli.js"); + registerCodexSessionCli(program); + }, + { + descriptors: [ + { + name: "codex", + description: "Inspect and branch from Codex sessions through the Gateway", + hasSubcommands: true, + }, + ], + }, + ); +} + +export default definePluginEntry({ + id: "codex", + name: "Codex", + description: "Codex app-server harness and native session supervision.", + register: registerCodexCliMetadata, +}); diff --git a/extensions/codex/harness.test.ts b/extensions/codex/harness.test.ts index 6ff3c10e44b3..1efc15649db1 100644 --- a/extensions/codex/harness.test.ts +++ b/extensions/codex/harness.test.ts @@ -19,6 +19,10 @@ describe("Codex agent harness supports()", () => { }); }); + it("delegates locked-session execution only to the voice-call plugin", () => { + expect(harness.delegatedExecutionPluginIds).toEqual(["voice-call"]); + }); + it("supports openai as the primary OpenClaw routing id", () => { expect(harness.supports({ provider: "openai", requestedRuntime: "codex" })).toEqual({ supported: true, diff --git a/extensions/codex/harness.ts b/extensions/codex/harness.ts index 4c869d5a179f..d047af6047d8 100644 --- a/extensions/codex/harness.ts +++ b/extensions/codex/harness.ts @@ -56,6 +56,7 @@ export function createCodexAppServerAgentHarness(options: { const harness: CodexAppServerAgentHarness = { id: options?.id ?? "codex", label: options?.label ?? "Codex agent harness", + delegatedExecutionPluginIds: ["voice-call"], contextEngineHostCapabilities: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES, deliveryDefaults: { sourceVisibleReplies: "message_tool", diff --git a/extensions/codex/index.test.ts b/extensions/codex/index.test.ts index 0af01c09c3b9..7d4a983c0c8e 100644 --- a/extensions/codex/index.test.ts +++ b/extensions/codex/index.test.ts @@ -12,6 +12,7 @@ import { createCodexTestBindingStateStore, testCodexAppServerBindingStore, } from "./src/app-server/session-binding.test-helpers.js"; +import { CODEX_SUPERVISION_COMPAT_TOOL_NAMES } from "./src/supervision-tools.js"; const runCodexAppServerAttemptMock = vi.hoisted(() => vi.fn()); const runCodexAppServerSideQuestionMock = vi.hoisted(() => vi.fn()); @@ -126,6 +127,9 @@ describe("codex plugin", () => { expect(migrationRegistration?.id).toBe("codex"); expect(migrationRegistration?.label).toBe("Codex"); expect(registerTool).toHaveBeenCalledWith(expect.any(Function), { name: "codex_threads" }); + expect(registerTool).not.toHaveBeenCalledWith(expect.any(Function), { + names: [...CODEX_SUPERVISION_COMPAT_TOOL_NAMES], + }); expect(registerToolMetadata).toHaveBeenCalledWith( expect.objectContaining({ toolName: "codex_threads", risk: "high" }), ); @@ -134,6 +138,176 @@ describe("codex plugin", () => { expect(typeof bindingResolvedRegistration?.[0]).toBe("function"); }); + it("registers the five shipped supervision tools only when supervision is enabled", () => { + const registerTool = vi.fn(); + plugin.register( + createTestPluginApi({ + id: "codex", + name: "Codex", + source: "test", + config: {}, + pluginConfig: { supervision: { enabled: true } }, + runtime: createCodexTestRuntime(), + registerAgentHarness: vi.fn(), + registerCommand: vi.fn(), + registerMediaUnderstandingProvider: vi.fn(), + registerMigrationProvider: vi.fn(), + registerProvider: vi.fn(), + registerTool, + on: vi.fn(), + }), + ); + + const registration = registerTool.mock.calls.find(([, options]) => + Array.isArray(options?.names), + ) as + | [(context: { senderIsOwner?: boolean }) => Array<{ name: string }>, { names: string[] }] + | undefined; + expect(registration?.[1]).toEqual({ names: [...CODEX_SUPERVISION_COMPAT_TOOL_NAMES] }); + expect(registration?.[0]({ senderIsOwner: true }).map((tool) => tool.name)).toEqual([ + ...CODEX_SUPERVISION_COMPAT_TOOL_NAMES, + ]); + expect(registration?.[0]({ senderIsOwner: false })).toEqual([]); + expect(registration?.[0]({})).toEqual([]); + }); + + it("activates from live supervision config through a normalized Codex entry id", () => { + const registerTool = vi.fn(); + plugin.register( + createTestPluginApi({ + id: "codex", + name: "Codex", + source: "test", + config: {}, + pluginConfig: {}, + runtime: createCodexTestRuntime(() => ({ + plugins: { + entries: { + " CODEX ": { + config: { supervision: { enabled: true } }, + }, + }, + }, + })), + registerAgentHarness: vi.fn(), + registerCommand: vi.fn(), + registerMediaUnderstandingProvider: vi.fn(), + registerMigrationProvider: vi.fn(), + registerProvider: vi.fn(), + registerTool, + on: vi.fn(), + }), + ); + + expect(registerTool.mock.calls.some(([, options]) => Array.isArray(options?.names))).toBe(true); + }); + + it.each([ + ["plugin entry is removed", { plugins: { entries: {} } }], + [ + "plugin entry is disabled", + { + plugins: { + entries: { + codex: { enabled: false, config: { supervision: { enabled: true } } }, + }, + }, + }, + ], + [ + "global plugin loading is disabled", + { + plugins: { + enabled: false, + entries: { + codex: { enabled: true, config: { supervision: { enabled: true } } }, + }, + }, + }, + ], + [ + "a restrictive allowlist omits Codex", + { + plugins: { + allow: ["other-plugin"], + entries: { + codex: { enabled: true, config: { supervision: { enabled: true } } }, + }, + }, + }, + ], + [ + "the denylist blocks Codex", + { + plugins: { + deny: ["codex"], + entries: { + codex: { enabled: true, config: { supervision: { enabled: true } } }, + }, + }, + }, + ], + [ + "supervision is explicitly disabled", + { + plugins: { + entries: { + codex: { enabled: true, config: { supervision: { enabled: false } } }, + }, + }, + }, + ], + ] as const)("revokes supervision live when %s", async (_label, revokedConfig) => { + const registerTool = vi.fn(); + let liveConfig: unknown = { + plugins: { + entries: { + codex: { enabled: true, config: { supervision: { enabled: true } } }, + }, + }, + }; + plugin.register( + createTestPluginApi({ + id: "codex", + name: "Codex", + source: "test", + config: {}, + pluginConfig: { supervision: { enabled: true } }, + runtime: createCodexTestRuntime(() => liveConfig), + registerAgentHarness: vi.fn(), + registerCommand: vi.fn(), + registerMediaUnderstandingProvider: vi.fn(), + registerMigrationProvider: vi.fn(), + registerProvider: vi.fn(), + registerTool, + on: vi.fn(), + }), + ); + const registration = registerTool.mock.calls.find(([, options]) => + Array.isArray(options?.names), + ) as + | [ + (context: { senderIsOwner?: boolean }) => Array<{ + name: string; + execute(callId: string, params: object): Promise; + }>, + { names: string[] }, + ] + | undefined; + const probe = registration?.[0]({ senderIsOwner: true }).find( + (tool) => tool.name === "codex_endpoint_probe", + ); + if (!probe) { + throw new Error("missing Codex endpoint probe tool"); + } + + liveConfig = revokedConfig; + + await expect(probe.execute("probe", {})).rejects.toThrow( + "Codex supervision is disabled in the codex plugin config.", + ); + }); + it("registers with capture APIs that do not expose conversation binding hooks yet", () => { const registerProvider = vi.fn(); const api = createTestPluginApi({ @@ -393,6 +567,7 @@ describe("codex plugin", () => { plugins: { entries: { codex: { + enabled: true, config: { codexPlugins: { enabled: true, diff --git a/extensions/codex/index.ts b/extensions/codex/index.ts index 852db9b76820..6396a14a1553 100644 --- a/extensions/codex/index.ts +++ b/extensions/codex/index.ts @@ -4,11 +4,17 @@ */ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation"; -import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime"; +import { + normalizePluginsConfig, + resolveEffectiveEnableState, + resolveLivePluginConfigObject, +} from "openclaw/plugin-sdk/plugin-config-runtime"; import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { registerCodexCliMetadata } from "./cli-metadata.js"; import { createCodexAppServerAgentHarness } from "./harness.js"; import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { buildCodexProvider } from "./provider.js"; +import { readCodexPluginConfig } from "./src/app-server/config.js"; import { CODEX_APP_SERVER_BINDING_MAX_ENTRIES, CODEX_APP_SERVER_BINDING_NAMESPACE, @@ -30,6 +36,16 @@ import { resumeCodexCliSessionOnNode, resolveCodexCliSessionForBindingOnNode, } from "./src/node-cli-sessions.js"; +import { + createCodexSessionCatalogControl, + createCodexSessionCatalogNodeHostCommands, + createCodexSessionCatalogNodeInvokePolicies, + registerCodexSessionCatalogGateway, +} from "./src/session-catalog.js"; +import { + CODEX_SUPERVISION_COMPAT_TOOL_NAMES, + createCodexSupervisionTools, +} from "./src/supervision-tools.js"; import { createCodexWebSearchProvider } from "./src/web-search-provider.js"; const ENDED_SESSION_REASONS: ReadonlySet = new Set([ @@ -43,18 +59,36 @@ const ENDED_SESSION_REASONS: ReadonlySet = new Set([ export default definePluginEntry({ id: "codex", name: "Codex", - description: "Codex app-server harness and Codex-managed GPT model catalog.", + description: + "Codex app-server harness, Codex-managed GPT catalog, and native session supervision.", register(api) { const resolveCurrentConfig = () => api.runtime.config?.current ? (api.runtime.config.current() as OpenClawConfig) : undefined; - const resolveCurrentPluginConfig = () => - // Codex plugin config can change at runtime; resolve from live config for - // harness attempts and binding claims instead of keeping startup values. - resolveLivePluginConfigObject( - resolveCurrentConfig, + const resolvePluginConfig = (resolveConfig: () => OpenClawConfig | undefined) => { + const liveConfig = resolveConfig(); + // Codex plugin config can change at runtime. A missing live entry is an + // explicit removal, while an unavailable runtime snapshot uses startup config. + if (!liveConfig) { + return api.pluginConfig; + } + const livePluginConfig = resolveLivePluginConfigObject( + () => liveConfig, "codex", api.pluginConfig as Record, - ) ?? api.pluginConfig; + ); + const enabled = resolveEffectiveEnableState({ + id: "codex", + origin: "bundled", + config: normalizePluginsConfig(liveConfig.plugins), + rootConfig: liveConfig, + enabledByDefault: readCodexPluginConfig(livePluginConfig).supervision?.enabled === true, + }).enabled; + if (!enabled) { + return undefined; + } + return livePluginConfig; + }; + const resolveCurrentPluginConfig = () => resolvePluginConfig(resolveCurrentConfig); const bindingStore = createLazyCodexAppServerBindingStore( api.runtime.state.openSyncKeyedStore({ namespace: CODEX_APP_SERVER_BINDING_NAMESPACE, @@ -62,6 +96,43 @@ export default definePluginEntry({ overflowPolicy: "reject-new", }), ); + registerCodexCliMetadata(api); + if (readCodexPluginConfig(resolveCurrentPluginConfig()).supervision?.enabled === true) { + const sessionCatalogControl = createCodexSessionCatalogControl({ + getPluginConfig: resolveCurrentPluginConfig, + getRuntimeConfig: resolveCurrentConfig, + }); + registerCodexSessionCatalogGateway({ + api, + bindingStore, + control: sessionCatalogControl, + getRuntimeConfig: resolveCurrentConfig, + }); + for (const command of createCodexSessionCatalogNodeHostCommands(sessionCatalogControl)) { + api.registerNodeHostCommand(command); + } + for (const policy of createCodexSessionCatalogNodeInvokePolicies()) { + api.registerNodeInvokePolicy(policy); + } + api.registerTool( + (context) => { + if (context.senderIsOwner !== true) { + return []; + } + const resolveToolRuntimeConfig = () => + context.getRuntimeConfig?.() ?? + context.runtimeConfig ?? + context.config ?? + resolveCurrentConfig(); + return createCodexSupervisionTools({ + getPluginConfig: () => resolvePluginConfig(resolveToolRuntimeConfig), + getRuntimeConfig: resolveToolRuntimeConfig, + senderIsOwner: context.senderIsOwner, + }); + }, + { names: [...CODEX_SUPERVISION_COMPAT_TOOL_NAMES] }, + ); + } api.registerAgentHarness( createCodexAppServerAgentHarness({ bindingStore, diff --git a/extensions/codex/openclaw.plugin.json b/extensions/codex/openclaw.plugin.json index 6b5b9d54b0f2..bf84e932b952 100644 --- a/extensions/codex/openclaw.plugin.json +++ b/extensions/codex/openclaw.plugin.json @@ -1,12 +1,19 @@ { "id": "codex", "name": "Codex", - "description": "OpenClaw Codex app-server harness and model provider plugin with a Codex-managed GPT catalog.", + "description": "OpenClaw Codex app-server harness, model provider, and native session supervision plugin.", "providers": ["codex"], "contracts": { "mediaUnderstandingProviders": ["codex"], "migrationProviders": ["codex"], - "tools": ["codex_threads"], + "tools": [ + "codex_threads", + "codex_endpoint_probe", + "codex_sessions_list", + "codex_session_read", + "codex_session_send", + "codex_session_interrupt" + ], "webSearchProviders": ["codex"] }, "mediaUnderstandingProviderMetadata": { @@ -22,7 +29,9 @@ "nonSecretAuthMarkers": ["codex-app-server"], "activation": { "onStartup": false, - "onAgentHarnesses": ["codex"] + "onAgentHarnesses": ["codex"], + "onCommands": ["codex"], + "onConfigPaths": ["plugins.entries.codex.config.supervision.enabled"] }, "commandAliases": [ { @@ -133,6 +142,46 @@ } } }, + "supervision": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean", "default": false }, + "endpoints": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "label": { "type": "string" }, + "transport": { "const": "stdio-proxy" }, + "command": { "type": "string" }, + "args": { "type": "array", "items": { "type": "string" } }, + "cwd": { "type": "string" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["transport", "url"], + "properties": { + "id": { "type": "string" }, + "label": { "type": "string" }, + "transport": { "const": "websocket" }, + "url": { "type": "string" }, + "authTokenEnv": { "type": "string" } + } + } + ] + } + }, + "allowRawTranscripts": { "type": "boolean", "default": false }, + "allowWriteControls": { "type": "boolean", "default": false } + } + }, "appServer": { "type": "object", "additionalProperties": false, @@ -143,7 +192,7 @@ }, "transport": { "type": "string", - "enum": ["stdio", "websocket"], + "enum": ["stdio", "websocket", "unix"], "default": "stdio" }, "homeScope": { @@ -366,6 +415,30 @@ "help": "Explicit migration-authored plugin entries. The wildcard key * is not supported.", "advanced": true }, + "supervision": { + "label": "Codex Supervision", + "help": "Expose non-archived Codex Desktop and CLI sessions through a separate supervision connection without changing the ordinary Codex harness.", + "advanced": true + }, + "supervision.enabled": { + "label": "Enable Codex Supervision", + "help": "List non-archived native sessions through managed user-home stdio by default; explicit App Server connection settings are honored." + }, + "supervision.endpoints": { + "label": "Legacy Supervisor Endpoints", + "help": "Advanced compatibility endpoints for migrated Codex Supervisor agent tools.", + "advanced": true + }, + "supervision.allowRawTranscripts": { + "label": "Allow Raw Transcript Tools", + "help": "Allow supervision agent tools, including turn-inclusive codex_threads reads, to expose full transcripts and transcript previews.", + "advanced": true + }, + "supervision.allowWriteControls": { + "label": "Allow Write Control Tools", + "help": "Allow supervision agent tools to control turns and let codex_threads fork, rename, archive, or unarchive native threads.", + "advanced": true + }, "appServer": { "label": "App Server", "help": "Runtime controls for connecting to Codex app-server.", @@ -378,7 +451,7 @@ }, "appServer.transport": { "label": "Transport", - "help": "Use stdio to spawn Codex locally, or websocket to connect to an already-running app-server.", + "help": "Use stdio to spawn Codex locally, unix for the shared local control socket, or websocket for a remote app-server.", "advanced": true }, "appServer.homeScope": { diff --git a/extensions/codex/package.json b/extensions/codex/package.json index 89b20c8843a2..7a9e55efdf00 100644 --- a/extensions/codex/package.json +++ b/extensions/codex/package.json @@ -1,7 +1,7 @@ { "name": "@openclaw/codex", "version": "2026.7.2", - "description": "OpenClaw Codex app-server harness and model provider plugin with a Codex-managed GPT catalog.", + "description": "OpenClaw Codex app-server harness, model provider, and native session supervision plugin.", "repository": { "type": "git", "url": "https://github.com/openclaw/openclaw" diff --git a/extensions/codex/src/app-server/attempt-startup.ts b/extensions/codex/src/app-server/attempt-startup.ts index e439868be073..6451aaffb52b 100644 --- a/extensions/codex/src/app-server/attempt-startup.ts +++ b/extensions/codex/src/app-server/attempt-startup.ts @@ -104,7 +104,7 @@ export async function startCodexAttemptThread(params: { appServer: CodexAppServerRuntimeOptions; pluginConfig: CodexPluginConfig; computerUseConfig: CodexComputerUseConfig; - startupAuthProfileId: string | undefined; + startupAuthProfileId: string | null | undefined; startupAuthAccountCacheKey: string | undefined; startupEnvApiKeyCacheKey: string | undefined; agentDir: string; @@ -132,6 +132,7 @@ export async function startCodexAttemptThread(params: { spawnedBy: EmbeddedRunAttemptParams["spawnedBy"]; }): Promise { let pluginAppServer = params.appServer; + const startupRuntimeAuthProfileId = params.startupAuthProfileId ?? undefined; let releaseSharedClientLease: (() => void) | undefined; let startupClientForAbandonedRequestCleanup: CodexAppServerClient | undefined; let releaseStartupResourcesOnTimeout: (() => Promise) | undefined; @@ -170,8 +171,7 @@ export async function startCodexAttemptThread(params: { : undefined; const computerUseMcpElicitationDelegationRequired = params.computerUseConfig.enabled; const mcpElicitationDelegationRequired = - resolvedPluginPolicy?.enabled === true || - computerUseMcpElicitationDelegationRequired; + resolvedPluginPolicy?.enabled === true || computerUseMcpElicitationDelegationRequired; const enabledPluginConfigKeys = resolvedPluginPolicy ? resolvedPluginPolicy.pluginPolicies .filter((plugin) => plugin.enabled) @@ -227,7 +227,7 @@ export async function startCodexAttemptThread(params: { } ensureCodexAppServerClientRuntime(activeStartupClient, { agentDir: params.agentDir, - authProfileId: params.startupAuthProfileId, + authProfileId: startupRuntimeAuthProfileId, config: params.config, }); const turnRouter = getCodexAppServerTurnRouter(activeStartupClient); @@ -241,7 +241,7 @@ export async function startCodexAttemptThread(params: { const pluginAppCacheKey = buildCodexPluginAppCacheKey({ appServer: params.appServer, agentDir: params.agentDir, - authProfileId: params.startupAuthProfileId, + authProfileId: startupRuntimeAuthProfileId, accountId: params.startupAuthAccountCacheKey, envApiKeyFingerprint: params.startupEnvApiKeyCacheKey, appServerVersion: activeStartupClient.getServerVersion(), @@ -268,7 +268,7 @@ export async function startCodexAttemptThread(params: { resolvedPluginPolicy, enabledPluginConfigKeys, pluginAppCacheKey, - startupAuthProfileId: params.startupAuthProfileId, + startupAuthProfileId: startupRuntimeAuthProfileId, appServer: params.appServer, }), ); diff --git a/extensions/codex/src/app-server/binding-connection.test.ts b/extensions/codex/src/app-server/binding-connection.test.ts new file mode 100644 index 000000000000..da8e15695383 --- /dev/null +++ b/extensions/codex/src/app-server/binding-connection.test.ts @@ -0,0 +1,121 @@ +// Codex tests cover private binding connection selection. +import { describe, expect, it } from "vitest"; +import { + requireCodexSupervisionModelSelection, + resolveCodexBindingAppServerConnection, +} from "./binding-connection.js"; +import { resolveCodexSupervisionAppServerRuntimeOptions } from "./config.js"; +import { buildCodexAppServerConnectionFingerprint } from "./plugin-app-cache-key.js"; + +function supervisedBinding(pluginConfig: unknown) { + return { + connectionScope: "supervision" as const, + appServerRuntimeFingerprint: buildCodexAppServerConnectionFingerprint( + resolveCodexSupervisionAppServerRuntimeOptions({ + pluginConfig, + env: {}, + requirementsToml: null, + }), + ), + }; +} + +describe("Codex binding app-server connection", () => { + it("preserves ordinary harness runtime and auth ownership", () => { + const connection = resolveCodexBindingAppServerConnection({ + binding: {}, + authProfileId: "openai:work", + env: {}, + requirementsToml: null, + }); + + expect(connection.appServer.start.homeScope).toBe("agent"); + expect(connection.usesSupervisionConnection).toBe(false); + expect(connection.requestAuthProfileId).toBe("openai:work"); + expect(connection.clientAuthProfileId).toBe("openai:work"); + }); + + it("uses native user-home auth only for an enabled supervised binding", () => { + const connection = resolveCodexBindingAppServerConnection({ + binding: supervisedBinding({ supervision: { enabled: true } }), + authProfileId: "openai:work", + pluginConfig: { supervision: { enabled: true } }, + env: {}, + requirementsToml: null, + }); + + expect(connection.appServer.start.homeScope).toBe("user"); + expect(connection.usesSupervisionConnection).toBe(true); + expect(connection.requestAuthProfileId).toBeUndefined(); + expect(connection.clientAuthProfileId).toBeNull(); + }); + + it("requires the exact native model pair for materialized supervised requests", () => { + expect( + requireCodexSupervisionModelSelection({ + connectionScope: "supervision", + model: " gpt-5.5 ", + modelProvider: " openai ", + }), + ).toEqual({ model: "gpt-5.5", modelProvider: "openai" }); + + expect(() => + requireCodexSupervisionModelSelection({ + connectionScope: "supervision", + model: "gpt-5.5", + }), + ).toThrow("missing its native model and provider"); + }); + + it("preserves an explicit supervised WebSocket endpoint while selecting native auth", () => { + const connection = resolveCodexBindingAppServerConnection({ + binding: supervisedBinding({ + supervision: { enabled: true }, + appServer: { transport: "websocket", url: "ws://127.0.0.1:4500" }, + }), + pluginConfig: { + supervision: { enabled: true }, + appServer: { transport: "websocket", url: "ws://127.0.0.1:4500" }, + }, + env: {}, + requirementsToml: null, + }); + + expect(connection.appServer.start).toMatchObject({ + transport: "websocket", + homeScope: "agent", + url: "ws://127.0.0.1:4500", + }); + expect(connection.clientAuthProfileId).toBeNull(); + }); + + it("fails closed when a supervised binding remains after supervision is disabled", () => { + expect(() => + resolveCodexBindingAppServerConnection({ + binding: { connectionScope: "supervision" }, + pluginConfig: { supervision: { enabled: false } }, + env: {}, + requirementsToml: null, + }), + ).toThrow("Codex supervision is disabled"); + }); + + it("fails closed when a supervised binding connection changes", () => { + const binding = supervisedBinding({ + supervision: { enabled: true }, + appServer: { transport: "websocket", url: "ws://127.0.0.1:4500" }, + }); + + expect(() => + resolveCodexBindingAppServerConnection({ + binding, + pluginConfig: { + supervision: { enabled: true }, + appServer: { transport: "websocket", url: "ws://127.0.0.1:4600" }, + }, + env: {}, + requirementsToml: null, + }), + ).toThrow("supervision connection changed"); + }); +}); diff --git a/extensions/codex/src/app-server/binding-connection.ts b/extensions/codex/src/app-server/binding-connection.ts new file mode 100644 index 000000000000..c7d95f0638d8 --- /dev/null +++ b/extensions/codex/src/app-server/binding-connection.ts @@ -0,0 +1,88 @@ +// Codex helper module selects an app-server connection from private binding ownership. +import { + readCodexPluginConfig, + resolveCodexAppServerRuntimeOptions, + resolveCodexSupervisionAppServerRuntimeOptions, + type CodexAppServerRuntimeOptions, +} from "./config.js"; +import { buildCodexAppServerConnectionFingerprint } from "./plugin-app-cache-key.js"; +import type { CodexAppServerThreadBinding } from "./session-binding.js"; + +type CodexAppServerRuntimeOptionsParams = NonNullable< + Parameters[0] +>; + +export type CodexBindingAppServerConnection = { + appServer: CodexAppServerRuntimeOptions; + usesSupervisionConnection: boolean; + requestAuthProfileId: string | undefined; + clientAuthProfileId: string | null | undefined; +}; + +export type CodexSupervisionModelSelection = { + model: string; + modelProvider: string; +}; + +/** Requires the native model pair after a supervised pending branch has materialized. */ +export function requireCodexSupervisionModelSelection( + binding: Pick, +): CodexSupervisionModelSelection { + const model = binding.model?.trim(); + const modelProvider = binding.modelProvider?.trim(); + if (binding.connectionScope !== "supervision" || !model || !modelProvider) { + throw new Error( + "Codex supervised binding is missing its native model and provider; refusing request selection", + ); + } + return { model, modelProvider }; +} + +/** Resolves connection and auth ownership exclusively from the private thread binding. */ +export function resolveCodexBindingAppServerConnection( + params: CodexAppServerRuntimeOptionsParams & { + binding?: Pick< + CodexAppServerThreadBinding, + "appServerRuntimeFingerprint" | "connectionScope" | "pendingSupervisionBranch" + >; + authProfileId?: string; + }, +): CodexBindingAppServerConnection { + const { binding, authProfileId, ...runtimeParams } = params; + const usesSupervisionConnection = binding?.connectionScope === "supervision"; + if ( + usesSupervisionConnection && + readCodexPluginConfig(runtimeParams.pluginConfig).supervision?.enabled !== true + ) { + throw new Error( + "Codex supervision is disabled; refusing to open a native user-home supervised session", + ); + } + const appServer = ( + usesSupervisionConnection + ? resolveCodexSupervisionAppServerRuntimeOptions + : resolveCodexAppServerRuntimeOptions + )(runtimeParams); + if (usesSupervisionConnection) { + // Thread ids are connection-local. Every binding-owned operation must reject + // config drift before a copied id can reach another native Codex store. + const persistedFingerprint = + binding.pendingSupervisionBranch?.connectionFingerprint ?? + binding.appServerRuntimeFingerprint; + const currentFingerprint = buildCodexAppServerConnectionFingerprint( + appServer, + runtimeParams.agentDir, + ); + if (!persistedFingerprint || persistedFingerprint !== currentFingerprint) { + throw new Error( + "Codex supervision connection changed; refusing to operate on its bound native thread", + ); + } + } + return { + appServer, + usesSupervisionConnection, + requestAuthProfileId: usesSupervisionConnection ? undefined : authProfileId, + clientAuthProfileId: usesSupervisionConnection ? null : authProfileId, + }; +} diff --git a/extensions/codex/src/app-server/client.ts b/extensions/codex/src/app-server/client.ts index 4cd6f2803140..1c553c576651 100644 --- a/extensions/codex/src/app-server/client.ts +++ b/extensions/codex/src/app-server/client.ts @@ -185,7 +185,7 @@ export class CodexAppServerClient { if (startOptions.transport === "stdio" && startOptions.commandSource === "managed") { throw new Error("Managed Codex app-server start options must be resolved before spawn."); } - if (startOptions.transport === "websocket") { + if (startOptions.transport === "websocket" || startOptions.transport === "unix") { return new CodexAppServerClient(createWebSocketTransport(startOptions)); } return new CodexAppServerClient(createStdioTransport(startOptions)); diff --git a/extensions/codex/src/app-server/compact.test.ts b/extensions/codex/src/app-server/compact.test.ts index 3e2af756fa46..d420eaabf703 100644 --- a/extensions/codex/src/app-server/compact.test.ts +++ b/extensions/codex/src/app-server/compact.test.ts @@ -9,6 +9,8 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js"; import { maybeCompactCodexAppServerSession as maybeCompactCodexAppServerSessionImpl } from "./compact.js"; +import { resolveCodexSupervisionAppServerRuntimeOptions } from "./config.js"; +import { buildCodexAppServerConnectionFingerprint } from "./plugin-app-cache-key.js"; import type { CodexServerNotification } from "./protocol.js"; import { sessionBindingIdentity } from "./session-binding.js"; import { @@ -81,6 +83,25 @@ async function writeTestBinding( return sessionFile; } +async function writeSupervisedTestBinding( + options: Partial[1]> = {}, +): Promise { + return writeTestBinding({ + connectionScope: "supervision", + supervisionSourceThreadId: "source-thread-1", + preserveNativeModel: true, + conversationSourceTransferComplete: true, + model: "gpt-5.4", + modelProvider: "openai", + appServerRuntimeFingerprint: buildCodexAppServerConnectionFingerprint( + resolveCodexSupervisionAppServerRuntimeOptions({ + pluginConfig: { supervision: { enabled: true } }, + }), + ), + ...options, + }); +} + function startCompaction(sessionFile: string, options: { currentTokenCount?: number } = {}) { return maybeCompactCodexAppServerSession({ sessionId: "session-1", @@ -181,6 +202,70 @@ describe("maybeCompactCodexAppServerSession", () => { expect(details.completed).toBe(true); }); + it("uses the native supervision runtime and auth for supervised bindings", async () => { + const fake = createFakeCodexClient(); + const factory = vi.fn(async () => fake.client); + const sessionFile = await writeSupervisedTestBinding({ + authProfileId: "openai:binding-profile", + }); + + const result = requireCompactResult( + await maybeCompactCodexAppServerSession( + { + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile, + workspaceDir: tempDir, + trigger: "manual", + authProfileId: "openai:outer-profile", + }, + { + clientFactory: factory, + pluginConfig: { supervision: { enabled: true } }, + }, + ), + ); + + expect(result.ok).toBe(true); + expect(factory).toHaveBeenCalledWith( + expect.objectContaining({ + authProfileId: null, + startOptions: expect.objectContaining({ homeScope: "user" }), + }), + ); + }); + + it("fails closed when a supervised binding is no longer enabled", async () => { + const fake = createFakeCodexClient(); + const factory = vi.fn(async () => fake.client); + const sessionFile = await writeSupervisedTestBinding(); + + const result = requireCompactResult( + await maybeCompactCodexAppServerSession( + { + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile, + workspaceDir: tempDir, + trigger: "manual", + }, + { clientFactory: factory, pluginConfig: { supervision: { enabled: false } } }, + ), + ); + + expect(result).toEqual({ + ok: false, + compacted: false, + reason: + "Codex supervision is disabled; refusing to open a native user-home supervised session", + }); + expect(factory).not.toHaveBeenCalled(); + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: "thread-1", + connectionScope: "supervision", + }); + }); + it("skips native app-server compaction for automatic budget triggers", async () => { const fake = createFakeCodexClient(); setCodexAppServerClientFactoryForTest(async () => fake.client); @@ -972,6 +1057,54 @@ describe("maybeCompactCodexAppServerSession", () => { await expect(readCodexAppServerBinding(sessionFile)).resolves.toBeUndefined(); }); + it("never detaches an unconfirmed remote supervised thread", async () => { + const fake = createFakeCodexClient({ + autoCompleteCompaction: false, + rejectInterrupt: true, + }); + fake.closeAndWait.mockResolvedValueOnce(false); + const pluginConfig = { + supervision: { enabled: true }, + appServer: { transport: "websocket" as const, url: "ws://127.0.0.1:45001" }, + }; + const sessionFile = await writeSupervisedTestBinding({ + threadId: "thread-stuck-supervision", + appServerRuntimeFingerprint: buildCodexAppServerConnectionFingerprint( + resolveCodexSupervisionAppServerRuntimeOptions({ pluginConfig }), + ), + }); + + const pendingResult = maybeCompactCodexAppServerSession( + { + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile, + workspaceDir: tempDir, + trigger: "manual", + }, + { + clientFactory: async () => fake.client, + pluginConfig, + nativeCompletionTimeoutMs: 10, + nativeInterruptGraceMs: 10, + }, + ); + + const outcome = await Promise.race([ + pendingResult.then(() => "settled" as const), + new Promise<"pending">((resolve) => { + setTimeout(() => resolve("pending"), 100); + }), + ]); + + expect(outcome).toBe("pending"); + expect(fake.closeAndWait).toHaveBeenCalledOnce(); + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: "thread-stuck-supervision", + connectionScope: "supervision", + }); + }); + it("cancels a native compaction after the start request", async () => { const fake = createFakeCodexClient({ autoCompleteCompaction: false }); setCodexAppServerClientFactoryForTest(async () => fake.client); diff --git a/extensions/codex/src/app-server/compact.ts b/extensions/codex/src/app-server/compact.ts index b51042b49655..4b4946461e41 100644 --- a/extensions/codex/src/app-server/compact.ts +++ b/extensions/codex/src/app-server/compact.ts @@ -7,9 +7,10 @@ import { type CompactEmbeddedAgentSessionParams, type EmbeddedAgentCompactResult, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { resolveAgentDir, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; import { readCodexNotificationItem } from "./attempt-notifications.js"; +import { resolveCodexBindingAppServerConnection } from "./binding-connection.js"; import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js"; -import { resolveCodexAppServerRuntimeOptions } from "./config.js"; import { readCodexNotificationThreadId, readCodexNotificationTurnId, @@ -504,7 +505,6 @@ async function compactCodexNativeThread( if (nativeExecutionBlock) { return { ok: false, compacted: false, reason: nativeExecutionBlock }; } - const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig }); const bindingIdentity: CodexAppServerBindingIdentity = sessionBindingIdentity({ sessionId: params.sessionId, sessionKey: params.sessionKey, @@ -520,7 +520,30 @@ async function compactCodexNativeThread( } let binding = initialBinding; const requestedAuthProfileId = params.authProfileId?.trim() || undefined; + let connection: ReturnType; + try { + const config = params.config ?? {}; + const agentId = + params.agentId ?? + readAgentIdFromSessionKey(params.sessionKey) ?? + resolveDefaultAgentId(config); + connection = resolveCodexBindingAppServerConnection({ + binding, + authProfileId: requestedAuthProfileId ?? binding.authProfileId, + pluginConfig: options.pluginConfig, + config, + agentDir: resolveAgentDir(config, agentId), + }); + } catch (error) { + return { + ok: false, + compacted: false, + reason: formatCompactionError(error), + }; + } + const { appServer, usesSupervisionConnection } = connection; if ( + !usesSupervisionConnection && requestedAuthProfileId && binding.authProfileId && binding.authProfileId !== requestedAuthProfileId @@ -538,7 +561,7 @@ async function compactCodexNativeThread( async () => { const client = await clientFactory({ startOptions: appServer.start, - authProfileId: requestedAuthProfileId ?? binding.authProfileId, + authProfileId: connection.clientAuthProfileId, agentDir: params.agentDir, config: params.config, }); @@ -562,6 +585,12 @@ async function compactCodexNativeThread( // the lifecycle fence held unless process exit is observed. throw new Error("failed to stop unconfirmed codex app-server process"); } + if (usesSupervisionConnection) { + // A supervised thread is native user-home state, not an + // OpenClaw-owned remote binding. Keep the lifecycle fence held + // rather than detach and permit a second writer. + throw new Error("cannot detach an unconfirmed supervised codex thread"); + } // Closing a WebSocket proves only that the connection ended, not // that its remote turn stopped. Detach this exact thread before // allowing future work to acquire the session lifecycle fence. diff --git a/extensions/codex/src/app-server/config.test.ts b/extensions/codex/src/app-server/config.test.ts index 542b88e26fea..b9063d21d7b9 100644 --- a/extensions/codex/src/app-server/config.test.ts +++ b/extensions/codex/src/app-server/config.test.ts @@ -10,12 +10,16 @@ import { CODEX_COMPUTER_USE_CONFIG_KEYS, CODEX_PLUGIN_ENTRY_CONFIG_KEYS, CODEX_PLUGINS_CONFIG_KEYS, + CODEX_SUPERVISION_CONFIG_KEYS, + CODEX_SUPERVISION_STDIO_ENDPOINT_CONFIG_KEYS, + CODEX_SUPERVISION_WEBSOCKET_ENDPOINT_CONFIG_KEYS, canUseCodexModelBackedApprovalsReviewerForModel, codexAppServerStartOptionsKey, fingerprintCodexAppServerNetworkProxyConfigPatch, readCodexPluginConfig, resolveCodexAppServerRuntimeOptions, resolveCodexAppServerUserHomeDir, + resolveCodexSupervisionAppServerRuntimeOptions, resolveCodexComputerUseConfig, resolveCodexModelBackedReviewerPolicyContext, resolveOpenClawExecModeForCodexAppServer, @@ -542,6 +546,102 @@ describe("Codex app-server config", () => { }); }); + it("does not change ordinary harness connection defaults when supervision is enabled", () => { + const runtime = resolveRuntimeForTest({ + pluginConfig: { supervision: { enabled: true } }, + }); + + expectFields(runtime.start, "runtime start", { + transport: "stdio", + homeScope: "agent", + }); + expect(runtime.start).not.toHaveProperty("url"); + }); + + it("uses shared user-home defaults only for supervision control connections", () => { + const runtime = resolveCodexSupervisionAppServerRuntimeOptions({ + pluginConfig: { supervision: { enabled: true } }, + env: {}, + requirementsToml: null, + }); + + expectFields(runtime.start, "runtime start", { + transport: "stdio", + homeScope: "user", + }); + expect(runtime.start).not.toHaveProperty("url"); + }); + + it("honors explicit app-server settings for supervision control connections", () => { + const runtime = resolveCodexSupervisionAppServerRuntimeOptions({ + pluginConfig: { + supervision: { enabled: true }, + appServer: { + transport: "websocket", + url: "ws://127.0.0.1:39175", + }, + }, + env: {}, + requirementsToml: null, + }); + + expectFields(runtime.start, "runtime start", { + transport: "websocket", + homeScope: "agent", + url: "ws://127.0.0.1:39175", + }); + }); + + it("honors explicit app-server connection settings when supervision is enabled", () => { + const runtime = resolveRuntimeForTest({ + pluginConfig: { + supervision: { enabled: true }, + appServer: { + transport: "websocket", + homeScope: "agent", + url: "ws://127.0.0.1:39175", + }, + }, + }); + + expectFields(runtime.start, "runtime start", { + transport: "websocket", + homeScope: "agent", + url: "ws://127.0.0.1:39175", + }); + }); + + it("rejects Unix app-server connections outside the shared user home", () => { + expect(() => + resolveRuntimeForTest({ + pluginConfig: { + appServer: { + transport: "unix", + homeScope: "agent", + }, + }, + }), + ).toThrow( + "plugins.entries.codex.config.appServer.transport=unix requires appServer.homeScope=user", + ); + }); + + it("rejects non-Unix URLs for Unix app-server connections", () => { + expect(() => + resolveRuntimeForTest({ + pluginConfig: { + appServer: { + transport: "unix", + homeScope: "user", + url: "ws://127.0.0.1:39175", + }, + }, + }), + ).toThrow( + "plugins.entries.codex.config.appServer.url must use unix:// when appServer.transport is unix", + ); + }); + it("resolves opt-in user-home coexistence only for local stdio", () => { const runtime = resolveRuntimeForTest({ pluginConfig: { appServer: { homeScope: "user" } }, @@ -559,7 +659,7 @@ describe("Codex app-server config", () => { }, }), ).toThrow( - "plugins.entries.codex.config.appServer.homeScope=user requires appServer.transport=stdio", + "plugins.entries.codex.config.appServer.homeScope=user requires appServer.transport=stdio or unix", ); }); @@ -2639,6 +2739,19 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] ); }); + it("derives distinct shared-client keys for distinct process working directories", () => { + const startOptions = { + transport: "stdio" as const, + command: "codex", + args: ["app-server"], + headers: {}, + }; + + expect(codexAppServerStartOptionsKey({ ...startOptions, cwd: "/tmp/project-a" })).not.toEqual( + codexAppServerStartOptionsKey({ ...startOptions, cwd: "/tmp/project-b" }), + ); + }); + it("keeps runtime config keys aligned with manifest schema and UI hints", async () => { const manifest = JSON.parse( await fs.readFile(new URL("../../openclaw.plugin.json", import.meta.url), "utf8"), @@ -2651,6 +2764,9 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] properties: Record; additionalProperties: boolean; }; + supervision: { + properties: Record; + }; }; }; uiHints: Record; @@ -2696,6 +2812,25 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] expect(Object.keys(pluginEntryProperties).toSorted()).toEqual( [...CODEX_PLUGIN_ENTRY_CONFIG_KEYS].toSorted(), ); + const supervisionProperties = manifest.configSchema.properties.supervision.properties; + expect(Object.keys(supervisionProperties).toSorted()).toEqual( + [...CODEX_SUPERVISION_CONFIG_KEYS].toSorted(), + ); + for (const key of CODEX_SUPERVISION_CONFIG_KEYS) { + expectUiHintLabel(manifest, `supervision.${key}`); + } + const supervisionEndpointVariants = ( + supervisionProperties.endpoints as { + items: { oneOf: Array<{ properties: Record }> }; + } + ).items.oneOf; + expect(supervisionEndpointVariants).toHaveLength(2); + expect(Object.keys(supervisionEndpointVariants[0]?.properties ?? {}).toSorted()).toEqual( + [...CODEX_SUPERVISION_STDIO_ENDPOINT_CONFIG_KEYS].toSorted(), + ); + expect(Object.keys(supervisionEndpointVariants[1]?.properties ?? {}).toSorted()).toEqual( + [...CODEX_SUPERVISION_WEBSOCKET_ENDPOINT_CONFIG_KEYS].toSorted(), + ); }); it("does not schema-default mode-derived policy fields", async () => { diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index c54ead706e66..12177d7fe8c5 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -37,7 +37,7 @@ const CODEX_APP_SERVER_HOME_DIRNAME = "codex-home"; const CODEX_CONFIG_TOML_FILENAME = "config.toml"; const PLAIN_DECIMAL_NUMBER_RE = /^[+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))$/; -type CodexAppServerTransportMode = "stdio" | "websocket"; +export type CodexAppServerTransportMode = "stdio" | "websocket" | "unix"; export type CodexAppServerHomeScope = "agent" | "user"; type CodexAppServerPolicyMode = "yolo" | "guardian"; export type CodexAppServerConnectionClass = "local-loopback" | "remote"; @@ -112,6 +112,30 @@ export type CodexPluginsConfig = { plugins?: Record; }; +export type CodexSupervisionEndpoint = + | { + id?: string; + label?: string; + transport?: "stdio-proxy"; + command?: string; + args?: string[]; + cwd?: string; + } + | { + id?: string; + label?: string; + transport: "websocket"; + url: string; + authTokenEnv?: string; + }; + +export type CodexSupervisionConfig = { + enabled?: boolean; + endpoints?: CodexSupervisionEndpoint[]; + allowRawTranscripts?: boolean; + allowWriteControls?: boolean; +}; + export type CodexAppServerExperimentalConfig = { sandboxExecServer?: boolean; }; @@ -169,6 +193,8 @@ export type CodexAppServerStartOptions = { commandSource?: CodexAppServerCommandSource; managedFallbackCommandPaths?: string[]; args: string[]; + /** Process working directory for shipped Supervisor stdio endpoint compatibility. */ + cwd?: string; url?: string; authToken?: string; headers: Record; @@ -212,6 +238,7 @@ export type CodexPluginConfig = { }; computerUse?: CodexComputerUseConfig; codexPlugins?: CodexPluginsConfig; + supervision?: CodexSupervisionConfig; appServer?: { mode?: CodexAppServerPolicyMode; transport?: CodexAppServerTransportMode; @@ -298,12 +325,36 @@ export const CODEX_PLUGIN_ENTRY_CONFIG_KEYS = [ "allow_destructive_actions", ] as const; +export const CODEX_SUPERVISION_CONFIG_KEYS = [ + "enabled", + "endpoints", + "allowRawTranscripts", + "allowWriteControls", +] as const; + +export const CODEX_SUPERVISION_STDIO_ENDPOINT_CONFIG_KEYS = [ + "id", + "label", + "transport", + "command", + "args", + "cwd", +] as const; + +export const CODEX_SUPERVISION_WEBSOCKET_ENDPOINT_CONFIG_KEYS = [ + "id", + "label", + "transport", + "url", + "authTokenEnv", +] as const; + const DEFAULT_CODEX_COMPUTER_USE_PLUGIN_NAME = "computer-use"; const DEFAULT_CODEX_COMPUTER_USE_MCP_SERVER_NAME = "computer-use"; const DEFAULT_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS = 60_000; const DEFAULT_CODEX_APP_SERVER_NETWORK_PROXY_PROFILE_PREFIX = "openclaw-network"; -const codexAppServerTransportSchema = z.enum(["stdio", "websocket"]); +const codexAppServerTransportSchema = z.enum(["stdio", "websocket", "unix"]); const codexAppServerHomeScopeSchema = z.enum(["agent", "user"]); const SecretInputSchema = buildSecretInputSchema(); const codexAppServerPolicyModeSchema = z.enum(["yolo", "guardian"]); @@ -374,6 +425,37 @@ const codexPluginsConfigSchema = z }) .strict(); +const codexSupervisionEndpointSchema = z.union([ + z + .object({ + id: z.string().optional(), + label: z.string().optional(), + transport: z.literal("stdio-proxy").optional(), + command: z.string().optional(), + args: z.array(z.string()).optional(), + cwd: z.string().optional(), + }) + .strict(), + z + .object({ + id: z.string().optional(), + label: z.string().optional(), + transport: z.literal("websocket"), + url: z.string(), + authTokenEnv: z.string().optional(), + }) + .strict(), +]); + +const codexSupervisionConfigSchema = z + .object({ + enabled: z.boolean().optional(), + endpoints: z.array(codexSupervisionEndpointSchema).optional(), + allowRawTranscripts: z.boolean().optional(), + allowWriteControls: z.boolean().optional(), + }) + .strict(); + const codexPluginConfigSchema = z .object({ codexDynamicToolsLoading: codexDynamicToolsLoadingSchema.optional(), @@ -399,6 +481,7 @@ const codexPluginConfigSchema = z .strict() .optional(), codexPlugins: z.unknown().optional(), + supervision: codexSupervisionConfigSchema.optional(), appServer: z .object({ mode: codexAppServerPolicyModeSchema.optional(), @@ -536,9 +619,10 @@ export function resolveCodexAppServerRuntimeOptions( } = {}, ): CodexAppServerRuntimeOptions { const env = params.env ?? process.env; - const config = readCodexPluginConfig(params.pluginConfig).appServer ?? {}; + const pluginConfig = readCodexPluginConfig(params.pluginConfig); + const config = pluginConfig.appServer ?? {}; const transport = resolveTransport(config.transport); - const homeScope: CodexAppServerHomeScope = config.homeScope === "user" ? "user" : "agent"; + const homeScope: CodexAppServerHomeScope = config.homeScope ?? "agent"; const configCommand = readNonEmptyString(config.command); const envCommand = readNonEmptyString(env.OPENCLAW_CODEX_APP_SERVER_BIN); const command = configCommand ?? envCommand ?? "codex"; @@ -557,7 +641,7 @@ export function resolveCodexAppServerRuntimeOptions( value: config.authToken, path: "plugins.entries.codex.config.appServer.authToken", }); - const url = readNonEmptyString(config.url); + const url = readNonEmptyString(config.url) ?? (transport === "unix" ? "unix://" : undefined); const connectionClass = inferCodexAppServerConnectionClass({ transport, url }); const remoteAppsSubstrate: CodexAppServerRemoteAppsSubstrate = "preconfigured"; const remoteWorkspaceRoot = normalizeRemoteWorkspaceRoot(config.remoteWorkspaceRoot); @@ -656,11 +740,22 @@ export function resolveCodexAppServerRuntimeOptions( } if (transport === "websocket" && homeScope === "user") { throw new Error( - "plugins.entries.codex.config.appServer.homeScope=user requires appServer.transport=stdio", + "plugins.entries.codex.config.appServer.homeScope=user requires appServer.transport=stdio or unix", ); } - assertCodexAppServerConnectionClassConfig({ - connectionClass, + if (transport === "unix" && homeScope !== "user") { + throw new Error( + "plugins.entries.codex.config.appServer.transport=unix requires appServer.homeScope=user", + ); + } + if (transport === "unix" && !url?.startsWith("unix://")) { + throw new Error( + "plugins.entries.codex.config.appServer.url must use unix:// when appServer.transport is unix", + ); + } + assertCodexAppServerConnectionSecurity({ + transport, + url, authToken, headers, }); @@ -900,6 +995,7 @@ export function codexAppServerStartOptionsKey( commandSource: options.commandSource ?? null, managedFallbackCommandPaths: [...(options.managedFallbackCommandPaths ?? [])], args: options.args, + cwd: options.cwd ?? null, url: options.url ?? null, authToken: hashSecretForKey(options.authToken, "authToken"), headers: Object.entries(options.headers) @@ -934,6 +1030,23 @@ export function codexSandboxPolicyForTurn( }; } +/** Resolves the passive supervision control connection without changing harness defaults. */ +export function resolveCodexSupervisionAppServerRuntimeOptions( + params: NonNullable[0]> = {}, +): CodexAppServerRuntimeOptions { + const pluginConfig = readCodexPluginConfig(params.pluginConfig); + const appServer = pluginConfig.appServer ?? {}; + const transport = resolveTransport(appServer.transport); + const homeScope = appServer.homeScope ?? (transport === "websocket" ? "agent" : "user"); + return resolveCodexAppServerRuntimeOptions({ + ...params, + pluginConfig: { + ...pluginConfig, + appServer: { ...appServer, homeScope }, + }, + }); +} + function resolveCodexAppServerNetworkProxy( config: CodexAppServerNetworkProxyConfig | undefined, sandbox: CodexAppServerSandboxMode, @@ -1066,7 +1179,7 @@ export function withMcpElicitationsApprovalPolicy( } function resolveTransport(value: unknown): CodexAppServerTransportMode { - return value === "websocket" ? "websocket" : "stdio"; + return value === "websocket" || value === "unix" ? value : "stdio"; } function normalizeRemoteWorkspaceRoot(value: string | undefined): string | undefined { @@ -1101,6 +1214,20 @@ function assertCodexAppServerConnectionClassConfig(params: { } } +/** Applies the canonical remote-auth boundary to any Codex AppServer transport. */ +export function assertCodexAppServerConnectionSecurity(params: { + transport: CodexAppServerTransportMode; + url?: string; + authToken?: string; + headers: Record; +}): void { + assertCodexAppServerConnectionClassConfig({ + connectionClass: inferCodexAppServerConnectionClass(params), + authToken: params.authToken, + headers: params.headers, + }); +} + function isLoopbackWebSocketUrl(value: string): boolean { let parsed: URL; try { diff --git a/extensions/codex/src/app-server/plugin-app-cache-key.test.ts b/extensions/codex/src/app-server/plugin-app-cache-key.test.ts index 3fbe66168880..79c35f9b8c87 100644 --- a/extensions/codex/src/app-server/plugin-app-cache-key.test.ts +++ b/extensions/codex/src/app-server/plugin-app-cache-key.test.ts @@ -1,11 +1,16 @@ // Codex tests cover plugin app cache key plugin behavior. -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { + buildCodexAppServerConnectionFingerprint, buildCodexAppServerRuntimeFingerprint, buildCodexPluginAppCacheKey, resolveCodexPluginAppCacheEndpoint, } from "./plugin-app-cache-key.js"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe("resolveCodexPluginAppCacheEndpoint", () => { it("keys plugin app inventory by websocket credentials without exposing them", () => { const first = resolveCodexPluginAppCacheEndpoint({ @@ -116,4 +121,23 @@ describe("resolveCodexPluginAppCacheEndpoint", () => { expect(first).not.toContain("secret-token"); expect(second).not.toContain("secret-token"); }); + + it("fingerprints the effective user Codex home for supervised connections", () => { + const appServer = { + start: { + transport: "stdio" as const, + homeScope: "user" as const, + command: "codex", + args: ["app-server"], + headers: {}, + }, + connectionClass: "local-loopback" as const, + }; + vi.stubEnv("CODEX_HOME", "/tmp/codex-home-one"); + const first = buildCodexAppServerConnectionFingerprint(appServer); + vi.stubEnv("CODEX_HOME", "/tmp/codex-home-two"); + const second = buildCodexAppServerConnectionFingerprint(appServer); + + expect(first).not.toEqual(second); + }); }); diff --git a/extensions/codex/src/app-server/plugin-app-cache-key.ts b/extensions/codex/src/app-server/plugin-app-cache-key.ts index e97b550c9a34..b93a7ed1953e 100644 --- a/extensions/codex/src/app-server/plugin-app-cache-key.ts +++ b/extensions/codex/src/app-server/plugin-app-cache-key.ts @@ -12,7 +12,11 @@ import { } from "./app-inventory-cache.js"; import { resolveCodexAppServerHomeDir } from "./auth-bridge.js"; import type { CodexAppServerRuntimeIdentity } from "./client.js"; -import type { CodexAppServerRuntimeOptions, CodexAppServerStartOptions } from "./config.js"; +import { + resolveCodexAppServerUserHomeDir, + type CodexAppServerRuntimeOptions, + type CodexAppServerStartOptions, +} from "./config.js"; const require = createRequire(import.meta.url); const CODEX_PLUGIN_VERSION = readPluginPackageVersion({ require }); @@ -64,6 +68,44 @@ export function buildCodexAppServerRuntimeFingerprint(params: { }); } +/** Fingerprints the configured connection that owns a supervised source thread. */ +export function buildCodexAppServerConnectionFingerprint( + appServer: Pick< + CodexAppServerRuntimeOptions, + "start" | "connectionClass" | "remoteWorkspaceRoot" + >, + agentDir?: string, +): string { + return JSON.stringify({ + endpoint: resolveCodexPluginAppCacheEndpoint(appServer), + connectionClass: appServer.connectionClass, + remoteWorkspaceRoot: appServer.remoteWorkspaceRoot ?? null, + homeScope: appServer.start.homeScope ?? null, + codexHome: resolveCodexAppServerConnectionHome(appServer.start, agentDir), + cwd: appServer.start.cwd ?? null, + }); +} + +function resolveCodexAppServerConnectionHome( + start: CodexAppServerStartOptions, + agentDir?: string, +): string | null { + const configured = start.env?.CODEX_HOME?.trim(); + if (configured) { + return configured; + } + if (start.transport === "unix" && (!start.url || start.url === "unix://")) { + return resolveCodexAppServerUserHomeDir(start.env ?? process.env); + } + if (start.transport !== "stdio") { + return null; + } + if (start.homeScope === "user") { + return resolveCodexAppServerUserHomeDir(process.env); + } + return agentDir ? resolveCodexAppServerHomeDir(agentDir) : null; +} + /** Serializes app-server endpoint identity, including credential fingerprints. */ export function resolveCodexPluginAppCacheEndpoint( appServer: Pick, diff --git a/extensions/codex/src/app-server/protocol.ts b/extensions/codex/src/app-server/protocol.ts index 01223c24139d..cccfa4ec3861 100644 --- a/extensions/codex/src/app-server/protocol.ts +++ b/extensions/codex/src/app-server/protocol.ts @@ -156,9 +156,22 @@ export type CodexThreadStartResponse = { modelProvider?: string | null; }; -export type CodexThreadForkParams = CodexThreadStartParams & { +export type CodexThreadForkParams = JsonObject & { threadId: string; + lastTurnId?: string | null; + path?: string | null; + model?: string | null; + modelProvider?: string | null; + serviceTier?: CodexServiceTier | null; + cwd?: string | null; + runtimeWorkspaceRoots?: string[] | null; + approvalPolicy?: CodexApprovalPolicy | null; + approvalsReviewer?: CodexApprovalsReviewer | null; + sandbox?: CodexSandboxMode | null; + permissions?: string | null; + config?: JsonObject | null; baseInstructions?: string; + developerInstructions?: string; ephemeral?: boolean; threadSource?: string | null; excludeTurns?: boolean; @@ -186,8 +199,12 @@ export type CodexThreadListParams = JsonObject & { sortKey?: "created_at" | "updated_at" | "recency_at" | null; sortDirection?: "asc" | "desc" | null; archived?: boolean | null; + cwd?: string | string[] | null; + useStateDbOnly?: boolean; searchTerm?: string | null; sourceKinds?: CodexThreadSourceKind[] | null; + parentThreadId?: string | null; + ancestorThreadId?: string | null; }; export type CodexThreadListResponse = { @@ -297,6 +314,7 @@ export type CodexThread = { createdAt?: number | null; updatedAt?: number | null; status?: CodexThreadStatus | null; + modelProvider?: string | null; cwd?: string | null; source?: CodexSessionSource | null; threadSource?: string | null; diff --git a/extensions/codex/src/app-server/provider-capabilities.ts b/extensions/codex/src/app-server/provider-capabilities.ts index d725d8377adc..261afa044ae2 100644 --- a/extensions/codex/src/app-server/provider-capabilities.ts +++ b/extensions/codex/src/app-server/provider-capabilities.ts @@ -49,7 +49,7 @@ export async function resolveCodexProviderWebSearchSupportForClient(params: { export async function resolveCodexProviderWebSearchSupport(params: { clientFactory: CodexAppServerClientFactory; appServer: CodexAppServerRuntimeOptions; - authProfileId: string | undefined; + authProfileId: string | null | undefined; agentDir: string; config: EmbeddedRunAttemptParams["config"] | undefined; modelProviderOverride: string | undefined; diff --git a/extensions/codex/src/app-server/request.ts b/extensions/codex/src/app-server/request.ts index a681c19ac59a..8861a70ea9bb 100644 --- a/extensions/codex/src/app-server/request.ts +++ b/extensions/codex/src/app-server/request.ts @@ -3,6 +3,7 @@ * checks, shared-client leasing, and isolated-client shutdown handling. */ import type { resolveCodexAppServerAuthProfileIdForAgent } from "./auth-bridge.js"; +import type { CodexAppServerClient } from "./client.js"; import type { CodexAppServerStartOptions } from "./config.js"; import type { CodexAppServerRequestMethod, @@ -18,6 +19,38 @@ import { } from "./shared-client.js"; import { withTimeout } from "./timeout.js"; +type CodexAppServerClientRequestParams = { + client: CodexAppServerClient; + method: string; + requestParams?: unknown; + timeoutMs?: number; + config?: Parameters[0]["config"]; + sessionKey?: string; + sessionId?: string; +}; + +/** Sends one guarded request over a client lease owned by the caller. */ +export async function requestCodexAppServerClientJson( + params: CodexAppServerClientRequestParams, +): Promise { + const sandboxBlock = resolveCodexAppServerDirectSandboxBypassBlock({ + method: params.method, + requestParams: params.requestParams, + config: params.config, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + }); + if (sandboxBlock) { + throw new Error(sandboxBlock); + } + const timeoutMs = params.timeoutMs ?? 60_000; + return await withTimeout( + params.client.request(params.method, params.requestParams, { timeoutMs }), + timeoutMs, + `codex app-server ${params.method} timed out`, + ); +} + /** Sends a typed Codex app-server request and returns the method-specific response shape. */ export async function requestCodexAppServerJson(params: { method: M; diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index b3f9828783b3..fec36b1c16a7 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -16,6 +16,7 @@ import { registerMemoryCapability } from "openclaw/plugin-sdk/memory-core-host-r import { MESSAGE_TOOL_DELIVERY_HINTS } from "openclaw/plugin-sdk/message-tool-delivery-hints"; import { registerPluginCommand } from "openclaw/plugin-sdk/plugin-runtime"; import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime"; +import { saveSessionStore } from "openclaw/plugin-sdk/session-store-runtime"; import { describe, expect, it, vi } from "vitest"; import WebSocket from "ws"; import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js"; @@ -29,7 +30,11 @@ import { } from "./attempt-context.js"; import { resolveCodexAppServerEnvApiKeyCacheKey } from "./auth-bridge.js"; import { CodexAppServerRpcError } from "./client.js"; -import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js"; +import { + readCodexPluginConfig, + resolveCodexAppServerRuntimeOptions, + resolveCodexSupervisionAppServerRuntimeOptions, +} from "./config.js"; import { CODEX_TURN_START_TEXT_INPUT_MAX_CHARS } from "./context-engine-projection.js"; import { CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE, @@ -40,7 +45,10 @@ import { CodexAppServerEventProjector, type CodexAppServerToolTelemetry, } from "./event-projector.js"; -import { buildCodexPluginAppCacheKey } from "./plugin-app-cache-key.js"; +import { + buildCodexAppServerConnectionFingerprint, + buildCodexPluginAppCacheKey, +} from "./plugin-app-cache-key.js"; import { buildCodexPluginThreadConfig } from "./plugin-thread-config.js"; import { flattenCodexDynamicToolFunctions, @@ -151,6 +159,14 @@ async function writeExistingBinding( workspaceDir: string, overrides: Partial[1]> = {}, ) { + const supervisionFingerprint = + overrides.connectionScope === "supervision" && !overrides.appServerRuntimeFingerprint + ? buildCodexAppServerConnectionFingerprint( + resolveCodexSupervisionAppServerRuntimeOptions({ + pluginConfig: { supervision: { enabled: true } }, + }), + ) + : undefined; await writeCodexAppServerBinding(sessionFile, { threadId: "thread-existing", cwd: workspaceDir, @@ -158,6 +174,7 @@ async function writeExistingBinding( modelProvider: "openai", historyCoveredThrough: new Date().toISOString(), webSearchThreadConfigFingerprint: DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT, + ...(supervisionFingerprint ? { appServerRuntimeFingerprint: supervisionFingerprint } : {}), ...overrides, }); } @@ -5935,6 +5952,114 @@ describe("runCodexAppServerAttempt", () => { expect(turnRequestParams?.approvalsReviewer).toBe("user"); }); + it("uses a supervised native model for review policy despite an outer Anthropic default", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + await writeExistingBinding(sessionFile, workspaceDir, { + connectionScope: "supervision", + supervisionSourceThreadId: "thread-existing", + model: "gpt-5.5", + modelProvider: "openai", + preserveNativeModel: true, + conversationSourceTransferComplete: true, + }); + const nativeResponse = threadStartResult("thread-existing"); + nativeResponse.model = "gpt-5.5"; + nativeResponse.modelProvider = "openai"; + nativeResponse.thread.modelProvider = "openai"; + const harness = createAppServerHarness(async (method) => { + if (method === "thread/read") { + return { thread: nativeResponse.thread }; + } + if (method === "thread/resume") { + return nativeResponse; + } + if (method === "turn/start") { + return turnStartResult(); + } + return {}; + }); + const clientFactory = vi.fn(async () => harness.client); + const params = { + ...createParams(sessionFile, workspaceDir), + provider: "anthropic", + modelId: "claude-opus-4-6", + model: createCodexTestModel("anthropic"), + config: { tools: { exec: { mode: "auto" } } }, + } as EmbeddedRunAttemptParams; + + const run = runCodexAppServerAttempt(params, { + pluginConfig: { + appServer: { mode: "guardian" }, + supervision: { enabled: true }, + }, + clientFactory, + }); + await harness.waitForMethod("turn/start"); + await harness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" }); + await run; + + expect(clientFactory).toHaveBeenCalledWith( + expect.objectContaining({ + authProfileId: null, + startOptions: expect.objectContaining({ homeScope: "user" }), + }), + ); + const resumeRequest = harness.requests.find((request) => request.method === "thread/resume"); + const resumeParams = resumeRequest?.params as Record | undefined; + expect(resumeParams).not.toHaveProperty("model"); + expect(resumeParams).not.toHaveProperty("modelProvider"); + expect(resumeParams?.approvalsReviewer).toBe("auto_review"); + const turnRequest = harness.requests.find((request) => request.method === "turn/start"); + const turnParams = turnRequest?.params as Record | undefined; + expect(turnParams).not.toHaveProperty("model"); + expect(turnParams).not.toHaveProperty("modelProvider"); + expect(turnParams?.approvalsReviewer).toBe("auto_review"); + }); + + it("fails before client startup when a successor generation hides a private supervision binding", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const sessionKey = "agent:main:supervised-stale-generation"; + registerCodexTestSessionIdentity(sessionFile, "session-previous", sessionKey); + await writeExistingBinding(sessionFile, workspaceDir, { + connectionScope: "supervision", + supervisionSourceThreadId: "thread-source", + model: "gpt-5.5", + modelProvider: "openai", + preserveNativeModel: true, + conversationSourceTransferComplete: true, + }); + const storePath = path.join(tempDir, "sessions.json"); + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: "session-current", + updatedAt: Date.now(), + }, + }); + const params = createParams(sessionFile, workspaceDir); + params.sessionId = "session-current"; + params.sessionKey = sessionKey; + params.config = { session: { store: storePath } }; + const clientFactory = vi.fn(async () => { + throw new Error("client must not start"); + }); + + await expect( + runCodexAppServerAttempt(params, { + pluginConfig: { supervision: { enabled: true } }, + clientFactory, + }), + ).rejects.toThrow("Codex session generation is no longer current"); + expect(clientFactory).not.toHaveBeenCalled(); + + registerCodexTestSessionIdentity(sessionFile, "session-previous", sessionKey); + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: "thread-existing", + connectionScope: "supervision", + }); + }); + it("does not inherit a bound local provider for explicit native OpenAI resumed runs", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index dab0806a56a3..e516b3eaa2ed 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -121,6 +121,7 @@ import { resolveCodexAppServerAuthProfileId, resolveCodexAppServerAuthProfileIdForAgent, } from "./auth-bridge.js"; +import { resolveCodexBindingAppServerConnection } from "./binding-connection.js"; import { CodexAppServerRpcError, isCodexAppServerApprovalRequest, @@ -131,7 +132,6 @@ import { isCodexSandboxExecServerEnabled, readCodexPluginConfig, resolveCodexComputerUseConfig, - resolveCodexAppServerRuntimeOptions, resolveCodexModelBackedReviewerPolicyContext, resolveOpenClawExecPolicyForCodexAppServer, shouldAutoApproveCodexAppServerApprovals, @@ -226,6 +226,7 @@ import { readCodexRateLimitsRevision, readRecentCodexRateLimits } from "./rate-l import { releaseCodexSandboxExecServerEnvironment } from "./sandbox-exec-server.js"; import { isCodexAppServerNativeAuthProfile, + reclaimCurrentCodexSessionGeneration, sessionBindingIdentity, type CodexAppServerBindingIdentity, type CodexAppServerBindingStore, @@ -535,53 +536,98 @@ export async function runCodexAppServerAttempt( }); const bindingStore = options.bindingStore; preDynamicStartupStages.mark("session-agent"); - const activeContextEngine = isActiveHarnessContextEngine(params.contextEngine) + let activeContextEngine = isActiveHarnessContextEngine(params.contextEngine) ? params.contextEngine : undefined; const isInactiveThreadBootstrapBinding = (binding: CodexAppServerThreadBinding | undefined) => !activeContextEngine && binding?.contextEngine?.projection?.mode === "thread_bootstrap"; let startupBinding = await bindingStore.read(bindingIdentity); + if (!startupBinding && bindingIdentity.kind === "session" && bindingIdentity.sessionKey) { + const reclaimed = await reclaimCurrentCodexSessionGeneration({ + bindingStore, + identity: bindingIdentity, + config: params.config, + }); + if (!reclaimed) { + throw new Error( + `Codex session generation is no longer current: ${bindingIdentity.sessionId}`, + ); + } + startupBinding = await bindingStore.read(bindingIdentity); + } preDynamicStartupStages.mark("read-binding"); + // Only the private binding store may authorize the native user-home runtime. + // Public session metadata and preserveNativeModel are intentionally insufficient. + const usesSupervisionConnection = startupBinding?.connectionScope === "supervision"; + if (usesSupervisionConnection) { + activeContextEngine = undefined; + } + if (usesSupervisionConnection && pluginConfig.supervision?.enabled !== true) { + throw new Error( + "Codex supervision is disabled; refusing to open a native user-home supervised session", + ); + } + const resolveRuntimeOptionsForBinding = (paramsLocal: { + modelProvider?: string; + model?: string; + }) => + resolveCodexBindingAppServerConnection({ + binding: startupBinding, + pluginConfig, + execPolicy, + modelProvider: paramsLocal.modelProvider, + model: paramsLocal.model, + config: params.config, + agentDir, + openClawSandboxActive: sandbox?.enabled === true, + }).appServer; const startupBindingAuthProfileId = startupBinding?.authProfileId; const initialStartupBindingHadInactiveThreadBootstrap = isInactiveThreadBootstrapBinding(startupBinding); - const startupAuthProfileCandidate = - params.runtimePlan?.auth.forwardedAuthProfileId ?? - params.authProfileId ?? - startupBinding?.authProfileId ?? - startupBindingAuthProfileId; - const startupAuthProfileId = params.authProfileStore - ? resolveCodexAppServerAuthProfileId({ - authProfileId: startupAuthProfileCandidate, - store: params.authProfileStore, - config: params.config, - }) - : resolveCodexAppServerAuthProfileIdForAgent({ - authProfileId: startupAuthProfileCandidate, - agentDir, - config: params.config, - }); - let reviewerPolicyContext = resolveCodexModelBackedReviewerPolicyContext({ - provider: params.provider, - model: params.modelId, - bindingModelProvider: startupBinding?.modelProvider, - bindingModel: startupBinding?.model, - nativeAuthProfile: isCodexAppServerNativeAuthProfile({ - authProfileId: startupAuthProfileId, + const startupAuthProfileCandidate = usesSupervisionConnection + ? undefined + : (params.runtimePlan?.auth.forwardedAuthProfileId ?? + params.authProfileId ?? + startupBinding?.authProfileId ?? + startupBindingAuthProfileId); + const startupAuthProfileId = usesSupervisionConnection + ? undefined + : params.authProfileStore + ? resolveCodexAppServerAuthProfileId({ + authProfileId: startupAuthProfileCandidate, + store: params.authProfileStore, + config: params.config, + }) + : resolveCodexAppServerAuthProfileIdForAgent({ + authProfileId: startupAuthProfileCandidate, + agentDir, + config: params.config, + }); + const startupClientAuthProfileId = usesSupervisionConnection ? null : startupAuthProfileId; + const nativeAuthProfile = + isCodexAppServerNativeAuthProfile({ + authProfileId: startupClientAuthProfileId ?? undefined, authProfileStore: params.authProfileStore, agentDir, config: params.config, - }), - }); + }) || usesSupervisionConnection; + const resolveReviewerPolicyContext = (binding: CodexAppServerThreadBinding | undefined) => { + const nativeModelOwned = binding?.preserveNativeModel === true; + // A supervised Codex branch owns its model. The outer OpenClaw default may + // be Anthropic (or anything else) and must not select this thread's reviewer. + return resolveCodexModelBackedReviewerPolicyContext({ + provider: nativeModelOwned ? "codex" : params.provider, + model: nativeModelOwned ? binding.model : params.modelId, + bindingModelProvider: binding?.modelProvider, + bindingModel: binding?.model, + nativeAuthProfile, + }); + }; + let reviewerPolicyContext = resolveReviewerPolicyContext(startupBinding); preDynamicStartupStages.mark("auth-profile"); - let configuredAppServer = resolveCodexAppServerRuntimeOptions({ - pluginConfig, - execPolicy, + let configuredAppServer = resolveRuntimeOptionsForBinding({ modelProvider: reviewerPolicyContext.modelProvider, model: reviewerPolicyContext.model, - config: params.config, - agentDir, - openClawSandboxActive: sandbox?.enabled === true, }); const effectiveWorkspace = sandbox?.enabled ? sandbox.workspaceAccess === "rw" @@ -682,26 +728,10 @@ export async function runCodexAppServerAttempt( const initialInactiveThreadBootstrapBindingForcedFreshStart = initialStartupBindingHadInactiveThreadBootstrap && !startupBinding?.threadId; preDynamicStartupStages.mark("rotate-binding"); - reviewerPolicyContext = resolveCodexModelBackedReviewerPolicyContext({ - provider: params.provider, - model: params.modelId, - bindingModelProvider: startupBinding?.modelProvider, - bindingModel: startupBinding?.model, - nativeAuthProfile: isCodexAppServerNativeAuthProfile({ - authProfileId: startupAuthProfileId, - authProfileStore: params.authProfileStore, - agentDir, - config: params.config, - }), - }); - configuredAppServer = resolveCodexAppServerRuntimeOptions({ - pluginConfig, - execPolicy, + reviewerPolicyContext = resolveReviewerPolicyContext(startupBinding); + configuredAppServer = resolveRuntimeOptionsForBinding({ modelProvider: reviewerPolicyContext.modelProvider, model: reviewerPolicyContext.model, - config: params.config, - agentDir, - openClawSandboxActive: sandbox?.enabled === true, }); policyAppServer = resolveCodexAppServerForOpenClawToolPolicy({ appServer: configuredAppServer, @@ -728,11 +758,60 @@ export async function runCodexAppServerAttempt( configuredEvents: options.nativeHookRelay?.events, appServer, }); - const runtimeParams = { - ...params, - sessionKey: contextSessionKey, - ...(startupAuthProfileId ? { authProfileId: startupAuthProfileId } : {}), - }; + const effectiveContextWindowInfo = usesSupervisionConnection + ? undefined + : params.contextWindowInfo; + const effectiveContextTokenBudget = usesSupervisionConnection + ? undefined + : params.contextTokenBudget; + const effectiveRuntimeProviderId = usesSupervisionConnection + ? (startupBinding?.modelProvider ?? "codex") + : params.provider; + // Pending branches learn the authoritative model only inside App Server. + // This placeholder prevents outer-model metadata from shaping pre-start context policy. + const effectiveRuntimeModelId = usesSupervisionConnection + ? (startupBinding?.model ?? "codex-native") + : params.modelId; + const { + authProfileId: _outerAuthProfileId, + contextWindowInfo: _outerContextWindowInfo, + contextTokenBudget: _outerContextTokenBudget, + model: _outerModel, + modelId: _outerModelId, + provider: _outerProvider, + runtimePlan: _outerRuntimePlan, + requestedModelId: _outerRequestedModelId, + fallbackReason: _outerFallbackReason, + degradedReason: _outerDegradedReason, + thinkLevel: _outerThinkLevel, + fastMode: _outerFastMode, + ...paramsWithoutOuterNativeOwnership + } = params; + const supervisedRuntimeModel = { + id: effectiveRuntimeModelId, + name: effectiveRuntimeModelId, + provider: effectiveRuntimeProviderId, + api: "openai-chatgpt-responses", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: undefined, + maxTokens: undefined, + } as unknown as EmbeddedRunAttemptParams["model"]; + const runtimeParams: EmbeddedRunAttemptParams = usesSupervisionConnection + ? { + ...paramsWithoutOuterNativeOwnership, + provider: "codex", + modelId: effectiveRuntimeModelId, + model: supervisedRuntimeModel, + thinkLevel: _outerThinkLevel, + sessionKey: contextSessionKey, + } + : { + ...params, + sessionKey: contextSessionKey, + ...(startupAuthProfileId ? { authProfileId: startupAuthProfileId } : {}), + }; const activeSessionId = params.sessionId; const activeSessionFile = params.sessionFile; const buildActiveRunAttemptParams = (): EmbeddedRunAttemptParams => ({ @@ -740,32 +819,40 @@ export async function runCodexAppServerAttempt( sessionId: activeSessionId, sessionFile: activeSessionFile, }); - const startupAuthAccountCacheKey = await resolveCodexAppServerAuthAccountCacheKey({ - authProfileId: startupAuthProfileId, - authProfileStore: params.authProfileStore, - agentDir, - config: params.config, - }); - const startupEnvApiKeyCacheKey = startupAuthProfileId + const startupAuthAccountCacheKey = usesSupervisionConnection ? undefined - : resolveCodexAppServerFallbackApiKeyCacheKey({ - startOptions: appServer.start, + : await resolveCodexAppServerAuthAccountCacheKey({ + authProfileId: startupAuthProfileId, + authProfileStore: params.authProfileStore, + agentDir, + config: params.config, }); + const startupEnvApiKeyCacheKey = usesSupervisionConnection + ? undefined + : startupAuthProfileId + ? undefined + : resolveCodexAppServerFallbackApiKeyCacheKey({ + startOptions: appServer.start, + }); preDynamicStartupStages.mark("auth-cache"); const bundleMcpThreadConfig = await loadCodexBundleMcpThreadConfig({ workspaceDir: effectiveWorkspace, cfg: params.config, - toolsEnabled: supportsModelTools(params.model), + toolsEnabled: usesSupervisionConnection || supportsModelTools(params.model), disableTools: params.disableTools, toolsAllow: params.toolsAllow, }); preDynamicStartupStages.mark("bundle-mcp"); const sandboxExecServerEnabled = isCodexSandboxExecServerEnabled(pluginConfig); - const nativeToolSurfaceEnabled = shouldEnableCodexAppServerNativeToolSurface(params, sandbox, { - agentId: sessionAgentId, - runtimeSessionKey: sandboxSessionKey, - sandboxExecServerEnabled, - }); + const nativeToolSurfaceEnabled = shouldEnableCodexAppServerNativeToolSurface( + runtimeParams, + sandbox, + { + agentId: sessionAgentId, + runtimeSessionKey: sandboxSessionKey, + sandboxExecServerEnabled, + }, + ); preDynamicStartupStages.mark("native-tool-surface"); const nativeProviderWebSearchSupport = resolveCodexWebSearchPlan({ @@ -776,18 +863,20 @@ export async function runCodexAppServerAttempt( ? await resolveCodexProviderWebSearchSupport({ clientFactory: attemptClientFactory, appServer, - authProfileId: startupAuthProfileId, + authProfileId: startupClientAuthProfileId, agentDir, config: params.config, - modelProviderOverride: resolveCodexAppServerThreadModelSelection({ - provider: params.provider, - model: params.modelId, - binding: startupBinding, - authProfileId: startupAuthProfileId, - authProfileStore: params.authProfileStore, - agentDir, - config: params.config, - }).modelProvider, + modelProviderOverride: usesSupervisionConnection + ? startupBinding?.modelProvider + : resolveCodexAppServerThreadModelSelection({ + provider: params.provider, + model: params.modelId, + binding: startupBinding, + authProfileId: startupAuthProfileId, + authProfileStore: params.authProfileStore, + agentDir, + config: params.config, + }).modelProvider, signal: runAbortController.signal, }) : "unsupported"; @@ -851,13 +940,13 @@ export async function runCodexAppServerAttempt( const dynamicToolParams = allocateCodexToolOutcomeOrdinal || onCodexToolOutcome ? { - ...params, + ...runtimeParams, ...(allocateCodexToolOutcomeOrdinal ? { allocateToolOutcomeOrdinal: allocateCodexToolOutcomeOrdinal } : {}), ...(onCodexToolOutcome ? { onToolOutcome: onCodexToolOutcome } : {}), } - : params; + : runtimeParams; let persistentWebSearchAllowed: boolean | undefined; let webSearchAllowed = false; // Codex can compact a thread while keeping the same dynamic-tool bridge. @@ -923,7 +1012,7 @@ export async function runCodexAppServerAttempt( registeredTools, signal: runAbortController.signal, computerContextEpoch, - loading: resolveCodexDynamicToolsLoadingForRuntime(pluginConfig, params.modelId, { + loading: resolveCodexDynamicToolsLoadingForRuntime(pluginConfig, effectiveRuntimeModelId, { connectionClass: appServer.connectionClass, }), directToolNames: resolveCodexDynamicToolDirectNames(params), @@ -959,16 +1048,16 @@ export async function runCodexAppServerAttempt( ? [] : ((await readMirroredSessionHistoryMessages(activeTranscriptTarget)) ?? []); const hookContextWindowFields = { - ...(params.contextWindowInfo?.tokens - ? { contextTokenBudget: params.contextWindowInfo.tokens } - : params.contextTokenBudget - ? { contextTokenBudget: params.contextTokenBudget } + ...(effectiveContextWindowInfo?.tokens + ? { contextTokenBudget: effectiveContextWindowInfo.tokens } + : effectiveContextTokenBudget + ? { contextTokenBudget: effectiveContextTokenBudget } : {}), - ...(params.contextWindowInfo?.source - ? { contextWindowSource: params.contextWindowInfo.source } + ...(effectiveContextWindowInfo?.source + ? { contextWindowSource: effectiveContextWindowInfo.source } : {}), - ...(params.contextWindowInfo?.referenceTokens - ? { contextWindowReferenceTokens: params.contextWindowInfo.referenceTokens } + ...(effectiveContextWindowInfo?.referenceTokens + ? { contextWindowReferenceTokens: effectiveContextWindowInfo.referenceTokens } : {}), }; const hookContext = { @@ -994,7 +1083,7 @@ export async function runCodexAppServerAttempt( agentDir, activeAgentId: sessionAgentId, contextEnginePluginId: activeContextEnginePluginId, - tokenBudget: params.contextTokenBudget, + tokenBudget: effectiveContextTokenBudget, }); if (activeContextEngine) { await bootstrapHarnessContextEngine({ @@ -1005,11 +1094,11 @@ export async function runCodexAppServerAttempt( sessionFile: activeSessionFile, runtimeContext: buildActiveContextEngineRuntimeContext(), contextEngineHostSupport: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST, - providerId: params.provider, - requestedModelId: params.requestedModelId, - modelId: params.modelId, - fallbackReason: params.fallbackReason, - degradedReason: params.degradedReason, + providerId: effectiveRuntimeProviderId, + requestedModelId: usesSupervisionConnection ? undefined : params.requestedModelId, + modelId: effectiveRuntimeModelId, + fallbackReason: usesSupervisionConnection ? undefined : params.fallbackReason, + degradedReason: usesSupervisionConnection ? undefined : params.degradedReason, runMaintenance: runHarnessContextEngineMaintenance, config: params.config, warn: (message) => embeddedAgentLog.warn(message), @@ -1019,7 +1108,7 @@ export async function runCodexAppServerAttempt( } const memoryToolNames = getCodexWorkspaceMemoryToolNames(toolBridge.availableSpecs); const workspaceBootstrapContext = await buildCodexWorkspaceBootstrapContext({ - params, + params: runtimeParams, resolvedWorkspace, effectiveWorkspace, sessionKey: contextSessionKey, @@ -1027,17 +1116,17 @@ export async function runCodexAppServerAttempt( memoryToolNames, }); const baseDeveloperInstructions = joinPresentSections( - buildDeveloperInstructions(params, { + buildDeveloperInstructions(runtimeParams, { dynamicTools: toolBridge.availableSpecs, }), workspaceBootstrapContext.developerInstructions, ); const openClawPromptContext = buildCodexOpenClawPromptContext({ - params, + params: runtimeParams, workspacePromptContext: workspaceBootstrapContext.promptContext, }); const skillsCollaborationInstructions = renderCodexSkillsCollaborationInstructions({ - attempt: params, + attempt: runtimeParams, skillsPrompt: params.skillsSnapshot?.prompt, }); let promptText = params.prompt; @@ -1045,7 +1134,7 @@ export async function runCodexAppServerAttempt( let developerInstructions = baseDeveloperInstructions; let prePromptMessageCount = historyMessages.length; const codexContextProjectionMaxChars = resolveCodexContextEngineProjectionMaxChars({ - contextTokenBudget: params.contextTokenBudget, + contextTokenBudget: effectiveContextTokenBudget, reserveTokens: resolveCodexContextEngineProjectionReserveTokens({ config: params.config, }), @@ -1077,19 +1166,19 @@ export async function runCodexAppServerAttempt( sessionId: activeSessionId, sessionKey: contextSessionKey, messages: historyMessages, - tokenBudget: params.contextTokenBudget, + tokenBudget: effectiveContextTokenBudget, availableTools: new Set( flattenCodexDynamicToolFunctions(toolBridge.availableSpecs) .map((tool) => tool.name) .filter(isNonEmptyString), ), citationsMode: params.config?.memory?.citations, - modelId: params.modelId, + modelId: effectiveRuntimeModelId, contextEngineHostSupport: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST, - providerId: params.provider, - requestedModelId: params.requestedModelId, - fallbackReason: params.fallbackReason, - degradedReason: params.degradedReason, + providerId: effectiveRuntimeProviderId, + requestedModelId: usesSupervisionConnection ? undefined : params.requestedModelId, + fallbackReason: usesSupervisionConnection ? undefined : params.fallbackReason, + degradedReason: usesSupervisionConnection ? undefined : params.degradedReason, prompt: params.prompt, }); if (!assembled) { @@ -1362,7 +1451,7 @@ export async function runCodexAppServerAttempt( ) => { precomputedStaleBindingContinuityProjectionApplied = false; staleBindingContinuityForcedFreshStart = false; - if (activeContextEngine || !binding?.threadId) { + if (activeContextEngine || !binding?.threadId || binding.pendingSupervisionBranch) { return false; } if (isInactiveThreadBootstrapBinding(binding)) { @@ -1374,7 +1463,7 @@ export async function runCodexAppServerAttempt( return projected; }; const applyNoContextEngineContinuityProjection = ( - action: "started" | "resumed", + action: "started" | "resumed" | "forked", binding?: CodexAppServerThreadBinding, ) => { if (activeContextEngine || !historyMessages.some((message) => message.role === "user")) { @@ -1633,7 +1722,7 @@ export async function runCodexAppServerAttempt( stream: "codex_app_server.lifecycle", data: { phase: "startup" }, }); - const attemptAppServer = withCodexAppServerFastModeServiceTier(appServer, params); + const attemptAppServer = withCodexAppServerFastModeServiceTier(appServer, runtimeParams); pluginAppServer = attemptAppServer; const startupResult = await startCodexAttemptThread({ attemptClientFactory, @@ -1641,7 +1730,7 @@ export async function runCodexAppServerAttempt( appServer: attemptAppServer, pluginConfig, computerUseConfig, - startupAuthProfileId, + startupAuthProfileId: startupClientAuthProfileId, startupAuthAccountCacheKey, startupEnvApiKeyCacheKey, agentDir, @@ -1673,27 +1762,18 @@ export async function runCodexAppServerAttempt( turnRouter = startupResult.turnRouter; turnRoute = startupResult.turnRoute; pluginAppServer = startupResult.pluginAppServer; - if (thread.lifecycle.action === "started") { - const activeThreadReviewerPolicyContext = resolveCodexModelBackedReviewerPolicyContext({ - provider: params.provider, - model: params.modelId, - bindingModelProvider: thread.modelProvider, - bindingModel: thread.model, - nativeAuthProfile: isCodexAppServerNativeAuthProfile({ - authProfileId: startupAuthProfileId, - authProfileStore: params.authProfileStore, - agentDir, - config: params.config, - }), - }); - const activeThreadConfiguredAppServer = resolveCodexAppServerRuntimeOptions({ - pluginConfig, - execPolicy, + if ( + usesSupervisionConnection && + (thread.connectionScope !== "supervision" || + thread.supervisionSourceThreadId !== startupBinding?.supervisionSourceThreadId) + ) { + throw new Error("Codex supervised thread lost its private connection ownership"); + } + if (thread.lifecycle.action === "started" || thread.lifecycle.action === "forked") { + const activeThreadReviewerPolicyContext = resolveReviewerPolicyContext(thread); + const activeThreadConfiguredAppServer = resolveRuntimeOptionsForBinding({ modelProvider: activeThreadReviewerPolicyContext.modelProvider, model: activeThreadReviewerPolicyContext.model, - config: params.config, - agentDir, - openClawSandboxActive: sandbox?.enabled === true, }); const activeThreadAppServer = resolveCodexAppServerForModelProvider({ appServer: activeThreadConfiguredAppServer, @@ -2731,8 +2811,10 @@ export async function runCodexAppServerAttempt( const buildLlmInputEvent = () => ({ runId: params.runId, sessionId: params.sessionId, - provider: params.provider, - model: params.modelId, + provider: usesSupervisionConnection + ? (thread.modelProvider ?? effectiveRuntimeProviderId) + : params.provider, + model: usesSupervisionConnection ? (thread.model ?? effectiveRuntimeModelId) : params.modelId, systemPrompt: buildRenderedCodexDeveloperInstructions(), prompt: codexTurnPromptText, historyMessages: codexModelInputHistoryMessages, @@ -2741,16 +2823,18 @@ export async function runCodexAppServerAttempt( }); const buildCodexModelInputMessages = () => [ ...codexModelInputHistoryMessages, - buildCodexUserPromptMessage({ ...params, prompt: codexTurnPromptText }), + buildCodexUserPromptMessage({ ...runtimeParams, prompt: codexTurnPromptText }), ]; const codexModelCallBaseFields = { runId: params.runId, callId: codexModelCallId, ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), sessionId: params.sessionId, - provider: params.provider, - model: params.modelId, - api: params.model.api, + provider: usesSupervisionConnection + ? (thread.modelProvider ?? effectiveRuntimeProviderId) + : params.provider, + model: usesSupervisionConnection ? (thread.model ?? effectiveRuntimeModelId) : params.modelId, + api: usesSupervisionConnection ? runtimeParams.model.api : params.model.api, transport: appServer.start.transport, ...hookContextWindowFields, trace: codexModelCallTrace, @@ -2787,22 +2871,24 @@ export async function runCodexAppServerAttempt( }; const startCodexTurn = async (): Promise => { const activeTurnRoute = await ensureCurrentThreadRoute(); - const turnAppServer = withCodexAppServerFastModeServiceTier(pluginAppServer, params); + const turnAppServer = withCodexAppServerFastModeServiceTier(pluginAppServer, runtimeParams); pluginAppServer = turnAppServer; - const turnStartParams = buildTurnStartParams(params, { + const turnStartParams = buildTurnStartParams(runtimeParams, { threadId: thread.threadId, cwd: codexExecutionCwd, appServer: turnAppServer, promptText: codexTurnPromptText, sandboxPolicy: codexSandboxPolicy, environmentSelection: codexEnvironmentSelection, - model: thread.model, - modelProvider: thread.modelProvider, + ...(usesSupervisionConnection + ? {} + : { model: thread.model, modelProvider: thread.modelProvider }), turnScopedDeveloperInstructions: workspaceBootstrapContext.turnScopedDeveloperInstructions, skillsCollaborationInstructions, memoryCollaborationInstructions: workspaceBootstrapContext.memoryCollaborationInstructions, heartbeatCollaborationInstructions: workspaceBootstrapContext.heartbeatCollaborationInstructions, + preserveNativeTurnSettings: usesSupervisionConnection, }); codexModelCallDiagnostics.setRequestPayloadBytes(utf8JsonByteLength(turnStartParams)); // Keep turn/start diagnostics scoped to this attempt: resumed native work @@ -2910,6 +2996,7 @@ export async function runCodexAppServerAttempt( } if ( turn === undefined && + thread.connectionScope !== "supervision" && shouldUseFreshCodexThreadAfterContextEngineOverflow({ error: turnStartError, contextEngineActive: Boolean(activeContextEngine), @@ -3018,12 +3105,18 @@ export async function runCodexAppServerAttempt( event: { runId: params.runId, sessionId: params.sessionId, - provider: params.provider, - model: params.modelId, + provider: usesSupervisionConnection + ? (thread.modelProvider ?? effectiveRuntimeProviderId) + : params.provider, + model: usesSupervisionConnection + ? (thread.model ?? effectiveRuntimeModelId) + : params.modelId, ...hookContextWindowFields, - resolvedRef: - params.runtimePlan?.observability.resolvedRef ?? `${params.provider}/${params.modelId}`, - ...(params.runtimePlan?.observability.harnessId + resolvedRef: usesSupervisionConnection + ? `${thread.modelProvider ?? effectiveRuntimeProviderId}/${thread.model ?? effectiveRuntimeModelId}` + : (params.runtimePlan?.observability.resolvedRef ?? + `${params.provider}/${params.modelId}`), + ...(!usesSupervisionConnection && params.runtimePlan?.observability.harnessId ? { harnessId: params.runtimePlan.observability.harnessId } : {}), assistantTexts: [], @@ -3046,7 +3139,7 @@ export async function runCodexAppServerAttempt( ); const turnStartFailureMessages = [ ...historyMessages, - buildCodexUserPromptMessage({ ...params, prompt: codexTurnPromptText }), + buildCodexUserPromptMessage({ ...runtimeParams, prompt: codexTurnPromptText }), ]; await runCodexAgentEndHook(params, { event: { @@ -3239,8 +3332,14 @@ export async function runCodexAppServerAttempt( const shouldRetireClient = timedOut; if (shouldRetireClient) { void (async () => { - // Timed-out native turns cannot be safely resumed on the same thread. - await bindingStore.mutate(bindingIdentity, { kind: "clear", threadId: thread.threadId }); + // Supervised sessions stay native even after a suspect turn. Clearing + // their private scope would make the next attempt silently agent-home. + if (thread.connectionScope !== "supervision") { + await bindingStore.mutate(bindingIdentity, { + kind: "clear", + threadId: thread.threadId, + }); + } await retireCodexAppServerClientAfterTimedOutTurn(client, { threadId: thread.threadId, turnId: activeTurnId, @@ -3342,6 +3441,7 @@ export async function runCodexAppServerAttempt( }); } if ( + thread.connectionScope !== "supervision" && shouldUseFreshCodexThreadAfterContextEngineOverflow({ error: finalPromptError, contextEngineActive: Boolean(activeContextEngine), @@ -3488,7 +3588,7 @@ export async function runCodexAppServerAttempt( sessionFile: activeSessionFile, messagesSnapshot: finalMessages, prePromptMessageCount, - tokenBudget: params.contextTokenBudget, + tokenBudget: effectiveContextTokenBudget, runtimeContext: buildHarnessContextEngineRuntimeContextFromUsage({ attempt: buildActiveRunAttemptParams(), workspaceDir: effectiveWorkspace, @@ -3496,16 +3596,20 @@ export async function runCodexAppServerAttempt( agentDir, activeAgentId: sessionAgentId, contextEnginePluginId: activeContextEnginePluginIdLocal, - tokenBudget: params.contextTokenBudget, + tokenBudget: effectiveContextTokenBudget, lastCallUsage: result.attemptUsage, promptCache: result.promptCache, }), contextEngineHostSupport: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST, - providerId: params.provider, - requestedModelId: params.requestedModelId, - modelId: params.modelId, - fallbackReason: params.fallbackReason, - degradedReason: params.degradedReason, + providerId: usesSupervisionConnection + ? (thread.modelProvider ?? effectiveRuntimeProviderId) + : params.provider, + requestedModelId: usesSupervisionConnection ? undefined : params.requestedModelId, + modelId: usesSupervisionConnection + ? (thread.model ?? effectiveRuntimeModelId) + : params.modelId, + fallbackReason: usesSupervisionConnection ? undefined : params.fallbackReason, + degradedReason: usesSupervisionConnection ? undefined : params.degradedReason, runMaintenance: runHarnessContextEngineMaintenance, config: params.config, warn: (message) => embeddedAgentLog.warn(message), @@ -3516,12 +3620,18 @@ export async function runCodexAppServerAttempt( event: { runId: params.runId, sessionId: params.sessionId, - provider: params.provider, - model: params.modelId, + provider: usesSupervisionConnection + ? (thread.modelProvider ?? effectiveRuntimeProviderId) + : params.provider, + model: usesSupervisionConnection + ? (thread.model ?? effectiveRuntimeModelId) + : params.modelId, ...hookContextWindowFields, - resolvedRef: - params.runtimePlan?.observability.resolvedRef ?? `${params.provider}/${params.modelId}`, - ...(params.runtimePlan?.observability.harnessId + resolvedRef: usesSupervisionConnection + ? `${thread.modelProvider ?? effectiveRuntimeProviderId}/${thread.model ?? effectiveRuntimeModelId}` + : (params.runtimePlan?.observability.resolvedRef ?? + `${params.provider}/${params.modelId}`), + ...(!usesSupervisionConnection && params.runtimePlan?.observability.harnessId ? { harnessId: params.runtimePlan.observability.harnessId } : {}), assistantTexts: result.assistantTexts, @@ -3555,6 +3665,9 @@ export async function runCodexAppServerAttempt( threadId: thread.threadId, }); } catch (error) { + if (thread.connectionScope === "supervision") { + throw error; + } const clearedStaleBinding = await bindingStore.mutate(bindingIdentity, { kind: "clear", threadId: thread.threadId, @@ -3737,6 +3850,13 @@ async function clearCodexBindingAfterInvalidImagePayload( ); return; } + if (currentBinding?.connectionScope === "supervision") { + embeddedAgentLog.warn( + "codex app-server image payload error detected for supervised thread; preserving native binding", + fields, + ); + return; + } embeddedAgentLog.warn( "codex app-server image payload error detected; clearing thread binding", fields, diff --git a/extensions/codex/src/app-server/session-binding-store.ts b/extensions/codex/src/app-server/session-binding-store.ts index f21defc324cd..9e9673da0359 100644 --- a/extensions/codex/src/app-server/session-binding-store.ts +++ b/extensions/codex/src/app-server/session-binding-store.ts @@ -11,7 +11,10 @@ export type { StoredCodexAppServerBinding } from "./session-binding.js"; /** Defers schema compilation and auth loading until the first binding operation. */ export function createLazyCodexAppServerBindingStore( - state: Pick, "lookup" | "update">, + state: Pick< + PluginStateSyncKeyedStore, + "entries" | "lookup" | "update" + >, ): CodexAppServerBindingStore { let resolved: Promise | undefined; const store = () => @@ -20,12 +23,15 @@ export function createLazyCodexAppServerBindingStore( )); return { read: async (identity) => (await store()).read(identity), + hasOtherThreadOwner: async (threadId, currentIdentity) => + (await store()).hasOtherThreadOwner(threadId, currentIdentity), mutate: async (identity, mutation) => (await store()).mutate(identity, mutation), prepareSessionGenerationReclaim: async (identity) => (await store()).prepareSessionGenerationReclaim(identity), adoptSessionGeneration: async (identity, previousSessionId) => (await store()).adoptSessionGeneration(identity, previousSessionId), retireSessionGeneration: async (identity) => (await store()).retireSessionGeneration(identity), + withThreadArchiveFence: async (run) => (await store()).withThreadArchiveFence(run), withLease: async (identity, run) => (await store()).withLease(identity, run), }; } diff --git a/extensions/codex/src/app-server/session-binding.test-helpers.ts b/extensions/codex/src/app-server/session-binding.test-helpers.ts index 47c18a11a228..593b2ec18444 100644 --- a/extensions/codex/src/app-server/session-binding.test-helpers.ts +++ b/extensions/codex/src/app-server/session-binding.test-helpers.ts @@ -1,6 +1,8 @@ /** In-memory binding store helpers for Codex app-server tests. */ export * from "./session-binding.js"; import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime"; +import { resolveCodexSupervisionAppServerRuntimeOptions } from "./config.js"; +import { buildCodexAppServerConnectionFingerprint } from "./plugin-app-cache-key.js"; import { bindingStoreKey, createCodexAppServerBindingStore, @@ -46,6 +48,18 @@ export function createCodexTestBindingStore(): CodexAppServerBindingStore { return createCodexAppServerBindingStore(createCodexTestBindingStateStore()); } +export function buildCodexSupervisionTestConnectionFingerprint( + pluginConfig: unknown = { supervision: { enabled: true } }, +): string { + return buildCodexAppServerConnectionFingerprint( + resolveCodexSupervisionAppServerRuntimeOptions({ + pluginConfig, + env: {}, + requirementsToml: null, + }), + ); +} + const sharedStateStore = createCodexTestBindingStateStore(); export const testCodexAppServerBindingStore = createCodexAppServerBindingStore(sharedStateStore); const testSessionIdentities = new Map< diff --git a/extensions/codex/src/app-server/session-binding.test.ts b/extensions/codex/src/app-server/session-binding.test.ts index 872b14c0faac..d397db12edd6 100644 --- a/extensions/codex/src/app-server/session-binding.test.ts +++ b/extensions/codex/src/app-server/session-binding.test.ts @@ -94,6 +94,259 @@ describe("Codex app-server binding store", () => { }); }); + it("does not report the exact session or conversation binding owner as another owner", async () => { + const { state } = createStateStore(); + const store = createCodexAppServerBindingStore(state); + const sessionIdentity = { + kind: "session" as const, + agentId: "main", + sessionId: "session-current", + }; + await store.mutate(sessionIdentity, { + kind: "set", + binding: { threadId: "thread-session", cwd: "/repo" }, + }); + + await expect(store.hasOtherThreadOwner("thread-session", sessionIdentity)).resolves.toBe(false); + + const conversationIdentity = { kind: "conversation" as const, bindingId: "conversation-1" }; + await store.mutate(conversationIdentity, { + kind: "set", + binding: { threadId: "thread-conversation", cwd: "/repo" }, + }); + await expect( + store.hasOtherThreadOwner("thread-conversation", conversationIdentity), + ).resolves.toBe(false); + }); + + it("reports a different valid active binding owner", async () => { + const { state } = createStateStore(); + const store = createCodexAppServerBindingStore(state); + const currentIdentity = { + kind: "session" as const, + agentId: "main", + sessionId: "session-current", + }; + await store.mutate( + { kind: "conversation", bindingId: "conversation-owner" }, + { + kind: "set", + binding: { threadId: "thread-owned", cwd: "/repo" }, + }, + ); + + await expect(store.hasOtherThreadOwner("thread-owned", currentIdentity)).resolves.toBe(true); + }); + + it.each([ + { name: "a different generation", storedSessionId: "session-previous" }, + { name: "a missing generation", storedSessionId: undefined }, + ])("treats $name under the same stable key as another owner", async ({ storedSessionId }) => { + const { state, values } = createStateStore(); + const store = createCodexAppServerBindingStore(state); + const currentIdentity = { + kind: "session" as const, + agentId: "main", + sessionId: "session-current", + sessionKey: "agent:main:stable", + }; + values.set(bindingStoreKey(currentIdentity), { + version: 1, + state: "active", + binding: { threadId: "thread-stale-generation", cwd: "/repo" }, + ...(storedSessionId ? { sessionId: storedSessionId } : {}), + }); + + await expect( + store.hasOtherThreadOwner("thread-stale-generation", currentIdentity), + ).resolves.toBe(true); + }); + + it("fails closed on a malformed row during reverse ownership scans", async () => { + const { state, values } = createStateStore(); + const store = createCodexAppServerBindingStore(state); + const currentIdentity = { + kind: "session" as const, + agentId: "main", + sessionId: "session-current", + }; + values.set("conversation:invalid", { + version: 1, + state: "active", + binding: { threadId: "", cwd: "/repo" }, + } as never); + + await expect(store.hasOtherThreadOwner("thread-unowned", currentIdentity)).rejects.toThrow( + "Invalid Codex app-server binding row: conversation:invalid", + ); + }); + + it("ignores stale cleared rows during reverse ownership scans", async () => { + const { state, values } = createStateStore(); + const store = createCodexAppServerBindingStore(state); + const currentIdentity = { + kind: "session" as const, + agentId: "main", + sessionId: "session-current", + }; + values.set("conversation:cleared", { + version: 1, + state: "cleared", + retired: true, + binding: { threadId: "thread-unowned", cwd: "/repo" }, + } as never); + + await expect(store.hasOtherThreadOwner("thread-unowned", currentIdentity)).resolves.toBe(false); + }); + + it("fails closed on malformed pending supervision state", async () => { + expect( + readCodexAppServerThreadBinding({ + threadId: "thread-source", + cwd: "/repo", + preserveNativeModel: true, + pendingSupervisionBranch: { + sourceThreadId: "thread-source", + cleanupThreadIds: ["thread-probe", "thread-probe"], + }, + }), + ).toBeUndefined(); + expect( + readCodexAppServerThreadBinding({ + threadId: "thread-other", + cwd: "/repo", + preserveNativeModel: true, + pendingSupervisionBranch: { sourceThreadId: "thread-source" }, + }), + ).toBeUndefined(); + expect( + readCodexAppServerThreadBinding({ + threadId: "thread-source", + cwd: "/repo", + pendingSupervisionBranch: { sourceThreadId: "thread-source", unknown: true }, + }), + ).toBeUndefined(); + + const { state } = createStateStore(); + const store = createCodexAppServerBindingStore(state); + const identity = { + kind: "session" as const, + agentId: "main", + sessionId: "session-corrupt", + }; + state.register(bindingStoreKey(identity), { + version: 1, + state: "active", + binding: { + threadId: "thread-source", + cwd: "/repo", + preserveNativeModel: true, + pendingSupervisionBranch: { + sourceThreadId: "thread-source", + cleanupThreadIds: ["thread-source"], + }, + }, + } as never); + + await expect(store.read(identity)).rejects.toThrow("Invalid Codex app-server binding row"); + }); + + it("fails closed on malformed private supervision ownership", () => { + const valid = { + threadId: "thread-source", + cwd: "/repo", + connectionScope: "supervision", + supervisionSourceThreadId: "thread-source", + preserveNativeModel: true, + conversationSourceTransferComplete: true, + pendingSupervisionBranch: { sourceThreadId: "thread-source" }, + }; + + expect(readCodexAppServerThreadBinding({ ...valid, connectionScope: "user" })).toBeUndefined(); + expect(readCodexAppServerThreadBinding({ ...valid, connectionScope: {} })).toBeUndefined(); + expect( + readCodexAppServerThreadBinding({ ...valid, supervisionSourceThreadId: undefined }), + ).toBeUndefined(); + }); + + it("commits a pending supervision branch only from its exact cleanup snapshot", async () => { + const { state } = createStateStore(); + const store = createCodexAppServerBindingStore(state); + const identity = { + kind: "session" as const, + agentId: "main", + sessionId: "session-supervision-cas", + }; + const initial = { + sourceThreadId: "thread-source", + connectionFingerprint: "connection-one", + lastTurnId: "turn-terminal", + }; + await expect( + store.mutate(identity, { + kind: "set", + if: { kind: "absent" }, + binding: { + threadId: "thread-source", + cwd: "/repo", + connectionScope: "supervision", + supervisionSourceThreadId: "thread-source", + preserveNativeModel: true, + conversationSourceTransferComplete: true, + pendingSupervisionBranch: initial, + }, + }), + ).resolves.toBe(true); + const tracked = { ...initial, cleanupThreadIds: ["thread-probe"] }; + await expect( + store.mutate(identity, { + kind: "patch-pending-supervision-branch", + expected: { ...initial, connectionFingerprint: "connection-two" }, + pending: tracked, + }), + ).resolves.toBe(false); + await expect( + store.mutate(identity, { + kind: "patch-pending-supervision-branch", + expected: { ...initial, lastTurnId: "turn-other" }, + pending: tracked, + }), + ).resolves.toBe(false); + await expect( + store.mutate(identity, { + kind: "patch-pending-supervision-branch", + expected: initial, + pending: tracked, + }), + ).resolves.toBe(true); + await expect( + store.mutate(identity, { + kind: "commit-pending-supervision-branch", + expected: initial, + threadId: "thread-final", + patch: { model: "native-model", modelProvider: "native-provider" }, + }), + ).resolves.toBe(false); + await expect( + store.mutate(identity, { + kind: "commit-pending-supervision-branch", + expected: tracked, + threadId: "thread-final", + patch: { model: "native-model", modelProvider: "native-provider" }, + }), + ).resolves.toBe(true); + await expect(store.read(identity)).resolves.toEqual({ + threadId: "thread-final", + cwd: "/repo", + connectionScope: "supervision", + supervisionSourceThreadId: "thread-source", + preserveNativeModel: true, + conversationSourceTransferComplete: true, + model: "native-model", + modelProvider: "native-provider", + }); + }); + it("round-trips account app policy context", async () => { const { state } = createStateStore(); const store = createCodexAppServerBindingStore(state); @@ -558,6 +811,57 @@ describe("Codex app-server binding store", () => { await expect(store.read(current)).resolves.toMatchObject({ threadId: "thread-new" }); }); + it("preserves a stale private supervision binding instead of reclaiming it as empty", async () => { + const { state, values } = createStateStore(); + const store = createCodexAppServerBindingStore(state); + const previous = { + kind: "session" as const, + agentId: "main", + sessionId: "session-1", + sessionKey: "agent:main:telegram:supervised", + }; + const current = { ...previous, sessionId: "session-2" }; + await store.mutate(previous, { + kind: "set", + binding: { + threadId: "thread-supervised", + connectionScope: "supervision", + supervisionSourceThreadId: "thread-source", + cwd: "/repo", + model: "gpt-5.5", + modelProvider: "openai", + preserveNativeModel: true, + conversationSourceTransferComplete: true, + }, + }); + await expect( + store.mutate(previous, { + kind: "set", + binding: { threadId: "thread-replacement", cwd: "/other" }, + }), + ).resolves.toBe(false); + await expect( + store.mutate(previous, { kind: "clear", threadId: "thread-supervised" }), + ).resolves.toBe(false); + + await expect( + store.mutate(current, { + kind: "reclaim-generation", + expectedPreviousSessionId: previous.sessionId, + }), + ).resolves.toBe(false); + expect(values.get(bindingStoreKey(previous))).toMatchObject({ + state: "active", + sessionId: previous.sessionId, + binding: { threadId: "thread-supervised", connectionScope: "supervision" }, + }); + await expect(store.read(previous)).resolves.toMatchObject({ + threadId: "thread-supervised", + connectionScope: "supervision", + }); + await expect(store.read(current)).resolves.toBeUndefined(); + }); + it("fences a retired physical generation until its successor claims the stable key", async () => { const { state, values } = createStateStore(); const store = createCodexAppServerBindingStore(state); @@ -620,6 +924,59 @@ describe("Codex app-server binding store", () => { await expect(store.read(current)).resolves.toMatchObject({ threadId: "thread-new" }); }); + it("drains an in-flight ownership mutation and rejects late attachment during archive", async () => { + const fixture = createStateStore(); + const stateUpdate = fixture.state.update; + if (!stateUpdate) { + throw new Error("test state store must support atomic updates"); + } + const originalUpdate = stateUpdate.bind(fixture.state); + let startArchive: (() => void) | undefined; + fixture.state.update = (...args) => { + startArchive?.(); + startArchive = undefined; + return originalUpdate(...args); + }; + const store = createCodexAppServerBindingStore(fixture.state); + const firstIdentity = { kind: "conversation" as const, bindingId: "first" }; + const lateIdentity = { kind: "conversation" as const, bindingId: "late" }; + let releaseArchive!: () => void; + const archiveReleased = new Promise((resolve) => { + releaseArchive = resolve; + }); + let archive!: Promise; + startArchive = () => { + archive = store.withThreadArchiveFence(async () => { + await expect( + store.mutate(firstIdentity, { + kind: "patch", + threadId: "thread-before-archive", + patch: { cwd: "/updated" }, + }), + ).resolves.toBe(true); + await archiveReleased; + }); + }; + + await expect( + store.mutate(firstIdentity, { + kind: "set", + binding: { threadId: "thread-before-archive", cwd: "/repo" }, + }), + ).resolves.toBe(true); + await Promise.resolve(); + await expect( + store.mutate(lateIdentity, { + kind: "set", + binding: { threadId: "thread-late", cwd: "/repo" }, + }), + ).rejects.toThrow("native archive is in progress"); + releaseArchive(); + await expect(archive).resolves.toBeUndefined(); + await expect(store.read(firstIdentity)).resolves.toMatchObject({ cwd: "/updated" }); + await expect(store.read(lateIdentity)).resolves.toBeUndefined(); + }); + it("hashes stable session keys and keeps agent ownership distinct", () => { const sessionKey = "agent:main:telegram:private-peer@example.com"; const first = bindingStoreKey({ diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index 3187a0f76a7d..390ca1a7db73 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -82,6 +82,29 @@ const optionalTimestampSchema = z .refine((value) => Number.isFinite(Date.parse(value))) .optional() .catch(undefined); +const pendingSupervisionBranchSchema = z + .object({ + sourceThreadId: z.string().trim().min(1), + connectionFingerprint: z.string().trim().min(1).optional(), + lastTurnId: z.string().trim().min(1).optional(), + cleanupThreadIds: z.array(z.string().trim().min(1)).max(2).optional(), + }) + .strict() + .superRefine((pending, context) => { + const cleanupThreadIds = pending.cleanupThreadIds ?? []; + if (new Set(cleanupThreadIds).size !== cleanupThreadIds.length) { + context.addIssue({ + code: "custom", + message: "pending supervision cleanup thread ids must be unique", + }); + } + if (cleanupThreadIds.includes(pending.sourceThreadId)) { + context.addIssue({ + code: "custom", + message: "pending supervision cleanup cannot target its source", + }); + } + }); const contextEngineProjectionSchema = z .object({ schemaVersion: z.literal(1), @@ -131,55 +154,148 @@ const pluginAppPolicyContextSchema = z pluginAppIds: z.record(z.string(), z.array(z.string())).default({}), }) .strict(); -const threadBindingSchema = z.object({ - threadId: z.string().refine((value) => Boolean(value.trim())), - cwd: z.string(), - authProfileId: optionalStringSchema, - model: optionalStringSchema, - modelProvider: z - .string() - .transform((value) => value.trim()) - .pipe(z.string().min(1)) - .optional() - .catch(undefined), - approvalPolicy: z - .preprocess( - (value) => (value === "on-failure" ? "on-request" : value), - z.enum(["never", "on-request", "untrusted"]).optional(), - ) - .catch(undefined), - sandbox: z - .enum(["read-only", "workspace-write", "danger-full-access"]) - .optional() - .catch(undefined), - serviceTier: z - .preprocess( - normalizeCodexServiceTier, - z.custom((value) => typeof value === "string").optional(), - ) - .optional() - .catch(undefined), - networkProxyProfileName: optionalStringSchema, - networkProxyConfigFingerprint: optionalStringSchema, - dynamicToolsFingerprint: optionalStringSchema, - dynamicToolsContainDeferred: optionalBooleanSchema, - webSearchThreadConfigFingerprint: optionalStringSchema, - userMcpServersFingerprint: optionalStringSchema, - mcpServersFingerprint: optionalStringSchema, - nativeHookRelayGeneration: optionalNonBlankStringSchema, - appServerRuntimeFingerprint: optionalStringSchema, - pluginAppsFingerprint: optionalStringSchema, - pluginAppsInputFingerprint: optionalStringSchema, - pluginAppPolicyContext: pluginAppPolicyContextSchema.optional().catch(undefined), - contextEngine: contextEngineSchema.optional().catch(undefined), - environmentSelectionFingerprint: optionalStringSchema, - conversationStartId: optionalStringSchema, - conversationSourceTransferComplete: z.literal(true).optional().catch(undefined), - historyCoveredThrough: optionalTimestampSchema, -}); +const threadBindingSchema = z + .object({ + threadId: z.string().refine((value) => Boolean(value.trim())), + cwd: z.string(), + // Private runtime ownership. Only the supervision catalog creates this + // marker; public OpenClaw session metadata must never authorize user-home access. + connectionScope: z.literal("supervision").optional(), + supervisionSourceThreadId: z.string().trim().min(1).optional(), + authProfileId: optionalStringSchema, + model: optionalStringSchema, + // Codex App Server owns selection for supervised and adopted threads. Keep + // this marker across resumes so OpenClaw never substitutes a default or fallback. + preserveNativeModel: z.literal(true).optional().catch(undefined), + // Continue creates the OpenClaw Chat before native execution. This closed + // snapshot state is materialized only inside the fully configured harness. + pendingSupervisionBranch: pendingSupervisionBranchSchema.optional(), + modelProvider: z + .string() + .transform((value) => value.trim()) + .pipe(z.string().min(1)) + .optional() + .catch(undefined), + approvalPolicy: z + .preprocess( + (value) => (value === "on-failure" ? "on-request" : value), + z.enum(["never", "on-request", "untrusted"]).optional(), + ) + .catch(undefined), + sandbox: z + .enum(["read-only", "workspace-write", "danger-full-access"]) + .optional() + .catch(undefined), + serviceTier: z + .preprocess( + normalizeCodexServiceTier, + z.custom((value) => typeof value === "string").optional(), + ) + .optional() + .catch(undefined), + networkProxyProfileName: optionalStringSchema, + networkProxyConfigFingerprint: optionalStringSchema, + dynamicToolsFingerprint: optionalStringSchema, + dynamicToolsContainDeferred: optionalBooleanSchema, + webSearchThreadConfigFingerprint: optionalStringSchema, + userMcpServersFingerprint: optionalStringSchema, + mcpServersFingerprint: optionalStringSchema, + nativeHookRelayGeneration: optionalNonBlankStringSchema, + appServerRuntimeFingerprint: optionalStringSchema, + pluginAppsFingerprint: optionalStringSchema, + pluginAppsInputFingerprint: optionalStringSchema, + pluginAppPolicyContext: pluginAppPolicyContextSchema.optional().catch(undefined), + contextEngine: contextEngineSchema.optional().catch(undefined), + environmentSelectionFingerprint: optionalStringSchema, + conversationStartId: optionalStringSchema, + conversationSourceTransferComplete: z.literal(true).optional().catch(undefined), + historyCoveredThrough: optionalTimestampSchema, + }) + .superRefine((binding, context) => { + if (binding.connectionScope === "supervision") { + if (!binding.supervisionSourceThreadId) { + context.addIssue({ + code: "custom", + message: "supervision connection ownership requires its native source thread id", + }); + } + if (binding.preserveNativeModel !== true) { + context.addIssue({ + code: "custom", + message: "supervision connection ownership requires native model ownership", + }); + } + if (binding.conversationSourceTransferComplete !== true) { + context.addIssue({ + code: "custom", + message: "supervision connection ownership requires a completed source transfer", + }); + } + if (!binding.pendingSupervisionBranch && (!binding.model?.trim() || !binding.modelProvider)) { + context.addIssue({ + code: "custom", + message: "materialized supervision bindings require a native model and provider", + }); + } + } + if (binding.supervisionSourceThreadId && binding.connectionScope !== "supervision") { + context.addIssue({ + code: "custom", + message: "a supervision source thread id requires supervision connection ownership", + }); + } + if (!binding.pendingSupervisionBranch) { + return; + } + if (binding.threadId !== binding.pendingSupervisionBranch.sourceThreadId) { + context.addIssue({ + code: "custom", + message: "pending supervision source must match the provisional thread binding", + }); + } + if (binding.supervisionSourceThreadId !== binding.pendingSupervisionBranch.sourceThreadId) { + context.addIssue({ + code: "custom", + message: "pending supervision source must match its durable source identity", + }); + } + if (binding.preserveNativeModel !== true) { + context.addIssue({ + code: "custom", + message: "pending supervision bindings must defer model selection to Codex App Server", + }); + } + if (binding.connectionScope !== "supervision") { + context.addIssue({ + code: "custom", + message: "pending supervision bindings require supervision connection ownership", + }); + } + }); /** Durable Codex thread facts. Storage identity and schema stay outside this domain value. */ export type CodexAppServerThreadBinding = z.infer; +/** Persisted source snapshot and orphan-cleanup state for a supervised native branch. */ +export type CodexAppServerPendingSupervisionBranch = z.infer; + +export class CodexSupervisionBindingReplacementError extends Error { + constructor(threadId: string, operation: string) { + super( + `Refusing to replace supervised Codex thread ${threadId} while ${operation}; ` + + "its native user-home connection and model ownership must be preserved", + ); + this.name = "CodexSupervisionBindingReplacementError"; + } +} + +export function assertCodexBindingMayBeReplaced( + binding: CodexAppServerThreadBinding | undefined, + operation: string, +): void { + if (binding?.connectionScope === "supervision") { + throw new CodexSupervisionBindingReplacementError(binding.threadId, operation); + } +} /** Context-engine state persisted with a Codex app-server thread binding. */ export type CodexAppServerContextEngineBinding = z.infer; /** Context-engine projection metadata used to guard resumed native threads. */ @@ -198,11 +314,27 @@ type CodexAppServerBindingMutation = threadId: string; patch: Partial>; } + | { + kind: "patch-pending-supervision-branch"; + expected: CodexAppServerPendingSupervisionBranch; + pending: CodexAppServerPendingSupervisionBranch; + } + | { + kind: "commit-pending-supervision-branch"; + expected: CodexAppServerPendingSupervisionBranch; + threadId: string; + patch: Partial>; + } | { kind: "reclaim-generation"; expectedPreviousSessionId: string; } - | { kind: "clear"; threadId?: string }; + | { + kind: "clear"; + threadId?: string; + /** Only failed creation may clear the exact provisional supervision owner. */ + expectedPendingSupervisionBranch?: CodexAppServerPendingSupervisionBranch; + }; export type CodexSessionGenerationAdoptionResult = "adopted" | "current" | "absent" | "conflict"; @@ -291,7 +423,7 @@ export function createStoredCodexAppServerBinding( type BindingStateStore = Pick< PluginStateSyncKeyedStore, - "lookup" | "update" + "entries" | "lookup" | "update" >; type BindingLeaseOwner = { @@ -305,6 +437,10 @@ function bindingLeaseLostError(key: string, cause?: unknown): Error { export type CodexAppServerBindingStore = { read(identity: CodexAppServerBindingIdentity): Promise; + hasOtherThreadOwner( + threadId: string, + currentIdentity?: CodexAppServerBindingIdentity, + ): Promise; mutate( identity: CodexAppServerBindingIdentity, mutation: CodexAppServerBindingMutation, @@ -319,6 +455,7 @@ export type CodexAppServerBindingStore = { retireSessionGeneration( identity: Extract, ): Promise; + withThreadArchiveFence(run: () => Promise): Promise; withLease(identity: CodexAppServerBindingIdentity, run: () => Promise): Promise; }; @@ -371,6 +508,46 @@ export function createCodexAppServerBindingStore( throw new Error("Codex app-server bindings require atomic plugin-state updates"); } const leaseContext = new AsyncLocalStorage>(); + const archiveContext = new AsyncLocalStorage(); + let activeBindingMutations = 0; + let pendingArchives = 0; + let archiveTail = Promise.resolve(); + let bindingMutationsDrained: (() => void)[] = []; + + const waitForBindingMutations = async (): Promise => { + if (activeBindingMutations === 0) { + return; + } + await new Promise((resolve) => { + bindingMutationsDrained.push(resolve); + }); + }; + + const runBindingMutation = async (run: () => Promise): Promise => { + if (archiveContext.getStore() === true) { + return await run(); + } + // Archive validates the complete native subtree against one stable ownership + // snapshot. Reject late mutations so a stale caller cannot attach after archive. + if (pendingArchives > 0) { + throw new Error( + "Codex binding mutation blocked while a native archive is in progress; retry", + ); + } + activeBindingMutations += 1; + try { + return await run(); + } finally { + activeBindingMutations -= 1; + if (activeBindingMutations === 0) { + const drained = bindingMutationsDrained; + bindingMutationsDrained = []; + for (const resolve of drained) { + resolve(); + } + } + } + }; const renewLease = (key: string, owner: BindingLeaseOwner): void => { if (owner.failure) { @@ -479,6 +656,25 @@ export function createCodexAppServerBindingStore( : undefined; }, + async hasOtherThreadOwner(threadId, currentIdentity) { + const currentKey = currentIdentity ? bindingStoreKey(currentIdentity) : undefined; + return state.entries().some(({ key, value }) => { + const stored = readStoredCodexAppServerBinding(value); + if (!stored) { + throw new Error(`Invalid Codex app-server binding row: ${key}`); + } + const isCurrentOwner = + currentIdentity !== undefined && + key === currentKey && + (currentIdentity.kind === "conversation" || + stored.sessionId === currentIdentity.sessionId.trim()); + if (stored.state !== "active" || stored.binding.threadId !== threadId || isCurrentOwner) { + return false; + } + return true; + }); + }, + async prepareSessionGenerationReclaim(identity) { const key = bindingStoreKey(identity); const raw = state.lookup(key); @@ -500,159 +696,220 @@ export function createCodexAppServerBindingStore( }, async mutate(identity, mutation) { - const key = bindingStoreKey(identity); - // A retained legacy sidecar may be revisited by doctor after runtime - // clear. Keep provenance so migration cannot resurrect its stale thread. - const retainLegacyClear = mutation.kind === "clear" && key.startsWith("conversation:legacy-"); - return await transactKey( - key, - (current, leaseToken) => { - const ownsGeneration = ownsStoredSessionGeneration(identity, current); - const ownedLease = - current?.lease && current.lease.token === leaseToken ? { lease: current.lease } : {}; - if (mutation.kind === "reclaim-generation") { - if (identity.kind !== "session" || !identity.sessionKey?.trim()) { - return { result: false }; - } - if (!current) { - return { result: true }; - } - if (ownsGeneration) { + return await runBindingMutation(async () => { + const key = bindingStoreKey(identity); + // A retained legacy sidecar may be revisited by doctor after runtime + // clear. Keep provenance so migration cannot resurrect its stale thread. + const retainLegacyClear = + mutation.kind === "clear" && key.startsWith("conversation:legacy-"); + return await transactKey( + key, + (current, leaseToken) => { + const ownsGeneration = ownsStoredSessionGeneration(identity, current); + const ownedLease = + current?.lease && current.lease.token === leaseToken ? { lease: current.lease } : {}; + if (mutation.kind === "reclaim-generation") { + if (identity.kind !== "session" || !identity.sessionKey?.trim()) { + return { result: false }; + } + if (!current) { + return { result: true }; + } + if (ownsGeneration) { + return { + result: current.state !== "cleared" || current.retired !== true, + }; + } + if (current.sessionId !== mutation.expectedPreviousSessionId) { + return { result: false }; + } + // A stale physical generation must never turn private user-home ownership into + // an ordinary empty binding. Supervision adoption has an explicit generation + // transfer path; every other successor fails closed and preserves this owner. + if (current.state === "active" && current.binding.connectionScope === "supervision") { + return { result: false }; + } return { - result: current.state !== "cleared" || current.retired !== true, + result: true, + next: { + version: 1, + state: "cleared", + sessionId: identity.sessionId, + ...ownedLease, + }, }; } - if (current.sessionId !== mutation.expectedPreviousSessionId) { + const storedActive = current?.state === "active" ? current : undefined; + const active = ownsGeneration ? storedActive : undefined; + const retiredGeneration = + current?.state === "cleared" && current.retired === true && ownsGeneration; + const preservesSupervisionOwner = + mutation.kind === "set" && + active?.binding.connectionScope === "supervision" && + isSameSupervisionOwner(active.binding, mutation.binding); + const clearsPendingSupervisionOwner = + mutation.kind === "clear" && + active?.binding.connectionScope === "supervision" && + matchesPendingSupervisionClear( + active.binding, + mutation.threadId, + mutation.expectedPendingSupervisionBranch, + ); + if ( + (mutation.kind === "set" && + ((mutation.if?.kind === "absent" && storedActive) || + (current !== undefined && !ownsGeneration) || + retiredGeneration || + (active?.binding.connectionScope === "supervision" && + !preservesSupervisionOwner))) || + (mutation.kind === "patch" && active?.binding.threadId !== mutation.threadId) || + ((mutation.kind === "patch-pending-supervision-branch" || + mutation.kind === "commit-pending-supervision-branch") && + !matchesPendingSupervisionBranch(active?.binding, mutation.expected)) || + (mutation.kind === "clear" && + ((mutation.threadId !== undefined && + active?.binding.threadId !== mutation.threadId) || + !ownsGeneration || + (active?.binding.connectionScope === "supervision" && + !clearsPendingSupervisionOwner))) + ) { return { result: false }; } + if (mutation.kind === "clear" && retiredGeneration) { + return { result: true }; + } + if (mutation.kind === "clear") { + return { + result: true, + next: { + version: 1, + state: "cleared", + ...storedSessionGeneration(identity, current), + ...ownedLease, + }, + }; + } + let binding: CodexAppServerThreadBinding; + if (mutation.kind === "set") { + binding = validateBindingForWrite(mutation.binding); + } else if (mutation.kind === "patch-pending-supervision-branch") { + binding = validateBindingForWrite({ + ...active!.binding, + pendingSupervisionBranch: mutation.pending, + }); + } else if (mutation.kind === "commit-pending-supervision-branch") { + binding = validateBindingForWrite({ + ...active!.binding, + ...mutation.patch, + threadId: mutation.threadId, + pendingSupervisionBranch: undefined, + }); + } else { + binding = validateBindingForWrite({ + ...active!.binding, + ...mutation.patch, + threadId: mutation.threadId, + }); + } return { result: true, next: { version: 1, - state: "cleared", - sessionId: identity.sessionId, - ...ownedLease, - }, - }; - } - const storedActive = current?.state === "active" ? current : undefined; - const active = ownsGeneration ? storedActive : undefined; - const retiredGeneration = - current?.state === "cleared" && current.retired === true && ownsGeneration; - if ( - (mutation.kind === "set" && - ((mutation.if?.kind === "absent" && storedActive) || - (current !== undefined && !ownsGeneration) || - retiredGeneration)) || - (mutation.kind === "patch" && active?.binding.threadId !== mutation.threadId) || - (mutation.kind === "clear" && - ((mutation.threadId !== undefined && - active?.binding.threadId !== mutation.threadId) || - !ownsGeneration)) - ) { - return { result: false }; - } - if (mutation.kind === "clear" && retiredGeneration) { - return { result: true }; - } - if (mutation.kind === "clear") { - return { - result: true, - next: { - version: 1, - state: "cleared", + state: "active", + binding, ...storedSessionGeneration(identity, current), ...ownedLease, }, }; - } - let binding: CodexAppServerThreadBinding; - if (mutation.kind === "set") { - binding = validateBindingForWrite(mutation.binding); - } else { - binding = validateBindingForWrite({ - ...active!.binding, - ...mutation.patch, - threadId: mutation.threadId, - }); - } - return { - result: true, - next: { - version: 1, - state: "active", - binding, - ...storedSessionGeneration(identity, current), - ...ownedLease, - }, - }; - }, - // Plain clears may expire immediately: a stale generation that re-sets - // the key afterwards is fenced by ownsStoredSessionGeneration on read - // and displaced via reclaim-generation; durable stable-key fences come - // from retireSessionGeneration, not runtime clears. - mutation.kind === "clear" && !retainLegacyClear && !leaseContext.getStore()?.has(key) - ? 1 - : undefined, - ); + }, + // Plain clears may expire immediately: a stale generation that re-sets + // the key afterwards is fenced by ownsStoredSessionGeneration on read + // and displaced via reclaim-generation; durable stable-key fences come + // from retireSessionGeneration, not runtime clears. + mutation.kind === "clear" && !retainLegacyClear && !leaseContext.getStore()?.has(key) + ? 1 + : undefined, + ); + }); }, async adoptSessionGeneration(identity, expectedPreviousSessionId) { - const key = bindingStoreKey(identity); - const expectedSessionId = expectedPreviousSessionId.trim(); - const targetSessionId = identity.sessionId.trim(); - if (!expectedSessionId) { - throw new Error("Codex session generation adoption requires the previous session id"); - } - // Context-engine compaction rotates the physical OpenClaw session before - // secondary native compaction. Compare both generations so a delayed hook - // cannot move a newer binding back to its stale predecessor. - return await transactKey(key, (current) => { - if (current?.state !== "active") { - return { result: "absent" as const }; + return await runBindingMutation(async () => { + const key = bindingStoreKey(identity); + const expectedSessionId = expectedPreviousSessionId.trim(); + const targetSessionId = identity.sessionId.trim(); + if (!expectedSessionId) { + throw new Error("Codex session generation adoption requires the previous session id"); } - if (current.sessionId === targetSessionId) { - return { result: "current" as const }; - } - if (current.sessionId !== expectedSessionId) { - return { result: "conflict" as const }; - } - return { - result: "adopted" as const, - next: { ...current, sessionId: targetSessionId }, - }; + // Context-engine compaction rotates the physical OpenClaw session before + // secondary native compaction. Compare both generations so a delayed hook + // cannot move a newer binding back to its stale predecessor. + return await transactKey(key, (current) => { + if (current?.state !== "active") { + return { result: "absent" as const }; + } + if (current.sessionId === targetSessionId) { + return { result: "current" as const }; + } + if (current.sessionId !== expectedSessionId) { + return { result: "conflict" as const }; + } + return { + result: "adopted" as const, + next: { ...current, sessionId: targetSessionId }, + }; + }); }); }, async retireSessionGeneration(identity) { - const key = bindingStoreKey(identity); - return await transactKey( - key, - (current, leaseToken) => { - if (!current) { - return { result: "absent" as const }; - } - if (!ownsStoredSessionGeneration(identity, current)) { - return { result: "conflict" as const }; - } - if (current.state === "cleared" && current.retired === true) { - return { result: "applied" as const }; - } - return { - result: "applied" as const, - next: { - version: 1, - state: "cleared", - retired: true, - ...storedSessionGeneration(identity, current), - ...(current.lease && current.lease.token === leaseToken - ? { lease: current.lease } - : {}), - }, - }; - }, - identity.sessionKey?.trim() ? undefined : PHYSICAL_SESSION_RETIRE_TTL_MS, + return await runBindingMutation(async () => { + const key = bindingStoreKey(identity); + return await transactKey( + key, + (current, leaseToken) => { + if (!current) { + return { result: "absent" as const }; + } + if (!ownsStoredSessionGeneration(identity, current)) { + return { result: "conflict" as const }; + } + if (current.state === "cleared" && current.retired === true) { + return { result: "applied" as const }; + } + return { + result: "applied" as const, + next: { + version: 1, + state: "cleared", + retired: true, + ...storedSessionGeneration(identity, current), + ...(current.lease && current.lease.token === leaseToken + ? { lease: current.lease } + : {}), + }, + }; + }, + identity.sessionKey?.trim() ? undefined : PHYSICAL_SESSION_RETIRE_TTL_MS, + ); + }); + }, + + async withThreadArchiveFence(run) { + pendingArchives += 1; + const operation = archiveTail.then(async () => { + await waitForBindingMutations(); + return await archiveContext.run(true, run); + }); + archiveTail = operation.then( + () => undefined, + () => undefined, ); + try { + return await operation; + } finally { + pendingArchives -= 1; + } }, async withLease(identity, run) { @@ -763,6 +1020,56 @@ export function createCodexAppServerBindingStore( }; } +function matchesPendingSupervisionBranch( + binding: CodexAppServerThreadBinding | undefined, + expected: CodexAppServerPendingSupervisionBranch, +): boolean { + const pending = binding?.pendingSupervisionBranch; + if (!pending || binding?.threadId !== expected.sourceThreadId) { + return false; + } + if ( + pending.sourceThreadId !== expected.sourceThreadId || + pending.connectionFingerprint !== expected.connectionFingerprint || + pending.lastTurnId !== expected.lastTurnId + ) { + return false; + } + const currentCleanup = pending.cleanupThreadIds ?? []; + const expectedCleanup = expected.cleanupThreadIds ?? []; + return ( + currentCleanup.length === expectedCleanup.length && + currentCleanup.every((threadId, index) => threadId === expectedCleanup[index]) + ); +} + +function isSameSupervisionOwner( + current: CodexAppServerThreadBinding, + replacement: CodexAppServerThreadBinding, +): boolean { + return ( + replacement.connectionScope === "supervision" && + replacement.threadId === current.threadId && + replacement.supervisionSourceThreadId === current.supervisionSourceThreadId + ); +} + +function matchesPendingSupervisionClear( + binding: CodexAppServerThreadBinding, + threadId: string | undefined, + expected: CodexAppServerPendingSupervisionBranch | undefined, +): boolean { + if (!expected) { + return false; + } + const sourceThreadId = expected.sourceThreadId; + return ( + threadId === sourceThreadId && + binding.supervisionSourceThreadId === sourceThreadId && + matchesPendingSupervisionBranch(binding, expected) + ); +} + /** Stable plugin-state key for one current binding owner. */ export function bindingStoreKey(identity: CodexAppServerBindingIdentity): string { if (identity.kind === "session") { diff --git a/extensions/codex/src/app-server/side-question.test.ts b/extensions/codex/src/app-server/side-question.test.ts index d46d8a19079e..e7030fdbc73b 100644 --- a/extensions/codex/src/app-server/side-question.test.ts +++ b/extensions/codex/src/app-server/side-question.test.ts @@ -14,6 +14,8 @@ import { } from "openclaw/plugin-sdk/hook-runtime"; import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveCodexSupervisionAppServerRuntimeOptions } from "./config.js"; +import { buildCodexAppServerConnectionFingerprint } from "./plugin-app-cache-key.js"; import type { CodexServerNotification, JsonObject, JsonValue, RpcRequest } from "./protocol.js"; import { createCodexTestBindingStore, @@ -29,6 +31,14 @@ const toolExecuteMock = vi.fn(); const handleCodexAppServerApprovalRequestMock = vi.fn(); const resolveCodexProviderWebSearchSupportForClientMock = vi.fn(); +function supervisionConnectionFingerprint(): string { + return buildCodexAppServerConnectionFingerprint( + resolveCodexSupervisionAppServerRuntimeOptions({ + pluginConfig: { supervision: { enabled: true } }, + }), + ); +} + vi.mock("./session-binding.js", async (importOriginal) => ({ ...(await importOriginal()), isCodexAppServerNativeAuthProfile: (...args: unknown[]) => @@ -511,14 +521,13 @@ describe("runCodexAppServerSideQuestion", () => { "developerInstructions", "ephemeral", "model", - "personality", "sandbox", "threadId", "threadSource", ]); expect(forkParams?.threadId).toBe("parent-thread"); expect(forkParams?.model).toBe("gpt-5.5"); - expect(forkParams?.personality).toBe("none"); + expect(forkParams).not.toHaveProperty("personality"); expect(forkParams?.approvalPolicy).toBe("on-request"); expect(forkParams?.sandbox).toBe("workspace-write"); expect(forkParams?.ephemeral).toBe(true); @@ -636,6 +645,116 @@ describe("runCodexAppServerSideQuestion", () => { expect(new Set(runIds).size).toBe(2); }); + it("uses the default supervision runtime, native auth, and exact bound model pair", async () => { + const client = createFakeClient(); + getSharedCodexAppServerClientMock.mockResolvedValue(client); + readCodexAppServerBindingMock.mockResolvedValue({ + threadId: "parent-thread", + connectionScope: "supervision", + supervisionSourceThreadId: "source-thread", + cwd: "/tmp/workspace", + model: "gpt-5.5", + modelProvider: "openai", + appServerRuntimeFingerprint: supervisionConnectionFingerprint(), + preserveNativeModel: true, + conversationSourceTransferComplete: true, + }); + + await expect( + runCodexAppServerSideQuestion( + sideParams({ + provider: "anthropic", + model: "claude-opus-4-6", + runtimeModel: { + id: "claude-opus-4-6", + provider: "anthropic", + compat: { supportsTools: false }, + } as never, + authProfileId: "openai:outer", + }), + { pluginConfig: { supervision: { enabled: true } } }, + ), + ).resolves.toEqual({ text: "Side answer." }); + + expect(getSharedCodexAppServerClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + authProfileId: null, + startOptions: expect.objectContaining({ homeScope: "user" }), + }), + ); + const forkCall = client.request.mock.calls.find(([method]) => method === "thread/fork"); + expect(forkCall?.[1]).toMatchObject({ + threadId: "parent-thread", + model: "gpt-5.5", + modelProvider: "openai", + }); + const turnCall = client.request.mock.calls.find(([method]) => method === "turn/start"); + expect(turnCall?.[1]).toMatchObject({ model: "gpt-5.5" }); + expect(turnCall?.[1]).not.toHaveProperty("effort"); + expect(turnCall?.[1]).not.toHaveProperty("collaborationMode"); + expect(turnCall?.[1]).not.toHaveProperty("personality"); + expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ modelProvider: "openai", modelId: "gpt-5.5" }), + ); + }); + + it("rejects an incomplete supervised model pair before selecting a client", async () => { + readCodexAppServerBindingMock.mockResolvedValue({ + threadId: "parent-thread", + connectionScope: "supervision", + supervisionSourceThreadId: "source-thread", + cwd: "/tmp/workspace", + model: "gpt-5.5", + appServerRuntimeFingerprint: supervisionConnectionFingerprint(), + preserveNativeModel: true, + conversationSourceTransferComplete: true, + }); + + await expect( + runCodexAppServerSideQuestion(sideParams(), { + pluginConfig: { supervision: { enabled: true } }, + }), + ).rejects.toThrow("missing its native model and provider"); + expect(getSharedCodexAppServerClientMock).not.toHaveBeenCalled(); + }); + + it("cleans up a supervised fork that returns a different native model pair", async () => { + const client = createFakeClient(); + getSharedCodexAppServerClientMock.mockResolvedValue(client); + readCodexAppServerBindingMock.mockResolvedValue({ + threadId: "parent-thread", + connectionScope: "supervision", + supervisionSourceThreadId: "source-thread", + cwd: "/tmp/workspace", + model: "gpt-5.4", + modelProvider: "openai", + appServerRuntimeFingerprint: supervisionConnectionFingerprint(), + preserveNativeModel: true, + conversationSourceTransferComplete: true, + }); + + await expect( + runCodexAppServerSideQuestion(sideParams(), { + pluginConfig: { supervision: { enabled: true } }, + }), + ).rejects.toThrow("did not preserve its native model and provider"); + + expect(client.request.mock.calls.map(([method]) => method)).toEqual([ + "thread/fork", + "thread/unsubscribe", + ]); + expect(client.request).not.toHaveBeenCalledWith( + "thread/inject_items", + expect.anything(), + expect.anything(), + ); + expect(client.request).not.toHaveBeenCalledWith( + "turn/start", + expect.anything(), + expect.anything(), + ); + }); + it("replays app-scoped reviewer policy into side-thread forks", async () => { const client = createFakeClient(); getSharedCodexAppServerClientMock.mockResolvedValue(client); diff --git a/extensions/codex/src/app-server/side-question.ts b/extensions/codex/src/app-server/side-question.ts index b805048ba9b7..6fd302108eef 100644 --- a/extensions/codex/src/app-server/side-question.ts +++ b/extensions/codex/src/app-server/side-question.ts @@ -22,13 +22,16 @@ import { loadExecApprovals } from "openclaw/plugin-sdk/exec-approvals-runtime"; import { readCodexSupportedReasoningEfforts } from "../../provider.js"; import { resolveCodexAppServerForModelProvider } from "./app-server-policy.js"; import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js"; +import { + requireCodexSupervisionModelSelection, + resolveCodexBindingAppServerConnection, +} from "./binding-connection.js"; import { ensureCodexAppServerClientRuntime } from "./client-runtime.js"; import { isCodexAppServerApprovalRequest, type CodexAppServerClient } from "./client.js"; import { canUseCodexModelBackedApprovalsReviewerForModel, readCodexPluginConfig, resolveOpenClawExecPolicyForCodexAppServer, - resolveCodexAppServerRuntimeOptions, resolveCodexModelBackedReviewerPolicyContext, shouldAutoApproveCodexAppServerApprovals, type CodexAppServerRuntimeOptions, @@ -184,39 +187,50 @@ export async function runCodexAppServerSideQuestion( config: params.cfg, agentId: sessionAgentId, }); - const authProfileId = params.authProfileId ?? binding.authProfileId; - const modelProvider = - resolveCodexAppServerModelProvider({ - provider: params.provider, - authProfileId, - agentDir: params.agentDir, - config: params.cfg, - }) ?? - resolveCodexBindingModelProviderFallback({ - provider: params.provider, - currentModel: params.model, - bindingModel: binding.model, - bindingModelProvider: binding.modelProvider, - }); + const usesSupervisionConnection = binding.connectionScope === "supervision"; + const supervisionModelSelection = usesSupervisionConnection + ? requireCodexSupervisionModelSelection(binding) + : undefined; + const authProfileId = usesSupervisionConnection + ? undefined + : (params.authProfileId ?? binding.authProfileId); + const modelProvider = supervisionModelSelection + ? supervisionModelSelection.modelProvider + : (resolveCodexAppServerModelProvider({ + provider: params.provider, + authProfileId, + agentDir: params.agentDir, + config: params.cfg, + }) ?? + resolveCodexBindingModelProviderFallback({ + provider: params.provider, + currentModel: params.model, + bindingModel: binding.model, + bindingModelProvider: binding.modelProvider, + })); const modelSelection = resolveCodexAppServerRequestModelSelection({ - model: params.model, + model: supervisionModelSelection?.model ?? params.model, modelProvider, authProfileId, agentDir: params.agentDir, config: params.cfg, }); const reviewerPolicyContext = resolveCodexModelBackedReviewerPolicyContext({ - provider: params.provider, - model: params.model, + provider: usesSupervisionConnection ? "codex" : params.provider, + model: supervisionModelSelection?.model ?? params.model, bindingModelProvider: binding.modelProvider, bindingModel: binding.model, - nativeAuthProfile: isCodexAppServerNativeAuthProfile({ - authProfileId, - agentDir: params.agentDir, - config: params.cfg, - }), + nativeAuthProfile: + usesSupervisionConnection || + isCodexAppServerNativeAuthProfile({ + authProfileId, + agentDir: params.agentDir, + config: params.cfg, + }), }); - const appServer = resolveCodexAppServerRuntimeOptions({ + const connection = resolveCodexBindingAppServerConnection({ + binding, + authProfileId, pluginConfig, execPolicy, modelProvider: reviewerPolicyContext.modelProvider, @@ -224,9 +238,28 @@ export async function runCodexAppServerSideQuestion( config: params.cfg, agentDir: params.agentDir, }); + const appServer = connection.appServer; const cwd = binding.cwd || params.workspaceDir || process.cwd(); const runId = params.opts?.runId ?? randomUUID(); - const sideRunParams = buildSideRunAttemptParams(params, { cwd, authProfileId, runId }); + // A supervised side run inherits capability facts from the private binding. + // Outer model metadata may describe another provider or disable tools entirely. + const effectiveParams: AgentHarnessSideQuestionParams = supervisionModelSelection + ? { + ...params, + provider: supervisionModelSelection.modelProvider, + model: supervisionModelSelection.model, + runtimeModel: { + id: supervisionModelSelection.model, + name: supervisionModelSelection.model, + provider: supervisionModelSelection.modelProvider, + api: "openai-chatgpt-responses", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + } as NonNullable, + } + : params; + const sideRunParams = buildSideRunAttemptParams(effectiveParams, { cwd, authProfileId, runId }); const nativeExecutionBlock = resolveCodexNativeExecutionBlock({ config: sideRunParams.config, sessionKey: sideRunParams.sandboxSessionKey?.trim() || sideRunParams.sessionKey, @@ -245,7 +278,7 @@ export async function runCodexAppServerSideQuestion( const client = await getLeasedSharedCodexAppServerClient({ startOptions: appServer.start, timeoutMs: appServer.requestTimeoutMs, - authProfileId, + authProfileId: connection.clientAuthProfileId, agentDir: params.agentDir, config: params.cfg, }); @@ -351,7 +384,7 @@ export async function runCodexAppServerSideQuestion( }) : "unsupported"; const { toolBridge, webSearchPlan } = await createCodexSideToolBridge({ - params, + params: effectiveParams, cwd, pluginConfig, sessionAgentId, @@ -364,7 +397,7 @@ export async function runCodexAppServerSideQuestion( // stays installed once per client instead of once per side question. ensureCodexAppServerClientRuntime(client, { agentDir: params.agentDir, - authProfileId, + authProfileId: connection.requestAuthProfileId, config: params.cfg, }); removeRequestHandler = client.addRequestHandler(async (request) => { @@ -525,7 +558,6 @@ export async function runCodexAppServerSideQuestion( threadId: binding.threadId, model: modelSelection.model, ...(modelSelection.modelProvider ? { modelProvider: modelSelection.modelProvider } : {}), - personality: CODEX_NATIVE_PERSONALITY_NONE, cwd, approvalPolicy, approvalsReviewer: modelScopedAppServer.approvalsReviewer, @@ -540,6 +572,15 @@ export async function runCodexAppServerSideQuestion( ), ); childThreadId = forkResponse.thread.id; + if ( + supervisionModelSelection && + (forkResponse.model !== supervisionModelSelection.model || + forkResponse.modelProvider !== supervisionModelSelection.modelProvider) + ) { + throw new Error( + "Codex supervised side thread did not preserve its native model and provider", + ); + } await client.request( "thread/inject_items", @@ -550,11 +591,13 @@ export async function runCodexAppServerSideQuestion( { timeoutMs: appServer.requestTimeoutMs, signal: params.opts?.abortSignal }, ); - const effort = resolveReasoningEffort( - params.resolvedThinkLevel ?? "off", - modelSelection.model, - readCodexSupportedReasoningEfforts(params.runtimeModel?.compat), - ); + const effort = usesSupervisionConnection + ? undefined + : resolveReasoningEffort( + params.resolvedThinkLevel ?? "off", + modelSelection.model, + readCodexSupportedReasoningEfforts(params.runtimeModel?.compat), + ); const turnResponse = assertCodexTurnStartResponse( await client.request( "turn/start", @@ -563,17 +606,21 @@ export async function runCodexAppServerSideQuestion( input: [{ type: "text", text: params.question.trim(), text_elements: [] }], cwd, model: modelSelection.model, - personality: CODEX_NATIVE_PERSONALITY_NONE, + ...(usesSupervisionConnection ? {} : { personality: CODEX_NATIVE_PERSONALITY_NONE }), ...(serviceTier ? { serviceTier } : {}), - effort, - collaborationMode: { - mode: "default", - settings: { - model: modelSelection.model, - reasoning_effort: effort, - developer_instructions: null, - }, - }, + ...(usesSupervisionConnection + ? {} + : { + effort, + collaborationMode: { + mode: "default" as const, + settings: { + model: modelSelection.model, + reasoning_effort: effort, + developer_instructions: null, + }, + }, + }), }, { timeoutMs: appServer.requestTimeoutMs, signal: params.opts?.abortSignal }, ), diff --git a/extensions/codex/src/app-server/startup-binding.test.ts b/extensions/codex/src/app-server/startup-binding.test.ts index 48b1757b0311..66027b8a428f 100644 --- a/extensions/codex/src/app-server/startup-binding.test.ts +++ b/extensions/codex/src/app-server/startup-binding.test.ts @@ -92,6 +92,94 @@ describe("Codex app-server startup binding", () => { expect(savedBinding?.threadId).toBe("thread-existing"); }); + it("never rotates a provisional supervision source binding", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const agentDir = path.join(tempDir, "agent"); + await writeExistingBinding(sessionFile, workspaceDir, { + connectionScope: "supervision", + supervisionSourceThreadId: "thread-existing", + preserveNativeModel: true, + conversationSourceTransferComplete: true, + pendingSupervisionBranch: { + sourceThreadId: "thread-existing", + lastTurnId: "turn-terminal", + }, + }); + await writeSessionRecord(sessionFile, { totalTokens: 999_999 }); + const rolloutDir = path.join(agentDir, "codex-home", "sessions"); + await fs.mkdir(rolloutDir, { recursive: true }); + await fs.writeFile( + path.join(rolloutDir, "rollout-thread-existing.jsonl"), + "x".repeat(2_000_000), + ); + + const binding = await rotateOversizedCodexAppServerStartupBinding({ + binding: await readCodexAppServerBinding(sessionFile), + sessionFile, + agentDir, + config: { + agents: { + defaults: { + compaction: { + truncateAfterCompaction: true, + maxActiveTranscriptBytes: "1k", + }, + }, + }, + } as never, + }); + + expect(binding).toMatchObject({ + threadId: "thread-existing", + pendingSupervisionBranch: { + sourceThreadId: "thread-existing", + lastTurnId: "turn-terminal", + }, + }); + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: "thread-existing", + pendingSupervisionBranch: { sourceThreadId: "thread-existing" }, + }); + }); + + it("never rotates a materialized supervised native thread", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const agentDir = path.join(tempDir, "agent"); + await writeExistingBinding(sessionFile, workspaceDir, { + connectionScope: "supervision", + supervisionSourceThreadId: "thread-source", + preserveNativeModel: true, + conversationSourceTransferComplete: true, + }); + await writeSessionRecord(sessionFile, { totalTokens: 999_999 }); + + const binding = await rotateOversizedCodexAppServerStartupBinding({ + binding: await readCodexAppServerBinding(sessionFile), + sessionFile, + agentDir, + projectedTurnTokens: 999_999, + config: { + agents: { + defaults: { + compaction: { truncateAfterCompaction: true, maxActiveTranscriptBytes: "1b" }, + }, + }, + } as never, + }); + + expect(binding).toMatchObject({ + threadId: "thread-existing", + connectionScope: "supervision", + supervisionSourceThreadId: "thread-source", + }); + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: "thread-existing", + connectionScope: "supervision", + }); + }); + it("reuses the session record cache while sessions.json is unchanged", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); diff --git a/extensions/codex/src/app-server/startup-binding.ts b/extensions/codex/src/app-server/startup-binding.ts index f3a34449ddb9..dcba9746891b 100644 --- a/extensions/codex/src/app-server/startup-binding.ts +++ b/extensions/codex/src/app-server/startup-binding.ts @@ -344,6 +344,11 @@ export async function rotateOversizedCodexAppServerStartupBinding(params: { if (!binding?.threadId) { return binding; } + // Native Codex owns compaction for supervised threads. Clearing this private + // scope marker would silently move the next turn back to the agent runtime. + if (binding.connectionScope === "supervision") { + return binding; + } const sessionRecord = await readCodexSessionRecordForSessionFile(params.sessionFile); const rolloutFiles = await listCodexAppServerRolloutFilesForThread( params.agentDir, diff --git a/extensions/codex/src/app-server/thread-archive-guard.ts b/extensions/codex/src/app-server/thread-archive-guard.ts new file mode 100644 index 000000000000..a4a1cd534a4d --- /dev/null +++ b/extensions/codex/src/app-server/thread-archive-guard.ts @@ -0,0 +1,95 @@ +import { isJsonObject, type CodexThreadListParams } from "./protocol.js"; +import type { CodexAppServerBindingStore } from "./session-binding.js"; + +const DESCENDANT_PAGE_LIMIT = 100; +const MAX_DESCENDANT_PAGES = 100; +const MAX_THREAD_ID_LENGTH = 256; +const MAX_CURSOR_LENGTH = 4096; + +function readBoundedId(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim(); + return normalized && normalized.length <= MAX_THREAD_ID_LENGTH ? normalized : undefined; +} + +function readNextCursor(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (typeof value !== "string" || !value.trim() || value.length > MAX_CURSOR_LENGTH) { + throw new Error("Codex app-server returned an invalid descendant-list cursor"); + } + return value; +} + +/** + * Native archive includes the spawned subtree. Enumerate that same subtree first so an + * OpenClaw-owned descendant cannot be stopped as an undocumented side effect. + */ +export async function assertCodexArchiveDescendantsUnowned(params: { + bindingStore: CodexAppServerBindingStore; + threadId: string; + listPage: (request: CodexThreadListParams) => Promise; + assertDescendantIdle: (threadId: string) => Promise; +}): Promise { + const ancestorThreadId = readBoundedId(params.threadId); + if (!ancestorThreadId) { + throw new Error("cannot verify Codex archive descendants for an invalid thread id"); + } + + const seenCursors = new Set(); + const seenThreadIds = new Set([ancestorThreadId]); + let cursor: string | undefined; + + for (let pageIndex = 0; pageIndex < MAX_DESCENDANT_PAGES; pageIndex += 1) { + const response = await params.listPage({ + ancestorThreadId, + archived: false, + limit: DESCENDANT_PAGE_LIMIT, + sortKey: "created_at", + sortDirection: "desc", + useStateDbOnly: true, + ...(cursor ? { cursor } : {}), + }); + if (!isJsonObject(response) || !Array.isArray(response.data)) { + throw new Error("Codex app-server returned an invalid descendant-list response"); + } + if (response.data.length > DESCENDANT_PAGE_LIMIT) { + throw new Error("Codex app-server exceeded the descendant-list page limit"); + } + + for (const value of response.data) { + if (!isJsonObject(value)) { + throw new Error("Codex app-server returned an invalid descendant thread"); + } + const descendantThreadId = readBoundedId(value.id); + if (!descendantThreadId) { + throw new Error("Codex app-server returned a descendant without a valid thread id"); + } + if (seenThreadIds.has(descendantThreadId)) { + throw new Error("Codex app-server returned a cyclic descendant thread list"); + } + seenThreadIds.add(descendantThreadId); + await params.assertDescendantIdle(descendantThreadId); + if (await params.bindingStore.hasOtherThreadOwner(descendantThreadId)) { + throw new Error( + "cannot archive a Codex thread while a spawned descendant is owned by an OpenClaw session", + ); + } + } + + const nextCursor = readNextCursor(response.nextCursor); + if (!nextCursor) { + return; + } + if (seenCursors.has(nextCursor)) { + throw new Error("Codex app-server returned a repeated descendant-list cursor"); + } + seenCursors.add(nextCursor); + cursor = nextCursor; + } + + throw new Error("Codex descendant enumeration exceeded its safety limit"); +} diff --git a/extensions/codex/src/app-server/thread-lifecycle.test.ts b/extensions/codex/src/app-server/thread-lifecycle.test.ts index fcd40b46776a..3fd75066a40b 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.test.ts @@ -5,8 +5,15 @@ import path from "node:path"; import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js"; +import { CodexAppServerRpcError } from "./client.js"; import { fingerprintCodexAppServerNetworkProxyConfigPatch } from "./config.js"; +import { buildCodexAppServerConnectionFingerprint } from "./plugin-app-cache-key.js"; import { CODEX_OPENCLAW_DIRECT_DYNAMIC_TOOL_NAMESPACE } from "./protocol.js"; +import { + sessionBindingIdentity, + type CodexAppServerBindingStore, + type CodexAppServerPendingSupervisionBranch, +} from "./session-binding.js"; import { resetCodexTestBindingStore, testCodexAppServerBindingStore, @@ -175,6 +182,79 @@ function createThreadLifecycleAppServerOptions(): Parameters< }; } +async function seedAdoptedThreadBinding(params: EmbeddedRunAttemptParams, cwd: string) { + const threadId = "thread-adopted"; + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult(threadId); + } + throw new Error(`unexpected method: ${method}`); + }); + await startOrResumeThread({ + client: { request } as never, + params, + cwd, + dynamicTools: [], + appServer: createThreadLifecycleAppServerOptions(), + }); + const identity = sessionBindingIdentity({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + agentId: params.agentId, + config: params.config, + }); + const patched = await testCodexAppServerBindingStore.mutate(identity, { + kind: "patch", + threadId, + patch: { + model: undefined, + modelProvider: undefined, + preserveNativeModel: true, + }, + }); + if (!patched) { + throw new Error("failed to seed adopted Codex thread binding"); + } + return { identity, threadId }; +} + +async function seedPendingSupervisionBinding(params: { + attempt: EmbeddedRunAttemptParams; + cwd: string; + pending: CodexAppServerPendingSupervisionBranch; +}) { + const pending = { + connectionFingerprint: buildCodexAppServerConnectionFingerprint( + createThreadLifecycleAppServerOptions(), + ), + ...params.pending, + }; + const identity = sessionBindingIdentity({ + sessionId: params.attempt.sessionId, + sessionKey: params.attempt.sessionKey, + agentId: params.attempt.agentId, + config: params.attempt.config, + }); + const written = await testCodexAppServerBindingStore.mutate(identity, { + kind: "set", + if: { kind: "absent" }, + binding: { + threadId: pending.sourceThreadId, + cwd: params.cwd, + connectionScope: "supervision", + supervisionSourceThreadId: pending.sourceThreadId, + preserveNativeModel: true, + pendingSupervisionBranch: pending, + conversationSourceTransferComplete: true, + historyCoveredThrough: new Date(0).toISOString(), + }, + }); + if (!written) { + throw new Error("failed to seed pending Codex supervision binding"); + } + return identity; +} + function threadStartResult(threadId = "thread-1") { return { thread: { @@ -210,6 +290,28 @@ function threadStartResult(threadId = "thread-1") { }; } +function nativeThreadResult(threadId: string, model: string, modelProvider: string) { + const response = threadStartResult(threadId); + return { + ...response, + model, + modelProvider, + thread: { ...response.thread, modelProvider }, + }; +} + +function sourceThread(params: { + threadId: string; + status?: "idle" | "active"; + turns?: Array>; +}) { + return { + ...threadStartResult(params.threadId).thread, + status: { type: params.status ?? "idle" }, + turns: params.turns ?? [], + }; +} + function createTimingLogger(traceEnabled: boolean): CodexThreadLifecycleTimingLogger { return { isEnabled: vi.fn((level: "trace") => level === "trace" && traceEnabled), @@ -566,6 +668,20 @@ describe("Codex app-server native code mode config", () => { expect(request.personality).toBe("none"); }); + it("omits OpenClaw model selection when adopting a native Codex thread", () => { + const request = buildThreadResumeParams(createAttemptParams({ provider: "codex" }), { + threadId: "thread-adopted", + model: "openclaw-model", + modelProvider: "openclaw-provider", + preserveNativeModel: true, + appServer: createAppServerOptions() as never, + developerInstructions: "test instructions", + }); + + expect(request).not.toHaveProperty("model"); + expect(request).not.toHaveProperty("modelProvider"); + }); + it("keeps Codex model personality disabled on turn/start", () => { const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), { threadId: "thread-1", @@ -576,6 +692,24 @@ describe("Codex app-server native code mode config", () => { expect(request.personality).toBe("none"); }); + it("does not overwrite native supervised turn settings", () => { + const params = createAttemptParams({ provider: "anthropic" }); + params.thinkLevel = "high"; + const request = buildTurnStartParams(params, { + threadId: "thread-supervised", + cwd: "/repo", + model: "native-model", + modelProvider: "native-provider", + appServer: createAppServerOptions() as never, + preserveNativeTurnSettings: true, + }); + + expect(request).not.toHaveProperty("model"); + expect(request).not.toHaveProperty("effort"); + expect(request).not.toHaveProperty("collaborationMode"); + expect(request).not.toHaveProperty("personality"); + }); + it("honors an explicit top-level reviewer on thread start and resume", () => { const appServer = { ...createAppServerOptions(), @@ -1236,6 +1370,1236 @@ describe("Codex app-server model provider selection", () => { }); }); +describe("Codex app-server adopted thread lifecycle", () => { + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-thread-adoption-")); + resetCodexTestBindingStore(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("keeps OpenClaw from overriding App Server model selection across resumes", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createThreadLifecycleParams(sessionFile, workspaceDir); + const { identity, threadId } = await seedAdoptedThreadBinding(params, workspaceDir); + let resumeCount = 0; + const request = vi.fn(async (method: string, _requestParams?: unknown) => { + if (method === "thread/read") { + return { thread: threadStartResult(threadId).thread }; + } + if (method === "thread/resume") { + resumeCount += 1; + return { + ...threadStartResult(threadId), + model: `native-model-${resumeCount}`, + modelProvider: resumeCount === 1 ? "lmstudio" : "ollama", + }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const commonParams = { + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [], + appServer: createThreadLifecycleAppServerOptions(), + }; + const firstBinding = await startOrResumeThread(commonParams); + const secondBinding = await startOrResumeThread(commonParams); + + expect(request.mock.calls.map(([method]) => method)).toEqual([ + "thread/read", + "thread/resume", + "thread/read", + "thread/resume", + ]); + expect(request.mock.calls[0]?.[1]).toEqual({ threadId, includeTurns: false }); + expect(request.mock.calls[2]?.[1]).toEqual({ threadId, includeTurns: false }); + expect(request.mock.calls[1]?.[1]).not.toHaveProperty("model"); + expect(request.mock.calls[1]?.[1]).not.toHaveProperty("modelProvider"); + expect(request.mock.calls[3]?.[1]).not.toHaveProperty("model"); + expect(request.mock.calls[3]?.[1]).not.toHaveProperty("modelProvider"); + expect(firstBinding).toMatchObject({ + model: "native-model-1", + modelProvider: "lmstudio", + preserveNativeModel: true, + }); + expect(secondBinding).toMatchObject({ + model: "native-model-2", + modelProvider: "ollama", + preserveNativeModel: true, + }); + + const persisted = await testCodexAppServerBindingStore.read(identity); + expect(persisted).toMatchObject({ + model: "native-model-2", + modelProvider: "ollama", + preserveNativeModel: true, + }); + }); + + it("rejects an adopted thread that is active in another runner before reserving it", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createThreadLifecycleParams(sessionFile, workspaceDir); + const { threadId } = await seedAdoptedThreadBinding(params, workspaceDir); + const request = vi.fn(async (method: string, _requestParams?: unknown) => { + if (method === "thread/read") { + return { + thread: { + ...threadStartResult(threadId).thread, + status: { type: "active" }, + }, + }; + } + throw new Error(`unexpected method: ${method}`); + }); + const reserveResumeThread = vi.fn(() => ({ release: vi.fn() })); + + await expect( + startOrResumeThread({ + client: { request } as never, + reserveResumeThread, + params, + cwd: workspaceDir, + dynamicTools: [], + appServer: createThreadLifecycleAppServerOptions(), + }), + ).rejects.toThrow("active in another runner"); + + expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/read"]); + expect(reserveResumeThread).not.toHaveBeenCalled(); + }); +}); + +describe("Codex app-server supervised branch lifecycle", () => { + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-supervision-")); + resetCodexTestBindingStore(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("materializes a model-locked canonical branch and injects the same visible snapshot once", async () => { + const sourceThreadId = "thread-source"; + const probeThreadId = "thread-probe"; + const finalThreadId = "thread-final"; + const lastTurnId = "turn-terminal"; + const workspaceDir = path.join(tempDir, "workspace"); + const attempt = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir); + attempt.modelId = "outer-global-default"; + const identity = await seedPendingSupervisionBinding({ + attempt, + cwd: workspaceDir, + pending: { sourceThreadId, lastTurnId }, + }); + const terminalSource = sourceThread({ + threadId: sourceThreadId, + turns: [ + { + id: lastTurnId, + status: "completed", + items: [ + { + id: "user-1", + type: "userMessage", + content: [{ type: "text", text: "Visible question" }], + }, + { id: "reasoning-1", type: "reasoning", text: "Private reasoning" }, + { + id: "assistant-1", + type: "agentMessage", + text: "Visible answer", + phase: "final_answer", + }, + { id: "tool-1", type: "commandExecution", command: "secret-tool" }, + ], + }, + ], + }); + const request = vi.fn(async (method: string, requestParams: unknown) => { + if (method === "thread/read") { + const threadId = (requestParams as { threadId?: string }).threadId; + return { + thread: + threadId === sourceThreadId + ? terminalSource + : sourceThread({ threadId: finalThreadId }), + }; + } + if (method === "thread/fork") { + return nativeThreadResult(probeThreadId, "native-effective", "native-provider"); + } + if (method === "thread/start" || method === "thread/resume") { + return nativeThreadResult(finalThreadId, "native-effective", "native-provider"); + } + if (method === "thread/inject_items" || method === "thread/archive") { + return {}; + } + throw new Error(`unexpected method: ${method}`); + }); + const dynamicTools = [ + { + type: "function" as const, + name: "message", + description: "Send a message", + inputSchema: { type: "object", properties: {} }, + }, + ]; + const commonParams = { + client: { request } as never, + params: attempt, + cwd: workspaceDir, + dynamicTools, + environmentSelection: [{ environmentId: "local", cwd: workspaceDir }], + appServer: createThreadLifecycleAppServerOptions(), + appServerRuntimeFingerprint: "codex-runtime-v1", + }; + + const materialized = await startOrResumeThread(commonParams); + + expect(request.mock.calls.map(([method]) => method)).toEqual([ + "thread/read", + "thread/fork", + "thread/start", + "thread/inject_items", + "thread/archive", + ]); + expect(request.mock.calls[0]?.[1]).toEqual({ + threadId: sourceThreadId, + includeTurns: true, + }); + const forkParams = request.mock.calls[1]?.[1] as Record; + expect(forkParams).toMatchObject({ + threadId: sourceThreadId, + lastTurnId, + excludeTurns: true, + }); + expect(forkParams).not.toHaveProperty("model"); + expect(forkParams).not.toHaveProperty("modelProvider"); + expect(forkParams).not.toHaveProperty("dynamicTools"); + expect(forkParams).not.toHaveProperty("environments"); + const startParams = request.mock.calls[2]?.[1] as Record; + expect(startParams).toMatchObject({ + model: "native-effective", + modelProvider: "native-provider", + dynamicTools, + environments: [{ environmentId: "local", cwd: workspaceDir }], + }); + expect(startParams.model).not.toBe(attempt.modelId); + expect(request.mock.calls[3]?.[1]).toEqual({ + threadId: finalThreadId, + items: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Visible question" }], + }, + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Visible answer" }], + phase: "final_answer", + }, + ], + }); + expect(JSON.stringify(request.mock.calls[3]?.[1])).not.toContain("Private reasoning"); + expect(JSON.stringify(request.mock.calls[3]?.[1])).not.toContain("secret-tool"); + expect(request.mock.calls[4]?.[1]).toEqual({ threadId: probeThreadId }); + expect(materialized).toMatchObject({ + threadId: finalThreadId, + model: "native-effective", + modelProvider: "native-provider", + preserveNativeModel: true, + conversationSourceTransferComplete: true, + lifecycle: { action: "forked" }, + }); + expect(materialized.pendingSupervisionBranch).toBeUndefined(); + expect(materialized.historyCoveredThrough).not.toBe(new Date(0).toISOString()); + await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject({ + threadId: finalThreadId, + model: "native-effective", + modelProvider: "native-provider", + preserveNativeModel: true, + conversationSourceTransferComplete: true, + appServerRuntimeFingerprint: buildCodexAppServerConnectionFingerprint(commonParams.appServer), + }); + + request.mockClear(); + const resumed = await startOrResumeThread({ + ...commonParams, + appServerRuntimeFingerprint: "codex-runtime-v2", + }); + + expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/read", "thread/resume"]); + expect(request.mock.calls[0]?.[1]).toEqual({ threadId: finalThreadId, includeTurns: false }); + expect(request.mock.calls[1]?.[1]).not.toHaveProperty("model"); + expect(request.mock.calls[1]?.[1]).not.toHaveProperty("modelProvider"); + expect(resumed).toMatchObject({ + threadId: finalThreadId, + preserveNativeModel: true, + conversationSourceTransferComplete: true, + lifecycle: { action: "resumed" }, + }); + await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject({ + appServerRuntimeFingerprint: buildCodexAppServerConnectionFingerprint(commonParams.appServer), + }); + }); + + it("rejects materialization after the supervised source connection changes", async () => { + const workspaceDir = path.join(tempDir, "workspace"); + const attempt = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir); + await seedPendingSupervisionBinding({ + attempt, + cwd: workspaceDir, + pending: { sourceThreadId: "thread-source" }, + }); + const request = vi.fn(); + const appServer = createThreadLifecycleAppServerOptions(); + appServer.start.command = "different-codex"; + + await expect( + startOrResumeThread({ + client: { request } as never, + params: attempt, + cwd: workspaceDir, + dynamicTools: [], + appServer, + }), + ).rejects.toThrow("source connection changed before branch materialization"); + expect(request).not.toHaveBeenCalled(); + }); + + it("recovers every persisted orphan before materializing a fresh canonical branch", async () => { + const sourceThreadId = "thread-source"; + const orphanProbeThreadId = "thread-orphan-probe"; + const orphanFinalThreadId = "thread-orphan-final"; + const probeThreadId = "thread-probe"; + const finalThreadId = "thread-final"; + const lastTurnId = "turn-terminal"; + const workspaceDir = path.join(tempDir, "workspace"); + const attempt = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir); + const identity = await seedPendingSupervisionBinding({ + attempt, + cwd: workspaceDir, + pending: { + sourceThreadId, + lastTurnId, + cleanupThreadIds: [orphanProbeThreadId, orphanFinalThreadId], + }, + }); + const connectionFingerprint = buildCodexAppServerConnectionFingerprint( + createThreadLifecycleAppServerOptions(), + ); + const mutations: Parameters[1][] = []; + const bindingStore: CodexAppServerBindingStore = { + ...testCodexAppServerBindingStore, + mutate: async (storeIdentity, mutation) => { + mutations.push(mutation); + return await testCodexAppServerBindingStore.mutate(storeIdentity, mutation); + }, + }; + const request = vi.fn(async (method: string, _requestParams?: unknown) => { + if (method === "thread/archive") { + return {}; + } + if (method === "thread/read") { + return { + thread: sourceThread({ + threadId: sourceThreadId, + turns: [{ id: lastTurnId, status: "completed", items: [] }], + }), + }; + } + if (method === "thread/fork") { + return nativeThreadResult(probeThreadId, "native-effective", "native-provider"); + } + if (method === "thread/start") { + return nativeThreadResult(finalThreadId, "native-effective", "native-provider"); + } + throw new Error(`unexpected method: ${method}`); + }); + + await expect( + startOrResumeThreadImpl({ + client: { request } as never, + bindingStore, + params: attempt, + cwd: workspaceDir, + dynamicTools: [], + appServer: createThreadLifecycleAppServerOptions(), + }), + ).resolves.toMatchObject({ + threadId: finalThreadId, + lifecycle: { action: "forked" }, + }); + expect(request.mock.calls.map(([method]) => method)).toEqual([ + "thread/archive", + "thread/archive", + "thread/read", + "thread/fork", + "thread/start", + "thread/archive", + ]); + expect(request.mock.calls.map(([, requestParams]) => requestParams)).toEqual([ + { threadId: orphanProbeThreadId }, + { threadId: orphanFinalThreadId }, + { threadId: sourceThreadId, includeTurns: true }, + expect.any(Object), + expect.any(Object), + { threadId: probeThreadId }, + ]); + expect(mutations[0]).toEqual({ + kind: "patch-pending-supervision-branch", + expected: { + sourceThreadId, + connectionFingerprint, + lastTurnId, + cleanupThreadIds: [orphanProbeThreadId, orphanFinalThreadId], + }, + pending: { sourceThreadId, connectionFingerprint, lastTurnId }, + }); + const persisted = await testCodexAppServerBindingStore.read(identity); + expect(persisted).toMatchObject({ threadId: finalThreadId }); + expect(persisted?.pendingSupervisionBranch).toBeUndefined(); + }); + + it("persists exact remaining orphan cleanup and performs no branch work after partial failure", async () => { + const sourceThreadId = "thread-source"; + const orphanProbeThreadId = "thread-orphan-probe"; + const orphanFinalThreadId = "thread-orphan-final"; + const workspaceDir = path.join(tempDir, "workspace"); + const attempt = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir); + const identity = await seedPendingSupervisionBinding({ + attempt, + cwd: workspaceDir, + pending: { + sourceThreadId, + cleanupThreadIds: [orphanProbeThreadId, orphanFinalThreadId], + }, + }); + const request = vi.fn(async (method: string, requestParams?: unknown) => { + const threadId = (requestParams as { threadId?: string } | undefined)?.threadId; + if (method === "thread/archive" && threadId === orphanProbeThreadId) { + return {}; + } + if (method === "thread/archive" && threadId === orphanFinalThreadId) { + throw new CodexAppServerRpcError( + { code: -32_000, message: "temporary archive failure" }, + method, + ); + } + if (method === "thread/unsubscribe") { + return {}; + } + throw new Error(`unexpected method: ${method}`); + }); + + await expect( + startOrResumeThread({ + client: { request } as never, + params: attempt, + cwd: workspaceDir, + dynamicTools: [], + appServer: createThreadLifecycleAppServerOptions(), + }), + ).rejects.toThrow(`cleanup must finish before retry: ${orphanFinalThreadId}`); + expect(request.mock.calls.map(([method]) => method)).toEqual([ + "thread/archive", + "thread/archive", + "thread/unsubscribe", + ]); + expect(request.mock.calls.some(([method]) => method === "thread/fork")).toBe(false); + expect(request.mock.calls.some(([method]) => method === "thread/start")).toBe(false); + await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject({ + threadId: sourceThreadId, + pendingSupervisionBranch: { + sourceThreadId, + cleanupThreadIds: [orphanFinalThreadId], + }, + }); + }); + + it("fails closed when persisted orphan cleanup loses its state CAS", async () => { + const sourceThreadId = "thread-source"; + const orphanProbeThreadId = "thread-orphan-probe"; + const orphanFinalThreadId = "thread-orphan-final"; + const workspaceDir = path.join(tempDir, "workspace"); + const attempt = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir); + const identity = await seedPendingSupervisionBinding({ + attempt, + cwd: workspaceDir, + pending: { + sourceThreadId, + cleanupThreadIds: [orphanProbeThreadId, orphanFinalThreadId], + }, + }); + const request = vi.fn(async (method: string, _requestParams?: unknown) => { + if (method === "thread/archive") { + return {}; + } + throw new Error(`unexpected method: ${method}`); + }); + const bindingStore: CodexAppServerBindingStore = { + ...testCodexAppServerBindingStore, + mutate: vi.fn(async (storeIdentity, mutation) => { + if ( + mutation.kind === "patch-pending-supervision-branch" && + mutation.expected.cleanupThreadIds?.length === 2 && + !mutation.pending.cleanupThreadIds + ) { + return false; + } + return await testCodexAppServerBindingStore.mutate(storeIdentity, mutation); + }), + }; + + await expect( + startOrResumeThreadImpl({ + client: { request } as never, + bindingStore, + params: attempt, + cwd: workspaceDir, + dynamicTools: [], + appServer: createThreadLifecycleAppServerOptions(), + }), + ).rejects.toThrow("recovering a supervised Codex branch"); + expect(request.mock.calls.map(([method]) => method)).toEqual([ + "thread/archive", + "thread/archive", + ]); + expect(request.mock.calls.some(([method]) => method === "thread/fork")).toBe(false); + expect(request.mock.calls.some(([method]) => method === "thread/start")).toBe(false); + await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject({ + threadId: sourceThreadId, + pendingSupervisionBranch: { + sourceThreadId, + cleanupThreadIds: [orphanProbeThreadId, orphanFinalThreadId], + }, + }); + }); + + it.each([ + { + name: "active source", + thread: sourceThread({ threadId: "thread-source", status: "active" }), + }, + { + name: "source with uncaptured turns", + thread: sourceThread({ + threadId: "thread-source", + turns: [{ id: "turn-late", status: "completed", items: [] }], + }), + }, + ])("fails closed for a zero-turn snapshot when the $name changed", async ({ thread }) => { + const workspaceDir = path.join(tempDir, "workspace"); + const attempt = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir); + await seedPendingSupervisionBinding({ + attempt, + cwd: workspaceDir, + pending: { sourceThreadId: "thread-source" }, + }); + const request = vi.fn(async (method: string, _requestParams?: unknown) => { + if (method === "thread/read") { + return { thread }; + } + throw new Error(`unexpected method: ${method}`); + }); + + await expect( + startOrResumeThread({ + client: { request } as never, + params: attempt, + cwd: workspaceDir, + dynamicTools: [], + appServer: createThreadLifecycleAppServerOptions(), + }), + ).rejects.toThrow("source changed after Continue"); + expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/read"]); + }); + + it("keeps a structured fork rejection retryable without touching the source", async () => { + const sourceThreadId = "thread-source"; + const workspaceDir = path.join(tempDir, "workspace"); + const attempt = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir); + const identity = await seedPendingSupervisionBinding({ + attempt, + cwd: workspaceDir, + pending: { sourceThreadId }, + }); + let forkAttempts = 0; + const request = vi.fn(async (method: string) => { + if (method === "thread/read") { + return { thread: sourceThread({ threadId: sourceThreadId }) }; + } + if (method === "thread/fork") { + forkAttempts += 1; + if (forkAttempts === 1) { + throw new CodexAppServerRpcError( + { code: -32_000, message: "temporary fork rejected" }, + method, + ); + } + return nativeThreadResult("thread-probe", "native-effective", "native-provider"); + } + if (method === "thread/start") { + return nativeThreadResult("thread-final", "native-effective", "native-provider"); + } + if (method === "thread/archive") { + return {}; + } + throw new Error(`unexpected method: ${method}`); + }); + const commonParams = { + client: { request } as never, + params: attempt, + cwd: workspaceDir, + dynamicTools: [], + appServer: createThreadLifecycleAppServerOptions(), + }; + + await expect(startOrResumeThread(commonParams)).rejects.toThrow("temporary fork rejected"); + expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/read", "thread/fork"]); + await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject({ + threadId: sourceThreadId, + pendingSupervisionBranch: { sourceThreadId }, + }); + + request.mockClear(); + await expect(startOrResumeThread(commonParams)).resolves.toMatchObject({ + threadId: "thread-final", + lifecycle: { action: "forked" }, + }); + expect(request.mock.calls.map(([method]) => method)).toEqual([ + "thread/read", + "thread/fork", + "thread/start", + "thread/archive", + ]); + }); + + it("tracks both materialized ids before observing abort and archives both", async () => { + const sourceThreadId = "thread-source"; + const probeThreadId = "thread-probe"; + const finalThreadId = "thread-final"; + const lastTurnId = "turn-terminal"; + const workspaceDir = path.join(tempDir, "workspace"); + const attempt = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir); + const identity = await seedPendingSupervisionBinding({ + attempt, + cwd: workspaceDir, + pending: { sourceThreadId, lastTurnId }, + }); + const abortController = new AbortController(); + const request = vi.fn(async (method: string, _requestParams?: unknown) => { + if (method === "thread/read") { + return { + thread: sourceThread({ + threadId: sourceThreadId, + turns: [{ id: lastTurnId, status: "completed", items: [] }], + }), + }; + } + if (method === "thread/fork") { + return nativeThreadResult(probeThreadId, "native-effective", "native-provider"); + } + if (method === "thread/start") { + abortController.abort("cancelled after canonical start"); + return nativeThreadResult(finalThreadId, "native-effective", "native-provider"); + } + if (method === "thread/archive") { + return {}; + } + throw new Error(`unexpected method: ${method}`); + }); + + await expect( + startOrResumeThread({ + client: { request } as never, + params: attempt, + cwd: workspaceDir, + dynamicTools: [], + appServer: createThreadLifecycleAppServerOptions(), + signal: abortController.signal, + }), + ).rejects.toThrow("cancelled after canonical start"); + expect(request.mock.calls.map(([method]) => method)).toEqual([ + "thread/read", + "thread/fork", + "thread/start", + "thread/archive", + "thread/archive", + ]); + expect(request.mock.calls.slice(3).map(([, params]) => params)).toEqual([ + { threadId: probeThreadId }, + { threadId: finalThreadId }, + ]); + const persisted = await testCodexAppServerBindingStore.read(identity); + expect(persisted).toMatchObject({ + threadId: sourceThreadId, + pendingSupervisionBranch: { sourceThreadId, lastTurnId }, + }); + expect(persisted?.pendingSupervisionBranch?.cleanupThreadIds).toBeUndefined(); + }); + + it("archives both materialized ids when canonical cleanup tracking loses its CAS", async () => { + const sourceThreadId = "thread-source"; + const probeThreadId = "thread-probe"; + const finalThreadId = "thread-final"; + const workspaceDir = path.join(tempDir, "workspace"); + const attempt = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir); + const identity = await seedPendingSupervisionBinding({ + attempt, + cwd: workspaceDir, + pending: { sourceThreadId }, + }); + const request = vi.fn(async (method: string, requestParams?: unknown) => { + if (method === "thread/read") { + return { thread: sourceThread({ threadId: sourceThreadId }) }; + } + if (method === "thread/fork") { + return nativeThreadResult(probeThreadId, "native-effective", "native-provider"); + } + if (method === "thread/start") { + return nativeThreadResult(finalThreadId, "native-effective", "native-provider"); + } + if (method === "thread/archive") { + return {}; + } + throw new Error(`unexpected method: ${method} ${JSON.stringify(requestParams)}`); + }); + const bindingStore: CodexAppServerBindingStore = { + ...testCodexAppServerBindingStore, + mutate: vi.fn(async (storeIdentity, mutation) => { + if ( + mutation.kind === "patch-pending-supervision-branch" && + mutation.pending.cleanupThreadIds?.join(",") === `${probeThreadId},${finalThreadId}` + ) { + return false; + } + return await testCodexAppServerBindingStore.mutate(storeIdentity, mutation); + }), + }; + const abandonClient = vi.fn(async () => undefined); + + await expect( + startOrResumeThreadImpl({ + client: { request } as never, + abandonClient, + bindingStore, + params: attempt, + cwd: workspaceDir, + dynamicTools: [], + appServer: createThreadLifecycleAppServerOptions(), + }), + ).rejects.toThrow("tracking supervised Codex branch cleanup"); + const archivedThreadIds = request.mock.calls + .filter(([method]) => method === "thread/archive") + .map(([, requestParams]) => (requestParams as { threadId: string }).threadId); + expect(archivedThreadIds).toEqual([probeThreadId, finalThreadId]); + expect(abandonClient).not.toHaveBeenCalled(); + const persisted = await testCodexAppServerBindingStore.read(identity); + expect(persisted).toMatchObject({ + threadId: sourceThreadId, + pendingSupervisionBranch: { + sourceThreadId, + cleanupThreadIds: [probeThreadId], + }, + }); + }); + + it("does not clean the committed canonical thread when post-commit diagnostics fail", async () => { + const sourceThreadId = "thread-source"; + const probeThreadId = "thread-probe"; + const finalThreadId = "thread-final"; + const workspaceDir = path.join(tempDir, "workspace"); + const attempt = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir); + const identity = await seedPendingSupervisionBinding({ + attempt, + cwd: workspaceDir, + pending: { sourceThreadId }, + }); + const request = vi.fn(async (method: string, _requestParams?: unknown) => { + if (method === "thread/read") { + return { thread: sourceThread({ threadId: sourceThreadId }) }; + } + if (method === "thread/fork") { + return nativeThreadResult(probeThreadId, "native-effective", "native-provider"); + } + if (method === "thread/start") { + return nativeThreadResult(finalThreadId, "native-effective", "native-provider"); + } + if (method === "thread/archive") { + return {}; + } + throw new Error(`unexpected method: ${method}`); + }); + const abandonClient = vi.fn(async () => undefined); + + await expect( + startOrResumeThread({ + client: { request } as never, + abandonClient, + params: attempt, + cwd: workspaceDir, + dynamicTools: [], + appServer: createThreadLifecycleAppServerOptions(), + timing: { + enabled: true, + now: () => 0, + log: { + isEnabled: () => true, + trace: () => { + throw new Error("timing log failed"); + }, + warn: vi.fn(), + }, + }, + }), + ).rejects.toThrow("timing log failed"); + expect( + request.mock.calls + .filter(([method]) => method === "thread/archive") + .map(([, requestParams]) => requestParams), + ).toEqual([{ threadId: probeThreadId }]); + expect(abandonClient).not.toHaveBeenCalled(); + const committedBinding = await testCodexAppServerBindingStore.read(identity); + expect(committedBinding).toMatchObject({ threadId: finalThreadId }); + expect(committedBinding).not.toHaveProperty("pendingSupervisionBranch"); + }); + + it("confirms an applied canonical commit after the binding write reports failure", async () => { + const sourceThreadId = "thread-source"; + const probeThreadId = "thread-probe"; + const finalThreadId = "thread-final"; + const workspaceDir = path.join(tempDir, "workspace"); + const attempt = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir); + const identity = await seedPendingSupervisionBinding({ + attempt, + cwd: workspaceDir, + pending: { sourceThreadId }, + }); + const request = vi.fn(async (method: string, _requestParams?: unknown) => { + if (method === "thread/read") { + return { thread: sourceThread({ threadId: sourceThreadId }) }; + } + if (method === "thread/fork") { + return nativeThreadResult(probeThreadId, "native-effective", "native-provider"); + } + if (method === "thread/start") { + return nativeThreadResult(finalThreadId, "native-effective", "native-provider"); + } + if (method === "thread/archive") { + return {}; + } + throw new Error(`unexpected method: ${method}`); + }); + const bindingStore: CodexAppServerBindingStore = { + ...testCodexAppServerBindingStore, + mutate: vi.fn(async (storeIdentity, mutation) => { + const result = await testCodexAppServerBindingStore.mutate(storeIdentity, mutation); + if (mutation.kind === "commit-pending-supervision-branch") { + throw new Error("binding write failed after commit"); + } + return result; + }), + }; + + await expect( + startOrResumeThreadImpl({ + client: { request } as never, + bindingStore, + params: attempt, + cwd: workspaceDir, + dynamicTools: [], + appServer: createThreadLifecycleAppServerOptions(), + }), + ).resolves.toMatchObject({ + threadId: finalThreadId, + lifecycle: { action: "forked" }, + }); + expect( + request.mock.calls + .filter(([method]) => method === "thread/archive") + .map(([, requestParams]) => requestParams), + ).toEqual([{ threadId: probeThreadId }]); + const committedBinding = await testCodexAppServerBindingStore.read(identity); + expect(committedBinding).toMatchObject({ threadId: finalThreadId }); + expect(committedBinding).not.toHaveProperty("pendingSupervisionBranch"); + }); + + it("rejects an applied commit when verification sees a changed connection", async () => { + const sourceThreadId = "thread-source"; + const probeThreadId = "thread-probe"; + const finalThreadId = "thread-final"; + const workspaceDir = path.join(tempDir, "workspace"); + const attempt = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir); + const identity = await seedPendingSupervisionBinding({ + attempt, + cwd: workspaceDir, + pending: { sourceThreadId }, + }); + const request = vi.fn(async (method: string, _requestParams?: unknown) => { + if (method === "thread/read") { + return { thread: sourceThread({ threadId: sourceThreadId }) }; + } + if (method === "thread/fork") { + return nativeThreadResult(probeThreadId, "native-effective", "native-provider"); + } + if (method === "thread/start") { + return nativeThreadResult(finalThreadId, "native-effective", "native-provider"); + } + if (method === "thread/archive") { + return {}; + } + throw new Error(`unexpected method: ${method}`); + }); + const bindingStore: CodexAppServerBindingStore = { + ...testCodexAppServerBindingStore, + read: vi.fn(async (storeIdentity) => { + const current = await testCodexAppServerBindingStore.read(storeIdentity); + if (current?.threadId !== finalThreadId || current.pendingSupervisionBranch) { + return current; + } + return { ...current, appServerRuntimeFingerprint: "changed-connection" }; + }), + mutate: vi.fn(async (storeIdentity, mutation) => { + const result = await testCodexAppServerBindingStore.mutate(storeIdentity, mutation); + if (mutation.kind === "commit-pending-supervision-branch") { + throw new Error("binding write failed after commit"); + } + return result; + }), + }; + const abandonClient = vi.fn(async () => undefined); + + await expect( + startOrResumeThreadImpl({ + client: { request } as never, + abandonClient, + bindingStore, + params: attempt, + cwd: workspaceDir, + dynamicTools: [], + appServer: createThreadLifecycleAppServerOptions(), + }), + ).rejects.toThrow(`binding changed while commit was uncertain: ${finalThreadId}`); + expect( + request.mock.calls + .filter(([method]) => method === "thread/archive") + .map(([, requestParams]) => requestParams), + ).toEqual([{ threadId: probeThreadId }]); + expect(abandonClient).toHaveBeenCalledOnce(); + await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject({ + threadId: finalThreadId, + }); + }); + + it("abandons without cleanup when a failed canonical commit cannot be verified", async () => { + const sourceThreadId = "thread-source"; + const probeThreadId = "thread-probe"; + const finalThreadId = "thread-final"; + const workspaceDir = path.join(tempDir, "workspace"); + const attempt = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir); + const identity = await seedPendingSupervisionBinding({ + attempt, + cwd: workspaceDir, + pending: { sourceThreadId }, + }); + const request = vi.fn(async (method: string, _requestParams?: unknown) => { + if (method === "thread/read") { + return { thread: sourceThread({ threadId: sourceThreadId }) }; + } + if (method === "thread/fork") { + return nativeThreadResult(probeThreadId, "native-effective", "native-provider"); + } + if (method === "thread/start") { + return nativeThreadResult(finalThreadId, "native-effective", "native-provider"); + } + if (method === "thread/archive") { + return {}; + } + throw new Error(`unexpected method: ${method}`); + }); + let commitFailed = false; + const bindingStore: CodexAppServerBindingStore = { + ...testCodexAppServerBindingStore, + read: vi.fn(async (storeIdentity) => { + if (commitFailed) { + throw new Error("binding verification read failed"); + } + return await testCodexAppServerBindingStore.read(storeIdentity); + }), + mutate: vi.fn(async (storeIdentity, mutation) => { + if (mutation.kind === "commit-pending-supervision-branch") { + commitFailed = true; + throw new Error("binding commit failed"); + } + return await testCodexAppServerBindingStore.mutate(storeIdentity, mutation); + }), + }; + const abandonClient = vi.fn(async () => undefined); + + await expect( + startOrResumeThreadImpl({ + client: { request } as never, + abandonClient, + bindingStore, + params: attempt, + cwd: workspaceDir, + dynamicTools: [], + appServer: createThreadLifecycleAppServerOptions(), + }), + ).rejects.toThrow(`binding could not be verified: ${finalThreadId}`); + expect( + request.mock.calls + .filter(([method]) => method === "thread/archive") + .map(([, requestParams]) => requestParams), + ).toEqual([{ threadId: probeThreadId }]); + expect(abandonClient).toHaveBeenCalledOnce(); + await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject({ + threadId: sourceThreadId, + pendingSupervisionBranch: { + sourceThreadId, + cleanupThreadIds: [finalThreadId], + }, + }); + }); + + it("abandons without cleanup when failed commit verification sees a changed connection", async () => { + const sourceThreadId = "thread-source"; + const probeThreadId = "thread-probe"; + const finalThreadId = "thread-final"; + const workspaceDir = path.join(tempDir, "workspace"); + const attempt = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir); + const identity = await seedPendingSupervisionBinding({ + attempt, + cwd: workspaceDir, + pending: { sourceThreadId }, + }); + const request = vi.fn(async (method: string, _requestParams?: unknown) => { + if (method === "thread/read") { + return { thread: sourceThread({ threadId: sourceThreadId }) }; + } + if (method === "thread/fork") { + return nativeThreadResult(probeThreadId, "native-effective", "native-provider"); + } + if (method === "thread/start") { + return nativeThreadResult(finalThreadId, "native-effective", "native-provider"); + } + if (method === "thread/archive") { + return {}; + } + throw new Error(`unexpected method: ${method}`); + }); + let commitFailed = false; + const bindingStore: CodexAppServerBindingStore = { + ...testCodexAppServerBindingStore, + read: vi.fn(async (storeIdentity) => { + const current = await testCodexAppServerBindingStore.read(storeIdentity); + if (!commitFailed || !current?.pendingSupervisionBranch) { + return current; + } + return { + ...current, + pendingSupervisionBranch: { + ...current.pendingSupervisionBranch, + connectionFingerprint: "changed-connection", + }, + }; + }), + mutate: vi.fn(async (storeIdentity, mutation) => { + if (mutation.kind === "commit-pending-supervision-branch") { + commitFailed = true; + throw new Error("binding commit failed"); + } + return await testCodexAppServerBindingStore.mutate(storeIdentity, mutation); + }), + }; + const abandonClient = vi.fn(async () => undefined); + + await expect( + startOrResumeThreadImpl({ + client: { request } as never, + abandonClient, + bindingStore, + params: attempt, + cwd: workspaceDir, + dynamicTools: [], + appServer: createThreadLifecycleAppServerOptions(), + }), + ).rejects.toThrow(`binding changed while commit was uncertain: ${finalThreadId}`); + expect( + request.mock.calls + .filter(([method]) => method === "thread/archive") + .map(([, requestParams]) => requestParams), + ).toEqual([{ threadId: probeThreadId }]); + expect(abandonClient).toHaveBeenCalledOnce(); + await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject({ + threadId: sourceThreadId, + pendingSupervisionBranch: { + sourceThreadId, + cleanupThreadIds: [finalThreadId], + }, + }); + }); + + it("abandons an untrackable probe response after known cleanup", async () => { + const sourceThreadId = "thread-source"; + const workspaceDir = path.join(tempDir, "workspace"); + const attempt = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir); + await seedPendingSupervisionBinding({ + attempt, + cwd: workspaceDir, + pending: { sourceThreadId }, + }); + const request = vi.fn(async (method: string) => { + if (method === "thread/read") { + return { thread: sourceThread({ threadId: sourceThreadId }) }; + } + if (method === "thread/fork") { + return { thread: { id: "" } }; + } + throw new Error(`unexpected method: ${method}`); + }); + const abandonClient = vi.fn(async () => undefined); + + await expect( + startOrResumeThread({ + client: { request } as never, + abandonClient, + params: attempt, + cwd: workspaceDir, + dynamicTools: [], + appServer: createThreadLifecycleAppServerOptions(), + }), + ).rejects.toThrow("model probe may have materialized without a safe thread id"); + expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/read", "thread/fork"]); + expect(abandonClient).toHaveBeenCalledOnce(); + }); + + it("cleans the known probe before abandoning an untrackable canonical response", async () => { + const sourceThreadId = "thread-source"; + const probeThreadId = "thread-probe"; + const workspaceDir = path.join(tempDir, "workspace"); + const attempt = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir); + const identity = await seedPendingSupervisionBinding({ + attempt, + cwd: workspaceDir, + pending: { sourceThreadId }, + }); + const request = vi.fn(async (method: string, _requestParams?: unknown) => { + if (method === "thread/read") { + return { thread: sourceThread({ threadId: sourceThreadId }) }; + } + if (method === "thread/fork") { + return nativeThreadResult(probeThreadId, "native-effective", "native-provider"); + } + if (method === "thread/start") { + return { thread: { id: "" } }; + } + if (method === "thread/archive") { + return {}; + } + throw new Error(`unexpected method: ${method}`); + }); + const abandonClient = vi.fn(async () => undefined); + + await expect( + startOrResumeThread({ + client: { request } as never, + abandonClient, + params: attempt, + cwd: workspaceDir, + dynamicTools: [], + appServer: createThreadLifecycleAppServerOptions(), + }), + ).rejects.toThrow("canonical branch may have materialized without a safe thread id"); + expect(request.mock.calls.map(([method]) => method)).toEqual([ + "thread/read", + "thread/fork", + "thread/start", + "thread/archive", + ]); + expect(request.mock.calls[3]?.[1]).toEqual({ threadId: probeThreadId }); + expect(request.mock.invocationCallOrder[3]).toBeLessThan( + abandonClient.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + expect(abandonClient).toHaveBeenCalledOnce(); + const persisted = await testCodexAppServerBindingStore.read(identity); + expect(persisted).toMatchObject({ + threadId: sourceThreadId, + pendingSupervisionBranch: { sourceThreadId }, + }); + expect(persisted?.pendingSupervisionBranch?.cleanupThreadIds).toBeUndefined(); + }); + + it("archives an untracked probe when the cleanup CAS loses a race", async () => { + const sourceThreadId = "thread-source"; + const probeThreadId = "thread-probe"; + const workspaceDir = path.join(tempDir, "workspace"); + const attempt = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir); + const identity = await seedPendingSupervisionBinding({ + attempt, + cwd: workspaceDir, + pending: { sourceThreadId }, + }); + const request = vi.fn(async (method: string, _requestParams?: unknown) => { + if (method === "thread/read") { + return { thread: sourceThread({ threadId: sourceThreadId }) }; + } + if (method === "thread/fork") { + return nativeThreadResult(probeThreadId, "native-effective", "native-provider"); + } + if (method === "thread/archive") { + return {}; + } + throw new Error(`unexpected method: ${method}`); + }); + const bindingStore: CodexAppServerBindingStore = { + ...testCodexAppServerBindingStore, + mutate: vi.fn(async (storeIdentity, mutation) => { + if ( + mutation.kind === "patch-pending-supervision-branch" && + mutation.pending.cleanupThreadIds?.includes(probeThreadId) + ) { + return false; + } + return await testCodexAppServerBindingStore.mutate(storeIdentity, mutation); + }), + }; + + await expect( + startOrResumeThreadImpl({ + client: { request } as never, + bindingStore, + params: attempt, + cwd: workspaceDir, + dynamicTools: [], + appServer: createThreadLifecycleAppServerOptions(), + }), + ).rejects.toThrow("tracking supervised Codex branch cleanup"); + expect(request.mock.calls.map(([method]) => method)).toEqual([ + "thread/read", + "thread/fork", + "thread/archive", + ]); + expect(request.mock.calls[2]?.[1]).toEqual({ threadId: probeThreadId }); + await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject({ + threadId: sourceThreadId, + pendingSupervisionBranch: { sourceThreadId }, + }); + }); +}); + describe("Codex app-server thread lifecycle timing", () => { beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-thread-lifecycle-")); diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index 835c26127b31..81dc0eda64d7 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -41,6 +41,7 @@ import { shouldDisableCodexToolSearchForModel, } from "./dynamic-tool-profile.js"; import { invalidInlineImageText, sanitizeInlineImageDataUrl } from "./image-payload-sanitizer.js"; +import { buildCodexAppServerConnectionFingerprint } from "./plugin-app-cache-key.js"; import { buildCodexPluginAppsConfigPatchFromPolicyContext, isCodexPluginThreadBindingStale, @@ -48,13 +49,18 @@ import { type CodexPluginThreadConfig, } from "./plugin-thread-config.js"; import { isCodexAppServerProfilerEnabled } from "./profiler-flag.js"; -import { assertCodexThreadStartResponse } from "./protocol-validators.js"; +import { + assertCodexThreadForkResponse, + assertCodexThreadStartResponse, +} from "./protocol-validators.js"; import { CODEX_OPENCLAW_DIRECT_DYNAMIC_TOOL_NAMESPACE, flattenCodexDynamicToolFunctions, isJsonObject, type CodexDynamicToolSpec, type CodexSandboxPolicy, + type CodexThread, + type CodexThreadForkParams, type CodexThreadResumeParams, type CodexThreadStartParams, type CodexTurnEnvironmentParams, @@ -64,6 +70,7 @@ import { type JsonValue, } from "./protocol.js"; import { + assertCodexBindingMayBeReplaced, isCodexAppServerNativeAuthProfile, normalizeCodexAppServerBindingModelProvider, reclaimCurrentCodexSessionGeneration, @@ -73,13 +80,15 @@ import { type CodexAppServerBindingStore, type CodexAppServerContextEngineBinding, type CodexAppServerContextEngineProjectionBinding, + type CodexAppServerPendingSupervisionBranch, type CodexAppServerThreadBinding, } from "./session-binding.js"; import { resumeCodexAppServerThread } from "./thread-resume.js"; +import { projectBoundedCodexThreadHistory } from "./transcript-mirror.js"; import { resolveCodexWebSearchPlan, type CodexNativeWebSearchSupport } from "./web-search.js"; export type CodexAppServerThreadLifecycle = { - action: "started" | "resumed"; + action: "started" | "resumed" | "forked"; rotatedContextEngineBinding?: boolean; activeTurnIds?: string[]; }; @@ -102,6 +111,15 @@ class CodexThreadBindingConflictError extends Error { } } +class CodexThreadBindingConflictAfterCleanupError extends CodexThreadBindingConflictError {} + +class CodexAdoptedThreadActiveError extends Error { + constructor() { + super("Codex session became active in another runner; wait for it to finish before continuing"); + this.name = "CodexAdoptedThreadActiveError"; + } +} + export type CodexThreadFinalConfigPatchDecision = | { action: "resume"; binding: CodexAppServerThreadBinding } | { action: "start" }; @@ -163,7 +181,7 @@ export type CodexThreadLifecycleTimingLogger = { warn: (message: string, meta?: Record) => void; }; -export type CodexThreadLifecycleTimingAction = "started" | "resumed" | "rotated"; +export type CodexThreadLifecycleTimingAction = "started" | "resumed" | "forked" | "rotated"; export type CodexThreadLifecycleTimingOptions = { enabled?: boolean; @@ -403,6 +421,22 @@ export async function startOrResumeThread(params: { agentDir: params.params.agentDir, config: params.params.config, }); + const throwIfAborted = () => { + if (!params.signal?.aborted) { + return; + } + const reason = params.signal.reason; + if (reason instanceof Error) { + throw reason; + } + const error = new Error( + typeof reason === "string" && reason.length > 0 + ? reason + : "codex app-server thread lifecycle aborted", + ); + error.name = "AbortError"; + throw error; + }; if (!binding && bindingIdentity.kind === "session" && bindingIdentity.sessionKey) { // Reset may rotate the OpenClaw session while this plugin is unloaded. Only // the authoritative session store may let its successor displace that stale owner. @@ -419,11 +453,85 @@ export async function startOrResumeThread(params: { ); } } + if (binding?.pendingSupervisionBranch) { + const pendingBinding = binding as CodexAppServerThreadBinding & { + pendingSupervisionBranch: CodexAppServerPendingSupervisionBranch; + }; + const pluginThreadConfig = params.pluginThreadConfig?.enabled + ? await lifecycleTiming.measure("plugin-config-build", () => + params.pluginThreadConfig?.build(), + ) + : undefined; + const finalConfigPatch = params.buildFinalConfigPatch?.({ action: "start" }) ?? { + configPatch: params.finalConfigPatch, + nativeHookRelayGeneration: params.nativeHookRelayGeneration, + }; + const config = lifecycleTiming.measureSync("merge-thread-config", () => + mergeCodexThreadConfigs( + params.config, + userMcpServersConfigPatch, + pluginThreadConfig?.configPatch, + finalConfigPatch.configPatch, + ), + ); + return await materializePendingSupervisionBranch({ + client: params.client, + abandonClient: + params.abandonClient ?? (() => closeCodexStartupClientBestEffort(params.client)), + bindingStore: params.bindingStore, + bindingIdentity, + binding: pendingBinding, + attempt: params.params, + cwd: params.cwd, + dynamicTools: params.dynamicTools, + appServer: params.appServer, + developerInstructions: params.developerInstructions, + config, + nativeCodeModeEnabled: params.nativeCodeModeEnabled, + nativeProviderWebSearchSupport: params.nativeProviderWebSearchSupport, + nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled, + webSearchAllowed: params.webSearchAllowed, + environmentSelection: params.environmentSelection, + signal: params.signal, + throwIfAborted, + lifecycleTiming, + normalizeBindingModelProvider, + bindingPatch: { + cwd: params.cwd, + // Supervised threads stay on the native user-home connection. Never + // persist an outer OpenClaw auth profile onto that private ownership. + authProfileId: undefined, + preserveNativeModel: true, + dynamicToolsFingerprint, + dynamicToolsContainDeferred, + webSearchThreadConfigFingerprint, + userMcpServersFingerprint, + mcpServersFingerprint: + params.mcpServersFingerprintEvaluated === true + ? params.mcpServersFingerprint + : pendingBinding.mcpServersFingerprint, + networkProxyProfileName: params.appServer.networkProxy?.profileName, + networkProxyConfigFingerprint, + nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration, + appServerRuntimeFingerprint: buildCodexAppServerConnectionFingerprint( + params.appServer, + params.params.agentDir, + ), + pluginAppsFingerprint: pluginThreadConfig?.fingerprint, + pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint, + pluginAppPolicyContext: pluginThreadConfig?.policyContext, + contextEngine: contextEngineBinding, + environmentSelectionFingerprint, + conversationSourceTransferComplete: true, + }, + }); + } const clearCurrentBinding = async (operation: string) => { const current = binding; if (!current?.threadId) { return; } + assertCodexBindingMayBeReplaced(current, operation); const cleared = await params.bindingStore.mutate(bindingIdentity, { kind: "clear", threadId: current.threadId, @@ -437,7 +545,10 @@ export async function startOrResumeThread(params: { binding?.threadId && shouldRotateCodexAppServerBindingForRuntime({ connectionClass: params.appServer.connectionClass, - current: params.appServerRuntimeFingerprint, + current: + binding.connectionScope === "supervision" + ? buildCodexAppServerConnectionFingerprint(params.appServer, params.params.agentDir) + : params.appServerRuntimeFingerprint, binding: binding.appServerRuntimeFingerprint, }) ) { @@ -484,22 +595,6 @@ export async function startOrResumeThread(params: { params.nativeProviderWebSearchSupport === "unknown" && !binding?.threadId; let rotatedContextEngineBinding = false; let prebuiltPluginThreadConfig: CodexPluginThreadConfig | undefined; - const throwIfAborted = () => { - if (!params.signal?.aborted) { - return; - } - const reason = params.signal.reason; - if (reason instanceof Error) { - throw reason; - } - const error = new Error( - typeof reason === "string" && reason.length > 0 - ? reason - : "codex app-server thread lifecycle aborted", - ); - error.name = "AbortError"; - throw error; - }; const webSearchBindingChanged = binding?.threadId && binding.webSearchThreadConfigFingerprint !== webSearchThreadConfigFingerprint; @@ -518,6 +613,7 @@ export async function startOrResumeThread(params: { params.mcpServersFingerprintEvaluated === true && binding.mcpServersFingerprint !== params.mcpServersFingerprint ) { + assertCodexBindingMayBeReplaced(binding, "changing MCP configuration"); if ( transientNativeToolRestriction || (webSearchBindingChanged && @@ -550,6 +646,7 @@ export async function startOrResumeThread(params: { webSearchBindingChanged && !deferLegacyWebSearchRotationToTransientNativeSurface ) { + assertCodexBindingMayBeReplaced(binding, "changing web-search configuration"); if (transientWebSearchRestriction) { embeddedAgentLog.debug( "codex app-server web search restricted for turn; starting transient thread", @@ -572,6 +669,7 @@ export async function startOrResumeThread(params: { binding = undefined; } if (binding?.threadId && transientNativeToolRestriction) { + assertCodexBindingMayBeReplaced(binding, "starting a native-tool-restricted turn"); embeddedAgentLog.debug( "codex app-server native tool surface disabled for turn; starting transient thread", { @@ -705,6 +803,7 @@ export async function startOrResumeThread(params: { dynamicToolsFingerprint, ) ) { + assertCodexBindingMayBeReplaced(binding, "changing the dynamic tool catalog"); preserveExistingBinding = shouldStartTransientNoToolThread({ previous: binding.dynamicToolsFingerprint, next: dynamicToolsFingerprint, @@ -729,7 +828,10 @@ export async function startOrResumeThread(params: { const resumeBinding = binding; let resumeReservation: { release: () => void } | undefined; try { - const authProfileId = params.params.authProfileId ?? resumeBinding.authProfileId; + const authProfileId = + resumeBinding.connectionScope === "supervision" + ? undefined + : (params.params.authProfileId ?? resumeBinding.authProfileId); const finalConfigPatch = params.buildFinalConfigPatch?.({ action: "resume", binding: resumeBinding, @@ -757,6 +859,7 @@ export async function startOrResumeThread(params: { authProfileId, model: startModelSelection.model, modelProvider: startModelProvider, + preserveNativeModel: resumeBinding.preserveNativeModel === true, appServer: params.appServer, dynamicTools: params.dynamicTools, developerInstructions: params.developerInstructions, @@ -774,6 +877,19 @@ export async function startOrResumeThread(params: { // Keep ownership accounting atomic with the resume request: a // pre-aborted request retains no subscription, so it must not reserve. throwIfAborted(); + if (resumeBinding.preserveNativeModel === true) { + const current = await lifecycleTiming.measure("thread-read-adoption-status", () => + params.client.request( + "thread/read", + { threadId: resumeBinding.threadId, includeTurns: false }, + { signal: params.signal }, + ), + ); + throwIfAborted(); + if (current.thread.status?.type === "active") { + throw new CodexAdoptedThreadActiveError(); + } + } resumeReservation = params.reserveResumeThread?.(resumeBinding.threadId); const response = await lifecycleTiming.measure("thread-resume-request", () => resumeCodexAppServerThread({ @@ -796,6 +912,7 @@ export async function startOrResumeThread(params: { cwd: params.cwd, authProfileId: boundAuthProfileId, model: response.model ?? resumeParams.model ?? params.params.modelId, + preserveNativeModel: resumeBinding.preserveNativeModel === true ? true : undefined, modelProvider: normalizeBindingModelProvider( boundAuthProfileId, response.modelProvider ?? requestModelProvider ?? startModelProvider, @@ -809,7 +926,10 @@ export async function startOrResumeThread(params: { networkProxyConfigFingerprint, nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration ?? resumeBinding.nativeHookRelayGeneration, - appServerRuntimeFingerprint: params.appServerRuntimeFingerprint, + appServerRuntimeFingerprint: + resumeBinding.connectionScope === "supervision" + ? buildCodexAppServerConnectionFingerprint(params.appServer, params.params.agentDir) + : params.appServerRuntimeFingerprint, pluginAppsFingerprint: resumeBinding.pluginAppsFingerprint, pluginAppsInputFingerprint: resumeBinding.pluginAppsInputFingerprint, pluginAppPolicyContext: resumeBinding.pluginAppPolicyContext, @@ -860,6 +980,11 @@ export async function startOrResumeThread(params: { }; } catch (error) { resumeReservation?.release(); + if (error instanceof CodexAdoptedThreadActiveError) { + // The passive preflight does not subscribe, so cleanup would target + // another runner's ownership and can turn a clear conflict into rotation. + throw error; + } if (isCodexAppServerUnsafeSubscriptionError(error)) { // The resume client is already retired; a fresh start here would // race the possibly-live subscription on the abandoned process. @@ -1036,6 +1161,606 @@ export async function startOrResumeThread(params: { }); } +type PendingSupervisionMaterializationParams = { + client: CodexAppServerClient; + abandonClient: () => Promise; + bindingStore: CodexAppServerBindingStore; + bindingIdentity: CodexAppServerBindingIdentity; + binding: CodexAppServerThreadBinding & { + pendingSupervisionBranch: CodexAppServerPendingSupervisionBranch; + }; + attempt: EmbeddedRunAttemptParams; + cwd: string; + dynamicTools: CodexDynamicToolSpec[]; + appServer: CodexAppServerRuntimeOptions; + developerInstructions?: string; + config?: JsonObject; + nativeCodeModeEnabled?: boolean; + nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport; + nativeCodeModeOnlyEnabled?: boolean; + webSearchAllowed?: boolean; + environmentSelection?: CodexTurnEnvironmentParams[]; + signal?: AbortSignal; + throwIfAborted: () => void; + lifecycleTiming: Pick< + ReturnType, + "measure" | "mark" | "logSummary" + >; + normalizeBindingModelProvider: ( + authProfileId: string | undefined, + modelProvider: string | undefined, + ) => string | undefined; + bindingPatch: Partial>; +}; + +async function materializePendingSupervisionBranch( + params: PendingSupervisionMaterializationParams, +): Promise { + let pending = params.binding.pendingSupervisionBranch; + const connectionFingerprint = buildCodexAppServerConnectionFingerprint( + params.appServer, + params.attempt.agentDir, + ); + if (!pending.connectionFingerprint || pending.connectionFingerprint !== connectionFingerprint) { + throw new Error("Codex supervision source connection changed before branch materialization"); + } + pending = await recoverPendingSupervisionArtifacts(params, pending); + params.throwIfAborted(); + + const sourceResponse = await params.lifecycleTiming.measure("supervision-source-read", () => + params.client.request( + "thread/read", + { threadId: pending.sourceThreadId, includeTurns: true }, + { signal: params.signal }, + ), + ); + params.throwIfAborted(); + const sourceThread = sourceResponse.thread; + if (sourceThread.id !== pending.sourceThreadId) { + throw new Error( + `Codex supervision source read returned ${sourceThread.id} for ${pending.sourceThreadId}`, + ); + } + assertPendingSupervisionSnapshotUnchanged(sourceThread, pending); + const history = projectBoundedCodexThreadHistory({ + thread: sourceThread, + throughTurnId: pending.lastTurnId ?? null, + importedAt: Date.now(), + modelProvider: sourceThread.modelProvider, + }); + + let bindingCommitted = false; + let provisionalCleanupSafe = true; + try { + const probeParams = buildPendingSupervisionProbeForkParams(params, pending); + const rawProbeResponse = await params.lifecycleTiming.measure( + "supervision-model-probe-fork", + async () => { + try { + return await params.client.request("thread/fork", probeParams, { + signal: params.signal, + }); + } catch (error) { + if (!(error instanceof CodexAppServerRpcError)) { + throw new CodexAppServerUnsafeSubscriptionError( + "Codex model probe fork may have materialized without a response", + { cause: error }, + ); + } + throw error; + } + }, + ); + const probeThreadId = requireDistinctSupervisionThreadId({ + threadId: readSupervisionResponseThreadId(rawProbeResponse), + sourceThreadId: pending.sourceThreadId, + role: "model probe", + }); + pending = await trackPendingSupervisionArtifacts(params, pending, [probeThreadId]); + params.throwIfAborted(); + const probeResponse = assertCodexThreadForkResponse(rawProbeResponse); + const nativeModel = requireNonBlankSupervisionValue(probeResponse.model, "native model"); + const nativeModelProvider = requireNativeSupervisionModelProvider({ + responseModelProvider: probeResponse.modelProvider, + responseThreadModelProvider: probeResponse.thread.modelProvider, + }); + + const nativeAttempt = { ...params.attempt, modelId: nativeModel }; + const startParams = buildThreadStartParams(nativeAttempt, { + cwd: params.cwd, + dynamicTools: params.dynamicTools, + appServer: params.appServer, + developerInstructions: params.developerInstructions, + config: params.config, + nativeCodeModeEnabled: params.nativeCodeModeEnabled, + nativeProviderWebSearchSupport: params.nativeProviderWebSearchSupport, + nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled, + webSearchAllowed: params.webSearchAllowed, + environmentSelection: params.environmentSelection, + model: nativeModel, + modelProvider: nativeModelProvider, + }); + assertExactSupervisionModelSelection(startParams, { + model: nativeModel, + modelProvider: nativeModelProvider, + operation: "thread/start request", + }); + const rawStartResponse = await params.lifecycleTiming.measure( + "supervision-thread-start", + async () => { + try { + return await params.client.request("thread/start", startParams, { + signal: params.signal, + }); + } catch (error) { + if (error instanceof CodexAppServerRpcError) { + throw new CodexThreadStartRequestError(error); + } + throw new CodexAppServerUnsafeSubscriptionError( + "Canonical Codex branch may have started without a response", + { cause: error }, + ); + } + }, + ); + const finalThreadId = requireDistinctSupervisionThreadId({ + threadId: readSupervisionResponseThreadId(rawStartResponse), + sourceThreadId: pending.sourceThreadId, + otherThreadId: probeThreadId, + role: "canonical branch", + }); + pending = await trackPendingSupervisionArtifacts(params, pending, [ + probeThreadId, + finalThreadId, + ]); + params.throwIfAborted(); + const startResponse = assertCodexThreadStartResponse(rawStartResponse); + assertExactSupervisionModelSelection(startResponse, { + model: nativeModel, + modelProvider: nativeModelProvider, + operation: "thread/start response", + }); + if (history.responseItems.length > 0) { + await params.lifecycleTiming.measure("supervision-history-inject", () => + params.client.request( + "thread/inject_items", + { threadId: finalThreadId, items: history.responseItems }, + { signal: params.signal }, + ), + ); + params.throwIfAborted(); + } + + if (!(await archiveSupervisionArtifact(params.client, probeThreadId))) { + throw new Error(`Failed to archive temporary Codex model probe: ${probeThreadId}`); + } + pending = await trackPendingSupervisionArtifacts(params, pending, [finalThreadId]); + const historyCoveredThrough = new Date().toISOString(); + const bindingModelProvider = params.normalizeBindingModelProvider( + params.attempt.authProfileId, + nativeModelProvider, + ); + let committed = false; + try { + committed = await params.bindingStore.mutate(params.bindingIdentity, { + kind: "commit-pending-supervision-branch", + expected: pending, + threadId: finalThreadId, + patch: { + ...params.bindingPatch, + model: nativeModel, + modelProvider: bindingModelProvider, + historyCoveredThrough, + }, + }); + } catch (error) { + let current: CodexAppServerThreadBinding | undefined; + try { + current = await params.bindingStore.read(params.bindingIdentity); + } catch (readError) { + provisionalCleanupSafe = false; + throw new CodexAppServerUnsafeSubscriptionError( + `Canonical Codex branch binding could not be verified: ${finalThreadId}`, + { cause: new AggregateError([error, readError]) }, + ); + } + if ( + matchesMaterializedSupervisionBranch(current, { + sourceThreadId: pending.sourceThreadId, + connectionFingerprint, + threadId: finalThreadId, + model: nativeModel, + modelProvider: bindingModelProvider, + historyCoveredThrough, + }) + ) { + committed = true; + } else { + if (!matchesPendingSupervisionState(current, pending)) { + provisionalCleanupSafe = false; + throw new CodexAppServerUnsafeSubscriptionError( + `Canonical Codex branch binding changed while commit was uncertain: ${finalThreadId}`, + { cause: error }, + ); + } + throw error; + } + } + if (!committed) { + throw new CodexThreadBindingConflictError( + pending.sourceThreadId, + "committing a supervised Codex branch", + ); + } + // This thread now belongs to the durable binding. Later diagnostics must + // never route it through provisional artifact cleanup. + bindingCommitted = true; + params.lifecycleTiming.mark("thread-ready"); + params.lifecycleTiming.logSummary({ + runId: params.attempt.runId, + sessionId: params.attempt.sessionId, + sessionKey: params.attempt.sessionKey, + threadId: finalThreadId, + action: "forked", + }); + return { + ...params.binding, + ...params.bindingPatch, + threadId: finalThreadId, + pendingSupervisionBranch: undefined, + model: nativeModel, + modelProvider: bindingModelProvider, + historyCoveredThrough, + lifecycle: { action: "forked" }, + }; + } catch (error) { + if (bindingCommitted) { + throw error; + } + // The tracking CAS owner already cleaned every known artifact. Its stale + // pending snapshot must not drive another cleanup or binding mutation. + if (error instanceof CodexThreadBindingConflictAfterCleanupError) { + throw error; + } + if (!provisionalCleanupSafe) { + await params.abandonClient(); + throw error; + } + const cleanup = await cleanPendingSupervisionArtifacts(params.client, pending); + let cleanupStateError: unknown; + if (cleanup.remaining.length !== (pending.cleanupThreadIds?.length ?? 0)) { + const nextPending = withPendingSupervisionCleanup(pending, cleanup.remaining); + try { + const updated = await params.bindingStore.mutate(params.bindingIdentity, { + kind: "patch-pending-supervision-branch", + expected: pending, + pending: nextPending, + }); + if (updated) { + pending = nextPending; + } + } catch (stateError) { + cleanupStateError = stateError; + } + } + const unsafeCleanup = + cleanup.remaining.length > 0 || isCodexAppServerUnsafeSubscriptionError(error); + if (unsafeCleanup) { + await params.abandonClient(); + } + if (cleanupStateError) { + const cause = new AggregateError([error, cleanupStateError]); + if (unsafeCleanup) { + throw new CodexAppServerUnsafeSubscriptionError( + "Codex supervised branch cleanup state could not be recorded", + { cause }, + ); + } + const aggregateError = new AggregateError( + [error, cleanupStateError], + "Codex supervised branch cleanup state could not be recorded", + { cause: error }, + ); + throw aggregateError; + } + if (cleanup.remaining.length > 0) { + throw new CodexAppServerUnsafeSubscriptionError( + `Codex supervised branch cleanup remains pending: ${cleanup.remaining.join(", ")}`, + { cause: error }, + ); + } + throw error; + } +} + +function buildPendingSupervisionProbeForkParams( + params: PendingSupervisionMaterializationParams, + pending: CodexAppServerPendingSupervisionBranch, +): CodexThreadForkParams { + const runtimeConfig = buildCodexRuntimeThreadConfigForRun(params.attempt, params.config, { + nativeCodeModeEnabled: params.nativeCodeModeEnabled, + nativeProviderWebSearchSupport: params.nativeProviderWebSearchSupport, + nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled, + webSearchAllowed: params.webSearchAllowed, + appServer: params.appServer, + }); + return { + threadId: pending.sourceThreadId, + ...(pending.lastTurnId ? { lastTurnId: pending.lastTurnId } : {}), + cwd: params.cwd, + approvalPolicy: params.appServer.approvalPolicy, + approvalsReviewer: resolveCodexThreadApprovalsReviewer(params.appServer, runtimeConfig), + ...codexThreadSandboxOrPermissions(params.appServer), + ...(params.appServer.serviceTier !== undefined + ? { serviceTier: params.appServer.serviceTier } + : {}), + config: runtimeConfig, + developerInstructions: + params.developerInstructions ?? + buildDeveloperInstructions(params.attempt, { dynamicTools: params.dynamicTools }), + ephemeral: false, + threadSource: "appServer", + excludeTurns: true, + }; +} + +function assertPendingSupervisionSnapshotUnchanged( + thread: CodexThread, + pending: CodexAppServerPendingSupervisionBranch, +): void { + if (pending.lastTurnId) { + return; + } + if (thread.status?.type === "active" || (thread.turns?.length ?? 0) > 0) { + throw new Error( + "Codex source changed after Continue; reopen the source session before sending a message", + ); + } +} + +function requireNonBlankSupervisionValue(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`Codex supervision ${label} is missing`); + } + return value.trim(); +} + +function requireNativeSupervisionModelProvider(params: { + responseModelProvider?: string | null; + responseThreadModelProvider?: string | null; +}): string { + const responseProvider = requireNonBlankSupervisionValue( + params.responseModelProvider, + "native model provider", + ); + const threadProvider = params.responseThreadModelProvider?.trim(); + if (threadProvider && threadProvider !== responseProvider) { + throw new Error( + `Codex supervision model provider mismatch: ${responseProvider} != ${threadProvider}`, + ); + } + return responseProvider; +} + +function assertExactSupervisionModelSelection( + value: { model?: string | null; modelProvider?: string | null }, + expected: { model: string; modelProvider: string; operation: string }, +): void { + if (value.model !== expected.model || value.modelProvider !== expected.modelProvider) { + throw new Error( + `Codex supervision ${expected.operation} changed native model selection: ` + + `${value.modelProvider ?? "unknown"}/${value.model ?? "unknown"}`, + ); + } +} + +function matchesPendingSupervisionState( + binding: CodexAppServerThreadBinding | undefined, + expected: CodexAppServerPendingSupervisionBranch, +): boolean { + const pending = binding?.pendingSupervisionBranch; + const cleanupThreadIds = pending?.cleanupThreadIds ?? []; + const expectedCleanupThreadIds = expected.cleanupThreadIds ?? []; + return ( + binding?.threadId === expected.sourceThreadId && + binding.connectionScope === "supervision" && + binding.supervisionSourceThreadId === expected.sourceThreadId && + pending?.sourceThreadId === expected.sourceThreadId && + pending.connectionFingerprint === expected.connectionFingerprint && + pending.lastTurnId === expected.lastTurnId && + cleanupThreadIds.length === expectedCleanupThreadIds.length && + cleanupThreadIds.every((threadId, index) => threadId === expectedCleanupThreadIds[index]) + ); +} + +function matchesMaterializedSupervisionBranch( + binding: CodexAppServerThreadBinding | undefined, + expected: { + sourceThreadId: string; + connectionFingerprint: string; + threadId: string; + model: string; + modelProvider: string | undefined; + historyCoveredThrough: string; + }, +): boolean { + return ( + binding?.threadId === expected.threadId && + binding.connectionScope === "supervision" && + binding.supervisionSourceThreadId === expected.sourceThreadId && + binding.appServerRuntimeFingerprint === expected.connectionFingerprint && + binding.pendingSupervisionBranch === undefined && + binding.model === expected.model && + binding.modelProvider === expected.modelProvider && + binding.historyCoveredThrough === expected.historyCoveredThrough + ); +} + +function requireDistinctSupervisionThreadId(params: { + threadId: unknown; + sourceThreadId: string; + otherThreadId?: string; + role: string; +}): string { + let threadId: string; + try { + threadId = requireNonBlankSupervisionValue(params.threadId, `${params.role} thread id`); + } catch (error) { + throw new CodexAppServerUnsafeSubscriptionError( + `Codex supervision ${params.role} may have materialized without a safe thread id`, + { cause: error }, + ); + } + if (threadId === params.sourceThreadId || threadId === params.otherThreadId) { + throw new CodexAppServerUnsafeSubscriptionError( + `Codex supervision ${params.role} reused an existing thread: ${threadId}`, + ); + } + return threadId; +} + +function readSupervisionResponseThreadId(value: unknown): unknown { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const thread = (value as { thread?: unknown }).thread; + if (!thread || typeof thread !== "object" || Array.isArray(thread)) { + return undefined; + } + return (thread as { id?: unknown }).id; +} + +async function recoverPendingSupervisionArtifacts( + params: PendingSupervisionMaterializationParams, + pending: CodexAppServerPendingSupervisionBranch, +): Promise { + if (!pending.cleanupThreadIds?.length) { + return pending; + } + const cleanup = await cleanPendingSupervisionArtifacts(params.client, pending); + const next = withPendingSupervisionCleanup(pending, cleanup.remaining); + if (cleanup.remaining.length > 0) { + if (cleanup.remaining.length !== pending.cleanupThreadIds.length) { + const updated = await params.bindingStore.mutate(params.bindingIdentity, { + kind: "patch-pending-supervision-branch", + expected: pending, + pending: next, + }); + if (!updated) { + throw new CodexThreadBindingConflictError( + pending.sourceThreadId, + "recording supervised Codex cleanup recovery", + ); + } + } + throw new Error( + `Codex supervised branch cleanup must finish before retry: ${cleanup.remaining.join(", ")}`, + ); + } + const updated = await params.bindingStore.mutate(params.bindingIdentity, { + kind: "patch-pending-supervision-branch", + expected: pending, + pending: next, + }); + if (!updated) { + throw new CodexThreadBindingConflictError( + pending.sourceThreadId, + "recovering a supervised Codex branch", + ); + } + return next; +} + +async function trackPendingSupervisionArtifacts( + params: PendingSupervisionMaterializationParams, + pending: CodexAppServerPendingSupervisionBranch, + cleanupThreadIds: string[], +): Promise { + const next = withPendingSupervisionCleanup(pending, cleanupThreadIds); + const updated = await params.bindingStore.mutate(params.bindingIdentity, { + kind: "patch-pending-supervision-branch", + expected: pending, + pending: next, + }); + if (!updated) { + const cleanupFailed: string[] = []; + for (const threadId of cleanupThreadIds) { + if (!(await archiveSupervisionArtifact(params.client, threadId))) { + cleanupFailed.push(threadId); + } + } + if (cleanupFailed.length > 0) { + throw new CodexAppServerUnsafeSubscriptionError( + `Codex supervised branch CAS cleanup failed: ${cleanupFailed.join(", ")}`, + ); + } + throw new CodexThreadBindingConflictAfterCleanupError( + pending.sourceThreadId, + "tracking supervised Codex branch cleanup", + ); + } + return next; +} + +function withPendingSupervisionCleanup( + pending: CodexAppServerPendingSupervisionBranch, + cleanupThreadIds: string[], +): CodexAppServerPendingSupervisionBranch { + return { + sourceThreadId: pending.sourceThreadId, + ...(pending.connectionFingerprint + ? { connectionFingerprint: pending.connectionFingerprint } + : {}), + ...(pending.lastTurnId ? { lastTurnId: pending.lastTurnId } : {}), + ...(cleanupThreadIds.length > 0 ? { cleanupThreadIds } : {}), + }; +} + +async function cleanPendingSupervisionArtifacts( + client: CodexAppServerClient, + pending: CodexAppServerPendingSupervisionBranch, +): Promise<{ remaining: string[] }> { + const remaining: string[] = []; + for (const threadId of pending.cleanupThreadIds ?? []) { + if (!(await archiveSupervisionArtifact(client, threadId))) { + remaining.push(threadId); + } + } + return { remaining }; +} + +async function archiveSupervisionArtifact( + client: CodexAppServerClient, + threadId: string, +): Promise { + try { + await client.request( + "thread/archive", + { threadId }, + { timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS }, + ); + return true; + } catch (error) { + const message = formatErrorMessage(error).toLowerCase(); + if ( + message.includes("no rollout found for thread id") || + message.includes("thread not found") || + message.includes("already archived") + ) { + return true; + } + await unsubscribeCodexThreadBestEffort(client, { + threadId, + timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS, + }); + embeddedAgentLog.warn("failed to archive temporary Codex supervision thread", { + threadId, + error, + }); + return false; + } +} + export function shouldRotateCodexAppServerBindingForRuntime(params: { connectionClass: CodexAppServerRuntimeOptions["connectionClass"]; current?: string; @@ -1316,27 +2041,35 @@ export function buildThreadResumeParams( nativeCodeModeOnlyEnabled?: boolean; webSearchAllowed?: boolean; model?: string | null; + preserveNativeModel?: boolean; }, ): CodexThreadResumeParams { - const resolvedModelProvider = resolveCodexAppServerModelProvider({ - provider: params.provider, - authProfileId: options.authProfileId ?? params.authProfileId, - authProfileStore: params.authProfileStore, - agentDir: params.agentDir, - config: params.config, - }); - const modelSelection = resolveCodexAppServerRequestModelSelection({ - model: options.model ?? params.modelId, - modelProvider: options.modelProvider ?? resolvedModelProvider, - authProfileId: options.authProfileId ?? params.authProfileId, - authProfileStore: params.authProfileStore, - agentDir: params.agentDir, - config: params.config, - }); + const modelSelection = options.preserveNativeModel + ? undefined + : resolveCodexAppServerRequestModelSelection({ + model: options.model ?? params.modelId, + modelProvider: + options.modelProvider ?? + resolveCodexAppServerModelProvider({ + provider: params.provider, + authProfileId: options.authProfileId ?? params.authProfileId, + authProfileStore: params.authProfileStore, + agentDir: params.agentDir, + config: params.config, + }), + authProfileId: options.authProfileId ?? params.authProfileId, + authProfileStore: params.authProfileStore, + agentDir: params.agentDir, + config: params.config, + }); return { threadId: options.threadId, - model: modelSelection.model, - ...(modelSelection.modelProvider ? { modelProvider: modelSelection.modelProvider } : {}), + ...(modelSelection + ? { + model: modelSelection.model, + ...(modelSelection.modelProvider ? { modelProvider: modelSelection.modelProvider } : {}), + } + : {}), approvalPolicy: options.appServer.approvalPolicy, approvalsReviewer: resolveCodexThreadApprovalsReviewer(options.appServer, options.config), ...codexThreadSandboxOrPermissions(options.appServer), @@ -1582,16 +2315,19 @@ export function buildTurnStartParams( skillsCollaborationInstructions?: string; memoryCollaborationInstructions?: string; heartbeatCollaborationInstructions?: string; + preserveNativeTurnSettings?: boolean; }, ): CodexTurnStartParams { - const modelSelection = resolveCodexAppServerRequestModelSelection({ - model: options.model ?? params.modelId, - modelProvider: options.modelProvider, - authProfileId: params.authProfileId, - authProfileStore: params.authProfileStore, - agentDir: params.agentDir, - config: params.config, - }); + const modelSelection = options.preserveNativeTurnSettings + ? undefined + : resolveCodexAppServerRequestModelSelection({ + model: options.model ?? params.modelId, + modelProvider: options.modelProvider, + authProfileId: params.authProfileId, + authProfileStore: params.authProfileStore, + agentDir: params.agentDir, + config: params.config, + }); const useThreadPermissionProfile = options.appServer.networkProxy && !options.sandboxPolicy; return { threadId: options.threadId, @@ -1606,24 +2342,33 @@ export function buildTurnStartParams( options.sandboxPolicy ?? codexSandboxPolicyForTurn(options.appServer.sandbox, options.cwd), }), - model: modelSelection.model, - personality: CODEX_NATIVE_PERSONALITY_NONE, + ...(modelSelection + ? { model: modelSelection.model, personality: CODEX_NATIVE_PERSONALITY_NONE } + : {}), ...(options.appServer.serviceTier !== undefined ? { serviceTier: options.appServer.serviceTier } : {}), - effort: resolveReasoningEffort( - params.thinkLevel, - modelSelection.model, - readCodexSupportedReasoningEfforts(params.model?.compat), - ), + ...(modelSelection + ? { + effort: resolveReasoningEffort( + params.thinkLevel, + modelSelection.model, + readCodexSupportedReasoningEfforts(params.model?.compat), + ), + } + : {}), ...(options.environmentSelection ? { environments: options.environmentSelection } : {}), - collaborationMode: buildTurnCollaborationMode(params, { - model: modelSelection.model, - turnScopedDeveloperInstructions: options.turnScopedDeveloperInstructions, - skillsCollaborationInstructions: options.skillsCollaborationInstructions, - memoryCollaborationInstructions: options.memoryCollaborationInstructions, - heartbeatCollaborationInstructions: options.heartbeatCollaborationInstructions, - }), + ...(modelSelection + ? { + collaborationMode: buildTurnCollaborationMode(params, { + model: modelSelection.model, + turnScopedDeveloperInstructions: options.turnScopedDeveloperInstructions, + skillsCollaborationInstructions: options.skillsCollaborationInstructions, + memoryCollaborationInstructions: options.memoryCollaborationInstructions, + heartbeatCollaborationInstructions: options.heartbeatCollaborationInstructions, + }), + } + : {}), }; } diff --git a/extensions/codex/src/app-server/transcript-mirror.test.ts b/extensions/codex/src/app-server/transcript-mirror.test.ts index 5f6539bf3ebe..d8bf0959ef11 100644 --- a/extensions/codex/src/app-server/transcript-mirror.test.ts +++ b/extensions/codex/src/app-server/transcript-mirror.test.ts @@ -1,4 +1,5 @@ // Codex tests cover transcript mirror plugin behavior. +import { Buffer } from "node:buffer"; import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; @@ -15,11 +16,15 @@ import { makeAgentUserMessage, } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CodexThread } from "./protocol.js"; +import { readCodexMirroredSessionHistoryMessages } from "./session-history.js"; import { attachCodexMirrorIdentity, buildCodexUserPromptMessage, + importCodexThreadHistoryToTranscript, mirrorCodexAppServerTranscript, mirrorTranscriptBestEffort, + projectBoundedCodexThreadHistory, } from "./transcript-mirror.js"; const publishSessionTranscriptUpdateByIdentityMock = vi.hoisted(() => vi.fn()); @@ -42,6 +47,13 @@ function expectedFingerprint(message: MirroredAgentMessage): string { return createHash("sha256").update(payload).digest("hex").slice(0, 16); } +function messageContent(message: AgentMessage | undefined) { + if (!message || !("content" in message)) { + throw new Error("expected transcript message content"); + } + return message.content; +} + const tempDirs: string[] = []; afterEach(async () => { @@ -58,6 +70,20 @@ async function createTempSessionFile() { return path.join(dir, "session.jsonl"); } +async function initializeSessionTranscript(sessionFile: string, sessionId: string): Promise { + await fs.writeFile( + sessionFile, + `${JSON.stringify({ + type: "session", + version: 3, + id: sessionId, + timestamp: new Date().toISOString(), + cwd: process.cwd(), + })}\n`, + "utf8", + ); +} + async function makeRoot(prefix: string): Promise { const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); tempDirs.push(root); @@ -105,6 +131,443 @@ function parseJsonLines(raw: string): T[] { return records; } +describe("importCodexThreadHistoryToTranscript", () => { + it("imports only bounded user-visible conversation items with stable identities", async () => { + const sessionFile = await createTempSessionFile(); + await initializeSessionTranscript(sessionFile, "session-history"); + const thread = { + id: "thread-history", + cwd: "/workspace/project", + turns: [ + { + id: "turn-1", + status: "completed", + startedAt: 1_700_000_000, + completedAt: 1_700_000_001, + items: [ + { + id: "user-1", + type: "userMessage", + content: [ + { type: "text", text: "Review this image" }, + { type: "image", url: "data:image/png;base64,private" }, + ], + }, + { + id: "reasoning-1", + type: "reasoning", + summary: ["private reasoning"], + content: ["private chain of thought"], + }, + { + id: "command-1", + type: "commandExecution", + command: "print-secret", + aggregatedOutput: "private tool output", + }, + { + id: "assistant-1", + type: "agentMessage", + text: "The visible answer", + phase: "final_answer", + }, + ], + }, + ], + } as unknown as CodexThread; + + const rawProjection = projectBoundedCodexThreadHistory({ + thread, + throughTurnId: "turn-1", + importedAt: 1_800_000_000_000, + }); + expect(rawProjection.responseItems).toEqual([ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Review this image\n[Image attachment]" }], + }, + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "The visible answer" }], + phase: "final_answer", + }, + ]); + expect(JSON.stringify(rawProjection.responseItems)).not.toContain("private"); + expect(JSON.stringify(rawProjection.responseItems)).not.toContain("data:image"); + + await expect( + importCodexThreadHistoryToTranscript({ + thread, + throughTurnId: "turn-1", + sessionFile, + sessionId: "session-history", + sessionKey: "agent:main:dashboard:history", + }), + ).resolves.toEqual({ importedMessages: 2, omittedMessages: 0 }); + + const raw = await fs.readFile(sessionFile, "utf8"); + const messages = parseJsonLines<{ message?: AgentMessage; type?: string }>(raw) + .filter((event) => event.type === "message") + .map((event) => event.message); + expect(messages).toMatchObject([ + { + role: "user", + content: "Review this image\n[Image attachment]", + timestamp: 1_700_000_000_000, + idempotencyKey: "codex-app-server:thread-history:history:turn-1:user-1", + }, + { + role: "assistant", + content: [{ type: "text", text: "The visible answer" }], + api: "openai-chatgpt-responses", + provider: "openai", + model: "native-history", + stopReason: "stop", + timestamp: 1_700_000_001_003, + idempotencyKey: "codex-app-server:thread-history:history:turn-1:assistant-1", + }, + ]); + expect(raw).not.toContain("private reasoning"); + expect(raw).not.toContain("private chain of thought"); + expect(raw).not.toContain("private tool output"); + expect(raw).not.toContain("data:image"); + await expect( + readCodexMirroredSessionHistoryMessages({ + sessionFile, + sessionId: "session-history", + sessionKey: "agent:main:dashboard:history", + }), + ).resolves.toMatchObject([ + { role: "user", content: "Review this image\n[Image attachment]" }, + { + role: "assistant", + content: [{ type: "text", text: "The visible answer" }], + api: "openai-chatgpt-responses", + provider: "openai", + model: "native-history", + stopReason: "stop", + }, + ]); + }); + + it("keeps the newest 200 visible messages and deduplicates a retried import", async () => { + const sessionFile = await createTempSessionFile(); + await initializeSessionTranscript(sessionFile, "session-bounded-history"); + const thread = { + id: "thread-bounded-history", + turns: Array.from({ length: 205 }, (_, index) => ({ + id: `turn-${index}`, + status: "completed", + startedAt: 1_700_000_000 + index, + completedAt: 1_700_000_000 + index, + items: [ + { + id: `user-${index}`, + type: "userMessage", + content: [{ type: "text", text: `message-${index}` }], + }, + ], + })), + } as unknown as CodexThread; + const importParams = { + thread, + throughTurnId: "turn-204", + sessionFile, + sessionId: "session-bounded-history", + sessionKey: "agent:main:dashboard:bounded-history", + }; + + await expect(importCodexThreadHistoryToTranscript(importParams)).resolves.toEqual({ + importedMessages: 200, + omittedMessages: 5, + }); + await expect(importCodexThreadHistoryToTranscript(importParams)).resolves.toEqual({ + importedMessages: 200, + omittedMessages: 5, + }); + + const raw = await fs.readFile(sessionFile, "utf8"); + const messages = parseJsonLines<{ message?: AgentMessage; type?: string }>(raw) + .filter((event) => event.type === "message") + .map((event) => event.message); + expect(messages).toHaveLength(200); + expect(messages[0]).toMatchObject({ content: "message-5" }); + expect(messages.at(-1)).toMatchObject({ content: "message-204" }); + }); + + it("assigns canonical assistant attribution and numeric fallback timestamps", async () => { + const sessionFile = await createTempSessionFile(); + await initializeSessionTranscript(sessionFile, "session-fallback-history"); + const thread = { + id: "thread-fallback-history", + modelProvider: "source-provider", + turns: [ + { + id: "turn-without-time", + status: "completed", + items: [ + { + id: "user-without-time", + type: "userMessage", + content: [{ type: "text", text: "Earlier prompt" }], + }, + { + id: "assistant-without-time", + type: "agentMessage", + text: "Earlier answer", + }, + ], + }, + ], + } as unknown as CodexThread; + + await importCodexThreadHistoryToTranscript({ + thread, + throughTurnId: "turn-without-time", + sessionFile, + sessionId: "session-fallback-history", + sessionKey: "agent:main:dashboard:fallback-history", + }); + + const history = await readCodexMirroredSessionHistoryMessages({ + sessionFile, + sessionId: "session-fallback-history", + sessionKey: "agent:main:dashboard:fallback-history", + }); + expect(history).toMatchObject([ + { role: "user", content: "Earlier prompt", timestamp: expect.any(Number) }, + { + role: "assistant", + content: [{ type: "text", text: "Earlier answer" }], + api: "openai-chatgpt-responses", + provider: "source-provider", + model: "native-history", + usage: { totalTokens: 0 }, + stopReason: "stop", + timestamp: expect.any(Number), + }, + ]); + }); +}); + +describe("projectBoundedCodexThreadHistory", () => { + const thread = { + id: "thread-prefix", + createdAt: 1_700_000_000, + turns: [ + { + id: "turn-a", + status: "completed", + startedAt: 1_700_000_001, + completedAt: 1_700_000_002, + items: [ + { + id: "user-a", + type: "userMessage", + content: [{ type: "text", text: "First question" }], + }, + { + id: "assistant-a", + type: "agentMessage", + text: "First answer", + phase: "commentary", + }, + ], + }, + { + id: "turn-b", + status: "completed", + startedAt: 1_700_000_003, + completedAt: 1_700_000_004, + items: [ + { + id: "user-b", + type: "userMessage", + content: [{ type: "text", text: "Second question" }], + }, + { + id: "assistant-b", + type: "agentMessage", + text: "Second answer", + phase: "final_answer", + }, + ], + }, + { + id: "turn-active", + status: "inProgress", + items: [ + { + id: "active-secret", + type: "agentMessage", + text: "Do not import the active tail", + }, + ], + }, + { + id: "turn-failed", + status: "failed", + items: [ + { + id: "failed-secret", + type: "agentMessage", + text: "Do not import the failed tail", + }, + ], + }, + ], + } as unknown as CodexThread; + + it("uses one inclusive completed-turn prefix for transcript and Responses API projection", () => { + const projection = projectBoundedCodexThreadHistory({ + thread, + throughTurnId: "turn-b", + importedAt: 1_800_000_000_000, + modelProvider: "native-provider", + }); + + expect(projection).toMatchObject({ importedMessages: 4, omittedMessages: 0 }); + expect(projection.transcriptMessages.map(messageContent)).toEqual([ + "First question", + [{ type: "text", text: "First answer" }], + "Second question", + [{ type: "text", text: "Second answer" }], + ]); + expect(projection.transcriptMessages[1]).toMatchObject({ + role: "assistant", + api: "openai-chatgpt-responses", + provider: "native-provider", + model: "native-history", + }); + expect(projection.responseItems).toEqual([ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "First question" }], + }, + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "First answer" }], + phase: "commentary", + }, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Second question" }], + }, + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Second answer" }], + phase: "final_answer", + }, + ]); + expect(JSON.stringify(projection)).not.toContain("active tail"); + expect(JSON.stringify(projection)).not.toContain("failed tail"); + }); + + it("accepts terminal boundaries", () => { + for (const status of ["completed", "interrupted", "failed"]) { + const terminalThread = { + ...thread, + turns: [ + ...(thread.turns?.slice(0, 2) ?? []), + { + id: `turn-${status}`, + status, + items: [ + { + id: `assistant-${status}`, + type: "agentMessage", + text: `${status} answer`, + }, + ], + }, + ], + } as unknown as CodexThread; + const projection = projectBoundedCodexThreadHistory({ + thread: terminalThread, + throughTurnId: `turn-${status}`, + importedAt: 1_800_000_000_000, + }); + expect(messageContent(projection.transcriptMessages.at(-1))).toEqual([ + { type: "text", text: `${status} answer` }, + ]); + } + }); + + it("enforces UTF-8 byte limits without splitting multibyte text", () => { + const oversizedText = `prefix-${"🙂".repeat(20_000)}-suffix`; + const oversizedThread = { + id: "thread-byte-bounds", + turns: Array.from({ length: 9 }, (_, index) => ({ + id: `turn-${index}`, + status: "completed", + items: [ + { + id: `user-${index}`, + type: "userMessage", + content: [{ type: "text", text: `${index}:${oversizedText}` }], + }, + ], + })), + } as unknown as CodexThread; + + const projection = projectBoundedCodexThreadHistory({ + thread: oversizedThread, + throughTurnId: "turn-8", + importedAt: 1_800_000_000_000, + }); + const texts = projection.transcriptMessages.map((message) => { + const content = messageContent(message); + return typeof content === "string" ? content : ""; + }); + + expect(projection).toMatchObject({ importedMessages: 8, omittedMessages: 1 }); + expect(texts[0]).toMatch(/^1:prefix-/u); + expect(texts.every((text) => Buffer.byteLength(text, "utf8") <= 64 * 1024)).toBe(true); + expect( + texts.reduce((bytes, text) => bytes + Buffer.byteLength(text, "utf8"), 0), + ).toBeLessThanOrEqual(512 * 1024); + expect(texts.every((text) => !text.includes("�"))).toBe(true); + expect( + texts.every((text) => text.endsWith("[Message truncated during Codex history import.]")), + ).toBe(true); + }); + + it("rejects a non-terminal or missing boundary and projects no history without one", () => { + expect(() => + projectBoundedCodexThreadHistory({ + thread, + throughTurnId: "turn-active", + importedAt: 1_800_000_000_000, + }), + ).toThrow("Codex history boundary turn is not terminal: turn-active"); + expect(() => + projectBoundedCodexThreadHistory({ + thread, + throughTurnId: "turn-missing", + importedAt: 1_800_000_000_000, + }), + ).toThrow("Codex history boundary turn not found: turn-missing"); + expect( + projectBoundedCodexThreadHistory({ + thread, + throughTurnId: null, + importedAt: 1_800_000_000_000, + }), + ).toEqual({ + importedMessages: 0, + omittedMessages: 0, + responseItems: [], + transcriptMessages: [], + }); + }); +}); + describe("mirrorCodexAppServerTranscript", () => { it("mirrors user, assistant, and tool result messages into the embedded-agent transcript", async () => { const sessionFile = await createTempSessionFile(); diff --git a/extensions/codex/src/app-server/transcript-mirror.ts b/extensions/codex/src/app-server/transcript-mirror.ts index 7daf1bf1398e..29928588713f 100644 --- a/extensions/codex/src/app-server/transcript-mirror.ts +++ b/extensions/codex/src/app-server/transcript-mirror.ts @@ -1,4 +1,5 @@ // Codex plugin module implements transcript mirror behavior. +import { Buffer } from "node:buffer"; import { createHash } from "node:crypto"; import { embeddedAgentLog, @@ -8,6 +9,7 @@ import { type EmbeddedRunAttemptParams, type EmbeddedRunAttemptResult, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import type { AssistantMessage, Usage } from "openclaw/plugin-sdk/llm"; import { publishSessionTranscriptUpdateByIdentity, withSessionTranscriptWriteLock, @@ -15,6 +17,7 @@ import { type SessionTranscriptWriteLockParams, } from "openclaw/plugin-sdk/session-transcript-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import type { CodexThread, JsonValue } from "./protocol.js"; type MirroredAgentMessage = Extract; type MirroredUserMessage = Extract; @@ -27,6 +30,285 @@ export type CodexAppServerTranscriptMirrorResult = { const MIRROR_IDENTITY_META_KEY = "mirrorIdentity" as const; const MIRROR_ORIGIN_META_KEY = "mirrorOrigin" as const; const CODEX_APP_SERVER_MIRROR_ORIGIN = "codex-app-server" as const; +const CODEX_HISTORY_IMPORT_MAX_MESSAGES = 200; +const CODEX_HISTORY_IMPORT_MAX_BYTES = 512 * 1024; +const CODEX_HISTORY_IMPORT_MAX_MESSAGE_BYTES = 64 * 1024; +const CODEX_HISTORY_TRUNCATION_SUFFIX = "\n\n[Message truncated during Codex history import.]"; +const CODEX_HISTORY_ASSISTANT_API = "openai-chatgpt-responses" as const; +const CODEX_HISTORY_ASSISTANT_PROVIDER = "openai"; +const CODEX_HISTORY_ASSISTANT_MODEL = "native-history"; +const CODEX_HISTORY_ZERO_USAGE: Usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +export type CodexThreadHistoryImportResult = { + importedMessages: number; + omittedMessages: number; +}; + +export type BoundedCodexThreadHistoryProjection = CodexThreadHistoryImportResult & { + responseItems: JsonValue[]; + transcriptMessages: AgentMessage[]; +}; + +type ProjectedCodexHistoryMessage = { + message: AgentMessage; + responseItem: JsonValue; + textBytes: number; +}; + +function isUtf8ContinuationByte(byte: number | undefined): boolean { + return byte !== undefined && (byte & 0xc0) === 0x80; +} + +function truncateUtf8Prefix(value: string, maxBytes: number): string { + const bytes = Buffer.from(value); + if (bytes.byteLength <= maxBytes) { + return value; + } + let end = Math.max(0, maxBytes); + while (end > 0 && isUtf8ContinuationByte(bytes[end])) { + end -= 1; + } + return bytes.subarray(0, end).toString("utf8"); +} + +function normalizeImportedHistoryText(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const text = value.trim(); + if (!text) { + return undefined; + } + if (Buffer.byteLength(text, "utf8") <= CODEX_HISTORY_IMPORT_MAX_MESSAGE_BYTES) { + return text; + } + const suffixBytes = Buffer.byteLength(CODEX_HISTORY_TRUNCATION_SUFFIX, "utf8"); + const contentLimitBytes = Math.max(0, CODEX_HISTORY_IMPORT_MAX_MESSAGE_BYTES - suffixBytes); + return `${truncateUtf8Prefix(text, contentLimitBytes)}${CODEX_HISTORY_TRUNCATION_SUFFIX}`; +} + +function projectCodexUserItemText(item: Record): string | undefined { + if (!Array.isArray(item.content)) { + return undefined; + } + const parts: string[] = []; + for (const value of item.content) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + continue; + } + const input = value as Record; + if (input.type === "text") { + const text = normalizeImportedHistoryText(input.text); + if (text) { + parts.push(text); + } + continue; + } + if (input.type === "image" || input.type === "localImage") { + parts.push("[Image attachment]"); + continue; + } + if (input.type === "skill" || input.type === "mention") { + const name = normalizeOptionalString(input.name); + if (name) { + parts.push(`${input.type === "skill" ? "$" : "@"}${name}`); + } + } + } + return normalizeImportedHistoryText(parts.join("\n")); +} + +function selectTurnsThroughBoundary( + thread: CodexThread, + throughTurnId: string | null, +): NonNullable { + if (throughTurnId === null) { + return []; + } + const turns = thread.turns ?? []; + const boundaryIndex = turns.findIndex((turn) => turn.id === throughTurnId); + if (boundaryIndex < 0) { + throw new Error(`Codex history boundary turn not found: ${throughTurnId}`); + } + const boundary = turns[boundaryIndex]; + if ( + boundary?.status !== "completed" && + boundary?.status !== "interrupted" && + boundary?.status !== "failed" + ) { + throw new Error(`Codex history boundary turn is not terminal: ${throughTurnId}`); + } + return turns.slice(0, boundaryIndex + 1); +} + +function projectCodexThreadHistory(params: { + thread: CodexThread; + throughTurnId: string | null; + importedAt: number; + modelProvider?: string; +}): ProjectedCodexHistoryMessage[] { + const projected: ProjectedCodexHistoryMessage[] = []; + const threadTimestamp = + typeof params.thread.createdAt === "number" && Number.isFinite(params.thread.createdAt) + ? params.thread.createdAt * 1000 + : params.importedAt; + let itemOffset = 0; + for (const turn of selectTurnsThroughBoundary(params.thread, params.throughTurnId)) { + for (const value of turn.items) { + const item = value as unknown as Record; + const itemId = normalizeOptionalString(item.id); + const identity = `${turn.id}:${itemId ?? itemOffset}`; + const timestampSeconds = + item.type === "agentMessage" + ? (turn.completedAt ?? turn.startedAt) + : (turn.startedAt ?? turn.completedAt); + const timestamp = + typeof timestampSeconds === "number" && Number.isFinite(timestampSeconds) + ? timestampSeconds * 1000 + itemOffset + : threadTimestamp + itemOffset; + const text = + item.type === "userMessage" + ? projectCodexUserItemText(item) + : item.type === "agentMessage" + ? normalizeImportedHistoryText(item.text) + : undefined; + const role = + item.type === "userMessage" + ? ("user" as const) + : item.type === "agentMessage" + ? ("assistant" as const) + : undefined; + itemOffset += 1; + if (!text || !role) { + continue; + } + const message = + role === "assistant" + ? attachCodexMirrorIdentity( + { + role, + content: [{ type: "text", text }], + api: CODEX_HISTORY_ASSISTANT_API, + provider: + normalizeOptionalString(params.modelProvider) ?? + normalizeOptionalString(params.thread.modelProvider) ?? + CODEX_HISTORY_ASSISTANT_PROVIDER, + model: CODEX_HISTORY_ASSISTANT_MODEL, + usage: CODEX_HISTORY_ZERO_USAGE, + stopReason: "stop", + timestamp, + } satisfies AssistantMessage, + identity, + ) + : attachCodexMirrorIdentity({ role, content: text, timestamp } as AgentMessage, identity); + const phase = + item.phase === "commentary" || item.phase === "final_answer" ? item.phase : undefined; + projected.push({ + message, + responseItem: { + type: "message", + role, + content: [ + { + type: role === "assistant" ? "output_text" : "input_text", + text, + }, + ], + ...(role === "assistant" && phase ? { phase } : {}), + }, + textBytes: Buffer.byteLength(text, "utf8"), + }); + } + } + return projected; +} + +function selectBoundedCodexHistoryTail( + projected: ProjectedCodexHistoryMessage[], +): ProjectedCodexHistoryMessage[] { + const selected: ProjectedCodexHistoryMessage[] = []; + let selectedBytes = 0; + for (let index = projected.length - 1; index >= 0; index -= 1) { + const candidate = projected[index]; + if (!candidate) { + continue; + } + if ( + selected.length >= CODEX_HISTORY_IMPORT_MAX_MESSAGES || + selectedBytes + candidate.textBytes > CODEX_HISTORY_IMPORT_MAX_BYTES + ) { + break; + } + selected.push(candidate); + selectedBytes += candidate.textBytes; + } + return selected.toReversed(); +} + +/** Projects one terminal Codex history prefix into transcript and Responses API items. */ +export function projectBoundedCodexThreadHistory(params: { + thread: CodexThread; + throughTurnId: string | null; + importedAt: number; + modelProvider?: string | null; +}): BoundedCodexThreadHistoryProjection { + const projected = projectCodexThreadHistory({ + thread: params.thread, + throughTurnId: params.throughTurnId, + importedAt: params.importedAt, + ...(params.modelProvider ? { modelProvider: params.modelProvider } : {}), + }); + const selected = selectBoundedCodexHistoryTail(projected); + return { + importedMessages: selected.length, + omittedMessages: projected.length - selected.length, + responseItems: selected.map(({ responseItem }) => responseItem), + transcriptMessages: selected.map(({ message }) => message), + }; +} + +/** Imports a bounded, user-visible Codex history tail into a new OpenClaw transcript. */ +export async function importCodexThreadHistoryToTranscript(params: { + thread: CodexThread; + throughTurnId: string | null; + sessionFile: string; + sessionId: string; + sessionKey: string; + agentId?: string; + cwd?: string; + modelProvider?: string | null; + config?: SessionTranscriptWriteLockParams["config"]; +}): Promise { + const projection = projectBoundedCodexThreadHistory({ + thread: params.thread, + throughTurnId: params.throughTurnId, + importedAt: Date.now(), + ...(params.modelProvider ? { modelProvider: params.modelProvider } : {}), + }); + if (projection.transcriptMessages.length > 0) { + await mirrorCodexAppServerTranscript({ + sessionFile: params.sessionFile, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(params.cwd ? { cwd: params.cwd } : {}), + ...(params.config ? { config: params.config } : {}), + messages: projection.transcriptMessages, + idempotencyScope: `codex-app-server:${params.thread.id}:history`, + }); + } + return { + importedMessages: projection.importedMessages, + omittedMessages: projection.omittedMessages, + }; +} function attachCodexMirrorOrigin(message: AgentMessage): AgentMessage { const record = message as unknown as Record; diff --git a/extensions/codex/src/app-server/transport-stdio.test.ts b/extensions/codex/src/app-server/transport-stdio.test.ts index 3a72a2d51365..be1c5aefeb7c 100644 --- a/extensions/codex/src/app-server/transport-stdio.test.ts +++ b/extensions/codex/src/app-server/transport-stdio.test.ts @@ -2,14 +2,19 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CodexAppServerStartOptions } from "./config.js"; import { + createStdioTransport, resolveCodexAppServerDetachedMode, resolveCodexAppServerSpawnEnv, resolveCodexAppServerSpawnInvocation, } from "./transport-stdio.js"; +const spawnMock = vi.hoisted(() => vi.fn(() => ({ pid: 1234 }))); + +vi.mock("node:child_process", () => ({ spawn: spawnMock })); + const tempDirs: string[] = []; async function createTempDir(): Promise { @@ -24,6 +29,10 @@ afterEach(async () => { } }); +beforeEach(() => { + spawnMock.mockClear(); +}); + function startOptions(command: string): CodexAppServerStartOptions { return { transport: "stdio", @@ -107,6 +116,21 @@ describe("resolveCodexAppServerSpawnInvocation", () => { }); }); +describe("createStdioTransport", () => { + it("spawns a compatibility endpoint in its configured working directory", () => { + createStdioTransport({ + ...startOptions("codex"), + cwd: "/srv/codex-project", + }); + + expect(spawnMock).toHaveBeenCalledWith( + "codex", + ["app-server", "--listen", "stdio://"], + expect.objectContaining({ cwd: "/srv/codex-project" }), + ); + }); +}); + describe("resolveCodexAppServerSpawnEnv", () => { it("applies configured env overrides before clearing denied env vars", () => { expect({ diff --git a/extensions/codex/src/app-server/transport-stdio.ts b/extensions/codex/src/app-server/transport-stdio.ts index 08988ef5b4a0..c42260ba5fa4 100644 --- a/extensions/codex/src/app-server/transport-stdio.ts +++ b/extensions/codex/src/app-server/transport-stdio.ts @@ -114,6 +114,9 @@ export function createStdioTransport(options: CodexAppServerStartOptions): Codex execPath: process.execPath, }); return spawn(invocation.command, invocation.args, { + // Preserve the shipped Supervisor endpoint contract: relative commands and + // config discovery may depend on the endpoint's process working directory. + ...(options.cwd !== undefined ? { cwd: options.cwd } : {}), env, detached: resolveCodexAppServerDetachedMode(env), shell: invocation.shell, diff --git a/extensions/codex/src/app-server/transport-websocket.test.ts b/extensions/codex/src/app-server/transport-websocket.test.ts index e9b6a5913d84..13ace6ee0fda 100644 --- a/extensions/codex/src/app-server/transport-websocket.test.ts +++ b/extensions/codex/src/app-server/transport-websocket.test.ts @@ -1,11 +1,18 @@ // Codex tests cover transport websocket plugin behavior. +import { mkdtemp, rm } from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { WebSocketServer, type RawData } from "ws"; import { CodexAppServerClient } from "./client.js"; +import { resolveCodexAppServerUnixSocketPath } from "./transport-websocket.js"; describe("Codex app-server websocket transport", () => { const clients: CodexAppServerClient[] = []; const servers: WebSocketServer[] = []; + const httpServers: http.Server[] = []; + const tempDirs: string[] = []; afterEach(async () => { for (const client of clients) { @@ -20,6 +27,15 @@ describe("Codex app-server websocket transport", () => { }), ), ); + await Promise.all( + httpServers.splice(0).map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()); + }), + ), + ); + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true }))); }); it("can speak JSON-RPC over websocket transport", async () => { @@ -59,6 +75,66 @@ describe("Codex app-server websocket transport", () => { await expect(client.request("model/list", {})).resolves.toEqual({ data: [] }); expect(authHeaders).toEqual(["Bearer secret"]); }); + + it("can speak JSON-RPC over the canonical unix control socket", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-codex-unix-")); + tempDirs.push(tempDir); + const socketPath = path.join(tempDir, "app-server.sock"); + const httpServer = http.createServer(); + httpServers.push(httpServer); + const server = new WebSocketServer({ server: httpServer }); + servers.push(server); + const upgradeExtensions: Array = []; + server.on("connection", (socket, request) => { + upgradeExtensions.push(request.headers["sec-websocket-extensions"]); + socket.on("message", (data) => { + const message = JSON.parse(rawDataToText(data)) as { id?: number; method?: string }; + if (message.method === "initialize") { + socket.send( + JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.144.1" } }), + ); + return; + } + if (message.method === "thread/list") { + socket.send(JSON.stringify({ id: message.id, result: { data: [] } })); + } + }); + }); + await new Promise((resolve, reject) => { + httpServer.once("error", reject); + httpServer.listen(socketPath, resolve); + }); + + const client = CodexAppServerClient.start({ + transport: "unix", + homeScope: "user", + url: `unix://${socketPath}`, + }); + clients.push(client); + + await expect(client.initialize()).resolves.toBeUndefined(); + await expect(client.request("thread/list", {})).resolves.toEqual({ data: [] }); + expect(upgradeExtensions).toEqual([undefined]); + }); + + it("resolves the default control socket under CODEX_HOME", () => { + expect( + resolveCodexAppServerUnixSocketPath({ + transport: "unix", + url: "unix://", + env: { CODEX_HOME: "/tmp/custom-codex-home" }, + }), + ).toBe("/tmp/custom-codex-home/app-server-control/app-server-control.sock"); + }); + + it("rejects unix URLs unless the unix transport is explicit", () => { + expect(() => + resolveCodexAppServerUnixSocketPath({ + transport: "websocket", + url: "unix://", + }), + ).toThrow("codex app-server unix URL requires unix transport"); + }); }); function rawDataToText(data: RawData): string { diff --git a/extensions/codex/src/app-server/transport-websocket.ts b/extensions/codex/src/app-server/transport-websocket.ts index 0f1f33616a5c..ba52ab9b3e56 100644 --- a/extensions/codex/src/app-server/transport-websocket.ts +++ b/extensions/codex/src/app-server/transport-websocket.ts @@ -3,9 +3,11 @@ * transport interface. */ import { EventEmitter } from "node:events"; +import net from "node:net"; +import path from "node:path"; import { PassThrough, Writable } from "node:stream"; import WebSocket, { type RawData } from "ws"; -import type { CodexAppServerStartOptions } from "./config.js"; +import { resolveCodexAppServerUserHomeDir, type CodexAppServerStartOptions } from "./config.js"; import type { CodexAppServerTransport } from "./transport.js"; /** Opens a WebSocket app-server transport and maps newline-delimited frames to stdout/stdin. */ @@ -24,7 +26,18 @@ export function createWebSocketTransport( ...options.headers, ...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}), }; - const socket = new WebSocket(options.url, { headers }); + const websocketOptions: WebSocket.ClientOptions = { + headers, + // Codex app-server closes Unix upgrade handshakes that offer compression. + perMessageDeflate: false, + }; + const unixSocketPath = resolveCodexAppServerUnixSocketPath(options); + const socket = unixSocketPath + ? new WebSocket("ws://localhost/", { + ...websocketOptions, + createConnection: () => connectCodexAppServerUnixSocket(unixSocketPath), + }) + : new WebSocket(options.url, websocketOptions); const pendingFrames: string[] = []; let killed = false; @@ -89,6 +102,36 @@ export function createWebSocketTransport( }; } +/** Opens the owner-scoped Codex control socket used by the WebSocket upgrade. */ +export function connectCodexAppServerUnixSocket(socketPath: string): net.Socket { + return net.createConnection(socketPath); +} + +/** Resolves the canonical or explicitly configured Codex control socket. */ +export function resolveCodexAppServerUnixSocketPath( + options: Pick, +): string | undefined { + if (options.transport !== "unix") { + if (options.url?.startsWith("unix://")) { + throw new Error("codex app-server unix URL requires unix transport"); + } + return undefined; + } + const url = options.url ?? "unix://"; + if (!url.startsWith("unix://")) { + throw new Error("codex app-server unix transport requires a unix:// URL"); + } + const configuredPath = url.slice("unix://".length); + return ( + configuredPath || + path.join( + resolveCodexAppServerUserHomeDir(options.env ?? process.env), + "app-server-control", + "app-server-control.sock", + ) + ); +} + function websocketFrameToText(data: RawData): string { if (typeof data === "string") { return data; diff --git a/extensions/codex/src/command-handlers.ts b/extensions/codex/src/command-handlers.ts index 1acf07ab1dbe..e86d62d493e0 100644 --- a/extensions/codex/src/command-handlers.ts +++ b/extensions/codex/src/command-handlers.ts @@ -1,12 +1,18 @@ // Codex plugin module implements command handlers behavior. import crypto from "node:crypto"; import { resolveAgentDir, resolveSessionAgentIds } from "openclaw/plugin-sdk/agent-runtime"; +import { + isModelSelectionLocked, + MODEL_SELECTION_LOCKED_MESSAGE, +} from "openclaw/plugin-sdk/model-session-runtime"; import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry"; import { parseAgentSessionKey } from "openclaw/plugin-sdk/routing"; +import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { resolveCodexAppServerAuthProfileIdForAgent } from "./app-server/auth-bridge.js"; +import { resolveCodexBindingAppServerConnection } from "./app-server/binding-connection.js"; import { CODEX_CONTROL_METHODS, type CodexControlMethod } from "./app-server/capabilities.js"; import { installCodexComputerUse, @@ -22,12 +28,14 @@ import { resolveCodexNativeSandboxBlock, } from "./app-server/sandbox-guard.js"; import { + assertCodexBindingMayBeReplaced, bindingStoreKey, normalizeCodexAppServerBindingModelProvider, reclaimCurrentCodexSessionGeneration, sessionBindingIdentity, type CodexAppServerBindingIdentity, type CodexAppServerBindingStore, + type CodexAppServerThreadBinding, } from "./app-server/session-binding.js"; import { readCodexAccountAuthOverview } from "./command-account.js"; import { canMutateCodexHost, CODEX_NATIVE_EXECUTION_AUTH_ERROR } from "./command-authorization.js"; @@ -190,6 +198,9 @@ type CodexDiagnosticsTarget = { threadId: string; identity: CodexAppServerBindingIdentity; agentDir: string; + connectionScope?: "supervision"; + appServerRuntimeFingerprint?: string; + pendingSupervisionBranch?: CodexAppServerThreadBinding["pendingSupervisionBranch"]; authProfileId?: string; sessionKey?: string; sessionId?: string; @@ -200,7 +211,14 @@ type CodexDiagnosticsTarget = { threadParentId?: string; }; -type CodexDiagnosticsCandidate = Omit; +type CodexDiagnosticsCandidate = Omit< + CodexDiagnosticsTarget, + | "threadId" + | "connectionScope" + | "appServerRuntimeFingerprint" + | "pendingSupervisionBranch" + | "authProfileId" +>; type PendingCodexDiagnosticsConfirmation = { token: string; @@ -512,7 +530,7 @@ export async function handleCodexSubcommand( if (rest.length > 0) { return { text: "Usage: /codex mcp" }; } - const scope = await resolveCommandAppServerScope(deps, ctx); + const scope = await resolveCommandAppServerScope(deps, ctx, options.pluginConfig); return { text: formatList( await deps.codexControlRequest( @@ -529,7 +547,7 @@ export async function handleCodexSubcommand( if (rest.length > 0) { return { text: "Usage: /codex skills" }; } - const scope = await resolveCommandAppServerScope(deps, ctx); + const scope = await resolveCommandAppServerScope(deps, ctx, options.pluginConfig); return { text: formatSkills( await deps.codexControlRequest( @@ -545,7 +563,7 @@ export async function handleCodexSubcommand( if (rest.length > 0) { return { text: "Usage: /codex account" }; } - const scope = await resolveCommandAppServerScope(deps, ctx); + const scope = await resolveCommandAppServerScope(deps, ctx, options.pluginConfig); const requestScope = { config: ctx.config, ...scope }; const [account, limits] = await Promise.all([ deps.safeCodexControlRequest( @@ -578,6 +596,24 @@ export async function handleCodexSubcommand( return { text: `Unknown Codex command: ${formatCodexDisplayText(subcommand)}\n\n${buildHelp()}` }; } +function isCurrentSessionModelSelectionLocked(ctx: PluginCommandContext): boolean { + const sessionKey = ctx.sessionKey?.trim(); + if (!sessionKey) { + return false; + } + // SessionEntry is the durable authority even when a native binding is absent or stale. + // Never infer this lock from binding model metadata such as preserveNativeModel. + const storePath = resolveStorePath(ctx.config.session?.store, { agentId: ctx.agentId }); + return isModelSelectionLocked( + getSessionEntry({ + storePath, + sessionKey, + hydrateSkillPromptRefs: false, + readConsistency: "latest", + }), + ); +} + function resolveCodexNativeCommandSandboxBlock( ctx: PluginCommandContext, subcommand: string, @@ -692,6 +728,9 @@ async function bindConversation( text: "Usage: /codex bind [thread-id] [--cwd ] [--model ] [--provider ]", }; } + if (isCurrentSessionModelSelectionLocked(ctx)) { + return { text: MODEL_SELECTION_LOCKED_MESSAGE }; + } const scope = resolveCodexConversationControlScope(ctx); const workspaceDir = parsed.cwd ?? deps.resolveCodexDefaultWorkspaceDir(pluginConfig); const currentConversation = await ctx.getCurrentConversationBinding(); @@ -715,6 +754,7 @@ async function bindConversation( ? conversationBindingIdentity(currentConversationData.bindingId) : sessionOwner; const existingBinding = currentOwner ? await deps.bindingStore.read(currentOwner) : undefined; + assertCodexBindingMayBeReplaced(existingBinding, "binding this conversation to another thread"); const sessionSource = sessionOwner && existingBinding ? { @@ -767,8 +807,15 @@ async function detachConversation( deps: CodexCommandDeps, ctx: PluginCommandContext, ): Promise { + if (isCurrentSessionModelSelectionLocked(ctx)) { + return MODEL_SELECTION_LOCKED_MESSAGE; + } const current = await ctx.getCurrentConversationBinding(); const data = readCodexConversationBindingData(current); + if (data?.kind === "codex-app-server-session") { + const binding = await deps.bindingStore.read(conversationBindingIdentity(data.bindingId)); + assertCodexBindingMayBeReplaced(binding, "detaching its conversation binding"); + } const detached = await ctx.detachConversationBinding(); if (data?.kind === "codex-app-server-session") { await deps.bindingStore.mutate(conversationBindingIdentity(data.bindingId), { kind: "clear" }); @@ -818,7 +865,7 @@ async function buildThreads( pluginConfig: unknown, filter: string, ): Promise { - const scope = await resolveCommandAppServerScope(deps, ctx); + const scope = await resolveCommandAppServerScope(deps, ctx, pluginConfig); const response = await deps.codexControlRequest( pluginConfig, CODEX_CONTROL_METHODS.listThreads, @@ -864,6 +911,9 @@ async function resumeThread( if (!normalizedThreadId || args.length !== 1) { return "Usage: /codex resume "; } + if (isCurrentSessionModelSelectionLocked(ctx)) { + return MODEL_SELECTION_LOCKED_MESSAGE; + } if (!ctx.sessionId) { return "Cannot attach a Codex thread because this command did not include an OpenClaw session id."; } @@ -884,6 +934,7 @@ async function resumeThread( throw new Error(`Codex session generation is no longer current: ${identity.sessionId}`); } const currentBinding = await deps.bindingStore.read(identity); + assertCodexBindingMayBeReplaced(currentBinding, "attaching a different resumed thread"); const authProfileId = resolveCodexAppServerAuthProfileIdForAgent({ authProfileId: currentBinding?.authProfileId, agentDir: scope.agentDir, @@ -922,6 +973,8 @@ async function resumeThread( agentDir: scope.agentDir, config: ctx.config, }); + const bindingBeforeCommit = await deps.bindingStore.read(identity); + assertCodexBindingMayBeReplaced(bindingBeforeCommit, "committing a different resumed thread"); const committed = await deps.bindingStore.mutate(identity, { kind: "set", binding: { @@ -950,6 +1003,21 @@ async function bindCodexCliNodeSession( if (!parsed.threadId || !parsed.host || parsed.bindHere !== true) { return "Usage: /codex resume --host --bind here"; } + if (isCurrentSessionModelSelectionLocked(ctx)) { + return MODEL_SELECTION_LOCKED_MESSAGE; + } + if (ctx.sessionId) { + const scope = resolveCodexConversationControlScope(ctx); + const binding = await deps.bindingStore.read( + sessionBindingIdentity({ + sessionId: ctx.sessionId, + sessionKey: ctx.sessionKey, + agentId: scope.agentId, + config: ctx.config, + }), + ); + assertCodexBindingMayBeReplaced(binding, "binding a Codex CLI node session"); + } const resolved = await deps.resolveCodexCliSessionForBindingOnNode({ requestedNode: parsed.host, sessionId: parsed.threadId, @@ -1036,12 +1104,15 @@ async function setConversationModel( if (args.length > 1) { return "Usage: /codex model "; } + const [model = ""] = args; + const normalized = model.trim(); + if (normalized && isCurrentSessionModelSelectionLocked(ctx)) { + return MODEL_SELECTION_LOCKED_MESSAGE; + } const target = await resolveControlTarget(ctx); if (!target) { return "Cannot set Codex model because this command did not include a stable binding identity."; } - const [model = ""] = args; - const normalized = model.trim(); if (!normalized) { const binding = await deps.bindingStore.read(target.identity); return binding?.model @@ -1143,26 +1214,38 @@ async function resolveControlTarget( type CommandAppServerScope = Pick< CodexControlRequestOptions, - "agentDir" | "authProfileId" | "sessionId" | "sessionKey" + "agentDir" | "authProfileId" | "sessionId" | "sessionKey" | "startOptions" > & { agentId: string }; async function resolveCommandAppServerScope( deps: CodexCommandDeps, ctx: PluginCommandContext, + pluginConfig: unknown, ): Promise { const target = await resolveControlTarget(ctx); const fallback = resolveCodexConversationControlScope(ctx); const agentDir = target?.agentDir ?? fallback.agentDir; const binding = target ? await deps.bindingStore.read(target.identity) : undefined; - const authProfileId = resolveCodexAppServerAuthProfileIdForAgent({ - authProfileId: binding?.authProfileId ?? target?.requestedAuthProfileId, - agentDir, - config: ctx.config, + const authProfileId = + binding?.connectionScope === "supervision" + ? undefined + : resolveCodexAppServerAuthProfileIdForAgent({ + authProfileId: binding?.authProfileId ?? target?.requestedAuthProfileId, + agentDir, + config: ctx.config, + }); + const connection = resolveCodexBindingAppServerConnection({ + binding, + authProfileId, + pluginConfig, }); return { agentId: target?.agentId ?? fallback.agentId, agentDir, - ...(authProfileId ? { authProfileId } : {}), + ...(connection.clientAuthProfileId !== undefined + ? { authProfileId: connection.clientAuthProfileId } + : {}), + ...(connection.usesSupervisionConnection ? { startOptions: connection.appServer.start } : {}), ...(ctx.sessionKey ? { sessionKey: ctx.sessionKey } : {}), ...(ctx.sessionId ? { sessionId: ctx.sessionId } : {}), }; @@ -1448,6 +1531,20 @@ async function sendCodexDiagnosticsFeedbackForTargets( const sent: CodexDiagnosticsTarget[] = []; const failed: Array<{ target: CodexDiagnosticsTarget; error: string }> = []; for (const target of targets) { + let connection: ReturnType; + try { + connection = resolveCodexBindingAppServerConnection({ + binding: target, + authProfileId: target.authProfileId, + pluginConfig, + }); + } catch (error) { + failed.push({ + target, + error: error instanceof Error ? error.message : String(error), + }); + continue; + } const response = await deps.safeCodexControlRequest( pluginConfig, CODEX_CONTROL_METHODS.feedback, @@ -1461,7 +1558,12 @@ async function sendCodexDiagnosticsFeedbackForTargets( { config: ctx.config, agentDir: target.agentDir, - ...(target.authProfileId ? { authProfileId: target.authProfileId } : {}), + ...(connection.clientAuthProfileId !== undefined + ? { authProfileId: connection.clientAuthProfileId } + : {}), + ...(connection.usesSupervisionConnection + ? { startOptions: connection.appServer.start } + : {}), ...(target.sessionId ? { sessionId: target.sessionId } : {}), ...(target.sessionKey ? { sessionKey: target.sessionKey } : {}), }, @@ -1566,17 +1668,46 @@ async function resolvePendingCodexDiagnosticsTargets( } function resolveCodexDiagnosticsTarget( - target: CodexDiagnosticsCandidate, - binding: { threadId: string; authProfileId?: string }, + target: CodexDiagnosticsCandidate | CodexDiagnosticsTarget, + binding: Pick< + CodexAppServerThreadBinding, + | "threadId" + | "connectionScope" + | "appServerRuntimeFingerprint" + | "pendingSupervisionBranch" + | "authProfileId" + >, config?: PluginCommandContext["config"], ): CodexDiagnosticsTarget { + // Confirmation re-resolution receives the previous target. Rebuild the candidate so a + // stale private connection scope or auth profile can never survive a binding change. + const candidate: CodexDiagnosticsCandidate = { + identity: target.identity, + agentDir: target.agentDir, + sessionKey: target.sessionKey, + sessionId: target.sessionId, + channel: target.channel, + channelId: target.channelId, + accountId: target.accountId, + messageThreadId: target.messageThreadId, + threadParentId: target.threadParentId, + }; + if (binding.connectionScope === "supervision") { + return { + ...candidate, + threadId: binding.threadId, + connectionScope: binding.connectionScope, + appServerRuntimeFingerprint: binding.appServerRuntimeFingerprint, + pendingSupervisionBranch: binding.pendingSupervisionBranch, + }; + } const authProfileId = resolveCodexAppServerAuthProfileIdForAgent({ authProfileId: binding.authProfileId, agentDir: target.agentDir, config, }); return { - ...target, + ...candidate, threadId: binding.threadId, authProfileId, }; @@ -1590,6 +1721,10 @@ function codexDiagnosticsTargetsMatch( JSON.stringify([ bindingStoreKey(target.identity), target.threadId, + target.connectionScope ?? null, + target.pendingSupervisionBranch?.connectionFingerprint ?? + target.appServerRuntimeFingerprint ?? + null, target.authProfileId ?? null, ]); const expectedTargets = expected.map(fingerprint).toSorted(); @@ -2113,6 +2248,11 @@ async function startThreadAction( if (!binding?.threadId) { return `No Codex thread is attached to this OpenClaw session yet.`; } + const connection = resolveCodexBindingAppServerConnection({ + binding, + authProfileId: binding.authProfileId, + pluginConfig, + }); await deps.codexControlRequest( pluginConfig, kind === "compact" ? CODEX_CONTROL_METHODS.compact : CODEX_CONTROL_METHODS.review, @@ -2121,8 +2261,9 @@ async function startThreadAction( : { threadId: binding.threadId }, { agentDir: target.agentDir, - authProfileId: binding.authProfileId, + authProfileId: connection.clientAuthProfileId, config: ctx.config, + ...(connection.usesSupervisionConnection ? { startOptions: connection.appServer.start } : {}), }, ); return `Started Codex ${label} for thread ${formatCodexDisplayText(binding.threadId)}.`; diff --git a/extensions/codex/src/command-rpc.test.ts b/extensions/codex/src/command-rpc.test.ts index e4eb3c6bd5e5..e6c2fdbf8366 100644 --- a/extensions/codex/src/command-rpc.test.ts +++ b/extensions/codex/src/command-rpc.test.ts @@ -1,9 +1,19 @@ // Codex tests cover command rpc plugin behavior. -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { CodexAppServerRpcError } from "./app-server/client.js"; -import { safeValue } from "./command-rpc.js"; +import { codexControlRequest, safeValue } from "./command-rpc.js"; + +const requestCodexAppServerJsonMock = vi.hoisted(() => vi.fn()); + +vi.mock("./app-server/request.js", () => ({ + requestCodexAppServerJson: requestCodexAppServerJsonMock, +})); describe("Codex command RPC helpers", () => { + beforeEach(() => { + requestCodexAppServerJsonMock.mockReset(); + }); + it("formats unsupported control methods from JSON-RPC error codes", async () => { await expect( safeValue(async () => { @@ -14,4 +24,53 @@ describe("Codex command RPC helpers", () => { error: "unsupported by this Codex app-server", }); }); + + it("uses an explicit control connection instead of ordinary harness start options", async () => { + requestCodexAppServerJsonMock.mockResolvedValue({ thread: { id: "thread-1" } }); + const startOptions = { + transport: "stdio" as const, + homeScope: "user" as const, + command: "codex", + args: ["app-server", "--listen", "stdio://"], + headers: {}, + }; + + await codexControlRequest( + {}, + "thread/read", + { threadId: "thread-1", includeTurns: false }, + { startOptions }, + ); + + expect(requestCodexAppServerJsonMock).toHaveBeenCalledWith( + expect.objectContaining({ startOptions }), + ); + }); + + it("forwards explicit native auth for supervised control connections", async () => { + requestCodexAppServerJsonMock.mockResolvedValue({}); + + await codexControlRequest( + {}, + "thread/compact/start", + { threadId: "thread-1" }, + { + authProfileId: null, + }, + ); + + expect(requestCodexAppServerJsonMock).toHaveBeenCalledWith( + expect.objectContaining({ authProfileId: null }), + ); + }); + + it("forwards an explicit per-request timeout budget", async () => { + requestCodexAppServerJsonMock.mockResolvedValue({ data: [] }); + + await codexControlRequest({}, "thread/list", { archived: false }, { timeoutMs: 321 }); + + expect(requestCodexAppServerJsonMock).toHaveBeenCalledWith( + expect.objectContaining({ timeoutMs: 321 }), + ); + }); }); diff --git a/extensions/codex/src/command-rpc.ts b/extensions/codex/src/command-rpc.ts index 21f660ed42c8..523541d7c651 100644 --- a/extensions/codex/src/command-rpc.ts +++ b/extensions/codex/src/command-rpc.ts @@ -5,7 +5,10 @@ import { describeControlFailure, type CodexControlMethod, } from "./app-server/capabilities.js"; -import { resolveCodexAppServerRuntimeOptions } from "./app-server/config.js"; +import { + resolveCodexAppServerRuntimeOptions, + type CodexAppServerStartOptions, +} from "./app-server/config.js"; import { listCodexAppServerModels } from "./app-server/models.js"; import type { CodexAppServerRequestMethod, @@ -23,11 +26,13 @@ type AuthProfileOrderConfig = Parameters< export type CodexControlRequestOptions = { config?: AuthProfileOrderConfig; - authProfileId?: string; + authProfileId?: string | null; agentDir?: string; sessionKey?: string; sessionId?: string; isolated?: boolean; + startOptions?: CodexAppServerStartOptions; + timeoutMs?: number; }; export function requestOptions( @@ -68,8 +73,8 @@ export async function codexControlRequest( return await requestCodexAppServerJson({ method, requestParams, - timeoutMs: runtime.requestTimeoutMs, - startOptions: runtime.start, + timeoutMs: options.timeoutMs ?? runtime.requestTimeoutMs, + startOptions: options.startOptions ?? runtime.start, config: options.config, sessionKey: options.sessionKey, sessionId: options.sessionId, diff --git a/extensions/codex/src/commands.test.ts b/extensions/codex/src/commands.test.ts index 8d6c228bd739..c1f231e20a77 100644 --- a/extensions/codex/src/commands.test.ts +++ b/extensions/codex/src/commands.test.ts @@ -8,6 +8,7 @@ import { resolveDefaultAgentDir, type AuthProfileStore, } from "openclaw/plugin-sdk/agent-runtime"; +import { MODEL_SELECTION_LOCKED_MESSAGE } from "openclaw/plugin-sdk/model-session-runtime"; import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry"; import { saveSessionStore } from "openclaw/plugin-sdk/session-store-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -17,6 +18,7 @@ import type { CodexAppServerStartOptions } from "./app-server/config.js"; import type { JsonValue } from "./app-server/protocol.js"; import type { CodexAppServerThreadBinding } from "./app-server/session-binding.js"; import { + buildCodexSupervisionTestConnectionFingerprint, resetCodexTestBindingStore, testCodexAppServerBindingStore, } from "./app-server/session-binding.test-helpers.js"; @@ -148,6 +150,38 @@ async function writeTestBinding( await testCodexAppServerBindingStore.mutate(identity, { kind: "set", binding }); } +function supervisedTestBinding(threadId = "thread-supervised"): CodexAppServerThreadBinding { + return { + threadId, + connectionScope: "supervision", + supervisionSourceThreadId: threadId, + appServerRuntimeFingerprint: buildCodexSupervisionTestConnectionFingerprint(), + cwd: "/repo", + model: "gpt-5.5", + modelProvider: "openai", + preserveNativeModel: true, + conversationSourceTransferComplete: true, + }; +} + +async function createLockedSessionContextOverrides( + sessionKey = "agent:main:test:locked", +): Promise> { + const storePath = path.join(tempDir, "locked-sessions.json"); + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: "session-1", + updatedAt: Date.now(), + agentHarnessId: "codex", + modelSelectionLocked: true, + }, + }); + return { + config: { session: { store: storePath } }, + sessionKey, + }; +} + function inMemoryCodexPluginsIO( initial: Record = {}, options: { enabled?: boolean } = { enabled: true }, @@ -2229,6 +2263,27 @@ describe("codex command", () => { ); }); + it("starts supervised compact and review actions through the native user-home connection", async () => { + await writeTestBinding( + { kind: "session", agentId: "main", sessionId: "session-1" }, + supervisedTestBinding(), + ); + const codexControlRequest = vi.fn(async () => undefined); + const pluginConfig = { supervision: { enabled: true } }; + const deps = createDeps({ codexControlRequest }); + + await handleCodexCommand(createContext("compact"), { deps, pluginConfig }); + await handleCodexCommand(createContext("review"), { deps, pluginConfig }); + + expect(codexControlRequest).toHaveBeenCalledTimes(2); + for (let callIndex = 0; callIndex < codexControlRequest.mock.calls.length; callIndex += 1) { + expect(mockArg(codexControlRequest, callIndex, 3)).toMatchObject({ + authProfileId: null, + startOptions: { homeScope: "user" }, + }); + } + }); + it("rejects malformed compact and review commands before starting thread actions", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const codexControlRequest = vi.fn(); @@ -2528,6 +2583,91 @@ describe("codex command", () => { expect(safeCodexControlRequest).not.toHaveBeenCalled(); }); + it("sends supervised diagnostics through the native user-home connection", async () => { + const identity = { kind: "session" as const, agentId: "main", sessionId: "session-1" }; + await writeTestBinding(identity, supervisedTestBinding("thread-supervised-diagnostics")); + const safeCodexControlRequest = vi.fn(async () => ({ + ok: true as const, + value: { threadId: "thread-supervised-diagnostics" }, + })); + const deps = createDeps({ safeCodexControlRequest }); + const pluginConfig = { supervision: { enabled: true } }; + const request = await handleCodexCommand(createContext("diagnostics"), { + deps, + pluginConfig, + }); + const token = readDiagnosticsConfirmationToken(request); + + await handleCodexCommand(createContext(`diagnostics confirm ${token}`), { + deps, + pluginConfig, + }); + + expect(safeCodexControlRequest).toHaveBeenCalledWith( + pluginConfig, + CODEX_CONTROL_METHODS.feedback, + expect.objectContaining({ threadId: "thread-supervised-diagnostics" }), + expect.objectContaining({ + authProfileId: null, + startOptions: expect.objectContaining({ homeScope: "user" }), + }), + ); + }); + + it("rejects diagnostics confirmation when private connection scope changes", async () => { + let binding: CodexAppServerThreadBinding = supervisedTestBinding("thread-scope-change"); + const readBinding = vi.fn(async () => binding); + const safeCodexControlRequest = vi.fn(); + const deps = createDeps({ + bindingStore: { ...testCodexAppServerBindingStore, read: readBinding }, + safeCodexControlRequest, + }); + const pluginConfig = { supervision: { enabled: true } }; + const request = await handleCodexCommand(createContext("diagnostics"), { + deps, + pluginConfig, + }); + const token = readDiagnosticsConfirmationToken(request); + binding = { threadId: "thread-scope-change", cwd: "/repo" }; + + await expect( + handleCodexCommand(createContext(`diagnostics confirm ${token}`), { + deps, + pluginConfig, + }), + ).resolves.toEqual({ + text: "The Codex diagnostics sessions changed before confirmation. Run /diagnostics again for the current threads.", + }); + expect(safeCodexControlRequest).not.toHaveBeenCalled(); + }); + + it("rejects diagnostics confirmation when the supervised connection changes", async () => { + let binding: CodexAppServerThreadBinding = supervisedTestBinding("thread-connection-change"); + const readBinding = vi.fn(async () => binding); + const safeCodexControlRequest = vi.fn(); + const deps = createDeps({ + bindingStore: { ...testCodexAppServerBindingStore, read: readBinding }, + safeCodexControlRequest, + }); + const pluginConfig = { supervision: { enabled: true } }; + const request = await handleCodexCommand(createContext("diagnostics"), { + deps, + pluginConfig, + }); + const token = readDiagnosticsConfirmationToken(request); + binding = { ...binding, appServerRuntimeFingerprint: "changed-connection" }; + + await expect( + handleCodexCommand(createContext(`diagnostics confirm ${token}`), { + deps, + pluginConfig, + }), + ).resolves.toEqual({ + text: "The Codex diagnostics sessions changed before confirmation. Run /diagnostics again for the current threads.", + }); + expect(safeCodexControlRequest).not.toHaveBeenCalled(); + }); + it("rejects malformed diagnostics confirmation commands without consuming the token", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); await writeTestBinding( @@ -3737,6 +3877,30 @@ describe("codex command", () => { ); }); + it("scopes supervised Codex reads to the native user-home connection", async () => { + await writeTestBinding( + { kind: "session", agentId: "main", sessionId: "session-1" }, + supervisedTestBinding(), + ); + const codexControlRequest = vi.fn(async () => ({ data: [] })); + const pluginConfig = { supervision: { enabled: true } }; + + await handleCodexCommand(createContext("threads"), { + deps: createDeps({ codexControlRequest }), + pluginConfig, + }); + + expect(codexControlRequest).toHaveBeenCalledWith( + pluginConfig, + CODEX_CONTROL_METHODS.listThreads, + { limit: 10 }, + expect.objectContaining({ + authProfileId: null, + startOptions: expect.objectContaining({ homeScope: "user" }), + }), + ); + }); + it("formats every Codex skill as a code-styled bullet and tolerates malformed entries", async () => { const malformedSkillEntries: JsonValue[] = [ null, @@ -4471,6 +4635,107 @@ describe("codex command", () => { }); }); + it("rejects model and binding replacement commands for a locked supervised session", async () => { + const locked = await createLockedSessionContextOverrides(); + const requestConversationBinding = vi.fn(); + const detachConversationBinding = vi.fn(); + const getCurrentConversationBinding = + vi.fn(); + const setCodexConversationModel = vi.fn(); + const codexControlRequest = vi.fn(); + const resolveCodexCliSessionForBindingOnNode = vi.fn(); + const deps = createDeps({ + codexControlRequest, + resolveCodexCliSessionForBindingOnNode, + setCodexConversationModel, + }); + + for (const args of [ + "model gpt-5.4", + "bind thread-other", + "resume thread-other", + "resume cli-other --host node-1 --bind here", + "detach", + "unbind", + ]) { + await expect( + handleCodexCommand( + createContext(args, undefined, { + ...locked, + detachConversationBinding, + getCurrentConversationBinding, + requestConversationBinding, + }), + { deps }, + ), + ).resolves.toEqual({ text: MODEL_SELECTION_LOCKED_MESSAGE }); + } + + expect(setCodexConversationModel).not.toHaveBeenCalled(); + expect(codexControlRequest).not.toHaveBeenCalled(); + expect(resolveCodexCliSessionForBindingOnNode).not.toHaveBeenCalled(); + expect(requestConversationBinding).not.toHaveBeenCalled(); + expect(detachConversationBinding).not.toHaveBeenCalled(); + expect(getCurrentConversationBinding).not.toHaveBeenCalled(); + }); + + it("rejects bind and resume replacement from private supervision state without a public lock", async () => { + await writeTestBinding( + { kind: "session", agentId: "main", sessionId: "session-1" }, + supervisedTestBinding("thread-private-owner"), + ); + const requestConversationBinding = vi.fn(); + const codexControlRequest = vi.fn(); + const resolveCodexCliSessionForBindingOnNode = vi.fn(); + const deps = createDeps({ codexControlRequest, resolveCodexCliSessionForBindingOnNode }); + + for (const args of [ + "bind thread-other", + "resume thread-other", + "resume cli-other --host node-1 --bind here", + ]) { + const result = await handleCodexCommand( + createContext(args, undefined, { requestConversationBinding }), + { deps, pluginConfig: { supervision: { enabled: true } } }, + ); + expectResultTextContains(result, "Refusing to replace supervised Codex thread"); + } + + expect(requestConversationBinding).not.toHaveBeenCalled(); + expect(codexControlRequest).not.toHaveBeenCalled(); + expect(resolveCodexCliSessionForBindingOnNode).not.toHaveBeenCalled(); + }); + + it("keeps model status, fast mode, and permissions available for a locked session", async () => { + const locked = await createLockedSessionContextOverrides(); + const sessionKey = locked.sessionKey ?? "missing"; + await writeTestBinding( + { kind: "session", agentId: "main", sessionId: "session-1", sessionKey }, + { threadId: "thread-native", cwd: "/repo", model: "native-model" }, + ); + const setCodexConversationFastMode = vi.fn(async () => "Codex fast mode enabled."); + const setCodexConversationPermissions = vi.fn( + async () => "Codex permissions set to full access.", + ); + const deps = createDeps({ + setCodexConversationFastMode, + setCodexConversationPermissions, + }); + + await expect( + handleCodexCommand(createContext("model", undefined, locked), { deps }), + ).resolves.toEqual({ text: "Codex model: native-model" }); + await expect( + handleCodexCommand(createContext("fast on", undefined, locked), { deps }), + ).resolves.toEqual({ text: "Codex fast mode enabled." }); + await expect( + handleCodexCommand(createContext("permissions yolo", undefined, locked), { deps }), + ).resolves.toEqual({ text: "Codex permissions set to full access." }); + + expect(setCodexConversationFastMode).toHaveBeenCalledOnce(); + expect(setCodexConversationPermissions).toHaveBeenCalledOnce(); + }); + it("escapes current bound model status before chat display", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); await writeTestBinding( diff --git a/extensions/codex/src/conversation-binding.test.ts b/extensions/codex/src/conversation-binding.test.ts index bc056bac502e..700c11965139 100644 --- a/extensions/codex/src/conversation-binding.test.ts +++ b/extensions/codex/src/conversation-binding.test.ts @@ -456,6 +456,33 @@ describe("codex conversation binding", () => { expect(data.agentDir).toBe(agentDir); }); + it("rejects direct conversation start over a private supervised binding", async () => { + const sessionFile = path.join(tempDir, "supervised-session.jsonl"); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-supervised", + connectionScope: "supervision", + supervisionSourceThreadId: "thread-source", + cwd: tempDir, + model: "gpt-5.5", + modelProvider: "openai", + preserveNativeModel: true, + conversationSourceTransferComplete: true, + }); + + await expect( + startCodexConversationThread({ + sessionFile, + workspaceDir: tempDir, + model: "gpt-5.4", + }), + ).rejects.toThrow("Refusing to replace supervised Codex thread"); + expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled(); + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: "thread-supervised", + connectionScope: "supervision", + }); + }); + it("rejects binding when configured exec auto mode may need unrouted human approvals", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const requests: Array<{ method: string; params: Record }> = []; diff --git a/extensions/codex/src/conversation-binding.ts b/extensions/codex/src/conversation-binding.ts index 5e51824b4cbd..efd6d59ddd30 100644 --- a/extensions/codex/src/conversation-binding.ts +++ b/extensions/codex/src/conversation-binding.ts @@ -39,6 +39,7 @@ import { resolveCodexNativeSandboxBlock, } from "./app-server/sandbox-guard.js"; import { + assertCodexBindingMayBeReplaced, isCodexAppServerNativeAuthProfile, normalizeCodexAppServerBindingModelProvider, sessionBindingIdentity, @@ -190,6 +191,7 @@ export async function startCodexConversationThread( config: params.config, }); const existingBinding = await params.bindingStore.read(identity); + assertCodexBindingMayBeReplaced(existingBinding, "starting a conversation-bound Codex thread"); const authProfileId = resolveCodexAppServerAuthProfileIdForAgent({ authProfileId: params.authProfileId ?? existingBinding?.authProfileId, ...agentLookup, @@ -350,6 +352,7 @@ export async function handleCodexConversationBindingResolved( } const identity = conversationBindingIdentity(data); const binding = await options.bindingStore.read(identity); + assertCodexBindingMayBeReplaced(binding, "clearing a denied conversation binding"); if (!data.start?.id || binding?.conversationStartId === data.start.id) { await options.bindingStore.mutate(identity, { kind: "clear" }); } @@ -513,6 +516,8 @@ async function writeThreadBindingFromResponse( resolved: CodexThreadBindingRuntime, response: CodexThreadResumeResponse | CodexThreadStartResponse, ): Promise { + const current = await params.bindingStore.read(params.identity); + assertCodexBindingMayBeReplaced(current, "storing a conversation-bound Codex thread"); const runtimeApprovalPolicy = typeof resolved.runtime.approvalPolicy === "string" ? resolved.runtime.approvalPolicy @@ -550,6 +555,8 @@ async function attachExistingThread( threadId: string; }, ): Promise { + const current = await params.bindingStore.read(params.identity); + assertCodexBindingMayBeReplaced(current, "attaching a conversation-bound Codex thread"); const resolved = await resolveThreadBindingRuntime(params); try { // Codex applies network-proxy permission profiles at thread/start. Resuming @@ -575,6 +582,8 @@ async function attachExistingThread( } async function createThread(params: CodexThreadBindingParams): Promise { + const current = await params.bindingStore.read(params.identity); + assertCodexBindingMayBeReplaced(current, "creating a conversation-bound Codex thread"); const resolved = await resolveThreadBindingRuntime(params); try { const response = await requestNewConversationBindingThread(params, resolved); @@ -600,6 +609,7 @@ async function runBoundTurn(params: { if (!binding?.threadId) { throw new Error("bound Codex conversation has no thread binding"); } + assertCodexBindingMayBeReplaced(binding, "running a conversation-bound Codex thread"); let threadId = binding.threadId; const workspaceDir = binding.cwd || params.data.workspaceDir; const reviewerModelProvider = resolveModelBackedReviewerPolicyProvider({ @@ -877,6 +887,11 @@ async function prepareConversationBinding( const sourceBinding = sourceIdentity ? await params.bindingStore.read(sourceIdentity) : undefined; + assertCodexBindingMayBeReplaced(current, "initializing a conversation-bound Codex thread"); + assertCodexBindingMayBeReplaced( + sourceBinding, + "transferring a session into a conversation-bound Codex thread", + ); const inherited = current ?? sourceBinding; const execPolicy = resolveConversationExecPolicy({ config: params.config, diff --git a/extensions/codex/src/conversation-control.test.ts b/extensions/codex/src/conversation-control.test.ts index cc0ccb0b1491..0e361732845c 100644 --- a/extensions/codex/src/conversation-control.test.ts +++ b/extensions/codex/src/conversation-control.test.ts @@ -3,15 +3,20 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { clearRuntimeAuthProfileStoreSnapshots } from "openclaw/plugin-sdk/agent-runtime"; +import { MODEL_SELECTION_LOCKED_MESSAGE } from "openclaw/plugin-sdk/model-session-runtime"; import { upsertAuthProfile } from "openclaw/plugin-sdk/provider-auth"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + buildCodexSupervisionTestConnectionFingerprint, readCodexAppServerBinding, resetCodexTestBindingStore, testCodexAppServerBindingStore, writeCodexAppServerBinding, } from "./app-server/session-binding.test-helpers.js"; import { + steerCodexConversationTurn, + stopCodexConversationTurn, + trackCodexConversationActiveTurn, setCodexConversationFastMode as setCodexConversationFastModeImpl, setCodexConversationModel as setCodexConversationModelImpl, setCodexConversationPermissions as setCodexConversationPermissionsImpl, @@ -106,6 +111,131 @@ describe("codex conversation controls", () => { expect(binding?.sandbox).toBe("workspace-write"); }); + it("routes supervised stop and steer requests through the native user-home connection", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const target = controlTarget(sessionFile); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-supervised", + connectionScope: "supervision", + supervisionSourceThreadId: "thread-supervised", + appServerRuntimeFingerprint: buildCodexSupervisionTestConnectionFingerprint(), + cwd: tempDir, + model: "gpt-5.5", + modelProvider: "openai", + preserveNativeModel: true, + conversationSourceTransferComplete: true, + }); + const request = vi.fn(async () => ({})); + sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request }); + const stopTracking = trackCodexConversationActiveTurn({ + identity: target.identity, + threadId: "thread-supervised", + turnId: "turn-1", + }); + + try { + await stopCodexConversationTurn({ + ...target, + pluginConfig: { supervision: { enabled: true } }, + }); + await steerCodexConversationTurn({ + ...target, + message: "focus tests", + pluginConfig: { supervision: { enabled: true } }, + }); + } finally { + stopTracking(); + } + + for (const [options] of sharedClientMocks.getSharedCodexAppServerClient.mock.calls) { + expect(options).toMatchObject({ + authProfileId: null, + startOptions: { homeScope: "user" }, + }); + } + expect(request).toHaveBeenNthCalledWith( + 1, + "turn/interrupt", + { threadId: "thread-supervised", turnId: "turn-1" }, + { timeoutMs: 60_000 }, + ); + expect(request).toHaveBeenNthCalledWith( + 2, + "turn/steer", + { + threadId: "thread-supervised", + expectedTurnId: "turn-1", + input: [{ type: "text", text: "focus tests", text_elements: [] }], + }, + { timeoutMs: 60_000 }, + ); + }); + + it("refuses to stop or steer when the active turn no longer matches the private binding", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const target = controlTarget(sessionFile); + await writeCodexAppServerBinding(sessionFile, { + threadId: "replacement-thread", + cwd: tempDir, + }); + const stopTracking = trackCodexConversationActiveTurn({ + identity: target.identity, + threadId: "stale-active-thread", + turnId: "turn-1", + }); + + try { + await expect(stopCodexConversationTurn(target)).resolves.toEqual({ + stopped: false, + message: "The active Codex run no longer matches this session binding.", + }); + await expect( + steerCodexConversationTurn({ ...target, message: "do not send" }), + ).resolves.toEqual({ + steered: false, + message: "The active Codex run no longer matches this session binding.", + }); + await testCodexAppServerBindingStore.mutate(target.identity, { kind: "clear" }); + await expect(stopCodexConversationTurn(target)).resolves.toEqual({ + stopped: false, + message: "The active Codex run no longer matches this session binding.", + }); + await expect( + steerCodexConversationTurn({ ...target, message: "still do not send" }), + ).resolves.toEqual({ + steered: false, + message: "The active Codex run no longer matches this session binding.", + }); + } finally { + stopTracking(); + } + + expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled(); + }); + + it("rejects direct model changes for private supervised bindings", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-supervised", + connectionScope: "supervision", + supervisionSourceThreadId: "thread-supervised", + cwd: tempDir, + model: "gpt-5.5", + modelProvider: "openai", + preserveNativeModel: true, + conversationSourceTransferComplete: true, + }); + + await expect( + setCodexConversationModel({ + sessionFile, + model: "gpt-5.4", + pluginConfig: { supervision: { enabled: true } }, + }), + ).rejects.toThrow(MODEL_SELECTION_LOCKED_MESSAGE); + expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled(); + }); + it("does not persist public OpenAI provider after model changes on native auth bindings", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const agentDir = path.join(tempDir, "agents", "bot-a", "agent"); diff --git a/extensions/codex/src/conversation-control.ts b/extensions/codex/src/conversation-control.ts index 5eabe1e22a47..55a7d537c3da 100644 --- a/extensions/codex/src/conversation-control.ts +++ b/extensions/codex/src/conversation-control.ts @@ -1,4 +1,6 @@ // Codex plugin module implements conversation control behavior. +import { ModelSelectionLockedError } from "openclaw/plugin-sdk/model-session-runtime"; +import { resolveCodexBindingAppServerConnection } from "./app-server/binding-connection.js"; import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js"; import { isCodexFastServiceTier, @@ -75,13 +77,24 @@ export async function stopCodexConversationTurn(params: { if (!active) { return { stopped: false, message: "No active Codex run to stop." }; } - const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig }); const lookup = buildBindingLookup(params); const binding = await params.bindingStore.read(params.identity); + if (binding?.threadId !== active.threadId) { + return { + stopped: false, + message: "The active Codex run no longer matches this session binding.", + }; + } + const connection = resolveCodexBindingAppServerConnection({ + binding, + authProfileId: binding?.authProfileId, + pluginConfig: params.pluginConfig, + }); + const runtime = connection.appServer; const client = await getLeasedSharedCodexAppServerClient({ startOptions: runtime.start, timeoutMs: runtime.requestTimeoutMs, - authProfileId: binding?.authProfileId, + authProfileId: connection.clientAuthProfileId, ...lookup, }); try { @@ -115,13 +128,24 @@ export async function steerCodexConversationTurn(params: { if (!active) { return { steered: false, message: "No active Codex run to steer." }; } - const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig }); const lookup = buildBindingLookup(params); const binding = await params.bindingStore.read(params.identity); + if (binding?.threadId !== active.threadId) { + return { + steered: false, + message: "The active Codex run no longer matches this session binding.", + }; + } + const connection = resolveCodexBindingAppServerConnection({ + binding, + authProfileId: binding?.authProfileId, + pluginConfig: params.pluginConfig, + }); + const runtime = connection.appServer; const client = await getLeasedSharedCodexAppServerClient({ startOptions: runtime.start, timeoutMs: runtime.requestTimeoutMs, - authProfileId: binding?.authProfileId, + authProfileId: connection.clientAuthProfileId, ...lookup, }); try { @@ -154,6 +178,9 @@ export async function setCodexConversationModel(params: { } const lookup = buildBindingLookup(params); const binding = await requireThreadBinding(params.bindingStore, params.identity); + if (binding.connectionScope === "supervision") { + throw new ModelSelectionLockedError(); + } const reviewerPolicyContext = resolveCodexModelBackedReviewerPolicyContext({ provider: "codex", model, diff --git a/extensions/codex/src/native-thread-tool.test.ts b/extensions/codex/src/native-thread-tool.test.ts index 034bade3e9fd..68083e096f8f 100644 --- a/extensions/codex/src/native-thread-tool.test.ts +++ b/extensions/codex/src/native-thread-tool.test.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { MODEL_SELECTION_LOCKED_MESSAGE } from "openclaw/plugin-sdk/model-session-runtime"; import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry"; import { createPluginRuntimeMock } from "openclaw/plugin-sdk/plugin-test-runtime"; import { withTempDir } from "openclaw/plugin-sdk/test-env"; @@ -7,10 +8,12 @@ import { describe, expect, it, vi } from "vitest"; import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js"; import { CODEX_INTERACTIVE_THREAD_SOURCE_KINDS } from "./app-server/protocol.js"; import { + buildCodexSupervisionTestConnectionFingerprint, readCodexAppServerBinding, registerCodexTestSessionIdentity, resetCodexTestBindingStore, testCodexAppServerBindingStore, + type CodexAppServerBindingStore, writeCodexAppServerBinding, } from "./app-server/session-binding.test-helpers.js"; import { createCodexThreadsTool } from "./native-thread-tool.js"; @@ -38,8 +41,15 @@ describe("native Codex thread tool", () => { function createTool(params?: { owner?: boolean; homeScope?: "agent" | "user"; + omitHomeScope?: boolean; + supervision?: boolean; + allowRawTranscripts?: boolean; + allowWriteControls?: boolean; + getPluginConfig?: () => unknown; request?: ReturnType; sessionId?: string | null; + modelSelectionLocked?: boolean; + bindingStore?: CodexAppServerBindingStore; }) { const context: OpenClawPluginToolContext = { config: {}, @@ -53,33 +63,89 @@ describe("native Codex thread tool", () => { const runtime = createPluginRuntimeMock({ agent: { session: { - getSessionEntry: () => ({ sessionId: "session-id", sessionFile, updatedAt: Date.now() }), + getSessionEntry: () => ({ + sessionId: "session-id", + sessionFile, + updatedAt: Date.now(), + modelSelectionLocked: params?.modelSelectionLocked, + }), resolveStorePath: () => path.join(root, "sessions", "sessions.json"), resolveSessionFilePath: () => sessionFile, }, }, }); return createCodexThreadsTool({ - bindingStore: testCodexAppServerBindingStore, + bindingStore: params?.bindingStore ?? testCodexAppServerBindingStore, context, runtime, - getPluginConfig: () => ({ appServer: { homeScope: params?.homeScope ?? "user" } }), + getPluginConfig: + params?.getPluginConfig ?? + (() => ({ + ...(params?.omitHomeScope + ? {} + : { appServer: { homeScope: params?.homeScope ?? "user" } }), + ...(params?.supervision + ? { + supervision: { + enabled: true, + ...(params.allowRawTranscripts ? { allowRawTranscripts: true } : {}), + ...(params.allowWriteControls ? { allowWriteControls: true } : {}), + }, + } + : {}), + })), request: params?.request as never, }); } - it("materializes only for owner turns in shared user-home mode", () => + it("materializes only for owner turns with user-home or supervision access", () => withFixture(() => { expect(createTool()).not.toBeNull(); expect(createTool({ owner: false })).toBeNull(); expect(createTool({ homeScope: "agent" })).toBeNull(); + expect(createTool({ omitHomeScope: true, supervision: true })).not.toBeNull(); + })); + + it("routes a private supervised binding through the supervision connection with native auth", () => + withFixture(async () => { + await writeCodexAppServerBinding("session-id", { + threadId: "supervised-thread", + connectionScope: "supervision", + supervisionSourceThreadId: "source-thread", + appServerRuntimeFingerprint: buildCodexSupervisionTestConnectionFingerprint(), + cwd: "/tmp/project", + model: "gpt-5.5", + modelProvider: "openai", + preserveNativeModel: true, + conversationSourceTransferComplete: true, + historyCoveredThrough: new Date().toISOString(), + }); + const request = vi.fn(async () => ({ data: [] })); + const tool = createTool({ + omitHomeScope: true, + supervision: true, + request, + modelSelectionLocked: true, + }); + + await tool?.execute("call-supervised-list", { action: "list" }); + + expect(request).toHaveBeenCalledWith( + { supervision: { enabled: true } }, + CODEX_CONTROL_METHODS.listThreads, + expect.any(Object), + expect.objectContaining({ + authProfileId: null, + startOptions: expect.objectContaining({ homeScope: "user" }), + }), + ); })); it("lists native threads with bounded deterministic parameters", () => withFixture(async () => { const response = { data: [{ id: "thread-1", status: { type: "idle" } }] }; const request = vi.fn(async () => response); - const tool = createTool({ request }); + const tool = createTool({ request, modelSelectionLocked: true }); const result = await tool?.execute("call-1", { action: "list", @@ -110,13 +176,254 @@ describe("native Codex thread tool", () => { expect(result?.details).toEqual(response); })); - it("forks a native thread and attaches the fork to the OpenClaw session", () => + it("keeps supervised metadata reads available without leaking transcript fields", () => + withFixture(async () => { + const request = vi.fn(async (_config, method: string) => + method === CODEX_CONTROL_METHODS.listThreads + ? { + data: [ + { + id: "thread-1", + name: "Safe title", + preview: "private preview", + status: { type: "idle" }, + turns: [{ id: "turn-1", items: [] }], + }, + ], + } + : { + thread: { + id: "thread-1", + name: "Safe title", + preview: "private preview", + status: { type: "idle" }, + turns: [{ id: "turn-1", items: [] }], + }, + }, + ); + const tool = createTool({ omitHomeScope: true, supervision: true, request }); + + const listed = await tool?.execute("call-safe-list", { action: "list" }); + const read = await tool?.execute("call-safe-read", { + action: "read", + thread_id: "thread-1", + include_turns: false, + }); + + expect(listed?.details).toEqual({ + data: [{ id: "thread-1", name: "Safe title", status: { type: "idle" } }], + }); + expect(read?.details).toEqual({ + thread: { id: "thread-1", name: "Safe title", status: { type: "idle" } }, + }); + expect(request).toHaveBeenCalledTimes(2); + })); + + it("requires explicit supervision permission for raw transcript reads", () => + withFixture(async () => { + const request = vi.fn(); + const tool = createTool({ omitHomeScope: true, supervision: true, request }); + + await expect( + tool?.execute("call-blocked-read", { + action: "read", + thread_id: "thread-1", + include_turns: true, + }), + ).rejects.toThrow("Codex raw transcript reads are disabled"); + expect(request).not.toHaveBeenCalled(); + })); + + it("does not expose transcript search matches when raw transcript access is disabled", () => + withFixture(async () => { + const request = vi.fn(); + const tool = createTool({ omitHomeScope: true, supervision: true, request }); + + await expect( + tool?.execute("call-blocked-search", { + action: "list", + search: "private transcript phrase", + }), + ).rejects.toThrow("search is disabled while raw transcript access is disabled"); + expect(request).not.toHaveBeenCalled(); + })); + + it("preserves supervised transcript fields when raw reads are explicitly enabled", () => + withFixture(async () => { + const response = { + thread: { + id: "thread-1", + preview: "allowed preview", + turns: [{ id: "turn-1", items: [] }], + }, + }; + const request = vi.fn(async () => response); + const tool = createTool({ + omitHomeScope: true, + supervision: true, + allowRawTranscripts: true, + request, + }); + + const result = await tool?.execute("call-allowed-read", { + action: "read", + thread_id: "thread-1", + include_turns: true, + }); + + expect(result?.details).toEqual(response); + })); + + it.each([ + { + action: "fork", + params: { action: "fork", thread_id: "thread-1", attach: false }, + }, + { + action: "rename", + params: { action: "rename", thread_id: "thread-1", name: "Renamed" }, + }, + { + action: "archive", + params: { action: "archive", thread_id: "thread-1", confirm: true }, + }, + { + action: "unarchive", + params: { action: "unarchive", thread_id: "thread-1" }, + }, + ])("blocks supervised $action without write-control permission", ({ params }) => + withFixture(async () => { + const request = vi.fn(); + const tool = createTool({ omitHomeScope: true, supervision: true, request }); + + await expect(tool?.execute("call-blocked-write", params)).rejects.toThrow( + "Codex native thread mutations are disabled", + ); + expect(request).not.toHaveBeenCalled(); + }), + ); + + it("allows supervised native mutations when write controls are explicitly enabled", () => + withFixture(async () => { + const request = vi.fn(async (_config, method: string) => + method === CODEX_CONTROL_METHODS.readThread + ? { thread: { id: "other-thread", status: { type: "idle" } } } + : {}, + ); + const tool = createTool({ + omitHomeScope: true, + supervision: true, + allowWriteControls: true, + request, + }); + + await tool?.execute("call-allowed-write", { + action: "rename", + thread_id: "thread-1", + name: "Renamed", + }); + + expect(request).toHaveBeenCalledWith( + expect.any(Object), + CODEX_CONTROL_METHODS.renameThread, + { threadId: "thread-1", name: "Renamed" }, + expect.any(Object), + ); + })); + + it("redacts detached fork transcripts when raw reads are disabled", () => withFixture(async () => { const request = vi.fn(async () => ({ - thread: { id: "forked-thread", cwd: "/tmp/project", status: { type: "idle" } }, - model: "gpt-5.5", - modelProvider: "openai", + thread: { + id: "forked-thread", + cwd: "/tmp/project", + name: "Safe title", + preview: "private preview", + status: { type: "idle" }, + turns: [{ id: "turn-1", items: [] }], + }, })); + const tool = createTool({ + omitHomeScope: true, + supervision: true, + allowWriteControls: true, + request, + }); + + const result = await tool?.execute("call-redacted-fork", { + action: "fork", + thread_id: "source-thread", + attach: false, + }); + + expect(request).toHaveBeenCalledWith( + expect.any(Object), + CODEX_CONTROL_METHODS.forkThread, + { threadId: "source-thread", threadSource: "user", excludeTurns: true }, + expect.any(Object), + ); + expect(result?.details).toEqual({ + action: "fork", + sourceThreadId: "source-thread", + thread: { + id: "forked-thread", + cwd: "/tmp/project", + name: "Safe title", + status: { type: "idle" }, + }, + attached: false, + }); + })); + + it("redacts unarchive transcripts when raw reads are disabled", () => + withFixture(async () => { + const request = vi.fn(async () => ({ + thread: { + id: "thread-1", + name: "Safe title", + preview: "private preview", + status: { type: "notLoaded" }, + turns: [{ id: "turn-1", items: [] }], + }, + })); + const tool = createTool({ + omitHomeScope: true, + supervision: true, + allowWriteControls: true, + request, + }); + + const result = await tool?.execute("call-redacted-unarchive", { + action: "unarchive", + thread_id: "thread-1", + }); + + expect(request).toHaveBeenCalledWith( + expect.any(Object), + CODEX_CONTROL_METHODS.unarchiveThread, + { threadId: "thread-1" }, + expect.any(Object), + ); + expect(result?.details).toEqual({ + thread: { + id: "thread-1", + name: "Safe title", + status: { type: "notLoaded" }, + }, + }); + })); + + it("forks a native thread and attaches the fork to the OpenClaw session", () => + withFixture(async () => { + const request = vi.fn(async (_config, method: string) => + method === CODEX_CONTROL_METHODS.readThread + ? { thread: { id: "source-thread", status: { type: "notLoaded" } } } + : { + thread: { id: "forked-thread", cwd: "/tmp/project", status: { type: "idle" } }, + model: "gpt-5.5", + modelProvider: "openai", + }, + ); const tool = createTool({ request, sessionId: null }); const result = await tool?.execute("call-2", { @@ -124,10 +431,18 @@ describe("native Codex thread tool", () => { thread_id: "source-thread", }); - expect(request).toHaveBeenCalledWith( + expect(request).toHaveBeenNthCalledWith( + 1, + { appServer: { homeScope: "user" } }, + CODEX_CONTROL_METHODS.readThread, + { threadId: "source-thread", includeTurns: false }, + expect.any(Object), + ); + expect(request).toHaveBeenNthCalledWith( + 2, { appServer: { homeScope: "user" } }, CODEX_CONTROL_METHODS.forkThread, - { threadId: "source-thread", threadSource: "user" }, + { threadId: "source-thread", threadSource: "user", excludeTurns: true }, expect.any(Object), ); await expect( @@ -146,11 +461,75 @@ describe("native Codex thread tool", () => { }); })); + it.each([ + { + name: "a different thread id", + response: { thread: { id: "different-thread", status: { type: "idle" } } }, + error: "returned a different thread than requested", + }, + { + name: "a malformed response", + response: { thread: null }, + error: "returned an invalid thread/read response", + }, + { + name: "an unknown status", + response: { thread: { id: "source-thread", status: { type: "futureStatus" } } }, + error: "unless it is idle or not loaded", + }, + { + name: "a missing status", + response: { thread: { id: "source-thread" } }, + error: "unless it is idle or not loaded", + }, + { + name: "a system-error status", + response: { thread: { id: "source-thread", status: { type: "systemError" } } }, + error: "unless it is idle or not loaded", + }, + { + name: "an active status", + response: { thread: { id: "source-thread", status: { type: "active" } } }, + error: "unless it is idle or not loaded", + }, + ])("refuses to attach a fork of the bound thread after $name", ({ response, error }) => + withFixture(async () => { + await writeCodexAppServerBinding("session-id", { + threadId: "source-thread", + cwd: "/tmp/project", + }); + const request = vi.fn(async () => response); + const tool = createTool({ request }); + + await expect( + tool?.execute("call-unsafe-fork", { + action: "fork", + thread_id: "source-thread", + }), + ).rejects.toThrow(error); + expect(request).toHaveBeenCalledOnce(); + expect(request).toHaveBeenCalledWith( + expect.anything(), + CODEX_CONTROL_METHODS.readThread, + { threadId: "source-thread", includeTurns: false }, + expect.anything(), + ); + expect(request).not.toHaveBeenCalledWith( + expect.anything(), + CODEX_CONTROL_METHODS.forkThread, + expect.anything(), + expect.anything(), + ); + }), + ); + it("reports a conflict when a fork cannot attach to the current generation", () => withFixture(async () => { - const request = vi.fn(async () => ({ - thread: { id: "forked-thread", cwd: "/tmp/project", status: { type: "idle" } }, - })); + const request = vi.fn(async (_config, method: string) => + method === CODEX_CONTROL_METHODS.readThread + ? { thread: { id: "source-thread", status: { type: "idle" } } } + : { thread: { id: "forked-thread", cwd: "/tmp/project", status: { type: "idle" } } }, + ); const mutate = vi .spyOn(testCodexAppServerBindingStore, "mutate") .mockResolvedValueOnce(false); @@ -166,7 +545,152 @@ describe("native Codex thread tool", () => { } })); - it("refuses to archive the active thread bound to this OpenClaw session", () => + it("does not replace a locked session binding with an attached fork", () => + withFixture(async () => { + await writeCodexAppServerBinding("session-id", { + threadId: "bound-thread", + cwd: "/tmp/project", + }); + const request = vi.fn(async () => ({ + thread: { id: "forked-thread", cwd: "/tmp/project", status: { type: "idle" } }, + })); + const tool = createTool({ request, modelSelectionLocked: true }); + + await expect( + tool?.execute("call-locked-fork", { + action: "fork", + thread_id: "source-thread", + }), + ).rejects.toThrow(MODEL_SELECTION_LOCKED_MESSAGE); + + expect(request).not.toHaveBeenCalled(); + await expect(readCodexAppServerBinding("session-id")).resolves.toMatchObject({ + threadId: "bound-thread", + }); + })); + + it("keeps an attached fork off a private supervision connection", () => + withFixture(async () => { + await writeCodexAppServerBinding("session-id", { + threadId: "bound-thread", + connectionScope: "supervision", + supervisionSourceThreadId: "source-thread", + cwd: "/tmp/project", + model: "gpt-5.5", + modelProvider: "openai", + preserveNativeModel: true, + conversationSourceTransferComplete: true, + historyCoveredThrough: new Date().toISOString(), + }); + const request = vi.fn(async () => ({ + thread: { id: "forked-thread", cwd: "/tmp/project", status: { type: "idle" } }, + })); + const tool = createTool({ + omitHomeScope: true, + supervision: true, + allowWriteControls: true, + request, + }); + + await expect( + tool?.execute("call-supervised-fork", { + action: "fork", + thread_id: "source-thread", + }), + ).rejects.toThrow("Supervised Codex forks must stay detached"); + + expect(request).not.toHaveBeenCalled(); + })); + + it("keeps an attached fork off a supervision-only connection without a binding", () => + withFixture(async () => { + const request = vi.fn(); + const tool = createTool({ + omitHomeScope: true, + supervision: true, + allowWriteControls: true, + request, + }); + + await expect( + tool?.execute("call-supervision-only-fork", { + action: "fork", + thread_id: "source-thread", + }), + ).rejects.toThrow("Supervised Codex forks must stay detached"); + expect(request).not.toHaveBeenCalled(); + await expect(readCodexAppServerBinding("session-id")).resolves.toBeUndefined(); + })); + + it("rechecks the live connection config before attaching a fork", () => + withFixture(async () => { + let pluginConfig: unknown = { appServer: { homeScope: "user" } }; + const request = vi.fn(); + const tool = createTool({ request, getPluginConfig: () => pluginConfig }); + pluginConfig = { supervision: { enabled: true, allowWriteControls: true } }; + + await expect( + tool?.execute("call-live-supervision-fork", { + action: "fork", + thread_id: "source-thread", + }), + ).rejects.toThrow("Supervised Codex forks must stay detached"); + expect(request).not.toHaveBeenCalled(); + })); + + it("allows a detached fork through a supervision-only connection", () => + withFixture(async () => { + const response = { + thread: { id: "forked-thread", cwd: "/tmp/project", status: { type: "idle" } }, + }; + const request = vi.fn(async () => response); + const tool = createTool({ + omitHomeScope: true, + supervision: true, + allowWriteControls: true, + request, + }); + + const result = await tool?.execute("call-supervision-detached-fork", { + action: "fork", + thread_id: "source-thread", + attach: false, + }); + + expect(result?.details).toMatchObject({ attached: false }); + await expect(readCodexAppServerBinding("session-id")).resolves.toBeUndefined(); + })); + + it("allows a detached fork without changing a locked session binding", () => + withFixture(async () => { + await writeCodexAppServerBinding("session-id", { + threadId: "bound-thread", + cwd: "/tmp/project", + }); + const request = vi.fn(async () => ({ + thread: { id: "forked-thread", cwd: "/tmp/project", status: { type: "idle" } }, + })); + const tool = createTool({ request, modelSelectionLocked: true }); + + const result = await tool?.execute("call-detached-fork", { + action: "fork", + thread_id: "source-thread", + attach: false, + }); + + expect(request).toHaveBeenCalledWith( + { appServer: { homeScope: "user" } }, + CODEX_CONTROL_METHODS.forkThread, + { threadId: "source-thread", threadSource: "user", excludeTurns: true }, + expect.any(Object), + ); + expect(result?.details).toMatchObject({ attached: false }); + await expect(readCodexAppServerBinding("session-id")).resolves.toMatchObject({ + threadId: "bound-thread", + }); + })); + + it("refuses to archive an active bound thread", () => withFixture(async () => { await writeCodexAppServerBinding("session-id", { threadId: "active-thread", @@ -186,7 +710,7 @@ describe("native Codex thread tool", () => { thread_id: "active-thread", confirm: true, }), - ).rejects.toThrow("cannot archive the Codex thread active in this OpenClaw session"); + ).rejects.toThrow("cannot archive an active Codex thread"); expect(request).not.toHaveBeenCalledWith( expect.anything(), CODEX_CONTROL_METHODS.archiveThread, @@ -205,6 +729,9 @@ describe("native Codex thread tool", () => { if (method === CODEX_CONTROL_METHODS.readThread) { return { thread: { id: "idle-thread", status: { type: "idle" } } }; } + if (method === CODEX_CONTROL_METHODS.listThreads) { + return { data: [] }; + } return {}; }); const tool = createTool({ request }); @@ -215,6 +742,12 @@ describe("native Codex thread tool", () => { confirm: true, }); + expect(request).toHaveBeenCalledWith( + { appServer: { homeScope: "user" } }, + CODEX_CONTROL_METHODS.readThread, + { threadId: "idle-thread", includeTurns: false }, + expect.any(Object), + ); expect(request).toHaveBeenCalledWith( { appServer: { homeScope: "user" } }, CODEX_CONTROL_METHODS.archiveThread, @@ -224,6 +757,293 @@ describe("native Codex thread tool", () => { await expect(readCodexAppServerBinding("session-id")).resolves.toBeUndefined(); })); + it.each([ + { + name: "a mismatched read response", + response: { thread: { id: "different-thread", status: { type: "idle" } } }, + error: "returned a different thread than requested", + }, + { + name: "a missing status", + response: { thread: { id: "thread-1" } }, + error: "cannot verify that the Codex thread is idle", + }, + { + name: "a system-error status", + response: { thread: { id: "thread-1", status: { type: "systemError" } } }, + error: "cannot verify that the Codex thread is idle", + }, + ])("refuses to archive after $name", ({ response, error }) => + withFixture(async () => { + await writeCodexAppServerBinding("session-id", { + threadId: "thread-1", + cwd: "/tmp/project", + }); + const request = vi.fn(async () => response); + const tool = createTool({ request }); + + await expect( + tool?.execute("call-unsafe-archive", { + action: "archive", + thread_id: "thread-1", + confirm: true, + }), + ).rejects.toThrow(error); + expect(request).toHaveBeenCalledOnce(); + expect(request).toHaveBeenCalledWith( + expect.anything(), + CODEX_CONTROL_METHODS.readThread, + { threadId: "thread-1", includeTurns: false }, + expect.anything(), + ); + }), + ); + + it("does not archive and clear the thread bound to a locked session", () => + withFixture(async () => { + await writeCodexAppServerBinding("session-id", { + threadId: "bound-thread", + cwd: "/tmp/project", + }); + const request = vi.fn(async () => ({})); + const tool = createTool({ request, modelSelectionLocked: true }); + + await expect( + tool?.execute("call-locked-archive", { + action: "archive", + thread_id: "bound-thread", + confirm: true, + }), + ).rejects.toThrow(MODEL_SELECTION_LOCKED_MESSAGE); + + expect(request).not.toHaveBeenCalled(); + await expect(readCodexAppServerBinding("session-id")).resolves.toMatchObject({ + threadId: "bound-thread", + }); + })); + + it("rechecks a binding attached while archive waits for its ownership fence", () => + withFixture(async () => { + let fenced = false; + const bindingStore: CodexAppServerBindingStore = { + ...testCodexAppServerBindingStore, + withThreadArchiveFence: async (run) => { + if (!fenced) { + fenced = true; + await writeCodexAppServerBinding("session-id", { + threadId: "newly-bound-thread", + cwd: "/tmp/project", + }); + } + return await testCodexAppServerBindingStore.withThreadArchiveFence(run); + }, + }; + const request = vi.fn(async () => ({})); + const tool = createTool({ bindingStore, request, modelSelectionLocked: true }); + + await expect( + tool?.execute("call-raced-locked-archive", { + action: "archive", + thread_id: "newly-bound-thread", + confirm: true, + }), + ).rejects.toThrow(MODEL_SELECTION_LOCKED_MESSAGE); + + expect(request).not.toHaveBeenCalled(); + await expect(readCodexAppServerBinding("session-id")).resolves.toMatchObject({ + threadId: "newly-bound-thread", + }); + })); + + it("does not archive a private supervised binding even if the public lock is unavailable", () => + withFixture(async () => { + await writeCodexAppServerBinding("session-id", { + threadId: "bound-thread", + connectionScope: "supervision", + supervisionSourceThreadId: "source-thread", + cwd: "/tmp/project", + model: "gpt-5.5", + modelProvider: "openai", + preserveNativeModel: true, + conversationSourceTransferComplete: true, + historyCoveredThrough: new Date().toISOString(), + }); + const request = vi.fn(async () => ({})); + const tool = createTool({ + omitHomeScope: true, + supervision: true, + allowWriteControls: true, + request, + }); + + await expect( + tool?.execute("call-supervised-archive", { + action: "archive", + thread_id: "bound-thread", + confirm: true, + }), + ).rejects.toThrow("Refusing to replace supervised Codex thread"); + + expect(request).not.toHaveBeenCalled(); + })); + + it("allows a locked session to archive an unowned unrelated thread", () => + withFixture(async () => { + await writeCodexAppServerBinding("session-id", { + threadId: "bound-thread", + cwd: "/tmp/project", + }); + const request = vi.fn(async (_config, method: string) => + method === CODEX_CONTROL_METHODS.readThread + ? { thread: { id: "other-thread", status: { type: "idle" } } } + : method === CODEX_CONTROL_METHODS.listThreads + ? { data: [] } + : {}, + ); + const tool = createTool({ + request, + modelSelectionLocked: true, + supervision: true, + allowWriteControls: true, + }); + + await tool?.execute("call-other-archive", { + action: "archive", + thread_id: "other-thread", + confirm: true, + }); + + expect(request).toHaveBeenCalledWith( + expect.anything(), + CODEX_CONTROL_METHODS.archiveThread, + { threadId: "other-thread" }, + expect.anything(), + ); + await expect(readCodexAppServerBinding("session-id")).resolves.toMatchObject({ + threadId: "bound-thread", + }); + })); + + it("rejects archive when another OpenClaw session owns the thread", () => + withFixture(async () => { + await writeCodexAppServerBinding("session-id", { + threadId: "current-thread", + cwd: "/tmp/project", + }); + await writeCodexAppServerBinding("other-session", { + threadId: "other-thread", + cwd: "/tmp/project", + }); + const request = vi.fn(async (_config, method: string) => + method === CODEX_CONTROL_METHODS.readThread + ? { thread: { id: "other-thread", status: { type: "idle" } } } + : {}, + ); + const tool = createTool({ request }); + + await expect( + tool?.execute("call-owned-archive", { + action: "archive", + thread_id: "other-thread", + confirm: true, + }), + ).rejects.toThrow("owned by another OpenClaw session"); + + expect(request).toHaveBeenCalledOnce(); + expect(request).not.toHaveBeenCalledWith( + expect.anything(), + CODEX_CONTROL_METHODS.archiveThread, + expect.anything(), + expect.anything(), + ); + await expect(readCodexAppServerBinding("other-session")).resolves.toMatchObject({ + threadId: "other-thread", + }); + })); + + it("rejects archive when a spawned descendant is owned by an OpenClaw session", () => + withFixture(async () => { + await writeCodexAppServerBinding("session-id", { + threadId: "current-thread", + cwd: "/tmp/project", + }); + await writeCodexAppServerBinding("other-session", { + threadId: "owned-descendant", + cwd: "/tmp/project", + }); + const request = vi.fn(async (_config, method: string, requestParams?: unknown) => { + if (method === CODEX_CONTROL_METHODS.readThread) { + return { + thread: { + id: (requestParams as { threadId: string }).threadId, + status: { type: "idle" }, + }, + }; + } + if (method === CODEX_CONTROL_METHODS.listThreads) { + return { data: [{ id: "owned-descendant" }] }; + } + return {}; + }); + const tool = createTool({ request }); + + await expect( + tool?.execute("call-descendant-owned-archive", { + action: "archive", + thread_id: "parent-thread", + confirm: true, + }), + ).rejects.toThrow("spawned descendant is owned by an OpenClaw session"); + + expect(request).toHaveBeenCalledWith( + expect.anything(), + CODEX_CONTROL_METHODS.listThreads, + { + ancestorThreadId: "parent-thread", + archived: false, + limit: 100, + sortKey: "created_at", + sortDirection: "desc", + useStateDbOnly: true, + }, + expect.anything(), + ); + expect(request).not.toHaveBeenCalledWith( + expect.anything(), + CODEX_CONTROL_METHODS.archiveThread, + expect.anything(), + expect.anything(), + ); + })); + + it("fails closed when native descendant enumeration errors", () => + withFixture(async () => { + const request = vi.fn(async (_config, method: string) => { + if (method === CODEX_CONTROL_METHODS.readThread) { + return { thread: { id: "parent-thread", status: { type: "idle" } } }; + } + if (method === CODEX_CONTROL_METHODS.listThreads) { + throw new Error("descendant lookup failed"); + } + return {}; + }); + const tool = createTool({ request }); + + await expect( + tool?.execute("call-descendant-error-archive", { + action: "archive", + thread_id: "parent-thread", + confirm: true, + }), + ).rejects.toThrow("descendant lookup failed"); + expect(request).not.toHaveBeenCalledWith( + expect.anything(), + CODEX_CONTROL_METHODS.archiveThread, + expect.anything(), + expect.anything(), + ); + })); + it.each([ { action: "read" as const, @@ -246,7 +1066,7 @@ describe("native Codex thread tool", () => { ])("routes $action through the typed Codex control method", ({ params, method, requestParams }) => withFixture(async () => { const request = vi.fn(async () => ({ thread: { id: "thread-1" } })); - const tool = createTool({ request }); + const tool = createTool({ request, modelSelectionLocked: true }); await tool?.execute("call-5", params); diff --git a/extensions/codex/src/native-thread-tool.ts b/extensions/codex/src/native-thread-tool.ts index 534f086a5427..ca89a671f054 100644 --- a/extensions/codex/src/native-thread-tool.ts +++ b/extensions/codex/src/native-thread-tool.ts @@ -8,15 +8,29 @@ import { type AnyAgentTool, type PluginRuntime, } from "openclaw/plugin-sdk/core"; +import { + isModelSelectionLocked, + ModelSelectionLockedError, +} from "openclaw/plugin-sdk/model-session-runtime"; import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry"; import { Type } from "typebox"; +import { resolveCodexBindingAppServerConnection } from "./app-server/binding-connection.js"; import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js"; -import { readCodexPluginConfig } from "./app-server/config.js"; -import { CODEX_INTERACTIVE_THREAD_SOURCE_KINDS, isJsonObject } from "./app-server/protocol.js"; import { + readCodexPluginConfig, + resolveCodexSupervisionAppServerRuntimeOptions, +} from "./app-server/config.js"; +import { + CODEX_INTERACTIVE_THREAD_SOURCE_KINDS, + isJsonObject, + type JsonValue, +} from "./app-server/protocol.js"; +import { + assertCodexBindingMayBeReplaced, sessionBindingIdentity, type CodexAppServerBindingStore, } from "./app-server/session-binding.js"; +import { assertCodexArchiveDescendantsUnowned } from "./app-server/thread-archive-guard.js"; import { codexControlRequest, type CodexControlRequestOptions } from "./command-rpc.js"; const ListParamsSchema = Type.Object( @@ -117,7 +131,7 @@ function readLimit(value: unknown): number | undefined { function resolveToolSession( context: OpenClawPluginToolContext, runtime: PluginRuntime, -): { sessionId: string; sessionFile: string } | undefined { +): { sessionId: string; sessionFile: string; modelSelectionLocked: boolean } | undefined { const sessionKey = context.sessionKey?.trim(); if (!sessionKey) { return undefined; @@ -140,6 +154,7 @@ function resolveToolSession( agentId: context.agentId, sessionsDir: path.dirname(storePath), }), + modelSelectionLocked: isModelSelectionLocked(entry), }; } @@ -154,50 +169,140 @@ function readThreadStatusType(value: unknown): string | undefined { return typeof value.thread.status.type === "string" ? value.thread.status.type : undefined; } -/** Builds the native Codex thread tool only for owner runs in shared-user-home mode. */ +function assertThreadMayBeArchived(value: unknown, expectedThreadId: string): void { + if (!isJsonObject(value) || !isJsonObject(value.thread)) { + throw new Error("Codex app-server returned an invalid thread/read response"); + } + if (value.thread.id !== expectedThreadId) { + throw new Error("Codex app-server returned a different thread than requested"); + } + const status = readThreadStatusType(value); + if (status === "active") { + throw new Error("cannot archive an active Codex thread; wait for its turn to finish"); + } + if (status !== "idle" && status !== "notLoaded") { + throw new Error("cannot verify that the Codex thread is idle; refusing to archive"); + } +} + +function assertThreadMayBeForked(value: unknown, expectedThreadId: string): void { + if (!isJsonObject(value) || !isJsonObject(value.thread)) { + throw new Error("Codex app-server returned an invalid thread/read response"); + } + if (value.thread.id !== expectedThreadId) { + throw new Error("Codex app-server returned a different thread than requested"); + } + const status = readThreadStatusType(value); + if (status !== "idle" && status !== "notLoaded") { + throw new Error("cannot fork a Codex thread unless it is idle or not loaded"); + } +} + +function redactNativeThreadTranscriptFields(value: JsonValue): JsonValue { + if (!isJsonObject(value)) { + return value; + } + const redacted = { ...value }; + delete redacted.preview; + delete redacted.turns; + return redacted; +} + +function redactNativeThreadResponse(value: unknown): unknown { + if (!isJsonObject(value)) { + return value; + } + const redacted = { ...value }; + if (Array.isArray(redacted.data)) { + redacted.data = redacted.data.map(redactNativeThreadTranscriptFields); + } + if (isJsonObject(redacted.thread)) { + redacted.thread = redactNativeThreadTranscriptFields(redacted.thread); + } + return redacted; +} + +/** Builds the native Codex thread tool only for owner runs with native-home access. */ export function createCodexThreadsTool(options: CodexThreadsToolOptions): AnyAgentTool | null { if (options.context.senderIsOwner !== true) { return null; } - if (readCodexPluginConfig(options.getPluginConfig()).appServer?.homeScope !== "user") { + const configured = readCodexPluginConfig(options.getPluginConfig()); + if (configured.appServer?.homeScope !== "user" && configured.supervision?.enabled !== true) { return null; } const request = options.request ?? codexControlRequest; - const requestOptions = (): CodexControlRequestOptions => ({ + const runtimeConfig = () => + options.context.getRuntimeConfig?.() ?? options.context.runtimeConfig ?? options.context.config; + const baseRequestOptions = (): CodexControlRequestOptions => ({ agentDir: options.context.agentDir, - config: - options.context.getRuntimeConfig?.() ?? - options.context.runtimeConfig ?? - options.context.config, + config: runtimeConfig(), sessionId: options.context.sessionId, sessionKey: options.context.sessionKey, }); const currentSession = () => resolveToolSession(options.context, options.runtime); - const currentIdentity = (sessionId: string) => { - return sessionBindingIdentity({ + const currentIdentity = (sessionId: string) => + sessionBindingIdentity({ sessionId, sessionKey: options.context.sessionKey, agentId: options.context.agentId, - config: requestOptions().config, + config: runtimeConfig(), }); - }; const currentBinding = async (session: ReturnType) => session ? await options.bindingStore.read(currentIdentity(session.sessionId)) : undefined; + const requestOptions = async (pluginConfig: unknown): Promise => { + const plugin = readCodexPluginConfig(pluginConfig); + const session = currentSession(); + const binding = await currentBinding(session); + if (binding?.connectionScope === "supervision") { + const connection = resolveCodexBindingAppServerConnection({ binding, pluginConfig }); + return { + ...baseRequestOptions(), + startOptions: connection.appServer.start, + authProfileId: connection.clientAuthProfileId, + }; + } + if (plugin.appServer?.homeScope === "user") { + const connection = resolveCodexBindingAppServerConnection({ binding, pluginConfig }); + return { + ...baseRequestOptions(), + startOptions: connection.appServer.start, + authProfileId: null, + }; + } + if (plugin.supervision?.enabled !== true) { + throw new Error("Codex native thread access is disabled for this run."); + } + return { + ...baseRequestOptions(), + startOptions: resolveCodexSupervisionAppServerRuntimeOptions({ pluginConfig }).start, + authProfileId: null, + }; + }; return { name: "codex_threads", label: "Codex Threads", description: - "List, read, fork, rename, archive, or restore native Codex threads. Fork to continue a thread safely across Codex clients; do not resume the same thread from two clients.", + "List and inspect native Codex threads. When supervision is enabled, raw transcript reads and every mutation require their matching supervision policy option.", parameters: CodexThreadsParamsSchema, async execute(_toolCallId, rawParams) { const params = asRecord(rawParams); const action = readStringParam(params, "action", { required: true, label: "action" }); const pluginConfig = options.getPluginConfig(); + const plugin = readCodexPluginConfig(pluginConfig); + const supervision = plugin.supervision; + const mayReadRawTranscripts = + supervision?.enabled !== true || supervision.allowRawTranscripts === true; if (action === "list") { const cursor = readStringParam(params, "cursor"); const searchTerm = readStringParam(params, "search"); + if (searchTerm && !mayReadRawTranscripts) { + throw new Error( + "Codex native thread search is disabled while raw transcript access is disabled.", + ); + } const response = await request( pluginConfig, CODEX_CONTROL_METHODS.listThreads, @@ -211,20 +316,33 @@ export function createCodexThreadsTool(options: CodexThreadsToolOptions): AnyAge ...(cursor ? { cursor } : {}), ...(searchTerm ? { searchTerm } : {}), }, - requestOptions(), + await requestOptions(pluginConfig), ); - return jsonResult(response); + return jsonResult(mayReadRawTranscripts ? response : redactNativeThreadResponse(response)); } const threadId = readThreadId(params); if (action === "read") { + const includeTurns = readBoolean(params.include_turns); + if (includeTurns && !mayReadRawTranscripts) { + throw new Error( + "Codex raw transcript reads are disabled for this codex plugin supervision config.", + ); + } const response = await request( pluginConfig, CODEX_CONTROL_METHODS.readThread, - { threadId, includeTurns: readBoolean(params.include_turns) }, - requestOptions(), + { threadId, includeTurns }, + await requestOptions(pluginConfig), + ); + return jsonResult(mayReadRawTranscripts ? response : redactNativeThreadResponse(response)); + } + const isMutation = + action === "fork" || action === "rename" || action === "archive" || action === "unarchive"; + if (isMutation && supervision?.enabled === true && supervision.allowWriteControls !== true) { + throw new Error( + "Codex native thread mutations are disabled for this codex plugin supervision config.", ); - return jsonResult(response); } if (action === "rename") { const name = readStringParam(params, "name", { required: true, label: "name" }); @@ -232,7 +350,7 @@ export function createCodexThreadsTool(options: CodexThreadsToolOptions): AnyAge pluginConfig, CODEX_CONTROL_METHODS.renameThread, { threadId, name }, - requestOptions(), + await requestOptions(pluginConfig), ); return jsonResult({ action, threadId, name }); } @@ -241,9 +359,9 @@ export function createCodexThreadsTool(options: CodexThreadsToolOptions): AnyAge pluginConfig, CODEX_CONTROL_METHODS.unarchiveThread, { threadId }, - requestOptions(), + await requestOptions(pluginConfig), ); - return jsonResult(response); + return jsonResult(mayReadRawTranscripts ? response : redactNativeThreadResponse(response)); } const session = currentSession(); @@ -252,29 +370,67 @@ export function createCodexThreadsTool(options: CodexThreadsToolOptions): AnyAge if (params.confirm !== true) { throw new Error("confirm=true is required to archive a native Codex thread"); } - if (binding?.threadId === threadId) { + if (!session) { + throw new Error("cannot safely archive a native Codex thread without a session identity"); + } + const identity = currentIdentity(session.sessionId); + await options.bindingStore.withThreadArchiveFence(async () => { + const archivedBinding = await currentBinding(session); + if (archivedBinding?.threadId === threadId) { + // Clearing the binding detaches the harness-owned Codex thread. The session lock keeps + // both that thread and App Server-selected model routing fixed. + if (session.modelSelectionLocked) { + throw new ModelSelectionLockedError(); + } + assertCodexBindingMayBeReplaced(archivedBinding, "archiving its bound native thread"); + } + // App Server status is process-local, and archive is a separate RPC. This read blocks + // known active/invalid state; `confirm` owns the remaining cross-client race. const current = await request( pluginConfig, CODEX_CONTROL_METHODS.readThread, { threadId, includeTurns: false }, - requestOptions(), + await requestOptions(pluginConfig), ); - if (readThreadStatusType(current) === "active") { - throw new Error("cannot archive the Codex thread active in this OpenClaw session"); + assertThreadMayBeArchived(current, threadId); + if (await options.bindingStore.hasOtherThreadOwner(threadId, identity)) { + throw new Error( + "cannot archive a native Codex thread owned by another OpenClaw session", + ); } - } - await request( - pluginConfig, - CODEX_CONTROL_METHODS.archiveThread, - { threadId }, - requestOptions(), - ); - if (session && binding?.threadId === threadId) { - await options.bindingStore.mutate(currentIdentity(session.sessionId), { - kind: "clear", + await assertCodexArchiveDescendantsUnowned({ + bindingStore: options.bindingStore, threadId, + listPage: async (listParams) => + await request( + pluginConfig, + CODEX_CONTROL_METHODS.listThreads, + listParams, + await requestOptions(pluginConfig), + ), + assertDescendantIdle: async (descendantThreadId) => { + const descendant = await request( + pluginConfig, + CODEX_CONTROL_METHODS.readThread, + { threadId: descendantThreadId, includeTurns: false }, + await requestOptions(pluginConfig), + ); + assertThreadMayBeArchived(descendant, descendantThreadId); + }, }); - } + await request( + pluginConfig, + CODEX_CONTROL_METHODS.archiveThread, + { threadId }, + await requestOptions(pluginConfig), + ); + if (archivedBinding?.threadId === threadId) { + await options.bindingStore.mutate(identity, { + kind: "clear", + threadId, + }); + } + }); return jsonResult({ action, threadId }); } if (action !== "fork") { @@ -285,22 +441,32 @@ export function createCodexThreadsTool(options: CodexThreadsToolOptions): AnyAge if (attach && !session) { throw new Error("cannot attach a Codex fork without an active OpenClaw session"); } - if (attach && binding?.threadId === threadId) { + if (attach && session?.modelSelectionLocked) { + throw new ModelSelectionLockedError(); + } + const usesSupervisionConnection = + binding?.connectionScope === "supervision" || + (plugin.appServer?.homeScope !== "user" && supervision?.enabled === true); + if (attach && usesSupervisionConnection) { + throw new Error("Supervised Codex forks must stay detached; set attach=false."); + } + if (attach) { + assertCodexBindingMayBeReplaced(binding, "attaching a different native fork"); + // Codex can snapshot an active source as interrupted. Attached forks require a known-safe + // local status and the exact source identity before App Server may create that snapshot. const current = await request( pluginConfig, CODEX_CONTROL_METHODS.readThread, { threadId, includeTurns: false }, - requestOptions(), + await requestOptions(pluginConfig), ); - if (readThreadStatusType(current) === "active") { - throw new Error("cannot replace the Codex thread active in this OpenClaw turn"); - } + assertThreadMayBeForked(current, threadId); } const response = await request( pluginConfig, CODEX_CONTROL_METHODS.forkThread, - { threadId, threadSource: "user" }, - requestOptions(), + { threadId, threadSource: "user", excludeTurns: true }, + await requestOptions(pluginConfig), ); if (!isJsonObject(response) || !isJsonObject(response.thread)) { throw new Error("Codex app-server returned an invalid thread/fork response"); @@ -331,12 +497,13 @@ export function createCodexThreadsTool(options: CodexThreadsToolOptions): AnyAge throw new Error("Codex session binding changed before the fork could be attached"); } } - return jsonResult({ + const result = { action, sourceThreadId: threadId, thread: response.thread, attached: attach, - }); + }; + return jsonResult(mayReadRawTranscripts ? result : redactNativeThreadResponse(result)); }, }; } diff --git a/extensions/codex/src/session-catalog-types.ts b/extensions/codex/src/session-catalog-types.ts new file mode 100644 index 000000000000..c154fd1cf33e --- /dev/null +++ b/extensions/codex/src/session-catalog-types.ts @@ -0,0 +1,60 @@ +/** Read-only metadata for one Codex app-server thread. */ +export type CodexSessionCatalogSession = { + threadId: string; + sessionId?: string; + name?: string | null; + cwd?: string; + status: string; + activeFlags?: string[]; + createdAt?: number; + updatedAt?: number; + recencyAt?: number | null; + source?: string; + modelProvider?: string; + cliVersion?: string; + gitBranch?: string; + /** Existing locked OpenClaw chat already mapped to this native source thread. */ + openClawSessionKey?: string; + archived: boolean; +}; + +export type CodexSessionCatalogPage = { + sessions: CodexSessionCatalogSession[]; + nextCursor?: string; + backwardsCursor?: string; +}; + +export type CodexSessionCatalogPageParams = { + cursor?: string; + limit?: number; + searchTerm?: string; + cwd?: string; +}; + +export type CodexSessionCatalogError = { + code: string; + message: string; +}; + +export type CodexSessionCatalogHost = { + hostId: string; + label: string; + kind: "gateway" | "node"; + connected: boolean; + nodeId?: string; + sessions: CodexSessionCatalogSession[]; + nextCursor?: string; + backwardsCursor?: string; + error?: CodexSessionCatalogError; +}; + +export type CodexSessionCatalogResult = { + hosts: CodexSessionCatalogHost[]; +}; + +export type CodexSessionCatalogParams = { + search?: string; + limitPerHost?: number; + hostIds?: string[]; + cursors?: Record; +}; diff --git a/extensions/codex/src/session-catalog.test.ts b/extensions/codex/src/session-catalog.test.ts new file mode 100644 index 000000000000..00ae21d0fd7c --- /dev/null +++ b/extensions/codex/src/session-catalog.test.ts @@ -0,0 +1,2659 @@ +// Codex supervision tests cover passive listing and safe local session takeover. +/* oxlint-disable typescript/unbound-method -- assertions inspect vi.fn-backed object methods, not unbound class methods. */ +import { createHash } from "node:crypto"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { GatewayRequestHandlerOptions } from "openclaw/plugin-sdk/gateway-runtime"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { CodexThread } from "./app-server/protocol.js"; +import { sessionBindingIdentity } from "./app-server/session-binding.js"; +import { + createCodexTestBindingStore, + type CodexAppServerBindingStore, + type CodexAppServerThreadBinding, +} from "./app-server/session-binding.test-helpers.js"; +import { + archiveLocalCodexSession, + CODEX_APP_SERVER_THREADS_LIST_COMMAND, + CODEX_LOCAL_SESSION_HOST_ID, + CODEX_SESSION_ARCHIVE_METHOD, + CODEX_SESSION_CATALOG_METHOD, + CODEX_SESSION_CONTINUE_METHOD, + continueLocalCodexSession, + createCodexSessionCatalogControl, + createCodexSessionCatalogNodeHostCommands, + listCodexSessionCatalog, + registerCodexSessionCatalogGateway, + type CodexSessionCatalogControl, +} from "./session-catalog.js"; + +const commandRpcMocks = vi.hoisted(() => ({ + codexControlRequest: vi.fn(), +})); +const pinnedConnectionMocks = vi.hoisted(() => ({ + client: { connectionId: "pinned-catalog-client" }, + getClient: vi.fn(), + releaseClient: vi.fn(), + request: vi.fn(), +})); +const transcriptMirrorMocks = vi.hoisted(() => ({ + importCodexThreadHistoryToTranscript: vi.fn(async () => ({ + importedMessages: 0, + omittedMessages: 0, + })), +})); + +vi.mock("./command-rpc.js", () => ({ + codexControlRequest: commandRpcMocks.codexControlRequest, +})); +vi.mock("./app-server/request.js", () => ({ + requestCodexAppServerClientJson: pinnedConnectionMocks.request, +})); +vi.mock("./app-server/shared-client.js", () => ({ + getLeasedSharedCodexAppServerClient: pinnedConnectionMocks.getClient, + releaseLeasedSharedCodexAppServerClient: pinnedConnectionMocks.releaseClient, +})); +vi.mock("./app-server/transcript-mirror.js", () => ({ + importCodexThreadHistoryToTranscript: transcriptMirrorMocks.importCodexThreadHistoryToTranscript, +})); + +type CreateSessionEntryParams = Parameters< + PluginRuntime["agent"]["session"]["createSessionEntry"] +>[0]; +type CreateSessionEntryResult = Awaited< + ReturnType +>; +type PatchSessionEntryParams = Parameters< + PluginRuntime["agent"]["session"]["patchSessionEntry"] +>[0]; +type SessionEntrySummary = ReturnType< + PluginRuntime["agent"]["session"]["listSessionEntries"] +>[number]; +type GatewayHandler = (options: GatewayRequestHandlerOptions) => void | Promise; + +const config = {} as OpenClawConfig; + +function idleThread(overrides: Partial = {}): CodexThread { + return { + id: "thread-1", + name: "Continue native task", + cwd: "/workspace/project", + status: { type: "idle" }, + ...overrides, + }; +} + +function createControl(overrides: Partial = {}) { + const withPinnedConnection = vi.fn( + async (run: (value: CodexSessionCatalogControl) => Promise) => await run(control), + ) as unknown as CodexSessionCatalogControl["withPinnedConnection"]; + const control: CodexSessionCatalogControl = { + assertEnabled: vi.fn(), + connectionFingerprint: "catalog-connection", + withPinnedConnection, + listPage: vi.fn(async () => ({ sessions: [] })), + listDescendantPage: vi.fn(async () => ({ data: [] })), + readThread: vi.fn(async (threadId: string) => idleThread({ id: threadId })), + archiveThread: vi.fn(async () => undefined), + ...overrides, + }; + return control; +} + +function createEligibleControl(overrides: Partial = {}) { + return createControl({ + listPage: vi.fn(async () => ({ + sessions: [{ threadId: "thread-1", status: "idle", source: "cli", archived: false as const }], + })), + ...overrides, + }); +} + +function adoptedEntry(params: { sourceThreadId: string; sessionId?: string }) { + return { + sessionId: params.sessionId ?? "openclaw-session-existing", + updatedAt: 1, + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { + supervision: { + sourceThreadId: params.sourceThreadId, + modelLocked: true, + }, + }, + }, + } as CreateSessionEntryResult["entry"]; +} + +function supervisionSessionInputKey(threadId: string): string { + return `harness:codex:supervision:${createHash("sha256").update(threadId).digest("hex")}`; +} + +function supervisionSessionKey(threadId: string): string { + return `agent:main:${supervisionSessionInputKey(threadId)}`; +} + +async function seedSupervisionBinding(params: { + bindingStore: CodexAppServerBindingStore; + sessionId: string; + sessionKey: string; + sourceThreadId: string; + pending?: boolean; +}): Promise { + const binding: CodexAppServerThreadBinding = { + threadId: params.pending ? params.sourceThreadId : `${params.sourceThreadId}-branch`, + connectionScope: "supervision", + supervisionSourceThreadId: params.sourceThreadId, + cwd: "/workspace/project", + conversationSourceTransferComplete: true, + preserveNativeModel: true, + historyCoveredThrough: new Date().toISOString(), + ...(params.pending + ? { + pendingSupervisionBranch: { + sourceThreadId: params.sourceThreadId, + connectionFingerprint: "catalog-connection", + }, + } + : { model: "gpt-5.4", modelProvider: "openai" }), + }; + const stored = await params.bindingStore.mutate( + sessionBindingIdentity({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + config, + }), + { kind: "set", if: { kind: "absent" }, binding }, + ); + if (!stored) { + throw new Error(`failed to seed supervision binding for ${params.sourceThreadId}`); + } +} + +function interruptedAdoptionEntry(params: { sourceThreadId: string; sessionId: string }) { + return { + sessionId: params.sessionId, + sessionFile: `/tmp/${params.sessionId}.jsonl`, + updatedAt: 1, + initializationPending: true, + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { + supervision: { + sourceThreadId: params.sourceThreadId, + initializing: true, + modelLocked: true, + }, + }, + }, + } as CreateSessionEntryResult["entry"]; +} + +function createRuntime( + params: { + entries?: SessionEntrySummary[]; + nodes?: Array>; + invoke?: PluginRuntime["nodes"]["invoke"]; + failAfterCreate?: () => boolean; + } = {}, +) { + const entries = params.entries ?? []; + let sessionSequence = 0; + const createSessionEntry = vi.fn(async (createParams: CreateSessionEntryParams) => { + const inputKey = createParams.key ?? "created"; + const agentId = createParams.agentId ?? "main"; + const key = inputKey.startsWith("agent:") ? inputKey : `agent:${agentId}:${inputKey}`; + const existing = entries.find((candidate) => candidate.sessionKey === key); + let summary: SessionEntrySummary; + if (existing) { + const entry = existing.entry; + const initialMatches = + createParams.recoverMatchingInitialEntry === true && + entry.initializationPending === true && + entry.agentHarnessId === createParams.initialEntry.agentHarnessId && + entry.modelSelectionLocked === createParams.initialEntry.modelSelectionLocked && + JSON.stringify(entry.pluginExtensions) === + JSON.stringify(createParams.initialEntry.pluginExtensions); + if (!initialMatches) { + throw new Error(`Session "${key}" does not match its trusted recovery state.`); + } + summary = existing; + } else { + sessionSequence += 1; + const sessionId = `openclaw-session-${sessionSequence}`; + const entry = { + sessionId, + sessionFile: `/tmp/${sessionId}.jsonl`, + ...createParams.initialEntry, + ...(createParams.afterCreate ? { initializationPending: true as const } : {}), + } as CreateSessionEntryResult["entry"]; + summary = { sessionKey: key, entry }; + entries.push(summary); + } + const entry = summary.entry; + const sessionId = entry.sessionId; + const result = { key, agentId, sessionId, entry }; + try { + const finalPatch = await createParams.afterCreate?.(result); + if (existing && !finalPatch) { + throw new Error("session creation recovery requires a final patch"); + } + if (finalPatch) { + entry.pluginExtensions = structuredClone(finalPatch.pluginExtensions); + } + delete entry.initializationPending; + if (params.failAfterCreate?.() === true) { + throw new Error("session finalization failed after binding commit"); + } + return result; + } catch (error) { + const index = entries.indexOf(summary); + if (index >= 0) { + entries.splice(index, 1); + } + throw error; + } + }); + const patchSessionEntry = vi.fn(async (patchParams: PatchSessionEntryParams) => { + const summary = entries.find((candidate) => candidate.sessionKey === patchParams.sessionKey); + if (!summary) { + return null; + } + const current = structuredClone(summary.entry); + const patch = await patchParams.update(current, { existingEntry: structuredClone(current) }); + if (!patch) { + return summary.entry; + } + const next = { ...summary.entry, ...patch }; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) { + Reflect.deleteProperty(next, key); + } + } + summary.entry = next; + return next; + }); + const runtime = { + nodes: { + list: vi.fn(async () => ({ nodes: params.nodes ?? [] })), + invoke: params.invoke ?? vi.fn(async () => ({})), + }, + agent: { + session: { + createSessionEntry, + listSessionEntries: vi.fn((listParams) => { + const agentPrefix = listParams?.agentId ? `agent:${listParams.agentId}:` : undefined; + return entries.filter( + ({ sessionKey }) => !agentPrefix || sessionKey.startsWith(agentPrefix), + ); + }), + patchSessionEntry, + }, + }, + } as unknown as PluginRuntime; + return { runtime, entries, createSessionEntry, patchSessionEntry }; +} + +function archiveTestSession(params: { + control: CodexSessionCatalogControl; + bindingStore?: CodexAppServerBindingStore; + runtime?: PluginRuntime; + threadId?: string; +}) { + return archiveLocalCodexSession({ + bindingStore: params.bindingStore ?? createCodexTestBindingStore(), + config, + control: params.control, + runtime: params.runtime ?? createRuntime().runtime, + threadId: params.threadId ?? "thread-1", + }); +} + +function createGatewayApi(runtime: PluginRuntime) { + const handlers = new Map(); + const registerControlUiDescriptor = vi.fn(); + const registerGatewayMethod = vi.fn( + (method: string, handler: GatewayHandler, _options?: { scope?: string }) => { + handlers.set(method, handler); + }, + ); + const api = { + runtime, + session: { controls: { registerControlUiDescriptor } }, + registerGatewayMethod, + } as unknown as OpenClawPluginApi; + return { api, handlers, registerControlUiDescriptor, registerGatewayMethod }; +} + +async function callGatewayHandler( + handler: GatewayHandler | undefined, + params: unknown, + respond = vi.fn(), +) { + if (!handler) { + throw new Error("Gateway handler was not registered"); + } + await handler({ params, respond } as unknown as GatewayRequestHandlerOptions); + return respond; +} + +beforeEach(() => { + commandRpcMocks.codexControlRequest.mockReset(); + pinnedConnectionMocks.getClient.mockReset(); + pinnedConnectionMocks.getClient.mockResolvedValue(pinnedConnectionMocks.client); + pinnedConnectionMocks.releaseClient.mockReset(); + pinnedConnectionMocks.request.mockReset(); + transcriptMirrorMocks.importCodexThreadHistoryToTranscript.mockReset(); + transcriptMirrorMocks.importCodexThreadHistoryToTranscript.mockResolvedValue({ + importedMessages: 0, + omittedMessages: 0, + }); +}); + +describe("Codex supervision catalog", () => { + it("lists non-archived interactive threads without probing transcript previews", async () => { + const pluginConfig = { supervision: { enabled: true } }; + commandRpcMocks.codexControlRequest.mockResolvedValue({ + data: [ + { + id: "thread-title", + name: "Match title", + preview: "private transcript preview", + cwd: "/workspace/one", + status: { type: "idle" }, + source: "vscode", + }, + { + id: "thread-preview", + name: "Other title", + preview: "Match appears only in private preview text", + status: { type: "idle" }, + source: "cli", + }, + ], + }); + const control = createCodexSessionCatalogControl({ + getPluginConfig: () => pluginConfig, + getRuntimeConfig: () => config, + }); + + await expect( + control.listPage({ limit: 25, searchTerm: "mAtCh", cwd: " /workspace/one " }), + ).resolves.toEqual({ + sessions: [ + { + threadId: "thread-title", + name: "Match title", + cwd: "/workspace/one", + status: "idle", + source: "vscode", + archived: false, + }, + ], + }); + expect(commandRpcMocks.codexControlRequest).toHaveBeenCalledOnce(); + expect(commandRpcMocks.codexControlRequest).toHaveBeenCalledWith( + pluginConfig, + "thread/list", + { + archived: false, + limit: 25, + modelProviders: [], + sortKey: "recency_at", + sortDirection: "desc", + sourceKinds: ["cli", "vscode"], + cwd: "/workspace/one", + }, + { + config, + startOptions: expect.objectContaining({ transport: "stdio", homeScope: "user" }), + timeoutMs: expect.any(Number), + }, + ); + expect(JSON.stringify(await control.listPage({ searchTerm: "mAtCh" }))).not.toContain( + "private", + ); + expect(commandRpcMocks.codexControlRequest.mock.calls.map((call) => call[1])).not.toContain( + "thread/resume", + ); + }); + + it("scans bounded native pages for complete title-only search results", async () => { + const pluginConfig = { supervision: { enabled: true } }; + commandRpcMocks.codexControlRequest.mockImplementation( + async (_pluginConfig: unknown, _method: string, request: Record) => { + if (request.cursor === "page-3") { + return { + data: [idleThread({ id: "match-2", name: "MATCH two", source: "vscode" })], + }; + } + if (request.cursor === "page-2") { + return { + data: [ + idleThread({ id: "match-1", name: "Match one", source: "cli" }), + idleThread({ id: "private-2", name: "Other", source: "cli" }), + ], + nextCursor: "page-3", + }; + } + return { + data: [idleThread({ id: "private-1", name: "Unrelated", source: "cli" })], + nextCursor: "page-2", + backwardsCursor: "previous-page", + }; + }, + ); + const control = createCodexSessionCatalogControl({ + getPluginConfig: () => pluginConfig, + getRuntimeConfig: () => config, + }); + + await expect(control.listPage({ limit: 2, searchTerm: "match" })).resolves.toEqual({ + sessions: [ + expect.objectContaining({ threadId: "match-1", name: "Match one" }), + expect.objectContaining({ threadId: "match-2", name: "MATCH two" }), + ], + backwardsCursor: "previous-page", + }); + expect(commandRpcMocks.codexControlRequest.mock.calls.map((call) => call[2])).toEqual([ + expect.objectContaining({ limit: 2 }), + expect.objectContaining({ cursor: "page-2", limit: 2 }), + expect.objectContaining({ cursor: "page-3", limit: 1 }), + ]); + for (const call of commandRpcMocks.codexControlRequest.mock.calls) { + expect(call[2]).not.toHaveProperty("searchTerm"); + } + }); + + it("returns the last native cursor when a title search reaches its scan cap", async () => { + let page = 0; + commandRpcMocks.codexControlRequest.mockImplementation(async () => { + page += 1; + return { + data: [idleThread({ id: `private-${page}`, name: "Unrelated", source: "cli" })], + nextCursor: `page-${page}`, + }; + }); + const control = createCodexSessionCatalogControl({ + getPluginConfig: () => ({ supervision: { enabled: true } }), + getRuntimeConfig: () => config, + }); + + await expect(control.listPage({ limit: 10, searchTerm: "match" })).resolves.toEqual({ + sessions: [], + nextCursor: "page-20", + }); + expect(commandRpcMocks.codexControlRequest).toHaveBeenCalledTimes(20); + }); + + it("fails closed when title-search cursors cycle", async () => { + commandRpcMocks.codexControlRequest.mockResolvedValue({ + data: [idleThread({ name: "Unrelated", source: "cli" })], + nextCursor: "cycle", + }); + const control = createCodexSessionCatalogControl({ + getPluginConfig: () => ({ supervision: { enabled: true } }), + getRuntimeConfig: () => config, + }); + + await expect(control.listPage({ limit: 10, searchTerm: "match" })).rejects.toThrow( + "repeated search cursor", + ); + expect(commandRpcMocks.codexControlRequest).toHaveBeenCalledTimes(2); + }); + + it("shares one timeout budget across title-search pages", async () => { + let elapsedMs = 0; + let page = 0; + commandRpcMocks.codexControlRequest.mockImplementation(async () => { + page += 1; + elapsedMs += 600; + return { + data: [idleThread({ id: `other-${page}`, name: "Unrelated", source: "cli" })], + nextCursor: `page-${page}`, + }; + }); + const control = createCodexSessionCatalogControl({ + getPluginConfig: () => ({ + appServer: { requestTimeoutMs: 1_000 }, + supervision: { enabled: true }, + }), + getRuntimeConfig: () => config, + now: () => elapsedMs, + }); + + await expect(control.listPage({ limit: 10, searchTerm: "match" })).rejects.toThrow( + "catalog listing timed out", + ); + expect(commandRpcMocks.codexControlRequest).toHaveBeenCalledTimes(2); + expect( + commandRpcMocks.codexControlRequest.mock.calls.map((call) => call[3]?.timeoutMs), + ).toEqual([1_000, 400]); + }); + + it("keeps a title-search cursor chain on its initial App Server configuration", async () => { + let pluginConfig = { + appServer: { command: "codex-initial" }, + supervision: { enabled: true }, + }; + commandRpcMocks.codexControlRequest + .mockImplementationOnce(async () => { + pluginConfig = { + appServer: { command: "codex-reconfigured" }, + supervision: { enabled: true }, + }; + return { + data: [idleThread({ id: "other", name: "Unrelated", source: "cli" })], + nextCursor: "page-2", + }; + }) + .mockResolvedValueOnce({ + data: [idleThread({ id: "match", name: "Match", source: "cli" })], + }); + const control = createCodexSessionCatalogControl({ + getPluginConfig: () => pluginConfig, + getRuntimeConfig: () => config, + }); + + await expect(control.listPage({ limit: 1, searchTerm: "match" })).resolves.toMatchObject({ + sessions: [expect.objectContaining({ threadId: "match" })], + }); + expect( + commandRpcMocks.codexControlRequest.mock.calls.map( + (call) => (call[3]?.startOptions as { command?: string } | undefined)?.command, + ), + ).toEqual(["codex-initial", "codex-initial"]); + }); + + it("rejects an oversized direct catalog cursor before native I/O", async () => { + const control = createCodexSessionCatalogControl({ + getPluginConfig: () => ({ supervision: { enabled: true } }), + getRuntimeConfig: () => config, + }); + + await expect(control.listPage({ cursor: "x".repeat(4097) })).rejects.toThrow( + "invalid Codex session catalog request cursor", + ); + expect(commandRpcMocks.codexControlRequest).not.toHaveBeenCalled(); + }); + + it.each(["nextCursor", "backwardsCursor"] as const)( + "rejects an oversized native %s", + async (cursorField) => { + commandRpcMocks.codexControlRequest.mockResolvedValue({ + data: [], + [cursorField]: "x".repeat(4097), + }); + const control = createCodexSessionCatalogControl({ + getPluginConfig: () => ({ supervision: { enabled: true } }), + getRuntimeConfig: () => config, + }); + + await expect(control.listPage({})).rejects.toThrow( + `invalid Codex session catalog ${cursorField === "nextCursor" ? "next" : "backwards"} response cursor`, + ); + }, + ); + + it("omits noninteractive sources when App Server ignores the requested source kinds", async () => { + commandRpcMocks.codexControlRequest.mockResolvedValue({ + data: [ + idleThread({ id: "cli", source: "cli" }), + idleThread({ id: "vscode", source: "vscode" }), + idleThread({ id: "exec", source: "exec" }), + idleThread({ id: "app-server", source: "appServer" }), + idleThread({ id: "subagent", source: { subAgent: "review" } }), + idleThread({ id: "custom", source: { custom: "integration" } }), + idleThread({ id: "unknown", source: "unknown" }), + idleThread({ id: "missing" }), + ], + }); + const control = createCodexSessionCatalogControl({ + getPluginConfig: () => ({ supervision: { enabled: true } }), + getRuntimeConfig: () => config, + }); + + const page = await control.listPage({}); + + expect(page.sessions.map((session) => session.threadId)).toEqual(["cli", "vscode"]); + expect(page.sessions.map((session) => session.source)).toEqual(["cli", "vscode"]); + }); + + it("keeps takeover forking out of the passive catalog control", async () => { + const pluginConfig = { supervision: { enabled: true } }; + const response = { thread: idleThread({ id: "thread-source" }) }; + commandRpcMocks.codexControlRequest.mockResolvedValue(response); + const control = createCodexSessionCatalogControl({ + getPluginConfig: () => pluginConfig, + getRuntimeConfig: () => config, + }); + + await expect(control.readThread("thread-source", true)).resolves.toBe(response.thread); + expect(commandRpcMocks.codexControlRequest).toHaveBeenCalledWith( + pluginConfig, + "thread/read", + { threadId: "thread-source", includeTurns: true }, + { + config, + startOptions: expect.objectContaining({ transport: "stdio", homeScope: "user" }), + }, + ); + expect(commandRpcMocks.codexControlRequest.mock.calls.map((call) => call[1])).not.toContain( + "thread/fork", + ); + }); + + it("revokes catalog reads and writes when supervision is disabled live", async () => { + let pluginConfig: unknown = { supervision: { enabled: true } }; + commandRpcMocks.codexControlRequest.mockResolvedValue({ data: [] }); + const control = createCodexSessionCatalogControl({ + getPluginConfig: () => pluginConfig, + getRuntimeConfig: () => config, + }); + + await expect(control.listPage({})).resolves.toEqual({ sessions: [] }); + pluginConfig = { supervision: { enabled: false } }; + + expect(() => control.assertEnabled()).toThrow("Codex session supervision is disabled"); + await expect(control.listPage({})).rejects.toThrow("Codex session supervision is disabled"); + await expect(control.readThread("thread-1")).rejects.toThrow( + "Codex session supervision is disabled", + ); + await expect(control.archiveThread("thread-1")).rejects.toThrow( + "Codex session supervision is disabled", + ); + expect(commandRpcMocks.codexControlRequest).toHaveBeenCalledOnce(); + }); + + it("revokes an in-flight catalog before requesting another native page", async () => { + let pluginConfig: unknown = { supervision: { enabled: true } }; + commandRpcMocks.codexControlRequest.mockImplementation(async () => { + pluginConfig = { supervision: { enabled: false } }; + return { + data: [idleThread({ id: "other", name: "Unrelated", source: "cli" })], + nextCursor: "page-2", + }; + }); + const control = createCodexSessionCatalogControl({ + getPluginConfig: () => pluginConfig, + getRuntimeConfig: () => config, + }); + + await expect(control.listPage({ limit: 10, searchTerm: "match" })).rejects.toThrow( + "Codex session supervision is disabled", + ); + expect(commandRpcMocks.codexControlRequest).toHaveBeenCalledOnce(); + }); + + it("keeps paired-node catalogs non-archived and metadata-only", async () => { + const control = createControl({ + listPage: vi.fn(async () => ({ + sessions: [{ threadId: "local", status: "idle", archived: false }], + })), + }); + const invoke = vi.fn(async () => ({ + payloadJSON: JSON.stringify({ + sessions: [ + { + threadId: "remote", + name: "Remote task", + status: "idle", + archived: false, + preview: "must be stripped", + turns: [{ private: true }], + }, + ], + }), + })); + const { runtime } = createRuntime({ + nodes: [ + { + nodeId: "devbox", + displayName: "Dev Box", + connected: true, + commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND], + }, + ], + invoke, + }); + + const result = await listCodexSessionCatalog({ + bindingStore: createCodexTestBindingStore(), + config, + runtime, + control, + }); + + expect(result.hosts).toEqual([ + { + hostId: CODEX_LOCAL_SESSION_HOST_ID, + label: "Local Codex", + kind: "gateway", + connected: true, + sessions: [{ threadId: "local", status: "idle", archived: false }], + }, + { + hostId: "node:devbox", + label: "Dev Box", + kind: "node", + nodeId: "devbox", + connected: true, + sessions: [{ threadId: "remote", name: "Remote task", status: "idle", archived: false }], + }, + ]); + expect(control.listPage).toHaveBeenCalledWith( + expect.not.objectContaining({ archived: expect.anything() }), + ); + expect(invoke).toHaveBeenCalledWith( + expect.objectContaining({ + nodeId: "devbox", + command: CODEX_APP_SERVER_THREADS_LIST_COMMAND, + params: expect.not.objectContaining({ archived: expect.anything() }), + }), + ); + expect(JSON.stringify(result)).not.toContain("private"); + + const [nodeCommand] = createCodexSessionCatalogNodeHostCommands(control); + expect(nodeCommand).toMatchObject({ + command: CODEX_APP_SERVER_THREADS_LIST_COMMAND, + dangerous: false, + }); + if (!nodeCommand) { + throw new Error("Codex session catalog node command was not registered"); + } + await expect(nodeCommand.handle(JSON.stringify({ archived: true }))).rejects.toThrow( + "unknown Codex session catalog parameter: archived", + ); + + invoke.mockResolvedValueOnce({ + payloadJSON: JSON.stringify({ + sessions: [{ threadId: "archived", status: "idle", archived: true }], + }), + }); + await expect( + listCodexSessionCatalog({ + bindingStore: createCodexTestBindingStore(), + config, + runtime, + control, + query: { hostIds: ["node:devbox"] }, + }), + ).resolves.toEqual({ + hosts: [ + expect.objectContaining({ + hostId: "node:devbox", + sessions: [], + error: { code: "NODE_INVOKE_FAILED", message: expect.any(String) }, + }), + ], + }); + }); + + it("isolates federated host failures while preserving selected healthy hosts", async () => { + const control = createControl({ + listPage: vi.fn(async () => ({ + sessions: [ + { + threadId: "local-match", + name: "Match locally", + status: "idle", + source: "cli", + archived: false, + }, + ], + nextCursor: "local-page-3", + })), + }); + const invoke = vi.fn(async ({ nodeId }) => { + if (nodeId === "broken") { + throw new Error("node transport failed"); + } + return { + payloadJSON: JSON.stringify({ + sessions: [ + { + threadId: "remote-match", + name: "Remote match", + status: "idle", + source: "vscode", + archived: false, + }, + { + threadId: "preview-only", + name: "Other title", + preview: "match appears only in private transcript text", + status: "idle", + source: "cli", + archived: false, + }, + ], + nextCursor: "healthy-page-3", + }), + }; + }); + const { runtime } = createRuntime({ + nodes: [ + { + nodeId: "healthy", + displayName: "A healthy node", + connected: true, + commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND], + }, + { + nodeId: "broken", + displayName: "B broken node", + connected: true, + commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND], + }, + { + nodeId: "offline", + displayName: "C offline node", + connected: false, + commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND], + }, + { + nodeId: "unsupported", + connected: true, + commands: ["other.command"], + }, + { + nodeId: "unselected", + connected: true, + commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND], + }, + ], + invoke, + }); + + const result = await listCodexSessionCatalog({ + bindingStore: createCodexTestBindingStore(), + config, + runtime, + control, + query: { + search: "match", + limitPerHost: 7, + hostIds: [ + CODEX_LOCAL_SESSION_HOST_ID, + "node:healthy", + "node:broken", + "node:offline", + "node:unsupported", + ], + cursors: { + [CODEX_LOCAL_SESSION_HOST_ID]: "local-page-2", + "node:healthy": "healthy-page-2", + "node:broken": "broken-page-2", + }, + }, + }); + + expect(control.listPage).toHaveBeenCalledWith({ + cursor: "local-page-2", + limit: 7, + searchTerm: "match", + }); + expect(invoke).toHaveBeenCalledTimes(2); + expect(invoke).toHaveBeenCalledWith( + expect.objectContaining({ + nodeId: "healthy", + params: { cursor: "healthy-page-2", limit: 7, searchTerm: "match" }, + }), + ); + expect(invoke).toHaveBeenCalledWith( + expect.objectContaining({ + nodeId: "broken", + params: { cursor: "broken-page-2", limit: 7, searchTerm: "match" }, + }), + ); + expect(result.hosts).toEqual([ + expect.objectContaining({ + hostId: CODEX_LOCAL_SESSION_HOST_ID, + connected: true, + nextCursor: "local-page-3", + sessions: [expect.objectContaining({ threadId: "local-match" })], + }), + expect.objectContaining({ + hostId: "node:healthy", + connected: true, + nextCursor: "healthy-page-3", + sessions: [expect.objectContaining({ threadId: "remote-match" })], + }), + expect.objectContaining({ + hostId: "node:broken", + connected: true, + sessions: [], + error: { code: "NODE_INVOKE_FAILED", message: expect.any(String) }, + }), + expect.objectContaining({ + hostId: "node:offline", + connected: false, + sessions: [], + error: { code: "NODE_OFFLINE", message: "Paired node is offline" }, + }), + ]); + expect(JSON.stringify(result)).not.toContain("private transcript"); + }); + + it("caps aggregate host results at the public wire bound", async () => { + const control = createControl(); + const invoke = vi.fn(async () => ({ + payloadJSON: JSON.stringify({ sessions: [] }), + })); + const { runtime } = createRuntime({ + nodes: Array.from({ length: 120 }, (_, index) => ({ + nodeId: `node-${index.toString().padStart(3, "0")}`, + connected: true, + commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND], + })), + invoke, + }); + + const result = await listCodexSessionCatalog({ + bindingStore: createCodexTestBindingStore(), + config, + runtime, + control, + }); + + expect(result.hosts).toHaveLength(100); + expect(result.hosts[0]?.hostId).toBe(CODEX_LOCAL_SESSION_HOST_ID); + expect(invoke).toHaveBeenCalledTimes(99); + }); + + it.each(["nextCursor", "backwardsCursor"] as const)( + "rejects an oversized Gateway-local %s before the public response", + async (cursorField) => { + const control = createControl({ + listPage: vi.fn(async () => ({ + sessions: [], + [cursorField]: "x".repeat(4097), + })), + }); + const { runtime } = createRuntime(); + + const result = await listCodexSessionCatalog({ + bindingStore: createCodexTestBindingStore(), + config, + runtime, + control, + query: { hostIds: [CODEX_LOCAL_SESSION_HOST_ID] }, + }); + + expect(result).toEqual({ + hosts: [ + { + hostId: CODEX_LOCAL_SESSION_HOST_ID, + label: "Local Codex", + kind: "gateway", + connected: false, + sessions: [], + error: { + code: "APP_SERVER_UNAVAILABLE", + message: "Codex app-server is unavailable on this host", + }, + }, + ], + }); + expect(JSON.stringify(result)).not.toContain("x".repeat(4097)); + }, + ); + + it.each([ + { + name: "out-of-range page limit", + params: { limitPerHost: 101 }, + error: "limitPerHost must be an integer from 1 to 100", + }, + { + name: "non-string host id", + params: { hostIds: [42] }, + error: "Codex session catalog host ids must be strings", + }, + { + name: "invalid host id", + params: { hostIds: ["remote:devbox"] }, + error: "invalid Codex session catalog host id: remote:devbox", + }, + { + name: "oversized search", + params: { search: "x".repeat(501) }, + error: "search must be at most 500 characters", + }, + { + name: "oversized cursor", + params: { cursors: { [CODEX_LOCAL_SESSION_HOST_ID]: "x".repeat(4097) } }, + error: `invalid cursor for Codex session catalog host: ${CODEX_LOCAL_SESSION_HOST_ID}`, + }, + { + name: "too many hosts", + params: { + hostIds: Array.from({ length: 101 }, (_, index) => `node:host-${index}`), + }, + error: "hostIds must contain at most 100 host ids", + }, + { + name: "too many cursors", + params: { + cursors: Object.fromEntries( + Array.from({ length: 101 }, (_, index) => [`node:host-${index}`, `cursor-${index}`]), + ), + }, + error: "cursors may contain at most 100 hosts", + }, + ])("rejects $name at the Gateway boundary", async ({ params: requestParams, error }) => { + const control = createControl(); + const { runtime } = createRuntime(); + const { api, handlers } = createGatewayApi(runtime); + registerCodexSessionCatalogGateway({ + api, + bindingStore: createCodexTestBindingStore(), + control, + getRuntimeConfig: () => config, + }); + + const respond = await callGatewayHandler( + handlers.get(CODEX_SESSION_CATALOG_METHOD), + requestParams, + ); + + expect(respond).toHaveBeenCalledWith( + false, + { error }, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + expect(control.assertEnabled).not.toHaveBeenCalled(); + expect(control.listPage).not.toHaveBeenCalled(); + expect(runtime.nodes.list).not.toHaveBeenCalled(); + }); + + it("enriches only the local source row with its adopted OpenClaw session", async () => { + const control = createControl({ + listPage: vi.fn(async () => ({ + sessions: [{ threadId: "source-thread", status: "active", archived: false }], + })), + }); + const invoke = vi.fn(async () => ({ + payloadJSON: JSON.stringify({ + sessions: [{ threadId: "source-thread", status: "idle", archived: false }], + }), + })); + const { runtime, entries } = createRuntime({ + nodes: [ + { + nodeId: "devbox", + connected: true, + commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND], + }, + ], + invoke, + }); + const sessionKey = supervisionSessionKey("source-thread"); + const sessionId = "openclaw-session-existing"; + entries.push({ + sessionKey, + entry: adoptedEntry({ + sourceThreadId: "source-thread", + sessionId, + }), + }); + const bindingStore = createCodexTestBindingStore(); + await seedSupervisionBinding({ + bindingStore, + sessionId, + sessionKey, + sourceThreadId: "source-thread", + }); + + const result = await listCodexSessionCatalog({ + bindingStore, + config, + runtime, + control, + }); + + expect(result.hosts[0]?.sessions[0]).toMatchObject({ + threadId: "source-thread", + openClawSessionKey: sessionKey, + }); + expect(result.hosts[1]?.sessions[0]).toEqual({ + threadId: "source-thread", + status: "idle", + archived: false, + }); + }); + + it("does not expose an adopted marker while generic initialization remains pending", async () => { + const control = createControl({ + listPage: vi.fn(async () => ({ + sessions: [{ threadId: "source-thread", status: "idle", archived: false }], + })), + }); + const { runtime, entries } = createRuntime(); + const sessionKey = supervisionSessionKey("source-thread"); + const sessionId = "openclaw-session-pending"; + entries.push({ + sessionKey, + entry: { + ...adoptedEntry({ sourceThreadId: "source-thread", sessionId }), + initializationPending: true, + }, + }); + const bindingStore = createCodexTestBindingStore(); + await seedSupervisionBinding({ + bindingStore, + sessionId, + sessionKey, + sourceThreadId: "source-thread", + pending: true, + }); + + const result = await listCodexSessionCatalog({ bindingStore, config, runtime, control }); + + expect(result.hosts[0]?.sessions[0]).not.toHaveProperty("openClawSessionKey"); + }); + + it("ignores a public marker retarget and trusts the private source binding", async () => { + const control = createControl({ + listPage: vi.fn(async () => ({ + sessions: [ + { threadId: "source-thread", status: "idle", archived: false }, + { threadId: "forged-thread", status: "idle", archived: false }, + ], + })), + }); + const sessionKey = supervisionSessionKey("source-thread"); + const sessionId = "openclaw-session-forged-marker"; + const { runtime, entries } = createRuntime({ + entries: [ + { + sessionKey, + entry: adoptedEntry({ sourceThreadId: "forged-thread", sessionId }), + }, + ], + }); + const bindingStore = createCodexTestBindingStore(); + await seedSupervisionBinding({ + bindingStore, + sessionId, + sessionKey, + sourceThreadId: "source-thread", + }); + + const result = await listCodexSessionCatalog({ bindingStore, config, runtime, control }); + + expect(result.hosts[0]?.sessions).toEqual([ + { + threadId: "source-thread", + status: "idle", + archived: false, + openClawSessionKey: sessionKey, + }, + { threadId: "forged-thread", status: "idle", archived: false }, + ]); + expect(entries[0]?.entry.pluginExtensions).toMatchObject({ + codex: { supervision: { sourceThreadId: "forged-thread" } }, + }); + }); + + it("requires both the Codex harness owner and model lock before adopting a session", async () => { + const sources = [ + { + threadId: "unlocked-thread", + sessionId: "openclaw-session-unlocked", + entryPatch: { modelSelectionLocked: false }, + }, + { + threadId: "wrong-harness-thread", + sessionId: "openclaw-session-wrong-harness", + entryPatch: { agentHarnessId: "other-harness" }, + }, + ]; + const entries = sources.map(({ threadId, sessionId, entryPatch }) => ({ + sessionKey: supervisionSessionKey(threadId), + entry: { ...adoptedEntry({ sourceThreadId: threadId, sessionId }), ...entryPatch }, + })); + const { runtime, createSessionEntry } = createRuntime({ entries }); + const { api } = createGatewayApi(runtime); + const bindingStore = createCodexTestBindingStore(); + for (const source of sources) { + await seedSupervisionBinding({ + bindingStore, + sessionId: source.sessionId, + sessionKey: supervisionSessionKey(source.threadId), + sourceThreadId: source.threadId, + }); + } + const control = createControl({ + listPage: vi.fn(async () => ({ + sessions: sources.map(({ threadId }) => ({ + threadId, + status: "idle", + source: "cli", + archived: false as const, + })), + })), + readThread: vi.fn(async (threadId: string) => idleThread({ id: threadId, source: "cli" })), + }); + + const result = await listCodexSessionCatalog({ bindingStore, config, runtime, control }); + + expect(result.hosts[0]?.sessions).toHaveLength(sources.length); + for (const source of sources) { + const session = result.hosts[0]?.sessions.find( + (candidate) => candidate.threadId === source.threadId, + ); + expect(session).toBeDefined(); + expect(session).not.toHaveProperty("openClawSessionKey"); + } + for (const source of sources) { + await expect( + continueLocalCodexSession({ + api, + bindingStore, + config, + control, + threadId: source.threadId, + }), + ).rejects.toThrow("does not match its trusted recovery state"); + } + expect(createSessionEntry).toHaveBeenCalledTimes(2); + expect(transcriptMirrorMocks.importCodexThreadHistoryToTranscript).not.toHaveBeenCalled(); + }); +}); + +describe("Codex supervision actions", () => { + it("creates one pending locked branch and reuses its source mapping", async () => { + const sourceThread = idleThread({ + modelProvider: "openai", + turns: [ + { id: "turn-completed", status: "completed", items: [] }, + { id: "turn-failed", status: "failed", items: [] }, + { id: "turn-active", status: "inProgress", items: [] }, + ], + }); + const { runtime, createSessionEntry } = createRuntime(); + const { api } = createGatewayApi(runtime); + const bindingStore = createCodexTestBindingStore(); + const control = createEligibleControl({ readThread: vi.fn(async () => sourceThread) }); + + const first = await continueLocalCodexSession({ + api, + bindingStore, + config, + control, + threadId: "thread-1", + }); + const second = await continueLocalCodexSession({ + api, + bindingStore, + config, + control, + threadId: "thread-1", + }); + + expect(first).toEqual({ + sessionKey: expect.stringMatching(/^agent:main:harness:codex:supervision:[0-9a-f]{64}$/), + disposition: "forked", + }); + expect(second).toEqual({ sessionKey: first.sessionKey, disposition: "existing" }); + expect(control.withPinnedConnection).toHaveBeenCalledTimes(2); + expect(createSessionEntry).toHaveBeenCalledOnce(); + expect(createSessionEntry).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: config, + key: supervisionSessionInputKey("thread-1"), + label: "Continue native task", + spawnedCwd: "/workspace/project", + afterCreate: expect.any(Function), + initialEntry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { + supervision: { + sourceThreadId: "thread-1", + initializing: true, + modelLocked: true, + }, + }, + }, + }, + }), + ); + expect(transcriptMirrorMocks.importCodexThreadHistoryToTranscript).toHaveBeenCalledWith({ + thread: sourceThread, + sessionFile: "/tmp/openclaw-session-1.jsonl", + sessionId: "openclaw-session-1", + sessionKey: first.sessionKey, + agentId: "main", + cwd: "/workspace/project", + throughTurnId: "turn-failed", + modelProvider: "openai", + config, + }); + await expect( + bindingStore.read( + sessionBindingIdentity({ + sessionId: "openclaw-session-1", + sessionKey: first.sessionKey, + config, + }), + ), + ).resolves.toMatchObject({ + threadId: "thread-1", + connectionScope: "supervision", + supervisionSourceThreadId: "thread-1", + cwd: "/workspace/project", + historyCoveredThrough: expect.any(String), + conversationSourceTransferComplete: true, + preserveNativeModel: true, + pendingSupervisionBranch: { + sourceThreadId: "thread-1", + connectionFingerprint: "catalog-connection", + lastTurnId: "turn-failed", + }, + }); + expect(control.readThread).toHaveBeenCalledTimes(2); + expect(control.readThread).toHaveBeenNthCalledWith(1, "thread-1", true); + expect(control.readThread).toHaveBeenNthCalledWith(2, "thread-1", false); + expect(commandRpcMocks.codexControlRequest).not.toHaveBeenCalled(); + }); + + it("keeps adopted sessions discoverable when the configured default agent changes", async () => { + const originalConfig = { + agents: { list: [{ id: "alpha", default: true }, { id: "beta" }] }, + } as OpenClawConfig; + const changedConfig = { + agents: { list: [{ id: "alpha" }, { id: "beta", default: true }] }, + } as OpenClawConfig; + const { runtime, createSessionEntry } = createRuntime(); + const { api } = createGatewayApi(runtime); + const bindingStore = createCodexTestBindingStore(); + const control = createEligibleControl(); + + const created = await continueLocalCodexSession({ + api, + bindingStore, + config: originalConfig, + control, + threadId: "thread-1", + }); + const reopened = await continueLocalCodexSession({ + api, + bindingStore, + config: changedConfig, + control, + threadId: "thread-1", + }); + const catalog = await listCodexSessionCatalog({ + bindingStore, + config: changedConfig, + runtime, + control, + }); + + expect(created.sessionKey).toMatch(/^agent:alpha:harness:codex:supervision:/); + expect(reopened).toEqual({ sessionKey: created.sessionKey, disposition: "existing" }); + expect(createSessionEntry).toHaveBeenCalledOnce(); + expect(createSessionEntry).toHaveBeenCalledWith(expect.objectContaining({ agentId: "alpha" })); + expect(catalog.hosts[0]?.sessions[0]).toMatchObject({ + threadId: "thread-1", + openClawSessionKey: created.sessionKey, + }); + }); + + it("does not expose or reuse an initializing session while history import is paused", async () => { + let releaseImport: (() => void) | undefined; + const importGate = new Promise((resolve) => { + releaseImport = resolve; + }); + transcriptMirrorMocks.importCodexThreadHistoryToTranscript.mockImplementationOnce(async () => { + await importGate; + return { importedMessages: 0, omittedMessages: 0 }; + }); + const { runtime, entries, createSessionEntry } = createRuntime(); + const { api } = createGatewayApi(runtime); + const bindingStore = createCodexTestBindingStore(); + const control = createEligibleControl(); + + const firstContinue = continueLocalCodexSession({ + api, + bindingStore, + config, + control, + threadId: "thread-1", + }); + await vi.waitFor(() => { + expect(transcriptMirrorMocks.importCodexThreadHistoryToTranscript).toHaveBeenCalledOnce(); + }); + + const duringImport = await listCodexSessionCatalog({ bindingStore, config, runtime, control }); + expect(duringImport.hosts[0]?.sessions[0]).not.toHaveProperty("openClawSessionKey"); + expect(entries[0]?.entry.initializationPending).toBe(true); + let secondSettled = false; + const secondContinue = continueLocalCodexSession({ + api, + bindingStore, + config, + control, + threadId: "thread-1", + }).then((result) => { + secondSettled = true; + return result; + }); + await Promise.resolve(); + await Promise.resolve(); + expect(secondSettled).toBe(false); + expect(createSessionEntry).toHaveBeenCalledOnce(); + + releaseImport?.(); + const [first, second] = await Promise.all([firstContinue, secondContinue]); + expect(second).toEqual(first); + expect(entries[0]?.entry.pluginExtensions).toEqual({ + codex: { + supervision: { sourceThreadId: "thread-1", modelLocked: true }, + }, + }); + expect(entries[0]?.entry.initializationPending).toBeUndefined(); + }); + + it("does not archive a source with an interrupted initializing branch", async () => { + const sessionKey = supervisionSessionKey("thread-1"); + const { runtime } = createRuntime({ + entries: [ + { + sessionKey, + entry: interruptedAdoptionEntry({ + sourceThreadId: "thread-1", + sessionId: "openclaw-session-initializing", + }), + }, + ], + }); + const control = createEligibleControl(); + + await expect(archiveTestSession({ control, runtime })).rejects.toThrow( + "cannot be archived while its OpenClaw branch is initializing", + ); + expect(control.readThread).not.toHaveBeenCalled(); + expect(control.archiveThread).not.toHaveBeenCalled(); + }); + + it("does not archive a source until its supervised branch materializes", async () => { + const { runtime } = createRuntime(); + const { api } = createGatewayApi(runtime); + const bindingStore = createCodexTestBindingStore(); + const control = createEligibleControl(); + const continued = await continueLocalCodexSession({ + api, + bindingStore, + config, + control, + threadId: "thread-1", + }); + + await expect(archiveTestSession({ control, bindingStore, runtime })).rejects.toThrow( + "cannot be archived until its OpenClaw branch starts", + ); + expect(control.archiveThread).not.toHaveBeenCalled(); + + const identity = sessionBindingIdentity({ + sessionId: "openclaw-session-1", + sessionKey: continued.sessionKey, + config, + }); + const pending = (await bindingStore.read(identity))?.pendingSupervisionBranch; + if (!pending) { + throw new Error("expected a pending supervision branch"); + } + await expect( + bindingStore.mutate(identity, { + kind: "commit-pending-supervision-branch", + expected: pending, + threadId: "thread-1-branch", + patch: { model: "gpt-5.4", modelProvider: "openai" }, + }), + ).resolves.toBe(true); + + await expect(archiveTestSession({ control, bindingStore, runtime })).resolves.toEqual({ + archived: true, + }); + expect(control.archiveThread).toHaveBeenCalledOnce(); + expect(control.archiveThread).toHaveBeenCalledWith("thread-1"); + }); + + it("serializes archive behind an in-flight Continue and rejects the pending branch", async () => { + let releaseImport: (() => void) | undefined; + const importGate = new Promise((resolve) => { + releaseImport = resolve; + }); + transcriptMirrorMocks.importCodexThreadHistoryToTranscript.mockImplementationOnce(async () => { + await importGate; + return { importedMessages: 0, omittedMessages: 0 }; + }); + const { runtime } = createRuntime(); + const { api } = createGatewayApi(runtime); + const bindingStore = createCodexTestBindingStore(); + const control = createEligibleControl(); + const continuing = continueLocalCodexSession({ + api, + bindingStore, + config, + control, + threadId: "thread-1", + }); + await vi.waitFor(() => { + expect(transcriptMirrorMocks.importCodexThreadHistoryToTranscript).toHaveBeenCalledOnce(); + }); + + let archiveSettled = false; + const archiving = archiveTestSession({ control, bindingStore, runtime }).then( + (value) => { + archiveSettled = true; + return { ok: true as const, value }; + }, + (error: unknown) => { + archiveSettled = true; + return { ok: false as const, error }; + }, + ); + await Promise.resolve(); + await Promise.resolve(); + expect(archiveSettled).toBe(false); + expect(control.archiveThread).not.toHaveBeenCalled(); + + releaseImport?.(); + await expect(continuing).resolves.toMatchObject({ disposition: "forked" }); + const archiveResult = await archiving; + expect(archiveResult.ok).toBe(false); + if (archiveResult.ok) { + throw new Error("archive unexpectedly succeeded"); + } + expect(archiveResult.error).toBeInstanceOf(Error); + expect((archiveResult.error as Error).message).toContain( + "cannot be archived until its OpenClaw branch starts", + ); + expect(control.archiveThread).not.toHaveBeenCalled(); + }); + + it("recovers the same pending session after a restart before binding commit", async () => { + const sessionKey = supervisionSessionKey("thread-1"); + const sessionId = "openclaw-interrupted-before-binding"; + const crashedRuntime = createRuntime(); + crashedRuntime.entries.push({ + sessionKey, + entry: interruptedAdoptionEntry({ sourceThreadId: "thread-1", sessionId }), + }); + const { runtime, entries, createSessionEntry } = createRuntime({ + entries: crashedRuntime.entries, + }); + const { api } = createGatewayApi(runtime); + const bindingStore = createCodexTestBindingStore(); + + await expect( + continueLocalCodexSession({ + api, + bindingStore, + config, + control: createEligibleControl(), + threadId: "thread-1", + }), + ).resolves.toEqual({ sessionKey, disposition: "forked" }); + + expect(createSessionEntry).toHaveBeenCalledOnce(); + expect(createSessionEntry).toHaveBeenCalledWith( + expect.objectContaining({ + key: supervisionSessionInputKey("thread-1"), + recoverMatchingInitialEntry: true, + }), + ); + expect(entries).toHaveLength(1); + expect(entries[0]?.entry).toMatchObject({ + sessionId, + pluginExtensions: { + codex: { + supervision: { sourceThreadId: "thread-1", modelLocked: true }, + }, + }, + }); + expect(entries[0]?.entry.initializationPending).toBeUndefined(); + expect(transcriptMirrorMocks.importCodexThreadHistoryToTranscript).toHaveBeenCalledWith( + expect.objectContaining({ + sessionFile: `/tmp/${sessionId}.jsonl`, + sessionId, + sessionKey, + }), + ); + await expect( + bindingStore.read(sessionBindingIdentity({ sessionId, sessionKey, config })), + ).resolves.toMatchObject({ + threadId: "thread-1", + connectionScope: "supervision", + supervisionSourceThreadId: "thread-1", + preserveNativeModel: true, + pendingSupervisionBranch: { sourceThreadId: "thread-1" }, + }); + }); + + it("recovers the same pending session after a restart following binding commit", async () => { + const sessionKey = supervisionSessionKey("thread-1"); + const sessionId = "openclaw-interrupted-after-binding"; + const crashedRuntime = createRuntime(); + crashedRuntime.entries.push({ + sessionKey, + entry: interruptedAdoptionEntry({ sourceThreadId: "thread-1", sessionId }), + }); + const { runtime, entries, createSessionEntry } = createRuntime({ + entries: crashedRuntime.entries, + }); + const { api } = createGatewayApi(runtime); + const inner = createCodexTestBindingStore(); + const identity = sessionBindingIdentity({ sessionId, sessionKey, config }); + await inner.mutate(identity, { + kind: "set", + if: { kind: "absent" }, + binding: { + threadId: "thread-1", + connectionScope: "supervision", + supervisionSourceThreadId: "thread-1", + cwd: "/workspace/project", + historyCoveredThrough: new Date().toISOString(), + conversationSourceTransferComplete: true, + preserveNativeModel: true, + pendingSupervisionBranch: { + sourceThreadId: "thread-1", + connectionFingerprint: "catalog-connection", + }, + }, + }); + const mutate = vi.fn(inner.mutate); + const bindingStore: CodexAppServerBindingStore = { ...inner, mutate }; + + await expect( + continueLocalCodexSession({ + api, + bindingStore, + config, + control: createEligibleControl(), + threadId: "thread-1", + }), + ).resolves.toEqual({ sessionKey, disposition: "forked" }); + + expect(createSessionEntry).toHaveBeenCalledOnce(); + expect(entries).toHaveLength(1); + expect(entries[0]?.entry.sessionId).toBe(sessionId); + expect(entries[0]?.entry.initializationPending).toBeUndefined(); + expect(entries[0]?.entry.pluginExtensions).toEqual({ + codex: { + supervision: { sourceThreadId: "thread-1", modelLocked: true }, + }, + }); + expect(mutate).not.toHaveBeenCalled(); + await expect(bindingStore.read(identity)).resolves.toMatchObject({ + threadId: "thread-1", + connectionScope: "supervision", + supervisionSourceThreadId: "thread-1", + preserveNativeModel: true, + pendingSupervisionBranch: { sourceThreadId: "thread-1" }, + }); + }); + + it.each([ + "a different working directory", + "a different terminal turn", + "pending cleanup artifacts", + ] as const)("rejects recovery against %s in a same-thread binding", async (invalidState) => { + const sessionKey = supervisionSessionKey("thread-1"); + const sessionId = "openclaw-interrupted-invalid-binding"; + const crashedRuntime = createRuntime(); + crashedRuntime.entries.push({ + sessionKey, + entry: interruptedAdoptionEntry({ sourceThreadId: "thread-1", sessionId }), + }); + const { runtime, entries } = createRuntime({ entries: crashedRuntime.entries }); + const { api } = createGatewayApi(runtime); + const bindingStore = createCodexTestBindingStore(); + const identity = sessionBindingIdentity({ sessionId, sessionKey, config }); + const binding: CodexAppServerThreadBinding = { + threadId: "thread-1", + connectionScope: "supervision", + supervisionSourceThreadId: "thread-1", + cwd: "/workspace/project", + historyCoveredThrough: new Date().toISOString(), + conversationSourceTransferComplete: true, + preserveNativeModel: true, + pendingSupervisionBranch: { sourceThreadId: "thread-1" }, + }; + if (invalidState === "a different working directory") { + binding.cwd = "/workspace/other"; + } else if (invalidState === "a different terminal turn") { + binding.pendingSupervisionBranch = { + sourceThreadId: "thread-1", + lastTurnId: "turn-other", + }; + } else { + binding.pendingSupervisionBranch = { + sourceThreadId: "thread-1", + cleanupThreadIds: ["thread-orphan"], + }; + } + await bindingStore.mutate(identity, { + kind: "set", + if: { kind: "absent" }, + binding, + }); + + await expect( + continueLocalCodexSession({ + api, + bindingStore, + config, + control: createEligibleControl(), + threadId: "thread-1", + }), + ).rejects.toThrow("OpenClaw session is already bound to Codex thread thread-1"); + expect(entries).toEqual([]); + }); + + it("does not infer a terminal boundary from completedAt without a terminal status", async () => { + const { runtime, createSessionEntry } = createRuntime(); + const { api } = createGatewayApi(runtime); + const bindingStore = createCodexTestBindingStore(); + const control = createEligibleControl({ + readThread: vi.fn(async () => + idleThread({ + status: { type: "notLoaded" }, + turns: [{ id: "turn-unknown", completedAt: 123, items: [] }], + }), + ), + }); + + const result = await continueLocalCodexSession({ + api, + bindingStore, + config, + control, + threadId: "thread-1", + }); + + expect(result.disposition).toBe("forked"); + expect(createSessionEntry).toHaveBeenCalledOnce(); + expect(transcriptMirrorMocks.importCodexThreadHistoryToTranscript).toHaveBeenCalledWith( + expect.objectContaining({ throughTurnId: null, modelProvider: undefined }), + ); + await expect( + bindingStore.read( + sessionBindingIdentity({ + sessionId: "openclaw-session-1", + sessionKey: result.sessionKey, + config, + }), + ), + ).resolves.toMatchObject({ + connectionScope: "supervision", + supervisionSourceThreadId: "thread-1", + pendingSupervisionBranch: { sourceThreadId: "thread-1" }, + }); + const binding = await bindingStore.read( + sessionBindingIdentity({ + sessionId: "openclaw-session-1", + sessionKey: result.sessionKey, + config, + }), + ); + expect(binding?.pendingSupervisionBranch).not.toHaveProperty("lastTurnId"); + }); + + it("restores an archived mapped session without changing its locked generation metadata", async () => { + const { runtime, entries, createSessionEntry, patchSessionEntry } = createRuntime(); + const { api } = createGatewayApi(runtime); + const sessionKey = supervisionSessionKey("thread-1"); + const sessionId = "openclaw-session-archived"; + entries.push({ + sessionKey, + entry: { + ...adoptedEntry({ sourceThreadId: "thread-1", sessionId }), + archivedAt: 123, + updatedAt: 99, + model: "gpt-5.4", + modelProvider: "openai", + }, + }); + const bindingStore = createCodexTestBindingStore(); + await seedSupervisionBinding({ + bindingStore, + sessionId, + sessionKey, + sourceThreadId: "thread-1", + }); + + await expect( + continueLocalCodexSession({ + api, + bindingStore, + config, + control: createEligibleControl(), + threadId: "thread-1", + }), + ).resolves.toEqual({ sessionKey, disposition: "existing" }); + + expect(patchSessionEntry).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey, + readConsistency: "latest", + preserveActivity: true, + update: expect.any(Function), + }), + ); + expect(entries[0]?.entry).toMatchObject({ + sessionId, + updatedAt: 99, + agentHarnessId: "codex", + modelSelectionLocked: true, + model: "gpt-5.4", + modelProvider: "openai", + pluginExtensions: { + codex: { supervision: { sourceThreadId: "thread-1", modelLocked: true } }, + }, + }); + expect(entries[0]?.entry.archivedAt).toBeUndefined(); + expect(createSessionEntry).not.toHaveBeenCalled(); + }); + + it("opens a mapped active source without applying the unadopted idle gate", async () => { + const { runtime, entries, createSessionEntry, patchSessionEntry } = createRuntime(); + const { api } = createGatewayApi(runtime); + const control = createEligibleControl({ + readThread: vi.fn(async () => + idleThread({ status: { type: "active", activeFlags: ["waitingOnApproval"] } }), + ), + }); + const sessionKey = supervisionSessionKey("thread-1"); + const sessionId = "openclaw-session-existing"; + entries.push({ + sessionKey, + entry: adoptedEntry({ sourceThreadId: "thread-1", sessionId }), + }); + const bindingStore = createCodexTestBindingStore(); + await seedSupervisionBinding({ + bindingStore, + sessionId, + sessionKey, + sourceThreadId: "thread-1", + }); + + await expect( + continueLocalCodexSession({ + api, + bindingStore, + config, + control, + threadId: "thread-1", + }), + ).resolves.toEqual({ + sessionKey, + disposition: "existing", + }); + expect(control.readThread).toHaveBeenCalledWith("thread-1", false); + expect(patchSessionEntry).toHaveBeenCalledOnce(); + expect(createSessionEntry).not.toHaveBeenCalled(); + }); + + it.each([ + { name: "mapped", mapped: true, includeTurns: false }, + { name: "unmapped", mapped: false, includeTurns: true }, + ])( + "rejects a $name Continue when the fresh read returns a different thread", + async ({ mapped, includeTurns }) => { + const { runtime, entries, createSessionEntry, patchSessionEntry } = createRuntime(); + const { api } = createGatewayApi(runtime); + const bindingStore = createCodexTestBindingStore(); + if (mapped) { + const sessionKey = supervisionSessionKey("thread-1"); + const sessionId = "openclaw-session-existing"; + entries.push({ + sessionKey, + entry: adoptedEntry({ sourceThreadId: "thread-1", sessionId }), + }); + await seedSupervisionBinding({ + bindingStore, + sessionId, + sessionKey, + sourceThreadId: "thread-1", + }); + } + const control = createEligibleControl({ + readThread: vi.fn(async () => idleThread({ id: "different-thread", source: "cli" })), + }); + + await expect( + continueLocalCodexSession({ + api, + bindingStore, + config, + control, + threadId: "thread-1", + }), + ).rejects.toThrow("returned a different thread than requested"); + + expect(control.readThread).toHaveBeenCalledWith("thread-1", includeTurns); + expect(createSessionEntry).not.toHaveBeenCalled(); + expect(patchSessionEntry).not.toHaveBeenCalled(); + expect(transcriptMirrorMocks.importCodexThreadHistoryToTranscript).not.toHaveBeenCalled(); + }, + ); + + it("does not restore a mapped session when supervision is disabled during source revalidation", async () => { + const { runtime, entries, patchSessionEntry } = createRuntime(); + const { api } = createGatewayApi(runtime); + const sessionKey = supervisionSessionKey("thread-1"); + const sessionId = "openclaw-session-existing"; + entries.push({ + sessionKey, + entry: { ...adoptedEntry({ sourceThreadId: "thread-1", sessionId }), archivedAt: 123 }, + }); + const bindingStore = createCodexTestBindingStore(); + await seedSupervisionBinding({ + bindingStore, + sessionId, + sessionKey, + sourceThreadId: "thread-1", + }); + let supervisionEnabled = true; + let finishRead: ((thread: CodexThread) => void) | undefined; + const readThread = vi.fn( + async () => + await new Promise((resolve) => { + finishRead = resolve; + }), + ); + const control = createEligibleControl({ + assertEnabled: vi.fn(() => { + if (!supervisionEnabled) { + throw new Error("Codex session supervision is disabled"); + } + }), + readThread, + }); + + const continuing = continueLocalCodexSession({ + api, + bindingStore, + config, + control, + threadId: "thread-1", + }); + await vi.waitFor(() => expect(readThread).toHaveBeenCalledOnce()); + supervisionEnabled = false; + finishRead?.(idleThread()); + + await expect(continuing).rejects.toThrow("Codex session supervision is disabled"); + expect(patchSessionEntry).not.toHaveBeenCalled(); + expect(entries[0]?.entry.archivedAt).toBe(123); + }); + + it("fails closed when a mapped session generation changes before restore", async () => { + const { runtime, entries, createSessionEntry, patchSessionEntry } = createRuntime(); + const { api } = createGatewayApi(runtime); + const sessionKey = supervisionSessionKey("thread-1"); + const sessionId = "openclaw-session-stale"; + entries.push({ + sessionKey, + entry: { + ...adoptedEntry({ sourceThreadId: "thread-1", sessionId }), + archivedAt: 123, + }, + }); + const bindingStore = createCodexTestBindingStore(); + await seedSupervisionBinding({ + bindingStore, + sessionId, + sessionKey, + sourceThreadId: "thread-1", + }); + const control = createEligibleControl({ + readThread: vi.fn(async () => { + const entry = entries[0]?.entry; + if (!entry) { + throw new Error("missing mapped session"); + } + entry.sessionId = "openclaw-session-replacement"; + return idleThread(); + }), + }); + + await expect( + continueLocalCodexSession({ + api, + bindingStore, + config, + control, + threadId: "thread-1", + }), + ).rejects.toThrow("changed before it could be opened"); + expect(patchSessionEntry).toHaveBeenCalledOnce(); + expect(entries[0]?.entry.archivedAt).toBe(123); + expect(entries[0]?.entry.modelSelectionLocked).toBe(true); + expect(createSessionEntry).not.toHaveBeenCalled(); + }); + + it("rolls back the session when its pending binding cannot be committed", async () => { + const { runtime, entries, createSessionEntry } = createRuntime(); + const { api } = createGatewayApi(runtime); + const inner = createCodexTestBindingStore(); + let rejectBinding = true; + const mutate = vi.fn(async (...args: Parameters) => { + if (rejectBinding && args[1].kind === "set") { + rejectBinding = false; + return false; + } + return await inner.mutate(...args); + }); + const bindingStore: CodexAppServerBindingStore = { ...inner, mutate }; + const control = createEligibleControl(); + + await expect( + continueLocalCodexSession({ + api, + bindingStore, + config, + control, + threadId: "thread-1", + }), + ).rejects.toThrow("failed to bind OpenClaw session to Codex thread thread-1"); + expect(entries).toEqual([]); + expect(createSessionEntry).toHaveBeenCalledOnce(); + expect(transcriptMirrorMocks.importCodexThreadHistoryToTranscript).toHaveBeenCalledOnce(); + expect(control.archiveThread).not.toHaveBeenCalled(); + }); + + it("clears a committed pending binding when session finalization fails", async () => { + const { runtime } = createRuntime({ failAfterCreate: () => true }); + const { api } = createGatewayApi(runtime); + const bindingStore = createCodexTestBindingStore(); + const control = createEligibleControl(); + + await expect( + continueLocalCodexSession({ + api, + bindingStore, + config, + control, + threadId: "thread-1", + }), + ).rejects.toThrow("session finalization failed after binding commit"); + await expect( + bindingStore.read( + sessionBindingIdentity({ + sessionId: "openclaw-session-1", + sessionKey: supervisionSessionKey("thread-1"), + config, + }), + ), + ).resolves.toBeUndefined(); + expect(control.archiveThread).not.toHaveBeenCalled(); + }); + + it("preserves successor cleanup state when failed finalization loses its binding CAS", async () => { + const { runtime } = createRuntime({ failAfterCreate: () => true }); + const { api } = createGatewayApi(runtime); + const inner = createCodexTestBindingStore(); + const successorThreadId = "thread-successor-probe"; + let replaced = false; + const bindingStore: CodexAppServerBindingStore = { + ...inner, + mutate: async (identity, mutation) => { + if (!replaced && mutation.kind === "clear") { + const current = await inner.read(identity); + const pending = current?.pendingSupervisionBranch; + if (!pending) { + throw new Error("missing pending supervision binding before cleanup"); + } + replaced = true; + const patched = await inner.mutate(identity, { + kind: "patch-pending-supervision-branch", + expected: pending, + pending: { ...pending, cleanupThreadIds: [successorThreadId] }, + }); + if (!patched) { + throw new Error("failed to install successor supervision cleanup state"); + } + } + return await inner.mutate(identity, mutation); + }, + }; + const control = createEligibleControl(); + + await expect( + continueLocalCodexSession({ + api, + bindingStore, + config, + control, + threadId: "thread-1", + }), + ).rejects.toThrow("session finalization failed after binding commit"); + await expect( + bindingStore.read( + sessionBindingIdentity({ + sessionId: "openclaw-session-1", + sessionKey: supervisionSessionKey("thread-1"), + config, + }), + ), + ).resolves.toMatchObject({ + threadId: "thread-1", + pendingSupervisionBranch: { + sourceThreadId: "thread-1", + cleanupThreadIds: [successorThreadId], + }, + }); + expect(control.archiveThread).not.toHaveBeenCalled(); + }); + + it("walks the canonical non-archived catalog before continuing a known thread", async () => { + const { runtime } = createRuntime(); + const { api } = createGatewayApi(runtime); + const listPage = vi.fn(async (params: { cursor?: string }) => + params.cursor + ? { + sessions: [ + { + threadId: "thread-1", + status: "idle", + source: "vscode", + archived: false as const, + }, + ], + } + : { + sessions: [ + { + threadId: "other-thread", + status: "idle", + source: "cli", + archived: false as const, + }, + ], + nextCursor: "page-2", + }, + ); + const control = createControl({ listPage }); + + await expect( + continueLocalCodexSession({ + api, + bindingStore: createCodexTestBindingStore(), + config, + control, + threadId: "thread-1", + }), + ).resolves.toMatchObject({ disposition: "forked" }); + expect(listPage).toHaveBeenNthCalledWith(1, { limit: 100 }); + expect(listPage).toHaveBeenNthCalledWith(2, { cursor: "page-2", limit: 100 }); + }); + + it("rejects archived interactive thread ids that are absent from the canonical catalog", async () => { + const { runtime, createSessionEntry } = createRuntime(); + const { api } = createGatewayApi(runtime); + const control = createControl({ + listPage: vi.fn(async () => ({ sessions: [] })), + readThread: vi.fn(async () => idleThread({ source: "cli" })), + }); + + await expect( + continueLocalCodexSession({ + api, + bindingStore: createCodexTestBindingStore(), + config, + control, + threadId: "thread-1", + }), + ).rejects.toThrow("not a non-archived interactive CLI or VS Code session"); + await expect(archiveTestSession({ control })).rejects.toThrow( + "not a non-archived interactive CLI or VS Code session", + ); + expect(control.readThread).not.toHaveBeenCalled(); + expect(createSessionEntry).not.toHaveBeenCalled(); + expect(control.archiveThread).not.toHaveBeenCalled(); + }); + + it("rejects internal App Server thread ids even if a control returns them", async () => { + const { runtime } = createRuntime(); + const { api } = createGatewayApi(runtime); + const control = createControl({ + listPage: vi.fn(async () => ({ + sessions: [ + { + threadId: "thread-1", + status: "idle", + source: "appServer", + archived: false, + }, + ], + })), + }); + + await expect( + continueLocalCodexSession({ + api, + bindingStore: createCodexTestBindingStore(), + config, + control, + threadId: "thread-1", + }), + ).rejects.toThrow("not a non-archived interactive CLI or VS Code session"); + await expect(archiveTestSession({ control })).rejects.toThrow( + "not a non-archived interactive CLI or VS Code session", + ); + expect(control.readThread).not.toHaveBeenCalled(); + }); + + it("fails closed when canonical catalog cursors cycle", async () => { + const { runtime } = createRuntime(); + const { api } = createGatewayApi(runtime); + const control = createControl({ + listPage: vi.fn(async () => ({ sessions: [], nextCursor: "cycle" })), + }); + + await expect( + continueLocalCodexSession({ + api, + bindingStore: createCodexTestBindingStore(), + config, + control, + threadId: "thread-1", + }), + ).rejects.toThrow("eligibility could not be verified"); + expect(control.listPage).toHaveBeenCalledTimes(2); + expect(control.readThread).not.toHaveBeenCalled(); + }); + + it("rechecks status and rejects active local sessions before either mutation", async () => { + const { runtime, createSessionEntry } = createRuntime(); + const { api } = createGatewayApi(runtime); + const bindingStore = createCodexTestBindingStore(); + const control = createEligibleControl({ + readThread: vi.fn(async () => + idleThread({ status: { type: "active", activeFlags: ["waitingOnApproval"] } }), + ), + }); + + await expect( + continueLocalCodexSession({ + api, + bindingStore, + config, + control, + threadId: "thread-1", + }), + ).rejects.toThrow("active in this App Server"); + await expect(archiveTestSession({ control, bindingStore, runtime })).rejects.toThrow( + "active in this App Server", + ); + expect(createSessionEntry).not.toHaveBeenCalled(); + expect(control.archiveThread).not.toHaveBeenCalled(); + expect(control.readThread).toHaveBeenNthCalledWith(1, "thread-1", true); + expect(control.readThread).toHaveBeenNthCalledWith(2, "thread-1", false); + }); + + it("archives an idle local thread only after the fresh status read", async () => { + const control = createEligibleControl(); + const readThread = vi.mocked(control.readThread); + const archiveThread = vi.mocked(control.archiveThread); + + await expect(archiveTestSession({ control })).resolves.toEqual({ + archived: true, + }); + expect(control.readThread).toHaveBeenCalledWith("thread-1", false); + expect(control.archiveThread).toHaveBeenCalledWith("thread-1"); + expect(readThread.mock.invocationCallOrder[0]).toBeLessThan( + archiveThread.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + }); + + it("pins one App Server connection while archive configuration changes live", async () => { + let pluginConfig: unknown = { + appServer: { command: "codex-archive-a" }, + supervision: { enabled: true }, + }; + let runtimeConfig = { agents: { defaults: { workspace: "/workspace/a" } } } as OpenClawConfig; + pinnedConnectionMocks.request.mockImplementation( + async (request: { method: string; requestParams?: Record }) => { + if ( + request.method === "thread/list" && + request.requestParams?.ancestorThreadId === undefined + ) { + pluginConfig = { + appServer: { command: "codex-archive-b", homeScope: "agent" }, + supervision: { enabled: true }, + }; + runtimeConfig = { + agents: { defaults: { workspace: "/workspace/b" } }, + } as OpenClawConfig; + return { + data: [idleThread({ source: "cli" })], + }; + } + if (request.method === "thread/read") { + return { thread: idleThread() }; + } + if (request.method === "thread/list") { + return { data: [] }; + } + if (request.method === "thread/archive") { + return {}; + } + throw new Error(`unexpected method: ${request.method}`); + }, + ); + const control = createCodexSessionCatalogControl({ + getPluginConfig: () => pluginConfig, + getRuntimeConfig: () => runtimeConfig, + }); + + await expect(archiveTestSession({ control })).resolves.toEqual({ archived: true }); + + expect(pinnedConnectionMocks.getClient).toHaveBeenCalledOnce(); + const acquisition = pinnedConnectionMocks.getClient.mock.calls[0]?.[0]; + expect(acquisition).toMatchObject({ + startOptions: expect.objectContaining({ command: "codex-archive-a", homeScope: "user" }), + config: { agents: { defaults: { workspace: "/workspace/a" } } }, + }); + expect(pinnedConnectionMocks.request.mock.calls.map(([request]) => request.method)).toEqual([ + "thread/list", + "thread/read", + "thread/list", + "thread/archive", + ]); + for (const [request] of pinnedConnectionMocks.request.mock.calls) { + expect(request.client).toBe(pinnedConnectionMocks.client); + expect(request.config).toBe(acquisition?.config); + } + expect(pinnedConnectionMocks.releaseClient).toHaveBeenCalledWith(pinnedConnectionMocks.client); + expect(commandRpcMocks.codexControlRequest).not.toHaveBeenCalled(); + }); + + it("stops a pinned archive when supervision permission is revoked live", async () => { + let pluginConfig: unknown = { supervision: { enabled: true } }; + pinnedConnectionMocks.request.mockImplementation(async (request: { method: string }) => { + if (request.method === "thread/list") { + return { data: [idleThread({ source: "cli" })] }; + } + if (request.method === "thread/read") { + pluginConfig = { supervision: { enabled: false } }; + return { thread: idleThread() }; + } + throw new Error(`unexpected method: ${request.method}`); + }); + const control = createCodexSessionCatalogControl({ + getPluginConfig: () => pluginConfig, + getRuntimeConfig: () => config, + }); + + await expect(archiveTestSession({ control })).rejects.toThrow( + "Codex session supervision is disabled", + ); + expect(pinnedConnectionMocks.request.mock.calls.map(([request]) => request.method)).toEqual([ + "thread/list", + "thread/read", + ]); + expect(pinnedConnectionMocks.releaseClient).toHaveBeenCalledWith(pinnedConnectionMocks.client); + }); + + it("rejects archive while another OpenClaw session owns the native thread", async () => { + const bindingStore = createCodexTestBindingStore(); + await bindingStore.mutate( + { kind: "conversation", bindingId: "bound-chat" }, + { + kind: "set", + binding: { threadId: "thread-1", cwd: "/workspace/project" }, + }, + ); + const control = createEligibleControl(); + + await expect(archiveTestSession({ bindingStore, control })).rejects.toThrow( + "attached to an OpenClaw session", + ); + expect(control.readThread).toHaveBeenCalledWith("thread-1", false); + expect(control.archiveThread).not.toHaveBeenCalled(); + }); + + it("rejects archive when a paginated spawned descendant has an OpenClaw owner", async () => { + const bindingStore = createCodexTestBindingStore(); + await bindingStore.mutate( + { kind: "conversation", bindingId: "descendant-chat" }, + { + kind: "set", + binding: { threadId: "owned-descendant", cwd: "/workspace/project" }, + }, + ); + const control = createEligibleControl({ + listDescendantPage: vi.fn(async (params) => + params.cursor === "descendants-2" + ? { data: [idleThread({ id: "owned-descendant" })] } + : { + data: [idleThread({ id: "unowned-descendant" })], + nextCursor: "descendants-2", + }, + ), + }); + + await expect(archiveTestSession({ bindingStore, control })).rejects.toThrow( + "spawned descendant is owned by an OpenClaw session", + ); + expect(control.listDescendantPage).toHaveBeenNthCalledWith(1, { + ancestorThreadId: "thread-1", + archived: false, + limit: 100, + sortKey: "created_at", + sortDirection: "desc", + useStateDbOnly: true, + }); + expect(control.listDescendantPage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ cursor: "descendants-2" }), + ); + expect(control.archiveThread).not.toHaveBeenCalled(); + }); + + it("rejects archive when a spawned descendant is active", async () => { + const control = createEligibleControl({ + listDescendantPage: vi.fn(async () => ({ data: [{ id: "active-descendant" }] })), + readThread: vi.fn(async (threadId: string) => + idleThread({ + id: threadId, + status: threadId === "active-descendant" ? { type: "active" } : { type: "idle" }, + }), + ), + }); + + await expect(archiveTestSession({ control })).rejects.toThrow( + "Codex session is active in this App Server", + ); + expect(control.readThread).toHaveBeenCalledWith("active-descendant", false); + expect(control.archiveThread).not.toHaveBeenCalled(); + }); + + it("fences ownership mutations while validating and archiving the native subtree", async () => { + const bindingStore = createCodexTestBindingStore(); + const lateIdentity = { kind: "conversation" as const, bindingId: "late-descendant-owner" }; + let validationReached!: () => void; + const validating = new Promise((resolve) => { + validationReached = resolve; + }); + let releaseValidation!: () => void; + const validationReleased = new Promise((resolve) => { + releaseValidation = resolve; + }); + const listDescendantPage = vi.fn(async () => { + validationReached(); + await validationReleased; + return { data: [{ id: "idle-descendant" }] }; + }); + const control = createEligibleControl({ listDescendantPage }); + + const archiving = archiveTestSession({ bindingStore, control }); + await validating; + await expect( + bindingStore.mutate(lateIdentity, { + kind: "set", + binding: { threadId: "late-descendant", cwd: "/workspace/project" }, + }), + ).rejects.toThrow("native archive is in progress"); + releaseValidation(); + await expect(archiving).resolves.toEqual({ archived: true }); + await expect(bindingStore.read(lateIdentity)).resolves.toBeUndefined(); + expect(control.readThread).toHaveBeenCalledWith("idle-descendant", false); + expect(control.archiveThread).toHaveBeenCalledWith("thread-1"); + }); + + it.each([ + { + name: "a repeated cursor", + response: { data: [], nextCursor: "cycle" }, + error: "repeated descendant-list cursor", + calls: 2, + }, + { + name: "the ancestor as its own descendant", + response: { data: [idleThread({ id: "thread-1" })] }, + error: "cyclic descendant thread list", + calls: 1, + }, + { + name: "an invalid response", + response: { data: null }, + error: "invalid descendant-list response", + calls: 1, + }, + ])( + "fails closed when descendant enumeration returns $name", + async ({ response, error, calls }) => { + const control = createEligibleControl({ + listDescendantPage: vi.fn(async () => response as never), + }); + + await expect(archiveTestSession({ control })).rejects.toThrow(error); + expect(control.listDescendantPage).toHaveBeenCalledTimes(calls); + expect(control.archiveThread).not.toHaveBeenCalled(); + }, + ); + + it("fails closed when descendant enumeration reaches its page cap", async () => { + let page = 0; + const control = createEligibleControl({ + listDescendantPage: vi.fn(async () => { + page += 1; + return { + data: [idleThread({ id: `descendant-${page}` })], + nextCursor: `descendants-${page}`, + }; + }), + }); + + await expect(archiveTestSession({ control })).rejects.toThrow( + "descendant enumeration exceeded its safety limit", + ); + expect(control.listDescendantPage).toHaveBeenCalledTimes(100); + expect(control.archiveThread).not.toHaveBeenCalled(); + }); + + it("rejects an archive when the fresh read returns a different thread", async () => { + const control = createEligibleControl({ + readThread: vi.fn(async () => idleThread({ id: "different-thread" })), + }); + + await expect(archiveTestSession({ control })).rejects.toThrow( + "returned a different thread than requested", + ); + expect(control.archiveThread).not.toHaveBeenCalled(); + }); + + it("archives a not-loaded local thread after explicit runner confirmation", async () => { + const control = createEligibleControl({ + readThread: vi.fn(async () => idleThread({ status: { type: "notLoaded" } })), + }); + + await expect(archiveTestSession({ control })).resolves.toEqual({ + archived: true, + }); + expect(control.archiveThread).toHaveBeenCalledWith("thread-1"); + }); + + it("requires archive confirmation and rejects paired-node mutations at Gateway handlers", async () => { + const { runtime, createSessionEntry } = createRuntime(); + const { api, handlers, registerControlUiDescriptor, registerGatewayMethod } = + createGatewayApi(runtime); + const control = createEligibleControl(); + registerCodexSessionCatalogGateway({ + api, + bindingStore: createCodexTestBindingStore(), + control, + getRuntimeConfig: () => config, + }); + + expect(registerControlUiDescriptor).toHaveBeenCalledWith( + expect.objectContaining({ id: "sessions", requiredScopes: ["operator.write"] }), + ); + for (const method of [ + CODEX_SESSION_CATALOG_METHOD, + CODEX_SESSION_CONTINUE_METHOD, + CODEX_SESSION_ARCHIVE_METHOD, + ]) { + expect(registerGatewayMethod).toHaveBeenCalledWith(method, expect.any(Function), { + scope: "operator.write", + }); + } + + const archivedRespond = await callGatewayHandler(handlers.get(CODEX_SESSION_CATALOG_METHOD), { + archived: true, + }); + expect(archivedRespond).toHaveBeenCalledWith( + false, + { error: "unknown Codex session catalog parameter: archived" }, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + + const unconfirmedArchive = await callGatewayHandler( + handlers.get(CODEX_SESSION_ARCHIVE_METHOD), + { hostId: CODEX_LOCAL_SESSION_HOST_ID, threadId: "thread-1" }, + ); + expect(unconfirmedArchive).toHaveBeenCalledWith( + false, + { + error: + "confirmNoOtherRunner=true is required because Codex client and runner activity is process-local", + }, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + expect(control.readThread).not.toHaveBeenCalled(); + + const confirmedArchive = await callGatewayHandler(handlers.get(CODEX_SESSION_ARCHIVE_METHOD), { + hostId: CODEX_LOCAL_SESSION_HOST_ID, + threadId: "thread-1", + confirmNoOtherRunner: true, + }); + expect(confirmedArchive).toHaveBeenCalledWith(true, { archived: true }); + + for (const method of [CODEX_SESSION_CONTINUE_METHOD, CODEX_SESSION_ARCHIVE_METHOD]) { + const respond = await callGatewayHandler(handlers.get(method), { + hostId: "node:devbox", + threadId: "thread-remote", + ...(method === CODEX_SESSION_ARCHIVE_METHOD ? { confirmNoOtherRunner: true } : {}), + }); + expect(respond).toHaveBeenCalledWith( + false, + { error: "paired-node Codex sessions are view-only" }, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + } + expect(control.readThread).toHaveBeenCalledOnce(); + expect(control.archiveThread).toHaveBeenCalledOnce(); + expect(createSessionEntry).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/codex/src/session-catalog.ts b/extensions/codex/src/session-catalog.ts new file mode 100644 index 000000000000..2f66be97d7c1 --- /dev/null +++ b/extensions/codex/src/session-catalog.ts @@ -0,0 +1,1751 @@ +import { createHash } from "node:crypto"; +import { + listAgentIds, + resolveDefaultAgentDir, + resolveDefaultAgentId, +} from "openclaw/plugin-sdk/agent-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { + ErrorCodes, + errorShape, + type GatewayRequestHandlerOptions, +} from "openclaw/plugin-sdk/gateway-runtime"; +import type { + OpenClawPluginApi, + OpenClawPluginNodeHostCommand, + OpenClawPluginNodeInvokePolicy, +} from "openclaw/plugin-sdk/plugin-entry"; +import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; +import { parseAgentSessionKey } from "openclaw/plugin-sdk/routing"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; +import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js"; +import { + readCodexPluginConfig, + resolveCodexSupervisionAppServerRuntimeOptions, +} from "./app-server/config.js"; +import { buildCodexAppServerConnectionFingerprint } from "./app-server/plugin-app-cache-key.js"; +import type { + CodexThread, + CodexThreadListParams, + CodexThreadListResponse, +} from "./app-server/protocol.js"; +import { CODEX_INTERACTIVE_THREAD_SOURCE_KINDS } from "./app-server/protocol.js"; +import { requestCodexAppServerClientJson } from "./app-server/request.js"; +import { + reclaimCurrentCodexSessionGeneration, + sessionBindingIdentity, + type CodexAppServerBindingStore, + type CodexAppServerPendingSupervisionBranch, + type CodexAppServerThreadBinding, +} from "./app-server/session-binding.js"; +import { + getLeasedSharedCodexAppServerClient, + releaseLeasedSharedCodexAppServerClient, +} from "./app-server/shared-client.js"; +import { assertCodexArchiveDescendantsUnowned } from "./app-server/thread-archive-guard.js"; +import { importCodexThreadHistoryToTranscript } from "./app-server/transcript-mirror.js"; +import { codexControlRequest } from "./command-rpc.js"; +import type { + CodexSessionCatalogError, + CodexSessionCatalogHost, + CodexSessionCatalogPage, + CodexSessionCatalogPageParams, + CodexSessionCatalogParams, + CodexSessionCatalogResult, + CodexSessionCatalogSession, +} from "./session-catalog-types.js"; + +export type { + CodexSessionCatalogError, + CodexSessionCatalogHost, + CodexSessionCatalogPage, + CodexSessionCatalogPageParams, + CodexSessionCatalogParams, + CodexSessionCatalogResult, + CodexSessionCatalogSession, +} from "./session-catalog-types.js"; + +export const CODEX_APP_SERVER_THREADS_LIST_COMMAND = "codex.appServer.threads.list.v1"; +export const CODEX_SESSION_CATALOG_METHOD = "codex.sessions.list"; +export const CODEX_SESSION_CONTINUE_METHOD = "codex.sessions.continue"; +export const CODEX_SESSION_ARCHIVE_METHOD = "codex.sessions.archive"; + +const CODEX_APP_SERVER_THREADS_CAPABILITY = "codex-app-server-threads"; +const DEFAULT_PAGE_LIMIT = 50; +export const CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT = 100; +const NODE_INVOKE_TIMEOUT_MS = 20_000; +const MAX_SEARCH_LENGTH = 500; +const MAX_CURSOR_LENGTH = 4096; +const MAX_CURSOR_COUNT = 100; +const MAX_HOST_COUNT = 100; +const MAX_HOST_ID_LENGTH = 256; +const MAX_CWD_LENGTH = 4096; +const MAX_SESSION_ID_LENGTH = 256; +const MAX_SESSION_NAME_LENGTH = 500; +const MAX_SESSION_KEY_LENGTH = 1024; +const MAX_METADATA_LENGTH = 500; +const MAX_ACTIVE_FLAGS = 16; +const MAX_ACTION_CATALOG_PAGES = 100; +const MAX_TITLE_SEARCH_CATALOG_PAGES = 20; +const CODEX_SUPERVISION_SESSION_KEY_PREFIX = "harness:codex:supervision:"; + +class CatalogParamsError extends Error {} + +type CatalogNode = Awaited>["nodes"][number]; + +export const CODEX_LOCAL_SESSION_HOST_ID = "gateway:local"; + +export type CodexSessionCatalogControl = { + assertEnabled(): void; + connectionFingerprint?: string; + withPinnedConnection(run: (control: CodexSessionCatalogControl) => Promise): Promise; + listPage(params: CodexSessionCatalogPageParams): Promise; + listDescendantPage(params: CodexThreadListParams): Promise; + readThread(threadId: string, includeTurns?: boolean): Promise; + archiveThread(threadId: string): Promise; +}; + +function requireCodexSessionSupervisionEnabled(pluginConfig: unknown): void { + if (readCodexPluginConfig(pluginConfig).supervision?.enabled !== true) { + throw new Error("Codex session supervision is disabled"); + } +} + +type CodexSessionCatalogRequestSnapshot = { + requestTimeoutMs: number; + listThreads(params: CodexThreadListParams, timeoutMs: number): Promise; + readThread(threadId: string, includeTurns: boolean): Promise; + archiveThread(threadId: string): Promise; +}; + +function createCodexSessionCatalogControlFromRequests(params: { + assertEnabled: () => void; + connectionFingerprint?: string; + createRequestSnapshot: () => CodexSessionCatalogRequestSnapshot; + now: () => number; + withPinnedConnection: CodexSessionCatalogControl["withPinnedConnection"]; +}): CodexSessionCatalogControl { + return { + assertEnabled: params.assertEnabled, + ...(params.connectionFingerprint + ? { connectionFingerprint: params.connectionFingerprint } + : {}), + withPinnedConnection: params.withPinnedConnection, + async listPage(pageParams) { + params.assertEnabled(); + const limit = normalizeLimit(pageParams.limit, "limit"); + // App Server search also matches transcript previews. Scan native pages + // without that filter so this catalog remains a title-only surface. + const search = pageParams.searchTerm?.trim().toLocaleLowerCase() || undefined; + const cwd = pageParams.cwd?.trim() || undefined; + const maxPages = search ? MAX_TITLE_SEARCH_CATALOG_PAGES : 1; + const sessions: CodexSessionCatalogSession[] = []; + let cursor = readControlCursor(pageParams.cursor, "request"); + let nextCursor: string | undefined; + let backwardsCursor: string | undefined; + const seenCursors = new Set(cursor ? [cursor] : []); + const requests = params.createRequestSnapshot(); + const deadline = params.now() + requests.requestTimeoutMs; + + for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { + params.assertEnabled(); + const remaining = limit - sessions.length; + const remainingTimeoutMs = Math.ceil(deadline - params.now()); + if (remainingTimeoutMs <= 0) { + throw new Error("Codex session catalog listing timed out"); + } + const response = await requests.listThreads( + { + archived: false, + limit: remaining, + modelProviders: [], + sortKey: "recency_at", + sortDirection: "desc", + sourceKinds: [...CODEX_INTERACTIVE_THREAD_SOURCE_KINDS], + ...(cwd ? { cwd } : {}), + ...(cursor ? { cursor } : {}), + }, + remainingTimeoutMs, + ); + params.assertEnabled(); + if (pageIndex === 0) { + backwardsCursor = readControlCursor(response.backwardsCursor, "backwards response"); + } + sessions.push( + ...response.data + .flatMap((thread) => { + const session = toCatalogSession(thread, false); + return session ? [session] : []; + }) + .filter((session) => !search || session.name?.toLocaleLowerCase().includes(search)), + ); + nextCursor = readControlCursor(response.nextCursor, "next response"); + if (!nextCursor || sessions.length >= limit) { + break; + } + if (seenCursors.has(nextCursor)) { + throw new Error("Codex session catalog returned a repeated search cursor"); + } + seenCursors.add(nextCursor); + cursor = nextCursor; + } + return { + sessions, + ...(nextCursor ? { nextCursor } : {}), + ...(backwardsCursor ? { backwardsCursor } : {}), + }; + }, + async listDescendantPage(listParams) { + params.assertEnabled(); + const requests = params.createRequestSnapshot(); + const response = await requests.listThreads(listParams, requests.requestTimeoutMs); + params.assertEnabled(); + return response; + }, + async readThread(threadId, includeTurns = false) { + params.assertEnabled(); + const thread = await params.createRequestSnapshot().readThread(threadId, includeTurns); + params.assertEnabled(); + return thread; + }, + async archiveThread(threadId) { + params.assertEnabled(); + await params.createRequestSnapshot().archiveThread(threadId); + params.assertEnabled(); + }, + }; +} + +/** Builds the passive catalog over the Codex plugin's canonical shared client. */ +export function createCodexSessionCatalogControl(params: { + getPluginConfig: () => unknown; + getRuntimeConfig: () => OpenClawConfig | undefined; + now?: () => number; +}): CodexSessionCatalogControl { + const now = params.now ?? Date.now; + const requireEnabledPluginConfig = () => { + const pluginConfig = params.getPluginConfig(); + requireCodexSessionSupervisionEnabled(pluginConfig); + return pluginConfig; + }; + const assertEnabled = () => void requireEnabledPluginConfig(); + const createRequestSnapshot = (): CodexSessionCatalogRequestSnapshot => { + const pluginConfig = requireEnabledPluginConfig(); + const runtime = resolveCodexSupervisionAppServerRuntimeOptions({ pluginConfig }); + const requestOptions = { + config: structuredClone(params.getRuntimeConfig()), + startOptions: structuredClone(runtime.start), + }; + return { + requestTimeoutMs: runtime.requestTimeoutMs, + listThreads: async (listParams, timeoutMs) => + await codexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.listThreads, listParams, { + ...requestOptions, + timeoutMs, + }), + readThread: async (threadId, includeTurns) => + ( + await codexControlRequest( + pluginConfig, + CODEX_CONTROL_METHODS.readThread, + { threadId, includeTurns }, + requestOptions, + ) + ).thread, + archiveThread: async (threadId) => { + await codexControlRequest( + pluginConfig, + CODEX_CONTROL_METHODS.archiveThread, + { threadId }, + requestOptions, + ); + }, + }; + }; + + const withPinnedConnection: CodexSessionCatalogControl["withPinnedConnection"] = async (run) => { + const pluginConfig = requireEnabledPluginConfig(); + const runtime = resolveCodexSupervisionAppServerRuntimeOptions({ pluginConfig }); + const runtimeConfig = structuredClone(params.getRuntimeConfig()); + const startOptions = structuredClone(runtime.start); + const client = await getLeasedSharedCodexAppServerClient({ + config: runtimeConfig, + startOptions, + timeoutMs: runtime.requestTimeoutMs, + }); + try { + const requests: CodexSessionCatalogRequestSnapshot = { + requestTimeoutMs: runtime.requestTimeoutMs, + listThreads: async (listParams, timeoutMs) => + await requestCodexAppServerClientJson({ + client, + method: CODEX_CONTROL_METHODS.listThreads, + requestParams: listParams, + config: runtimeConfig, + timeoutMs, + }), + readThread: async (threadId, includeTurns) => + ( + await requestCodexAppServerClientJson<{ thread: CodexThread }>({ + client, + method: CODEX_CONTROL_METHODS.readThread, + requestParams: { threadId, includeTurns }, + config: runtimeConfig, + timeoutMs: runtime.requestTimeoutMs, + }) + ).thread, + archiveThread: async (threadId) => { + await requestCodexAppServerClientJson({ + client, + method: CODEX_CONTROL_METHODS.archiveThread, + requestParams: { threadId }, + config: runtimeConfig, + timeoutMs: runtime.requestTimeoutMs, + }); + }, + }; + const pinnedControl: CodexSessionCatalogControl = + createCodexSessionCatalogControlFromRequests({ + assertEnabled, + connectionFingerprint: buildCodexAppServerConnectionFingerprint( + runtime, + resolveDefaultAgentDir(runtimeConfig ?? {}), + ), + createRequestSnapshot: () => requests, + now, + withPinnedConnection: async (nestedRun) => await nestedRun(pinnedControl), + }); + return await run(pinnedControl); + } finally { + releaseLeasedSharedCodexAppServerClient(client); + } + }; + + return createCodexSessionCatalogControlFromRequests({ + assertEnabled, + createRequestSnapshot, + now, + withPinnedConnection, + }); +} + +function readControlCursor(value: unknown, label: string): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (typeof value !== "string" || !value.trim() || value.length > MAX_CURSOR_LENGTH) { + throw new CatalogParamsError(`invalid Codex session catalog ${label} cursor`); + } + return value; +} + +function boundedCatalogString( + value: unknown, + maxLength: number, + overflow: "omit" | "truncate" = "omit", +): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim(); + if (!normalized) { + return undefined; + } + if (normalized.length <= maxLength) { + return normalized; + } + return overflow === "truncate" ? truncateUtf16Safe(normalized, maxLength) : undefined; +} + +type CodexInteractiveThreadSourceKind = (typeof CODEX_INTERACTIVE_THREAD_SOURCE_KINDS)[number]; + +function isInteractiveThreadSource( + source: CodexThread["source"], +): source is CodexInteractiveThreadSourceKind { + return CODEX_INTERACTIVE_THREAD_SOURCE_KINDS.some((kind) => kind === source); +} + +function toCatalogSession( + thread: CodexThread, + archived: boolean, +): CodexSessionCatalogSession | undefined { + const source = thread.source; + if (!isInteractiveThreadSource(source)) { + return undefined; + } + const record = thread as CodexThread & Record; + const threadId = boundedCatalogString(thread.id, MAX_SESSION_ID_LENGTH); + if (!threadId) { + return undefined; + } + const activeFlags = + thread.status?.type === "active" + ? thread.status.activeFlags + ?.flatMap((flag) => { + const normalized = boundedCatalogString(flag, 128); + return normalized ? [normalized] : []; + }) + .slice(0, MAX_ACTIVE_FLAGS) + : undefined; + const gitInfo = isRecord(record.gitInfo) ? record.gitInfo : undefined; + const sessionId = boundedCatalogString(thread.sessionId, MAX_SESSION_ID_LENGTH); + const name = boundedCatalogString(thread.name, MAX_SESSION_NAME_LENGTH, "truncate"); + const cwd = boundedCatalogString(thread.cwd, MAX_CWD_LENGTH); + const modelProvider = boundedCatalogString(record.modelProvider, MAX_METADATA_LENGTH, "truncate"); + const cliVersion = boundedCatalogString(record.cliVersion, MAX_METADATA_LENGTH, "truncate"); + const gitBranch = boundedCatalogString(gitInfo?.branch, MAX_METADATA_LENGTH, "truncate"); + return { + threadId, + status: thread.status?.type ?? "notLoaded", + archived, + ...(sessionId ? { sessionId } : {}), + ...(thread.name === null ? { name: null } : name ? { name } : {}), + ...(cwd ? { cwd } : {}), + ...(activeFlags?.length ? { activeFlags } : {}), + ...(typeof thread.createdAt === "number" && Number.isFinite(thread.createdAt) + ? { createdAt: thread.createdAt } + : {}), + ...(typeof thread.updatedAt === "number" && Number.isFinite(thread.updatedAt) + ? { updatedAt: thread.updatedAt } + : {}), + ...(typeof record.recencyAt === "number" && Number.isFinite(record.recencyAt) + ? { recencyAt: record.recencyAt } + : record.recencyAt === null + ? { recencyAt: null } + : {}), + source, + ...(modelProvider ? { modelProvider } : {}), + ...(cliVersion ? { cliVersion } : {}), + ...(gitBranch ? { gitBranch } : {}), + }; +} + +function normalizeLimit(value: unknown, key: string): number { + if (value === undefined) { + return DEFAULT_PAGE_LIMIT; + } + if ( + !Number.isInteger(value) || + (value as number) < 1 || + (value as number) > CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT + ) { + throw new CatalogParamsError( + `${key} must be an integer from 1 to ${CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT}`, + ); + } + return value as number; +} + +function readOptionalString(params: Record, key: string, maxLength: number) { + const value = params[key]; + if (value === undefined) { + return undefined; + } + if (typeof value !== "string") { + throw new CatalogParamsError(`${key} must be a string`); + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + if (trimmed.length > maxLength) { + throw new CatalogParamsError(`${key} must be at most ${maxLength} characters`); + } + return trimmed; +} + +function requireOnlyKeys(params: Record, allowed: ReadonlySet): void { + const unknown = Object.keys(params).find((key) => !allowed.has(key)); + if (unknown) { + throw new CatalogParamsError(`unknown Codex session catalog parameter: ${unknown}`); + } +} + +function readPageParams(value: unknown): CodexSessionCatalogPageParams { + if (!isRecord(value)) { + throw new CatalogParamsError("Codex session catalog parameters must be an object"); + } + const params = value; + requireOnlyKeys(params, new Set(["cursor", "limit", "searchTerm", "cwd"])); + const cursor = readOptionalString(params, "cursor", MAX_CURSOR_LENGTH); + const searchTerm = readOptionalString(params, "searchTerm", MAX_SEARCH_LENGTH); + const cwd = readOptionalString(params, "cwd", MAX_CWD_LENGTH); + return { + limit: normalizeLimit(params.limit, "limit"), + ...(cursor ? { cursor } : {}), + ...(searchTerm ? { searchTerm } : {}), + ...(cwd ? { cwd } : {}), + }; +} + +function readGatewayParams(value: unknown): CodexSessionCatalogParams { + if (value !== undefined && !isRecord(value)) { + throw new CatalogParamsError("Codex session catalog parameters must be an object"); + } + const params = isRecord(value) ? value : {}; + requireOnlyKeys(params, new Set(["search", "limitPerHost", "hostIds", "cursors"])); + const search = readOptionalString(params, "search", MAX_SEARCH_LENGTH); + let hostIds: string[] | undefined; + if (params.hostIds !== undefined) { + if (!Array.isArray(params.hostIds) || params.hostIds.length > MAX_HOST_COUNT) { + throw new CatalogParamsError(`hostIds must contain at most ${MAX_HOST_COUNT} host ids`); + } + hostIds = [...new Set(params.hostIds.map((hostId) => readHostId(hostId)))]; + } + let cursors: Record | undefined; + if (params.cursors !== undefined) { + if (!isRecord(params.cursors)) { + throw new CatalogParamsError("cursors must be an object"); + } + const entries = Object.entries(params.cursors); + if (entries.length > MAX_CURSOR_COUNT) { + throw new CatalogParamsError(`cursors may contain at most ${MAX_CURSOR_COUNT} hosts`); + } + cursors = {}; + for (const [hostId, cursor] of entries) { + const normalizedHostId = hostId.trim(); + if ( + normalizedHostId.length === 0 || + normalizedHostId.length > MAX_HOST_ID_LENGTH || + (!normalizedHostId.startsWith("gateway:") && !normalizedHostId.startsWith("node:")) + ) { + throw new CatalogParamsError(`invalid Codex session catalog host id: ${hostId}`); + } + if ( + typeof cursor !== "string" || + !cursor.trim() || + cursor.trim().length > MAX_CURSOR_LENGTH + ) { + throw new CatalogParamsError(`invalid cursor for Codex session catalog host: ${hostId}`); + } + cursors[normalizedHostId] = cursor.trim(); + } + } + return { + limitPerHost: normalizeLimit(params.limitPerHost, "limitPerHost"), + ...(search ? { search } : {}), + ...(hostIds && hostIds.length > 0 ? { hostIds } : {}), + ...(cursors && Object.keys(cursors).length > 0 ? { cursors } : {}), + }; +} + +function readHostId(value: unknown): string { + if (typeof value !== "string") { + throw new CatalogParamsError("Codex session catalog host ids must be strings"); + } + const hostId = value.trim(); + if ( + hostId.length === 0 || + hostId.length > MAX_HOST_ID_LENGTH || + (!hostId.startsWith("gateway:") && !hostId.startsWith("node:")) + ) { + throw new CatalogParamsError(`invalid Codex session catalog host id: ${value}`); + } + return hostId; +} + +function parseJsonParams(paramsJSON?: string | null): unknown { + if (!paramsJSON?.trim()) { + return {}; + } + try { + return JSON.parse(paramsJSON) as unknown; + } catch (error) { + throw new Error("Codex session catalog parameters must be valid JSON", { cause: error }); + } +} + +function readFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function parseOptionalCatalogString( + value: unknown, + field: string, + maxLength: number, +): string | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== "string" || value.length > maxLength) { + throw new Error(`Codex session catalog returned an invalid ${field}`); + } + return value; +} + +function parseCatalogSession( + value: unknown, + options: { allowOpenClawSessionKey?: boolean } = {}, +): CodexSessionCatalogSession { + if ( + !isRecord(value) || + typeof value.threadId !== "string" || + !value.threadId.trim() || + value.threadId.length > MAX_SESSION_ID_LENGTH || + value.archived !== false + ) { + throw new Error("Codex session catalog returned an invalid session"); + } + const status = parseOptionalCatalogString(value.status, "status", 64); + if (!status?.trim()) { + throw new Error("Codex session catalog returned an invalid status"); + } + if (value.activeFlags !== undefined && !Array.isArray(value.activeFlags)) { + throw new Error("Codex session catalog returned invalid active flags"); + } + if (Array.isArray(value.activeFlags) && value.activeFlags.length > MAX_ACTIVE_FLAGS) { + throw new Error("Codex session catalog returned too many active flags"); + } + const activeFlags = Array.isArray(value.activeFlags) + ? value.activeFlags.map((entry) => { + const flag = parseOptionalCatalogString(entry, "active flag", 128); + if (flag === undefined) { + throw new Error("Codex session catalog returned an invalid active flag"); + } + return flag; + }) + : undefined; + const sessionId = parseOptionalCatalogString( + value.sessionId, + "session id", + MAX_SESSION_ID_LENGTH, + ); + const name = + value.name === null + ? null + : parseOptionalCatalogString(value.name, "session name", MAX_SESSION_NAME_LENGTH); + const cwd = parseOptionalCatalogString(value.cwd, "cwd", MAX_CWD_LENGTH); + const source = parseOptionalCatalogString(value.source, "source", MAX_METADATA_LENGTH); + const modelProvider = parseOptionalCatalogString( + value.modelProvider, + "model provider", + MAX_METADATA_LENGTH, + ); + const cliVersion = parseOptionalCatalogString( + value.cliVersion, + "CLI version", + MAX_METADATA_LENGTH, + ); + const gitBranch = parseOptionalCatalogString(value.gitBranch, "Git branch", MAX_METADATA_LENGTH); + const openClawSessionKey = options.allowOpenClawSessionKey + ? parseOptionalCatalogString( + value.openClawSessionKey, + "OpenClaw session key", + MAX_SESSION_KEY_LENGTH, + ) + : undefined; + const createdAt = readFiniteNumber(value.createdAt); + const updatedAt = readFiniteNumber(value.updatedAt); + const recencyAt = value.recencyAt === null ? null : readFiniteNumber(value.recencyAt); + return { + threadId: value.threadId, + status, + archived: value.archived, + ...(sessionId !== undefined ? { sessionId } : {}), + ...(name !== undefined ? { name } : {}), + ...(cwd !== undefined ? { cwd } : {}), + ...(activeFlags && activeFlags.length > 0 ? { activeFlags } : {}), + ...(createdAt !== undefined ? { createdAt } : {}), + ...(updatedAt !== undefined ? { updatedAt } : {}), + ...(recencyAt !== undefined ? { recencyAt } : {}), + ...(source !== undefined ? { source } : {}), + ...(modelProvider !== undefined ? { modelProvider } : {}), + ...(cliVersion !== undefined ? { cliVersion } : {}), + ...(gitBranch !== undefined ? { gitBranch } : {}), + ...(openClawSessionKey !== undefined ? { openClawSessionKey } : {}), + }; +} + +function parseCatalogError(value: unknown): CodexSessionCatalogError | undefined { + if (!isRecord(value) || typeof value.code !== "string" || typeof value.message !== "string") { + return undefined; + } + const messages: Record = { + APP_SERVER_UNAVAILABLE: "Codex app-server is unavailable on this host", + NODE_INVOKE_FAILED: "The paired node could not return its Codex session catalog", + NODE_LIST_FAILED: "Paired nodes could not be listed", + NODE_OFFLINE: "Paired node is offline", + }; + return { + code: value.code in messages ? value.code : "CATALOG_FAILED", + message: messages[value.code] ?? "Codex session catalog request failed", + }; +} + +function parseCatalogPage( + value: unknown, + options: { allowOpenClawSessionKey?: boolean } = {}, +): CodexSessionCatalogPage { + if ( + !isRecord(value) || + !Array.isArray(value.sessions) || + value.sessions.length > CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT + ) { + throw new Error("Codex session catalog returned an invalid page"); + } + const nextCursor = parseOptionalCatalogString(value.nextCursor, "next cursor", MAX_CURSOR_LENGTH); + const backwardsCursor = parseOptionalCatalogString( + value.backwardsCursor, + "backwards cursor", + MAX_CURSOR_LENGTH, + ); + return { + sessions: value.sessions.map((session) => parseCatalogSession(session, options)), + ...(nextCursor ? { nextCursor } : {}), + ...(backwardsCursor ? { backwardsCursor } : {}), + }; +} + +function filterCatalogPageByTitle( + page: CodexSessionCatalogPage, + searchTerm: string | undefined, +): CodexSessionCatalogPage { + if (!searchTerm) { + return page; + } + return { + ...page, + sessions: page.sessions.filter((session) => + session.name?.toLocaleLowerCase().includes(searchTerm.toLocaleLowerCase()), + ), + }; +} + +function parseCatalogHost(value: unknown): CodexSessionCatalogHost { + if ( + !isRecord(value) || + typeof value.hostId !== "string" || + typeof value.label !== "string" || + (value.kind !== "gateway" && value.kind !== "node") || + typeof value.connected !== "boolean" || + !Array.isArray(value.sessions) + ) { + throw new Error("Codex session catalog returned an invalid host"); + } + const page = parseCatalogPage(value, { allowOpenClawSessionKey: value.kind === "gateway" }); + const error = parseCatalogError(value.error); + return { + hostId: value.hostId, + label: value.label, + kind: value.kind, + connected: value.connected, + sessions: page.sessions, + ...(typeof value.nodeId === "string" ? { nodeId: value.nodeId } : {}), + ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}), + ...(page.backwardsCursor ? { backwardsCursor: page.backwardsCursor } : {}), + ...(error ? { error } : {}), + }; +} + +/** Validates and strips unknown fields from a Gateway catalog response. */ +export function parseCodexSessionCatalogResult(value: unknown): CodexSessionCatalogResult { + if (!isRecord(value) || !Array.isArray(value.hosts) || value.hosts.length > MAX_HOST_COUNT) { + throw new Error("Codex session catalog returned an invalid result"); + } + return { hosts: value.hosts.map(parseCatalogHost) }; +} + +function unwrapNodeInvokePayload(value: unknown): unknown { + if (!isRecord(value)) { + return value; + } + if (typeof value.payloadJSON === "string" && value.payloadJSON.trim()) { + try { + return JSON.parse(value.payloadJSON) as unknown; + } catch (error) { + throw new Error("Codex node returned malformed session catalog JSON", { cause: error }); + } + } + return "payload" in value ? value.payload : value; +} + +function catalogError(code: string, _error: unknown): CodexSessionCatalogError { + const messages: Record = { + APP_SERVER_UNAVAILABLE: "Codex app-server is unavailable on this host", + NODE_INVOKE_FAILED: "The paired node could not return its Codex session catalog", + NODE_LIST_FAILED: "Paired nodes could not be listed", + }; + return { code, message: messages[code] ?? "Codex session catalog request failed" }; +} + +async function listGatewayHost(params: { + bindingStore: CodexAppServerBindingStore; + config?: OpenClawConfig; + control: CodexSessionCatalogControl; + query: CodexSessionCatalogParams; + runtime: PluginRuntime; +}): Promise { + try { + const page = parseCatalogPage( + await params.control.listPage({ + limit: params.query.limitPerHost, + ...(params.query.cursors?.[CODEX_LOCAL_SESSION_HOST_ID] + ? { cursor: params.query.cursors[CODEX_LOCAL_SESSION_HOST_ID] } + : {}), + ...(params.query.search ? { searchTerm: params.query.search } : {}), + }), + ); + const adoptedSessions = await listAdoptedSessionEntries({ + bindingStore: params.bindingStore, + config: params.config, + runtime: params.runtime, + }); + return { + hostId: CODEX_LOCAL_SESSION_HOST_ID, + label: "Local Codex", + kind: "gateway", + connected: true, + ...page, + sessions: page.sessions.map((session) => { + const adopted = adoptedSessions.get(session.threadId); + return adopted ? Object.assign({}, session, { openClawSessionKey: adopted.key }) : session; + }), + }; + } catch (error) { + return { + hostId: CODEX_LOCAL_SESSION_HOST_ID, + label: "Local Codex", + kind: "gateway", + connected: false, + sessions: [], + error: catalogError("APP_SERVER_UNAVAILABLE", error), + }; + } +} + +function nodeLabel(node: CatalogNode): string { + return node.displayName?.trim() || node.remoteIp?.trim() || node.nodeId; +} + +function compareNodeLabels(left: CatalogNode, right: CatalogNode): number { + const leftLabel = nodeLabel(left); + const rightLabel = nodeLabel(right); + if (leftLabel < rightLabel) { + return -1; + } + if (leftLabel > rightLabel) { + return 1; + } + return 0; +} + +async function listPairedNode(params: { + runtime: PluginRuntime; + node: CatalogNode; + query: CodexSessionCatalogParams; +}): Promise { + const hostId = `node:${params.node.nodeId}`; + const common = { + hostId, + label: nodeLabel(params.node), + kind: "node" as const, + nodeId: params.node.nodeId, + }; + if (params.node.connected !== true) { + return { + ...common, + connected: false, + sessions: [], + error: { code: "NODE_OFFLINE", message: "Paired node is offline" }, + }; + } + try { + const raw = await params.runtime.nodes.invoke({ + nodeId: params.node.nodeId, + command: CODEX_APP_SERVER_THREADS_LIST_COMMAND, + params: { + cursor: params.query.cursors?.[hostId], + limit: params.query.limitPerHost, + searchTerm: params.query.search, + }, + timeoutMs: NODE_INVOKE_TIMEOUT_MS, + }); + const page = filterCatalogPageByTitle( + parseCatalogPage(unwrapNodeInvokePayload(raw)), + params.query.search, + ); + return { + ...common, + connected: true, + ...page, + }; + } catch (error) { + return { + ...common, + connected: true, + sessions: [], + error: catalogError("NODE_INVOKE_FAILED", error), + }; + } +} + +/** Lists Gateway-local and paired-node Codex sessions with per-host failures. */ +export async function listCodexSessionCatalog(params: { + bindingStore: CodexAppServerBindingStore; + config?: OpenClawConfig; + runtime: PluginRuntime; + control: CodexSessionCatalogControl; + query?: CodexSessionCatalogParams; +}): Promise { + params.control.assertEnabled(); + const query = readGatewayParams(params.query); + const requestedHostIds = query.hostIds ? new Set(query.hostIds) : undefined; + const localHosts = + !requestedHostIds || requestedHostIds.has(CODEX_LOCAL_SESSION_HOST_ID) + ? [ + listGatewayHost({ + bindingStore: params.bindingStore, + config: params.config, + control: params.control, + query, + runtime: params.runtime, + }), + ] + : []; + const wantsNodes = + !requestedHostIds || query.hostIds?.some((hostId) => hostId.startsWith("node:")); + if (!wantsNodes) { + return { hosts: await Promise.all(localHosts) }; + } + let nodes: CatalogNode[]; + try { + nodes = (await params.runtime.nodes.list()).nodes + .filter( + (node) => + node.commands?.includes(CODEX_APP_SERVER_THREADS_LIST_COMMAND) && + (!requestedHostIds || requestedHostIds.has(`node:${node.nodeId}`)), + ) + .slice(0, MAX_HOST_COUNT - localHosts.length); + } catch (error) { + return { + hosts: [ + ...(await Promise.all(localHosts)), + { + hostId: "node:registry", + label: "Paired nodes", + kind: "node", + connected: false, + sessions: [], + error: catalogError("NODE_LIST_FAILED", error), + }, + ], + }; + } + const nodeHosts = nodes + .toSorted(compareNodeLabels) + .map((node) => listPairedNode({ runtime: params.runtime, node, query })); + return { hosts: await Promise.all([...localHosts, ...nodeHosts]) }; +} + +/** Builds the node-local read-only Codex app-server catalog command. */ +export function createCodexSessionCatalogNodeHostCommands( + control: CodexSessionCatalogControl, +): OpenClawPluginNodeHostCommand[] { + return [ + { + command: CODEX_APP_SERVER_THREADS_LIST_COMMAND, + cap: CODEX_APP_SERVER_THREADS_CAPABILITY, + dangerous: false, + handle: async (paramsJSON) => { + const pageParams = readPageParams(parseJsonParams(paramsJSON)); + try { + const page = filterCatalogPageByTitle( + parseCatalogPage(await control.listPage(pageParams)), + pageParams.searchTerm, + ); + return JSON.stringify(page); + } catch { + // App-server stderr and transport details stay on the node boundary. + throw new Error("Codex app-server catalog is unavailable"); + } + }, + }, + ]; +} + +type CodexSessionActionParams = { + hostId: string; + threadId: string; +}; + +function readActionParams( + value: unknown, + options: { archive?: boolean } = {}, +): CodexSessionActionParams { + if (!isRecord(value)) { + throw new CatalogParamsError("Codex session action parameters must be an object"); + } + requireOnlyKeys( + value, + new Set( + options.archive ? ["hostId", "threadId", "confirmNoOtherRunner"] : ["hostId", "threadId"], + ), + ); + const hostId = readHostId(value.hostId); + const threadId = readOptionalString(value, "threadId", MAX_SESSION_ID_LENGTH); + if (!threadId) { + throw new CatalogParamsError("threadId is required"); + } + if (hostId !== CODEX_LOCAL_SESSION_HOST_ID) { + throw new CatalogParamsError("paired-node Codex sessions are view-only"); + } + if (options.archive && value.confirmNoOtherRunner !== true) { + throw new CatalogParamsError( + "confirmNoOtherRunner=true is required because Codex client and runner activity is process-local", + ); + } + return { hostId, threadId }; +} + +function requireIdleThread(thread: CodexThread, action: "continue" | "archive"): void { + if ( + thread.status?.type === "idle" || + (action === "archive" && thread.status?.type === "notLoaded") + ) { + return; + } + if (thread.status?.type === "active") { + throw new CatalogParamsError( + `Codex session is active in this App Server; wait for it to finish before ${action === "continue" ? "starting a branch" : "archiving"}`, + ); + } + throw new CatalogParamsError( + action === "archive" + ? "Codex session cannot be archived in its current state" + : "Codex session cannot start a branch in its current state", + ); +} + +async function requireCatalogEligibleThread( + control: CodexSessionCatalogControl, + threadId: string, +): Promise { + let cursor: string | undefined; + const seenCursors = new Set(); + for (let pageIndex = 0; pageIndex < MAX_ACTION_CATALOG_PAGES; pageIndex += 1) { + const page = await control.listPage({ + limit: CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT, + ...(cursor ? { cursor } : {}), + }); + const candidate = page.sessions.find((session) => session.threadId === threadId); + if (candidate) { + if (candidate.source === "cli" || candidate.source === "vscode") { + return; + } + throw new CatalogParamsError( + "Codex session is not a non-archived interactive CLI or VS Code session", + ); + } + const nextCursor = page.nextCursor?.trim(); + if (!nextCursor) { + throw new CatalogParamsError( + "Codex session is not a non-archived interactive CLI or VS Code session", + ); + } + if (seenCursors.has(nextCursor)) { + throw new CatalogParamsError("Codex session eligibility could not be verified"); + } + seenCursors.add(nextCursor); + cursor = nextCursor; + } + throw new CatalogParamsError("Codex session eligibility could not be verified"); +} + +function adoptionSessionKey(threadId: string): string { + const digest = createHash("sha256").update(threadId).digest("hex"); + return `${CODEX_SUPERVISION_SESSION_KEY_PREFIX}${digest}`; +} + +// Session creation persists this plugin-owned suffix under an agent-qualified key. +// Restart discovery must compare the parsed suffix, not the returned canonical key. +function adoptionSessionKeyRest(sessionKey: string): string { + const trimmed = sessionKey.trim(); + return parseAgentSessionKey(trimmed)?.rest ?? trimmed; +} + +function isAdoptionSessionKeyForThread(sessionKey: string, threadId: string): boolean { + return adoptionSessionKeyRest(sessionKey) === adoptionSessionKey(threadId); +} + +type CodexSessionDisposition = "existing" | "forked"; + +type CodexSupervisionMarker = { sourceThreadId: string }; + +type AdoptedSessionEntry = { + key: string; + sessionId: string; +}; + +function listSupervisionAgentIds(config: OpenClawConfig): string[] { + const defaultAgentId = resolveDefaultAgentId(config); + return [defaultAgentId, ...listAgentIds(config).filter((agentId) => agentId !== defaultAgentId)]; +} + +async function listAdoptedSessionEntries(params: { + bindingStore: CodexAppServerBindingStore; + config?: OpenClawConfig; + runtime: PluginRuntime; +}): Promise> { + const adopted = new Map(); + for (const { entry, sessionKey } of listSupervisionAgentIds(params.config ?? {}).flatMap( + (agentId) => params.runtime.agent.session.listSessionEntries({ agentId }), + )) { + const sessionKeyRest = adoptionSessionKeyRest(sessionKey); + if ( + !sessionKeyRest.startsWith(CODEX_SUPERVISION_SESSION_KEY_PREFIX) || + entry.initializationPending === true || + entry.agentHarnessId !== "codex" || + entry.modelSelectionLocked !== true + ) { + continue; + } + const sessionId = entry.sessionId?.trim(); + if (!sessionId) { + continue; + } + const binding = await params.bindingStore.read( + sessionBindingIdentity({ sessionId, sessionKey, config: params.config }), + ); + const sourceThreadId = binding?.supervisionSourceThreadId?.trim(); + if ( + binding?.connectionScope !== "supervision" || + !sourceThreadId || + sessionKeyRest !== adoptionSessionKey(sourceThreadId) + ) { + continue; + } + if (adopted.has(sourceThreadId)) { + throw new Error(`multiple OpenClaw sessions adopt Codex thread ${sourceThreadId}`); + } + adopted.set(sourceThreadId, { key: sessionKey, sessionId }); + } + return adopted; +} + +async function findAdoptedSessionEntry(params: { + bindingStore: CodexAppServerBindingStore; + config: OpenClawConfig; + runtime: PluginRuntime; + threadId: string; +}): Promise { + return (await listAdoptedSessionEntries(params)).get(params.threadId); +} + +class CodexAdoptionBindingCleanupError extends AggregateError {} + +async function clearCreatedAdoptionBinding(params: { + bindingStore: CodexAppServerBindingStore; + identity: ReturnType; + sourceThreadId: string; + expectedPending: CodexAppServerPendingSupervisionBranch; + cause: unknown; +}): Promise { + let cleared = false; + let clearError: unknown; + try { + cleared = await params.bindingStore.mutate(params.identity, { + kind: "clear", + threadId: params.sourceThreadId, + expectedPendingSupervisionBranch: params.expectedPending, + }); + } catch (error) { + clearError = error; + } + if (cleared) { + return; + } + + let current: CodexAppServerThreadBinding | undefined; + try { + current = await params.bindingStore.read(params.identity); + } catch (readError) { + throw new CodexAdoptionBindingCleanupError( + [params.cause, ...(clearError ? [clearError] : []), readError], + `OpenClaw session creation failed and the Codex binding could not be verified for ${params.sourceThreadId}`, + ); + } + // Pending state is the cleanup CAS token. Once lifecycle work changes it, + // that successor owns every tracked native artifact and must survive here. + if (!matchesPendingSupervisionOwner(current, params.expectedPending)) { + return; + } + throw new CodexAdoptionBindingCleanupError( + [params.cause, ...(clearError ? [clearError] : [])], + `OpenClaw session creation failed and the Codex binding could not be cleared for ${params.sourceThreadId}`, + ); +} + +function lastTerminalTurnId(thread: CodexThread): string | undefined { + for (let index = (thread.turns?.length ?? 0) - 1; index >= 0; index -= 1) { + const turn = thread.turns?.[index]; + const turnId = boundedCatalogString(turn?.id, MAX_SESSION_ID_LENGTH); + if (!turnId) { + continue; + } + if ( + turn?.status === "completed" || + turn?.status === "interrupted" || + turn?.status === "failed" + ) { + return turnId; + } + } + return undefined; +} + +function matchesPendingAdoptionBinding( + binding: CodexAppServerThreadBinding | undefined, + expected: { + sourceThreadId: string; + connectionFingerprint: string; + cwd: string; + lastTurnId?: string; + }, +): boolean { + const historyCoveredThrough = binding?.historyCoveredThrough; + return ( + binding?.threadId === expected.sourceThreadId && + binding.connectionScope === "supervision" && + binding.supervisionSourceThreadId === expected.sourceThreadId && + binding.cwd === expected.cwd && + binding.conversationSourceTransferComplete === true && + binding.preserveNativeModel === true && + binding.pendingSupervisionBranch?.sourceThreadId === expected.sourceThreadId && + binding.pendingSupervisionBranch.connectionFingerprint === expected.connectionFingerprint && + binding.pendingSupervisionBranch.lastTurnId === expected.lastTurnId && + (binding.pendingSupervisionBranch.cleanupThreadIds?.length ?? 0) === 0 && + typeof historyCoveredThrough === "string" && + Number.isFinite(Date.parse(historyCoveredThrough)) + ); +} + +function matchesPendingSupervisionOwner( + binding: CodexAppServerThreadBinding | undefined, + expected: CodexAppServerPendingSupervisionBranch, +): boolean { + const pending = binding?.pendingSupervisionBranch; + const cleanupThreadIds = pending?.cleanupThreadIds ?? []; + const expectedCleanupThreadIds = expected.cleanupThreadIds ?? []; + return ( + binding?.threadId === expected.sourceThreadId && + binding.connectionScope === "supervision" && + binding.supervisionSourceThreadId === expected.sourceThreadId && + pending?.sourceThreadId === expected.sourceThreadId && + pending.connectionFingerprint === expected.connectionFingerprint && + pending.lastTurnId === expected.lastTurnId && + cleanupThreadIds.length === expectedCleanupThreadIds.length && + cleanupThreadIds.every((threadId, index) => threadId === expectedCleanupThreadIds[index]) + ); +} + +async function ensurePendingAdoptionBinding(params: { + bindingStore: CodexAppServerBindingStore; + config: OpenClawConfig; + identity: ReturnType; + sourceThreadId: string; + connectionFingerprint: string; + cwd: string; + lastTurnId?: string; +}): Promise { + const pending: CodexAppServerPendingSupervisionBranch = { + sourceThreadId: params.sourceThreadId, + connectionFingerprint: params.connectionFingerprint, + ...(params.lastTurnId ? { lastTurnId: params.lastTurnId } : {}), + }; + const ownsGeneration = await reclaimCurrentCodexSessionGeneration({ + bindingStore: params.bindingStore, + identity: params.identity, + config: params.config, + }); + if (!ownsGeneration) { + throw new Error(`failed to claim the OpenClaw session generation for ${params.sourceThreadId}`); + } + const existing = await params.bindingStore.read(params.identity); + if (existing) { + if (matchesPendingAdoptionBinding(existing, params)) { + return; + } + throw new Error(`OpenClaw session is already bound to Codex thread ${existing.threadId}`); + } + const binding = { + threadId: params.sourceThreadId, + connectionScope: "supervision" as const, + supervisionSourceThreadId: params.sourceThreadId, + cwd: params.cwd, + historyCoveredThrough: new Date().toISOString(), + conversationSourceTransferComplete: true as const, + preserveNativeModel: true as const, + pendingSupervisionBranch: pending, + }; + let stored: boolean; + try { + stored = await params.bindingStore.mutate(params.identity, { + kind: "set", + if: { kind: "absent" }, + binding, + }); + } catch (error) { + const committed = await params.bindingStore.read(params.identity); + if (matchesPendingAdoptionBinding(committed, params)) { + return; + } + throw error; + } + if (stored) { + return; + } + const raced = await params.bindingStore.read(params.identity); + if (!matchesPendingAdoptionBinding(raced, params)) { + throw new Error(`failed to bind OpenClaw session to Codex thread ${params.sourceThreadId}`); + } +} + +async function createOrReuseAdoptedSession(params: { + api: OpenClawPluginApi; + bindingStore: CodexAppServerBindingStore; + config: OpenClawConfig; + sourceThread: CodexThread; + connectionFingerprint: string; +}): Promise { + const existing = await findAdoptedSessionEntry({ + bindingStore: params.bindingStore, + config: params.config, + runtime: params.api.runtime, + threadId: params.sourceThread.id, + }); + if (existing) { + return existing; + } + let createdBindingIdentity: ReturnType | undefined; + let createdPendingBinding: CodexAppServerPendingSupervisionBranch | undefined; + try { + const label = params.sourceThread.name?.trim() || undefined; + const spawnedCwd = params.sourceThread.cwd?.trim() || undefined; + const pendingLastTurnId = lastTerminalTurnId(params.sourceThread); + const marker: CodexSupervisionMarker = { sourceThreadId: params.sourceThread.id }; + const created = await params.api.runtime.agent.session.createSessionEntry({ + cfg: params.config, + key: adoptionSessionKey(params.sourceThread.id), + agentId: resolveDefaultAgentId(params.config), + recoverMatchingInitialEntry: true, + ...(label ? { label } : {}), + ...(spawnedCwd ? { spawnedCwd } : {}), + initialEntry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { + supervision: { + ...marker, + initializing: true, + modelLocked: true, + }, + }, + }, + }, + afterCreate: async (entry) => { + createdBindingIdentity = sessionBindingIdentity({ + sessionId: entry.sessionId, + sessionKey: entry.key, + config: params.config, + }); + const sessionFile = entry.entry.sessionFile?.trim(); + if (!sessionFile) { + throw new Error("Codex supervision session creation did not produce a transcript file"); + } + await importCodexThreadHistoryToTranscript({ + thread: params.sourceThread, + throughTurnId: pendingLastTurnId ?? null, + sessionFile, + sessionId: entry.sessionId, + sessionKey: entry.key, + agentId: entry.agentId, + ...(spawnedCwd ? { cwd: spawnedCwd } : {}), + modelProvider: params.sourceThread.modelProvider, + config: params.config, + }); + createdPendingBinding = { + sourceThreadId: params.sourceThread.id, + connectionFingerprint: params.connectionFingerprint, + ...(pendingLastTurnId ? { lastTurnId: pendingLastTurnId } : {}), + }; + await ensurePendingAdoptionBinding({ + bindingStore: params.bindingStore, + config: params.config, + identity: createdBindingIdentity, + sourceThreadId: params.sourceThread.id, + connectionFingerprint: params.connectionFingerprint, + cwd: spawnedCwd ?? "", + ...(pendingLastTurnId ? { lastTurnId: pendingLastTurnId } : {}), + }); + return { + pluginExtensions: { + codex: { + supervision: { ...marker, modelLocked: true }, + }, + }, + }; + }, + }); + return { key: created.key, sessionId: created.sessionId }; + } catch (error) { + // Concurrent/retried Continue calls converge on the same trusted marker. + // An unrelated entry at the deterministic key is never overwritten. + let raced = await findAdoptedSessionEntry({ + bindingStore: params.bindingStore, + config: params.config, + runtime: params.api.runtime, + threadId: params.sourceThread.id, + }); + if (raced) { + return raced; + } + if (createdBindingIdentity && createdPendingBinding) { + await clearCreatedAdoptionBinding({ + bindingStore: params.bindingStore, + identity: createdBindingIdentity, + sourceThreadId: params.sourceThread.id, + expectedPending: createdPendingBinding, + cause: error, + }); + raced = await findAdoptedSessionEntry({ + bindingStore: params.bindingStore, + config: params.config, + runtime: params.api.runtime, + threadId: params.sourceThread.id, + }); + if (raced) { + return raced; + } + } + throw error; + } +} + +const continueOperations = new Map< + string, + Promise<{ sessionKey: string; disposition: CodexSessionDisposition }> +>(); +const sessionActionTails = new Map>(); + +async function runSessionActionExclusive(threadId: string, run: () => Promise): Promise { + const previous = sessionActionTails.get(threadId) ?? Promise.resolve(); + const operation = previous.then(run); + const tail = operation.then( + () => undefined, + () => undefined, + ); + sessionActionTails.set(threadId, tail); + try { + return await operation; + } finally { + if (sessionActionTails.get(threadId) === tail) { + sessionActionTails.delete(threadId); + } + } +} + +async function continueLocalCodexSessionInner(params: { + api: OpenClawPluginApi; + bindingStore: CodexAppServerBindingStore; + config: OpenClawConfig; + control: CodexSessionCatalogControl; + threadId: string; +}): Promise<{ sessionKey: string; disposition: CodexSessionDisposition }> { + await requireCatalogEligibleThread(params.control, params.threadId); + const existing = await findAdoptedSessionEntry({ + bindingStore: params.bindingStore, + config: params.config, + runtime: params.api.runtime, + threadId: params.threadId, + }); + if (existing) { + const sourceThread = await params.control.readThread(params.threadId, false); + if (sourceThread.id !== params.threadId) { + throw new Error("Codex app-server returned a different thread than requested"); + } + params.control.assertEnabled(); + // Catalog state can race archive/reset. Restore only the same locked generation + // under the session-store write lock so a stale Open Chat cannot revive a replacement. + const changedError = () => + new CatalogParamsError("Codex OpenClaw session changed before it could be opened. Retry."); + const restored = await params.api.runtime.agent.session.patchSessionEntry({ + sessionKey: existing.key, + readConsistency: "latest", + preserveActivity: true, + update: (entry) => { + if ( + entry.sessionId?.trim() !== existing.sessionId || + entry.initializationPending === true || + entry.agentHarnessId !== "codex" || + entry.modelSelectionLocked !== true + ) { + throw changedError(); + } + return { archivedAt: undefined }; + }, + }); + if (!restored) { + throw changedError(); + } + return { sessionKey: existing.key, disposition: "existing" }; + } + + const sourceThread = await params.control.readThread(params.threadId, true); + if (sourceThread.id !== params.threadId) { + throw new Error("Codex app-server returned a different thread than requested"); + } + if (sourceThread.status?.type !== "notLoaded") { + requireIdleThread(sourceThread, "continue"); + } + params.control.assertEnabled(); + const connectionFingerprint = params.control.connectionFingerprint; + if (!connectionFingerprint) { + throw new Error("Codex Continue requires a pinned app-server connection"); + } + const adopted = await createOrReuseAdoptedSession({ + api: params.api, + bindingStore: params.bindingStore, + config: params.config, + sourceThread, + connectionFingerprint, + }); + return { sessionKey: adopted.key, disposition: "forked" }; +} + +/** Creates one locked OpenClaw branch whose first harness run forks the Codex source. */ +export async function continueLocalCodexSession(params: { + api: OpenClawPluginApi; + bindingStore: CodexAppServerBindingStore; + config: OpenClawConfig; + control: CodexSessionCatalogControl; + threadId: string; +}): Promise<{ sessionKey: string; disposition: CodexSessionDisposition }> { + params.control.assertEnabled(); + const current = continueOperations.get(params.threadId); + if (current) { + return await current; + } + const operation = runSessionActionExclusive(params.threadId, async () => + params.control.withPinnedConnection(async (control) => + continueLocalCodexSessionInner({ ...params, control }), + ), + ); + continueOperations.set(params.threadId, operation); + try { + return await operation; + } finally { + if (continueOperations.get(params.threadId) === operation) { + continueOperations.delete(params.threadId); + } + } +} + +async function assertNoPendingSupervisionBranch(params: { + bindingStore: CodexAppServerBindingStore; + config: OpenClawConfig; + runtime: PluginRuntime; + threadId: string; +}): Promise { + const adoptedEntries = listSupervisionAgentIds(params.config) + .flatMap((agentId) => params.runtime.agent.session.listSessionEntries({ agentId })) + .filter((candidate) => isAdoptionSessionKeyForThread(candidate.sessionKey, params.threadId)); + for (const adopted of adoptedEntries) { + if (adopted.entry.initializationPending === true) { + throw new CatalogParamsError( + "Codex session cannot be archived while its OpenClaw branch is initializing", + ); + } + const sessionId = adopted.entry.sessionId?.trim(); + if (!sessionId) { + continue; + } + const binding = await params.bindingStore.read( + sessionBindingIdentity({ + sessionId, + sessionKey: adopted.sessionKey, + config: params.config, + }), + ); + if ( + binding?.connectionScope === "supervision" && + binding.supervisionSourceThreadId === params.threadId && + binding.pendingSupervisionBranch?.sourceThreadId === params.threadId + ) { + throw new CatalogParamsError( + "Codex session cannot be archived until its OpenClaw branch starts", + ); + } + } +} + +/** Archives one inactive Gateway-local Codex thread after a fresh status read. */ +export async function archiveLocalCodexSession(params: { + bindingStore: CodexAppServerBindingStore; + config: OpenClawConfig; + control: CodexSessionCatalogControl; + runtime: PluginRuntime; + threadId: string; +}): Promise<{ archived: true }> { + params.control.assertEnabled(); + return await runSessionActionExclusive(params.threadId, async () => { + return await params.bindingStore.withThreadArchiveFence(async () => { + return await params.control.withPinnedConnection(async (control) => { + await requireCatalogEligibleThread(control, params.threadId); + await assertNoPendingSupervisionBranch(params); + const thread = await control.readThread(params.threadId, false); + if (thread.id !== params.threadId) { + throw new Error("Codex app-server returned a different thread than requested"); + } + requireIdleThread(thread, "archive"); + if (await params.bindingStore.hasOtherThreadOwner(params.threadId)) { + throw new CatalogParamsError( + "Codex session cannot be archived while it is attached to an OpenClaw session", + ); + } + await assertCodexArchiveDescendantsUnowned({ + bindingStore: params.bindingStore, + threadId: params.threadId, + listPage: (request) => control.listDescendantPage(request), + assertDescendantIdle: async (descendantThreadId) => { + const descendant = await control.readThread(descendantThreadId, false); + if (descendant.id !== descendantThreadId) { + throw new Error("Codex app-server returned a different descendant than requested"); + } + requireIdleThread(descendant, "archive"); + }, + }); + await control.archiveThread(params.threadId); + return { archived: true }; + }); + }); + }); +} + +/** Allows the metadata-only catalog command on supported paired-node platforms. */ +export function createCodexSessionCatalogNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] { + return [ + { + commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND], + defaultPlatforms: ["macos", "linux", "windows"], + handle: (context) => context.invokeNode(), + }, + ]; +} + +/** Registers the Control UI descriptor and host-grouped Gateway catalog method. */ +export function registerCodexSessionCatalogGateway(params: { + api: OpenClawPluginApi; + bindingStore: CodexAppServerBindingStore; + control: CodexSessionCatalogControl; + getRuntimeConfig: () => OpenClawConfig | undefined; +}): void { + params.api.session.controls.registerControlUiDescriptor({ + surface: "tab", + id: "sessions", + label: "Codex Sessions", + description: "Codex sessions on this Gateway and paired nodes.", + icon: "terminal", + group: "control", + requiredScopes: ["operator.write"], + }); + params.api.registerGatewayMethod( + CODEX_SESSION_CATALOG_METHOD, + async ({ params: requestParams, respond }: GatewayRequestHandlerOptions) => { + try { + respond( + true, + await listCodexSessionCatalog({ + bindingStore: params.bindingStore, + config: params.getRuntimeConfig(), + runtime: params.api.runtime, + control: params.control, + query: readGatewayParams(requestParams), + }), + ); + } catch (error) { + if (error instanceof CatalogParamsError) { + respond( + false, + { error: error.message }, + errorShape(ErrorCodes.INVALID_REQUEST, error.message), + ); + return; + } + const message = "Codex session catalog request failed"; + respond(false, { error: message }, errorShape(ErrorCodes.UNAVAILABLE, message)); + } + }, + // Core node.invoke is a write-scoped method even for read-only plugin commands. + { scope: "operator.write" }, + ); + params.api.registerGatewayMethod( + CODEX_SESSION_CONTINUE_METHOD, + async ({ params: requestParams, respond }: GatewayRequestHandlerOptions) => { + try { + const action = readActionParams(requestParams); + const config = params.getRuntimeConfig(); + if (!config) { + throw new Error("OpenClaw runtime config is unavailable"); + } + respond( + true, + await continueLocalCodexSession({ + api: params.api, + bindingStore: params.bindingStore, + config, + control: params.control, + threadId: action.threadId, + }), + ); + } catch (error) { + if (error instanceof CatalogParamsError) { + respond( + false, + { error: error.message }, + errorShape(ErrorCodes.INVALID_REQUEST, error.message), + ); + return; + } + const message = "Codex session could not be continued"; + respond(false, { error: message }, errorShape(ErrorCodes.UNAVAILABLE, message)); + } + }, + { scope: "operator.write" }, + ); + params.api.registerGatewayMethod( + CODEX_SESSION_ARCHIVE_METHOD, + async ({ params: requestParams, respond }: GatewayRequestHandlerOptions) => { + try { + const action = readActionParams(requestParams, { archive: true }); + const config = params.getRuntimeConfig(); + if (!config) { + throw new Error("OpenClaw runtime config is unavailable"); + } + respond( + true, + await archiveLocalCodexSession({ + bindingStore: params.bindingStore, + config, + control: params.control, + runtime: params.api.runtime, + threadId: action.threadId, + }), + ); + } catch (error) { + if (error instanceof CatalogParamsError) { + respond( + false, + { error: error.message }, + errorShape(ErrorCodes.INVALID_REQUEST, error.message), + ); + return; + } + const message = "Codex session could not be archived"; + respond(false, { error: message }, errorShape(ErrorCodes.UNAVAILABLE, message)); + } + }, + { scope: "operator.write" }, + ); +} diff --git a/extensions/codex/src/session-cli.test.ts b/extensions/codex/src/session-cli.test.ts new file mode 100644 index 000000000000..a04e78c83a36 --- /dev/null +++ b/extensions/codex/src/session-cli.test.ts @@ -0,0 +1,403 @@ +// Codex session CLI tests cover passive catalog output and local supervision actions. +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { registerCodexSessionCli } from "./session-cli.js"; + +const gatewayRuntime = vi.hoisted(() => ({ + callGatewayFromCli: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/gateway-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/gateway-runtime", + ); + return { + ...actual, + callGatewayFromCli: gatewayRuntime.callGatewayFromCli, + }; +}); + +const catalog = { + hosts: [ + { + hostId: "gateway:local", + label: "MacBook Pro", + kind: "gateway", + connected: true, + endpointId: "local", + sessions: [ + { + threadId: "00000000-0000-4000-8000-000000000002", + name: "Build Codex fleet sessions", + cwd: "/Users/test/Projects/openclaw", + status: "idle", + activeFlags: [], + updatedAt: 1_788_805_800, + recencyAt: 1_788_805_800, + source: "vscode", + modelProvider: "openai", + gitBranch: "codex/codex-session-fleet", + archived: false, + }, + ], + nextCursor: "gateway-next", + }, + { + hostId: "node:devbox", + label: "Dev Box", + kind: "node", + connected: false, + nodeId: "devbox", + sessions: [], + error: { code: "NODE_OFFLINE", message: "Paired node is offline" }, + }, + ], +}; + +function createProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeErr: () => {}, writeOut: () => {} }); + registerCodexSessionCli(program); + return program; +} + +async function captureStdout(run: () => Promise): Promise { + const chunks: string[] = []; + const write = vi.spyOn(process.stdout, "write").mockImplementation((chunk): boolean => { + chunks.push(String(chunk)); + return true; + }); + try { + await run(); + return chunks.join(""); + } finally { + write.mockRestore(); + } +} + +describe("registerCodexSessionCli", () => { + beforeEach(() => { + gatewayRuntime.callGatewayFromCli.mockReset(); + gatewayRuntime.callGatewayFromCli.mockResolvedValue(catalog); + }); + + describe("sessions", () => { + it("maps non-archived filters and a host cursor to the catalog Gateway method", async () => { + const program = createProgram(); + const output = await captureStdout(async () => { + await program.parseAsync( + [ + "codex", + "sessions", + "--search", + " openclaw ", + "--host", + "node:devbox", + "--limit", + "25", + "--cursor", + "node-next", + "--url", + "ws://gateway.test", + "--token", + "secret", + "--timeout", + "1234", + "--json", + ], + { from: "user" }, + ); + }); + + expect(gatewayRuntime.callGatewayFromCli).toHaveBeenCalledWith( + "codex.sessions.list", + { + url: "ws://gateway.test", + token: "secret", + timeout: "1234", + json: true, + }, + { + search: "openclaw", + limitPerHost: 25, + hostIds: ["node:devbox"], + cursors: { "node:devbox": "node-next" }, + }, + { mode: "cli", scopes: ["operator.write"] }, + ); + expect(JSON.parse(output)).toEqual({ hosts: [catalog.hosts[1]] }); + }); + + it("renders connected sessions, metadata, pagination, and offline host errors", async () => { + const program = createProgram(); + const output = await captureStdout(async () => { + await program.parseAsync(["codex", "sessions"], { from: "user" }); + }); + + expect(output).toContain("MacBook Pro (gateway · gateway:local) — connected — 1 session"); + expect(output).toContain("00000000-0000-4000-8000-000000000002"); + expect(output).toContain("Build Codex fleet sessions"); + expect(output).toContain("/Users/test/Projects/openclaw"); + expect(output).toContain("branch codex/codex-session-fleet"); + expect(output).toContain("source vscode"); + expect(output).toContain("provider openai"); + expect(output).toContain( + "repeat the same filters with --host 'gateway:local' --cursor 'gateway-next'", + ); + expect(output).toContain("Dev Box (node · node:devbox · devbox) — offline — 0 sessions"); + expect(output).toContain("Error [NODE_OFFLINE]: Paired node is offline"); + }); + + it("neutralizes terminal controls in human-readable host and session metadata", async () => { + const program = createProgram(); + gatewayRuntime.callGatewayFromCli.mockResolvedValueOnce({ + hosts: [ + { + hostId: "gateway:local\u001b", + label: "Mac\u001b[31m\nBook", + kind: "gateway", + connected: true, + sessions: [ + { + threadId: "thread\u001b[2J", + name: "Fleet\u0007\nSession", + cwd: "/tmp/work\u001b]0;owned\u0007", + status: "idle\u001b", + activeFlags: ["turn\u001b"], + gitBranch: "main\u0000branch", + archived: false, + }, + ], + error: { code: "WARN\u001b", message: "first\nsecond\u0007" }, + nextCursor: "next\u001b[2J", + }, + ], + }); + + const output = await captureStdout(async () => { + await program.parseAsync(["codex", "sessions"], { from: "user" }); + }); + + expect(output).not.toContain("\u001b"); + expect(output).not.toContain("\u0007"); + expect(output).not.toContain("\u0000"); + expect(output).toContain("Fleet\\nSession"); + expect(output).toContain("mainbranch"); + expect(output).toContain("Error [CATALOG_FAILED]: Codex session catalog request failed"); + }); + + it("reports empty catalog and unmatched host results", async () => { + gatewayRuntime.callGatewayFromCli.mockResolvedValue({ hosts: [] }); + + const emptyOutput = await captureStdout(async () => { + await createProgram().parseAsync(["codex", "sessions"], { from: "user" }); + }); + const hostOutput = await captureStdout(async () => { + await createProgram().parseAsync(["codex", "sessions", "--host", "node:missing"], { + from: "user", + }); + }); + + expect(emptyOutput).toBe("No Codex session hosts found.\n"); + expect(hostOutput).toBe('No Codex session host matched "node:missing".\n'); + }); + + it("rejects an archived-session filter and unroutable cursor before calling the Gateway", async () => { + await expect( + createProgram().parseAsync(["codex", "sessions", "--archived"], { from: "user" }), + ).rejects.toThrow("unknown option '--archived'"); + await expect( + createProgram().parseAsync(["codex", "sessions", "--cursor", "next"], { from: "user" }), + ).rejects.toThrow("--cursor requires --host"); + + expect(gatewayRuntime.callGatewayFromCli).not.toHaveBeenCalled(); + }); + + it("rejects invalid per-host limits before calling the Gateway", async () => { + await expect( + createProgram().parseAsync(["codex", "sessions", "--limit", "1.5"], { from: "user" }), + ).rejects.toThrow("--limit must be an integer between 1 and 100"); + await expect( + createProgram().parseAsync(["codex", "sessions", "--limit", "101"], { from: "user" }), + ).rejects.toThrow("--limit must be an integer between 1 and 100"); + + expect(gatewayRuntime.callGatewayFromCli).not.toHaveBeenCalled(); + }); + + it("rejects malformed catalog responses", async () => { + gatewayRuntime.callGatewayFromCli.mockResolvedValueOnce({ hosts: null }); + + await expect( + createProgram().parseAsync(["codex", "sessions"], { from: "user" }), + ).rejects.toThrow("Codex session catalog returned an invalid result"); + }); + + it("rejects archived sessions from a catalog response", async () => { + gatewayRuntime.callGatewayFromCli.mockResolvedValueOnce({ + hosts: [ + { + hostId: "gateway:local", + label: "Local Codex", + kind: "gateway", + connected: true, + sessions: [{ threadId: "archived-thread", status: "notLoaded", archived: true }], + }, + ], + }); + + await expect( + createProgram().parseAsync(["codex", "sessions"], { from: "user" }), + ).rejects.toThrow("Codex session catalog returned an invalid session"); + }); + }); + + describe("continue", () => { + it("continues a Gateway-local thread and sanitizes the human-readable session key", async () => { + gatewayRuntime.callGatewayFromCli.mockResolvedValueOnce({ + sessionKey: "harness:codex:supervision:branch\u001b[2J", + disposition: "forked", + }); + + const output = await captureStdout(async () => { + await createProgram().parseAsync( + [ + "codex", + "continue", + "thread-1", + "--url", + "ws://gateway.test", + "--token", + "secret", + "--timeout", + "4321", + ], + { from: "user" }, + ); + }); + + expect(gatewayRuntime.callGatewayFromCli).toHaveBeenCalledWith( + "codex.sessions.continue", + { + url: "ws://gateway.test", + token: "secret", + timeout: "4321", + json: false, + }, + { hostId: "gateway:local", threadId: "thread-1" }, + { mode: "cli", scopes: ["operator.write"] }, + ); + expect(output).toBe("OpenClaw session (branch created): harness:codex:supervision:branch\n"); + expect(output).not.toContain("\u001b"); + }); + + it("prints existing session results as JSON", async () => { + gatewayRuntime.callGatewayFromCli.mockResolvedValueOnce({ + sessionKey: "harness:codex:supervision:existing", + disposition: "existing", + ignored: true, + }); + + const output = await captureStdout(async () => { + await createProgram().parseAsync(["codex", "continue", "thread-2", "--json"], { + from: "user", + }); + }); + + expect(JSON.parse(output)).toEqual({ + sessionKey: "harness:codex:supervision:existing", + disposition: "existing", + }); + }); + + it.each([ + [{ disposition: "forked" }, "invalid session key"], + [{ sessionKey: " ", disposition: "forked" }, "invalid session key"], + [{ sessionKey: "session", disposition: "new" }, "invalid disposition"], + ])("rejects invalid Gateway results %#", async (result, message) => { + gatewayRuntime.callGatewayFromCli.mockResolvedValueOnce(result); + + await expect( + createProgram().parseAsync(["codex", "continue", "thread-1"], { from: "user" }), + ).rejects.toThrow(message); + }); + + it("rejects an empty thread id before calling the Gateway", async () => { + await expect( + createProgram().parseAsync(["codex", "continue", " "], { from: "user" }), + ).rejects.toThrow("Codex thread id must not be empty"); + + expect(gatewayRuntime.callGatewayFromCli).not.toHaveBeenCalled(); + }); + }); + + describe("archive", () => { + it("requires explicit confirmation before calling the Gateway", async () => { + await expect( + createProgram().parseAsync(["codex", "archive", "thread-1"], { from: "user" }), + ).rejects.toThrow("--confirm-no-other-runner is required"); + + expect(gatewayRuntime.callGatewayFromCli).not.toHaveBeenCalled(); + }); + + it("archives a Gateway-local thread and prints the structured result as JSON", async () => { + gatewayRuntime.callGatewayFromCli.mockResolvedValueOnce({ archived: true, ignored: true }); + + const output = await captureStdout(async () => { + await createProgram().parseAsync( + [ + "codex", + "archive", + "thread-1", + "--confirm-no-other-runner", + "--json", + "--url", + "ws://gateway.test", + ], + { from: "user" }, + ); + }); + + expect(gatewayRuntime.callGatewayFromCli).toHaveBeenCalledWith( + "codex.sessions.archive", + { url: "ws://gateway.test", timeout: "30000", json: true }, + { hostId: "gateway:local", threadId: "thread-1", confirmNoOtherRunner: true }, + { mode: "cli", scopes: ["operator.write"] }, + ); + expect(JSON.parse(output)).toEqual({ archived: true }); + }); + + it("renders a successful archive in human-readable output", async () => { + gatewayRuntime.callGatewayFromCli.mockResolvedValueOnce({ archived: true }); + + const output = await captureStdout(async () => { + await createProgram().parseAsync( + ["codex", "archive", "thread-2", "--confirm-no-other-runner"], + { from: "user" }, + ); + }); + + expect(output).toBe("Archived Codex thread thread-2.\n"); + }); + + it("rejects an invalid Gateway result", async () => { + gatewayRuntime.callGatewayFromCli.mockResolvedValueOnce({ archived: false }); + + await expect( + createProgram().parseAsync(["codex", "archive", "thread-1", "--confirm-no-other-runner"], { + from: "user", + }), + ).rejects.toThrow("Codex session archive returned an invalid result"); + }); + + it("rejects an empty thread id before checking confirmation or calling the Gateway", async () => { + await expect( + createProgram().parseAsync(["codex", "archive", " ", "--confirm-no-other-runner"], { + from: "user", + }), + ).rejects.toThrow("Codex thread id must not be empty"); + + expect(gatewayRuntime.callGatewayFromCli).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/extensions/codex-supervisor/src/cli.ts b/extensions/codex/src/session-cli.ts similarity index 52% rename from extensions/codex-supervisor/src/cli.ts rename to extensions/codex/src/session-cli.ts index 4dd5d6935f96..cc397a55015b 100644 --- a/extensions/codex-supervisor/src/cli.ts +++ b/extensions/codex/src/session-cli.ts @@ -1,33 +1,43 @@ -// Codex Supervisor CLI lists Codex sessions exposed by the Gateway session catalog. +// Codex CLI lists native sessions and adopts or archives idle local threads. import type { Command } from "commander"; import { addGatewayClientOptions, callGatewayFromCli, type GatewayRpcOpts, } from "openclaw/plugin-sdk/gateway-runtime"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { sanitizeTerminalText } from "openclaw/plugin-sdk/text-chunking"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { - CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT, - CODEX_SESSION_CATALOG_METHOD, - parseCodexSessionCatalogResult, -} from "./session-catalog.js"; import type { CodexSessionCatalogHost, CodexSessionCatalogParams, CodexSessionCatalogResult, CodexSessionCatalogSession, -} from "./types.js"; +} from "./session-catalog-types.js"; +import { + CODEX_LOCAL_SESSION_HOST_ID, + CODEX_SESSION_ARCHIVE_METHOD, + CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT, + CODEX_SESSION_CATALOG_METHOD, + CODEX_SESSION_CONTINUE_METHOD, + parseCodexSessionCatalogResult, +} from "./session-catalog.js"; -type CodexSessionsCliOptions = GatewayRpcOpts & { +type CodexGatewayOptions = GatewayRpcOpts & { json?: boolean; +}; + +type CodexSessionsCliOptions = CodexGatewayOptions & { search?: string; - archived?: boolean; host?: string; limit?: string; cursor?: string; }; +type CodexArchiveCliOptions = CodexGatewayOptions & { + confirmNoOtherRunner?: boolean; +}; + function writeLine(value = ""): void { process.stdout.write(`${value}\n`); } @@ -36,18 +46,23 @@ function writeJson(value: unknown): void { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } +function gatewayOptions(options: CodexGatewayOptions): GatewayRpcOpts { + return { + ...(options.url ? { url: options.url } : {}), + ...(options.token ? { token: options.token } : {}), + ...(options.timeout !== undefined ? { timeout: options.timeout } : {}), + json: options.json === true, + }; +} + function parsePageLimit(value: string | undefined): number | undefined { if (value === undefined) { return undefined; } const trimmed = value.trim(); - if (!/^\d+$/.test(trimmed)) { - throw new Error( - `--limit must be an integer between 1 and ${CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT}`, - ); - } const parsed = Number(trimmed); if ( + !/^\d+$/.test(trimmed) || !Number.isSafeInteger(parsed) || parsed < 1 || parsed > CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT @@ -69,10 +84,9 @@ function formatTimestamp(session: CodexSessionCatalogSession): string { return "-"; } const date = new Date(normalizeTimestampMs(value)); - if (Number.isNaN(date.getTime())) { - return "-"; - } - return date.toISOString().replace("T", " ").slice(0, 16) + "Z"; + return Number.isNaN(date.getTime()) + ? "-" + : `${date.toISOString().replace("T", " ").slice(0, 16)}Z`; } function singleLineTerminalText(value: string): string { @@ -80,10 +94,7 @@ function singleLineTerminalText(value: string): string { } function truncate(value: string, maxLength: number): string { - if (value.length <= maxLength) { - return value; - } - return `${truncateUtf16Safe(value, maxLength - 1)}\u2026`; + return value.length <= maxLength ? value : `${truncateUtf16Safe(value, maxLength - 1)}\u2026`; } function sessionTitle(session: CodexSessionCatalogSession): string { @@ -92,13 +103,14 @@ function sessionTitle(session: CodexSessionCatalogSession): string { } function sessionStatus(session: CodexSessionCatalogSession): string { - const status = singleLineTerminalText(session.status) || "unknown"; - const details = [ - ...(session.activeFlags ?? []) - .filter((entry) => entry !== session.status) - .map(singleLineTerminalText), - ...(session.archived ? ["archived"] : []), - ].filter(Boolean); + const status = + session.status === "notLoaded" + ? "stored / activity unknown" + : singleLineTerminalText(session.status) || "unknown"; + const details = (session.activeFlags ?? []) + .filter((entry) => entry !== session.status) + .map(singleLineTerminalText) + .filter(Boolean); return details.length > 0 ? `${status} (${details.join(", ")})` : status; } @@ -125,26 +137,23 @@ function writeHost(host: CodexSessionCatalogHost): void { ` Error [${singleLineTerminalText(host.error.code)}]: ${singleLineTerminalText(host.error.message)}`, ); } - if (host.sessions.length === 0) { - if (!host.error) { - writeLine(" No sessions."); - } - } else { - for (const session of host.sessions) { - writeLine( - ` ${formatTimestamp(session)} ${sessionStatus(session)} ${singleLineTerminalText(session.threadId)} ${sessionTitle(session)}`, - ); - const details = [ - session.cwd ? singleLineTerminalText(session.cwd) : undefined, - session.gitBranch ? `branch ${singleLineTerminalText(session.gitBranch)}` : undefined, - session.source ? `source ${singleLineTerminalText(session.source)}` : undefined, - session.modelProvider - ? `provider ${singleLineTerminalText(session.modelProvider)}` - : undefined, - ].filter((entry): entry is string => Boolean(entry)); - if (details.length > 0) { - writeLine(` ${details.join(" · ")}`); - } + if (host.sessions.length === 0 && !host.error) { + writeLine(" No sessions."); + } + for (const session of host.sessions) { + writeLine( + ` ${formatTimestamp(session)} ${sessionStatus(session)} ${singleLineTerminalText(session.threadId)} ${sessionTitle(session)}`, + ); + const details = [ + session.cwd ? singleLineTerminalText(session.cwd) : undefined, + session.gitBranch ? `branch ${singleLineTerminalText(session.gitBranch)}` : undefined, + session.source ? `source ${singleLineTerminalText(session.source)}` : undefined, + session.modelProvider + ? `provider ${singleLineTerminalText(session.modelProvider)}` + : undefined, + ].filter((entry): entry is string => Boolean(entry)); + if (details.length > 0) { + writeLine(` ${details.join(" · ")}`); } } if (host.nextCursor) { @@ -154,30 +163,13 @@ function writeHost(host: CodexSessionCatalogHost): void { } } -function hostMatches(host: CodexSessionCatalogHost, selector: string): boolean { - return host.hostId === selector; -} - function filterHosts( result: CodexSessionCatalogResult, selector: string | undefined, ): CodexSessionCatalogResult { - if (!selector) { - return result; - } - return { - ...result, - hosts: result.hosts.filter((host) => hostMatches(host, selector)), - }; -} - -function gatewayOptions(options: CodexSessionsCliOptions): GatewayRpcOpts { - return { - ...(options.url ? { url: options.url } : {}), - ...(options.token ? { token: options.token } : {}), - ...(options.timeout !== undefined ? { timeout: options.timeout } : {}), - json: options.json === true, - }; + return selector + ? { ...result, hosts: result.hosts.filter((host) => host.hostId === selector) } + : result; } async function listCodexSessions(options: CodexSessionsCliOptions): Promise { @@ -190,7 +182,6 @@ async function listCodexSessions(options: CodexSessionsCliOptions): Promise { + const threadId = readThreadId(threadIdValue); + const raw = await callGatewayFromCli( + CODEX_SESSION_CONTINUE_METHOD, + gatewayOptions(options), + { hostId: CODEX_LOCAL_SESSION_HOST_ID, threadId }, + { mode: "cli", scopes: ["operator.write"] }, + ); + if (!isRecord(raw) || typeof raw.sessionKey !== "string" || !raw.sessionKey.trim()) { + throw new Error("Codex session continue returned an invalid session key"); + } + if (raw.disposition !== "existing" && raw.disposition !== "forked") { + throw new Error("Codex session continue returned an invalid disposition"); + } + const result = { sessionKey: raw.sessionKey, disposition: raw.disposition }; + if (options.json) { + writeJson(result); + return; + } + const dispositionLabel = result.disposition === "forked" ? "branch created" : "existing"; + writeLine(`OpenClaw session (${dispositionLabel}): ${singleLineTerminalText(result.sessionKey)}`); +} + +async function archiveCodexSession( + threadIdValue: string, + options: CodexArchiveCliOptions, +): Promise { + const threadId = readThreadId(threadIdValue); + if (options.confirmNoOtherRunner !== true) { + throw new Error( + "--confirm-no-other-runner is required because Codex client and runner activity is process-local", + ); + } + const raw = await callGatewayFromCli( + CODEX_SESSION_ARCHIVE_METHOD, + gatewayOptions(options), + { hostId: CODEX_LOCAL_SESSION_HOST_ID, threadId, confirmNoOtherRunner: true }, + { mode: "cli", scopes: ["operator.write"] }, + ); + if (!isRecord(raw) || raw.archived !== true) { + throw new Error("Codex session archive returned an invalid result"); + } + const result = { archived: true as const }; + if (options.json) { + writeJson(result); + return; + } + writeLine(`Archived Codex thread ${singleLineTerminalText(threadId)}.`); +} + +/** Registers the plugin-owned Codex session supervision CLI. */ +export function registerCodexSessionCli(program: Command): void { const codex = program .command("codex") - .description("Inspect Codex sessions across the Gateway and paired nodes"); + .description("Inspect and branch from Codex sessions through the Gateway"); addGatewayClientOptions( codex .command("sessions") - .description("List Codex app-server sessions across connected hosts") - .option("--search ", "Search session titles (case-sensitive)") - .option("--archived", "List archived sessions", false) + .description("List non-archived Codex app-server sessions across connected hosts") + .option("--search ", "Search session titles (case-insensitive)") .option("--host ", "Filter by stable host id") .option("--limit ", "Maximum sessions returned per host") .option("--cursor ", "Continue one host page (requires --host)") @@ -245,4 +296,27 @@ export function registerCodexSupervisorCli(program: Command): void { ).action(async (options: CodexSessionsCliOptions) => { await listCodexSessions(options); }); + + addGatewayClientOptions( + codex + .command("continue ") + .description("Continue a Gateway-local Codex thread as an OpenClaw branch") + .option("--json", "Print the structured response", false), + ).action(async (threadId: string, options: CodexGatewayOptions) => { + await continueCodexSession(threadId, options); + }); + + addGatewayClientOptions( + codex + .command("archive ") + .description("Archive a stored or idle Gateway-local Codex thread") + .option( + "--confirm-no-other-runner", + "Confirm no other Codex client or OpenClaw runner is using this thread", + false, + ) + .option("--json", "Print the structured response", false), + ).action(async (threadId: string, options: CodexArchiveCliOptions) => { + await archiveCodexSession(threadId, options); + }); } diff --git a/extensions/codex/src/supervision-tools.test.ts b/extensions/codex/src/supervision-tools.test.ts new file mode 100644 index 000000000000..e374a38c2cfc --- /dev/null +++ b/extensions/codex/src/supervision-tools.test.ts @@ -0,0 +1,1035 @@ +// Codex supervision compatibility tests lock writes to active-turn controls. +import { + clearRuntimeAuthProfileStoreSnapshots, + replaceRuntimeAuthProfileStoreSnapshots, +} from "openclaw/plugin-sdk/agent-runtime"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createCodexSupervisionTools, + LEGACY_CODEX_SUPERVISOR_ENDPOINTS_ENV, + LEGACY_CODEX_SUPERVISOR_RAW_TRANSCRIPTS_ENV, + LEGACY_CODEX_SUPERVISOR_WRITE_CONTROLS_ENV, + type CodexSupervisionToolsOptions, +} from "./supervision-tools.js"; + +const requestCodexAppServerJsonMock = vi.hoisted(() => vi.fn()); + +vi.mock("./app-server/request.js", () => ({ + requestCodexAppServerJson: requestCodexAppServerJsonMock, +})); + +type RecordedRequest = { method: string; params?: unknown }; +type EndpointRequest = NonNullable; +type EndpointRequestHandler = (...args: Parameters) => unknown; + +function createEndpointRequest(handler: EndpointRequestHandler): EndpointRequest { + return async (...args: Parameters) => (await handler(...args)) as T; +} + +function toolByName(tools: ReturnType, name: string) { + const tool = tools.find((entry) => entry.name === name); + if (!tool) { + throw new Error(`missing tool: ${name}`); + } + return tool; +} + +function createRequest(thread: Record) { + const calls: RecordedRequest[] = []; + const request = createEndpointRequest(async (_endpoint, method, params) => { + calls.push({ method, ...(params === undefined ? {} : { params }) }); + if (method === "thread/read") { + return { thread }; + } + if (method === "thread/loaded/list") { + return { data: [], nextCursor: null }; + } + return {}; + }); + return { calls, request }; +} + +function createTools( + request: EndpointRequest, + overrides: Partial = {}, +) { + return createCodexSupervisionTools({ + getPluginConfig: () => ({ + supervision: { + enabled: true, + allowRawTranscripts: true, + allowWriteControls: true, + }, + }), + senderIsOwner: true, + request, + ...overrides, + }); +} + +describe("Codex supervision compatibility tools", () => { + beforeEach(() => { + requestCodexAppServerJsonMock.mockReset(); + }); + + afterEach(() => { + clearRuntimeAuthProfileStoreSnapshots(); + vi.unstubAllEnvs(); + }); + + it("keeps the legacy local endpoint alias on shared user-home stdio", async () => { + const transports: Array = []; + const request = createEndpointRequest(async (endpoint) => { + transports.push(endpoint.configured?.transport); + return {}; + }); + const tools = createCodexSupervisionTools({ + getPluginConfig: () => ({ supervision: { enabled: true } }), + senderIsOwner: true, + env: { [LEGACY_CODEX_SUPERVISOR_ENDPOINTS_ENV]: "local" }, + request, + }); + + await toolByName(tools, "codex_endpoint_probe").execute("probe", {}); + + expect(transports).toEqual(["stdio-proxy"]); + }); + + it("defaults the local compatibility endpoint to shared user-home stdio", async () => { + requestCodexAppServerJsonMock.mockResolvedValue({ data: [], nextCursor: null }); + const tools = createCodexSupervisionTools({ + getPluginConfig: () => ({ supervision: { enabled: true } }), + senderIsOwner: true, + env: {}, + }); + + await toolByName(tools, "codex_endpoint_probe").execute("probe", {}); + + expect(requestCodexAppServerJsonMock).toHaveBeenCalledWith( + expect.objectContaining({ + startOptions: expect.objectContaining({ transport: "stdio", homeScope: "user" }), + }), + ); + }); + + it("preserves the shipped stdio endpoint working directory", async () => { + requestCodexAppServerJsonMock.mockResolvedValue({ data: [], nextCursor: null }); + const tools = createCodexSupervisionTools({ + getPluginConfig: () => ({ + supervision: { + enabled: true, + endpoints: [ + { + id: "legacy-cwd", + transport: "stdio-proxy", + command: "codex", + cwd: "/srv/codex-project", + }, + ], + }, + }), + senderIsOwner: true, + env: {}, + }); + + await toolByName(tools, "codex_endpoint_probe").execute("probe", {}); + + expect(requestCodexAppServerJsonMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "thread/loaded/list", + startOptions: expect.objectContaining({ + transport: "stdio", + cwd: "/srv/codex-project", + }), + }), + ); + }); + + it("rejects unauthenticated remote compatibility endpoints before connecting", async () => { + const tools = createCodexSupervisionTools({ + getPluginConfig: () => ({ + supervision: { + enabled: true, + allowRawTranscripts: true, + endpoints: [ + { id: "remote", transport: "websocket", url: "wss://codex.example.com/app-server" }, + ], + }, + }), + senderIsOwner: true, + env: {}, + }); + + await expect( + toolByName(tools, "codex_session_read").execute("read", { + endpoint_id: "remote", + thread_id: "thread-1", + }), + ).rejects.toThrow("remote Codex app-server WebSocket URLs require"); + }); + + it("retains the five shipped tool names and policy gates", async () => { + const { request } = createRequest({ id: "thread-1", status: { type: "idle" } }); + const tools = createCodexSupervisionTools({ + getPluginConfig: () => ({ supervision: { enabled: true } }), + senderIsOwner: true, + request, + }); + + expect(tools.map((tool) => tool.name)).toEqual([ + "codex_endpoint_probe", + "codex_sessions_list", + "codex_session_read", + "codex_session_send", + "codex_session_interrupt", + ]); + await expect( + toolByName(tools, "codex_session_read").execute("read", { thread_id: "thread-1" }), + ).rejects.toThrow("Codex session reads are disabled"); + await expect( + toolByName(tools, "codex_session_send").execute("send", { + thread_id: "thread-1", + text: "continue", + }), + ).rejects.toThrow("Codex write controls are disabled"); + }); + + it("denies non-owner execution before reading endpoint or session data", async () => { + const request = vi.fn(); + const tools = createCodexSupervisionTools({ + getPluginConfig: () => ({ supervision: { enabled: true } }), + senderIsOwner: false, + request, + }); + + await expect(toolByName(tools, "codex_sessions_list").execute("list", {})).rejects.toThrow( + "require an owner-authorized sender", + ); + expect(request).not.toHaveBeenCalled(); + }); + + it("omits stored transcript metadata and endpoint errors when raw reads are disabled", async () => { + const privatePreview = "private stored transcript preview"; + const privateName = "private stored thread name"; + const privateError = "private endpoint failure detail"; + const request = createEndpointRequest(async (endpoint, method) => { + if (endpoint.id === "broken") { + throw new Error(privateError); + } + if (method === "thread/loaded/list") { + return { data: [], nextCursor: null }; + } + if (method === "thread/list") { + return { + data: [ + { + id: "stored-thread", + name: privateName, + preview: privatePreview, + status: { type: "idle" }, + }, + ], + nextCursor: null, + }; + } + throw new Error(`unexpected method: ${method}`); + }); + const tools = createCodexSupervisionTools({ + getPluginConfig: () => ({ + supervision: { + enabled: true, + endpoints: [ + { id: "healthy", transport: "stdio-proxy" }, + { id: "broken", transport: "stdio-proxy" }, + ], + }, + }), + senderIsOwner: true, + request, + }); + + const result = await toolByName(tools, "codex_sessions_list").execute("list", { + include_stored: true, + max_stored_sessions: 1, + }); + + expect(result).toMatchObject({ + details: { + sessions: [ + { + endpointId: "healthy", + threadId: "stored-thread", + status: "idle", + }, + ], + errors: [{ endpointId: "broken", ok: false }], + }, + }); + const serialized = JSON.stringify(result); + expect(serialized).not.toContain(privatePreview); + expect(serialized).not.toContain(privateName); + expect(serialized).not.toContain(privateError); + }); + + it("stops loaded-session pagination when Codex cycles through prior cursors", async () => { + let pageCalls = 0; + const request = createEndpointRequest(async (_endpoint, method) => { + if (method !== "thread/loaded/list") { + throw new Error(`unexpected method: ${method}`); + } + pageCalls += 1; + if (pageCalls > 3) { + throw new Error("unexpected fourth loaded-session page"); + } + const nextCursor = ["loaded-page-a", "loaded-page-b", "loaded-page-a"][pageCalls - 1]; + return { data: [], nextCursor }; + }); + const tools = createTools(request); + + await expect( + toolByName(tools, "codex_sessions_list").execute("list", {}), + ).resolves.toMatchObject({ + details: { + sessions: [], + errors: [ + { + endpointId: "local", + ok: false, + detail: "Codex thread/loaded/list returned repeated cursor loaded-page-a", + }, + ], + }, + }); + expect(pageCalls).toBe(3); + }); + + it("stops stored-session pagination when duplicate-only pages repeat a cursor", async () => { + let storedPageCalls = 0; + const request = createEndpointRequest(async (_endpoint, method) => { + if (method === "thread/loaded/list") { + return { data: [], nextCursor: null }; + } + if (method !== "thread/list") { + throw new Error(`unexpected method: ${method}`); + } + storedPageCalls += 1; + if (storedPageCalls > 2) { + throw new Error("unexpected third stored-session page"); + } + return { + data: [{ id: "stored-thread", status: { type: "idle" } }], + nextCursor: "stored-page-2", + }; + }); + const tools = createTools(request); + + await expect( + toolByName(tools, "codex_sessions_list").execute("list", { + include_stored: true, + max_stored_sessions: 2, + }), + ).resolves.toMatchObject({ + details: { + sessions: [], + errors: [ + { + endpointId: "local", + ok: false, + detail: "Codex thread/list returned repeated cursor stored-page-2", + }, + ], + }, + }); + expect(storedPageCalls).toBe(2); + }); + + it("fails closed at the loaded-session page cap when a cursor remains", async () => { + let loadedPageCalls = 0; + const request = createEndpointRequest(async (_endpoint, method) => { + if (method === "thread/read") { + return { + thread: { id: "loaded-thread", status: { type: "idle" } }, + }; + } + if (method !== "thread/loaded/list") { + throw new Error(`unexpected method: ${method}`); + } + loadedPageCalls += 1; + return { + data: loadedPageCalls === 1 ? ["loaded-thread"] : [], + nextCursor: `loaded-page-${loadedPageCalls + 1}`, + }; + }); + const tools = createTools(request); + + await expect( + toolByName(tools, "codex_sessions_list").execute("list", {}), + ).resolves.toMatchObject({ + details: { + sessions: [], + errors: [ + { + endpointId: "local", + ok: false, + detail: "Codex thread/loaded/list exceeded 100 pages with a continuation cursor", + }, + ], + }, + }); + expect(loadedPageCalls).toBe(100); + }); + + it("rejects an over-returned loaded page before reading any thread", async () => { + const methods: string[] = []; + const request = createEndpointRequest(async (_endpoint, method) => { + methods.push(method); + return { + data: Array.from({ length: 101 }, (_, index) => `thread-${index}`), + nextCursor: null, + }; + }); + const tools = createTools(request); + + await expect( + toolByName(tools, "codex_sessions_list").execute("list", {}), + ).resolves.toMatchObject({ + details: { + sessions: [], + errors: [ + { + endpointId: "local", + ok: false, + detail: "Codex thread/loaded/list returned more than 100 entries", + }, + ], + }, + }); + expect(methods).toEqual(["thread/loaded/list"]); + }); + + it.each([ + { + name: "non-string thread id", + response: { data: [42], nextCursor: null }, + detail: "Codex thread/loaded/list returned an invalid thread id at data[0]", + }, + { + name: "non-string cursor", + response: { data: [], nextCursor: 42 }, + detail: "Codex thread/loaded/list returned an invalid nextCursor", + }, + { + name: "oversized cursor", + response: { data: [], nextCursor: "x".repeat(4097) }, + detail: "Codex thread/loaded/list returned an invalid nextCursor", + }, + ])("rejects a loaded page with $name", async ({ response, detail }) => { + const request = createEndpointRequest(async () => response); + const tools = createTools(request); + + await expect( + toolByName(tools, "codex_sessions_list").execute("list", {}), + ).resolves.toMatchObject({ + details: { + sessions: [], + errors: [{ endpointId: "local", ok: false, detail }], + }, + }); + }); + + it("continues past a duplicate-only stored page when its cursor advances", async () => { + let storedPageCalls = 0; + const request = createEndpointRequest(async (_endpoint, method) => { + if (method === "thread/loaded/list") { + return { data: [], nextCursor: null }; + } + if (method !== "thread/list") { + throw new Error(`unexpected method: ${method}`); + } + storedPageCalls += 1; + if (storedPageCalls === 3) { + return { + data: [{ id: "stored-thread-2", status: { type: "idle" } }], + nextCursor: null, + }; + } + return { + data: [{ id: "stored-thread-1", status: { type: "idle" } }], + nextCursor: `stored-page-${storedPageCalls + 1}`, + }; + }); + const tools = createTools(request); + + await expect( + toolByName(tools, "codex_sessions_list").execute("list", { + include_stored: true, + max_stored_sessions: 2, + }), + ).resolves.toMatchObject({ + details: { + sessions: [ + { endpointId: "local", threadId: "stored-thread-1" }, + { endpointId: "local", threadId: "stored-thread-2" }, + ], + errors: [], + }, + }); + expect(storedPageCalls).toBe(3); + }); + + it("rejects a stored page that over-returns max_stored_sessions", async () => { + const request = createEndpointRequest(async (_endpoint, method) => { + if (method === "thread/loaded/list") { + return { data: [], nextCursor: null }; + } + if (method !== "thread/list") { + throw new Error(`unexpected method: ${method}`); + } + return { + data: [ + { id: "stored-thread-1", status: { type: "idle" } }, + { id: "stored-thread-2", status: { type: "idle" } }, + { id: "stored-thread-3", status: { type: "idle" } }, + ], + nextCursor: null, + }; + }); + const tools = createTools(request); + + await expect( + toolByName(tools, "codex_sessions_list").execute("list", { + include_stored: true, + max_stored_sessions: 1, + }), + ).resolves.toMatchObject({ + details: { + sessions: [], + errors: [ + { + endpointId: "local", + ok: false, + detail: "Codex thread/list returned more than 1 entries", + }, + ], + }, + }); + }); + + it.each([ + { + name: "missing thread id", + response: { data: [{}], nextCursor: null }, + detail: "Codex thread/list returned an invalid thread id at data[0]", + }, + { + name: "blank cursor", + response: { data: [], nextCursor: " " }, + detail: "Codex thread/list returned an invalid nextCursor", + }, + ])("rejects a stored page with $name", async ({ response, detail }) => { + const request = createEndpointRequest(async (_endpoint, method) => { + if (method === "thread/loaded/list") { + return { data: [], nextCursor: null }; + } + return response; + }); + const tools = createTools(request); + + await expect( + toolByName(tools, "codex_sessions_list").execute("list", { + include_stored: true, + }), + ).resolves.toMatchObject({ + details: { + sessions: [], + errors: [{ endpointId: "local", ok: false, detail }], + }, + }); + }); + + it("fails closed at the stored-session page cap when a cursor remains", async () => { + let storedPageCalls = 0; + const request = createEndpointRequest(async (_endpoint, method) => { + if (method === "thread/loaded/list") { + return { data: [], nextCursor: null }; + } + if (method !== "thread/list") { + throw new Error(`unexpected method: ${method}`); + } + storedPageCalls += 1; + return { + data: [{ id: "stored-thread", status: { type: "idle" } }], + nextCursor: `stored-page-${storedPageCalls + 1}`, + }; + }); + const tools = createTools(request); + + await expect( + toolByName(tools, "codex_sessions_list").execute("list", { + include_stored: true, + max_stored_sessions: 2, + }), + ).resolves.toMatchObject({ + details: { + sessions: [], + errors: [ + { + endpointId: "local", + ok: false, + detail: "Codex thread/list exceeded 100 pages with a continuation cursor", + }, + ], + }, + }); + expect(storedPageCalls).toBe(100); + }); + + it("rechecks live supervision config before every paginated request", async () => { + let pluginConfig: unknown = { supervision: { enabled: true } }; + let requestCalls = 0; + const request = createEndpointRequest(async (_endpoint, method) => { + if (method !== "thread/loaded/list") { + throw new Error(`unexpected method: ${method}`); + } + requestCalls += 1; + pluginConfig = { supervision: { enabled: false } }; + return { data: [], nextCursor: "next-page" }; + }); + const tools = createCodexSupervisionTools({ + getPluginConfig: () => pluginConfig, + senderIsOwner: true, + request, + }); + + await expect(toolByName(tools, "codex_sessions_list").execute("list", {})).rejects.toThrow( + "Codex supervision is disabled", + ); + expect(requestCalls).toBe(1); + }); + + it("revokes a removed endpoint before the next paginated request", async () => { + let pluginConfig: unknown = { + supervision: { + enabled: true, + endpoints: [{ id: "remote", transport: "stdio-proxy", command: "codex" }], + }, + }; + let requestCalls = 0; + const request = createEndpointRequest(async (_endpoint, method) => { + if (method !== "thread/loaded/list") { + throw new Error(`unexpected method: ${method}`); + } + requestCalls += 1; + pluginConfig = { supervision: { enabled: true, endpoints: [] } }; + return { data: [], nextCursor: "next-page" }; + }); + const tools = createCodexSupervisionTools({ + getPluginConfig: () => pluginConfig, + senderIsOwner: true, + request, + }); + + await expect(toolByName(tools, "codex_sessions_list").execute("list", {})).rejects.toThrow( + "endpoint remote was removed or changed", + ); + expect(requestCalls).toBe(1); + }); + + it("rechecks raw-transcript policy before a fallback transcript request", async () => { + let pluginConfig: unknown = { + supervision: { enabled: true, allowRawTranscripts: true }, + }; + let requestCalls = 0; + const request = createEndpointRequest(async (_endpoint, method) => { + if (method !== "thread/read") { + throw new Error(`unexpected method: ${method}`); + } + requestCalls += 1; + pluginConfig = { supervision: { enabled: true, allowRawTranscripts: false } }; + throw new Error("turns not materialized yet"); + }); + const tools = createCodexSupervisionTools({ + getPluginConfig: () => pluginConfig, + senderIsOwner: true, + request, + }); + + await expect( + toolByName(tools, "codex_session_read").execute("read", { + endpoint_id: "local", + thread_id: "thread-1", + include_turns: true, + }), + ).rejects.toThrow("Codex session reads are disabled"); + expect(requestCalls).toBe(1); + }); + + it("does not return a transcript when its endpoint changes in flight", async () => { + let pluginConfig: unknown = { + supervision: { + enabled: true, + allowRawTranscripts: true, + endpoints: [{ id: "primary", transport: "stdio-proxy", command: "codex-a" }], + }, + }; + const request = createEndpointRequest(async (_endpoint, method) => { + if (method !== "thread/read") { + throw new Error(`unexpected method: ${method}`); + } + pluginConfig = { + supervision: { + enabled: true, + allowRawTranscripts: true, + endpoints: [{ id: "primary", transport: "stdio-proxy", command: "codex-b" }], + }, + }; + return { + thread: { + id: "thread-1", + status: { type: "idle" }, + turns: [{ id: "private-turn" }], + }, + }; + }); + const tools = createCodexSupervisionTools({ + getPluginConfig: () => pluginConfig, + senderIsOwner: true, + request, + }); + + await expect( + toolByName(tools, "codex_session_read").execute("read", { + endpoint_id: "primary", + thread_id: "thread-1", + include_turns: true, + }), + ).rejects.toThrow("endpoint primary was removed or changed"); + }); + + it("does not return a transcript when the effective agent directory changes in flight", async () => { + const pluginConfig = { + appServer: { + transport: "websocket" as const, + homeScope: "agent" as const, + url: "ws://127.0.0.1:4500", + }, + supervision: { enabled: true, allowRawTranscripts: true }, + }; + let runtimeConfig = { + agents: { + list: [{ id: "main", default: true, agentDir: "/tmp/codex-supervision-agent-a" }], + }, + }; + const request = createEndpointRequest(async (_endpoint, method) => { + if (method !== "thread/read") { + throw new Error(`unexpected method: ${method}`); + } + runtimeConfig = { + agents: { + list: [{ id: "main", default: true, agentDir: "/tmp/codex-supervision-agent-b" }], + }, + }; + return { + thread: { id: "thread-1", status: { type: "idle" } }, + }; + }); + const tools = createCodexSupervisionTools({ + getPluginConfig: () => pluginConfig, + getRuntimeConfig: () => runtimeConfig, + senderIsOwner: true, + request, + }); + + await expect( + toolByName(tools, "codex_session_read").execute("read", { + endpoint_id: "local", + thread_id: "thread-1", + }), + ).rejects.toThrow("endpoint local was removed or changed"); + }); + + it("does not return a transcript when the effective auth profile changes in flight", async () => { + const agentDir = "/tmp/codex-supervision-auth-agent"; + replaceRuntimeAuthProfileStoreSnapshots([ + { + agentDir, + store: { + version: 1, + profiles: { + "openai:first": { + type: "oauth", + provider: "openai", + access: "first-access", + refresh: "first-refresh", + expires: Date.now() + 60_000, + }, + "openai:second": { + type: "oauth", + provider: "openai", + access: "second-access", + refresh: "second-refresh", + expires: Date.now() + 60_000, + }, + }, + }, + }, + ]); + const pluginConfig = { + appServer: { + transport: "websocket" as const, + homeScope: "agent" as const, + url: "ws://127.0.0.1:4500", + }, + supervision: { enabled: true, allowRawTranscripts: true }, + }; + let runtimeConfig = { + agents: { list: [{ id: "main", default: true, agentDir }] }, + auth: { order: { openai: ["openai:first", "openai:second"] } }, + }; + const request = createEndpointRequest(async (_endpoint, method) => { + if (method !== "thread/read") { + throw new Error(`unexpected method: ${method}`); + } + runtimeConfig = { + agents: { list: [{ id: "main", default: true, agentDir }] }, + auth: { order: { openai: ["openai:second", "openai:first"] } }, + }; + return { + thread: { id: "thread-1", status: { type: "idle" } }, + }; + }); + const tools = createCodexSupervisionTools({ + getPluginConfig: () => pluginConfig, + getRuntimeConfig: () => runtimeConfig, + senderIsOwner: true, + request, + }); + + await expect( + toolByName(tools, "codex_session_read").execute("read", { + endpoint_id: "local", + thread_id: "thread-1", + }), + ).rejects.toThrow("endpoint local was removed or changed"); + }); + + it("does not return a transcript when fallback API-key auth changes in flight", async () => { + vi.stubEnv("OPENAI_API_KEY", "first-fallback-key"); + const pluginConfig = { + supervision: { enabled: true, allowRawTranscripts: true }, + }; + const request = createEndpointRequest(async (_endpoint, method) => { + if (method !== "thread/read") { + throw new Error(`unexpected method: ${method}`); + } + vi.stubEnv("OPENAI_API_KEY", "second-fallback-key"); + return { + thread: { id: "thread-1", status: { type: "idle" } }, + }; + }); + const tools = createCodexSupervisionTools({ + getPluginConfig: () => pluginConfig, + senderIsOwner: true, + request, + }); + + await expect( + toolByName(tools, "codex_session_read").execute("read", { + endpoint_id: "local", + thread_id: "thread-1", + }), + ).rejects.toThrow("endpoint local was removed or changed"); + }); + + it("rechecks write policy before mutating an active turn", async () => { + let pluginConfig: unknown = { + supervision: { enabled: true, allowWriteControls: true }, + }; + const methods: string[] = []; + const request = createEndpointRequest(async (_endpoint, method) => { + methods.push(method); + if (method !== "thread/read") { + throw new Error(`unexpected method: ${method}`); + } + pluginConfig = { supervision: { enabled: true, allowWriteControls: false } }; + return { + thread: { + id: "thread-1", + status: { type: "active" }, + turns: [{ id: "turn-1", status: "inProgress" }], + }, + }; + }); + const tools = createCodexSupervisionTools({ + getPluginConfig: () => pluginConfig, + senderIsOwner: true, + request, + }); + + await expect( + toolByName(tools, "codex_session_send").execute("send", { + endpoint_id: "local", + thread_id: "thread-1", + text: "continue", + }), + ).rejects.toThrow("Codex write controls are disabled"); + expect(methods).toEqual(["thread/read"]); + }); + + it("rejects an endpoint repoint before mutating an active turn", async () => { + let pluginConfig: unknown = { + supervision: { + enabled: true, + allowWriteControls: true, + endpoints: [{ id: "primary", transport: "stdio-proxy", command: "codex-a" }], + }, + }; + const methods: string[] = []; + const request = createEndpointRequest(async (_endpoint, method) => { + methods.push(method); + if (method !== "thread/read") { + throw new Error(`unexpected method: ${method}`); + } + pluginConfig = { + supervision: { + enabled: true, + allowWriteControls: true, + endpoints: [{ id: "primary", transport: "stdio-proxy", command: "codex-b" }], + }, + }; + return { + thread: { + id: "thread-1", + status: { type: "active" }, + turns: [{ id: "turn-1", status: "inProgress" }], + }, + }; + }); + const tools = createCodexSupervisionTools({ + getPluginConfig: () => pluginConfig, + senderIsOwner: true, + request, + }); + + await expect( + toolByName(tools, "codex_session_send").execute("send", { + endpoint_id: "primary", + thread_id: "thread-1", + text: "continue", + }), + ).rejects.toThrow("endpoint primary was removed or changed"); + expect(methods).toEqual(["thread/read"]); + }); + + it("rejects explicit starts and idle auto sends without a mutating request", async () => { + const { calls, request } = createRequest({ + id: "thread-1", + status: { type: "idle" }, + turns: [], + }); + const tools = createTools(request); + const send = toolByName(tools, "codex_session_send"); + + await expect( + send.execute("start", { + endpoint_id: "local", + thread_id: "thread-1", + text: "continue", + mode: "start", + }), + ).rejects.toThrow("Continue it from Codex Sessions"); + expect(calls).toEqual([]); + + await expect( + send.execute("auto", { + endpoint_id: "local", + thread_id: "thread-1", + text: "continue", + }), + ).rejects.toThrow("Continue it from Codex Sessions"); + expect(calls.map((call) => call.method)).toEqual(["thread/read"]); + }); + + it("steers and interrupts only after a passive active-turn read", async () => { + const { calls, request } = createRequest({ + id: "thread-1", + status: { type: "active" }, + turns: [{ id: "turn-1", status: "inProgress" }], + }); + const tools = createTools(request); + + await toolByName(tools, "codex_session_send").execute("steer", { + endpoint_id: "local", + thread_id: "thread-1", + text: "focus on the failing test", + mode: "steer", + }); + await toolByName(tools, "codex_session_interrupt").execute("interrupt", { + endpoint_id: "local", + thread_id: "thread-1", + }); + + expect(calls).toEqual([ + { + method: "thread/read", + params: { threadId: "thread-1", includeTurns: true }, + }, + { + method: "turn/steer", + params: { + threadId: "thread-1", + expectedTurnId: "turn-1", + input: [ + { + type: "text", + text: "focus on the failing test", + text_elements: [], + }, + ], + }, + }, + { + method: "thread/read", + params: { threadId: "thread-1", includeTurns: true }, + }, + { + method: "turn/interrupt", + params: { threadId: "thread-1", turnId: "turn-1" }, + }, + ]); + expect(calls.some((call) => call.method === "turn/start")).toBe(false); + expect(calls.some((call) => call.method === "thread/resume")).toBe(false); + }); + + it("retains standalone MCP env aliases only behind the trusted adapter opt-in", async () => { + const { request } = createRequest({ + id: "thread-1", + status: { type: "active" }, + turns: [{ id: "turn-1", status: "inProgress" }], + }); + const tools = createCodexSupervisionTools({ + getPluginConfig: () => ({ supervision: { enabled: true } }), + senderIsOwner: true, + env: { + [LEGACY_CODEX_SUPERVISOR_RAW_TRANSCRIPTS_ENV]: "1", + [LEGACY_CODEX_SUPERVISOR_WRITE_CONTROLS_ENV]: "1", + }, + request, + useLegacyMcpPolicyEnv: true, + }); + + await expect( + toolByName(tools, "codex_session_read").execute("read", { + endpoint_id: "local", + thread_id: "thread-1", + }), + ).resolves.toMatchObject({ details: { summary: "codex session: thread-1" } }); + await expect( + toolByName(tools, "codex_session_send").execute("send", { + endpoint_id: "local", + thread_id: "thread-1", + text: "continue", + }), + ).resolves.toMatchObject({ details: { summary: "codex steer: turn-1" } }); + }); +}); diff --git a/extensions/codex/src/supervision-tools.ts b/extensions/codex/src/supervision-tools.ts new file mode 100644 index 000000000000..17917da52181 --- /dev/null +++ b/extensions/codex/src/supervision-tools.ts @@ -0,0 +1,1225 @@ +/** + * Compatibility tools for the retired Codex Supervisor plugin. + * + * Read operations and active-turn controls use the Codex plugin's canonical + * shared app-server client. Idle threads are never resumed or started here: + * continuation belongs to the Codex harness, which installs approval and tool + * handlers before it starts or resumes the harness-owned Codex thread. + */ +import { resolveDefaultAgentDir } from "openclaw/plugin-sdk/agent-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { jsonResult, readStringParam, type AnyAgentTool } from "openclaw/plugin-sdk/core"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { Type } from "typebox"; +import { + resolveCodexAppServerAuthProfileIdForAgent, + resolveCodexAppServerFallbackApiKeyCacheKey, +} from "./app-server/auth-bridge.js"; +import { + assertCodexAppServerConnectionSecurity, + codexAppServerStartOptionsKey, + readCodexPluginConfig, + resolveCodexSupervisionAppServerRuntimeOptions, + type CodexAppServerStartOptions, + type CodexSupervisionEndpoint, +} from "./app-server/config.js"; +import { requestCodexAppServerJson } from "./app-server/request.js"; + +/** Legacy endpoint env retained for the shipped Supervisor tool contract. */ +export const LEGACY_CODEX_SUPERVISOR_ENDPOINTS_ENV = "OPENCLAW_CODEX_SUPERVISOR_ENDPOINTS"; +/** Legacy standalone-MCP transcript gate. Agent tools use canonical config. */ +export const LEGACY_CODEX_SUPERVISOR_RAW_TRANSCRIPTS_ENV = + "OPENCLAW_CODEX_SUPERVISOR_ALLOW_RAW_TRANSCRIPTS"; +/** Legacy standalone-MCP write gate. Agent tools use canonical config. */ +export const LEGACY_CODEX_SUPERVISOR_WRITE_CONTROLS_ENV = + "OPENCLAW_CODEX_SUPERVISOR_ALLOW_WRITE_CONTROLS"; + +export const CODEX_SUPERVISION_COMPAT_TOOL_NAMES = [ + "codex_endpoint_probe", + "codex_sessions_list", + "codex_session_read", + "codex_session_send", + "codex_session_interrupt", +] as const; + +const EmptyParamsSchema = Type.Object({}, { additionalProperties: false }); + +const SessionsListParamsSchema = Type.Object( + { + include_stored: Type.Optional(Type.Boolean()), + max_stored_sessions: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000 })), + }, + { additionalProperties: false }, +); + +const SessionReadParamsSchema = Type.Object( + { + endpoint_id: Type.Optional(Type.String()), + thread_id: Type.String(), + include_turns: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, +); + +const SessionSendParamsSchema = Type.Object( + { + endpoint_id: Type.Optional(Type.String()), + thread_id: Type.String(), + text: Type.String(), + mode: Type.Optional( + Type.Union([Type.Literal("auto"), Type.Literal("start"), Type.Literal("steer")]), + ), + }, + { additionalProperties: false }, +); + +const SessionInterruptParamsSchema = Type.Object( + { + endpoint_id: Type.Optional(Type.String()), + thread_id: Type.String(), + turn_id: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +); + +const ALL_CODEX_THREAD_SOURCE_KINDS = [ + "cli", + "vscode", + "exec", + "appServer", + "subAgent", + "subAgentReview", + "subAgentCompact", + "subAgentThreadSpawn", + "subAgentOther", + "unknown", +] as const; +const DEFAULT_MAX_STORED_SESSIONS = 200; +const PAGE_LIMIT = 100; +const MAX_COMPAT_PAGINATION_PAGES = 100; +const MAX_COMPAT_CURSOR_LENGTH = 4096; +const MAX_COMPAT_THREAD_ID_LENGTH = 4096; + +type CodexSupervisorTurnMode = "auto" | "start" | "steer"; +type CodexSupervisionRequestPolicy = "enabled" | "raw-transcripts" | "write-controls"; + +class CodexSupervisionPolicyError extends Error {} + +type NormalizedSupervisionEndpoint = { + id: string; + label?: string; + configured?: CodexSupervisionEndpoint; +}; + +type ResolvedSupervisionEndpoint = NormalizedSupervisionEndpoint & { + connectionKey: string; +}; + +type CodexSupervisorSession = { + endpointId: string; + threadId: string; + sessionId?: string; + cwd?: string; + preview?: string; + name?: string | null; + source?: string; + status: string; + updatedAt?: number; + humanAttached?: boolean; +}; + +type CodexSupervisorEndpointHealth = { + endpointId: string; + ok: boolean; + detail?: string; +}; + +type CodexSupervisorSessionListResult = { + sessions: CodexSupervisorSession[]; + errors: CodexSupervisorEndpointHealth[]; +}; + +type EndpointRequest = ( + endpoint: ResolvedSupervisionEndpoint, + method: string, + requestParams?: unknown, +) => Promise; + +export type CodexSupervisionToolsOptions = { + getPluginConfig: () => unknown; + getRuntimeConfig?: () => OpenClawConfig | undefined; + /** Trusted owner bit supplied by the plugin tool context. */ + senderIsOwner: boolean; + env?: NodeJS.ProcessEnv; + /** Test seam; production omits this to use the canonical shared client. */ + request?: EndpointRequest; + /** Only a trusted standalone MCP adapter may opt into the shipped env gates. */ + useLegacyMcpPolicyEnv?: boolean; +}; + +function asRecord(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function asRecordArray(value: unknown): Record[] { + return Array.isArray(value) ? value.filter(isRecord) : []; +} + +function readCompatNextCursor(value: unknown, method: string): string | undefined { + if (value === null || value === undefined) { + return undefined; + } + if ( + typeof value !== "string" || + value.trim().length === 0 || + value.length > MAX_COMPAT_CURSOR_LENGTH + ) { + throw new Error(`Codex ${method} returned an invalid nextCursor`); + } + return value; +} + +function readCompatThreadId(value: unknown, method: string, index: number): string { + if ( + typeof value !== "string" || + value.trim().length === 0 || + value.length > MAX_COMPAT_THREAD_ID_LENGTH + ) { + throw new Error(`Codex ${method} returned an invalid thread id at data[${index}]`); + } + return value; +} + +function readLoadedThreadIds(data: unknown[]): string[] { + if (data.length > PAGE_LIMIT) { + throw new Error(`Codex thread/loaded/list returned more than ${PAGE_LIMIT} entries`); + } + return data.map((entry, index) => readCompatThreadId(entry, "thread/loaded/list", index)); +} + +function readStoredThreads(data: unknown[], maxEntries: number): Record[] { + if (data.length > maxEntries) { + throw new Error(`Codex thread/list returned more than ${maxEntries} entries`); + } + return data.map((entry, index) => { + if (!isRecord(entry)) { + throw new Error(`Codex thread/list returned an invalid entry at data[${index}]`); + } + readCompatThreadId(entry.id, "thread/list", index); + return entry; + }); +} + +function readBooleanParam(params: Record, key: string): boolean { + return params[key] === true; +} + +function readIntegerParam(params: Record, key: string): number | undefined { + const value = params[key]; + if (value === undefined) { + return undefined; + } + if (typeof value !== "number" || !Number.isInteger(value)) { + throw new Error(`${key} must be an integer`); + } + if (value < 1 || value > 1000) { + throw new Error(`${key} must be between 1 and 1000`); + } + return value; +} + +function readModeParam(params: Record): CodexSupervisorTurnMode | undefined { + const mode = readStringParam(params, "mode"); + if (!mode) { + return undefined; + } + if (mode === "auto" || mode === "start" || mode === "steer") { + return mode; + } + throw new Error("mode must be auto, start, or steer"); +} + +function normalizeEndpointId(value: string, index: number): string { + const trimmed = value.trim(); + return trimmed ? trimmed.replace(/[^a-zA-Z0-9_.:-]/g, "-") : `endpoint-${index + 1}`; +} + +function normalizeConfiguredEndpoint( + endpoint: CodexSupervisionEndpoint, + index: number, +): NormalizedSupervisionEndpoint { + const rawId = endpoint.id ?? endpoint.label ?? ""; + return { + id: normalizeEndpointId(rawId, index), + ...(endpoint.label?.trim() ? { label: endpoint.label.trim() } : {}), + configured: endpoint, + }; +} + +function parseEndpointRecord(value: unknown): CodexSupervisionEndpoint | undefined { + if (!isRecord(value)) { + return undefined; + } + const transport = typeof value.transport === "string" ? value.transport : undefined; + const common = { + ...(typeof value.id === "string" ? { id: value.id } : {}), + ...(typeof value.label === "string" ? { label: value.label } : {}), + }; + if (transport === "websocket" && typeof value.url === "string") { + return { + ...common, + transport, + url: value.url, + ...(typeof value.authTokenEnv === "string" ? { authTokenEnv: value.authTokenEnv } : {}), + }; + } + if (transport === "stdio-proxy" || transport === undefined) { + const args = Array.isArray(value.args) + ? value.args.filter((entry): entry is string => typeof entry === "string") + : undefined; + return { + ...common, + transport: "stdio-proxy", + ...(typeof value.command === "string" ? { command: value.command } : {}), + ...(args && args.length > 0 ? { args } : {}), + ...(typeof value.cwd === "string" ? { cwd: value.cwd } : {}), + }; + } + return undefined; +} + +function endpointFromToken(token: string, index: number): CodexSupervisionEndpoint | undefined { + const trimmed = token.trim(); + if (!trimmed) { + return undefined; + } + if ( + trimmed.startsWith("ws://") || + trimmed.startsWith("wss://") || + trimmed.startsWith("unix://") + ) { + return { + id: normalizeEndpointId("", index), + transport: "websocket", + url: trimmed, + }; + } + if (trimmed === "local" || trimmed === "proxy" || trimmed === "stdio") { + return { + id: "local", + label: "local Codex app-server", + transport: "stdio-proxy", + }; + } + const separatorIndex = trimmed.indexOf("="); + const id = separatorIndex >= 0 ? trimmed.slice(0, separatorIndex) : trimmed; + const url = separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : undefined; + if (url?.startsWith("ws://") || url?.startsWith("wss://") || url?.startsWith("unix://")) { + return { id: normalizeEndpointId(id, index), transport: "websocket", url }; + } + return undefined; +} + +function requireUniqueEndpointIds( + endpoints: NormalizedSupervisionEndpoint[], +): NormalizedSupervisionEndpoint[] { + const seen = new Set(); + for (const endpoint of endpoints) { + if (seen.has(endpoint.id)) { + throw new Error(`duplicate Codex supervisor endpoint id: ${endpoint.id}`); + } + seen.add(endpoint.id); + } + return endpoints; +} + +function readLegacyEnvEndpoints(env: NodeJS.ProcessEnv): CodexSupervisionEndpoint[] | undefined { + const raw = env[LEGACY_CODEX_SUPERVISOR_ENDPOINTS_ENV]?.trim(); + if (!raw) { + return undefined; + } + if (raw.startsWith("[")) { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + throw new Error(`${LEGACY_CODEX_SUPERVISOR_ENDPOINTS_ENV} must be a JSON array`); + } + return parsed + .map((entry) => parseEndpointRecord(entry)) + .filter((entry): entry is CodexSupervisionEndpoint => Boolean(entry)); + } + return raw + .split(",") + .map(endpointFromToken) + .filter((entry): entry is CodexSupervisionEndpoint => Boolean(entry)); +} + +function resolveEndpoints( + pluginConfig: unknown, + env: NodeJS.ProcessEnv, + runtimeConfig: OpenClawConfig | undefined, +): ResolvedSupervisionEndpoint[] { + const configured = readCodexPluginConfig(pluginConfig).supervision?.endpoints; + const endpoints = configured?.length ? configured : readLegacyEnvEndpoints(env); + const normalized = endpoints + ? requireUniqueEndpointIds(endpoints.map(normalizeConfiguredEndpoint)) + : [{ id: "local", label: "local Codex app-server" }]; + return normalized.map((endpoint) => { + const resolved: ResolvedSupervisionEndpoint = { + id: endpoint.id, + connectionKey: supervisionEndpointConnectionKey({ + endpoint, + pluginConfig, + env, + runtimeConfig, + }), + }; + if (endpoint.label !== undefined) { + resolved.label = endpoint.label; + } + if (endpoint.configured !== undefined) { + resolved.configured = endpoint.configured; + } + return resolved; + }); +} + +function resolveEndpointStartOptions(params: { + endpoint: NormalizedSupervisionEndpoint; + pluginConfig: unknown; + env: NodeJS.ProcessEnv; + validateSecurity?: boolean; +}): CodexAppServerStartOptions { + const base = resolveCodexSupervisionAppServerRuntimeOptions({ + pluginConfig: params.pluginConfig, + env: params.env, + }).start; + const configured = params.endpoint.configured; + if (!configured) { + return base; + } + if (!("url" in configured)) { + return { + transport: "stdio", + homeScope: "user", + command: configured.command?.trim() || "codex", + commandSource: "config", + args: configured.args?.length ? [...configured.args] : ["app-server", "--listen", "stdio://"], + ...(configured.cwd !== undefined ? { cwd: configured.cwd } : {}), + headers: {}, + }; + } + const tokenEnv = configured.authTokenEnv?.trim(); + const authToken = tokenEnv ? params.env[tokenEnv]?.trim() : undefined; + const startOptions: CodexAppServerStartOptions = { + transport: configured.url.startsWith("unix://") ? "unix" : "websocket", + ...(configured.url.startsWith("unix://") ? { homeScope: "user" as const } : {}), + command: base.command, + ...(base.commandSource ? { commandSource: base.commandSource } : {}), + ...(base.managedFallbackCommandPaths + ? { managedFallbackCommandPaths: [...base.managedFallbackCommandPaths] } + : {}), + args: [...base.args], + url: configured.url, + ...(authToken ? { authToken } : {}), + headers: {}, + }; + if (params.validateSecurity !== false) { + assertCodexAppServerConnectionSecurity(startOptions); + } + return startOptions; +} + +function supervisionEndpointConnectionKey(params: { + endpoint: NormalizedSupervisionEndpoint; + pluginConfig: unknown; + env: NodeJS.ProcessEnv; + runtimeConfig: OpenClawConfig | undefined; +}): string { + // Endpoint probes report unsafe connections as unhealthy; the actual request path still + // validates security before connecting, while this path only fingerprints live ownership. + const startOptions = resolveEndpointStartOptions({ ...params, validateSecurity: false }); + const usesNativeAuth = + params.endpoint.configured !== undefined || startOptions.homeScope === "user"; + const agentDir = usesNativeAuth ? undefined : resolveDefaultAgentDir(params.runtimeConfig ?? {}); + const authProfileId = usesNativeAuth + ? undefined + : resolveCodexAppServerAuthProfileIdForAgent({ + agentDir, + config: params.runtimeConfig, + }); + const fallbackApiKeyCacheKey = authProfileId + ? undefined + : resolveCodexAppServerFallbackApiKeyCacheKey({ startOptions }); + return JSON.stringify({ + homeScope: startOptions.homeScope ?? null, + startOptions: codexAppServerStartOptionsKey(startOptions, { + authProfileId, + agentDir, + fallbackApiKeyCacheKey, + }), + }); +} + +function createCanonicalEndpointRequest(options: CodexSupervisionToolsOptions): EndpointRequest { + return async ( + endpoint: ResolvedSupervisionEndpoint, + method: string, + requestParams?: unknown, + ) => { + const pluginConfig = options.getPluginConfig(); + const env = options.env ?? process.env; + const runtime = resolveCodexSupervisionAppServerRuntimeOptions({ pluginConfig, env }); + const config = options.getRuntimeConfig?.(); + const startOptions = resolveEndpointStartOptions({ + endpoint, + pluginConfig, + env, + }); + return await requestCodexAppServerJson({ + method, + requestParams, + timeoutMs: runtime.requestTimeoutMs, + startOptions, + ...(endpoint.configured || startOptions.homeScope === "user" ? { authProfileId: null } : {}), + ...(config ? { config } : {}), + }); + }; +} + +function statusType(thread: Record): string { + const status = isRecord(thread.status) ? thread.status.type : thread.status; + return typeof status === "string" ? status : "unknown"; +} + +function sourceLabel(value: unknown): string | undefined { + if (typeof value === "string") { + return value; + } + if (!isRecord(value)) { + return undefined; + } + if (typeof value.custom === "string") { + return `custom:${value.custom}`; + } + return Object.keys(value).toSorted()[0]; +} + +function toSession( + endpointId: string, + thread: Record, + humanAttached?: boolean, +): CodexSupervisorSession | undefined { + if (typeof thread.id !== "string") { + return undefined; + } + const source = sourceLabel(thread.source); + return { + endpointId, + threadId: thread.id, + status: statusType(thread), + ...(typeof thread.sessionId === "string" ? { sessionId: thread.sessionId } : {}), + ...(typeof thread.cwd === "string" ? { cwd: thread.cwd } : {}), + ...(typeof thread.preview === "string" ? { preview: thread.preview } : {}), + ...(typeof thread.name === "string" || thread.name === null ? { name: thread.name } : {}), + ...(source ? { source } : {}), + ...(typeof thread.updatedAt === "number" ? { updatedAt: thread.updatedAt } : {}), + ...(humanAttached !== undefined ? { humanAttached } : {}), + }; +} + +function threadFromRead(value: unknown): Record | undefined { + return isRecord(value) && isRecord(value.thread) ? value.thread : undefined; +} + +function isLoadedThreadReadMiss(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes("thread not found") || message.includes("thread not loaded"); +} + +async function readThread(params: { + request: EndpointRequest; + endpoint: ResolvedSupervisionEndpoint; + threadId: string; + includeTurns: boolean; +}): Promise> { + try { + const response = await params.request(params.endpoint, "thread/read", { + threadId: params.threadId, + includeTurns: params.includeTurns, + }); + const thread = threadFromRead(response); + if (!thread) { + throw new Error("Codex thread/read returned an invalid response"); + } + return thread; + } catch (error) { + if (!params.includeTurns || !String(error).includes("not materialized yet")) { + throw error; + } + const response = await params.request(params.endpoint, "thread/read", { + threadId: params.threadId, + includeTurns: false, + }); + const thread = threadFromRead(response); + if (!thread) { + throw new Error("Codex thread/read returned an invalid response", { cause: error }); + } + return thread; + } +} + +async function listLoadedSessions( + request: EndpointRequest, + endpoint: ResolvedSupervisionEndpoint, +): Promise { + const sessions: CodexSupervisorSession[] = []; + const seenCursors = new Set(); + let cursor: string | undefined; + for (let pageIndex = 0; pageIndex < MAX_COMPAT_PAGINATION_PAGES; pageIndex += 1) { + const listed = await request(endpoint, "thread/loaded/list", { + limit: PAGE_LIMIT, + ...(cursor ? { cursor } : {}), + }); + if (!isRecord(listed) || !Array.isArray(listed.data)) { + throw new Error("Codex thread/loaded/list returned an invalid response"); + } + const threadIds = readLoadedThreadIds(listed.data); + for (const threadId of threadIds) { + if (sessions.some((entry) => entry.threadId === threadId)) { + continue; + } + try { + const thread = await readThread({ request, endpoint, threadId, includeTurns: false }); + const session = toSession(endpoint.id, thread, true); + if (session) { + sessions.push(session); + } + } catch (error) { + if (!isLoadedThreadReadMiss(error)) { + throw error; + } + } + } + const nextCursor = readCompatNextCursor(listed.nextCursor, "thread/loaded/list"); + if (nextCursor && seenCursors.has(nextCursor)) { + throw new Error(`Codex thread/loaded/list returned repeated cursor ${nextCursor}`); + } + if (nextCursor) { + seenCursors.add(nextCursor); + } + cursor = nextCursor; + if (!cursor) { + break; + } + } + if (cursor) { + throw new Error( + `Codex thread/loaded/list exceeded ${MAX_COMPAT_PAGINATION_PAGES} pages with a continuation cursor`, + ); + } + return sessions; +} + +async function listStoredSessions(params: { + request: EndpointRequest; + endpoint: ResolvedSupervisionEndpoint; + limit: number; +}): Promise { + const sessions: CodexSupervisorSession[] = []; + const seenCursors = new Set(); + let cursor: string | undefined; + for (let pageIndex = 0; pageIndex < MAX_COMPAT_PAGINATION_PAGES; pageIndex += 1) { + const remaining = params.limit - sessions.length; + if (remaining <= 0) { + break; + } + const pageLimit = Math.min(PAGE_LIMIT, remaining); + const listed = await params.request(params.endpoint, "thread/list", { + archived: false, + limit: pageLimit, + sourceKinds: [...ALL_CODEX_THREAD_SOURCE_KINDS], + modelProviders: [], + sortKey: "recency_at", + sortDirection: "desc", + useStateDbOnly: true, + ...(cursor ? { cursor } : {}), + }); + if (!isRecord(listed) || !Array.isArray(listed.data)) { + throw new Error("Codex thread/list returned an invalid response"); + } + for (const thread of readStoredThreads(listed.data, pageLimit)) { + if (sessions.length >= params.limit) { + break; + } + const session = toSession(params.endpoint.id, thread); + if (session && !sessions.some((entry) => entry.threadId === session.threadId)) { + sessions.push(session); + } + } + const nextCursor = readCompatNextCursor(listed.nextCursor, "thread/list"); + if (nextCursor && sessions.length < params.limit && seenCursors.has(nextCursor)) { + throw new Error(`Codex thread/list returned repeated cursor ${nextCursor}`); + } + if (nextCursor) { + seenCursors.add(nextCursor); + } + cursor = nextCursor; + if (!cursor || sessions.length >= params.limit) { + break; + } + } + if (cursor && sessions.length < params.limit) { + throw new Error( + `Codex thread/list exceeded ${MAX_COMPAT_PAGINATION_PAGES} pages with a continuation cursor`, + ); + } + return sessions; +} + +async function listSessionSnapshot(params: { + endpoints: ResolvedSupervisionEndpoint[]; + request: EndpointRequest; + includeStored: boolean; + maxStoredSessions?: number; +}): Promise { + const sessions: CodexSupervisorSession[] = []; + const errors: CodexSupervisorEndpointHealth[] = []; + for (const endpoint of params.endpoints) { + try { + const loaded = await listLoadedSessions(params.request, endpoint); + sessions.push(...loaded); + if (params.includeStored) { + const stored = await listStoredSessions({ + request: params.request, + endpoint, + limit: params.maxStoredSessions ?? DEFAULT_MAX_STORED_SESSIONS, + }); + for (const session of stored) { + if ( + !sessions.some( + (entry) => entry.endpointId === endpoint.id && entry.threadId === session.threadId, + ) + ) { + sessions.push(session); + } + } + } + } catch (error) { + if (error instanceof CodexSupervisionPolicyError) { + throw error; + } + errors.push({ + endpointId: endpoint.id, + ok: false, + detail: error instanceof Error ? error.message : String(error), + }); + } + } + return { sessions, errors }; +} + +async function resolveEndpointForThread(params: { + endpoints: ResolvedSupervisionEndpoint[]; + request: EndpointRequest; + endpointId?: string; + threadId: string; +}): Promise { + if (params.endpointId) { + const endpoint = params.endpoints.find((entry) => entry.id === params.endpointId); + if (!endpoint) { + throw new Error(`Unknown Codex supervisor endpoint: ${params.endpointId}`); + } + return endpoint; + } + const matches: ResolvedSupervisionEndpoint[] = []; + for (const endpoint of params.endpoints) { + try { + const thread = await readThread({ + request: params.request, + endpoint, + threadId: params.threadId, + includeTurns: false, + }); + if (thread.id === params.threadId) { + matches.push(endpoint); + } + } catch (error) { + if (error instanceof CodexSupervisionPolicyError) { + throw error; + } + if (!isLoadedThreadReadMiss(error)) { + continue; + } + } + } + if (matches.length === 1) { + return matches[0]; + } + if (matches.length > 1) { + throw new Error(`Codex thread id is ambiguous across endpoints: ${params.threadId}`); + } + throw new Error(`Codex thread not found: ${params.threadId}`); +} + +function findInProgressTurnId(thread: Record): string | undefined { + const turns = asRecordArray(thread.turns); + for (let index = turns.length - 1; index >= 0; index -= 1) { + const turn = turns[index]; + if (turn.status === "inProgress" && typeof turn.id === "string") { + return turn.id; + } + } + return undefined; +} + +async function resolveInProgressTurnId(params: { + request: EndpointRequest; + endpoint: ResolvedSupervisionEndpoint; + thread: Record; + threadId: string; +}): Promise { + const inline = findInProgressTurnId(params.thread); + if (inline) { + return inline; + } + try { + const response = await params.request(params.endpoint, "thread/turns/list", { + threadId: params.threadId, + limit: 10, + sortDirection: "desc", + itemsView: "summary", + }); + return isRecord(response) ? findInProgressTurnId({ turns: response.data }) : undefined; + } catch (error) { + if (error instanceof CodexSupervisionPolicyError) { + throw error; + } + return undefined; + } +} + +function redactString(value: string): string { + return value + .replace(/\b(?:sk|glpat|xox[baprs])-[-_a-zA-Z0-9]{12,}\b/g, "[redacted]") + .replace(/\b(?:ghp|gho|ghu|ghs)_[-_a-zA-Z0-9]{12,}\b/g, "[redacted]") + .replace(/\bBearer\s+[-._~+/a-zA-Z0-9]+=*/g, "Bearer [redacted]"); +} + +/** Redacts secret-bearing fields before legacy tool results leave the plugin. */ +export function redactCodexSupervisionValue(value: unknown, key = ""): unknown { + if (typeof value === "string") { + return /authorization|password|secret|token|api[-_]?key/i.test(key) + ? "[redacted]" + : redactString(value); + } + if (Array.isArray(value)) { + return value.map((entry) => redactCodexSupervisionValue(entry)); + } + if (!isRecord(value)) { + return value; + } + return Object.fromEntries( + Object.entries(value).map(([entryKey, entryValue]) => [ + entryKey, + redactCodexSupervisionValue(entryValue, entryKey), + ]), + ); +} + +function redactEndpointUrl(value: string): string { + if (value.startsWith("unix://")) { + return "unix://"; + } + try { + const url = new URL(value); + url.username = ""; + url.password = ""; + if (url.search) { + url.search = "?[redacted]"; + } + return url.toString(); + } catch { + return "[redacted]"; + } +} + +function endpointResult( + endpoint: ResolvedSupervisionEndpoint, + pluginConfig: unknown, + env: NodeJS.ProcessEnv, +): Record { + const configured = endpoint.configured; + if ( + configured && + (configured.transport === "stdio-proxy" || configured.transport === undefined) + ) { + return { + id: endpoint.id, + transport: "stdio-proxy", + ...(endpoint.label ? { label: endpoint.label } : {}), + }; + } + if (configured?.transport === "websocket") { + return { + id: endpoint.id, + transport: "websocket", + ...(endpoint.label ? { label: endpoint.label } : {}), + url: redactEndpointUrl(configured.url), + }; + } + const start = resolveCodexSupervisionAppServerRuntimeOptions({ pluginConfig, env }).start; + return { + id: endpoint.id, + transport: start.transport === "stdio" ? "stdio-proxy" : "websocket", + ...(endpoint.label ? { label: endpoint.label } : {}), + ...(start.transport === "stdio" + ? {} + : { + url: redactEndpointUrl( + start.transport === "unix" ? (start.url ?? "unix://") : (start.url ?? ""), + ), + }), + }; +} + +function sanitizeSessionListResult( + result: CodexSupervisorSessionListResult, + includeTranscriptDerivedFields: boolean, +): Record { + return { + sessions: result.sessions.map((session) => { + const sanitized = redactCodexSupervisionValue(session) as Record; + if (!includeTranscriptDerivedFields) { + delete sanitized.preview; + delete sanitized.name; + } + return sanitized; + }), + errors: includeTranscriptDerivedFields + ? redactCodexSupervisionValue(result.errors) + : result.errors.map(({ endpointId, ok }) => ({ endpointId, ok })), + }; +} + +function requireSupervisionEnabled(pluginConfig: unknown): void { + if (readCodexPluginConfig(pluginConfig).supervision?.enabled !== true) { + throw new CodexSupervisionPolicyError( + "Codex supervision is disabled in the codex plugin config.", + ); + } +} + +function requireOwnerAccess(options: CodexSupervisionToolsOptions): void { + if (!options.senderIsOwner) { + throw new CodexSupervisionPolicyError( + "Codex supervision compatibility tools require an owner-authorized sender.", + ); + } +} + +function resolveToolPolicy( + options: CodexSupervisionToolsOptions, + pluginConfig: unknown, +): { + allowRawTranscripts: boolean; + allowWriteControls: boolean; +} { + const config = readCodexPluginConfig(pluginConfig).supervision; + const env = options.env ?? process.env; + return { + allowRawTranscripts: + config?.allowRawTranscripts === true || + (options.useLegacyMcpPolicyEnv === true && + env[LEGACY_CODEX_SUPERVISOR_RAW_TRANSCRIPTS_ENV] === "1"), + allowWriteControls: + config?.allowWriteControls === true || + (options.useLegacyMcpPolicyEnv === true && + env[LEGACY_CODEX_SUPERVISOR_WRITE_CONTROLS_ENV] === "1"), + }; +} + +function requireRawTranscriptAccess( + options: CodexSupervisionToolsOptions, + pluginConfig: unknown, +): void { + if (!resolveToolPolicy(options, pluginConfig).allowRawTranscripts) { + throw new CodexSupervisionPolicyError( + "Codex session reads are disabled for this codex plugin supervision config.", + ); + } +} + +function requireWriteAccess(options: CodexSupervisionToolsOptions, pluginConfig: unknown): void { + if (!resolveToolPolicy(options, pluginConfig).allowWriteControls) { + throw new CodexSupervisionPolicyError( + "Codex write controls are disabled for this codex plugin supervision config.", + ); + } +} + +function requireLiveToolPolicy( + options: CodexSupervisionToolsOptions, + policy: CodexSupervisionRequestPolicy, +): { pluginConfig: unknown; endpoints: ResolvedSupervisionEndpoint[] } { + requireOwnerAccess(options); + const pluginConfig = options.getPluginConfig(); + requireSupervisionEnabled(pluginConfig); + if (policy === "raw-transcripts") { + requireRawTranscriptAccess(options, pluginConfig); + } else if (policy === "write-controls") { + requireWriteAccess(options, pluginConfig); + } + return { + pluginConfig, + endpoints: resolveEndpoints( + pluginConfig, + options.env ?? process.env, + options.getRuntimeConfig?.(), + ), + }; +} + +function requireCurrentEndpoint( + options: CodexSupervisionToolsOptions, + policy: CodexSupervisionRequestPolicy, + endpoint: ResolvedSupervisionEndpoint, +): ResolvedSupervisionEndpoint { + const { endpoints } = requireLiveToolPolicy(options, policy); + const currentEndpoint = endpoints.find((candidate) => candidate.id === endpoint.id); + if (!currentEndpoint || currentEndpoint.connectionKey !== endpoint.connectionKey) { + throw new CodexSupervisionPolicyError( + `Codex supervision endpoint ${endpoint.id} was removed or changed during the request.`, + ); + } + return currentEndpoint; +} + +function requireCurrentEndpointSet( + options: CodexSupervisionToolsOptions, + expected: ResolvedSupervisionEndpoint[], +): { pluginConfig: unknown } { + const current = requireLiveToolPolicy(options, "enabled"); + const unchanged = + current.endpoints.length === expected.length && + expected.every((endpoint) => + current.endpoints.some( + (candidate) => + candidate.id === endpoint.id && candidate.connectionKey === endpoint.connectionKey, + ), + ); + if (!unchanged) { + throw new CodexSupervisionPolicyError( + "Codex supervision endpoint configuration changed during the request.", + ); + } + return { pluginConfig: current.pluginConfig }; +} + +function createPolicyGuardedRequest( + options: CodexSupervisionToolsOptions, + request: EndpointRequest, + policy: CodexSupervisionRequestPolicy, +): EndpointRequest { + return async (endpoint: ResolvedSupervisionEndpoint, method: string, params?: unknown) => { + // Configuration can be reloaded while one compatibility call is paginating or resolving a + // thread. Recheck immediately before every app-server request so revocation stops the call. + const currentEndpoint = requireCurrentEndpoint(options, policy, endpoint); + return await request(currentEndpoint, method, params); + }; +} + +function idleContinuationError(threadId: string): Error { + return new Error( + `Codex thread ${threadId} is idle. Continue it from Codex Sessions so OpenClaw can install the Codex harness approval and tool handlers before resume.`, + ); +} + +/** Builds the five shipped Codex Supervisor compatibility tools. */ +export function createCodexSupervisionTools(options: CodexSupervisionToolsOptions): AnyAgentTool[] { + const baseRequest = options.request ?? createCanonicalEndpointRequest(options); + const request = createPolicyGuardedRequest(options, baseRequest, "enabled"); + const rawTranscriptRequest = createPolicyGuardedRequest(options, baseRequest, "raw-transcripts"); + const writeRequest = createPolicyGuardedRequest(options, baseRequest, "write-controls"); + const current = () => { + // Keep the execute-time check beside factory filtering so direct/internal + // callers cannot construct a usable tool without explicit owner authorization. + return requireLiveToolPolicy(options, "enabled"); + }; + + return [ + { + name: "codex_endpoint_probe", + label: "Codex Endpoint Probe", + description: "Check configured Codex app-server endpoints.", + parameters: EmptyParamsSchema, + execute: async () => { + const { pluginConfig, endpoints } = current(); + const health: CodexSupervisorEndpointHealth[] = []; + for (const endpoint of endpoints) { + try { + await request(endpoint, "thread/loaded/list", { limit: 1 }); + health.push({ endpointId: endpoint.id, ok: true }); + } catch (error) { + if (error instanceof CodexSupervisionPolicyError) { + throw error; + } + health.push({ endpointId: endpoint.id, ok: false }); + } + } + requireCurrentEndpointSet(options, endpoints); + return jsonResult({ + summary: `codex endpoints: ${health.filter((entry) => entry.ok).length}/${health.length} ok`, + endpoints: endpoints.map((endpoint) => + endpointResult(endpoint, pluginConfig, options.env ?? process.env), + ), + health, + }); + }, + }, + { + name: "codex_sessions_list", + label: "Codex Sessions List", + description: "List Codex sessions visible to the OpenClaw supervisor.", + parameters: SessionsListParamsSchema, + execute: async (_toolCallId, rawParams) => { + const params = asRecord(rawParams); + const { endpoints } = current(); + const result = await listSessionSnapshot({ + endpoints, + request, + includeStored: readBooleanParam(params, "include_stored"), + maxStoredSessions: readIntegerParam(params, "max_stored_sessions"), + }); + const { pluginConfig } = requireCurrentEndpointSet(options, endpoints); + return jsonResult({ + summary: `codex sessions: ${result.sessions.length}`, + ...sanitizeSessionListResult( + result, + resolveToolPolicy(options, pluginConfig).allowRawTranscripts, + ), + }); + }, + }, + { + name: "codex_session_read", + label: "Codex Session Read", + description: "Read one Codex session transcript from app-server.", + parameters: SessionReadParamsSchema, + execute: async (_toolCallId, rawParams) => { + const { endpoints, pluginConfig } = current(); + requireRawTranscriptAccess(options, pluginConfig); + const params = asRecord(rawParams); + const threadId = readStringParam(params, "thread_id", { required: true }); + const endpoint = await resolveEndpointForThread({ + endpoints, + request: rawTranscriptRequest, + endpointId: readStringParam(params, "endpoint_id"), + threadId, + }); + const thread = await readThread({ + request: rawTranscriptRequest, + endpoint, + threadId, + includeTurns: readBooleanParam(params, "include_turns"), + }); + requireCurrentEndpoint(options, "raw-transcripts", endpoint); + return jsonResult({ + summary: `codex session: ${threadId}`, + response: redactCodexSupervisionValue({ thread }), + }); + }, + }, + { + name: "codex_session_send", + label: "Codex Session Send", + description: + "Steer an active Codex turn. Idle sessions must be continued through Codex Sessions.", + parameters: SessionSendParamsSchema, + execute: async (_toolCallId, rawParams) => { + const { endpoints, pluginConfig } = current(); + requireWriteAccess(options, pluginConfig); + const params = asRecord(rawParams); + const threadId = readStringParam(params, "thread_id", { required: true }); + const text = readStringParam(params, "text", { required: true, allowEmpty: false }); + const mode = readModeParam(params) ?? "auto"; + if (mode === "start") { + throw idleContinuationError(threadId); + } + const endpoint = await resolveEndpointForThread({ + endpoints, + request: writeRequest, + endpointId: readStringParam(params, "endpoint_id"), + threadId, + }); + const thread = await readThread({ + request: writeRequest, + endpoint, + threadId, + includeTurns: true, + }); + requireCurrentEndpoint(options, "write-controls", endpoint); + if (statusType(thread) !== "active") { + throw idleContinuationError(threadId); + } + const turnId = await resolveInProgressTurnId({ + request: writeRequest, + endpoint, + thread, + threadId, + }); + if (!turnId) { + throw new Error(`Codex thread ${threadId} is active but no in-progress turn is readable`); + } + await writeRequest(endpoint, "turn/steer", { + threadId, + expectedTurnId: turnId, + input: [{ type: "text", text, text_elements: [] }], + }); + const result = { endpointId: endpoint.id, threadId, mode: "steer" as const, turnId }; + return jsonResult({ summary: `codex steer: ${turnId}`, result }); + }, + }, + { + name: "codex_session_interrupt", + label: "Codex Session Interrupt", + description: "Interrupt an active Codex turn.", + parameters: SessionInterruptParamsSchema, + execute: async (_toolCallId, rawParams) => { + const { endpoints, pluginConfig } = current(); + requireWriteAccess(options, pluginConfig); + const params = asRecord(rawParams); + const threadId = readStringParam(params, "thread_id", { required: true }); + const endpoint = await resolveEndpointForThread({ + endpoints, + request: writeRequest, + endpointId: readStringParam(params, "endpoint_id"), + threadId, + }); + const thread = await readThread({ + request: writeRequest, + endpoint, + threadId, + includeTurns: true, + }); + requireCurrentEndpoint(options, "write-controls", endpoint); + if (statusType(thread) !== "active") { + throw new Error(`Codex thread ${threadId} has no active turn to interrupt`); + } + const turnId = + readStringParam(params, "turn_id") ?? + (await resolveInProgressTurnId({ + request: writeRequest, + endpoint, + thread, + threadId, + })); + if (!turnId) { + throw new Error(`Codex thread ${threadId} has no readable in-progress turn`); + } + await writeRequest(endpoint, "turn/interrupt", { threadId, turnId }); + const result = { endpointId: endpoint.id, threadId, turnId }; + return jsonResult({ summary: `codex interrupted: ${turnId}`, result }); + }, + }, + ]; +} diff --git a/extensions/discord/src/monitor/native-command-model-picker-apply.ts b/extensions/discord/src/monitor/native-command-model-picker-apply.ts index 33ba997ba713..1e6daba03604 100644 --- a/extensions/discord/src/monitor/native-command-model-picker-apply.ts +++ b/extensions/discord/src/monitor/native-command-model-picker-apply.ts @@ -2,7 +2,10 @@ import { randomUUID } from "node:crypto"; import type { ChatCommandDefinition, CommandArgs } from "openclaw/plugin-sdk/command-auth-native"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/model-session-runtime"; +import { + applyModelOverrideToSessionEntry, + ModelSelectionLockedError, +} from "openclaw/plugin-sdk/model-session-runtime"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { patchSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; @@ -180,6 +183,12 @@ export async function applyDiscordModelPickerSelection(params: { ); } } catch (error) { + if (error instanceof ModelSelectionLockedError) { + return { + status: "rejected", + noticeMessage: `❌ ${error.message}`, + }; + } const message = error instanceof Error ? error.message : String(error); logVerbose( `discord: direct session override persist threw for session key ${fallbackRoute.sessionKey}: ${message}`, @@ -207,6 +216,12 @@ export async function applyDiscordModelPickerSelection(params: { noticeMessage: `⚠️ Tried to set ${params.resolvedModelRef}, but current model is ${effectiveModelRef}.`, }; } catch (error) { + if (error instanceof ModelSelectionLockedError) { + return { + status: "rejected", + noticeMessage: `❌ ${error.message}`, + }; + } if (error instanceof Error && error.message === "timeout") { return { status: "timeout", diff --git a/extensions/discord/src/monitor/native-command.model-picker.test.ts b/extensions/discord/src/monitor/native-command.model-picker.test.ts index fd2bfc394ac6..47749555cb3b 100644 --- a/extensions/discord/src/monitor/native-command.model-picker.test.ts +++ b/extensions/discord/src/monitor/native-command.model-picker.test.ts @@ -901,6 +901,68 @@ describe("Discord model picker interactions", () => { ).toContain("❌ Failed to apply openai/gpt-4o."); }); + it("shows a locked-session rejection without writing a fallback override", async () => { + const context = createModelPickerContext(); + context.threadBindings = createBoundThreadBindingManager({ + accountId: "default", + threadId: "thread-bound", + targetSessionKey: "agent:worker:subagent:bound", + agentId: "worker", + }); + const pickerData = createDefaultModelPickerData(); + pickerData.runtimeChoicesByProvider = new Map([ + [ + "openai", + [ + { id: "codex", label: "Codex", description: "Use Codex." }, + { id: "openclaw", label: "OpenClaw Default", description: "Use OpenClaw." }, + ], + ], + ]); + const modelCommand = createModelCommandDefinition(); + const storePath = resolveStorePath(context.cfg.session?.store, { agentId: "worker" }); + await upsertSessionEntry({ + storePath, + sessionKey: "agent:worker:subagent:bound", + entry: { + updatedAt: Date.now(), + sessionId: "bound-session", + providerOverride: "openai", + modelOverride: "gpt-5.5", + agentHarnessId: "codex", + agentRuntimeOverride: "codex", + modelSelectionLocked: true, + }, + }); + + vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData); + mockModelCommandPipeline(modelCommand); + + const button = createModelPickerFallbackButton(context, createDispatchSpy()); + const submitInteraction = createInteraction({ userId: "owner" }); + submitInteraction.channel = { + type: ChannelType.PublicThread, + id: "thread-bound", + }; + + await button.run(submitInteraction as unknown as PickerButtonInteraction, { + ...createModelsViewSubmitData(), + r: "openclaw", + }); + + const store = loadSessionStore(storePath, { skipCache: true }); + expect(store["agent:worker:subagent:bound"]).toMatchObject({ + providerOverride: "openai", + modelOverride: "gpt-5.5", + agentHarnessId: "codex", + agentRuntimeOverride: "codex", + modelSelectionLocked: true, + }); + expect( + JSON.stringify(firstMockArg(submitInteraction.followUp, "interaction.followUp")), + ).toContain("❌ Model selection is locked for this session."); + }); + it("loads model picker data from the effective bound route", async () => { const context = createModelPickerContext(); context.threadBindings = createBoundThreadBindingManager({ diff --git a/extensions/feishu/src/doctor.test.ts b/extensions/feishu/src/doctor.test.ts index 558cd712cbb9..c5e8a319f5c0 100644 --- a/extensions/feishu/src/doctor.test.ts +++ b/extensions/feishu/src/doctor.test.ts @@ -345,6 +345,48 @@ describe("Feishu doctor state repair", () => { ).toBe(true); }); + it("preserves locked harness sessions while repairing ordinary Feishu sessions", async () => { + const lockedTranscriptPath = writeTranscript("sess-codex-locked", [ + sessionHeader("sess-codex-locked"), + userMessage(""), + userMessage(""), + userMessage(""), + ]); + const ordinaryTranscriptPath = writeTranscript("sess-feishu-bad", [ + sessionHeader("sess-feishu-bad"), + userMessage(""), + userMessage(""), + userMessage(""), + ]); + const targetStorePath = writeStore({ + "agent:main:ordinary-codex-locked": { + sessionId: "sess-codex-locked", + sessionFile: "sess-codex-locked.jsonl", + agentHarnessId: "codex", + modelSelectionLocked: true, + route: { channel: "feishu", target: { to: "ou_user", chatType: "direct" } }, + }, + "agent:main:feishu:direct:ou_user": { + sessionId: "sess-feishu-bad", + sessionFile: "sess-feishu-bad.jsonl", + }, + }); + + const result = await runFeishuDoctorSequence({ + cfg: feishuConfig(), + env: process.env, + shouldRepair: true, + }); + + expect(result.warningNotes).toEqual([]); + expect(result.changeNotes.join("\n")).toContain("Removed 1 Feishu-scoped session entry"); + const store = loadSessionStore(targetStorePath, { skipCache: true }); + expect(store["agent:main:ordinary-codex-locked"]).toBeDefined(); + expect(store["agent:main:feishu:direct:ou_user"]).toBeUndefined(); + expect(fs.existsSync(lockedTranscriptPath)).toBe(true); + expect(fs.existsSync(ordinaryTranscriptPath)).toBe(false); + }); + it("archives unhealthy default-scope sessions when metadata identifies Feishu", async () => { const transcriptPath = writeTranscript("sess-default-feishu-bad", [ sessionHeader("sess-default-feishu-bad"), diff --git a/extensions/feishu/src/doctor.ts b/extensions/feishu/src/doctor.ts index 3cd615e896f2..9285abc5593b 100644 --- a/extensions/feishu/src/doctor.ts +++ b/extensions/feishu/src/doctor.ts @@ -9,6 +9,7 @@ import type { import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; import { + isValidAgentHarnessSessionStoreEntry, loadSessionStore, resolveSessionFilePath, resolveStorePath, @@ -559,6 +560,11 @@ function inspectFeishuDoctorState(params: { for (const [key, entry] of Object.entries(store).toSorted(([left], [right]) => left.localeCompare(right), )) { + // Harness ownership supersedes channel-derived route metadata. Feishu + // doctor must not diagnose or clean up another runtime's locked row. + if (isRecord(entry) && isValidAgentHarnessSessionStoreEntry(key, entry)) { + continue; + } if (!isFeishuSessionEntry(key, entry)) { continue; } @@ -738,12 +744,19 @@ async function repairFeishuDoctorState(params: { (store) => { const removed: typeof group.entries = []; for (const key of keys) { - if (Object.hasOwn(store, key)) { - delete store[key]; - const entry = group.entries.find((candidate) => candidate.key === key); - if (entry) { - removed.push(entry); - } + const currentEntry = store[key]; + // Recheck under the store update lock because a harness can claim + // a previously flagged row after inspection but before repair. + if ( + !Object.hasOwn(store, key) || + (currentEntry && isValidAgentHarnessSessionStoreEntry(key, currentEntry)) + ) { + continue; + } + delete store[key]; + const entry = group.entries.find((candidate) => candidate.key === key); + if (entry) { + removed.push(entry); } } return removed; diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 2c76604a7bc3..8d9657bbafc1 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -36,7 +36,10 @@ import { } from "openclaw/plugin-sdk/conversation-runtime"; import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; -import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/model-session-runtime"; +import { + applyModelOverrideToSessionEntry, + ModelSelectionLockedError, +} from "openclaw/plugin-sdk/model-session-runtime"; import { formatModelsAvailableHeader } from "openclaw/plugin-sdk/models-provider-runtime"; import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history"; @@ -3463,6 +3466,14 @@ export const registerTelegramHandlers = ({ }, }); } catch (err) { + if (err instanceof ModelSelectionLockedError) { + try { + await editMessageWithButtons(`❌ ${err.message}`, []); + } catch (editErr) { + throw new TelegramRetryableCallbackError(editErr); + } + return; + } throw new TelegramRetryableCallbackError(err); } diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 5e4b8a419a09..ab158476b73e 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -6039,4 +6039,45 @@ describe("createTelegramBot", () => { ), ).toBe(false); }); + + it("shows a permanent rejection when model selection is locked", async () => { + createTelegramBot({ token: "tok" }); + const callbackHandler = getOnHandler("callback_query"); + const patchSessionEntrySpy = vi + .spyOn(sessionStoreRuntime, "patchSessionEntry") + .mockImplementationOnce(async (params) => { + const entry = { + sessionId: "locked-session", + updatedAt: Date.now(), + modelSelectionLocked: true, + }; + await params.update(entry, { existingEntry: entry }); + return entry; + }); + const ctx = { + callbackQuery: { + id: "cbq-model-select-locked-1", + data: "mdl_sel_openai/gpt-5.4", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 25, + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }; + + try { + await expect(callbackHandler(ctx)).resolves.toBeUndefined(); + } finally { + patchSessionEntrySpy.mockRestore(); + } + + expect(editMessageTextSpy).toHaveBeenCalledTimes(1); + expect(editMessageTextSpy.mock.calls.at(-1)?.[2]).toBe( + "❌ Model selection is locked for this session.", + ); + }); }); diff --git a/extensions/voice-call/src/response-generator.test.ts b/extensions/voice-call/src/response-generator.test.ts index 5e71e9b4ffa4..ddbb2e0bcda4 100644 --- a/extensions/voice-call/src/response-generator.test.ts +++ b/extensions/voice-call/src/response-generator.test.ts @@ -7,20 +7,27 @@ import { generateVoiceResponse } from "./response-generator.js"; type TestSessionEntry = { sessionId: string; updatedAt: number; + agentHarnessId?: string; + agentRuntimeOverride?: string; providerOverride?: string; modelOverride?: string; modelOverrideSource?: string; model?: string; modelProvider?: string; + modelSelectionLocked?: boolean; + pluginExtensions?: Record; contextTokens?: number; authProfileOverride?: string; }; type EmbeddedAgentArgs = { abortSignal?: AbortSignal; + agentHarnessId?: string; + agentHarnessRuntimeOverride?: string; extraSystemPrompt: string; provider?: string; model?: string; + modelSelectionLocked?: boolean; sessionKey?: string; sessionTarget?: { agentId?: string; @@ -557,6 +564,88 @@ describe("generateVoiceResponse", () => { expect(args.sessionKey).toBe("agent:main:voice:15550001111"); }); + it("rejects responseModel for a model-locked session without running the embedded agent", async () => { + const { runtime, runEmbeddedAgent, patchSessionEntry, sessionStore } = createAgentRuntime([]); + sessionStore["agent:main:voice:15550001111"] = { + sessionId: "locked-session", + updatedAt: 100, + model: "gpt-5.5", + modelProvider: "openai", + modelSelectionLocked: true, + }; + const voiceConfig = VoiceCallConfigSchema.parse({ + responseModel: "openai/gpt-4.1-nano", + responseTimeoutMs: 5000, + }); + + const result = await generateVoiceResponse({ + voiceConfig, + coreConfig: {} as CoreConfig, + agentRuntime: runtime, + callId: "call-123", + from: "+15550001111", + transcript: [{ speaker: "user", text: "hello there" }], + userMessage: "hello there", + }); + + expect(result).toEqual({ + text: null, + deliveredEarly: false, + error: "Model selection is locked for this session.", + }); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); + expect(patchSessionEntry).not.toHaveBeenCalled(); + expect(sessionStore["agent:main:voice:15550001111"]).toMatchObject({ + model: "gpt-5.5", + modelProvider: "openai", + modelSelectionLocked: true, + }); + expect(sessionStore["agent:main:voice:15550001111"]?.modelOverride).toBeUndefined(); + }); + + it("propagates the lock for a same-agent supervised Codex session", async () => { + const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime([ + { text: '{"spoken":"Native Codex continued."}' }, + ]); + const sessionKey = "agent:main:harness:codex:supervision:019f-codex-thread"; + sessionStore[sessionKey] = { + sessionId: "catalog-adopted-session", + updatedAt: 100, + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { + supervision: { + sourceThreadId: "019f-codex-thread", + modelLocked: true, + }, + }, + }, + }; + const voiceConfig = VoiceCallConfigSchema.parse({ responseTimeoutMs: 5000 }); + + const result = await generateVoiceResponse({ + voiceConfig, + coreConfig: {} as CoreConfig, + agentRuntime: runtime, + callId: "call-123", + sessionKey, + from: "+15550001111", + transcript: [{ speaker: "user", text: "continue" }], + userMessage: "continue", + }); + + expect(result.text).toBe("Native Codex continued."); + expect(requireEmbeddedAgentArgs(runEmbeddedAgent)).toMatchObject({ + provider: "together", + model: "Qwen/Qwen2.5-7B-Instruct-Turbo", + modelSelectionLocked: true, + agentHarnessId: "codex", + agentHarnessRuntimeOverride: "codex", + sessionKey, + }); + }); + it("canonicalizes a restored legacy per-call key for classic responses", async () => { const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime([ { text: '{"spoken":"Fresh call context."}' }, diff --git a/extensions/voice-call/src/response-generator.ts b/extensions/voice-call/src/response-generator.ts index 56a4e970d649..276bd45c1b58 100644 --- a/extensions/voice-call/src/response-generator.ts +++ b/extensions/voice-call/src/response-generator.ts @@ -4,7 +4,11 @@ */ import crypto from "node:crypto"; -import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/model-session-runtime"; +import { + applyModelOverrideToSessionEntry, + ModelSelectionLockedError, + resolvePersistedSessionRuntimeId, +} from "openclaw/plugin-sdk/model-session-runtime"; import { isRecord, normalizeLowercaseStringOrEmpty, @@ -286,6 +290,9 @@ export async function generateVoiceResponse( const { provider, model } = resolveVoiceResponseModel({ voiceConfig, agentRuntime }); let sessionEntry = existingSessionEntry; + if (sessionEntry?.modelSelectionLocked === true && voiceConfig.responseModel) { + throw new ModelSelectionLockedError(); + } if (!sessionEntry?.sessionId || voiceConfig.responseModel) { sessionEntry = (await agentRuntime.session.patchSessionEntry({ @@ -323,6 +330,8 @@ export async function generateVoiceResponse( }; } const sessionId = sessionEntry.sessionId; + const modelSelectionLocked = sessionEntry.modelSelectionLocked === true; + const persistedRuntimeId = resolvePersistedSessionRuntimeId(sessionEntry); // Resolve thinking level const thinkLevel = agentRuntime.resolveThinkingDefault({ cfg, provider, model }); @@ -373,6 +382,13 @@ export async function generateVoiceResponse( prompt: userMessage, provider, model, + modelSelectionLocked, + ...(persistedRuntimeId + ? { + agentHarnessId: persistedRuntimeId, + agentHarnessRuntimeOverride: persistedRuntimeId, + } + : {}), thinkLevel, verboseLevel: "off", timeoutMs, @@ -442,6 +458,9 @@ export async function generateVoiceResponse( }, ); } catch (err) { + if (err instanceof ModelSelectionLockedError) { + return { text: null, deliveredEarly: false, error: err.message }; + } console.error(`[voice-call] Response generation failed:`, err); return { text: null, deliveredEarly: false, error: String(err) }; } diff --git a/extensions/voice-call/src/runtime.test.ts b/extensions/voice-call/src/runtime.test.ts index 4b1df7103385..f205c49da844 100644 --- a/extensions/voice-call/src/runtime.test.ts +++ b/extensions/voice-call/src/runtime.test.ts @@ -593,6 +593,50 @@ describe("createVoiceCallRuntime lifecycle", () => { expect(consultParams.sessionKey).toBe("agent:main:voice:call:call-1"); }); + it("blocks locked Codex realtime consults before fast context or model dispatch", async () => { + const config = createBaseConfig(); + config.realtime.enabled = true; + const sessionStore: Record = { + "agent:main:voice:15550001234": { + sessionId: "locked-codex-session", + updatedAt: 1, + agentHarnessId: "codex", + modelSelectionLocked: true, + }, + }; + const runEmbeddedAgent = vi.fn(); + const agentRuntime = { + resolveAgentDir: vi.fn(() => "/tmp/agent"), + resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), + resolveAgentIdentity: vi.fn(), + resolveThinkingDefault: vi.fn(() => "high"), + resolveAgentTimeoutMs: vi.fn(() => 30_000), + ensureAgentWorkspace: vi.fn(async () => {}), + session: createMockSessionRuntime(sessionStore), + runEmbeddedAgent, + }; + mocks.managerGetCall.mockReturnValue({ + callId: "call-locked", + direction: "inbound", + from: "+15550001234", + to: "+15550009999", + transcript: [], + }); + + await createVoiceCallRuntime({ + config, + coreConfig: {} as CoreConfig, + agentRuntime: agentRuntime as never, + }); + + const handler = requireRealtimeConsultToolHandler(); + await expect(handler({ question: "Continue this session." }, "call-locked")).rejects.toThrow( + "Model selection is locked for this session.", + ); + expect(mocks.resolveRealtimeFastContextConsult).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); + }); + it("answers realtime consults from fast memory context before starting the full agent", async () => { const config = createBaseConfig(); config.realtime.enabled = true; diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index 5d01bb019d9a..8d8aaf134957 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -4,6 +4,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { + assertRealtimeVoiceAgentConsultModelSelectionUnlocked, consultRealtimeVoiceAgent, REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, resolveRealtimeVoiceAgentConsultTools, @@ -396,6 +397,14 @@ export async function createVoiceCallRuntime(params: { typeof call.metadata?.requesterSessionKey === "string" ? call.metadata.requesterSessionKey : undefined; + const modelLockParams = { + cfg, + agentRuntime, + agentId, + sessionKey, + spawnedBy: requesterSessionKey, + }; + assertRealtimeVoiceAgentConsultModelSelectionUnlocked(modelLockParams); const fastContext = await resolveRealtimeFastContextConsult({ cfg, agentId, @@ -405,6 +414,7 @@ export async function createVoiceCallRuntime(params: { logger: log, }); if (fastContext.handled) { + assertRealtimeVoiceAgentConsultModelSelectionUnlocked(modelLockParams); return fastContext.result; } const { provider: agentProvider, model } = resolveVoiceResponseModel({ diff --git a/package.json b/package.json index 16c298b7028e..f8fceebc1d39 100644 --- a/package.json +++ b/package.json @@ -1561,7 +1561,8 @@ "clean:dist": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\"", "codex-app-server:protocol:check": "node --import tsx scripts/check-codex-app-server-protocol.ts", "codex-app-server:protocol:sync": "node --import tsx scripts/sync-codex-app-server-protocol.ts", - "codex-supervisor:mcp": "node --import tsx extensions/codex-supervisor/src/mcp-serve.ts", + "codex-supervisor:mcp": "node --import tsx src/mcp/codex-supervision-tools-serve.ts", + "codex:mcp": "node --import tsx src/mcp/codex-supervision-tools-serve.ts", "config:channels:check": "node --import tsx scripts/generate-bundled-channel-config-metadata.ts --check", "config:channels:gen": "node --import tsx scripts/generate-bundled-channel-config-metadata.ts --write", "config:docs:check": "node --import tsx scripts/generate-config-doc-baseline.ts --check", diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index f57ff3853ab6..a9b2967b040a 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -1909,5 +1909,13 @@ type SessionsPatchResult = { type GatewayAgentRuntime = { id: string; fallback?: "openclaw" | "none"; - source: "env" | "agent" | "defaults" | "model" | "provider" | "implicit" | "session-key"; + source: + | "env" + | "agent" + | "defaults" + | "model" + | "provider" + | "implicit" + | "session" + | "session-key"; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cdf5a6dcd7ff..31b644c62d16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -547,25 +547,6 @@ importers: specifier: workspace:* version: link:../../packages/plugin-sdk - extensions/codex-supervisor: - dependencies: - '@modelcontextprotocol/sdk': - specifier: 1.29.0 - version: 1.29.0(zod@4.4.3) - typebox: - specifier: 1.3.3 - version: 1.3.3 - ws: - specifier: 8.21.0 - version: 8.21.0 - zod: - specifier: 4.4.3 - version: 4.4.3 - devDependencies: - '@openclaw/plugin-sdk': - specifier: workspace:* - version: link:../../packages/plugin-sdk - extensions/cohere: devDependencies: '@openclaw/plugin-sdk': diff --git a/scripts/lib/session-accessor-debt-baseline.json b/scripts/lib/session-accessor-debt-baseline.json index a111c9a4fdfb..f3ba2a93e2a3 100644 --- a/scripts/lib/session-accessor-debt-baseline.json +++ b/scripts/lib/session-accessor-debt-baseline.json @@ -34,12 +34,13 @@ "src/config/sessions/session-accessor.ts": 25, "src/config/sessions/session-registry-maintenance.ts": 2, "src/config/sessions/store-load.ts": 5, - "src/config/sessions/store.ts": 15, + "src/config/sessions/store.ts": 16, "src/config/sessions/test-helpers.ts": 2, "src/config/sessions/transcript.ts": 6, "src/cron/isolated-agent/session.ts": 2, "src/infra/approval-request-account-binding.ts": 2, "src/infra/heartbeat-runner.ts": 4, + "src/plugins/registry.ts": 2, "src/plugins/runtime/runtime-agent.ts": 1 }, "sessionAccessorWrite": { @@ -49,6 +50,7 @@ "src/auto-reply/reply/commands-name.ts": 2, "src/auto-reply/reply/dispatch-from-config.ts": 2, "src/commands/doctor-heartbeat-main-session-repair.ts": 2, + "src/commands/doctor-session-snapshots.ts": 2, "src/commands/doctor-session-state-providers.ts": 2, "src/commands/doctor-state-integrity.ts": 2, "src/commands/doctor/shared/codex-route-warnings.ts": 2, @@ -61,6 +63,7 @@ "src/gateway/test-helpers.mocks.ts": 1, "src/infra/heartbeat-runner.ts": 5, "src/infra/state-migrations.ts": 2, + "src/plugins/registry.ts": 3, "src/plugins/runtime/runtime-agent.ts": 2 }, "sessionCompactManualTrim": {}, diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 5cc6177ecc8b..a38705809e4d 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -195,12 +195,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { ), publicExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", - 10499, + 10505, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", - 5238, + 5242, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/agents/acp-runtime-overlay.ts b/src/agents/acp-runtime-overlay.ts index 0411959ad003..abf511fe3dc2 100644 --- a/src/agents/acp-runtime-overlay.ts +++ b/src/agents/acp-runtime-overlay.ts @@ -9,7 +9,7 @@ import { isAcpSessionKey } from "../routing/session-key.js"; */ export type AgentRuntimeMetadata = { id: string; - source: "implicit" | "model" | "provider" | "session-key"; + source: "implicit" | "model" | "provider" | "session" | "session-key"; }; /** diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index 325a6d572a64..74112e6ef0de 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -40,7 +40,12 @@ const state = vi.hoisted(() => ({ ), resolveEffectiveModelFallbacksMock: vi.fn().mockReturnValue(undefined), hasLegacyAutoFallbackWithoutOriginMock: vi.fn((_entry: unknown) => false), + isModelSelectionLockedMock: vi.fn( + (entry: unknown) => + (entry as { modelSelectionLocked?: boolean } | undefined)?.modelSelectionLocked === true, + ), applyModelOverrideToSessionEntryMock: vi.fn((_params: unknown) => ({ updated: false })), + repairProviderWrappedModelOverrideMock: vi.fn((_params: unknown) => ({ updated: false })), resolveAutoFallbackPrimaryProbeMock: vi.fn((_params: unknown) => undefined as unknown), resolveChannelModelOverrideMock: vi.fn((_params: unknown) => null as unknown), assertLifecycleCurrentMock: vi.fn(), @@ -369,7 +374,16 @@ vi.mock("../sessions/level-overrides.js", () => ({ vi.mock("../sessions/model-overrides.js", () => ({ applyModelOverrideToSessionEntry: (params: unknown) => state.applyModelOverrideToSessionEntryMock(params), - repairProviderWrappedModelOverride: () => ({ updated: false }), + isModelSelectionLocked: (entry: unknown) => state.isModelSelectionLockedMock(entry), + MODEL_SELECTION_LOCKED_MESSAGE: "Model selection is locked for this session.", + ModelSelectionLockedError: class ModelSelectionLockedError extends Error { + constructor() { + super("Model selection is locked for this session."); + this.name = "ModelSelectionLockedError"; + } + }, + repairProviderWrappedModelOverride: (params: unknown) => + state.repairProviderWrappedModelOverrideMock(params), })); vi.mock("../sessions/send-policy.js", () => ({ @@ -846,6 +860,7 @@ type FallbackRunnerParams = { provider: string; model: string; sessionId?: string; + fallbacksOverride?: string[]; resolveAgentHarnessRuntimeOverride?: (provider: string, model: string) => string | undefined; run: (provider: string, model: string) => Promise; onFallbackStep?: (step: Record) => void | Promise; @@ -988,7 +1003,12 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { state.resolveAgentSkillsFilterMock.mockReturnValue(undefined); state.loadManifestModelCatalogMock.mockReturnValue([]); state.hasLegacyAutoFallbackWithoutOriginMock.mockReturnValue(false); + state.isModelSelectionLockedMock.mockImplementation( + (entry: unknown) => + (entry as { modelSelectionLocked?: boolean } | undefined)?.modelSelectionLocked === true, + ); state.applyModelOverrideToSessionEntryMock.mockReturnValue({ updated: false }); + state.repairProviderWrappedModelOverrideMock.mockReturnValue({ updated: false }); state.resolveAutoFallbackPrimaryProbeMock.mockReturnValue(undefined); state.resolveChannelModelOverrideMock.mockImplementation((params: unknown) => { const input = params as { @@ -1207,6 +1227,107 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { }); }); + it("rejects live model switches for locked sessions without retrying", async () => { + setupModelSwitchRetry({ + provider: "openai", + model: "gpt-5.4", + }); + state.sessionEntryMock = { + sessionId: "session-1", + updatedAt: 1, + modelSelectionLocked: true, + skillsSnapshot: { prompt: "", skills: [], version: 0 }, + }; + state.isModelSelectionLockedMock.mockReturnValue(true); + + await expect(runBasicAgentCommand()).rejects.toMatchObject({ + name: "ModelSelectionLockedError", + message: "Model selection is locked for this session.", + }); + + expect(state.runWithModelFallbackMock).toHaveBeenCalledTimes(1); + expect(state.runAgentAttemptMock).not.toHaveBeenCalled(); + expect(state.trajectoryFlushMock).toHaveBeenCalledTimes(1); + }); + + it("pins catalog-adopted direct runs before fallback preflight", async () => { + setupSingleAttemptFallback(); + state.runtimeConfigMock = { + agents: { + defaults: { + models: state.defaultRuntimeConfig.agents.defaults.models, + cliBackends: { codex: { command: "codex" } }, + }, + }, + }; + state.sessionEntryMock = { + sessionId: "session-1", + updatedAt: 1, + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { + supervision: { + sourceThreadId: "019f-codex-thread", + modelLocked: true, + }, + }, + }, + skillsSnapshot: { prompt: "", skills: [], version: 0 }, + }; + state.isModelSelectionLockedMock.mockReturnValue(true); + state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude")); + + await runBasicAgentCommand(); + + const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams; + expect(fallbackParams.resolveAgentHarnessRuntimeOverride?.("anthropic", "claude")).toBe( + "codex", + ); + expect(fallbackParams.fallbacksOverride).toEqual([]); + expectRecordFields(mockCallArg(state.runAgentAttemptMock), { + providerOverride: "anthropic", + modelOverride: "claude", + agentHarnessRuntimeOverride: "codex", + sessionEntry: expect.objectContaining({ + agentHarnessId: "codex", + modelSelectionLocked: true, + }), + }); + }); + + it("skips legacy override repair when continuing an ordinary locked harness session", async () => { + setupSingleAttemptFallback(); + state.resolvedSessionKeyMock = "agent:main:plugin-owned"; + state.hasLegacyAutoFallbackWithoutOriginMock.mockReturnValue(true); + state.sessionEntryMock = { + sessionId: "session-1", + updatedAt: 1, + agentHarnessId: "codex", + modelSelectionLocked: true, + providerOverride: "openai", + modelOverride: "stale-fallback-model", + modelOverrideSource: "auto", + skillsSnapshot: { prompt: "", skills: [], version: 0 }, + }; + state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude")); + + await runBasicAgentCommand(); + + expect(state.applyModelOverrideToSessionEntryMock).not.toHaveBeenCalled(); + expect(state.repairProviderWrappedModelOverrideMock).not.toHaveBeenCalled(); + expectRecordFields(mockCallArg(state.runAgentAttemptMock), { + agentHarnessRuntimeOverride: "codex", + sessionEntry: expect.objectContaining({ + agentHarnessId: "codex", + modelSelectionLocked: true, + providerOverride: "openai", + modelOverride: "stale-fallback-model", + modelOverrideSource: "auto", + }), + }); + }); + it("keeps the fast mode cutoff timestamp across live model switch retries", async () => { let invocation = 0; state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => { @@ -2886,6 +3007,90 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { expect(state.removeInternalSessionEffectsTranscriptMock).not.toHaveBeenCalled(); }); + it("rejects session-id-resolved model runs for harness-owned sessions", async () => { + state.resolvedSessionKeyMock = "agent:main:harness:codex:supervision:native-thread"; + state.sessionEntryMock = { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "session-1", + updatedAt: 1, + }; + + await expect( + agentCommand({ + message: "probe", + sessionId: "session-1", + modelRun: true, + promptMode: "none", + sessionEffects: "internal", + }), + ).rejects.toThrow("Agent harness-owned sessions cannot be used for one-shot model runs."); + expect(state.runAgentAttemptMock).not.toHaveBeenCalled(); + }); + + it("rejects one-shot model runs for locked harness sessions with ordinary keys", async () => { + state.resolvedSessionKeyMock = "agent:main:plugin-owned"; + state.sessionEntryMock = { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "session-1", + updatedAt: 1, + }; + + await expect( + agentCommand({ + message: "probe", + sessionId: "session-1", + modelRun: true, + promptMode: "none", + sessionEffects: "internal", + }), + ).rejects.toThrow("Agent harness-owned sessions cannot be used for one-shot model runs."); + expect(state.runAgentAttemptMock).not.toHaveBeenCalled(); + }); + + it("continues a grandfathered unlocked harness-prefixed session as an ordinary run", async () => { + setupSingleAttemptFallback(); + state.resolvedSessionKeyMock = "agent:main:harness:notes"; + state.sessionEntryMock = { + agentHarnessId: "codex", + modelSelectionLocked: false, + sessionId: "session-1", + sessionFile: "/tmp/legacy-session.jsonl", + updatedAt: 1, + }; + state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude")); + + await runBasicAgentCommand(); + + expect(state.runAgentAttemptMock).toHaveBeenCalledTimes(1); + expectRecordFields(mockCallArg(state.runAgentAttemptMock), { + agentHarnessRuntimeOverride: undefined, + sessionEntry: expect.objectContaining({ + agentHarnessId: "codex", + modelSelectionLocked: false, + sessionId: "session-1", + }), + }); + }); + + it("rejects a locked harness row with the wrong owner before transcript or model work", async () => { + state.resolvedSessionKeyMock = "agent:main:harness:codex:supervision:native-thread"; + state.sessionEntryMock = { + agentHarnessId: "other", + modelSelectionLocked: true, + sessionId: "session-1", + sessionFile: "/tmp/native-session.jsonl", + updatedAt: 1, + }; + + await expect(agentCommand({ message: "continue", sessionId: "session-1" })).rejects.toThrow( + "Session key namespace is reserved for agent harness-owned sessions.", + ); + expect(state.prepareInternalSessionEffectsTranscriptMock).not.toHaveBeenCalled(); + expect(state.runAgentAttemptMock).not.toHaveBeenCalled(); + }); + it("removes one-shot internal model-run artifacts after success", async () => { setupSingleAttemptFallback(); state.prepareInternalSessionEffectsTranscriptMock.mockResolvedValueOnce( @@ -3619,6 +3824,52 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { }); }); + it("does not persist an automatic probe result after the session becomes locked", async () => { + const sessionEntry: SessionEntry = { + sessionId: "session-1", + updatedAt: Date.now(), + providerOverride: "openai", + modelOverride: "claude", + modelOverrideSource: "auto", + modelOverrideFallbackOriginProvider: "anthropic", + modelOverrideFallbackOriginModel: "claude", + skillsSnapshot: { prompt: "", skills: [], version: 0 }, + }; + state.sessionEntryMock = sessionEntry; + state.sessionStoreMock = { "agent:main:main": sessionEntry }; + state.storePathMock = "/tmp/openclaw-session-store.json"; + state.resolveAutoFallbackPrimaryProbeMock.mockReturnValue({ + provider: "anthropic", + model: "claude", + fallbackProvider: "openai", + fallbackModel: "claude", + }); + state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => { + state.persistSessionEntryMock.mockClear(); + const result = await params.run("openai", "claude"); + const currentEntry = (state.sessionStoreMock as Record)[ + "agent:main:main" + ]; + currentEntry.modelSelectionLocked = true; + state.isModelSelectionLockedMock.mockReturnValue(true); + return { + result, + provider: "openai", + model: "claude", + attempts: [], + }; + }); + state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "claude")); + + await runBasicAgentCommand(); + + const autoProbeWrites = state.persistSessionEntryMock.mock.calls.filter((call) => { + const entry = (call[0] as { entry?: SessionEntry } | undefined)?.entry; + return entry?.modelOverrideSource === "auto" && entry?.modelOverride === "claude"; + }); + expect(autoProbeWrites).toHaveLength(0); + }); + it("keeps aliased session auth profiles for codex-cli runs", async () => { let capturedAuthProfileProvider: string | undefined; const sessionEntry = { diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 65ff3894bd61..df69a4794b37 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -48,9 +48,17 @@ import { scopeLegacySessionKeyToAgent, } from "../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { + AGENT_HARNESS_MODEL_RUN_FORBIDDEN_MESSAGE, + isValidAgentHarnessSessionStoreEntry, + resolveAgentHarnessSessionContextError, +} from "../sessions/agent-harness-session-key.js"; import { applyVerboseOverride } from "../sessions/level-overrides.js"; import { applyModelOverrideToSessionEntry, + MODEL_SELECTION_LOCKED_MESSAGE, + ModelSelectionLockedError, + isModelSelectionLocked, repairProviderWrappedModelOverride, } from "../sessions/model-overrides.js"; import { resolveSendPolicy } from "../sessions/send-policy.js"; @@ -787,6 +795,20 @@ async function prepareAgentCommandExecution(opts: AgentCommandOpts, runtime: Run const { sessionId, sessionKey, storePath, isNewSession, persistedThinking, persistedVerbose } = sessionResolution; + const harnessSessionError = sessionKey + ? resolveAgentHarnessSessionContextError(sessionKey, sessionResolution.sessionEntry) + : undefined; + if (harnessSessionError) { + throw new Error(harnessSessionError); + } + const isOneShotModelRun = opts.modelRun === true || opts.promptMode === "none"; + if ( + isOneShotModelRun && + sessionKey && + sessionResolution.sessionEntry?.modelSelectionLocked === true + ) { + throw new Error(AGENT_HARNESS_MODEL_RUN_FORBIDDEN_MESSAGE); + } const { sessionEntry: sessionEntryRaw, sessionStore } = createAgentCommandSessionWorkingCopy({ sessionKey, sessionEntry: sessionResolution.sessionEntry, @@ -1475,6 +1497,9 @@ async function agentCommandInternal( ? normalizeExplicitOverrideInput(opts.model, "model") : undefined; const hasExplicitRunOverride = Boolean(explicitProviderOverride || explicitModelOverride); + if (hasExplicitRunOverride && isModelSelectionLocked(sessionEntry)) { + throw new ModelSelectionLockedError(); + } if (hasExplicitRunOverride && opts.allowModelOverride !== true) { throw new Error("Model override is not authorized for this caller."); } @@ -1513,8 +1538,11 @@ async function agentCommandInternal( sessionStore && sessionKey && hasStoredOverride && + !isValidAgentHarnessSessionStoreEntry(sessionKey, sessionEntry) && !suppressVisibleSessionEffects ) { + // Validate legacy model-only locks on a clone so repair rejects before mutation. + // Durable harness locks own their model metadata and bypass generic repair entirely. const initialEntry = sessionEntry; const entry = { ...sessionEntry }; let entryUpdated = false; @@ -1644,14 +1672,15 @@ async function agentCommandInternal( model = normalizedStored.model; } } - const autoFallbackPrimaryProbe = !hasExplicitRunOverride - ? resolveAutoFallbackPrimaryProbe({ - entry: sessionEntry, - sessionKey, - primaryProvider, - primaryModel, - }) - : undefined; + const autoFallbackPrimaryProbe = + !hasExplicitRunOverride && !isModelSelectionLocked(sessionEntry) + ? resolveAutoFallbackPrimaryProbe({ + entry: sessionEntry, + sessionKey, + primaryProvider, + primaryModel, + }) + : undefined; let autoFallbackPrimaryProbeSessionEntry: SessionEntry | undefined; if (autoFallbackPrimaryProbe && sessionEntry) { provider = autoFallbackPrimaryProbe.provider; @@ -1702,6 +1731,12 @@ async function agentCommandInternal( provider = allowedInitialSelection.provider; model = allowedInitialSelection.model; providerForAuthProfileValidation = provider; + let sessionEntryForAttempt = autoFallbackPrimaryProbeSessionEntry ?? sessionEntry; + const initialAgentHarnessRuntimeOverride = resolveSessionRuntimeOverrideForProvider({ + provider, + entry: sessionEntryForAttempt, + cfg, + }); await ensureSelectedAgentHarnessPlugin({ config: cfg, @@ -1709,15 +1744,10 @@ async function agentCommandInternal( modelId: model, agentId: sessionAgentId, sessionKey, - agentHarnessRuntimeOverride: resolveSessionRuntimeOverrideForProvider({ - provider, - entry: sessionEntry, - cfg, - }), + agentHarnessRuntimeOverride: initialAgentHarnessRuntimeOverride, workspaceDir, }); - let sessionEntryForAttempt = autoFallbackPrimaryProbeSessionEntry ?? sessionEntry; if (sessionEntryForAttempt) { const authProfileId = sessionEntryForAttempt.authProfileOverride; if (authProfileId) { @@ -2084,17 +2114,19 @@ async function agentCommandInternal( ? getGeneratedMediaTaskIdsForSessionKey(sessionKey) : new Set(); const spawnedBy = normalizedSpawned.spawnedBy ?? sessionEntry?.spawnedBy; - const effectiveFallbacksOverride = resolveEffectiveModelFallbacks({ - cfg, - agentId: sessionAgentId, - sessionKey, - hasSessionModelOverride: - hasExplicitRunOverride || Boolean(storedProviderOverride || storedModelOverride), - modelOverrideSource: hasExplicitRunOverride ? "user" : storedModelOverrideSource, - hasAutoFallbackProvenance: hasExplicitRunOverride - ? false - : hasStoredAutoFallbackProvenance, - }); + const effectiveFallbacksOverride = isModelSelectionLocked(sessionEntry) + ? [] + : resolveEffectiveModelFallbacks({ + cfg, + agentId: sessionAgentId, + sessionKey, + hasSessionModelOverride: + hasExplicitRunOverride || Boolean(storedProviderOverride || storedModelOverride), + modelOverrideSource: hasExplicitRunOverride ? "user" : storedModelOverrideSource, + hasAutoFallbackProvenance: hasExplicitRunOverride + ? false + : hasStoredAutoFallbackProvenance, + }); let fallbackAttemptIndex = 0; const fallbackRuntimeState: { originRuntime?: "cli" | "embedded" } = {}; @@ -2305,6 +2337,7 @@ async function agentCommandInternal( sessionEntry && sessionStore && sessionKey && + !isModelSelectionLocked(sessionEntry) && !suppressVisibleSessionEffects && !preserveUserFacingSessionModelState && entryMatchesAutoFallbackPrimaryProbe(sessionEntry, autoFallbackPrimaryProbe) && @@ -2345,6 +2378,23 @@ async function agentCommandInternal( break; } catch (err) { if (err instanceof LiveSessionModelSwitchError) { + if (isModelSelectionLocked(sessionEntry)) { + if (!attemptLifecycleState.lifecycleEnded) { + emitAgentEvent({ + runId, + lifecycleGeneration, + stream: "lifecycle", + data: { + phase: "error", + startedAt, + endedAt: Date.now(), + error: MODEL_SELECTION_LOCKED_MESSAGE, + }, + }); + } + await fallbackTrajectoryRecorder?.flush(); + throw new ModelSelectionLockedError(); + } if ( sessionKey && hasNewGeneratedMediaTaskForSessionKey(sessionKey, liveSwitchMediaTaskIds) diff --git a/src/agents/agent-runtime-metadata.ts b/src/agents/agent-runtime-metadata.ts index c025e7d24c98..ec0fd13561db 100644 --- a/src/agents/agent-runtime-metadata.ts +++ b/src/agents/agent-runtime-metadata.ts @@ -1,8 +1,10 @@ /** Resolves agent runtime metadata from model/provider policy and ACP session overlays. */ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { applyAcpRuntimeOverlay, type AgentRuntimeMetadata } from "./acp-runtime-overlay.js"; +import { isDefaultAgentRuntimeId } from "./agent-runtime-id.js"; import { resolveAgentHarnessPolicy } from "./harness/policy.js"; import { resolveDefaultModelForAgent } from "./model-selection.js"; +import { resolvePersistedSessionRuntimeId } from "./session-runtime-compat.js"; /** Resolves the runtime id/source that should be reported for a model-backed agent session. */ export function resolveModelAgentRuntimeMetadata(params: { @@ -11,6 +13,7 @@ export function resolveModelAgentRuntimeMetadata(params: { provider?: string; model?: string; sessionKey?: string; + sessionEntry?: Parameters[0]; /** * True when the loaded session entry has persisted ACP metadata. ACP-shaped * keys without this marker can be bridge sessions that use the configured @@ -25,6 +28,15 @@ export function resolveModelAgentRuntimeMetadata(params: { */ acpBackend?: string; }): AgentRuntimeMetadata { + const persistedRuntimeId = resolvePersistedSessionRuntimeId(params.sessionEntry); + if (persistedRuntimeId && !isDefaultAgentRuntimeId(persistedRuntimeId)) { + return applyAcpRuntimeOverlay( + { id: persistedRuntimeId, source: "session" }, + params.sessionKey, + params.acpRuntime, + params.acpBackend, + ); + } const resolved = params.provider && params.model ? { provider: params.provider, model: params.model } diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index 8636398dbced..d429e6978b13 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -605,7 +605,10 @@ describe("runBtwSideQuestion", () => { registerAgentHarness({ id: "codex", label: "Codex test harness", - supports: () => ({ supported: true, priority: 100 }), + supports: ({ provider }) => + provider === "openai" + ? { supported: true, priority: 100 } + : { supported: false, reason: "Codex only supports OpenAI providers" }, runAttempt: vi.fn(), runSideQuestion: codexSideQuestionMock, }); @@ -684,6 +687,44 @@ describe("runBtwSideQuestion", () => { expect(registerProviderStreamForModelMock).not.toHaveBeenCalled(); }); + it("keeps a model-locked session on its persisted harness for BTW", async () => { + const codexSideQuestionMock = vi.fn().mockResolvedValue({ text: "Locked Codex answer." }); + registerAgentHarness({ + id: "codex", + label: "Codex test harness", + supports: () => ({ supported: true, priority: 100 }), + runAttempt: vi.fn(), + runSideQuestion: codexSideQuestionMock, + }); + + const result = await runSideQuestion({ + cfg: { + agents: { + defaults: { + models: { + "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "openclaw" } }, + }, + }, + }, + }, + sessionEntry: createSessionEntry({ + agentHarnessId: "codex", + modelSelectionLocked: true, + }), + }); + + expect(result).toEqual({ text: "Locked Codex answer." }); + expect(codexSideQuestionMock).toHaveBeenCalledOnce(); + expect(ensureSelectedAgentHarnessPluginMock).toHaveBeenCalledWith( + expect.objectContaining({ agentHarnessId: "codex" }), + ); + expect(ensureSelectedAgentHarnessPluginMock).not.toHaveBeenCalledWith( + expect.objectContaining({ agentHarnessRuntimeOverride: expect.anything() }), + ); + expect(streamSimpleMock).not.toHaveBeenCalled(); + expect(executePreparedCliRunMock).not.toHaveBeenCalled(); + }); + it("reselects the Codex hook after resolving legacy openai-codex route state", async () => { const codexSideQuestionMock = vi.fn().mockResolvedValue({ text: "Codex side answer." }); registerAgentHarness({ diff --git a/src/agents/btw.ts b/src/agents/btw.ts index 30e3858550bf..da0f93aa598a 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -20,6 +20,7 @@ import type { TextContent, } from "../llm/types.js"; import { prepareProviderRuntimeAuth } from "../plugins/provider-runtime.js"; +import { isModelSelectionLocked } from "../sessions/model-overrides.js"; import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js"; import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js"; import { resolveExternalCliAuthOverlayScopeFromSelection } from "./auth-profiles/external-cli-auth-selection.js"; @@ -61,6 +62,7 @@ import { unwrapSecretSentinelsForProviderEgress, } from "./provider-secret-egress.js"; import { registerProviderStreamForModel } from "./provider-stream.js"; +import { resolveSessionRuntimeOverrideForProvider } from "./session-runtime-compat.js"; import { stripToolResultDetails } from "./session-transcript-repair.js"; import { resolveAgentTimeoutMs } from "./timeout.js"; import { sanitizeImageBlocks } from "./tool-images.js"; @@ -491,7 +493,18 @@ export async function runBtwSideQuestion( const workspaceDir = resolveAgentWorkspaceDir(params.cfg, sessionAgentId); const preparedHarnesses = new Map(); const prepareHarness = async (provider: string, modelId: string): Promise => { - const key = `${provider}/${modelId}`; + const agentHarnessId = isModelSelectionLocked(params.sessionEntry) + ? params.sessionEntry.agentHarnessId + : undefined; + const agentHarnessRuntimeOverride = agentHarnessId + ? undefined + : resolveSessionRuntimeOverrideForProvider({ + provider, + entry: params.sessionEntry, + cfg: params.cfg, + }); + const selectedHarnessId = agentHarnessId ?? agentHarnessRuntimeOverride ?? "configured"; + const key = `${provider}/${modelId}/${selectedHarnessId}`; const cached = preparedHarnesses.get(key); if (cached) { return cached; @@ -503,6 +516,8 @@ export async function runBtwSideQuestion( agentId: sessionAgentId, sessionKey: params.sessionKey, workspaceDir, + ...(agentHarnessId ? { agentHarnessId } : {}), + ...(agentHarnessRuntimeOverride ? { agentHarnessRuntimeOverride } : {}), }); const harness = selectAgentHarness({ provider, @@ -510,6 +525,8 @@ export async function runBtwSideQuestion( config: params.cfg, agentId: sessionAgentId, sessionKey: params.sessionKey, + ...(agentHarnessId ? { agentHarnessId } : {}), + ...(agentHarnessRuntimeOverride ? { agentHarnessRuntimeOverride } : {}), }); preparedHarnesses.set(key, harness); return harness; diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index ab079814c291..827093aac887 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -69,10 +69,17 @@ vi.mock("../cli-runner/claude-live-session.js", () => ({ })); vi.mock("../model-selection.js", () => ({ - isCliProvider: (provider: string) => - provider.trim().toLowerCase() === "claude-cli" || - provider.trim().toLowerCase() === "codex-cli" || - provider.trim().toLowerCase() === "google-gemini-cli", + isCliProvider: (provider: string, cfg?: OpenClawConfig) => { + const normalized = provider.trim().toLowerCase(); + return ( + normalized === "claude-cli" || + normalized === "codex-cli" || + normalized === "google-gemini-cli" || + Object.keys(cfg?.agents?.defaults?.cliBackends ?? {}).some( + (candidate) => candidate.trim().toLowerCase() === normalized, + ) + ); + }, normalizeProviderId: (provider: string) => provider.trim().toLowerCase(), })); @@ -2982,6 +2989,73 @@ describe("embedded attempt harness pinning", () => { expectMockArgFields(runEmbeddedAgentMock, { agentHarnessId: undefined }); }); + it("keeps a catalog-adopted Codex harness pinned for direct command attempts", async () => { + const sessionEntry: SessionEntry = { + sessionId: "mixed-provider-session", + updatedAt: Date.now(), + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { + supervision: { + sourceThreadId: "019f-codex-thread", + modelLocked: true, + }, + }, + }, + }; + runEmbeddedAgentMock.mockResolvedValueOnce({ + meta: { durationMs: 1 }, + } satisfies EmbeddedAgentRunResult); + + await runAgentAttempt({ + providerOverride: "anthropic", + originalProvider: "anthropic", + modelOverride: "claude-opus-4-7", + cfg: { + agents: { + defaults: { + cliBackends: { codex: { command: "codex" } }, + models: { + "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, + }, + }, + }, + } as OpenClawConfig, + sessionEntry, + agentHarnessRuntimeOverride: "codex", + sessionId: sessionEntry.sessionId, + sessionKey: "agent:main:main", + sessionAgentId: "main", + sessionFile: path.join(tmpDir, "session.jsonl"), + workspaceDir: tmpDir, + body: "switch to minimax", + isFallbackRetry: false, + resolvedThinkLevel: "medium", + timeoutMs: 1_000, + runId: "run-mixed-provider-auto-runtime", + opts: {} as Parameters[0]["opts"], + runContext: {} as Parameters[0]["runContext"], + spawnedBy: undefined, + messageChannel: undefined, + skillsSnapshot: undefined, + resolvedVerboseLevel: undefined, + agentDir: tmpDir, + onAgentEvent: vi.fn(), + authProfileProvider: "anthropic", + sessionHasHistory: true, + }); + + expect(runCliAgentMock).not.toHaveBeenCalled(); + expectMockArgFields(runEmbeddedAgentMock, { + provider: "anthropic", + model: "claude-opus-4-7", + agentHarnessId: "codex", + agentHarnessRuntimeOverride: "codex", + modelSelectionLocked: true, + }); + }); + it("ignores stale session Codex harness pins on non-OpenAI model switches", async () => { const sessionEntry: SessionEntry = { sessionId: "mixed-provider-session", @@ -3023,6 +3097,59 @@ describe("embedded attempt harness pinning", () => { expectMockArgFields(runEmbeddedAgentMock, { agentHarnessId: undefined }); }); + it("does not leak a persisted CLI harness alias across providers", async () => { + const sessionEntry: SessionEntry = { + sessionId: "legacy-cli-pin", + updatedAt: Date.now(), + agentHarnessId: "claude-cli", + }; + runEmbeddedAgentMock.mockResolvedValueOnce({ + meta: { durationMs: 1 }, + } satisfies EmbeddedAgentRunResult); + + await runAgentAttempt({ + providerOverride: "openai", + originalProvider: "openai", + modelOverride: "gpt-5.4", + cfg: { + agents: { + defaults: { + cliBackends: { "claude-cli": { command: "claude" } }, + }, + }, + } as OpenClawConfig, + sessionEntry, + sessionId: sessionEntry.sessionId, + sessionKey: "agent:main:main", + sessionAgentId: "main", + sessionFile: path.join(tmpDir, "session.jsonl"), + workspaceDir: tmpDir, + body: "continue", + isFallbackRetry: false, + resolvedThinkLevel: "medium", + timeoutMs: 1_000, + runId: "run-provider-incompatible-cli-pin", + opts: {} as Parameters[0]["opts"], + runContext: {} as Parameters[0]["runContext"], + spawnedBy: undefined, + messageChannel: undefined, + skillsSnapshot: undefined, + resolvedVerboseLevel: undefined, + agentDir: tmpDir, + onAgentEvent: vi.fn(), + authProfileProvider: "openai", + sessionHasHistory: true, + }); + + expect(runCliAgentMock).not.toHaveBeenCalled(); + expectMockArgFields(runEmbeddedAgentMock, { + provider: "openai", + model: "gpt-5.4", + agentHarnessId: undefined, + agentHarnessRuntimeOverride: undefined, + }); + }); + it("forwards runtime toolsAllow into embedded attempts", async () => { const sessionEntry: SessionEntry = { sessionId: "tools-allow-session", @@ -3227,7 +3354,7 @@ describe("embedded attempt harness pinning", () => { expectMockArgFields(runEmbeddedAgentMock, { agentHarnessId: undefined }); }); - it("ignores stale OpenAI sessions pinned to OpenClaw and relies on default Codex routing", async () => { + it("honors a resolved persisted OpenClaw harness", async () => { const sessionEntry: SessionEntry = { sessionId: "stale-agent-session", updatedAt: Date.now(), @@ -3243,6 +3370,7 @@ describe("embedded attempt harness pinning", () => { modelOverride: "gpt-5.4", cfg: {} as OpenClawConfig, sessionEntry, + agentHarnessRuntimeOverride: "openclaw", sessionId: sessionEntry.sessionId, sessionKey: "agent:main:main", sessionAgentId: "main", @@ -3267,7 +3395,8 @@ describe("embedded attempt harness pinning", () => { expectMockArgFields(runEmbeddedAgentMock, { provider: "openai", - agentHarnessId: undefined, + agentHarnessId: "openclaw", + agentHarnessRuntimeOverride: "openclaw", }); }); diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index f7251dea6b9c..140aacc6b8d4 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -569,8 +569,12 @@ export function runAgentAttempt(params: { bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1]; const requestedAgentHarnessId = isRawModelRun ? "openclaw" : undefined; const sessionRuntimeOverride = isRawModelRun ? undefined : params.agentHarnessRuntimeOverride; + const locksSessionRuntimeOverride = + sessionRuntimeOverride !== undefined && params.sessionEntry?.modelSelectionLocked === true; const sessionCliRuntime = - sessionRuntimeOverride && isCliProvider(sessionRuntimeOverride, params.cfg) + sessionRuntimeOverride && + !locksSessionRuntimeOverride && + isCliProvider(sessionRuntimeOverride, params.cfg) ? sessionRuntimeOverride : undefined; const configuredCliRuntime = @@ -586,7 +590,9 @@ export function runAgentAttempt(params: { const cliExecutionProvider = isRawModelRun ? params.providerOverride : (sessionCliRuntime ?? configuredCliRuntime ?? params.providerOverride); - const isCliExecutionProvider = isCliProvider(cliExecutionProvider, params.cfg); + const isCliExecutionProvider = sessionRuntimeOverride + ? sessionCliRuntime !== undefined + : isCliProvider(cliExecutionProvider, params.cfg); if (params.fallbackRuntimeState && params.fallbackRuntimeState.originRuntime === undefined) { params.fallbackRuntimeState.originRuntime = !isRawModelRun && isCliExecutionProvider ? "cli" : "embedded"; @@ -874,6 +880,7 @@ export function runAgentAttempt(params: { cwd: params.cwd, config: params.cfg, agentHarnessId: embeddedAgentHarnessOverride, + modelSelectionLocked: !isRawModelRun && params.sessionEntry?.modelSelectionLocked === true, agentHarnessRuntimeOverride: embeddedAgentHarnessOverride, skillsSnapshot: params.skillsSnapshot, prompt: effectivePrompt, diff --git a/src/agents/command/cli-compaction.test.ts b/src/agents/command/cli-compaction.test.ts index 226d1ef7b398..3bf7b8b90f95 100644 --- a/src/agents/command/cli-compaction.test.ts +++ b/src/agents/command/cli-compaction.test.ts @@ -356,6 +356,7 @@ describe("runCliTurnCompactionLifecycle", () => { totalTokens: 950, totalTokensFresh: true, agentHarnessId: "codex", + modelSelectionLocked: true, authProfileOverride: "github-copilot:work", authProfileOverrideSource: "auto", }; @@ -446,12 +447,15 @@ describe("runCliTurnCompactionLifecycle", () => { currentTokenCount: 950, contextEngine, agentHarnessId: "codex", + modelSelectionLocked: true, authProfileId: "github-copilot:work", trigger: "budget", force: true, }); expect(compactAgentHarnessSessionCalls[0]?.[0].contextEngineRuntimeContext).toMatchObject({ authProfileId: "github-copilot:work", + agentHarnessId: "codex", + modelSelectionLocked: true, }); expect(compactCalls).toHaveLength(0); expect(recordCliCompactionInStore).toHaveBeenCalledTimes(1); @@ -595,6 +599,25 @@ describe("runCliTurnCompactionLifecycle", () => { expect(compactAgentHarnessSession).not.toHaveBeenCalled(); expect(compactCalls).toHaveLength(1); + + const lockedEntry: SessionEntry = { ...sessionEntry, modelSelectionLocked: true }; + await expect( + runCliTurnCompactionLifecycle({ + cfg: {} as OpenClawConfig, + sessionId, + sessionKey, + sessionEntry: lockedEntry, + sessionStore: { [sessionKey]: lockedEntry }, + storePath, + sessionAgentId: "main", + workspaceDir: tmpDir, + agentDir: tmpDir, + provider: "openclaw", + model: "sonnet-4.6", + }), + ).rejects.toThrow("CLI compaction cannot replace a model-locked native harness runtime"); + expect(compactAgentHarnessSession).not.toHaveBeenCalled(); + expect(compactCalls).toHaveLength(1); }); it("surfaces nonrecoverable native harness CLI compaction failures", async () => { @@ -690,7 +713,7 @@ describe("runCliTurnCompactionLifecycle", () => { const compactCalls: Array[0]> = []; const maintenance = vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0 })); - const compactAgentHarnessSession = vi.fn(async () => ({ + const compactAgentHarnessSession = vi.fn(async (_params: Record) => ({ ok: true, compacted: false, reason: "codex app-server owns automatic compaction", @@ -755,6 +778,39 @@ describe("runCliTurnCompactionLifecycle", () => { }), ); expect(result?.compactionCount).toBe(1); + + const lockedEntry: SessionEntry = { ...sessionEntry, modelSelectionLocked: true }; + sessionStore[sessionKey] = lockedEntry; + const lockedResult = await runCliTurnCompactionLifecycle({ + cfg: {} as OpenClawConfig, + sessionId, + sessionKey, + sessionEntry: lockedEntry, + sessionStore, + storePath, + sessionAgentId: "main", + workspaceDir: tmpDir, + agentDir: tmpDir, + provider: "codex", + model: "gpt-5.5", + }); + + expect(compactAgentHarnessSession).toHaveBeenCalledTimes(2); + expect(compactCalls).toHaveLength(1); + expect(maintenance).toHaveBeenCalledTimes(1); + expect(recordCliCompactionInStore).toHaveBeenCalledTimes(1); + const lockedNativeCall = compactAgentHarnessSession.mock.calls[1]?.[0]; + expect(lockedNativeCall).toMatchObject({ + agentHarnessId: "codex", + modelSelectionLocked: true, + contextEngineRuntimeContext: expect.objectContaining({ + agentHarnessId: "codex", + modelSelectionLocked: true, + provider: "codex", + model: "gpt-5.5", + }), + }); + expect(lockedResult).toBe(lockedEntry); }); it("does not fall back when native harness compaction returns no result", async () => { diff --git a/src/agents/command/cli-compaction.ts b/src/agents/command/cli-compaction.ts index 7575302ed7b7..25a8cbb61305 100644 --- a/src/agents/command/cli-compaction.ts +++ b/src/agents/command/cli-compaction.ts @@ -106,6 +106,8 @@ type CliCompactionRuntimeContextParams = { senderIsOwner?: boolean; provider: string; model: string; + harnessRuntime?: string; + modelSelectionLocked?: boolean; thinkLevel?: Parameters[0]["thinkLevel"]; extraSystemPrompt?: string; currentTokenCount: number; @@ -236,6 +238,8 @@ function buildCliCompactionRuntimeContext(params: CliCompactionRuntimeContextPar senderIsOwner: params.senderIsOwner, provider: params.provider, modelId: params.model, + harnessRuntime: params.harnessRuntime, + modelSelectionLocked: params.modelSelectionLocked, thinkLevel: params.thinkLevel, extraSystemPrompt: params.extraSystemPrompt, }), @@ -257,6 +261,8 @@ async function compactCliTranscript(params: { agentDir: string; provider: string; model: string; + harnessRuntime?: string; + modelSelectionLocked?: boolean; contextTokenBudget: number; currentTokenCount: number; skillsSnapshot?: SkillSnapshot; @@ -281,6 +287,8 @@ async function compactCliTranscript(params: { senderIsOwner: params.senderIsOwner, provider: params.provider, model: params.model, + harnessRuntime: params.harnessRuntime, + modelSelectionLocked: params.modelSelectionLocked, thinkLevel: params.thinkLevel, extraSystemPrompt: params.extraSystemPrompt, currentTokenCount: params.currentTokenCount, @@ -397,6 +405,7 @@ async function compactNativeHarnessCliTranscript(params: { try { const sessionAgentId = readAgentIdFromSessionKey(params.sessionKey); const nativeHarnessId = params.sessionEntry.agentHarnessId?.trim(); + const modelSelectionLocked = params.sessionEntry.modelSelectionLocked === true; const authProfileId = params.sessionEntry.authProfileOverride?.trim() || undefined; await cliCompactionDeps.ensureSelectedAgentHarnessPlugin({ provider: params.provider, @@ -430,6 +439,7 @@ async function compactNativeHarnessCliTranscript(params: { senderIsOwner: params.senderIsOwner, thinkLevel: params.thinkLevel, extraSystemPrompt: params.extraSystemPrompt, + modelSelectionLocked, allowGatewaySubagentBinding: true, ...(params.contextEngine ? { @@ -447,6 +457,8 @@ async function compactNativeHarnessCliTranscript(params: { senderIsOwner: params.senderIsOwner, provider: params.provider, model: params.model, + harnessRuntime: nativeHarnessId, + modelSelectionLocked, thinkLevel: params.thinkLevel, extraSystemPrompt: params.extraSystemPrompt, currentTokenCount: params.currentTokenCount, @@ -479,6 +491,9 @@ async function compactNativeHarnessCliTranscript(params: { return { compacted: false }; } if (isIntentionalNativeAutoCompactionSkip(result)) { + if (params.sessionEntry.modelSelectionLocked === true) { + return { compacted: false }; + } return { compacted: false, fallbackToContextEngine: true, @@ -487,7 +502,8 @@ async function compactNativeHarnessCliTranscript(params: { } const recoverableBindingFailure = isRecoverableNativeHarnessBindingFailure(result); const fallbackToContextEngine = - isUnsupportedNativeHarnessCompaction(result) || recoverableBindingFailure; + params.sessionEntry.modelSelectionLocked !== true && + (isUnsupportedNativeHarnessCompaction(result) || recoverableBindingFailure); // Native harness binding failures can be repaired by clearing the stored CLI // session binding and falling back to the context engine for this turn. log.warn( @@ -496,7 +512,8 @@ async function compactNativeHarnessCliTranscript(params: { return { compacted: false, fallbackToContextEngine, - clearCliSessionBinding: recoverableBindingFailure, + clearCliSessionBinding: + params.sessionEntry.modelSelectionLocked !== true && recoverableBindingFailure, failureReason: result?.reason ?? "native harness compaction did not reduce context", }; } @@ -563,6 +580,14 @@ export async function runCliTurnCompactionLifecycle(params: { } const resolvedBackend = cliCompactionDeps.resolveCliBackendConfig(params.provider, params.cfg); + const lockedHarnessRuntime = normalizeOptionalAgentRuntimeId(params.sessionEntry?.agentHarnessId); + if ( + params.sessionEntry?.modelSelectionLocked === true && + lockedHarnessRuntime !== OPENCLAW_AGENT_RUNTIME_ID && + !isNativeHarnessCompactionSession(params.sessionEntry, params.provider) + ) { + throw new Error("CLI compaction cannot replace a model-locked native harness runtime"); + } if ( resolvedBackend?.ownsNativeCompaction && !isNativeHarnessCompactionSession(params.sessionEntry, params.provider) @@ -623,7 +648,7 @@ export async function runCliTurnCompactionLifecycle(params: { nativeCompactionResult = nativeOutcome.result; useContextEngineCompaction = false; } else if (nativeOutcome.fallbackToContextEngine) { - // Unsupported or recoverable native compaction should not abort the CLI turn. + // Unlocked sessions may repair or replace a stale native compaction path. nativeFallbackToContextEngine = true; nativeFallbackNeedsBindingClear = nativeOutcome.clearCliSessionBinding === true; } else if (nativeOutcome.failureReason) { @@ -657,6 +682,8 @@ export async function runCliTurnCompactionLifecycle(params: { agentDir: params.agentDir, provider: params.provider, model: params.model, + harnessRuntime: params.sessionEntry?.agentHarnessId, + modelSelectionLocked: params.sessionEntry?.modelSelectionLocked, contextTokenBudget, currentTokenCount, skillsSnapshot: params.skillsSnapshot, diff --git a/src/agents/command/session.provider-owned-reset.test.ts b/src/agents/command/session.provider-owned-reset.test.ts index b842722d87da..c5048d946927 100644 --- a/src/agents/command/session.provider-owned-reset.test.ts +++ b/src/agents/command/session.provider-owned-reset.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions/types.js"; const hoisted = vi.hoisted(() => ({ store: {} as Record, + terminalTranscriptNewer: false, })); vi.mock("../../config/sessions/store-load.js", () => ({ @@ -14,6 +15,16 @@ vi.mock("../../config/sessions/paths.js", () => ({ resolveStorePath: () => "/stores/main.json", })); +vi.mock("../../config/sessions/lifecycle.js", async () => { + const actual = await vi.importActual( + "../../config/sessions/lifecycle.js", + ); + return { + ...actual, + hasTerminalMainSessionTranscriptNewerThanRegistrySync: () => hoisted.terminalTranscriptNewer, + }; +}); + const { resolveSession } = await import("./session.js"); const DAY_MS = 24 * 60 * 60 * 1000; @@ -34,6 +45,10 @@ function seedProviderOwned(sessionKey: string): void { } describe("command resolveSession provider-owned daily reset", () => { + beforeEach(() => { + hoisted.terminalTranscriptNewer = false; + }); + it("keeps a provider-owned CLI session across the default daily boundary", () => { const sessionKey = "agent:main:cli"; seedProviderOwned(sessionKey); @@ -69,4 +84,29 @@ describe("command resolveSession provider-owned daily reset", () => { expect(result.isNewSession).toBe(true); expect(result.sessionId).not.toBe("old-session-id"); }); + + it("keeps a model-locked session across the daily boundary", () => { + const sessionKey = "agent:main:codex-supervised"; + const startedAt = Date.now() - DAY_MS; + hoisted.store = { + [sessionKey]: { + sessionId: "locked-session-id", + updatedAt: startedAt, + sessionStartedAt: startedAt, + lastInteractionAt: startedAt, + agentHarnessId: "codex", + modelSelectionLocked: true, + }, + }; + hoisted.terminalTranscriptNewer = true; + + const result = resolveSession({ + cfg: { session: {} } as OpenClawConfig, + sessionKey, + agentId: "main", + }); + + expect(result.isNewSession).toBe(false); + expect(result.sessionId).toBe("locked-session-id"); + }); }); diff --git a/src/agents/command/session.ts b/src/agents/command/session.ts index bc523964a1c2..5bfd94218710 100644 --- a/src/agents/command/session.ts +++ b/src/agents/command/session.ts @@ -36,6 +36,7 @@ import { normalizeAgentId, normalizeMainKey, } from "../../routing/session-key.js"; +import { isModelSelectionLocked } from "../../sessions/model-overrides.js"; import { resolveSessionIdMatchSelection } from "../../sessions/session-id-resolution.js"; import { listAgentIds, resolveDefaultAgentId } from "../agent-scope.js"; import { clearBootstrapSnapshotOnSessionRollover } from "../bootstrap-cache.js"; @@ -382,28 +383,29 @@ export function resolveSession(opts: { storePath, }) : false; + const lockedModelSelection = isModelSelectionLocked(sessionEntry); const skipImplicitExpiry = resetPolicy.configured !== true && hasProviderOwnedSession(sessionEntry); const fresh = sessionEntry - ? !terminalMainTranscriptNewerThanRegistry && - (skipImplicitExpiry || - evaluateSessionFreshness({ - updatedAt: sessionEntry.updatedAt, - ...resolveSessionLifecycleTimestamps({ - entry: sessionEntry, - agentId: sessionAgentId, - storePath, - }), - now, - policy: resetPolicy, - }).fresh) + ? lockedModelSelection || + (!terminalMainTranscriptNewerThanRegistry && + (skipImplicitExpiry || + evaluateSessionFreshness({ + updatedAt: sessionEntry.updatedAt, + ...resolveSessionLifecycleTimestamps({ + entry: sessionEntry, + agentId: sessionAgentId, + storePath, + }), + now, + policy: resetPolicy, + }).fresh)) : false; const sessionId = requestedSessionId || (fresh ? sessionEntry?.sessionId : undefined) || crypto.randomUUID(); const isNewSession = !fresh && !requestedSessionId; - const resolvedSessionEntry = isNewSession && sessionEntry - ? clearRotatedSessionMetadata(sessionEntry) - : sessionEntry; + const resolvedSessionEntry = + isNewSession && sessionEntry ? clearRotatedSessionMetadata(sessionEntry) : sessionEntry; clearBootstrapSnapshotOnSessionRollover({ sessionKey, diff --git a/src/agents/embedded-agent-runner.e2e.test.ts b/src/agents/embedded-agent-runner.e2e.test.ts index 18869a30a61b..86b3238275e9 100644 --- a/src/agents/embedded-agent-runner.e2e.test.ts +++ b/src/agents/embedded-agent-runner.e2e.test.ts @@ -776,6 +776,72 @@ describe("runEmbeddedAgent", () => { ).toBe("openai"); }); + it("lets a locked Codex harness own stale model resolution, prompts, and context policy", async () => { + const sessionFile = nextSessionFile(); + const cfg = createEmbeddedAgentRunnerOpenAiConfig([]); + const prompt = "ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL"; + resolveModelAsyncMock.mockRejectedValueOnce(new Error("stale outer model must not resolve")); + mockSuccessfulEmbeddedAttempt(); + + await runEmbeddedAgent({ + sessionId: "locked-codex-native-policy", + sessionFile, + workspaceDir, + config: cfg, + prompt, + provider: "anthropic", + model: "retired-outer-model", + timeoutMs: 5_000, + agentDir, + agentHarnessId: "codex", + modelSelectionLocked: true, + runId: nextRunId("locked-codex-native-policy"), + enqueue: immediateEnqueue, + }); + + expect(resolveModelAsyncMock).not.toHaveBeenCalled(); + expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled(); + const attempt = firstRunEmbeddedAttemptParams() as Record; + expect(attempt).toMatchObject({ + agentHarnessId: "codex", + modelSelectionLocked: true, + provider: "anthropic", + modelId: "retired-outer-model", + prompt, + }); + expect("contextEngine" in attempt).toBe(false); + expect("contextTokenBudget" in attempt).toBe(false); + expect("contextWindowInfo" in attempt).toBe(false); + }); + + it("does not apply outer context-overflow recovery to a locked Codex harness", async () => { + const sessionFile = nextSessionFile(); + runEmbeddedAttemptMock.mockResolvedValueOnce( + makeEmbeddedRunnerAttempt({ + promptError: new Error("request exceeds the model context window"), + }), + ); + + await runEmbeddedAgent({ + sessionId: "locked-codex-native-overflow", + sessionFile, + workspaceDir, + config: createEmbeddedAgentRunnerOpenAiConfig([]), + prompt: "hello", + provider: "anthropic", + model: "retired-outer-model", + timeoutMs: 5_000, + agentDir, + agentHarnessId: "codex", + modelSelectionLocked: true, + runId: nextRunId("locked-codex-native-overflow"), + enqueue: immediateEnqueue, + }).catch(() => undefined); + + expect(resolveModelAsyncMock).not.toHaveBeenCalled(); + expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1); + }); + it("backfills a trimmed session key from sessionId when the embedded run omits it", async () => { const sessionFile = nextSessionFile(); const cfg = createEmbeddedAgentRunnerOpenAiConfig(["mock-1"]); diff --git a/src/agents/embedded-agent-runner/compact.hooks.test.ts b/src/agents/embedded-agent-runner/compact.hooks.test.ts index f91976e04323..a9b599aea157 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.test.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.test.ts @@ -265,6 +265,26 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { resetCompactSessionStateMocks(); }); + it("fails closed before generic compaction for a model-locked native session", async () => { + const result = await compactEmbeddedAgentSessionDirect({ + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp/workspace", + provider: "openai", + model: "gpt-5.5", + agentHarnessId: "codex", + modelSelectionLocked: true, + }); + + expect(result).toMatchObject({ + ok: false, + compacted: false, + failure: { reason: "model_selection_locked" }, + }); + expect(resolveModelMock).not.toHaveBeenCalled(); + expect(sessionCompactImpl).not.toHaveBeenCalled(); + }); + it("bootstraps runtime plugins with the resolved workspace", async () => { // This assertion only cares about bootstrap wiring, so stop before the // rest of the compaction pipeline can pull in unrelated runtime surfaces. @@ -744,6 +764,46 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { } }); + it("keeps model-locked OpenClaw compaction on its exact model without fallbacks", async () => { + resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({ + model: { provider, api: "responses", id: modelId, input: [] }, + error: null, + authStorage: { setRuntimeApiKey: vi.fn() }, + modelRegistry: {}, + })); + sessionCompactImpl.mockRejectedValueOnce( + Object.assign(new Error("primary compaction rate limited"), { status: 429 }), + ); + + const result = await compactEmbeddedAgentSessionDirect({ + sessionId: "session-1", + sessionKey: TEST_SESSION_KEY, + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp/workspace", + provider: "openai", + model: "gpt-primary", + agentHarnessId: "openclaw", + modelSelectionLocked: true, + modelFallbacksOverride: ["anthropic/claude-fallback"], + config: { + agents: { + defaults: { + compaction: { model: "azure/compact-primary" }, + model: { + primary: "openai/gpt-primary", + fallbacks: ["anthropic/claude-fallback"], + }, + }, + }, + } as never, + }); + + expect(result.ok).toBe(false); + expect(resolveModelMock).toHaveBeenCalledTimes(1); + expect(mockCallArg(resolveModelMock)).toBe("openai"); + expect(mockCallArg(resolveModelMock, 0, 1)).toBe("gpt-primary"); + }); + it("revalidates immutable Ultra for each compaction fallback candidate", async () => { resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "openclaw" }); resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({ @@ -2409,6 +2469,85 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { }); }); + it.each([ + ["missing_thread_binding", "no codex app-server thread binding"], + ["stale_thread_binding", "thread not found"], + ])( + "fails model-locked Codex compaction on %s without a context-engine fallback", + async (failureReason, reason) => { + resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "openclaw" }); + maybeCompactAgentHarnessSessionMock.mockResolvedValueOnce({ + ok: false, + compacted: false, + reason, + failure: { reason: failureReason }, + }); + + const result = await compactEmbeddedAgentSession( + wrappedCompactionArgs({ + provider: "openai", + model: "gpt-5.5", + agentHarnessId: "codex", + modelSelectionLocked: true, + currentTokenCount: 333, + }), + ); + + expect(result).toMatchObject({ + ok: false, + compacted: false, + failure: { reason: failureReason }, + }); + expect(maybeCompactAgentHarnessSessionMock).toHaveBeenCalledTimes(1); + expect(contextEngineCompactMock).not.toHaveBeenCalled(); + }, + ); + + it.each([undefined, "auto"])( + "fails a model-locked session with unavailable persisted harness %s", + async (agentHarnessId) => { + const result = await compactEmbeddedAgentSession( + wrappedCompactionArgs({ + provider: "openai", + model: "gpt-5.5", + agentHarnessId, + modelSelectionLocked: true, + currentTokenCount: 333, + }), + ); + + expect(result).toMatchObject({ + ok: false, + compacted: false, + failure: { reason: "model_selection_locked" }, + }); + expect(maybeCompactAgentHarnessSessionMock).not.toHaveBeenCalled(); + expect(contextEngineCompactMock).not.toHaveBeenCalled(); + }, + ); + + it("fails a model-locked native session when its harness returns no result", async () => { + maybeCompactAgentHarnessSessionMock.mockResolvedValueOnce(undefined); + + const result = await compactEmbeddedAgentSession( + wrappedCompactionArgs({ + provider: "openai", + model: "gpt-5.5", + agentHarnessId: "codex", + modelSelectionLocked: true, + currentTokenCount: 333, + }), + ); + + expect(result).toMatchObject({ + ok: false, + compacted: false, + failure: { reason: "model_selection_locked" }, + }); + expect(maybeCompactAgentHarnessSessionMock).toHaveBeenCalledTimes(1); + expect(contextEngineCompactMock).not.toHaveBeenCalled(); + }); + it("keeps owning context-engine compaction primary for legacy Codex native sessions", async () => { const successorSessionId = "engine-successor-session"; const successorSessionFile = "/tmp/engine-successor-session.jsonl"; diff --git a/src/agents/embedded-agent-runner/compact.queued.ts b/src/agents/embedded-agent-runner/compact.queued.ts index 4cd19d7e5df5..a0e8536b11dd 100644 --- a/src/agents/embedded-agent-runner/compact.queued.ts +++ b/src/agents/embedded-agent-runner/compact.queued.ts @@ -78,6 +78,17 @@ function shouldFallbackAfterHarnessCompaction( return isRecoverableNativeHarnessBindingFailure(result); } +function lockedCompactionRuntimeFailure(runtime?: string): EmbeddedAgentCompactResult { + return { + ok: false, + compacted: false, + reason: runtime + ? `Model selection is locked to native agent harness "${runtime}", but native compaction is unavailable.` + : "Model selection is locked but the persisted agent harness is unavailable.", + failure: { reason: "model_selection_locked" }, + }; +} + const DEFERRED_CONTEXT_ENGINE_COMPACTION_SCHEDULE_FAILURE_REASON = "failed to schedule background context-engine maintenance"; const MANUAL_COMPACTION_ACTIVE_RUN_REASON = @@ -290,6 +301,7 @@ async function compactEmbeddedAgentSessionImpl( provider: params.provider, modelId: params.model, authProfileId: params.authProfileId, + modelSelectionLocked: params.modelSelectionLocked, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); @@ -306,15 +318,32 @@ async function compactEmbeddedAgentSessionImpl( !isDefaultAgentRuntimeId(configuredHarnessPolicy.runtime) ? configuredHarnessPolicy.runtime : undefined; - // The persisted harness id is the runtime contract for this session; config - // changes can supply a runtime only when the session has no concrete pin. - const selectedHarnessRuntime = params.agentHarnessId ?? configuredHarnessRuntime; + const lockedHarnessRuntime = + params.modelSelectionLocked === true + ? normalizeOptionalAgentRuntimeId(params.agentHarnessId) + : undefined; + if ( + params.modelSelectionLocked === true && + (!lockedHarnessRuntime || lockedHarnessRuntime === "auto") + ) { + await contextEngine.dispose?.(); + return lockedCompactionRuntimeFailure(); + } + // A model lock makes the persisted harness authoritative. Config may select + // a runtime only for unlocked sessions that have no concrete pin. + const selectedHarnessRuntime = + params.modelSelectionLocked === true + ? lockedHarnessRuntime + : (params.agentHarnessId ?? configuredHarnessRuntime); + const lockedNativeHarness = + params.modelSelectionLocked === true && selectedHarnessRuntime !== "openclaw"; const resolvedCompactionTarget = resolveEmbeddedCompactionTarget({ config: params.config, provider: params.provider, modelId: params.model, authProfileId: params.authProfileId, harnessRuntime: selectedHarnessRuntime, + modelSelectionLocked: params.modelSelectionLocked, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); @@ -379,7 +408,7 @@ async function compactEmbeddedAgentSessionImpl( }); const contextEngineOwnsCompaction = contextEngine.info.ownsCompaction === true; const harnessResult = - attemptNativeHarnessCompaction && !contextEngineOwnsCompaction + attemptNativeHarnessCompaction && (!contextEngineOwnsCompaction || lockedNativeHarness) ? await maybeCompactAgentHarnessSession({ ...params, contextEngine, @@ -387,6 +416,10 @@ async function compactEmbeddedAgentSessionImpl( contextEngineRuntimeContext, }) : undefined; + if (lockedNativeHarness) { + await contextEngine.dispose?.(); + return harnessResult ?? lockedCompactionRuntimeFailure(selectedHarnessRuntime); + } if (harnessResult) { if (!shouldFallbackAfterHarnessCompaction(harnessResult)) { await contextEngine.dispose?.(); @@ -749,6 +782,7 @@ function buildCompactionContextEngineRuntimeContext(params: { provider: params.params.provider, modelId: params.params.model, harnessRuntime: params.harnessRuntime, + modelSelectionLocked: params.params.modelSelectionLocked, modelFallbacksOverride: params.params.modelFallbacksOverride, thinkLevel: params.params.thinkLevel, reasoningLevel: params.params.reasoningLevel, diff --git a/src/agents/embedded-agent-runner/compact.ts b/src/agents/embedded-agent-runner/compact.ts index 29377003aea0..d8438e218c5c 100644 --- a/src/agents/embedded-agent-runner/compact.ts +++ b/src/agents/embedded-agent-runner/compact.ts @@ -55,7 +55,7 @@ import { setCompactionSafeguardCancelReason, } from "../agent-hooks/compaction-safeguard-runtime.js"; import { createPreparedEmbeddedAgentSettingsManager } from "../agent-project-settings.js"; -import { isDefaultAgentRuntimeId } from "../agent-runtime-id.js"; +import { isDefaultAgentRuntimeId, normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; import { resolveAgentDir, resolveRunModelFallbacksOverride, @@ -436,6 +436,9 @@ function hasExplicitCompactionModel(params: CompactEmbeddedAgentSessionParams): function resolveCompactionFallbacksOverride( params: CompactEmbeddedAgentSessionParams, ): string[] | undefined { + if (params.modelSelectionLocked) { + return []; + } return ( params.modelFallbacksOverride ?? resolveRunModelFallbacksOverride({ @@ -488,6 +491,17 @@ export async function compactEmbeddedAgentSessionDirect( paramsInput: CompactEmbeddedAgentSessionRuntimeParams, ): Promise { const paramsBase = applyAgentRunSessionTargetIdentity(paramsInput); + const lockedHarnessRuntime = normalizeOptionalAgentRuntimeId(paramsBase.agentHarnessId); + if (paramsBase.modelSelectionLocked === true && lockedHarnessRuntime !== "openclaw") { + return { + ok: false, + compacted: false, + reason: lockedHarnessRuntime + ? `Model selection is locked to native agent harness "${lockedHarnessRuntime}"; generic compaction is unavailable.` + : "Model selection is locked but the persisted agent harness is unavailable.", + failure: { reason: "model_selection_locked" }, + }; + } const runSessionTarget = await resolveAgentRunSessionTarget(paramsBase); const params: CompactEmbeddedAgentSessionParamsWithSessionFile = { ...paramsBase, @@ -504,6 +518,7 @@ export async function compactEmbeddedAgentSessionDirect( provider: params.provider, modelId: params.model, authProfileId: params.authProfileId, + modelSelectionLocked: params.modelSelectionLocked, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); @@ -612,6 +627,7 @@ async function compactEmbeddedAgentSessionDirectOnce( provider: params.provider, modelId: params.model, authProfileId: params.authProfileId, + modelSelectionLocked: params.modelSelectionLocked, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); @@ -635,6 +651,7 @@ async function compactEmbeddedAgentSessionDirectOnce( modelId: params.model, authProfileId: params.authProfileId, harnessRuntime: selectedHarnessRuntime, + modelSelectionLocked: params.modelSelectionLocked, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); diff --git a/src/agents/embedded-agent-runner/compact.types.ts b/src/agents/embedded-agent-runner/compact.types.ts index eee8c04ad7d2..0f8cd18b3eb8 100644 --- a/src/agents/embedded-agent-runner/compact.types.ts +++ b/src/agents/embedded-agent-runner/compact.types.ts @@ -72,6 +72,8 @@ export type CompactEmbeddedAgentSessionParams = { contextEngineRuntimeContext?: ContextEngineRuntimeContext; /** Session-pinned embedded harness id. Prevents compaction hot-switching. */ agentHarnessId?: string; + /** Prevent compaction from changing the persisted session runtime or model. */ + modelSelectionLocked?: boolean; /** OpenClaw-owned runtime policy prepared for this compaction path. */ runtimePlan?: AgentRuntimePlan; thinkLevel?: ThinkLevel; diff --git a/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts b/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts index f2b315526887..e0212928455e 100644 --- a/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts +++ b/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts @@ -221,6 +221,24 @@ describe("buildEmbeddedCompactionRuntimeContext", () => { }); }); + it("ignores compaction model overrides for model-locked sessions", () => { + expect( + resolveEmbeddedCompactionTarget({ + config: { + agents: { defaults: { compaction: { model: "anthropic/claude-opus-4-6" } } }, + } as unknown as OpenClawConfig, + provider: "openai", + modelId: "gpt-5.5", + authProfileId: "openai:default", + modelSelectionLocked: true, + }), + ).toEqual({ + provider: "openai", + model: "gpt-5.5", + authProfileId: "openai:default", + }); + }); + it("keeps configured OpenAI provider with legacy Codex auth profiles (#86373)", () => { const result = resolveEmbeddedCompactionTarget({ provider: "openai", @@ -280,12 +298,18 @@ describe("buildEmbeddedCompactionRuntimeContext", () => { const result = buildEmbeddedCompactionRuntimeContext({ workspaceDir: "/tmp/workspace", agentDir: "/tmp/agent", - config: {} as unknown as OpenClawConfig, + config: { + agents: { defaults: { compaction: { model: "anthropic/claude-opus-4-6" } } }, + } as unknown as OpenClawConfig, provider: "openai", modelId: "gpt-5.5", harnessRuntime: "codex", + modelSelectionLocked: true, }); expect(result.agentHarnessId).toBe("codex"); + expect(result.modelSelectionLocked).toBe(true); + expect(result.provider).toBe("openai"); + expect(result.model).toBe("gpt-5.5"); expect(result.runtimeProvider).toBeUndefined(); }); @@ -337,6 +361,32 @@ describe("buildEmbeddedCompactionRuntimeContext", () => { expect(result.authProfileId).toBeUndefined(); }); + it("keeps a locked Codex harness authoritative over a custom OpenAI base URL", () => { + const result = resolveEmbeddedCompactionTarget({ + config: { + models: { + providers: { + openai: { + baseUrl: "https://example.test/v1", + models: [{ id: "gpt-5.5" }], + }, + }, + }, + } as unknown as OpenClawConfig, + provider: "openai", + modelId: "gpt-5.5", + harnessRuntime: "codex", + modelSelectionLocked: true, + defaultProvider: "openai", + defaultModel: "gpt-5.5", + }); + expect(result.provider).toBe("openai"); + expect(result.runtimeProvider).toBeUndefined(); + expect(result.contextProvider).toBeUndefined(); + expect(result.nativeHarnessCompaction).toBe(true); + expect(result.model).toBe("gpt-5.5"); + }); + it("keeps model-only compaction overrides with legacy Codex auth on OpenAI", () => { const result = resolveEmbeddedCompactionTarget({ config: { diff --git a/src/agents/embedded-agent-runner/compaction-runtime-context.ts b/src/agents/embedded-agent-runner/compaction-runtime-context.ts index 3bf00d90a0d2..83186ab5cb72 100644 --- a/src/agents/embedded-agent-runner/compaction-runtime-context.ts +++ b/src/agents/embedded-agent-runner/compaction-runtime-context.ts @@ -35,6 +35,7 @@ type EmbeddedCompactionRuntimeContext = { currentMessageId?: string | number; authProfileId?: string; agentHarnessId?: string; + modelSelectionLocked?: boolean; workspaceDir: string; cwd?: string; agentDir: string; @@ -65,6 +66,7 @@ export function resolveEmbeddedCompactionTarget(params: { modelId?: string | null; authProfileId?: string | null; harnessRuntime?: string | null; + modelSelectionLocked?: boolean; defaultProvider?: string; defaultModel?: string; }): { @@ -77,7 +79,11 @@ export function resolveEmbeddedCompactionTarget(params: { } { const provider = params.provider?.trim() || params.defaultProvider; const model = params.modelId?.trim() || params.defaultModel; - const override = params.config?.agents?.defaults?.compaction?.model?.trim(); + // A locked session's creating model owns every transcript read, including + // summaries. Compaction-specific model overrides would cross that boundary. + const override = params.modelSelectionLocked + ? undefined + : params.config?.agents?.defaults?.compaction?.model?.trim(); const resolveTargetProviders = ( targetProvider: string | undefined, authProfileId: string | undefined, @@ -89,6 +95,7 @@ export function resolveEmbeddedCompactionTarget(params: { config: params.config, provider: targetProvider, harnessRuntime: params.harnessRuntime, + modelSelectionLocked: params.modelSelectionLocked, }); const harnessRuntime = useCodexHarnessRuntime ? params.harnessRuntime : "openclaw"; const runtimeProvider = resolveSelectedOpenAIRuntimeProvider({ @@ -230,10 +237,16 @@ function shouldUseCodexRuntimeProviderForCompaction(params: { config?: OpenClawConfig; provider: string; harnessRuntime?: string | null; + modelSelectionLocked?: boolean; }): boolean { if (normalizeOptionalAgentRuntimeId(params.harnessRuntime) !== "codex") { return false; } + // A persisted lock makes the selected native harness authoritative. Local + // provider config must not reroute compaction away from that owner. + if (params.modelSelectionLocked === true) { + return true; + } if (!openAIProviderUsesCodexRuntimeByDefault(params)) { return false; } @@ -261,6 +274,7 @@ export function buildEmbeddedCompactionRuntimeContext(params: { provider?: string | null; modelId?: string | null; harnessRuntime?: string | null; + modelSelectionLocked?: boolean; modelFallbacksOverride?: string[]; thinkLevel?: ThinkLevel; reasoningLevel?: ReasoningLevel; @@ -276,6 +290,7 @@ export function buildEmbeddedCompactionRuntimeContext(params: { modelId: params.modelId, authProfileId: params.authProfileId, harnessRuntime: params.harnessRuntime, + modelSelectionLocked: params.modelSelectionLocked, }); const agentHarnessId = params.harnessRuntime?.trim() || undefined; const processScopeKey = params.sessionKey?.trim(); @@ -296,6 +311,7 @@ export function buildEmbeddedCompactionRuntimeContext(params: { currentMessageId: params.currentMessageId ?? undefined, authProfileId: resolved.authProfileId, agentHarnessId, + modelSelectionLocked: params.modelSelectionLocked, workspaceDir: params.workspaceDir, cwd: params.cwd ?? undefined, agentDir: params.agentDir, diff --git a/src/agents/embedded-agent-runner/model.ts b/src/agents/embedded-agent-runner/model.ts index 723ac2e2517c..46e4d3945442 100644 --- a/src/agents/embedded-agent-runner/model.ts +++ b/src/agents/embedded-agent-runner/model.ts @@ -118,7 +118,8 @@ const SKIP_AGENT_DISCOVERY_PROVIDER_RUNTIME_HOOKS: ProviderRuntimeHooks = { ...TARGET_PROVIDER_RUNTIME_HOOKS, }; -function createEmptyAgentDiscoveryStores(): { +/** Creates isolated model/auth stores for harnesses that own model discovery themselves. */ +export function createEmptyAgentDiscoveryStores(): { authStorage: AuthStorage; modelRegistry: ModelRegistry; } { diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts index b6364d7fbbec..bcf694bbec1b 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts @@ -12,6 +12,7 @@ import { rotateAgentEventLifecycleGeneration, withAgentRunLifecycleGeneration, } from "../../infra/agent-events.js"; +import { AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE } from "../../sessions/agent-harness-session-key.js"; import type { AgentHarness } from "../harness/types.js"; import type { AgentInternalEvent } from "../internal-events.js"; import type { AgentRuntimePlan } from "../runtime-plan/types.js"; @@ -1046,6 +1047,65 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { resetAgentRunContextForTest(); }); + it("revalidates reserved harness ownership after the global queue wait", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-harness-admission-")); + const storePath = path.join(dir, "sessions.json"); + const sessionId = "native-session"; + const sessionKey = "agent:main:harness:codex:supervision:native-thread"; + await fs.writeFile( + storePath, + JSON.stringify({ + [sessionKey]: { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId, + updatedAt: Date.now(), + }, + }), + "utf8", + ); + + let enqueueCount = 0; + let runQueuedTask: (() => void) | undefined; + mockedGlobalHookRunner.hasHooks.mockImplementation( + (hookName) => hookName === "before_model_resolve", + ); + mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); + + try { + const runPromise = runEmbeddedAgent({ + ...overflowBaseRunParams, + agentHarnessId: "codex", + config: { session: { store: storePath } } as RunEmbeddedAgentParams["config"], + modelSelectionLocked: true, + runId: "queued-harness-admission", + sessionId, + sessionKey, + enqueue: async (task) => { + enqueueCount += 1; + if (enqueueCount === 1) { + return await task(); + } + return await new Promise((resolve, reject) => { + runQueuedTask = () => { + void Promise.resolve().then(task).then(resolve, reject); + }; + }); + }, + }); + await vi.waitFor(() => expect(runQueuedTask).toBeTypeOf("function")); + + await fs.writeFile(storePath, "{}", "utf8"); + runQueuedTask?.(); + + await expect(runPromise).rejects.toThrow(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE); + expect(mockedGlobalHookRunner.runBeforeModelResolve).not.toHaveBeenCalled(); + expect(mockedRunEmbeddedAttempt).not.toHaveBeenCalled(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + it("rejects background work queued across lifecycle rotation", async () => { resetAgentRunContextForTest(); let enqueueCount = 0; @@ -2327,6 +2387,32 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }); }); + it("preserves a locked OpenClaw model in overflow compaction context", async () => { + mockOverflowRetrySuccess({ + runEmbeddedAttempt: mockedRunEmbeddedAttempt, + compactDirect: mockedCompactDirect, + }); + + await runEmbeddedAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.5", + agentHarnessId: "openclaw", + modelSelectionLocked: true, + config: { + agents: { defaults: { compaction: { model: "anthropic/claude-opus-4-6" } } }, + }, + }); + + const compactParams = expectMockCallFields(mockedCompactDirect, {}); + expectRecordFields(compactParams.runtimeContext, { + trigger: "overflow", + modelSelectionLocked: true, + provider: "openai", + model: "gpt-5.5", + }); + }); + it("threads prompt-cache runtime context into overflow compaction", async () => { mockedRunEmbeddedAttempt .mockResolvedValueOnce( diff --git a/src/agents/embedded-agent-runner/run.timeout-triggered-compaction.test.ts b/src/agents/embedded-agent-runner/run.timeout-triggered-compaction.test.ts index 0c0394b2ea2b..b0f8d49af9e1 100644 --- a/src/agents/embedded-agent-runner/run.timeout-triggered-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run.timeout-triggered-compaction.test.ts @@ -54,6 +54,9 @@ type CompactRuntimeContext = { currentMessageId?: string; senderId?: string; authProfileId?: string; + provider?: string; + model?: string; + modelSelectionLocked?: boolean; }; type CompactParams = { @@ -242,6 +245,8 @@ describe("timeout-triggered compaction", () => { await runEmbeddedAgent({ ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.5", messageChannel: "slack", messageProvider: "slack", agentAccountId: "acct-1", @@ -249,6 +254,11 @@ describe("timeout-triggered compaction", () => { currentThreadTs: "thread-1", currentMessageId: "message-1", senderId: "sender-1", + agentHarnessId: "openclaw", + modelSelectionLocked: true, + config: { + agents: { defaults: { compaction: { model: "anthropic/claude-opus-4-6" } } }, + }, }); expect(mockedCompactDirect).toHaveBeenCalledTimes(1); @@ -260,6 +270,9 @@ describe("timeout-triggered compaction", () => { expect(compactParams.runtimeContext?.currentThreadTs).toBe("thread-1"); expect(compactParams.runtimeContext?.currentMessageId).toBe("message-1"); expect(compactParams.runtimeContext?.senderId).toBe("sender-1"); + expect(compactParams.runtimeContext?.modelSelectionLocked).toBe(true); + expect(compactParams.runtimeContext?.provider).toBe("openai"); + expect(compactParams.runtimeContext?.model).toBe("gpt-5.5"); }); it("falls through to normal handling when timeout compaction fails", async () => { diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index e237f94e142a..76868b576c6c 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -15,7 +15,7 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js"; import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; import { getRuntimeConfigSnapshot } from "../../config/config.js"; import { resolveStorePath } from "../../config/sessions.js"; -import { updateSessionEntry } from "../../config/sessions/session-accessor.js"; +import { loadSessionEntry, updateSessionEntry } from "../../config/sessions/session-accessor.js"; import { OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST } from "../../context-engine/host-compat.js"; import { ensureContextEnginesInitialized } from "../../context-engine/init.js"; import { @@ -43,6 +43,7 @@ import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { resolveProviderAuthProfileId } from "../../plugins/provider-runtime.js"; import { enqueueCommandInLane, getCommandLaneSnapshot } from "../../process/command-queue.js"; import type { CommandQueueEnqueueOptions } from "../../process/command-queue.types.js"; +import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { createAgentHarnessTaskRuntimeScope } from "../../tasks/agent-harness-task-runtime-scope.js"; import { resolveUserPath } from "../../utils.js"; import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; @@ -169,7 +170,7 @@ import { import { resolveEmbeddedRunFailureSignal } from "./failure-signal.js"; import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; import { log } from "./logger.js"; -import { resolveModelAsync } from "./model.js"; +import { createEmptyAgentDiscoveryStores, resolveModelAsync } from "./model.js"; import { createPostCompactionLoopGuard, PostCompactionLoopPersistedError, @@ -211,10 +212,10 @@ import { resolveOverloadFailoverBackoffMs, resolveOverloadProfileRotationLimit, resolveRateLimitProfileRotationLimit, + resolveEmbeddedAttemptBasePrompt, resolveNextSameModelRateLimitRetryCount, resolveSameModelRateLimitRetryDelayMs, type RuntimeAuthState, - scrubAnthropicRefusalMagic, } from "./run/helpers.js"; import { MAX_CONSECUTIVE_IDLE_TIMEOUTS_BEFORE_OUTPUT, @@ -241,8 +242,11 @@ import { buildEmbeddedRunPayloads } from "./run/payloads.js"; import { handleRetryLimitExhaustion } from "./run/retry-limit.js"; import { buildBeforeModelResolveAttachments, - resolveEffectiveRuntimeModel, + createNativeModelOwnedRuntimeModel, + resolveEmbeddedRuntimeModelPolicy, + resolveAgentHarnessRunAdmissionError, resolveHookModelSelection, + resolveNativeModelOwnedHarnessId, } from "./run/setup.js"; import { mergeAttemptToolMediaPayloads } from "./run/tool-media-payloads.js"; import type { EmbeddedRunFastModeParam } from "./run/types.js"; @@ -569,6 +573,33 @@ function backfillSessionKey(params: { } } +function assertAgentHarnessRunAdmission(params: RunEmbeddedAgentParams): void { + const sessionKey = normalizeOptionalString(params.sessionKey); + if (!sessionKey) { + return; + } + const admissionAgentId = params.agentId ?? resolveAgentIdFromSessionKey(sessionKey); + const storePath = + normalizeOptionalString(params.sessionTarget?.storePath) ?? + resolveStorePath(params.config?.session?.store, { agentId: admissionAgentId }); + const durableEntry = loadSessionEntry({ + ...(admissionAgentId ? { agentId: admissionAgentId } : {}), + readConsistency: "latest", + sessionKey, + storePath, + }); + const admissionError = resolveAgentHarnessRunAdmissionError({ + agentHarnessId: params.agentHarnessId, + entry: durableEntry, + modelSelectionLocked: params.modelSelectionLocked, + sessionId: params.sessionId, + sessionKey, + }); + if (admissionError) { + throw new Error(admissionError); + } +} + function buildHandledReplyPayloads(reply?: ReplyPayload) { const normalized = reply ?? { text: SILENT_REPLY_TOKEN }; return [ @@ -663,6 +694,7 @@ async function runEmbeddedAgentInternal( sessionKey: paramsBase.sessionKey, agentId: paramsBase.agentId, }); + assertAgentHarnessRunAdmission({ ...paramsBase, sessionKey: effectiveSessionKey }); const runSessionTarget = await resolveAgentRunSessionTarget({ ...paramsBase, sessionKey: effectiveSessionKey, @@ -771,6 +803,9 @@ async function runEmbeddedAgentInternal( lifecycleGeneration = currentLifecycleGeneration; params = { ...params, lifecycleGeneration }; } + // Queue waits can outlive the durable harness binding that admitted a run. + // Recheck only after lifecycle admission, before any run context or hook can execute. + assertAgentHarnessRunAdmission(params); claimAgentRunContext(params.runId, { ...existingContext, sessionKey: params.sessionKey ?? existingContext?.sessionKey, @@ -1078,6 +1113,7 @@ async function runEmbeddedAgentInternal( config: params.config, agentId: params.agentId, sessionKey: params.sessionKey, + agentHarnessId: params.agentHarnessId, agentHarnessRuntimeOverride: params.agentHarnessRuntimeOverride, workspaceDir: resolvedWorkspace, }); @@ -1091,52 +1127,33 @@ async function runEmbeddedAgentInternal( agentHarnessRuntimeOverride: params.agentHarnessRuntimeOverride, }); const pluginHarnessOwnsTransport = agentHarness.id !== "openclaw"; - const modelConfigProvider = provider; - const selectedRuntimeProvider = resolveSelectedOpenAIRuntimeProvider({ - provider, - harnessRuntime: agentHarness.id, - agentHarnessId: agentHarness.id, - authProfileProvider: params.authProfileId?.split(":", 1)[0], - authProfileId: params.authProfileId, - config: params.config, - workspaceDir: resolvedWorkspace, + const nativeModelOwnedHarnessId = resolveNativeModelOwnedHarnessId({ + agentHarnessId: params.agentHarnessId, + modelSelectionLocked: params.modelSelectionLocked, + selectedHarnessId: agentHarness.id, }); - const modelResolutionProviders = - selectedRuntimeProvider !== provider ? [selectedRuntimeProvider, provider] : [provider]; + const nativeModelOwned = nativeModelOwnedHarnessId !== undefined; + const modelConfigProvider = provider; let resolvedModelProvider = provider; let firstModelResolution: Awaited> | undefined; let modelResolution: Awaited> | undefined; - for (const candidateProvider of modelResolutionProviders) { - const candidateResolution = await resolveModelAsync( - candidateProvider, - modelId, - agentDir, - params.config, - { - // Plugin dynamic model hooks can resolve explicit model refs without - // first generating OpenClaw models.json. This keeps one-shot model runs from - // blocking on unrelated provider discovery. - skipAgentDiscovery: true, - allowBundledStaticCatalogFallback: pluginHarnessOwnsTransport, - preferBundledStaticCatalogTransport: pluginHarnessOwnsTransport, - workspaceDir: resolvedWorkspace, - authProfileId: params.authProfileId, - }, - ); - firstModelResolution ??= candidateResolution; - if (candidateResolution.model) { - resolvedModelProvider = candidateProvider; - modelResolution = candidateResolution; - break; - } - } - if (!modelResolution && pluginHarnessOwnsTransport) { - modelResolution ??= firstModelResolution; - } - if (!modelResolution) { - await ensureOpenClawModelsJson(params.config, agentDir, { + if (nativeModelOwned) { + modelResolution = { + model: createNativeModelOwnedRuntimeModel({ provider, modelId }), + ...createEmptyAgentDiscoveryStores(), + }; + } else { + const selectedRuntimeProvider = resolveSelectedOpenAIRuntimeProvider({ + provider, + harnessRuntime: agentHarness.id, + agentHarnessId: agentHarness.id, + authProfileProvider: params.authProfileId?.split(":", 1)[0], + authProfileId: params.authProfileId, + config: params.config, workspaceDir: resolvedWorkspace, }); + const modelResolutionProviders = + selectedRuntimeProvider !== provider ? [selectedRuntimeProvider, provider] : [provider]; for (const candidateProvider of modelResolutionProviders) { const candidateResolution = await resolveModelAsync( candidateProvider, @@ -1144,12 +1161,14 @@ async function runEmbeddedAgentInternal( agentDir, params.config, { + // Plugin dynamic model hooks can resolve explicit model refs without + // first generating OpenClaw models.json. This keeps one-shot model runs from + // blocking on unrelated provider discovery. + skipAgentDiscovery: true, + allowBundledStaticCatalogFallback: pluginHarnessOwnsTransport, + preferBundledStaticCatalogTransport: pluginHarnessOwnsTransport, workspaceDir: resolvedWorkspace, authProfileId: params.authProfileId, - // Enable bundled static catalog fallback so plugin-provided - // models that are not discoverable via agent model discovery - // can still be resolved from the static catalog. - allowBundledStaticCatalogFallback: true, }, ); firstModelResolution ??= candidateResolution; @@ -1159,8 +1178,38 @@ async function runEmbeddedAgentInternal( break; } } + if (!modelResolution && pluginHarnessOwnsTransport) { + modelResolution ??= firstModelResolution; + } + if (!modelResolution) { + await ensureOpenClawModelsJson(params.config, agentDir, { + workspaceDir: resolvedWorkspace, + }); + for (const candidateProvider of modelResolutionProviders) { + const candidateResolution = await resolveModelAsync( + candidateProvider, + modelId, + agentDir, + params.config, + { + workspaceDir: resolvedWorkspace, + authProfileId: params.authProfileId, + // Enable bundled static catalog fallback so plugin-provided + // models that are not discoverable via agent model discovery + // can still be resolved from the static catalog. + allowBundledStaticCatalogFallback: true, + }, + ); + firstModelResolution ??= candidateResolution; + if (candidateResolution.model) { + resolvedModelProvider = candidateProvider; + modelResolution = candidateResolution; + break; + } + } + } + modelResolution ??= firstModelResolution; } - modelResolution ??= firstModelResolution; if (!modelResolution) { throw new FailoverError(`Unknown model: ${provider}/${modelId}`, { reason: "model_not_found", @@ -1183,7 +1232,7 @@ async function runEmbeddedAgentInternal( } let runtimeModel = model; - const resolvedRuntimeModel = resolveEffectiveRuntimeModel({ + const resolvedRuntimeModel = resolveEmbeddedRuntimeModelPolicy({ cfg: params.config, provider, contextConfigProvider: resolveContextConfigProviderForRuntime({ @@ -1193,8 +1242,12 @@ async function runEmbeddedAgentInternal( }), modelId, runtimeModel, + nativeModelOwned, }); - const ctxInfo = resolvedRuntimeModel.ctxInfo; + const contextTokenBudget = resolvedRuntimeModel.contextTokenBudget; + const contextWindowInfo = resolvedRuntimeModel.contextWindowInfo; + const outerContextTokenMeta = + contextTokenBudget === undefined ? {} : { contextTokens: contextTokenBudget }; let effectiveModel = resolvedRuntimeModel.effectiveModel; startupStages.mark("model-resolution"); notifyExecutionPhase("model_resolution", { provider, model: modelId }); @@ -2019,7 +2072,7 @@ async function runEmbeddedAgentInternal( sessionFile: activeSessionFile, provider, model: model.id, - contextTokens: ctxInfo.tokens, + ...outerContextTokenMeta, usageAccumulator, lastRunPromptUsage, lastTurnTotal, @@ -2039,7 +2092,11 @@ async function runEmbeddedAgentInternal( const basePrompt = nextAttemptPromptOverride ?? - (provider === "anthropic" ? scrubAnthropicRefusalMagic(params.prompt) : params.prompt); + resolveEmbeddedAttemptBasePrompt({ + nativeModelOwned, + provider, + prompt: params.prompt, + }); nextAttemptPromptOverride = null; const promptAdditions = [ reasoningOnlyRetryInstruction, @@ -2199,9 +2256,13 @@ async function runEmbeddedAgentInternal( agentDir, config: params.config, allowGatewaySubagentBinding: params.allowGatewaySubagentBinding, - contextEngine, - contextTokenBudget: ctxInfo.tokens, - contextWindowInfo: ctxInfo, + ...(nativeModelOwned + ? {} + : { + contextEngine, + contextTokenBudget, + contextWindowInfo, + }), skillsSnapshot: params.skillsSnapshot, prompt, transcriptPrompt: params.transcriptPrompt, @@ -2223,6 +2284,7 @@ async function runEmbeddedAgentInternal( // bootstrap but drift back to OpenClaw when the attempt is created. agentHarnessId: agentHarness.id, agentHarnessRuntimeOverride: agentHarness.id, + modelSelectionLocked: params.modelSelectionLocked, ...(params.sessionKey ? { agentHarnessTaskRuntimeScope: createAgentHarnessTaskRuntimeScope({ @@ -2459,7 +2521,7 @@ async function runEmbeddedAgentInternal( sessionFile: activeSessionFile, provider, model: model.id, - contextTokens: ctxInfo.tokens, + ...outerContextTokenMeta, usageAccumulator, lastRunPromptUsage, lastTurnTotal, @@ -2530,7 +2592,7 @@ async function runEmbeddedAgentInternal( !attempt.lastToolError && (attempt.toolMetas?.length ?? 0) === 0 && (attempt.assistantTexts?.length ?? 0) === 0; - if (preflightRecovery?.handled) { + if (!nativeModelOwned && preflightRecovery?.handled) { const retryingFromTranscript = preflightRecovery.source === "mid-turn"; log.info( `[context-overflow-precheck] early recovery route=${preflightRecovery.route} ` + @@ -2566,6 +2628,8 @@ async function runEmbeddedAgentInternal( throw new LiveSessionModelSwitchError(requestedSelection); } if ( + !nativeModelOwned && + contextTokenBudget !== undefined && timedOut && !timedOutDuringCompaction && !timedOutDuringToolExecution && @@ -2578,8 +2642,8 @@ async function runEmbeddedAgentInternal( lastCallUsage: lastRunPromptUsage, }); const tokenUsedRatio = - lastTurnPromptTokens != null && ctxInfo.tokens > 0 - ? lastTurnPromptTokens / ctxInfo.tokens + lastTurnPromptTokens != null && contextTokenBudget > 0 + ? lastTurnPromptTokens / contextTokenBudget : 0; if (timeoutCompactionAttempts >= MAX_TIMEOUT_COMPACTION_ATTEMPTS) { log.warn( @@ -2615,6 +2679,7 @@ async function runEmbeddedAgentInternal( provider, modelId, harnessRuntime: agentHarness.id, + modelSelectionLocked: params.modelSelectionLocked, modelFallbacksOverride: params.modelFallbacksOverride, thinkLevel, reasoningLevel: params.reasoningLevel, @@ -2656,12 +2721,12 @@ async function runEmbeddedAgentInternal( sessionId: activeSessionId, sessionKey: params.sessionKey, sessionFile: activeSessionFile, - tokenBudget: ctxInfo.tokens, + tokenBudget: contextTokenBudget, force: true, compactionTarget: "budget", runtimeContext: timeoutCompactionRuntimeContext, runtimeSettings: buildEmbeddedContextEngineRuntimeSettings({ - tokenBudget: ctxInfo.tokens, + tokenBudget: contextTokenBudget, }), }, resolveCompactionTimeoutMs(params.config), @@ -2736,7 +2801,7 @@ async function runEmbeddedAgentInternal( })() : null; - if (contextOverflowError) { + if (contextOverflowError && !nativeModelOwned && contextTokenBudget !== undefined) { const overflowDiagId = createCompactionDiagId(); const errorText = contextOverflowError.text; const msgCount = attempt.messagesSnapshot?.length ?? 0; @@ -2750,10 +2815,10 @@ async function runEmbeddedAgentInternal( const overflowTokenCountForCompaction = observedOverflowTokens ?? preflightEstimatedPromptTokens ?? - (ctxInfo.tokens > 0 + (contextTokenBudget > 0 ? // Confirmed overflow with an unparseable provider message still carries a // minimally over-budget count for compaction engines and diagnostics. - ctxInfo.tokens + 1 + contextTokenBudget + 1 : undefined); log.warn( `[context-overflow-diag] sessionKey=${params.sessionKey ?? params.sessionId} ` + @@ -2825,6 +2890,7 @@ async function runEmbeddedAgentInternal( provider, modelId, harnessRuntime: agentHarness.id, + modelSelectionLocked: params.modelSelectionLocked, thinkLevel, reasoningLevel: params.reasoningLevel, bashElevated: params.bashElevated, @@ -2864,7 +2930,7 @@ async function runEmbeddedAgentInternal( // surfaces as a thrown error handled by the catch below. const overflowCompactionRuntimeSettings = buildEmbeddedContextEngineRuntimeSettings( { - tokenBudget: ctxInfo.tokens, + tokenBudget: contextTokenBudget, degradedReason: "context_overflow", }, ); @@ -2874,7 +2940,7 @@ async function runEmbeddedAgentInternal( sessionId: activeSessionId, sessionKey: params.sessionKey, sessionFile: activeSessionFile, - tokenBudget: ctxInfo.tokens, + tokenBudget: contextTokenBudget, ...(overflowTokenCountForCompaction !== undefined ? { currentTokenCount: overflowTokenCountForCompaction } : {}), @@ -2944,9 +3010,9 @@ async function runEmbeddedAgentInternal( if (preflightRecovery?.route === "compact_then_truncate") { const truncResult = await truncateOversizedToolResultsInSession({ sessionFile: activeSessionFile, - contextWindowTokens: ctxInfo.tokens, + contextWindowTokens: contextTokenBudget, maxCharsOverride: resolveLiveToolResultMaxChars({ - contextWindowTokens: ctxInfo.tokens, + contextWindowTokens: contextTokenBudget, cfg: params.config, agentId: sessionAgentId, }), @@ -2990,7 +3056,7 @@ async function runEmbeddedAgentInternal( ); } if (!toolResultTruncationAttempted) { - const contextWindowTokens = ctxInfo.tokens; + const contextWindowTokens = contextTokenBudget; const toolResultMaxChars = resolveLiveToolResultMaxChars({ contextWindowTokens, cfg: params.config, @@ -3071,7 +3137,7 @@ async function runEmbeddedAgentInternal( sessionFile: activeSessionFile, provider, model: model.id, - contextTokens: ctxInfo.tokens, + ...outerContextTokenMeta, usageAccumulator, lastRunPromptUsage, lastAssistant: attemptAssistant, @@ -3104,7 +3170,7 @@ async function runEmbeddedAgentInternal( sessionFile: activeSessionFile, provider, model: model.id, - contextTokens: ctxInfo.tokens, + ...outerContextTokenMeta, usageAccumulator, lastRunPromptUsage, lastAssistant: attemptAssistant, @@ -3220,7 +3286,7 @@ async function runEmbeddedAgentInternal( sessionFile: activeSessionFile, provider, model: model.id, - contextTokens: ctxInfo.tokens, + ...outerContextTokenMeta, usageAccumulator, lastRunPromptUsage, lastAssistant: attemptAssistant, @@ -3261,7 +3327,7 @@ async function runEmbeddedAgentInternal( sessionFile: activeSessionFile, provider, model: model.id, - contextTokens: ctxInfo.tokens, + ...outerContextTokenMeta, usageAccumulator, lastRunPromptUsage, lastAssistant: attemptAssistant, @@ -3696,7 +3762,7 @@ async function runEmbeddedAgentInternal( sessionFile: sessionFileUsed, provider: reportedModelRef.provider, model: reportedModelRef.model, - contextTokens: ctxInfo.tokens, + ...outerContextTokenMeta, agentHarnessId: attempt.agentHarnessId, usage: usageMeta.usage, lastCallUsage: usageMeta.lastCallUsage, diff --git a/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts b/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts index 76ec56555e6e..564128fa1312 100644 --- a/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts +++ b/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts @@ -593,6 +593,7 @@ type AfterTurnRuntimeContextAttempt = Pick< | "provider" | "modelId" | "agentHarnessId" + | "modelSelectionLocked" | "thinkLevel" | "reasoningLevel" | "bashElevated" @@ -634,6 +635,7 @@ export function buildAfterTurnRuntimeContext(params: { provider: params.attempt.provider, modelId: params.attempt.modelId, harnessRuntime: params.attempt.agentHarnessId, + modelSelectionLocked: params.attempt.modelSelectionLocked, thinkLevel: params.attempt.thinkLevel, reasoningLevel: params.attempt.reasoningLevel, bashElevated: params.attempt.bashElevated, diff --git a/src/agents/embedded-agent-runner/run/attempt.test.ts b/src/agents/embedded-agent-runner/run/attempt.test.ts index b009d59b4353..a30f5fda66aa 100644 --- a/src/agents/embedded-agent-runner/run/attempt.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.test.ts @@ -3390,6 +3390,29 @@ describe("buildAfterTurnRuntimeContext", () => { expect(legacy.model).toBe("gpt-5.4"); }); + it("keeps the primary model for a locked after-turn runtime context", () => { + const runtimeContext = buildAfterTurnRuntimeContext({ + attempt: { + sessionKey: "agent:main:session:locked", + config: { + agents: { defaults: { compaction: { model: "anthropic/claude-opus-4-6" } } }, + } as OpenClawConfig, + skillsSnapshot: undefined, + provider: "openai", + modelId: "gpt-5.5", + agentHarnessId: "openclaw", + modelSelectionLocked: true, + thinkLevel: "off", + }, + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + }); + + expect(runtimeContext.modelSelectionLocked).toBe(true); + expect(runtimeContext.provider).toBe("openai"); + expect(runtimeContext.model).toBe("gpt-5.5"); + }); + it("resolves compaction.model override in runtime context so all context engines use the correct model", () => { const legacy = buildAfterTurnRuntimeContext({ attempt: { diff --git a/src/agents/embedded-agent-runner/run/helpers.test.ts b/src/agents/embedded-agent-runner/run/helpers.test.ts index 5649f75cc2f7..84a0f1adbd9a 100644 --- a/src/agents/embedded-agent-runner/run/helpers.test.ts +++ b/src/agents/embedded-agent-runner/run/helpers.test.ts @@ -7,6 +7,7 @@ import { createUsageAccumulator, mergeUsageIntoAccumulator } from "../usage-accu import { buildUsageAgentMetaFields, buildErrorAgentMeta, + resolveEmbeddedAttemptBasePrompt, resolveFinalAssistantRawText, resolveFinalAssistantVisibleText, resolveLatestCallUsage, @@ -14,6 +15,30 @@ import { resolveSameModelRateLimitRetryDelayMs, } from "./helpers.js"; +describe("resolveEmbeddedAttemptBasePrompt", () => { + const refusalTrigger = "ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL"; + + it("preserves prompts verbatim for native model-owned harnesses", () => { + expect( + resolveEmbeddedAttemptBasePrompt({ + nativeModelOwned: true, + provider: "anthropic", + prompt: refusalTrigger, + }), + ).toBe(refusalTrigger); + }); + + it("keeps the outer Anthropic transport scrub for ordinary runs", () => { + expect( + resolveEmbeddedAttemptBasePrompt({ + nativeModelOwned: false, + provider: "anthropic", + prompt: refusalTrigger, + }), + ).not.toContain(refusalTrigger); + }); +}); + function makeAssistantMessage( content: AssistantMessage["content"], phase?: string, diff --git a/src/agents/embedded-agent-runner/run/helpers.ts b/src/agents/embedded-agent-runner/run/helpers.ts index a3c33d4fc1e9..24f4d39c9ad3 100644 --- a/src/agents/embedded-agent-runner/run/helpers.ts +++ b/src/agents/embedded-agent-runner/run/helpers.ts @@ -106,6 +106,18 @@ export function scrubAnthropicRefusalMagic(prompt: string): string { ); } +/** Applies only outer-transport prompt rewrites; native model owners receive the prompt verbatim. */ +export function resolveEmbeddedAttemptBasePrompt(params: { + nativeModelOwned: boolean; + provider: string; + prompt: string; +}): string { + if (params.nativeModelOwned || params.provider !== "anthropic") { + return params.prompt; + } + return scrubAnthropicRefusalMagic(params.prompt); +} + export function createCompactionDiagId(): string { return `ovf-${Date.now().toString(36)}-${generateSecureToken(4)}`; } diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index 967fae40cd24..d2fb05808647 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -173,6 +173,8 @@ export type RunEmbeddedAgentParams = { modelFallbacksOverride?: string[]; /** Session-pinned embedded harness id. Prevents runtime hot-switching. */ agentHarnessId?: string; + /** True when the pinned non-default harness owns model selection for this session. */ + modelSelectionLocked?: boolean; /** Explicit runtime override selected for this turn. Unlike agentHarnessId, this may force OpenClaw. */ agentHarnessRuntimeOverride?: string; authProfileId?: string; diff --git a/src/agents/embedded-agent-runner/run/setup.test.ts b/src/agents/embedded-agent-runner/run/setup.test.ts index d2913ac49d2f..5f33e550c2ed 100644 --- a/src/agents/embedded-agent-runner/run/setup.test.ts +++ b/src/agents/embedded-agent-runner/run/setup.test.ts @@ -1,13 +1,18 @@ // Setup tests cover model-resolution hooks and effective runtime model context // metadata before an embedded run starts. import { describe, expect, it, vi } from "vitest"; +import type { SessionEntry } from "../../../config/sessions/types.js"; import type { ModelDefinitionConfig } from "../../../config/types.models.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { ProviderRuntimeModel } from "../../../plugins/provider-runtime-model.types.js"; +import { AGENT_HARNESS_SESSION_ID_LOCKED_MESSAGE } from "../../../sessions/agent-harness-session-key.js"; import { buildBeforeModelResolveAttachments, + resolveAgentHarnessRunAdmissionError, + resolveEmbeddedRuntimeModelPolicy, resolveEffectiveRuntimeModel, resolveHookModelSelection, + resolveNativeModelOwnedHarnessId, } from "./setup.js"; const hookContext = { @@ -15,6 +20,104 @@ const hookContext = { workspaceDir: "/tmp/workspace", }; +describe("agent harness run admission", () => { + const sessionKey = "agent:main:harness:codex:supervision:native-thread"; + const entry: SessionEntry = { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + updatedAt: 1, + }; + + it("accepts only the matching requested and durable harness lock", () => { + expect( + resolveAgentHarnessRunAdmissionError({ + agentHarnessId: "codex", + entry, + modelSelectionLocked: true, + sessionId: "native-session", + sessionKey, + }), + ).toBeUndefined(); + }); + + it("keeps a pre-existing unlocked harness-prefixed session on the ordinary runtime path", () => { + expect( + resolveAgentHarnessRunAdmissionError({ + entry: { + agentHarnessId: "openclaw", + sessionId: "legacy-session", + updatedAt: 1, + }, + sessionId: "legacy-session", + sessionKey: "agent:main:harness:notes", + }), + ).toBeUndefined(); + }); + + it("accepts an ordinary-key session with the exact durable harness lock", () => { + expect( + resolveAgentHarnessRunAdmissionError({ + agentHarnessId: "codex", + entry, + modelSelectionLocked: true, + sessionId: "native-session", + sessionKey: "agent:main:main", + }), + ).toBeUndefined(); + }); + + it("keeps a legacy model-selection lock on the ordinary runtime path", () => { + expect( + resolveAgentHarnessRunAdmissionError({ + entry: { + modelSelectionLocked: true, + sessionId: "legacy-session", + updatedAt: 1, + }, + sessionId: "legacy-session", + sessionKey: "agent:main:main", + }), + ).toBeUndefined(); + }); + + it.each([ + ["a different session id", { sessionId: "other-session" }], + ["an omitted runtime lock", { modelSelectionLocked: undefined }], + ["a different harness", { agentHarnessId: "openclaw" }], + ])("rejects an ordinary-key locked session with %s", (_label, overrides) => { + expect( + resolveAgentHarnessRunAdmissionError({ + agentHarnessId: "codex", + entry, + modelSelectionLocked: true, + sessionId: "native-session", + sessionKey: "agent:main:main", + ...overrides, + }), + ).toBe(AGENT_HARNESS_SESSION_ID_LOCKED_MESSAGE); + }); + + it.each([ + { agentHarnessId: "openclaw", modelSelectionLocked: true, entry }, + { agentHarnessId: "codex", modelSelectionLocked: false, entry }, + { agentHarnessId: "codex", modelSelectionLocked: true, entry: undefined }, + { + agentHarnessId: "codex", + modelSelectionLocked: true, + entry: { ...entry, sessionId: "stale-session" }, + }, + ])("rejects a mismatched or missing reserved runtime", (params) => { + expect( + resolveAgentHarnessRunAdmissionError({ + ...params, + sessionId: "native-session", + sessionKey, + }), + ).toContain("reserved"); + }); +}); + describe("buildBeforeModelResolveAttachments", () => { it("maps prompt image metadata to before_model_resolve attachments", () => { expect( @@ -173,3 +276,58 @@ describe("resolveEffectiveRuntimeModel", () => { expect(result.effectiveModel.contextWindow).toBe(272_000); }); }); + +describe("native model-owned harness policy", () => { + it("requires an exact pinned, locked, non-default harness", () => { + expect( + resolveNativeModelOwnedHarnessId({ + agentHarnessId: "codex", + modelSelectionLocked: true, + selectedHarnessId: "codex", + }), + ).toBe("codex"); + expect( + resolveNativeModelOwnedHarnessId({ + agentHarnessId: "codex", + modelSelectionLocked: false, + selectedHarnessId: "codex", + }), + ).toBeUndefined(); + expect( + resolveNativeModelOwnedHarnessId({ + agentHarnessId: "openclaw", + modelSelectionLocked: true, + selectedHarnessId: "openclaw", + }), + ).toBeUndefined(); + expect( + resolveNativeModelOwnedHarnessId({ + agentHarnessId: "codex", + modelSelectionLocked: true, + selectedHarnessId: "other", + }), + ).toBeUndefined(); + }); + + it("does not apply outer context guards or budgets", () => { + const runtimeModel = createRuntimeModel(); + const result = resolveEmbeddedRuntimeModelPolicy({ + cfg: { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + models: [createConfiguredModel({ contextWindow: 1, contextTokens: 1 })], + }, + }, + }, + }, + provider: "openai", + modelId: runtimeModel.id, + runtimeModel, + nativeModelOwned: true, + }); + + expect(result).toEqual({ effectiveModel: runtimeModel }); + }); +}); diff --git a/src/agents/embedded-agent-runner/run/setup.ts b/src/agents/embedded-agent-runner/run/setup.ts index 97e20e73cf7f..c26d98693ba9 100644 --- a/src/agents/embedded-agent-runner/run/setup.ts +++ b/src/agents/embedded-agent-runner/run/setup.ts @@ -1,6 +1,7 @@ /** * Resolves hook-selected model state and pre-model attachments for a run. */ +import type { SessionEntry } from "../../../config/sessions/types.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { ProviderRuntimeModel } from "../../../plugins/provider-runtime-model.types.js"; import type { @@ -8,6 +9,18 @@ import type { PluginHookBeforeModelResolveAttachment, PluginHookBeforeModelResolveEvent, } from "../../../plugins/types.js"; +import { + AGENT_HARNESS_SESSION_ID_LOCKED_MESSAGE, + AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE, + isAgentHarnessSessionKey, + isValidAgentHarnessSessionStoreEntry, + resolveAgentHarnessSessionStoreEntryError, +} from "../../../sessions/agent-harness-session-key.js"; +import { + isDefaultAgentRuntimeId, + normalizeOptionalAgentRuntimeId, + OPENCLAW_AGENT_RUNTIME_ID, +} from "../../agent-runtime-id.js"; import { evaluateContextWindowGuard, formatContextWindowBlockMessage, @@ -42,6 +55,48 @@ type HookRunnerLike = { ): Promise; }; +/** Durable harness sessions run only with their exact persisted identity and runtime lock. */ +export function resolveAgentHarnessRunAdmissionError(params: { + agentHarnessId?: string; + entry?: SessionEntry; + modelSelectionLocked?: boolean; + sessionId: string; + sessionKey?: string; +}): string | undefined { + const sessionKey = params.sessionKey?.trim(); + if (!sessionKey) { + return undefined; + } + const entry = params.entry; + const reservedKey = isAgentHarnessSessionKey(sessionKey); + if (!entry) { + return reservedKey ? AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE : undefined; + } + // Rows created before harness supervision could already use this prefix. Only the + // durable lock makes an existing row harness-owned; missing reserved keys stay closed. + if (entry.modelSelectionLocked !== true) { + return undefined; + } + const durableEntryError = resolveAgentHarnessSessionStoreEntryError(sessionKey, entry); + if (durableEntryError) { + return durableEntryError; + } + if (!isValidAgentHarnessSessionStoreEntry(sessionKey, entry)) { + return undefined; + } + const requestedHarnessId = normalizeOptionalAgentRuntimeId(params.agentHarnessId); + const durableHarnessId = normalizeOptionalAgentRuntimeId(entry.agentHarnessId); + const matchesRequestedRuntime = + params.modelSelectionLocked === true && requestedHarnessId === durableHarnessId; + const matchesDurableRuntime = + entry.sessionId === params.sessionId && durableHarnessId !== undefined; + return matchesRequestedRuntime && matchesDurableRuntime + ? undefined + : reservedKey + ? AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE + : AGENT_HARNESS_SESSION_ID_LOCKED_MESSAGE; +} + /** * Runs model-selection hooks before resolving the runtime model. The dedicated * `before_model_resolve` hook wins over legacy `before_agent_start` overrides @@ -128,6 +183,47 @@ export function buildBeforeModelResolveAttachments( })); } +/** Resolves a pinned non-default harness that owns native model selection. */ +export function resolveNativeModelOwnedHarnessId(params: { + agentHarnessId?: string; + modelSelectionLocked?: boolean; + selectedHarnessId: string; +}): string | undefined { + if (params.modelSelectionLocked !== true) { + return undefined; + } + const requestedHarnessId = normalizeOptionalAgentRuntimeId(params.agentHarnessId); + const selectedHarnessId = normalizeOptionalAgentRuntimeId(params.selectedHarnessId); + if ( + !requestedHarnessId || + isDefaultAgentRuntimeId(requestedHarnessId) || + requestedHarnessId === OPENCLAW_AGENT_RUNTIME_ID || + requestedHarnessId !== selectedHarnessId + ) { + return undefined; + } + return requestedHarnessId; +} + +/** Builds structural model metadata for a harness that resolves its real model natively. */ +export function createNativeModelOwnedRuntimeModel(params: { + provider: string; + modelId: string; +}): ProviderRuntimeModel { + return { + provider: params.provider, + id: params.modelId, + name: params.modelId, + baseUrl: "", + api: "openai-responses", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: DEFAULT_CONTEXT_TOKENS, + maxTokens: DEFAULT_CONTEXT_TOKENS, + }; +} + /** * Resolves context-window policy for the selected runtime model and returns the * model shape the session runtime should see. Configured context caps are @@ -194,3 +290,27 @@ export function resolveEffectiveRuntimeModel(params: { effectiveModel, }; } + +/** Resolves only OpenClaw-owned context policy; native model owners keep that policy private. */ +export function resolveEmbeddedRuntimeModelPolicy(params: { + cfg: OpenClawConfig | undefined; + provider: string; + contextConfigProvider?: string; + modelId: string; + runtimeModel: ProviderRuntimeModel; + nativeModelOwned: boolean; +}): { + contextWindowInfo?: ContextWindowInfo; + contextTokenBudget?: number; + effectiveModel: ProviderRuntimeModel; +} { + if (params.nativeModelOwned) { + return { effectiveModel: params.runtimeModel }; + } + const resolved = resolveEffectiveRuntimeModel(params); + return { + contextWindowInfo: resolved.ctxInfo, + contextTokenBudget: resolved.ctxInfo.tokens, + effectiveModel: resolved.effectiveModel, + }; +} diff --git a/src/agents/embedded-agent-runner/run/types.ts b/src/agents/embedded-agent-runner/run/types.ts index 59855413e578..691967f55355 100644 --- a/src/agents/embedded-agent-runner/run/types.ts +++ b/src/agents/embedded-agent-runner/run/types.ts @@ -171,7 +171,7 @@ export type EmbeddedRunAttemptResult = { codexAppServerFailure?: { kind: "client_closed_before_turn_completed" | "turn_completion_idle_timeout"; turnWatchTimeoutKind?: "progress" | "completion" | "terminal"; - transport: "stdio" | "websocket"; + transport: "stdio" | "unix" | "websocket"; threadId?: string; turnId?: string; replaySafe: boolean; diff --git a/src/agents/harness/compaction.ts b/src/agents/harness/compaction.ts index 8373379107f6..089fd7790654 100644 --- a/src/agents/harness/compaction.ts +++ b/src/agents/harness/compaction.ts @@ -118,7 +118,14 @@ export async function maybeCompactAgentHarnessSession( params: CompactEmbeddedAgentSessionParams, options: InternalAgentHarnessCompactionOptions = {}, ): Promise { - if (params.provider && isCliRuntimeProvider(params.provider, { config: params.config })) { + const selectedRuntime = normalizeOptionalAgentRuntimeId(params.agentHarnessId); + const pinnedHarnessId = + selectedRuntime && !isDefaultAgentRuntimeId(selectedRuntime) ? selectedRuntime : undefined; + if ( + !pinnedHarnessId && + params.provider && + isCliRuntimeProvider(params.provider, { config: params.config }) + ) { return undefined; } const runtimePolicySessionKey = params.sandboxSessionKey ?? params.sessionKey; @@ -133,33 +140,23 @@ export async function maybeCompactAgentHarnessSession( agentId: runtimePolicyAgentId, sessionKey: runtimePolicySessionKey, }).runtime; - if (isCliRuntimeAliasForProvider({ runtime, provider: params.provider, cfg: params.config })) { + if ( + isCliRuntimeAliasForProvider({ + runtime: pinnedHarnessId ?? runtime, + provider: params.provider, + cfg: params.config, + }) + ) { return undefined; } - const selectedRuntime = normalizeOptionalAgentRuntimeId(params.agentHarnessId); - const agentHarnessRuntimeOverride = - selectedRuntime && !isDefaultAgentRuntimeId(selectedRuntime) ? selectedRuntime : undefined; - let harness: AgentHarness; - try { - harness = selectAgentHarness({ - provider: params.provider ?? "", - modelId: params.model, - config: params.config, - agentId: runtimePolicyAgentId, - sessionKey: runtimePolicySessionKey, - agentHarnessRuntimeOverride, - }); - } catch (err) { - if (agentHarnessRuntimeOverride) { - const message = formatErrorMessage(err); - if (message.includes("does not support")) { - // Explicit runtime overrides can name a harness that cannot serve this model. Falling back - // to native compaction preserves existing OpenClaw behavior instead of failing rotation. - return undefined; - } - } - throw err; - } + const harness = selectAgentHarness({ + provider: params.provider ?? "", + modelId: params.model, + config: params.config, + agentId: runtimePolicyAgentId, + sessionKey: runtimePolicySessionKey, + agentHarnessId: pinnedHarnessId, + }); const internalHarness = harness as InternalAgentHarness; const shouldCompactAfterContextEngine = options.nativeCompactionRequest === "after_context_engine"; diff --git a/src/agents/harness/runtime-plugin.test.ts b/src/agents/harness/runtime-plugin.test.ts index a0b0272711d5..11ef4ffd4e80 100644 --- a/src/agents/harness/runtime-plugin.test.ts +++ b/src/agents/harness/runtime-plugin.test.ts @@ -101,6 +101,29 @@ describe("ensureSelectedAgentHarnessPlugin", () => { ); }); + it("loads a session-pinned Codex harness for an unrelated outer provider", async () => { + await ensureSelectedAgentHarnessPlugin({ + provider: "anthropic", + modelId: "claude-opus-4-6", + agentHarnessId: "codex", + workspaceDir: "/tmp/workspace", + }); + + expect(mocks.resolveManifestActivationPlan).toHaveBeenCalledWith({ + trigger: { kind: "agentHarness", runtime: "codex" }, + config: undefined, + workspaceDir: "/tmp/workspace", + requireExplicitManifestOwnerTrust: true, + }); + expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith( + expect.objectContaining({ + scope: "all", + workspaceDir: "/tmp/workspace", + onlyPluginIds: expect.arrayContaining(["codex"]), + }), + ); + }); + it("loads Codex and the provider owner for the implicit official OpenAI runtime before selection", async () => { await ensureSelectedAgentHarnessPlugin({ provider: "openai", diff --git a/src/agents/harness/runtime-plugin.ts b/src/agents/harness/runtime-plugin.ts index 4d0cee23d23c..961c4b542203 100644 --- a/src/agents/harness/runtime-plugin.ts +++ b/src/agents/harness/runtime-plugin.ts @@ -156,9 +156,11 @@ export async function ensureSelectedAgentHarnessPlugin(params: { config?: OpenClawConfig; agentId?: string; sessionKey?: string; + agentHarnessId?: string; agentHarnessRuntimeOverride?: string; workspaceDir: string; }): Promise { + const pinnedHarnessId = normalizeOptionalAgentRuntimeId(params.agentHarnessId); const runtimeOverride = normalizeOptionalAgentRuntimeId(params.agentHarnessRuntimeOverride); const policy = resolveAgentHarnessPolicy({ provider: params.provider, @@ -167,8 +169,11 @@ export async function ensureSelectedAgentHarnessPlugin(params: { agentId: params.agentId, sessionKey: params.sessionKey, }); + const requestedRuntime = pinnedHarnessId ?? runtimeOverride; const runtime = - runtimeOverride && !isDefaultAgentRuntimeId(runtimeOverride) ? runtimeOverride : policy.runtime; + requestedRuntime && !isDefaultAgentRuntimeId(requestedRuntime) + ? requestedRuntime + : policy.runtime; if ( isDefaultAgentRuntimeId(runtime) || runtime === OPENCLAW_AGENT_RUNTIME_ID || diff --git a/src/agents/harness/selection.test.ts b/src/agents/harness/selection.test.ts index 18ba178c20d1..fa4c18cf4797 100644 --- a/src/agents/harness/selection.test.ts +++ b/src/agents/harness/selection.test.ts @@ -392,6 +392,32 @@ describe("runAgentHarnessAttempt", () => { expect(agentRunAttempt).not.toHaveBeenCalled(); }); + it("keeps a session-pinned Codex harness across outer provider overrides", async () => { + registerSuccessfulCodexHarness(); + + const result = await runAgentHarnessAttempt({ + ...createAttemptParams(), + provider: "anthropic", + modelId: "claude-opus-4-6", + agentHarnessId: "codex", + }); + + expect(result.sessionIdUsed).toBe("codex"); + expect(agentRunAttempt).not.toHaveBeenCalled(); + }); + + it("fails closed when a session-pinned Codex harness is unavailable", async () => { + await expect( + runAgentHarnessAttempt({ + ...createAttemptParams(), + provider: "anthropic", + modelId: "claude-opus-4-6", + agentHarnessId: "codex", + }), + ).rejects.toThrow('Requested agent harness "codex" is not registered'); + expect(agentRunAttempt).not.toHaveBeenCalled(); + }); + it.each(["openai", "openai"])( "does not override forced Codex harness support rejection for %s", (provider) => { @@ -711,7 +737,7 @@ describe("selectAgentHarness", () => { expect(unsupportedSupports).toHaveBeenCalledTimes(1); }); - it("ignores session-level OpenClaw pins when selecting a harness", () => { + it("honors session-level OpenClaw pins when selecting a harness", () => { const supports = vi.fn(() => ({ supported: true as const, priority: 100 })); registerAgentHarness({ id: "codex", @@ -726,8 +752,8 @@ describe("selectAgentHarness", () => { agentHarnessId: "openclaw", }); - expect(harness.id).toBe("codex"); - expect(supports).toHaveBeenCalledTimes(1); + expect(harness.id).toBe("openclaw"); + expect(supports).not.toHaveBeenCalled(); }); it("passes manifest provider owners into plugin support checks", () => { @@ -965,7 +991,7 @@ describe("selectAgentHarness", () => { expect(agentRunAttempt).not.toHaveBeenCalled(); }); - it("ignores existing session OpenClaw pins when provider policy forces a plugin harness", () => { + it("keeps an existing session OpenClaw pin when provider policy forces a plugin harness", () => { registerFailingCodexHarness(); expect( @@ -975,7 +1001,7 @@ describe("selectAgentHarness", () => { agentHarnessId: "openclaw", config: providerRuntimeConfig("codex", "codex"), }).id, - ).toBe("codex"); + ).toBe("openclaw"); }); it("ignores env-forced OpenClaw for OpenAI default runtime selection", () => { @@ -1019,8 +1045,22 @@ describe("selectAgentHarness", () => { ).resolves.toBeUndefined(); }); - it("ignores stale plugin pins during compaction when the provider no longer matches", async () => { - registerFailingCodexHarness(); + it("keeps pinned plugin compaction when the outer provider no longer matches", async () => { + const compact = vi.fn>(async () => ({ + ok: true, + compacted: false, + })); + registerAgentHarness( + { + id: "codex", + label: "Codex", + supports: (ctx) => + ctx.provider === "openai" ? { supported: true, priority: 100 } : { supported: false }, + runAttempt: vi.fn(async () => createAttemptResult("codex")), + compact, + }, + { ownerPluginId: "codex" }, + ); await expect( maybeCompactAgentHarnessSession({ @@ -1032,7 +1072,22 @@ describe("selectAgentHarness", () => { model: "llama3.3", agentHarnessId: "codex", }), - ).resolves.toBeUndefined(); + ).resolves.toEqual({ ok: true, compacted: false }); + expect(compact).toHaveBeenCalledOnce(); + }); + + it("fails closed when a pinned compaction harness is unavailable", async () => { + await expect( + maybeCompactAgentHarnessSession({ + sessionId: "session-1", + sessionKey: "agent:main:main", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp/workspace", + provider: "anthropic", + model: "claude-opus-4-6", + agentHarnessId: "codex", + }), + ).rejects.toThrow('Requested agent harness "codex" is not registered'); }); it("honors selected plugin harness pins during compaction preflight", async () => { diff --git a/src/agents/harness/selection.ts b/src/agents/harness/selection.ts index 0cd414ed1933..aeae89140080 100644 --- a/src/agents/harness/selection.ts +++ b/src/agents/harness/selection.ts @@ -167,12 +167,14 @@ function selectAgentHarnessDecision(params: { agentHarnessRuntimeOverride?: string; }): AgentHarnessSelectionDecision { const resolvedPolicy = resolveConfiguredAgentHarnessPolicy(params); + const pinnedHarnessId = normalizeOptionalAgentRuntimeId(params.agentHarnessId); const runtimeOverride = normalizeOptionalAgentRuntimeId(params.agentHarnessRuntimeOverride); + const selectedRuntimeOverride = pinnedHarnessId ?? runtimeOverride; const policy = - runtimeOverride && !isDefaultAgentRuntimeId(runtimeOverride) + selectedRuntimeOverride && !isDefaultAgentRuntimeId(selectedRuntimeOverride) ? ({ ...resolvedPolicy, - runtime: runtimeOverride, + runtime: selectedRuntimeOverride, runtimeSource: "model", } as AgentHarnessPolicy) : resolvedPolicy; @@ -192,6 +194,17 @@ function selectAgentHarnessDecision(params: { if (runtime !== "auto") { const forced = pluginHarnesses.find((entry) => entry.id === runtime); if (forced) { + // A persisted harness owns the existing transcript. Provider/model fields are only + // routing metadata for native sessions and may change with channel or heartbeat config. + // Keep the pinned harness authoritative; if it is unavailable, fail closed below. + if (pinnedHarnessId === runtime) { + return buildSelectionDecision({ + harness: forced, + policy, + selectedReason: "forced_plugin", + candidates: listHarnessCandidates(pluginHarnesses), + }); + } const supportContext = buildAgentHarnessSupportContext({ provider: params.provider, modelId: params.modelId, diff --git a/src/agents/harness/types.ts b/src/agents/harness/types.ts index b44a0af1720e..19f16404df87 100644 --- a/src/agents/harness/types.ts +++ b/src/agents/harness/types.ts @@ -104,6 +104,11 @@ type AgentHarnessRunCapability = { id: string; label: string; pluginId?: string; + /** + * Plugin ids this harness owner permits to execute its locked sessions. + * Delegates receive work admission and execution only; session mutation stays owner-only. + */ + delegatedExecutionPluginIds?: readonly string[]; /** * Context-engine host capabilities provided by this harness during agent * runs. Harnesses that omit this are unsupported for engines that declare diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 0df2f2bdd849..c673b3a4e6e3 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -1303,6 +1303,91 @@ describe("runWithModelFallback", () => { expect(result.attempts).toStrictEqual([]); }); + it("lets a pinned Codex harness bypass unrelated provider auth cooldowns", async () => { + const cfg = makeCfg(); + registerAgentHarness( + { + id: "codex", + label: "Codex", + supports: () => ({ supported: true }), + runAttempt: vi.fn(async () => { + throw new Error("fallback test should not invoke the harness runtime"); + }), + }, + { ownerPluginId: "codex-test" }, + ); + const tempDir = await makeAuthTempDir(); + setAuthRuntimeStore(tempDir, { + version: AUTH_STORE_VERSION, + profiles: { + "anthropic:default": { type: "api_key", provider: "anthropic", key: "test-key" }, + }, + usageStats: { + "anthropic:default": { + disabledUntil: Date.now() + 60_000, + disabledReason: "billing", + failureCounts: { billing: 1 }, + }, + }, + }); + const run = vi.fn().mockResolvedValueOnce("native codex ok"); + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-sonnet-4-6", + agentDir: tempDir, + resolveAgentHarnessRuntimeOverride: () => "codex", + run, + }); + + expect(result.result).toBe("native codex ok"); + expect(run).toHaveBeenCalledOnce(); + expect(result.attempts).toStrictEqual([]); + }); + + it("prefers a prepared harness over a colliding CLI runtime id", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + cliBackends: { codex: { command: "codex" } }, + model: { primary: "anthropic/claude-sonnet-4-6" }, + }, + }, + }); + const prepareAgentHarnessRuntime = vi.fn(() => { + registerAgentHarness( + { + id: "codex", + label: "Codex", + supports: () => ({ supported: true }), + runAttempt: vi.fn(async () => { + throw new Error("fallback test should not invoke the harness runtime"); + }), + }, + { ownerPluginId: "codex-test" }, + ); + }); + const run = vi.fn().mockResolvedValueOnce("native codex ok"); + + const result = await runWithModelFallback({ + cfg, + provider: "codex", + model: "gpt-5.5", + resolveAgentHarnessRuntimeOverride: () => "codex", + prepareAgentHarnessRuntime, + run, + }); + + expect(prepareAgentHarnessRuntime).toHaveBeenCalledWith({ + provider: "codex", + model: "gpt-5.5", + agentHarnessRuntimeOverride: "codex", + }); + expect(result.result).toBe("native codex ok"); + expect(run).toHaveBeenCalledOnce(); + }); + it("lets configured CLI runtimes bypass stale provider auth cooldowns", async () => { const cfg = makeCfg({ agents: { diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index 7fbcfc7d9c9b..9c7767965536 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -577,10 +577,14 @@ async function resolveModelFallbackCandidateHarnessAuthPrecheck( if (!params.cfg) { return result(false); } - if (isCliProvider(params.provider, params.cfg)) { + const agentRuntimeOverride = normalizeOptionalAgentRuntimeId(agentHarnessRuntimeOverride); + const explicitAgentRuntime = + agentRuntimeOverride && !isDefaultAgentRuntimeId(agentRuntimeOverride) + ? agentRuntimeOverride + : undefined; + if (!explicitAgentRuntime && isCliProvider(params.provider, params.cfg)) { return result(true); } - const agentRuntimeOverride = normalizeOptionalAgentRuntimeId(agentHarnessRuntimeOverride); const harnessPolicy = resolveAgentHarnessPolicy({ provider: params.provider, modelId: params.model, @@ -588,19 +592,8 @@ async function resolveModelFallbackCandidateHarnessAuthPrecheck( agentId: params.agentId, sessionKey: params.sessionKey, }); - const agentRuntime = - agentRuntimeOverride && !isDefaultAgentRuntimeId(agentRuntimeOverride) - ? agentRuntimeOverride - : harnessPolicy.runtime; - const agentRuntimeSource = - agentRuntimeOverride && !isDefaultAgentRuntimeId(agentRuntimeOverride) - ? "model" - : harnessPolicy.runtimeSource; - if (isCliAgentRuntime(agentRuntime, params.cfg)) { - // CLI runtimes own their transport/auth, so stale OpenClaw provider - // profile state must not block the candidate before the CLI starts. - return result(true); - } + const agentRuntime = explicitAgentRuntime ?? harnessPolicy.runtime; + const agentRuntimeSource = explicitAgentRuntime ? "model" : harnessPolicy.runtimeSource; if (agentRuntime === "openclaw") { return result(false); } @@ -612,12 +605,17 @@ async function resolveModelFallbackCandidateHarnessAuthPrecheck( model: params.model, agentHarnessRuntimeOverride, }); - if (!getRegisteredAgentHarness(agentRuntime)) { - throw new MissingAgentHarnessError(agentRuntime); + if (getRegisteredAgentHarness(agentRuntime)) { + // A prepared harness owns its transport/auth even when a CLI backend happens + // to reuse the same id. Runtime identity must be resolved before auth preflight. + return result(true); } - // Explicit non-Codex plugin harnesses own transport/auth; stale OpenClaw - // provider cooldowns must not block the harness before it starts. - return result(agentRuntime !== "codex"); + if (isCliAgentRuntime(agentRuntime, params.cfg)) { + // CLI runtimes own their transport/auth, so stale OpenClaw provider + // profile state must not block the candidate before the CLI starts. + return result(true); + } + throw new MissingAgentHarnessError(agentRuntime); } function resolveCandidateAttemptError( diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index dc3955388073..30b8b889c499 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -7,6 +7,7 @@ import { registerInternalHook, type InternalHookEvent, } from "../hooks/internal-hooks.js"; +import { MODEL_SELECTION_LOCKED_MESSAGE } from "../sessions/model-overrides.js"; import { resolvePreferredSessionKeyForSessionIdMatches } from "../sessions/session-id-resolution.js"; import type { TaskRecord } from "../tasks/task-registry.types.js"; import { buildTaskStatusSnapshot } from "../tasks/task-status.js"; @@ -1301,6 +1302,33 @@ describe("session_status tool", () => { }); }); + it("rejects model changes for model-locked sessions", async () => { + const store: Record = { + main: { + sessionId: "s1", + updatedAt: 10, + providerOverride: "openai", + modelOverride: "gpt-5.4", + modelSelectionLocked: true, + }, + }; + resetSessionStore(store); + + const tool = getSessionStatusTool(); + await expect( + tool.execute("call-session-status-model-locked", { + model: "anthropic/claude-sonnet-4-6", + }), + ).rejects.toThrow(MODEL_SELECTION_LOCKED_MESSAGE); + + expect(updateSessionStoreMock).not.toHaveBeenCalled(); + expect(store.main).toMatchObject({ + providerOverride: "openai", + modelOverride: "gpt-5.4", + modelSelectionLocked: true, + }); + }); + it("materializes a valid persisted session entry when the default implicit current fallback mutates model state", async () => { resetSessionStore({}); diff --git a/src/agents/session-runtime-compat.test.ts b/src/agents/session-runtime-compat.test.ts new file mode 100644 index 000000000000..32b41c821638 --- /dev/null +++ b/src/agents/session-runtime-compat.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + resolvePersistedSessionRuntimeId, + resolveSessionRuntimeOverrideForProvider, +} from "./session-runtime-compat.js"; + +describe("resolvePersistedSessionRuntimeId", () => { + it("lets a locked harness outrank a conflicting persisted runtime override", () => { + expect( + resolvePersistedSessionRuntimeId({ + agentHarnessId: "codex", + agentRuntimeOverride: "openclaw", + modelSelectionLocked: true, + }), + ).toBe("codex"); + }); + + it("uses the override when the historical harness is not locked", () => { + expect( + resolvePersistedSessionRuntimeId({ + agentHarnessId: "codex", + agentRuntimeOverride: "openclaw", + modelSelectionLocked: false, + }), + ).toBe("openclaw"); + }); + + it("filters default overrides before falling back to the persisted harness", () => { + expect( + resolvePersistedSessionRuntimeId({ + agentHarnessId: "codex-app-server", + agentRuntimeOverride: "default", + }), + ).toBe("codex"); + }); +}); + +describe("resolveSessionRuntimeOverrideForProvider", () => { + it("keeps a locked harness across a conflicting provider runtime alias", () => { + expect( + resolveSessionRuntimeOverrideForProvider({ + provider: "anthropic", + entry: { + agentHarnessId: "codex", + agentRuntimeOverride: "claude-cli", + modelSelectionLocked: true, + }, + }), + ).toBe("codex"); + }); + + it("does not revive an unlocked historical harness for a future turn", () => { + expect( + resolveSessionRuntimeOverrideForProvider({ + provider: "openai", + entry: { + agentHarnessId: "codex", + modelSelectionLocked: false, + }, + }), + ).toBeUndefined(); + }); +}); diff --git a/src/agents/session-runtime-compat.ts b/src/agents/session-runtime-compat.ts index 92cf9fce7e9b..b3702bbbda5d 100644 --- a/src/agents/session-runtime-compat.ts +++ b/src/agents/session-runtime-compat.ts @@ -10,20 +10,33 @@ import { normalizeOptionalAgentRuntimeId } from "./agent-runtime-id.js"; import { isCliRuntimeAliasForProvider } from "./model-runtime-aliases.js"; /** Persisted runtime fields used to recover session runtime compatibility. */ -type SessionRuntimeCompatEntry = Pick; -type SessionRuntimeOverrideEntry = Pick; +type SessionRuntimeCompatEntry = Pick< + SessionEntry, + "agentHarnessId" | "agentRuntimeOverride" | "modelSelectionLocked" +>; +type SessionRuntimeOverrideEntry = Pick< + SessionEntry, + "agentHarnessId" | "agentRuntimeOverride" | "modelSelectionLocked" +>; -/** Resolves the persisted runtime id, preferring explicit overrides. */ +/** Resolves the persisted runtime id, preserving locked transcript ownership. */ export function resolvePersistedSessionRuntimeId( entry?: SessionRuntimeCompatEntry, ): string | undefined { + const harnessRuntime = normalizeOptionalAgentRuntimeId(entry?.agentHarnessId); + if ( + entry?.modelSelectionLocked === true && + harnessRuntime && + !isDefaultAgentRuntimeId(harnessRuntime) + ) { + return harnessRuntime; + } const runtimeOverride = normalizeOptionalAgentRuntimeId(entry?.agentRuntimeOverride); if (runtimeOverride && !isDefaultAgentRuntimeId(runtimeOverride)) { return runtimeOverride; } - return normalizeOptionalAgentRuntimeId(entry?.agentHarnessId); + return harnessRuntime; } - /** Resolves a runtime id only when it can serve the selected provider. */ export function resolveCompatibleAgentRuntimeForProvider(params: { provider?: string | null; @@ -44,13 +57,23 @@ export function resolveCompatibleAgentRuntimeForProvider(params: { } return isCliRuntimeAliasForProvider({ provider, runtime, cfg: params.cfg }) ? runtime : undefined; } - /** Resolves a persisted runtime override only when it can serve the selected provider. */ export function resolveSessionRuntimeOverrideForProvider(params: { provider?: string | null; entry?: SessionRuntimeOverrideEntry; cfg?: OpenClawConfig; }): string | undefined { + const lockedHarness = normalizeOptionalAgentRuntimeId(params.entry?.agentHarnessId); + if ( + params.entry?.modelSelectionLocked === true && + lockedHarness && + !isDefaultAgentRuntimeId(lockedHarness) + ) { + // A locked transcript stays with its creating harness; provider metadata on + // internal turns must not reinterpret that runtime as a CLI backend. + return lockedHarness; + } + // agentHarnessId records the runtime that produced the existing transcript; // it must not override the runtime selected for the next turn. return resolveCompatibleAgentRuntimeForProvider({ diff --git a/src/agents/subagent-spawn.context.test.ts b/src/agents/subagent-spawn.context.test.ts index 07754f87b78d..e135963fedd6 100644 --- a/src/agents/subagent-spawn.context.test.ts +++ b/src/agents/subagent-spawn.context.test.ts @@ -63,7 +63,18 @@ describe("sessions_spawn context modes", () => { }) => { const parentEntry = params.parentStoreKeys ?.map((key) => store[key]) - .find((entry): entry is Record => Boolean(entry)); + .filter((entry): entry is Record => Boolean(entry)) + .reduce | undefined>((freshest, entry) => { + const entryUpdatedAt = typeof entry.updatedAt === "number" ? entry.updatedAt : 0; + const freshestUpdatedAt = + typeof freshest?.updatedAt === "number" ? freshest.updatedAt : 0; + return !freshest || entryUpdatedAt > freshestUpdatedAt ? entry : freshest; + }, undefined); + if (parentEntry?.modelSelectionLocked === true) { + throw new Error( + "Model-selection-locked sessions cannot create child sessions from parent context.", + ); + } const maxTokens = 100_000; const parentTokens = parentEntry?.totalTokens; if ( @@ -294,6 +305,37 @@ describe("sessions_spawn context modes", () => { expect(prepareContext.parentSessionId).toBe("parent-session-id"); }); + it("rejects fork context when the freshest requester alias is model-locked", async () => { + const store: SessionStore = { + "agent:main:main": { + sessionId: "stale-canonical-parent", + updatedAt: 1, + }, + main: { + sessionId: "fresh-locked-parent", + modelSelectionLocked: true, + updatedAt: 2, + }, + }; + usePersistentStoreMock(store); + + const result = await spawnSubagentDirect( + { task: "inspect the current thread", context: "fork" }, + { agentSessionKey: "main" }, + ); + + expect(result.status).toBe("error"); + expect(result.error).toContain( + "Model-selection-locked sessions cannot create child sessions from parent context.", + ); + expect(forkSessionFromParentMock).not.toHaveBeenCalled(); + expect( + callGatewayMock.mock.calls.some( + ([request]) => (request as GatewayRequest).method === "agent", + ), + ).toBe(false); + }); + it("forks by default for thread-bound subagent sessions", async () => { const store: SessionStore = { main: { diff --git a/src/agents/tools/agents-list-tool.ts b/src/agents/tools/agents-list-tool.ts index 8870602ebf0f..b3cacc51fe40 100644 --- a/src/agents/tools/agents-list-tool.ts +++ b/src/agents/tools/agents-list-tool.ts @@ -28,7 +28,15 @@ type AgentListEntry = { model?: string; agentRuntime?: { id: string; - source: "env" | "agent" | "defaults" | "model" | "provider" | "implicit" | "session-key"; + source: + | "env" + | "agent" + | "defaults" + | "model" + | "provider" + | "implicit" + | "session" + | "session-key"; }; }; diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index ad6625ed9a51..119b00bf8e65 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -99,6 +99,27 @@ describe("resolveSessionRuntimeOverrideForProvider", () => { ).toBe(expected); }); + it("does not treat an observed harness as a future-turn override", () => { + expect( + resolveSessionRuntimeOverrideForProvider({ + provider: "anthropic", + entry: { agentHarnessId: "codex" }, + }), + ).toBeUndefined(); + }); + + it("keeps a locked harness pin ahead of a conflicting runtime override", () => { + expect( + resolveSessionRuntimeOverrideForProvider({ + provider: "anthropic", + entry: { + agentHarnessId: "codex", + agentRuntimeOverride: "claude-cli", + modelSelectionLocked: true, + }, + }), + ).toBe("codex"); + }); it("keeps CLI runtime pins only when the runtime serves the selected provider", () => { cliBackendsTesting.setDepsForTest({ resolveRuntimeCliBackends: () => [], @@ -4083,6 +4104,112 @@ describe("runAgentTurnWithFallback", () => { }); }); + it("keeps catalog-adopted Codex sessions on Codex during heartbeat model overrides", async () => { + state.isCliProviderMock.mockImplementation((provider: unknown) => provider === "claude-cli"); + state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({ + result: await params.run("anthropic", "claude-opus-4-6"), + provider: "anthropic", + model: "claude-opus-4-6", + attempts: [], + })); + state.runEmbeddedAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "heartbeat" }], + meta: {}, + }); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const followupRun = createFollowupRun(); + followupRun.run.provider = "anthropic"; + followupRun.run.model = "claude-opus-4-6"; + followupRun.run.config = { + agents: { + defaults: { + models: { + "anthropic/claude-opus-4-6": { agentRuntime: { id: "claude-cli" } }, + }, + }, + }, + }; + + const result = await runAgentTurnWithFallback({ + ...createMinimalRunAgentTurnParams({ followupRun }), + isHeartbeat: true, + getActiveSessionEntry: () => + ({ + sessionId: "catalog-adopted-session", + updatedAt: Date.now(), + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { + supervision: { + sourceThreadId: "019f-codex-thread", + modelLocked: true, + }, + }, + }, + }) as SessionEntry, + }); + + expect(result.kind).toBe("success"); + expect(state.runCliAgentMock).not.toHaveBeenCalled(); + expectMockCallArgFields(state.runEmbeddedAgentMock, 0, "embedded run params", { + provider: "anthropic", + model: "claude-opus-4-6", + trigger: "heartbeat", + agentHarnessId: "codex", + agentHarnessRuntimeOverride: "codex", + }); + }); + + it("keeps a locked Codex harness embedded when cliBackends.codex is configured", async () => { + state.isCliProviderMock.mockImplementation((provider: unknown) => provider === "codex"); + state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({ + result: await params.run("openai", "gpt-5.4"), + provider: "openai", + model: "gpt-5.4", + attempts: [], + })); + state.runEmbeddedAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "continued" }], + meta: {}, + }); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const followupRun = createFollowupRun(); + followupRun.run.provider = "openai"; + followupRun.run.model = "gpt-5.4"; + followupRun.run.config = { + agents: { + defaults: { + cliBackends: { + codex: { command: "codex" }, + }, + }, + }, + }; + + const result = await runAgentTurnWithFallback({ + ...createMinimalRunAgentTurnParams({ followupRun }), + getActiveSessionEntry: () => + ({ + sessionId: "catalog-adopted-session", + updatedAt: Date.now(), + agentHarnessId: "codex", + modelSelectionLocked: true, + }) as SessionEntry, + }); + + expect(result.kind).toBe("success"); + expect(state.runCliAgentMock).not.toHaveBeenCalled(); + expectMockCallArgFields(state.runEmbeddedAgentMock, 0, "embedded run params", { + provider: "openai", + model: "gpt-5.4", + agentHarnessId: "codex", + agentHarnessRuntimeOverride: "codex", + }); + }); + it("honors agent session runtime overrides before CLI runtime aliases", async () => { state.isCliProviderMock.mockImplementation((provider: unknown) => provider === "claude-cli"); state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({ diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 9e7dd98e57c8..98790a034e12 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -1518,7 +1518,9 @@ async function runAgentTurnWithFallbackInternal( } return effectiveRun; }; - let liveModelSwitchRuntimeEntry: Pick | undefined; + let liveModelSwitchRuntimeEntry: + | Pick + | undefined; const applyLiveModelSwitchToRun = ( run: FollowupRun["run"], err: LiveSessionModelSwitchError, @@ -1978,38 +1980,52 @@ async function runAgentTurnWithFallbackInternal( model, thinkLevel: candidateThinkLevel, }); - const { sessionRuntimeOverride, cliExecutionProvider } = agentTurnTiming.measureSync( - "fallback_resolve_runtime", - () => { + const { sessionRuntimeOverride, cliExecutionProvider, useCliExecution } = + agentTurnTiming.measureSync("fallback_resolve_runtime", () => { + const activeSessionEntry = + liveModelSwitchRuntimeEntry ?? params.getActiveSessionEntry(); const resolvedSessionRuntimeOverride = resolveSessionRuntimeOverrideForProvider({ provider, - entry: liveModelSwitchRuntimeEntry ?? params.getActiveSessionEntry(), + entry: activeSessionEntry, cfg: runtimeConfig, }); + // A locked harness owns the transcript. A configured CLI backend with the + // same id must not steal dispatch from that persisted harness. + const locksPersistedHarness = + activeSessionEntry?.modelSelectionLocked === true && + normalizeLowercaseStringOrEmpty(activeSessionEntry.agentHarnessId) === + resolvedSessionRuntimeOverride; const resolvedSelectedAuthProfile = resolveRunAuthProfile(candidateRun, provider, { config: runtimeConfig, }); - const resolvedCliExecutionProvider = - (resolvedSessionRuntimeOverride && + const pinnedCliRuntime = + !locksPersistedHarness && + resolvedSessionRuntimeOverride && isCliProvider(resolvedSessionRuntimeOverride, runtimeConfig) ? resolvedSessionRuntimeOverride - : undefined) ?? - resolveCliRuntimeExecutionProvider({ - provider, - cfg: runtimeConfig, - agentId: params.followupRun.run.agentId, - modelId: model, - authProfileId: resolvedSelectedAuthProfile.authProfileId, - }) ?? - provider; + : undefined; + const resolvedCliExecutionProvider = + pinnedCliRuntime ?? + (resolvedSessionRuntimeOverride + ? provider + : (resolveCliRuntimeExecutionProvider({ + provider, + cfg: runtimeConfig, + agentId: params.followupRun.run.agentId, + modelId: model, + authProfileId: resolvedSelectedAuthProfile.authProfileId, + }) ?? provider)); return { sessionRuntimeOverride: resolvedSessionRuntimeOverride, cliExecutionProvider: resolvedCliExecutionProvider, + useCliExecution: + pinnedCliRuntime !== undefined || + (!resolvedSessionRuntimeOverride && + isCliProvider(resolvedCliExecutionProvider, runtimeConfig)), }; - }, - ); + }); - if (isCliProvider(cliExecutionProvider, runtimeConfig)) { + if (useCliExecution) { const cliSessionBinding = getCliSessionBinding( params.getActiveSessionEntry(), cliExecutionProvider, diff --git a/src/auto-reply/reply/agent-runner-memory.test.ts b/src/auto-reply/reply/agent-runner-memory.test.ts index 85a02f46e19d..e18606db63bd 100644 --- a/src/auto-reply/reply/agent-runner-memory.test.ts +++ b/src/auto-reply/reply/agent-runner-memory.test.ts @@ -101,6 +101,8 @@ type EmbeddedAgentParams = { provider?: string; model?: string; thinkLevel?: string; + agentHarnessId?: string; + agentHarnessRuntimeOverride?: string; authProfileId?: unknown; authProfileIdSource?: unknown; prompt?: string; @@ -116,6 +118,7 @@ type EmbeddedAgentParams = { type CompactEmbeddedAgentSessionParams = { agentId?: string; + agentHarnessId?: string; authProfileId?: string; contextTokenBudget?: number; sessionKey?: string; @@ -124,6 +127,7 @@ type CompactEmbeddedAgentSessionParams = { cwd?: string; force?: boolean; forcePreflight?: boolean; + modelSelectionLocked?: boolean; preflightRequired?: boolean; preflightCompactionTrigger?: string; sessionFile?: string; @@ -385,6 +389,65 @@ describe("runMemoryFlushIfNeeded", () => { expect(followupRun.run.thinkLevel).toBe("ultra"); }); + it("keeps catalog-adopted sessions on Codex for memory flush turns", async () => { + const sessionEntry: SessionEntry = { + sessionId: "catalog-adopted-session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + agentHarnessId: "codex", + agentRuntimeOverride: "claude-cli", + modelSelectionLocked: true, + pluginExtensions: { + codex: { + supervision: { + sourceThreadId: "019f-codex-thread", + modelLocked: true, + }, + }, + }, + }; + + const result = await runMemoryFlushIfNeeded({ + cfg: { + agents: { + defaults: { + cliBackends: { + "claude-cli": { command: "claude" }, + }, + compaction: { memoryFlush: {} }, + models: { + "anthropic/claude-opus-4-6": { agentRuntime: { id: "claude-cli" } }, + }, + }, + }, + }, + followupRun: createTestFollowupRun({ + provider: "anthropic", + model: "claude-opus-4-6", + sessionId: sessionEntry.sessionId, + sessionKey: "main", + }), + sessionCtx: { Provider: "whatsapp" } as unknown as TemplateContext, + defaultModel: "anthropic/claude-opus-4-6", + agentCfgContextTokens: 100_000, + resolvedVerboseLevel: "off", + sessionEntry, + sessionStore: { main: sessionEntry }, + sessionKey: "main", + isHeartbeat: false, + replyOperation: createReplyOperation(), + }); + + expect(result.outcome).toBe("completed"); + expect(requireEmbeddedAgentCall()).toMatchObject({ + provider: "anthropic", + model: "claude-opus-4-6", + agentHarnessId: "codex", + agentHarnessRuntimeOverride: "codex", + }); + }); + it("counts resolved error payloads as failed memory flushes", async () => { const storePath = path.join(rootDir, "sessions.json"); const sessionEntry: SessionEntry = { @@ -969,6 +1032,7 @@ describe("runMemoryFlushIfNeeded", () => { modelId: "gpt-5.4", agentId: "main", sessionKey: runtimePolicySessionKey, + agentHarnessId: "codex", agentHarnessRuntimeOverride: "codex", workspaceDir: "/workspace", }); @@ -1140,6 +1204,8 @@ describe("runMemoryFlushIfNeeded", () => { updatedAt: Date.now(), totalTokens: 120, totalTokensFresh: true, + agentHarnessId: "openclaw", + modelSelectionLocked: true, }; const onCompactionNotice = vi.fn(); @@ -1171,6 +1237,8 @@ describe("runMemoryFlushIfNeeded", () => { preflightCompactionTrigger: "tokens", deferOwningContextEngineCompaction: false, contextTokenBudget: 100, + agentHarnessId: "openclaw", + modelSelectionLocked: true, }); expect(incrementCompactionCountMock).not.toHaveBeenCalled(); expect(onCompactionNotice).toHaveBeenNthCalledWith(1, "start"); diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 000e19858859..b93229eb3714 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -17,7 +17,10 @@ import { isCliProvider } from "../../agents/model-selection.js"; import { resolveContextConfigProviderForRuntime } from "../../agents/openai-routing.js"; import type { AgentMessage } from "../../agents/runtime/index.js"; import { resolveSandboxConfigForAgent, resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; -import { resolveSessionRuntimeOverrideForProvider } from "../../agents/session-runtime-compat.js"; +import { + resolvePersistedSessionRuntimeId, + resolveSessionRuntimeOverrideForProvider, +} from "../../agents/session-runtime-compat.js"; import { resolveCandidateThinkingLevel, resolveEffectiveAgentRuntime, @@ -253,7 +256,10 @@ function resolveMemoryFlushModelFallbackOptions( function followupUsesCliRuntime(params: { cfg: OpenClawConfig; followupRun: FollowupRun; - sessionEntry?: Pick; + sessionEntry?: Pick< + SessionEntry, + "agentHarnessId" | "agentRuntimeOverride" | "modelSelectionLocked" + >; }): boolean { const provider = params.followupRun.run.provider; if (isCliProvider(provider, params.cfg)) { @@ -261,7 +267,7 @@ function followupUsesCliRuntime(params: { } return isCliRuntimeAliasForProvider({ provider, - runtime: params.sessionEntry?.agentRuntimeOverride, + runtime: resolvePersistedSessionRuntimeId(params.sessionEntry), cfg: params.cfg, }); } @@ -953,7 +959,12 @@ export async function runPreflightCompactionIfNeeded(params: { model: params.followupRun.run.model, authProfileId: params.followupRun.run.authProfileId, agentHarnessId: - entry.sessionId === params.followupRun.run.sessionId ? entry.agentHarnessId : undefined, + entry.sessionId === params.followupRun.run.sessionId + ? entry.modelSelectionLocked === true + ? resolvePersistedSessionRuntimeId(entry) + : entry.agentHarnessId + : undefined, + modelSelectionLocked: entry.modelSelectionLocked === true, thinkLevel: params.followupRun.run.thinkLevel, bashElevated: params.followupRun.run.bashElevated, trigger: "budget", @@ -1328,11 +1339,17 @@ export async function runMemoryFlushIfNeeded(params: { params.runtimePolicySessionKey ?? params.followupRun.run.runtimePolicySessionKey ?? params.sessionKey, + agentHarnessId: agentHarnessRuntimeOverride, agentHarnessRuntimeOverride, workspaceDir: params.followupRun.run.workspaceDir, }); }, run: async (provider, model, runOptions) => { + const sessionRuntimeOverride = resolveSessionRuntimeOverrideForProvider({ + provider, + entry: activeSessionEntry, + cfg: params.cfg, + }); const candidateThinkLevel = resolveCandidateThinkingLevel({ cfg: params.cfg, provider, @@ -1344,6 +1361,7 @@ export async function runMemoryFlushIfNeeded(params: { params.followupRun.run.runtimePolicySessionKey ?? params.sessionKey, sessionEntry: activeSessionEntry, + agentRuntime: sessionRuntimeOverride, }); const { embeddedContext, senderContext, runBaseParams } = buildEmbeddedRunExecutionParams({ run: { ...params.followupRun.run, thinkLevel: candidateThinkLevel }, @@ -1359,6 +1377,8 @@ export async function runMemoryFlushIfNeeded(params: { ...embeddedContext, ...senderContext, ...runBaseParams, + agentHarnessId: sessionRuntimeOverride, + agentHarnessRuntimeOverride: sessionRuntimeOverride, sandboxSessionKey: params.runtimePolicySessionKey, allowGatewaySubagentBinding: true, silentExpected: true, diff --git a/src/auto-reply/reply/agent-runner-run-params.ts b/src/auto-reply/reply/agent-runner-run-params.ts index 0a19dfdf546f..fbb6a67d71df 100644 --- a/src/auto-reply/reply/agent-runner-run-params.ts +++ b/src/auto-reply/reply/agent-runner-run-params.ts @@ -19,14 +19,16 @@ export function resolveModelFallbackOptions( configOverride: FollowupRun["run"]["config"] = run.config, ) { const config = configOverride; - const fallbacksOverride = resolveEffectiveModelFallbacks({ - cfg: config, - agentId: run.agentId, - sessionKey: run.sessionKey, - hasSessionModelOverride: run.hasSessionModelOverride === true, - modelOverrideSource: run.modelOverrideSource, - hasAutoFallbackProvenance: run.hasAutoFallbackProvenance === true, - }); + const fallbacksOverride = run.modelSelectionLocked + ? [] + : resolveEffectiveModelFallbacks({ + cfg: config, + agentId: run.agentId, + sessionKey: run.sessionKey, + hasSessionModelOverride: run.hasSessionModelOverride === true, + modelOverrideSource: run.modelOverrideSource, + hasAutoFallbackProvenance: run.hasAutoFallbackProvenance === true, + }); return { cfg: config, provider: run.provider, @@ -69,14 +71,16 @@ export function buildEmbeddedRunBaseParams(params: { isReasoningTagProvider?: ReasoningTagProviderResolver; }) { const config = params.run.config; - const modelFallbacksOverride = resolveEffectiveModelFallbacks({ - cfg: config, - agentId: params.run.agentId, - sessionKey: params.run.sessionKey, - hasSessionModelOverride: params.run.hasSessionModelOverride === true, - modelOverrideSource: params.run.modelOverrideSource, - hasAutoFallbackProvenance: params.run.hasAutoFallbackProvenance === true, - }); + const modelFallbacksOverride = params.run.modelSelectionLocked + ? [] + : resolveEffectiveModelFallbacks({ + cfg: config, + agentId: params.run.agentId, + sessionKey: params.run.sessionKey, + hasSessionModelOverride: params.run.hasSessionModelOverride === true, + modelOverrideSource: params.run.modelOverrideSource, + hasAutoFallbackProvenance: params.run.hasAutoFallbackProvenance === true, + }); const enforceFinalTag = resolveEnforceFinalTagWithResolver( params.run, params.provider, @@ -105,6 +109,7 @@ export function buildEmbeddedRunBaseParams(params: { taskSuggestionDeliveryMode: params.run.taskSuggestionDeliveryMode, provider: params.provider, model: params.model, + modelSelectionLocked: params.run.modelSelectionLocked, modelFallbacksOverride, ...params.authProfile, thinkLevel: params.run.thinkLevel, diff --git a/src/auto-reply/reply/agent-runner-session-reset.test.ts b/src/auto-reply/reply/agent-runner-session-reset.test.ts index f67b80050ba6..740d28e76b76 100644 --- a/src/auto-reply/reply/agent-runner-session-reset.test.ts +++ b/src/auto-reply/reply/agent-runner-session-reset.test.ts @@ -154,6 +154,41 @@ describe("resetReplyRunSession", () => { expect(persisted.main.memoryFlushLastFailureError).toBeUndefined(); }); + it("rejects automatic recovery rotation for a model-locked session", async () => { + const storePath = path.join(rootDir, "sessions.json"); + const sessionEntry: SessionEntry = { + sessionId: "locked-session", + updatedAt: 1, + sessionFile: path.join(rootDir, "locked-session.jsonl"), + agentHarnessId: "codex", + modelSelectionLocked: true, + }; + const sessionStore = { main: sessionEntry }; + const followupRun = createTestFollowupRun(); + await writeTestSessionStore(storePath, "main", sessionEntry); + + await expect( + resetReplyRunSession({ + options: { + failureLabel: "memory flush exhaustion", + buildLogMessage: (next) => `reset ${next}`, + }, + sessionKey: "main", + queueKey: "main", + activeSessionEntry: sessionEntry, + activeSessionStore: sessionStore, + storePath, + followupRun, + onActiveSessionEntry: vi.fn(), + onNewSession: vi.fn(), + }), + ).rejects.toThrow("cannot be reset while model selection is locked"); + + expect(sessionStore.main).toEqual(sessionEntry); + expect(followupRun.run.sessionId).not.toBe("00000000-0000-0000-0000-000000000123"); + expect(refreshQueuedFollowupSessionMock).not.toHaveBeenCalled(); + }); + it("cleans up the old transcript when requested", async () => { const storePath = path.join(rootDir, "sessions.json"); const oldTranscriptPath = path.join(rootDir, "old-session.jsonl"); diff --git a/src/auto-reply/reply/agent-runner-session-reset.ts b/src/auto-reply/reply/agent-runner-session-reset.ts index 423e6653bba7..9aeab48c78c2 100644 --- a/src/auto-reply/reply/agent-runner-session-reset.ts +++ b/src/auto-reply/reply/agent-runner-session-reset.ts @@ -7,6 +7,11 @@ import { import { persistSessionResetLifecycle } from "../../config/sessions/session-accessor.js"; import { generateSecureUuid } from "../../infra/secure-random.js"; import { defaultRuntime } from "../../runtime.js"; +import { + isModelSelectionLocked, + ModelSelectionLockedError, + MODEL_SELECTION_LOCKED_RESET_MESSAGE, +} from "../../sessions/model-overrides.js"; import { refreshQueuedFollowupSession, type FollowupRun } from "./queue.js"; type ResetSessionOptions = { @@ -51,6 +56,9 @@ export async function resetReplyRunSession(params: { if (!prevEntry) { return false; } + if (isModelSelectionLocked(prevEntry)) { + throw new ModelSelectionLockedError(MODEL_SELECTION_LOCKED_RESET_MESSAGE); + } const prevSessionId = params.options.cleanupTranscripts ? prevEntry.sessionId : undefined; const nextSessionId = deps.generateSecureUuid(); const now = Date.now(); diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index d11e0a4c0018..2c18c80ee13c 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -107,6 +107,15 @@ describe("agent-runner-utils", () => { expect(resolved.fallbacksOverride).toEqual(["fallback-model"]); }); + it("disables model fallback options for a model-locked run", () => { + const run = makeRun({ modelSelectionLocked: true }); + + const resolved = resolveModelFallbackOptions(run); + + expect(hoisted.resolveEffectiveModelFallbacksMock).not.toHaveBeenCalled(); + expect(resolved.fallbacksOverride).toEqual([]); + }); + it("passes through missing agentId for helper-based fallback resolution", () => { hoisted.resolveEffectiveModelFallbacksMock.mockReturnValue(["fallback-model"]); const run = makeRun({ agentId: undefined }); @@ -232,6 +241,26 @@ describe("agent-runner-utils", () => { expect(resolved.modelFallbacksOverride).toEqual(["fallback-model"]); }); + it("disables embedded model fallbacks for a model-locked run", () => { + const run = makeRun({ modelSelectionLocked: true }); + const authProfile = resolveProviderScopedAuthProfile({ + provider: "openai", + primaryProvider: "openai", + }); + + const resolved = buildEmbeddedRunBaseParams({ + run, + provider: "openai", + model: "gpt-4.1-mini", + runId: "run-1", + authProfile, + }); + + expect(hoisted.resolveEffectiveModelFallbacksMock).not.toHaveBeenCalled(); + expect(resolved.modelFallbacksOverride).toEqual([]); + expect(resolved.modelSelectionLocked).toBe(true); + }); + it("does not force final-tag enforcement for minimax providers", () => { const run = makeRun({ enforceFinalTag: false }); const authProfile = resolveProviderScopedAuthProfile({ diff --git a/src/auto-reply/reply/commands-compact.test.ts b/src/auto-reply/reply/commands-compact.test.ts index a564520eb27e..32b6a88bece7 100644 --- a/src/auto-reply/reply/commands-compact.test.ts +++ b/src/auto-reply/reply/commands-compact.test.ts @@ -457,6 +457,37 @@ describe("handleCompactCommand", () => { expect(call.skillsSnapshot).toEqual({ prompt: "target", skills: [] }); }); + it("carries a model-locked session's persisted native runtime into compaction", async () => { + vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ + ok: false, + compacted: false, + reason: "no codex app-server thread binding", + }); + + await handleCompactCommand( + { + ...buildCompactParams("/compact", { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig), + sessionEntry: { + sessionId: "locked-session", + updatedAt: Date.now(), + agentHarnessId: "codex", + agentRuntimeOverride: "openclaw", + modelSelectionLocked: true, + }, + } as HandleCommandsParams, + true, + ); + + expect(requireCompactEmbeddedAgentSessionCall()).toMatchObject({ + sessionId: "locked-session", + agentHarnessId: "codex", + modelSelectionLocked: true, + }); + }); + it("prefers the target session entry when incrementing compaction count", async () => { vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ ok: true, diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts index faf381e1ec57..a89cee804c7f 100644 --- a/src/auto-reply/reply/commands-compact.ts +++ b/src/auto-reply/reply/commands-compact.ts @@ -13,6 +13,7 @@ import { OPENAI_PROVIDER_ID, resolveContextConfigProviderForRuntime, } from "../../agents/openai-routing.js"; +import { resolvePersistedSessionRuntimeId } from "../../agents/session-runtime-compat.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { logVerbose } from "../../globals.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; @@ -273,7 +274,10 @@ export const handleCompactCommand: CommandHandler = async (params) => { authProfileId: targetSessionEntry.authProfileOverride, contextTokenBudget, agentHarnessId: - targetSessionEntry.sessionId === sessionId ? targetSessionEntry.agentHarnessId : undefined, + targetSessionEntry.modelSelectionLocked === true + ? resolvePersistedSessionRuntimeId(targetSessionEntry) + : targetSessionEntry.agentHarnessId, + modelSelectionLocked: targetSessionEntry.modelSelectionLocked === true, thinkLevel: params.resolvedThinkLevel ?? (await params.resolveDefaultThinkingLevel()), bashElevated: { enabled: false, diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index c48cef90d2cb..0892aaf04b9c 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -19,7 +19,11 @@ import { import { triggerSessionPatchHook } from "../../gateway/session-patch-hooks.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { applyTraceOverride, applyVerboseOverride } from "../../sessions/level-overrides.js"; -import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; +import { + applyModelOverrideToSessionEntry, + isModelSelectionLocked, + MODEL_SELECTION_LOCKED_MESSAGE, +} from "../../sessions/model-overrides.js"; import { formatThinkingLevels, isThinkingLevelSupported, @@ -151,6 +155,9 @@ export async function handleDirectiveOnly( } const modelSelection = modelResolution.modelSelection; const profileOverride = modelResolution.profileOverride; + if (modelSelection && isModelSelectionLocked(sessionEntry)) { + return { text: MODEL_SELECTION_LOCKED_MESSAGE }; + } const resolvedProvider = modelSelection?.provider ?? provider; const resolvedModel = modelSelection?.model ?? model; diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index d2728eef55e4..018fc098e7f9 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ModelCatalogEntry } from "../../agents/model-catalog.js"; +import { MODEL_SELECTION_LOCKED_MESSAGE } from "../../sessions/model-overrides.js"; vi.hoisted(() => { vi.resetModules(); @@ -1711,6 +1712,27 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => { expect(sessionEntry.agentRuntimeOverride).toBe("codex"); }); + it("rejects model and runtime changes for model-locked sessions", async () => { + const sessionEntry = createSessionEntry({ + providerOverride: "anthropic", + modelOverride: "claude-opus-4-6", + agentHarnessId: "codex", + agentRuntimeOverride: "codex", + modelSelectionLocked: true, + }); + const initialSessionEntry = { ...sessionEntry }; + + const result = await handleDirectiveOnly( + createHandleParams({ + directives: parseInlineDirectives("/model openai/gpt-4o --runtime openclaw"), + sessionEntry, + }), + ); + + expect(result?.text).toBe(MODEL_SELECTION_LOCKED_MESSAGE); + expect(sessionEntry).toEqual(initialSessionEntry); + }); + it("persists /model only on the targeted session entry", async () => { const targetEntry = createSessionEntry(); const otherEntry = createSessionEntry(); diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 489342a0fdc2..21f68184b4f3 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -12374,6 +12374,57 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); }); + it("keeps locked supervised Codex delivery defaults across outer model overrides", async () => { + setNoAbort(); + registerAgentHarness({ + id: "codex", + label: "Codex", + deliveryDefaults: { sourceVisibleReplies: "message_tool" }, + supports: (ctx) => + ctx.provider === "codex" + ? { supported: true, priority: 100 } + : { supported: false, reason: "codex provider only" }, + runAttempt: vi.fn(async () => ({}) as never), + }); + sessionStoreMocks.currentEntry = { + sessionId: "catalog-adopted-session", + updatedAt: 0, + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { + supervision: { + sourceThreadId: "019f-codex-thread", + modelLocked: true, + }, + }, + }, + providerOverride: "anthropic", + modelOverride: "claude-sonnet-4.6", + sendPolicy: "allow", + }; + const dispatcher = createDispatcher(); + const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => { + expect(opts?.sourceReplyDeliveryMode).toBe("message_tool_only"); + return { text: "private supervised reply" } satisfies ReplyPayload; + }); + + const result = await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + ChatType: "direct", + CommandSource: undefined, + SessionKey: "agent:main:main", + }), + cfg: emptyConfig, + dispatcher, + replyResolver, + }); + + expect(result.queuedFinal).toBe(false); + expect(result.sourceReplyDeliveryMode).toBe("message_tool_only"); + expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); + }); + it("uses Codex direct source delivery defaults before a session entry exists", async () => { setNoAbort(); registerAgentHarness({ diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 4502253e55f5..3f14527a240b 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -722,6 +722,8 @@ const resolveHarnessSourceVisibleRepliesDefault = (params: { config: params.cfg, agentId: params.sessionAgentId, sessionKey: params.sessionKey, + agentHarnessId: + params.entry?.modelSelectionLocked === true ? params.entry.agentHarnessId : undefined, agentHarnessRuntimeOverride, }); return harness.deliveryDefaults?.sourceVisibleReplies; diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 35e32f9b13c5..a3454581befe 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -741,6 +741,111 @@ describe("createFollowupRunner reply-lane admission", () => { expect(call.clientCaps).toEqual(["tool-events", "inline-widgets"]); }); + it("adopts a matching admission-time model lock for queued execution", async () => { + const storePath = "/tmp/openclaw-followup-admission-model-lock.json"; + const queuedEntry: SessionEntry = { + sessionId: "catalog-adopted-session", + updatedAt: 1, + }; + const admittedEntry: SessionEntry = { + ...queuedEntry, + updatedAt: 2, + agentHarnessId: "codex", + modelSelectionLocked: true, + }; + registerFollowupTestSessionStore(storePath, { main: admittedEntry }); + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {} }); + const runtimeConfig: OpenClawConfig = { + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-5.4-mini"], + }, + }, + }, + }; + const runner = createFollowupRunner({ + typing: createMockTypingController(), + typingMode: "instant", + sessionEntry: queuedEntry, + sessionStore: { main: queuedEntry }, + sessionKey: "main", + storePath, + defaultModel: "anthropic/claude", + }); + + await runner( + createQueuedRun({ + run: { + config: runtimeConfig, + sessionId: queuedEntry.sessionId, + sessionKey: "main", + provider: "anthropic", + model: "claude", + }, + }), + ); + + const preflightCall = requireLastMockCallArg( + runPreflightCompactionIfNeededMock, + "preflight compaction", + ); + const preflightRun = requireRecord(preflightCall.followupRun, "preflight follow-up run"); + expect(requireRecord(preflightRun.run, "preflight run").modelSelectionLocked).toBe(true); + const fallbackCall = requireLastMockCallArg(runWithModelFallbackMock, "model fallback"); + expect(fallbackCall.fallbacksOverride).toEqual([]); + expect(requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent")).toMatchObject({ + modelSelectionLocked: true, + agentHarnessId: "codex", + agentHarnessRuntimeOverride: "codex", + }); + }); + + it("keeps the queued model lock when the admission entry belongs to another session", async () => { + const replacementEntry: SessionEntry = { + sessionId: "replacement-session", + updatedAt: 2, + }; + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {} }); + const runtimeConfig: OpenClawConfig = { + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-5.4-mini"], + }, + }, + }, + }; + const runner = createFollowupRunner({ + typing: createMockTypingController(), + typingMode: "instant", + sessionEntry: replacementEntry, + sessionStore: { main: replacementEntry }, + sessionKey: "main", + defaultModel: "anthropic/claude", + }); + + await runner( + createQueuedRun({ + run: { + config: runtimeConfig, + sessionId: "queued-session", + sessionKey: "main", + provider: "anthropic", + model: "claude", + modelSelectionLocked: true, + }, + }), + ); + + const fallbackCall = requireLastMockCallArg(runWithModelFallbackMock, "model fallback"); + expect(fallbackCall.fallbacksOverride).toEqual([]); + expect(requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent")).toMatchObject({ + sessionId: "queued-session", + modelSelectionLocked: true, + }); + }); + it("awaits queued-owner admission before model execution", async () => { const events: string[] = []; let releaseAdmission!: () => void; @@ -1321,6 +1426,67 @@ describe("createFollowupRunner auto fallback primary probes", () => { }); describe("createFollowupRunner runtime config", () => { + it("keeps a locked Codex harness pinned when a CLI backend shares its id", async () => { + const runtimeConfig: OpenClawConfig = { + agents: { + defaults: { + cliBackends: { + codex: { command: "codex" }, + "claude-cli": { command: "claude" }, + }, + models: { + "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, + }, + }, + }, + }; + const sessionEntry: SessionEntry = { + sessionId: "catalog-adopted-session", + updatedAt: Date.now(), + agentHarnessId: "codex", + agentRuntimeOverride: "claude-cli", + modelSelectionLocked: true, + pluginExtensions: { + codex: { + supervision: { + sourceThreadId: "019f-codex-thread", + modelLocked: true, + }, + }, + }, + }; + runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {} }); + + const runner = createFollowupRunner({ + typing: createMockTypingController(), + typingMode: "instant", + sessionEntry, + sessionStore: { main: sessionEntry }, + sessionKey: "main", + defaultModel: "anthropic/claude-opus-4-7", + }); + + await runner( + createQueuedRun({ + run: { + config: runtimeConfig, + sessionId: sessionEntry.sessionId, + sessionKey: "main", + provider: "anthropic", + model: "claude-opus-4-7", + }, + }), + ); + + expect(runCliAgentMock).not.toHaveBeenCalled(); + expect(requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent")).toMatchObject({ + provider: "anthropic", + model: "claude-opus-4-7", + agentHarnessId: "codex", + agentHarnessRuntimeOverride: "codex", + }); + }); + it("routes queued followups through CLI runtime dispatch when the model selects a CLI backend", async () => { const runtimeConfig: OpenClawConfig = { agents: { diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 333fca34724a..117a34c6c434 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import { readStringValue } from "@openclaw/normalization-core/string-coerce"; import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; +import { normalizeOptionalAgentRuntimeId } from "../../agents/agent-runtime-id.js"; import { clearAutoFallbackPrimaryProbeSelection, entryMatchesAutoFallbackPrimaryProbe, @@ -736,10 +737,16 @@ export function createFollowupRunner(params: { : undefined; if (admittedSessionEntry?.sessionId === replyOperation.sessionId) { activeSessionEntry = admittedSessionEntry; - if (admittedSessionEntry.sessionFile) { - run = { ...run, sessionFile: admittedSessionEntry.sessionFile }; - effectiveQueued = { ...effectiveQueued, run }; - } + // Admission is the authority for policy on this exact session generation. A queued + // snapshot may predate catalog adoption, but a replacement session must not inherit it. + run = { + ...run, + ...(admittedSessionEntry.sessionFile + ? { sessionFile: admittedSessionEntry.sessionFile } + : {}), + modelSelectionLocked: admittedSessionEntry.modelSelectionLocked === true, + }; + effectiveQueued = { ...effectiveQueued, run }; } const sendPolicyDenied = resolveSendPolicy({ @@ -1030,6 +1037,7 @@ export function createFollowupRunner(params: { modelId: model, agentId: run.agentId, sessionKey: run.runtimePolicySessionKey ?? replySessionKey, + agentHarnessId: agentHarnessRuntimeOverride, agentHarnessRuntimeOverride, workspaceDir: run.workspaceDir, }); @@ -1077,18 +1085,32 @@ export function createFollowupRunner(params: { entry: activeSessionEntry, cfg: runtimeConfig, }); - const cliExecutionProvider = - (sessionRuntimeOverride && isCliProvider(sessionRuntimeOverride, runtimeConfig) + // A locked harness owns the transcript. A configured CLI backend with the + // same id must not steal dispatch from that persisted harness. + const locksPersistedHarness = + activeSessionEntry?.modelSelectionLocked === true && + normalizeOptionalAgentRuntimeId(activeSessionEntry.agentHarnessId) === + sessionRuntimeOverride; + const pinnedCliRuntime = + !locksPersistedHarness && + sessionRuntimeOverride && + isCliProvider(sessionRuntimeOverride, runtimeConfig) ? sessionRuntimeOverride - : undefined) ?? - resolveCliRuntimeExecutionProvider({ - provider, - cfg: runtimeConfig, - agentId: run.agentId, - modelId: model, - authProfileId: selectedAuthProfile.authProfileId, - }) ?? - provider; + : undefined; + const cliExecutionProvider = + pinnedCliRuntime ?? + (sessionRuntimeOverride + ? provider + : (resolveCliRuntimeExecutionProvider({ + provider, + cfg: runtimeConfig, + agentId: run.agentId, + modelId: model, + authProfileId: selectedAuthProfile.authProfileId, + }) ?? provider)); + const useCliExecution = + pinnedCliRuntime !== undefined || + (!sessionRuntimeOverride && isCliProvider(cliExecutionProvider, runtimeConfig)); let attemptCompactionCount = 0; const userTurnTranscriptRecorder = effectiveQueued.userTurnTranscriptRecorder ?? opts?.userTurnTranscriptRecorder; @@ -1124,7 +1146,7 @@ export function createFollowupRunner(params: { } }); try { - if (isCliProvider(cliExecutionProvider, runtimeConfig)) { + if (useCliExecution) { const cliSessionBinding = getCliSessionBinding( activeSessionEntry, cliExecutionProvider, @@ -1412,6 +1434,9 @@ export function createFollowupRunner(params: { allowEmptyAssistantReplyAsSilent: run.allowEmptyAssistantReplyAsSilent, provider, model, + modelSelectionLocked: run.modelSelectionLocked, + agentHarnessId: sessionRuntimeOverride, + agentHarnessRuntimeOverride: sessionRuntimeOverride, ...selectedAuthProfile, thinkLevel: candidateThinkLevel, fastMode: candidateFastMode.fastMode, diff --git a/src/auto-reply/reply/get-reply-directives-apply.test.ts b/src/auto-reply/reply/get-reply-directives-apply.test.ts index 986d1e8ed312..21571ae3d6f1 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.test.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.test.ts @@ -1,5 +1,6 @@ // Tests applying parsed directives to get-reply execution options. import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MODEL_SELECTION_LOCKED_MESSAGE } from "../../sessions/model-overrides.js"; import { parseInlineDirectives } from "./directive-handling.parse.js"; import { applyInlineDirectiveOverrides, @@ -60,6 +61,95 @@ describe("formatModelOverrideResetEvent", () => { }); describe("applyInlineDirectiveOverrides", () => { + it("rejects a mixed inline model and runtime change when model selection is locked", async () => { + const directives = parseInlineDirectives("hello /model openai/gpt-5.4 --runtime openclaw"); + const typing = { + onReplyStart: async () => {}, + startTypingLoop: async () => {}, + startTypingOnText: async () => {}, + refreshTypingTtl: () => {}, + isActive: () => false, + markRunComplete: () => {}, + markDispatchIdle: () => {}, + cleanup: vi.fn(), + }; + const sessionEntry = { + sessionId: "session-1", + updatedAt: 1, + providerOverride: "openai", + modelOverride: "gpt-5.5", + agentHarnessId: "codex", + agentRuntimeOverride: "codex", + modelSelectionLocked: true, + }; + + const result = await applyInlineDirectiveOverrides({ + ctx: buildTestCtx({ + Body: "hello /model openai/gpt-5.4 --runtime openclaw", + CommandAuthorized: true, + }), + cfg: {}, + agentId: "main", + agentDir: "/tmp/agent", + workspaceDir: "/tmp/workspace", + agentCfg: {}, + sessionEntry, + sessionStore: { "agent:main:main": sessionEntry }, + sessionKey: "agent:main:main", + sessionScope: undefined, + isGroup: false, + allowTextCommands: true, + command: { + surface: "webchat", + channel: "webchat", + ownerList: [], + senderIsOwner: true, + isAuthorizedSender: true, + rawBodyNormalized: "hello /model openai/gpt-5.4 --runtime openclaw", + commandBodyNormalized: "hello /model openai/gpt-5.4 --runtime openclaw", + }, + directives, + messageProviderKey: "webchat", + elevatedEnabled: true, + elevatedAllowed: true, + elevatedFailures: [], + defaultProvider: "openai", + defaultModel: "gpt-5.5", + aliasIndex: { byAlias: new Map(), byKey: new Map() }, + provider: "openai", + model: "gpt-5.5", + modelState: createFastTestModelSelectionState({ + agentCfg: {}, + provider: "openai", + model: "gpt-5.5", + }), + initialModelLabel: "openai/gpt-5.5", + formatModelSwitchEvent: (label) => label, + resolvedElevatedLevel: "off", + defaultActivation: () => "always", + contextTokens: 8192, + effectiveModelDirective: directives.rawModelDirective, + typing, + }); + + expect(result).toEqual({ + kind: "reply", + reply: { text: MODEL_SELECTION_LOCKED_MESSAGE }, + }); + expect(typing.cleanup).toHaveBeenCalledOnce(); + expect(mocks.fastLane).not.toHaveBeenCalled(); + expect(mocks.persist).not.toHaveBeenCalled(); + expect(sessionEntry).toEqual({ + sessionId: "session-1", + updatedAt: 1, + providerOverride: "openai", + modelOverride: "gpt-5.5", + agentHarnessId: "codex", + agentRuntimeOverride: "codex", + modelSelectionLocked: true, + }); + }); + it("stops a mixed inline turn when final directive persistence loses", async () => { const directives = parseInlineDirectives("hello /elevated full"); mocks.fastLane.mockResolvedValue({ diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts index 6f7fe298ac24..5102bc567693 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.ts @@ -2,6 +2,10 @@ import type { SessionEntry, SessionScope } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; +import { + isModelSelectionLocked, + MODEL_SELECTION_LOCKED_MESSAGE, +} from "../../sessions/model-overrides.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import type { MsgContext } from "../templating.js"; import type { ElevatedLevel } from "../thinking.js"; @@ -214,6 +218,31 @@ export async function applyInlineDirectiveOverrides(params: { directives = clearInlineDirectives(directives.cleaned); } + if ( + directives.hasModelDirective && + effectiveModelDirective && + isModelSelectionLocked(sessionEntry) + ) { + const lockedModelResolution = resolveModelSelectionFromDirective({ + directives: { + ...directives, + rawModelDirective: effectiveModelDirective, + }, + cfg, + agentDir, + defaultProvider, + defaultModel, + aliasIndex, + allowedModelKeys: modelState.allowedModelKeys, + allowedModelCatalog: modelState.allowedModelCatalog, + provider, + }); + if (lockedModelResolution.modelSelection) { + typing.cleanup(); + return { kind: "reply", reply: { text: MODEL_SELECTION_LOCKED_MESSAGE } }; + } + } + const hasAnyDirective = directives.hasThinkDirective || directives.hasFastDirective || diff --git a/src/auto-reply/reply/get-reply-directives.target-session.test.ts b/src/auto-reply/reply/get-reply-directives.target-session.test.ts index 85c4845cc315..563d93926643 100644 --- a/src/auto-reply/reply/get-reply-directives.target-session.test.ts +++ b/src/auto-reply/reply/get-reply-directives.target-session.test.ts @@ -2,6 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../../config/sessions.js"; import { SessionWorkStartInvalidatedError } from "../../config/sessions/lifecycle.js"; +import { + MODEL_SELECTION_LOCKED_MESSAGE, + ModelSelectionLockedError, +} from "../../sessions/model-overrides.js"; import { getReplyPayloadMetadata } from "../reply-payload.js"; import type { TemplateContext } from "../templating.js"; import { resolveReplyDirectives } from "./get-reply-directives.js"; @@ -421,6 +425,21 @@ describe("resolveReplyDirectives", () => { expect(mocks.applyInlineDirectiveOverrides).not.toHaveBeenCalled(); }); + it("returns a terminal rejection when locked model preparation cannot preserve its model", async () => { + const { result, typing } = await resolveHelloWithModelDefaults({ + defaultThinking: "off", + defaultReasoning: "on", + modelError: new ModelSelectionLockedError(), + }); + + expect(result).toEqual({ + kind: "reply", + reply: { text: MODEL_SELECTION_LOCKED_MESSAGE }, + }); + expect(typing.cleanup).toHaveBeenCalledOnce(); + expect(mocks.applyInlineDirectiveOverrides).not.toHaveBeenCalled(); + }); + it("marks terminal directive replies for delivery under source suppression", async () => { mocks.applyInlineDirectiveOverrides.mockResolvedValueOnce({ kind: "reply", diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index dae633b13243..f8e1fce1906d 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -13,6 +13,7 @@ import type { SessionEntry } from "../../config/sessions.js"; import { isSessionWorkStartInvalidatedError } from "../../config/sessions/lifecycle.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { normalizeAgentId } from "../../routing/session-key.js"; +import { ModelSelectionLockedError } from "../../sessions/model-overrides.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import type { SkillCommandSpec } from "../../skills/types.js"; import { shouldHandleTextCommands } from "../commands-text-routing.js"; @@ -552,6 +553,10 @@ export async function resolveReplyDirectives(params: { isHeartbeat: opts?.isHeartbeat === true, }); } catch (error) { + if (error instanceof ModelSelectionLockedError) { + typing.cleanup(); + return { kind: "reply", reply: { text: error.message } }; + } if (!isSessionWorkStartInvalidatedError(error)) { throw error; } diff --git a/src/auto-reply/reply/get-reply-fast-path.ts b/src/auto-reply/reply/get-reply-fast-path.ts index 21da412afe72..893310454264 100644 --- a/src/auto-reply/reply/get-reply-fast-path.ts +++ b/src/auto-reply/reply/get-reply-fast-path.ts @@ -12,6 +12,11 @@ import { resolveSessionKey } from "../../config/sessions/session-key.js"; import { loadSessionStore, resolveSessionStoreEntry } from "../../config/sessions/store.js"; import type { SessionEntry, SessionScope } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + isModelSelectionLocked, + MODEL_SELECTION_LOCKED_RESET_MESSAGE, + ModelSelectionLockedError, +} from "../../sessions/model-overrides.js"; import { resolveCommandTurnTargetSessionKey } from "../command-turn-context.js"; import { normalizeCommandBody } from "../commands-registry.js"; import type { MsgContext, TemplateContext } from "../templating.js"; @@ -218,6 +223,9 @@ export function initFastReplySessionState(params: { const softReset = parseSoftResetCommand(normalizedResetBody); const resetMatch = normalizedResetBody.match(/^\/(new|reset)(?:\s|$)/i); const resetTriggered = Boolean(resetMatch) && !softReset.matched; + if (resetTriggered && isModelSelectionLocked(existingEntry)) { + throw new ModelSelectionLockedError(MODEL_SELECTION_LOCKED_RESET_MESSAGE); + } const previousSessionEntry = resetTriggered && existingEntry ? { ...existingEntry } : undefined; const sessionId = !resetTriggered && existingEntry ? existingEntry.sessionId : crypto.randomUUID(); diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 0fbb314f314c..f312a7d9d238 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -1525,6 +1525,7 @@ export async function runPreparedReply( skillsSnapshot, provider, model, + modelSelectionLocked: preparedSessionState.sessionEntry?.modelSelectionLocked === true, hasSessionModelOverride: runHasSessionModelOverride, modelOverrideSource: runModelOverrideSource, hasAutoFallbackProvenance: runHasAutoFallbackProvenance || undefined, diff --git a/src/auto-reply/reply/get-reply.auto-fallback.test.ts b/src/auto-reply/reply/get-reply.auto-fallback.test.ts index 045a7bcf13b9..8c5b765934af 100644 --- a/src/auto-reply/reply/get-reply.auto-fallback.test.ts +++ b/src/auto-reply/reply/get-reply.auto-fallback.test.ts @@ -135,7 +135,7 @@ function makePerModelThinkingConfig( const tempDirs = useAutoCleanupTempDirTracker(afterEach); -function mockAutoFallbackSession() { +function mockAutoFallbackSession(params: { modelSelectionLocked?: boolean } = {}) { const sessionKey = "agent:main:telegram:123"; const sessionEntry: SessionEntry = { sessionId: "fallback-session", @@ -145,6 +145,7 @@ function mockAutoFallbackSession() { modelOverrideSource: "auto", modelOverrideFallbackOriginProvider: "openai", modelOverrideFallbackOriginModel: "gpt-5.5", + modelSelectionLocked: params.modelSelectionLocked, }; // Reply-turn admission re-reads the store from disk before starting work; // seed a real per-test store so the guard sees the same session the mocks @@ -215,6 +216,41 @@ describe("getReplyFromConfig auto-fallback primary probes", () => { vi.mocked(runPreparedReplyMock).mockResolvedValue({ text: "ok" }); }); + it("does not probe the primary model for a model-locked session", async () => { + const { sessionKey } = mockAutoFallbackSession({ modelSelectionLocked: true }); + mockFallbackDirectiveResult({ sessionKey, resolvedThinkLevel: "off" }); + + await expect( + getReplyFromConfig(buildGetReplyCtx(), undefined, makeReasoningModelConfig()), + ).resolves.toEqual({ text: "ok" }); + + expect(vi.mocked(runPreparedReplyMock)).toHaveBeenCalledOnce(); + const runParams = vi.mocked(runPreparedReplyMock).mock.calls[0]?.[0]; + expect(runParams?.provider).toBe("anthropic"); + expect(runParams?.model).toBe("claude-fallback"); + expect(runParams?.autoFallbackPrimaryProbe).toBeUndefined(); + }); + + it("suppresses heartbeat model overrides for a model-locked session", async () => { + const { sessionKey } = mockAutoFallbackSession({ modelSelectionLocked: true }); + mockFallbackDirectiveResult({ sessionKey, resolvedThinkLevel: "off" }); + + await expect( + getReplyFromConfig( + buildGetReplyCtx(), + { isHeartbeat: true, heartbeatModelOverride: "openai/gpt-5.5" }, + makeReasoningModelConfig(), + ), + ).resolves.toEqual({ text: "ok" }); + + expect(mocks.resolveReplyDirectives).toHaveBeenCalledOnce(); + expect(mocks.resolveReplyDirectives.mock.calls[0]?.[0]).toMatchObject({ + provider: "anthropic", + model: "claude-fallback", + hasResolvedHeartbeatModelOverride: false, + }); + }); + it("does not re-enable default reasoning for explicit thinking-off primary probes", async () => { const { sessionKey } = mockAutoFallbackSession(); mockFallbackDirectiveResult({ sessionKey, resolvedThinkLevel: "off" }); diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index c6080e0886ee..627fbc7e6ef3 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -10,6 +10,10 @@ import { readSessionStoreForTest, writeSessionStoreForTestAsync, } from "../../config/sessions/test-helpers.js"; +import { + MODEL_SELECTION_LOCKED_RESET_MESSAGE, + ModelSelectionLockedError, +} from "../../sessions/model-overrides.js"; import { getReplyPayloadMetadata } from "../reply-payload.js"; import { handleGoalCommand } from "./commands-goal.js"; import { @@ -288,6 +292,30 @@ describe("getReplyFromConfig fast test bootstrap", () => { }); }); + it("returns a clean rejection when session bootstrap rejects a locked reset", async () => { + vi.stubEnv("OPENCLAW_ALLOW_SLOW_REPLY_TESTS", "1"); + const sessionKey = "agent:main:telegram:123"; + mocks.initSessionState.mockRejectedValueOnce( + new ModelSelectionLockedError(MODEL_SELECTION_LOCKED_RESET_MESSAGE), + ); + + const result = await getReplyFromConfig( + buildGetReplyCtx({ + Body: "/reset openai/gpt-5.5 continue", + RawBody: "/reset openai/gpt-5.5 continue", + CommandBody: "/reset openai/gpt-5.5 continue", + CommandAuthorized: true, + SessionKey: sessionKey, + }), + undefined, + {} as OpenClawConfig, + ); + + expect(result).toEqual({ text: MODEL_SELECTION_LOCKED_RESET_MESSAGE }); + expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled(); + expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled(); + }); + it("marks configs through withFastReplyConfig()", async () => { const cfg = withFastReplyConfig({ session: { store: "/tmp/sessions.json" } } as OpenClawConfig); @@ -731,6 +759,40 @@ describe("getReplyFromConfig fast test bootstrap", () => { expect(result.sessionEntry.responseUsage).toBe("full"); }); + it("rejects a fast reset bootstrap for a model-locked session", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fast-reset-locked-")); + const storePath = path.join(home, "sessions.json"); + const sessionKey = "agent:main:telegram:123"; + await seedFastPathSessionStore(storePath, { + [sessionKey]: { + sessionId: "existing-fast-reset-locked", + updatedAt: Date.now(), + agentHarnessId: "codex", + modelSelectionLocked: true, + }, + }); + + expect(() => + initFastReplySessionState({ + ctx: buildGetReplyCtx({ + Body: "/reset", + RawBody: "/reset", + CommandBody: "/reset", + SessionKey: sessionKey, + }), + cfg: { session: { store: storePath } } as OpenClawConfig, + agentId: "main", + commandAuthorized: true, + workspaceDir: home, + }), + ).toThrow(MODEL_SELECTION_LOCKED_RESET_MESSAGE); + expect(readFastPathSessionEntry(storePath, sessionKey)).toMatchObject({ + sessionId: "existing-fast-reset-locked", + agentHarnessId: "codex", + modelSelectionLocked: true, + }); + }); + it("maps explicit gateway origin into command context", () => { const command = buildFastReplyCommandContext({ ctx: buildGetReplyCtx({ diff --git a/src/auto-reply/reply/get-reply.message-hooks.test.ts b/src/auto-reply/reply/get-reply.message-hooks.test.ts index 3c0c5377e4e6..a6e3c61db18c 100644 --- a/src/auto-reply/reply/get-reply.message-hooks.test.ts +++ b/src/auto-reply/reply/get-reply.message-hooks.test.ts @@ -2,6 +2,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { logVerbose } from "../../globals.js"; import type { ApplyMediaUnderstandingResult } from "../../media-understanding/apply.js"; +import { AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE } from "../../sessions/agent-harness-session-key.js"; import type { MsgContext } from "../templating.js"; import { withFastReplyConfig } from "./get-reply-fast-path.js"; import { @@ -23,6 +24,7 @@ const mocks = vi.hoisted(() => ({ resolveReplyDirectives: vi.fn(), handleInlineActions: vi.fn(), initSessionState: vi.fn(), + resolveReplySessionPreprocessingState: vi.fn(), })); vi.mock("../../globals.js", () => ({ @@ -102,6 +104,7 @@ async function resetMessageHookTestState() { mocks.resolveReplyDirectives.mockReset(); mocks.handleInlineActions.mockReset(); mocks.initSessionState.mockReset(); + mocks.resolveReplySessionPreprocessingState.mockReset(); vi.mocked(resolveDefaultModelMock).mockReset(); vi.mocked(runPreparedReplyMock).mockReset(); vi.mocked(stageSandboxMediaMock).mockReset(); @@ -146,6 +149,11 @@ async function resetMessageHookTestState() { }); vi.mocked(runPreparedReplyMock).mockResolvedValue({ text: "ok" }); vi.mocked(stageSandboxMediaMock).mockResolvedValue({ staged: new Map() }); + mocks.resolveReplySessionPreprocessingState.mockReturnValue({ + sessionEntry: undefined, + sessionKey: "agent:main:telegram:-100123", + storePath: "/tmp/sessions.json", + }); mocks.initSessionState.mockResolvedValue( createGetReplySessionState({ sessionKey: "agent:main:telegram:-100123", @@ -197,6 +205,175 @@ describe("getReplyFromConfig message hooks", () => { expect(triggerCount).toBe(2); }); + it("prepares durable session state before media understanding", async () => { + const order: string[] = []; + mocks.resolveReplySessionPreprocessingState.mockImplementationOnce(() => { + order.push("preflight"); + return { + sessionEntry: undefined, + sessionKey: "agent:main:telegram:-100123", + storePath: "/tmp/sessions.json", + }; + }); + mocks.initSessionState.mockImplementationOnce(async (...args: unknown[]) => { + order.push("session"); + const { ctx } = args[0] as { ctx: MsgContext }; + expect(ctx.BodyForAgent).toBe("[Audio]\nTranscript:\nresolved after admission"); + return createGetReplySessionState({ + sessionCtx: { ...ctx, BodyStripped: ctx.BodyForAgent }, + sessionKey: "agent:main:telegram:-100123", + sessionScope: "per-chat", + isGroup: true, + }); + }); + mocks.applyMediaUnderstanding.mockImplementationOnce(async (...args: unknown[]) => { + order.push("media"); + const { ctx } = args[0] as { ctx: MsgContext }; + ctx.Body = "[Audio]\nTranscript:\nresolved after admission"; + ctx.BodyForAgent = ctx.Body; + ctx.Transcript = "resolved after admission"; + return undefined; + }); + + await getReplyFromConfig(buildCtx(), undefined, withFastReplyConfig({})); + + expect(order).toEqual(["preflight", "media", "session"]); + expect(mocks.resolveReplyDirectives.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + sessionCtx: expect.objectContaining({ + BodyForAgent: "[Audio]\nTranscript:\nresolved after admission", + BodyStripped: "[Audio]\nTranscript:\nresolved after admission", + Transcript: "resolved after admission", + }), + }), + ); + }); + + it("skips utility media understanding for a model-locked harness session", async () => { + const sessionKey = "agent:main:harness:codex:supervision:locked-media"; + const sessionEntry = { + sessionId: "locked-session", + updatedAt: 1, + agentHarnessId: "codex", + modelSelectionLocked: true, + }; + mocks.resolveReplySessionPreprocessingState.mockReturnValueOnce({ + sessionEntry, + sessionKey, + storePath: "/tmp/sessions.json", + }); + mocks.initSessionState.mockResolvedValueOnce( + createGetReplySessionState({ + sessionCtx: { + BodyForAgent: "", + SessionKey: sessionKey, + }, + sessionEntry, + sessionKey, + }), + ); + + await getReplyFromConfig( + buildCtx({ SessionKey: sessionKey }), + undefined, + withFastReplyConfig({}), + ); + + expect(mocks.resolveReplySessionPreprocessingState).toHaveBeenCalledOnce(); + expect(mocks.initSessionState).toHaveBeenCalledOnce(); + expect(mocks.applyMediaUnderstanding).not.toHaveBeenCalled(); + expect(mocks.resolveReplyDirectives.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + sessionEntry: expect.objectContaining({ + agentHarnessId: "codex", + modelSelectionLocked: true, + }), + sessionCtx: expect.objectContaining({ + BodyForAgent: "", + SessionKey: sessionKey, + }), + }), + ); + }); + + it("skips utility link understanding for a model-locked harness session", async () => { + const sessionKey = "agent:main:harness:codex:supervision:locked-link"; + const body = "read https://example.test/page"; + const sessionEntry = { + sessionId: "locked-link-session", + updatedAt: 1, + agentHarnessId: "codex", + modelSelectionLocked: true, + }; + mocks.resolveReplySessionPreprocessingState.mockReturnValueOnce({ + sessionEntry, + sessionKey, + storePath: "/tmp/sessions.json", + }); + mocks.initSessionState.mockResolvedValueOnce( + createGetReplySessionState({ + sessionCtx: { BodyForAgent: body, SessionKey: sessionKey }, + sessionEntry, + sessionKey, + }), + ); + + await getReplyFromConfig( + buildCtx({ + Body: body, + BodyForAgent: body, + RawBody: body, + CommandBody: body, + BodyForCommands: body, + SessionKey: sessionKey, + MediaPath: undefined, + MediaUrl: undefined, + MediaPaths: undefined, + MediaUrls: undefined, + MediaTypes: undefined, + MediaType: undefined, + }), + undefined, + withFastReplyConfig({}), + ); + + expect(mocks.resolveReplySessionPreprocessingState).toHaveBeenCalledOnce(); + expect(mocks.applyLinkUnderstanding).not.toHaveBeenCalled(); + expect(mocks.initSessionState).toHaveBeenCalledOnce(); + }); + + it("fails closed before link understanding when the reserved session is missing", async () => { + const sessionKey = "agent:main:harness:codex:supervision:missing-link"; + const body = "read https://example.test/page"; + mocks.resolveReplySessionPreprocessingState.mockImplementationOnce(() => { + throw new Error(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE); + }); + + await expect( + getReplyFromConfig( + buildCtx({ + Body: body, + BodyForAgent: body, + RawBody: body, + CommandBody: body, + BodyForCommands: body, + SessionKey: sessionKey, + MediaPath: undefined, + MediaUrl: undefined, + MediaPaths: undefined, + MediaUrls: undefined, + MediaTypes: undefined, + MediaType: undefined, + }), + undefined, + withFastReplyConfig({}), + ), + ).rejects.toThrow(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE); + + expect(mocks.applyLinkUnderstanding).not.toHaveBeenCalled(); + expect(mocks.initSessionState).not.toHaveBeenCalled(); + }); + it("enriches staged text-only images before reply without switching the reply model", async () => { const enrichedBody = "describe image\n\n[Image 1]\na tiny dot image"; const extractedPdfPage = { diff --git a/src/auto-reply/reply/get-reply.test-fixtures.ts b/src/auto-reply/reply/get-reply.test-fixtures.ts index d67c99366a90..50dae0e8ec38 100644 --- a/src/auto-reply/reply/get-reply.test-fixtures.ts +++ b/src/auto-reply/reply/get-reply.test-fixtures.ts @@ -146,6 +146,7 @@ export function createGetReplyContinueDirectivesResult(params: { export function registerGetReplyRuntimeOverrides(handles: { resolveReplyDirectives: (...args: unknown[]) => unknown; initSessionState: (...args: unknown[]) => unknown; + resolveReplySessionPreprocessingState?: (...args: unknown[]) => unknown; handleInlineActions?: (...args: unknown[]) => unknown; }): void { vi.doMock("./get-reply-directives.js", () => ({ @@ -157,6 +158,12 @@ export function registerGetReplyRuntimeOverrides(handles: { })); vi.doMock("./session.js", () => ({ initSessionState: (...args: unknown[]) => handles.initSessionState(...args), + resolveReplySessionPreprocessingState: (...args: unknown[]) => + handles.resolveReplySessionPreprocessingState?.(...args) ?? { + sessionEntry: undefined, + sessionKey: "agent:main:telegram:123", + storePath: "/tmp/sessions.json", + }, })); } diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 68ad7951f0cc..de069ad56516 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -28,6 +28,10 @@ import { buildAgentHookContextIdentityFields, } from "../../plugins/hook-agent-context.js"; import { defaultRuntime } from "../../runtime.js"; +import { + isModelSelectionLocked, + ModelSelectionLockedError, +} from "../../sessions/model-overrides.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { resolveCommandTurnTargetSessionKey } from "../command-turn-context.js"; import type { GetReplyOptions } from "../get-reply-options.types.js"; @@ -62,7 +66,7 @@ import { createFastTestModelSelectionState, createModelSelectionState } from "./ import { sanitizePendingFinalDeliveryText } from "./pending-final-delivery.js"; import { attachProgressNarratorToReplyOptions } from "./progress-narrator.js"; import { createReplyTimingTracker } from "./reply-timing-tracker.js"; -import { initSessionState } from "./session.js"; +import { initSessionState, resolveReplySessionPreprocessingState } from "./session.js"; import { stageRemoteInboundMediaIfNeeded } from "./stage-remote-inbound-media.js"; import { isStaleHeartbeatAutoFallbackOverride, @@ -319,13 +323,9 @@ export async function getReplyFromConfig( ); const optsWithSkillFilter = mergedSkillFilter !== undefined ? { ...opts, skillFilter: mergedSkillFilter } : opts; - const resolvedOpts = attachProgressNarratorToReplyOptions({ - cfg, - agentId, - userMessage: finalized.BodyForAgent ?? finalized.Body, - opts: optsWithSkillFilter, - }); - const internalResolvedOpts = resolvedOpts as RuntimeInternalGetReplyOptions | undefined; + const internalOptsWithSkillFilter = optsWithSkillFilter as + | RuntimeInternalGetReplyOptions + | undefined; let extractedFileImages: ExtractedFileImage[] | undefined; const agentCfg = cfg.agents?.defaults; const agentEntry = resolveAgentConfig(cfg, agentId); @@ -413,7 +413,7 @@ export async function getReplyFromConfig( model, workspaceDir: workspaceDirForNativeCommand, typing, - opts: resolvedOpts, + opts: optsWithSkillFilter, skillFilter: mergedSkillFilter, }), ); @@ -447,22 +447,37 @@ export async function getReplyFromConfig( }), ); } - if (!isFastTestEnv && hasInboundMediaForUnderstanding(finalized)) { - const mediaResult = await traceGetReplyPhase("reply.apply_media_understanding", () => - applyMediaUnderstandingIfNeeded({ - ctx: finalized, - cfg, - agentId, - agentDir, - workspaceDir, - activeModel: { provider, model }, - }), - ); - if (mediaResult?.extractedFileImages.length) { - extractedFileImages = mediaResult.extractedFileImages; + + const mediaUnderstandingRequested = !isFastTestEnv && hasInboundMediaForUnderstanding(finalized); + const linkUnderstandingRequested = !isFastTestEnv && hasLinkCandidate(finalized); + const preprocessingState = + mediaUnderstandingRequested || linkUnderstandingRequested + ? await traceGetReplyPhase("reply.resolve_session_preprocessing_state", () => + resolveReplySessionPreprocessingState({ ctx: finalized, cfg }), + ) + : undefined; + const utilityModelSelectionLocked = isModelSelectionLocked(preprocessingState?.sessionEntry); + + if (mediaUnderstandingRequested) { + // A durable native-harness lock owns attachment and link interpretation. The + // harness receives raw inputs, so unrelated utility models stay outside the turn. + if (!utilityModelSelectionLocked) { + const mediaResult = await traceGetReplyPhase("reply.apply_media_understanding", () => + applyMediaUnderstandingIfNeeded({ + ctx: finalized, + cfg, + agentId, + agentDir, + workspaceDir, + activeModel: { provider, model }, + }), + ); + if (mediaResult?.extractedFileImages.length) { + extractedFileImages = mediaResult.extractedFileImages; + } } } - if (!isFastTestEnv && hasLinkCandidate(finalized)) { + if (linkUnderstandingRequested && !utilityModelSelectionLocked) { await traceGetReplyPhase("reply.apply_link_understanding", () => applyLinkUnderstandingIfNeeded({ ctx: finalized, @@ -477,24 +492,33 @@ export async function getReplyFromConfig( }); const commandAuthorized = finalized.CommandAuthorized; - const sessionState = useFastTestBootstrap - ? initFastReplySessionState({ - ctx: finalized, - cfg, - agentId, - commandAuthorized, - workspaceDir, - }) - : await traceGetReplyPhase("reply.init_session_state", () => - initSessionState({ + let sessionState: Awaited>; + try { + sessionState = useFastTestBootstrap + ? initFastReplySessionState({ ctx: finalized, cfg, + agentId, commandAuthorized, - requestedSessionId: internalResolvedOpts?.requestedSessionId, - resumeRequestedSession: internalResolvedOpts?.resumeRequestedSession, - signal: internalResolvedOpts?.abortSignal, - }), - ); + workspaceDir, + }) + : await traceGetReplyPhase("reply.init_session_state", () => + initSessionState({ + ctx: finalized, + cfg, + commandAuthorized, + requestedSessionId: internalOptsWithSkillFilter?.requestedSessionId, + resumeRequestedSession: internalOptsWithSkillFilter?.resumeRequestedSession, + signal: internalOptsWithSkillFilter?.abortSignal, + }), + ); + } catch (error) { + if (error instanceof ModelSelectionLockedError) { + typing.cleanup(); + return { text: error.message }; + } + throw error; + } const { sessionCtx, sessionEntry, @@ -514,6 +538,24 @@ export async function getReplyFromConfig( triggerBodyNormalized, bodyStripped, } = sessionState; + const sessionModelSelectionLocked = isModelSelectionLocked(sessionEntry); + if (sessionModelSelectionLocked && hasResolvedHeartbeatModelOverride) { + // Heartbeat routing is turn-local. A native harness lock owns the durable + // model selection, so heartbeat.model must not retarget its AppServer turn. + provider = defaultProvider; + model = defaultModel; + hasResolvedHeartbeatModelOverride = false; + } + // Utility-model narration is turn-local decoration. Initialize the durable + // session first, then keep it completely outside model-locked native runs. + const resolvedOpts = attachProgressNarratorToReplyOptions({ + cfg, + agentId, + userMessage: finalized.BodyForAgent ?? finalized.Body, + opts: optsWithSkillFilter, + disabled: sessionModelSelectionLocked, + }); + const internalResolvedOpts = resolvedOpts as RuntimeInternalGetReplyOptions | undefined; let { abortedLastRun } = sessionState; resolverTimingSessionKey = sessionKey ?? resolverTimingSessionKey; internalResolvedOpts?.onSessionPrepared?.({ @@ -587,6 +629,10 @@ export async function getReplyFromConfig( aliasIndex, }); } catch (error) { + if (error instanceof ModelSelectionLockedError) { + typing.cleanup(); + return { text: error.message }; + } if (!isSessionWorkStartInvalidatedError(error)) { throw error; } @@ -623,7 +669,7 @@ export async function getReplyFromConfig( }) : null; const resolvedChannelModelOverride = - channelModelOverride && !hasResolvedHeartbeatModelOverride + channelModelOverride && !hasResolvedHeartbeatModelOverride && !sessionModelSelectionLocked ? resolveModelRefFromString({ raw: channelModelOverride.model, defaultProvider, @@ -646,18 +692,22 @@ export async function getReplyFromConfig( sessionCtx.ParentSessionKey, defaultProvider, }); - const staleHeartbeatAutoFallbackOverride = isStaleHeartbeatAutoFallbackOverride({ - isHeartbeat: opts?.isHeartbeat === true, - hasResolvedHeartbeatModelOverride, - sessionEntry, - storedOverride: storedModelOverride, - defaultProvider, - defaultModel, - primaryProvider, - primaryModel, - }); + const staleHeartbeatAutoFallbackOverride = + !sessionModelSelectionLocked && + isStaleHeartbeatAutoFallbackOverride({ + isHeartbeat: opts?.isHeartbeat === true, + hasResolvedHeartbeatModelOverride, + sessionEntry, + storedOverride: storedModelOverride, + defaultProvider, + defaultModel, + primaryProvider, + primaryModel, + }); const staleLegacyAutoFallbackWithoutOrigin = - storedModelOverride?.source === "session" && hasLegacyAutoFallbackWithoutOrigin(sessionEntry); + !sessionModelSelectionLocked && + storedModelOverride?.source === "session" && + hasLegacyAutoFallbackWithoutOrigin(sessionEntry); if ( storedModelOverride?.model && !hasResolvedHeartbeatModelOverride && @@ -668,7 +718,9 @@ export async function getReplyFromConfig( model = storedModelOverride.model; } const canApplyAutoFallbackPrimaryProbe = - !hasResolvedHeartbeatModelOverride && !staleHeartbeatAutoFallbackOverride; + !sessionModelSelectionLocked && + !hasResolvedHeartbeatModelOverride && + !staleHeartbeatAutoFallbackOverride; const autoFallbackPrimaryProbe = canApplyAutoFallbackPrimaryProbe ? resolveAutoFallbackPrimaryProbe({ entry: sessionEntry, @@ -947,6 +999,10 @@ export async function getReplyFromConfig( isHeartbeat: opts?.isHeartbeat === true, }); } catch (error) { + if (error instanceof ModelSelectionLockedError) { + typing.cleanup(); + return { text: error.message }; + } if (!isSessionWorkStartInvalidatedError(error)) { throw error; } diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index cbb211c38715..3c887e724a62 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -14,6 +14,7 @@ import { import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import { loadSessionStore, saveSessionStore } from "../../config/sessions/store.js"; +import { MODEL_SELECTION_LOCKED_MESSAGE } from "../../sessions/model-overrides.js"; import { createModelSelectionState, resolveContextTokens } from "./model-selection.js"; vi.mock("../../agents/model-catalog.runtime.js", () => ({ @@ -1152,6 +1153,51 @@ describe("createModelSelectionState respects session model override", () => { expect(sessionStore[sessionKey]?.providerOverride).toBeUndefined(); }); + it("rejects automatic repair of a locked disallowed override", async () => { + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-4o" }, + models: { + "openai/gpt-4o": {}, + }, + }, + }, + } as OpenClawConfig; + const sessionKey = "agent:main:telegram:direct:locked"; + const sessionEntry = makeEntry({ + providerOverride: "openai", + modelOverride: "gpt-4o-mini", + modelOverrideSource: "user", + modelSelectionLocked: true, + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + await expect( + createModelSelectionState({ + cfg, + agentCfg: cfg.agents?.defaults, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider: "openai", + defaultModel: "gpt-4o", + provider: "openai", + model: "gpt-4o", + hasModelDirective: false, + }), + ).rejects.toMatchObject({ + name: "ModelSelectionLockedError", + message: MODEL_SELECTION_LOCKED_MESSAGE, + }); + expect(sessionStore[sessionKey]).toMatchObject({ + providerOverride: "openai", + modelOverride: "gpt-4o-mini", + modelOverrideSource: "user", + modelSelectionLocked: true, + }); + }); + it("adopts a concurrent valid model while repairing a stale override", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-model-repair-race-")); const storePath = path.join(tempRoot, "sessions.json"); diff --git a/src/auto-reply/reply/progress-narrator.test.ts b/src/auto-reply/reply/progress-narrator.test.ts index 709444d41e4f..ba270c44c6ef 100644 --- a/src/auto-reply/reply/progress-narrator.test.ts +++ b/src/auto-reply/reply/progress-narrator.test.ts @@ -236,6 +236,17 @@ describe("attachProgressNarratorToReplyOptions", () => { ); }); + it("returns options unchanged for model-locked native sessions", () => { + const opts: GetReplyOptions = { onNarrationUpdate: vi.fn(), onToolStart: vi.fn() }; + expect( + attachProgressNarratorToReplyOptions({ + cfg: utilityCfg, + agentId: "main", + opts, + disabled: true, + }), + ).toBe(opts); + }); it("tees tool events while preserving the channel callback results", async () => { const onToolStart = vi.fn(async () => {}); const onItemEvent = vi.fn(() => false as const); diff --git a/src/auto-reply/reply/progress-narrator.ts b/src/auto-reply/reply/progress-narrator.ts index c36e8c4620d6..27bc57b7ec2b 100644 --- a/src/auto-reply/reply/progress-narrator.ts +++ b/src/auto-reply/reply/progress-narrator.ts @@ -361,10 +361,12 @@ export function attachProgressNarratorToReplyOptions(params: { agentId: string; userMessage?: string; opts?: GetReplyOptions; + /** Model-locked native sessions must never invoke the utility model. */ + disabled?: boolean; }): GetReplyOptions | undefined { const opts = params.opts; const onNarrationUpdate = opts?.onNarrationUpdate; - if (!opts || !onNarrationUpdate) { + if (!opts || !onNarrationUpdate || params.disabled === true) { return opts; } // Explicit config or a provider-declared default both enable narration; diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index 0275f8074186..547ade19dc07 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -141,6 +141,8 @@ export type FollowupRun = { skillsSnapshot?: SkillSnapshot; provider: string; model: string; + /** Prevents the queued run from selecting configured fallback models. */ + modelSelectionLocked?: boolean; hasSessionModelOverride?: boolean; modelOverrideSource?: "auto" | "user"; hasAutoFallbackProvenance?: boolean; diff --git a/src/auto-reply/reply/session-fork.runtime.test.ts b/src/auto-reply/reply/session-fork.runtime.test.ts index e054b4f84f2e..055805689c79 100644 --- a/src/auto-reply/reply/session-fork.runtime.test.ts +++ b/src/auto-reply/reply/session-fork.runtime.test.ts @@ -4,6 +4,12 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { SessionEntry } from "../../config/sessions/types.js"; +import { + forkSessionEntryFromParent, + forkSessionFromParent, + MODEL_SELECTION_LOCKED_PARENT_FORK_MESSAGE, + resolveParentForkDecision, +} from "./session-fork.js"; import { resolveParentForkTokenCountRuntime } from "./session-fork.runtime.js"; const roots: string[] = []; @@ -18,6 +24,65 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); }); +describe("parent fork policy", () => { + it("rejects model-selection-locked parent context", async () => { + const parentEntry: SessionEntry = { + sessionId: "locked-parent", + modelSelectionLocked: true, + updatedAt: 1, + }; + await expect( + resolveParentForkDecision({ + parentEntry, + storePath: "/tmp/unused-sessions.json", + }), + ).rejects.toThrow(MODEL_SELECTION_LOCKED_PARENT_FORK_MESSAGE); + await expect( + forkSessionFromParent({ + agentId: "main", + parentEntry, + parentSessionKey: "agent:main:main", + sessionKey: "agent:main:subagent:child", + storePath: "/tmp/unused-sessions.json", + }), + ).rejects.toThrow(MODEL_SELECTION_LOCKED_PARENT_FORK_MESSAGE); + }); + + it("rejects a newer locked parent alias shadowed by a stale canonical row", async () => { + const root = await makeRoot("openclaw-parent-fork-locked-alias-"); + const storePath = path.join(root, "sessions.json"); + await fs.writeFile( + storePath, + JSON.stringify({ + "agent:main:main": { + sessionId: "stale-canonical-parent", + updatedAt: 1, + }, + main: { + sessionId: "fresh-locked-parent", + modelSelectionLocked: true, + updatedAt: 2, + }, + }), + "utf-8", + ); + + await expect( + forkSessionEntryFromParent({ + agentId: "main", + fallbackEntry: { sessionId: "", updatedAt: 3 }, + parentSessionKey: "agent:main:main", + parentStoreKeys: ["agent:main:main", "main"], + sessionKey: "agent:main:subagent:child", + storePath, + }), + ).rejects.toThrow(MODEL_SELECTION_LOCKED_PARENT_FORK_MESSAGE); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record; + expect(stored["agent:main:subagent:child"]).toBeUndefined(); + }); +}); + describe("resolveParentForkTokenCountRuntime", () => { it("falls back to recent transcript usage when cached totals are stale", async () => { const root = await makeRoot("openclaw-parent-fork-token-estimate-"); diff --git a/src/auto-reply/reply/session-fork.ts b/src/auto-reply/reply/session-fork.ts index 334f14805085..2882efe1b012 100644 --- a/src/auto-reply/reply/session-fork.ts +++ b/src/auto-reply/reply/session-fork.ts @@ -7,6 +7,10 @@ import { } from "../../config/sessions/session-accessor.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + isModelSelectionLocked, + ModelSelectionLockedError, +} from "../../sessions/model-overrides.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; /** @@ -17,6 +21,17 @@ import { createLazyImportLoader } from "../../shared/lazy-promise.js"; const DEFAULT_PARENT_FORK_MAX_TOKENS = 100_000; const sessionForkRuntimeLoader = createLazyImportLoader(() => import("./session-fork.runtime.js")); +export const MODEL_SELECTION_LOCKED_PARENT_FORK_MESSAGE = + "Model-selection-locked sessions cannot create child sessions from parent context."; + +function assertParentSessionForkAllowed(parentEntry: SessionEntry): void { + // A locked harness owns both the model and transcript lineage. Copying that + // context into an ordinary child would let the child continue it elsewhere. + if (isModelSelectionLocked(parentEntry)) { + throw new ModelSelectionLockedError(MODEL_SELECTION_LOCKED_PARENT_FORK_MESSAGE); + } +} + export type ParentForkDecision = SessionParentForkDecision; type ParentForkDecisionParams = { @@ -107,6 +122,7 @@ function resolveParentForkStorePath(params: { export async function resolveParentForkDecision( params: ParentForkDecisionParams, ): Promise { + assertParentSessionForkAllowed(params.parentEntry); const maxTokens = DEFAULT_PARENT_FORK_MAX_TOKENS; const parentTokens = await resolveParentForkTokenCount({ parentEntry: params.parentEntry, @@ -131,6 +147,8 @@ export async function resolveParentForkDecision( export async function forkSessionFromParent( params: ForkSessionFromParentParams, ): Promise<{ sessionId: string; sessionFile: string } | null> { + // Keep direct callers fail-closed even if they skipped the normal decision step. + assertParentSessionForkAllowed(params.parentEntry); const storePath = resolveParentForkStorePath(params); const fork = await forkSessionFromParentTranscript({ agentId: params.agentId, diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 938fba154259..a74329797fbd 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -24,6 +24,7 @@ import { resetSystemEventsForTest, } from "../../infra/system-events.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js"; +import { MODEL_SELECTION_LOCKED_RESET_MESSAGE } from "../../sessions/model-overrides.js"; import { beginSessionWorkAdmission, isSessionLifecycleMutationActive, @@ -38,7 +39,7 @@ import { createSessionConversationTestRegistry } from "../../test-utils/session- import { replyRunRegistry } from "./reply-run-registry.js"; import { drainFormattedSystemEvents } from "./session-updates.js"; import { persistSessionUsageUpdate } from "./session-usage.js"; -import { initSessionState } from "./session.js"; +import { initSessionState, resolveReplySessionPreprocessingState } from "./session.js"; const sessionForkMocks = vi.hoisted(() => ({ forkSessionFromParent: vi.fn(), @@ -280,6 +281,75 @@ async function writeSessionStoreFast( await fs.writeFile(storePath, JSON.stringify(store), "utf-8"); } +describe("resolveReplySessionPreprocessingState", () => { + const sessionKey = "agent:main:harness:codex:supervision:media-preflight"; + + function resolvePreprocessingState(storePath: string) { + return resolveReplySessionPreprocessingState({ + cfg: { session: { store: storePath } } as OpenClawConfig, + ctx: { + Body: "", + RawBody: "", + CommandBody: "", + From: "media-preflight", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + }); + } + + it("returns the valid durable harness owner lock before preprocessing", async () => { + const storePath = await createStorePath("openclaw-media-preflight-valid-"); + await writeSessionStoreFast(storePath, { + [sessionKey]: { + sessionId: "native-media-session", + updatedAt: Date.now(), + agentHarnessId: "codex", + modelSelectionLocked: true, + }, + }); + + expect(resolvePreprocessingState(storePath)).toMatchObject({ + sessionKey, + storePath, + sessionEntry: { + sessionId: "native-media-session", + agentHarnessId: "codex", + modelSelectionLocked: true, + }, + }); + }); + + it.each([ + ["missing row", undefined], + [ + "wrong owner", + { + sessionId: "native-media-session", + updatedAt: 1, + agentHarnessId: "other", + modelSelectionLocked: true, + }, + ], + [ + "missing session id", + { + updatedAt: 1, + agentHarnessId: "codex", + modelSelectionLocked: true, + }, + ], + ] as const)("rejects a reserved %s before preprocessing", async (_label, entry) => { + const storePath = await createStorePath(`openclaw-media-preflight-invalid-${_label}-`); + await writeSessionStoreFast(storePath, entry ? { [sessionKey]: entry } : {}); + + expect(() => resolvePreprocessingState(storePath)).toThrow(); + }); +}); + async function writeTerminalTranscriptSessionStore(params: { storePath: string; sessionKey: string; @@ -3157,6 +3227,101 @@ describe("initSessionState preserves behavior overrides across /new and /reset", }); } + it("rejects explicit resets without replacing a model-locked session", async () => { + const storePath = await createStorePath("openclaw-reset-model-locked-"); + const sessionKey = "agent:main:telegram:dm:model-locked"; + const existingSessionId = "existing-model-locked-session"; + + for (const body of ["/new", "/reset openai/gpt-5.5 continue"] as const) { + await seedSessionStoreWithOverrides({ + storePath, + sessionKey, + sessionId: existingSessionId, + overrides: { + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { threadId: "codex-thread-1" }, + }, + }, + }); + + await expect( + initSessionState({ + ctx: { + Body: body, + RawBody: body, + CommandBody: body, + From: "model-locked", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg: { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig, + commandAuthorized: true, + }), + ).rejects.toThrow(MODEL_SELECTION_LOCKED_RESET_MESSAGE); + + expect(readSessionStoreForTest(storePath)[sessionKey]).toMatchObject({ + sessionId: existingSessionId, + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { threadId: "codex-thread-1" }, + }, + }); + } + }); + + it("does not implicitly expire a model-locked session", async () => { + const storePath = await createStorePath("openclaw-expiry-model-locked-"); + const sessionKey = "agent:main:telegram:dm:model-locked-expiry"; + const existingSessionId = "existing-model-locked-expiry-session"; + await writeSessionStoreFast(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: Date.now() - 60 * 60 * 1000, + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { threadId: "codex-thread-expiry" }, + }, + }, + }); + + const result = await initSessionState({ + ctx: { + Body: "continue", + RawBody: "continue", + CommandBody: "continue", + From: "model-locked-expiry", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg: { + session: { store: storePath, idleMinutes: 1 }, + } as OpenClawConfig, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(false); + expect(result.sessionId).toBe(existingSessionId); + expect(result.sessionEntry).toMatchObject({ + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { threadId: "codex-thread-expiry" }, + }, + }); + }); + it("preserves behavior overrides across /new and /reset", async () => { const storePath = await createStorePath("openclaw-reset-overrides-"); const sessionKey = "agent:main:telegram:dm:user-overrides"; diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index e25b7773331d..f5aeb1ab5319 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -61,7 +61,13 @@ import { isAcpSessionKey, normalizeMainKey, } from "../../routing/session-key.js"; +import { resolveAgentHarnessSessionContextError } from "../../sessions/agent-harness-session-key.js"; import { isInterSessionInputProvenance } from "../../sessions/input-provenance.js"; +import { + isModelSelectionLocked, + MODEL_SELECTION_LOCKED_RESET_MESSAGE, + ModelSelectionLockedError, +} from "../../sessions/model-overrides.js"; import { SESSION_WORK_ADMISSION_DRAIN_TIMEOUT_MS, interruptSessionWorkAdmissions, @@ -254,6 +260,7 @@ function resolveSessionConversationBindingContext( function resolveBoundConversationSessionKey(params: { cfg: OpenClawConfig; ctx: MsgContext; + touch?: boolean; bindingContext?: { channel: string; accountId: string; @@ -279,12 +286,15 @@ function resolveBoundConversationSessionKey(params: { if (!binding?.targetSessionKey) { return undefined; } - getSessionBindingService().touch(binding.bindingId); + if (params.touch !== false) { + getSessionBindingService().touch(binding.bindingId); + } return binding.targetSessionKey; } function resolveInitSessionStateAttemptContext( - params: InitSessionStateParams, + params: Pick, + options?: { touchConversationBinding?: boolean }, ): InitSessionStateAttemptContext { const { cfg, ctx } = params; // Automated system events must not reset sessions or retarget conversation bindings. @@ -302,6 +312,7 @@ function resolveInitSessionStateAttemptContext( cfg, ctx, bindingContext: conversationBindingContext, + touch: options?.touchConversationBinding, }); const sessionCtxForState = targetSessionKey && targetSessionKey !== ctx.SessionKey @@ -321,6 +332,43 @@ function resolveInitSessionStateAttemptContext( }; } +export type ReplySessionPreprocessingState = { + sessionEntry?: SessionEntry; + sessionKey: string; + storePath: string; +}; + +/** Resolves durable ownership before utility preprocessing can invoke another model. */ +export function resolveReplySessionPreprocessingState( + params: Pick, +): ReplySessionPreprocessingState { + const attemptContext = resolveInitSessionStateAttemptContext(params, { + touchConversationBinding: false, + }); + const sessionKey = canonicalizeMainSessionAlias({ + cfg: params.cfg, + agentId: attemptContext.agentId, + sessionKey: resolveSessionKey( + params.cfg.session?.scope ?? "per-sender", + attemptContext.sessionCtxForState, + normalizeMainKey(params.cfg.session?.mainKey), + ), + }); + const sessionEntry = loadReplySessionInitializationSnapshot({ + storePath: attemptContext.storePath, + sessionKey, + }).currentEntry; + const contextError = resolveAgentHarnessSessionContextError(sessionKey, sessionEntry); + if (contextError) { + throw new Error(contextError); + } + return { + sessionEntry, + sessionKey, + storePath: attemptContext.storePath, + }; +} + /** Initializes or reuses the reply session state for one inbound turn. */ export async function initSessionState(params: InitSessionStateParams): Promise { return await initSessionStateAttempt(params, false); @@ -555,6 +603,11 @@ async function initSessionStateAttemptLocked( if (archivedSessionError) { throw new Error(archivedSessionError); } + // Locked model selection is coupled to the current native session id. Reject before + // lifecycle cleanup so a reset cannot detach the durable harness binding. + if (resetTriggered && isModelSelectionLocked(entry)) { + throw new ModelSelectionLockedError(MODEL_SELECTION_LOCKED_RESET_MESSAGE); + } const now = Date.now(); const isThread = resolveThreadFlag({ sessionKey, @@ -589,7 +642,10 @@ async function initSessionStateAttemptLocked( // resume signal is allowed to suppress configured idle/daily rollover. const reconnectResumeRequested = params.resumeRequestedSession === true && requestedCurrentSession; - const skipImplicitExpiry = hasProviderOwnedSession(entry) && resetPolicy.configured !== true; + // Implicit expiry must preserve the same identity for model-locked native sessions too. + const lockedModelSelection = isModelSelectionLocked(entry); + const skipImplicitExpiry = + lockedModelSelection || (hasProviderOwnedSession(entry) && resetPolicy.configured !== true); const lifecycleTimestamps = resolveSessionLifecycleTimestamps({ entry, agentId, @@ -638,6 +694,7 @@ async function initSessionStateAttemptLocked( (entryFreshness?.fresh ?? false) && isRecoverableTerminalSessionStatus(entry?.status); const freshEntry = + (lockedModelSelection && canReuseExistingEntry) || (isSystemEvent && canReuseExistingEntry) || (((reconnectResumeRequested && canReuseExistingEntry) || recoverTerminalVisibleEntry || diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts index 263ac1689c95..395a87c0d2cb 100644 --- a/src/commands/agent-via-gateway.test.ts +++ b/src/commands/agent-via-gateway.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite import type { OpenClawConfig } from "../config/config.js"; import { loggingState } from "../logging/state.js"; import type { RuntimeEnv } from "../runtime.js"; +import { AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE } from "../sessions/agent-harness-session-key.js"; import { agentCliCommand, agentViaGatewayTesting } from "./agent-via-gateway.js"; import type { agentCommand as AgentCommand } from "./agent.js"; @@ -1674,6 +1675,24 @@ describe("agentCliCommand", () => { }); }); + it("propagates harness-owned session rejection from embedded gateway fallback", async () => { + await withTempStore(async () => { + const sessionKey = "agent:main:harness:codex:supervision:missing-fallback"; + callGateway.mockRejectedValue(createGatewayClosedError()); + agentCommand.mockRejectedValueOnce(new Error(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE)); + + await expect(agentCliCommand({ message: "hi", sessionKey }, runtime)).rejects.toThrow( + AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE, + ); + + expect(callGateway).toHaveBeenCalledOnce(); + expect(agentCommand).toHaveBeenCalledOnce(); + expect( + requireRecord(requireFirstCallArg(agentCommand, "embedded agent"), "options"), + ).toMatchObject({ sessionKey }); + }); + }); + it("does not fall back to embedded agent for gateway request errors", async () => { await withTempStore(async () => { callGateway.mockRejectedValue( @@ -1959,6 +1978,23 @@ describe("agentCliCommand", () => { }); }); + it("propagates harness-owned session rejection from --local dispatch", async () => { + await withTempStore(async () => { + const sessionKey = "agent:main:harness:codex:supervision:missing-local"; + agentCommand.mockRejectedValueOnce(new Error(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE)); + + await expect( + agentCliCommand({ message: "hi", sessionKey, local: true }, runtime), + ).rejects.toThrow(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE); + + expect(callGateway).not.toHaveBeenCalled(); + expect(agentCommand).toHaveBeenCalledOnce(); + expect( + requireRecord(requireFirstCallArg(agentCommand, "embedded agent"), "options"), + ).toMatchObject({ sessionKey }); + }); + }); + it("scopes legacy explicit session keys before local embedded runs", async () => { await withTempStore(async () => { mockLocalAgentReply(); diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 0a3fb9dfd85e..19afd157a3fd 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -28,6 +28,8 @@ import { import type { PluginProviderRegistration } from "../plugins/registry.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import type { RuntimeEnv } from "../runtime.js"; +import { AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE } from "../sessions/agent-harness-session-key.js"; +import { MODEL_SELECTION_LOCKED_MESSAGE } from "../sessions/model-overrides.js"; import { interruptSessionWorkAdmissions } from "../sessions/session-lifecycle-admission.js"; import { createDirectOutboundTestAdapter, @@ -466,6 +468,73 @@ describe("agentCommand", () => { ).rejects.toThrow("allowModelOverride must be explicitly set for ingress agent runs."); }); + it("rejects a missing harness-owned session before local CLI dispatch", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store); + + await expect( + agentCommand( + { + message: "do not squat", + sessionKey: "agent:main:harness:codex:supervision:missing-local", + }, + runtime, + ), + ).rejects.toThrow(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE); + + expect(runEmbeddedAgent).not.toHaveBeenCalled(); + }); + }); + + it("rejects a missing harness-owned session through embedded ingress", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store); + + await expect( + agentCommandFromIngress( + { + message: "do not squat", + sessionKey: "agent:main:harness:codex:supervision:missing-ingress", + allowModelOverride: false, + }, + runtime, + ), + ).rejects.toThrow(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE); + + expect(runEmbeddedAgent).not.toHaveBeenCalled(); + }); + }); + + it("continues an existing locked harness-owned session", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + const sessionKey = "agent:main:harness:openclaw:supervision:existing"; + mockConfig(home, store); + writeSessionStoreSeed(store, { + [sessionKey]: { + sessionId: "existing-harness-session", + updatedAt: Date.now(), + agentHarnessId: "openclaw", + modelSelectionLocked: true, + }, + }); + + await agentCommandFromIngress( + { + message: "continue safely", + sessionKey, + allowModelOverride: false, + }, + runtime, + ); + + expect(runEmbeddedAgent).toHaveBeenCalledOnce(); + expect(getLastEmbeddedCall()?.sessionId).toBe("existing-harness-session"); + }); + }); + it("reuses a Discord voice session after one stale-session rollover", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); @@ -1223,6 +1292,51 @@ describe("agentCommand", () => { }); }); + it("does not probe or fall back from a locked stored model", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions-locked-model.json"); + const sessionKey = "agent:main:subagent:locked-model"; + writeSessionStoreSeed(store, { + [sessionKey]: { + sessionId: "session-locked-model", + updatedAt: Date.now(), + providerOverride: "anthropic", + modelOverride: "claude-opus-4-6", + modelOverrideSource: "auto", + modelOverrideFallbackOriginProvider: "openai", + modelOverrideFallbackOriginModel: "gpt-4.1-mini", + modelSelectionLocked: true, + }, + }); + + mockConfig(home, store, { + model: { + primary: "openai/gpt-4.1-mini", + fallbacks: ["openai/gpt-5.4"], + }, + models: { + "anthropic/claude-opus-4-6": {}, + "openai/gpt-4.1-mini": {}, + "openai/gpt-5.4": {}, + }, + }); + mockModelCatalogOnce([ + { id: "claude-opus-4-6", name: "Opus", provider: "anthropic" }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, + { id: "gpt-5.4", name: "GPT-5.4", provider: "openai" }, + ]); + vi.mocked(runEmbeddedAgent).mockRejectedValueOnce( + Object.assign(new Error("rate limited"), { status: 429 }), + ); + + await expect(runAgentWithSessionKey(sessionKey)).rejects.toThrow("rate limited"); + const attempts = vi + .mocked(runEmbeddedAgent) + .mock.calls.map((call) => ({ provider: call[0]?.provider, model: call[0]?.model })); + expect(attempts).toEqual([{ provider: "anthropic", model: "claude-opus-4-6" }]); + }); + }); + it("clears legacy auto session model overrides without origin metadata", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions-legacy-auto-override.json"); @@ -1279,6 +1393,67 @@ describe("agentCommand", () => { }); }); + it("does not repair locked legacy auto session model overrides", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions-locked-legacy-auto-override.json"); + writeSessionStoreSeed(store, { + "agent:main:subagent:locked-legacy-auto": { + sessionId: "session-locked-legacy-auto", + updatedAt: Date.now(), + providerOverride: "anthropic", + modelOverride: "claude-opus-4-6", + modelOverrideSource: "auto", + modelSelectionLocked: true, + }, + }); + + mockConfig(home, store, { + model: { + primary: "openai/gpt-4.1-mini", + fallbacks: ["openai/gpt-5.4"], + }, + models: { + "anthropic/claude-opus-4-6": {}, + "openai/gpt-4.1-mini": {}, + "openai/gpt-5.4": {}, + }, + }); + + mockModelCatalogOnce([ + { id: "claude-opus-4-6", name: "Opus", provider: "anthropic" }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, + { id: "gpt-5.4", name: "GPT-5.4", provider: "openai" }, + ]); + + await expect( + agentCommand( + { + message: "hi", + sessionKey: "agent:main:subagent:locked-legacy-auto", + }, + runtime, + ), + ).rejects.toMatchObject({ + name: "ModelSelectionLockedError", + message: MODEL_SELECTION_LOCKED_MESSAGE, + }); + + expect(runEmbeddedAgent).not.toHaveBeenCalled(); + const persisted = readSessionStore<{ + providerOverride?: string; + modelOverride?: string; + modelOverrideSource?: string; + modelSelectionLocked?: boolean; + }>(store)["agent:main:subagent:locked-legacy-auto"]; + expect(persisted).toMatchObject({ + providerOverride: "anthropic", + modelOverride: "claude-opus-4-6", + modelOverrideSource: "auto", + modelSelectionLocked: true, + }); + }); + }); + it("does not use fallback list for user session model overrides", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions-user-override.json"); @@ -1384,6 +1559,93 @@ describe("agentCommand", () => { }); }); + it("rejects a locked disallowed stored override without clearing it", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions-locked-disallowed-override.json"); + const sessionKey = "agent:main:subagent:locked-disallowed"; + writeSessionStoreSeed(store, { + [sessionKey]: { + sessionId: "session-locked-disallowed", + updatedAt: Date.now(), + providerOverride: "anthropic", + modelOverride: "claude-opus-4-6", + modelOverrideSource: "user", + modelSelectionLocked: true, + }, + }); + + mockConfig(home, store, { + model: { primary: "openai/gpt-4.1-mini" }, + models: { + "openai/gpt-4.1-mini": {}, + }, + }); + mockModelCatalogOnce([ + { id: "claude-opus-4-6", name: "Opus", provider: "anthropic" }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, + ]); + + await expect(runAgentWithSessionKey(sessionKey)).rejects.toMatchObject({ + name: "ModelSelectionLockedError", + message: MODEL_SELECTION_LOCKED_MESSAGE, + }); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); + expect( + readSessionStore<{ + providerOverride?: string; + modelOverride?: string; + modelOverrideSource?: string; + modelSelectionLocked?: boolean; + }>(store)[sessionKey], + ).toMatchObject({ + providerOverride: "anthropic", + modelOverride: "claude-opus-4-6", + modelOverrideSource: "user", + modelSelectionLocked: true, + }); + }); + }); + + it("rejects one-off model overrides for locked sessions", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions-locked-one-off-override.json"); + const sessionKey = "agent:main:subagent:locked-one-off"; + writeSessionStoreSeed(store, { + [sessionKey]: { + sessionId: "session-locked-one-off", + updatedAt: Date.now(), + providerOverride: "anthropic", + modelOverride: "claude-opus-4-6", + modelOverrideSource: "user", + modelSelectionLocked: true, + }, + }); + mockConfig(home, store, { + model: { primary: "anthropic/claude-opus-4-6" }, + models: { + "anthropic/claude-opus-4-6": {}, + "openai/gpt-4.1-mini": {}, + }, + }); + + await expect( + agentCommand( + { + message: "hi", + sessionKey, + model: "openai/gpt-4.1-mini", + allowModelOverride: true, + }, + runtime, + ), + ).rejects.toMatchObject({ + name: "ModelSelectionLockedError", + message: MODEL_SELECTION_LOCKED_MESSAGE, + }); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); + }); + }); + it("handles one-off provider/model overrides and validates override values", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); diff --git a/src/commands/codex-runtime-plugin-install.test.ts b/src/commands/codex-runtime-plugin-install.test.ts index 4bc5e1b3cedf..a48957b2fac0 100644 --- a/src/commands/codex-runtime-plugin-install.test.ts +++ b/src/commands/codex-runtime-plugin-install.test.ts @@ -31,11 +31,9 @@ vi.mock("./doctor/shared/missing-configured-plugin-install.js", () => ({ vi.mock("../plugins/installed-plugin-index-records.js", () => ({ loadInstalledPluginIndexInstallRecords: mocks.loadInstalledPluginIndexInstallRecords, })); - vi.mock("./onboarding-plugin-install.js", () => ({ ensureOnboardingPluginInstalled: mocks.ensureOnboardingPluginInstalled, })); - describe("Codex runtime plugin install repair", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/commands/codex-runtime-plugin-install.ts b/src/commands/codex-runtime-plugin-install.ts index 6d7d144d110d..e984dcf2adb1 100644 --- a/src/commands/codex-runtime-plugin-install.ts +++ b/src/commands/codex-runtime-plugin-install.ts @@ -22,5 +22,11 @@ const codexRuntimePluginInstall = createRuntimePluginModelSelectionHelpers({ }), }); +const codexSupervisionPluginInstall = createRuntimePluginModelSelectionHelpers({ + descriptor: CODEX_RUNTIME_PLUGIN_DESCRIPTOR, + shouldEnsure: () => true, +}); + export const ensureCodexRuntimePluginForModelSelection = codexRuntimePluginInstall.ensure; export const repairCodexRuntimePluginInstallForModelSelection = codexRuntimePluginInstall.repair; +export const ensureCodexRuntimePluginForSupervision = codexSupervisionPluginInstall.ensure; diff --git a/src/commands/doctor-session-snapshots.test.ts b/src/commands/doctor-session-snapshots.test.ts index f41f25a83b3a..5985f5d949a9 100644 --- a/src/commands/doctor-session-snapshots.test.ts +++ b/src/commands/doctor-session-snapshots.test.ts @@ -4,17 +4,35 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { saveSessionStore } from "../config/sessions/store.js"; +import { + clearSessionStoreCacheForTest, + saveSessionStore, + updateSessionStore, +} from "../config/sessions/store.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { __testing as openClawRootTesting } from "../infra/openclaw-root.js"; +import { AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE } from "../sessions/agent-harness-session-key.js"; import type { Skill } from "../skills/loading/skill-contract.js"; const note = vi.hoisted(() => vi.fn()); +const atomicWriteControl = vi.hoisted(() => ({ + beforeWrite: undefined as undefined | ((filePath: string) => Promise), +})); vi.mock("../../packages/terminal-core/src/note.js", () => ({ note, })); +vi.mock("../infra/json-files.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + writeTextAtomic: async (...args: Parameters) => { + await atomicWriteControl.beforeWrite?.(args[0]); + return await actual.writeTextAtomic(...args); + }, + }; +}); import { detectSessionSnapshotHealthIssues, @@ -89,6 +107,11 @@ function readMainSkillsSnapshot(raw: string): NonNullable { + atomicWriteControl.beforeWrite = undefined; + clearSessionStoreCacheForTest(); +}); + describe("doctor session snapshot stale runtime metadata", () => { let root = ""; let bundledSkillsDir = ""; @@ -628,6 +651,121 @@ describe("doctor session snapshot repair (shouldRepair)", () => { expect(message).toContain("Repaired"); }); + it("preserves a supervised session added while the repair backup is pending", async () => { + const stalePath = path.join( + root, + "old-runtime", + "node_modules", + "openclaw", + "skills", + "doctor", + "SKILL.md", + ); + const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json"); + await writeSessionStore(storePath, { + "agent:main": sessionEntry({ + skillsSnapshot: { + prompt: skillPrompt(stalePath), + skills: [{ name: "doctor" }], + }, + }), + }); + + let markBackupStarted!: () => void; + const backupStarted = new Promise((resolve) => { + markBackupStarted = resolve; + }); + let releaseBackup!: () => void; + const backupReleased = new Promise((resolve) => { + releaseBackup = resolve; + }); + let blockedBackup = false; + atomicWriteControl.beforeWrite = async (filePath) => { + if (blockedBackup || !filePath.startsWith(`${storePath}.bak.`)) { + return; + } + blockedBackup = true; + markBackupStarted(); + await backupReleased; + }; + + const repair = noteSessionSnapshotHealth({ + storePaths: [storePath], + bundledSkillsDir, + shouldRepair: true, + }); + await backupStarted; + + const supervisedKey = "agent:main:harness:codex:supervision:concurrent"; + const concurrentWrite = updateSessionStore( + storePath, + (store) => { + store[supervisedKey] = sessionEntry({ + sessionId: "supervised-session", + agentHarnessId: "codex", + modelSelectionLocked: true, + }); + }, + { requireWriteSuccess: true, skipMaintenance: true }, + ); + releaseBackup(); + await Promise.all([repair, concurrentWrite]); + + const raw = await fs.readFile(storePath, "utf-8"); + const parsed = JSON.parse(raw) as Record; + expect(parsed[supervisedKey]).toMatchObject({ + sessionId: "supervised-session", + agentHarnessId: "codex", + modelSelectionLocked: true, + }); + expect(readMainSkillsSnapshot(raw).prompt).not.toContain(stalePath); + }); + + it("rejects repair when the current store contains an invalid reserved session", async () => { + const stalePath = path.join( + root, + "old-runtime", + "node_modules", + "openclaw", + "skills", + "doctor", + "SKILL.md", + ); + const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json"); + const invalidKey = "agent:main:harness:codex:supervision:invalid"; + await writeSessionStore(storePath, { + "agent:main": sessionEntry({ + skillsSnapshot: { + prompt: skillPrompt(stalePath), + skills: [{ name: "doctor" }], + }, + }), + [invalidKey]: sessionEntry({ + sessionId: "invalid-supervised-session", + agentHarnessId: "other", + modelSelectionLocked: true, + }), + }); + const rawBefore = await fs.readFile(storePath, "utf-8"); + + await noteSessionSnapshotHealth({ + storePaths: [storePath], + bundledSkillsDir, + shouldRepair: true, + }); + + expect(await fs.readFile(storePath, "utf-8")).toBe(rawBefore); + expect(note.mock.calls.some(([message]) => String(message).includes("Failed to repair"))).toBe( + true, + ); + expect( + note.mock.calls.some(([message]) => + String(message).includes(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE), + ), + ).toBe(true); + expect(note.mock.calls.some(([message]) => String(message).includes("Repaired"))).toBe(false); + }); + it("repairs stale promptRef blob paths", async () => { const stalePath = path.join( root, @@ -671,7 +809,7 @@ describe("doctor session snapshot repair (shouldRepair)", () => { expect(message).toContain("Repaired"); }); - it("repairs stale resolvedSkills filePath and baseDir", async () => { + it("drops stale legacy resolvedSkills cache entries", async () => { const stalePath = path.join( root, "old-runtime", @@ -699,20 +837,14 @@ describe("doctor session snapshot repair (shouldRepair)", () => { }); const raw = await fs.readFile(storePath, "utf-8"); - const expectedBaseDir = path.dirname(path.join(bundledSkillsDir, "doctor", "SKILL.md")); - const expectedPath = path.join(bundledSkillsDir, "doctor", "SKILL.md"); const snapshot = readMainSkillsSnapshot(raw); - const skill = snapshot.resolvedSkills?.[0]; - expect(skill?.filePath).toBe(expectedPath); - expect(skill?.baseDir).toBe(expectedBaseDir); - expect(skill?.sourceInfo.path).toBe(expectedPath); - expect(skill?.sourceInfo.baseDir).toBe(expectedBaseDir); + expect(snapshot.resolvedSkills).toBeUndefined(); expect(note).toHaveBeenCalledTimes(1); const [message] = note.mock.calls[0] as [string, string]; expect(message).toContain("Repaired"); }); - it("repairs stale resolvedSkills sourceInfo paths after top-level fields are current", async () => { + it("drops resolvedSkills cache entries with stale sourceInfo paths", async () => { const stalePath = path.join( root, "old-runtime", @@ -746,11 +878,7 @@ describe("doctor session snapshot repair (shouldRepair)", () => { const raw = await fs.readFile(storePath, "utf-8"); const snapshot = readMainSkillsSnapshot(raw); - const repairedSkill = snapshot.resolvedSkills?.[0]; - expect(repairedSkill?.filePath).toBe(currentPath); - expect(repairedSkill?.baseDir).toBe(path.dirname(currentPath)); - expect(repairedSkill?.sourceInfo.path).toBe(currentPath); - expect(repairedSkill?.sourceInfo.baseDir).toBe(path.dirname(currentPath)); + expect(snapshot.resolvedSkills).toBeUndefined(); expect(note).toHaveBeenCalledTimes(1); const [message] = note.mock.calls[0] as [string, string]; expect(message).toContain("Repaired"); diff --git a/src/commands/doctor-session-snapshots.ts b/src/commands/doctor-session-snapshots.ts index 1c3073f24691..bbe492e3f8ba 100644 --- a/src/commands/doctor-session-snapshots.ts +++ b/src/commands/doctor-session-snapshots.ts @@ -1,14 +1,11 @@ /** Doctor repair for stale runtime snapshot paths cached in session stores. */ -import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { note } from "../../packages/terminal-core/src/note.js"; import { resolveStateDir } from "../config/paths.js"; -import { - hydrateSessionStoreSkillPromptRefs, - resolveSessionSkillPromptBlobPath, -} from "../config/sessions/skill-prompt-blobs.js"; +import { hydrateSessionStoreSkillPromptRefs } from "../config/sessions/skill-prompt-blobs.js"; +import { updateSessionStore } from "../config/sessions/store.js"; import { resolveAllAgentSessionStoreTargetsSync } from "../config/sessions/targets.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -443,6 +440,71 @@ function replaceStalePathsInText(text: string, finding: StaleSessionSnapshotPath return result; } +function repairFreshSessionSnapshotPaths(params: { + store: Record; + rawStore: Record; + findings: readonly StaleSessionSnapshotPathFinding[]; +}): number { + let replacements = 0; + for (const finding of params.findings) { + // Canonical loading intentionally strips resolvedSkills. Count the stale legacy cache once; + // saving through the writer removes that non-persistent cache from the durable entry. + if (finding.field === "skillsSnapshot.resolvedSkills") { + const rawSession = params.rawStore[finding.sessionKey]; + const rawSnapshot = isRecord(rawSession) ? rawSession.skillsSnapshot : undefined; + if (isRecord(rawSnapshot) && Array.isArray(rawSnapshot.resolvedSkills)) { + replacements += 1; + } + continue; + } + + const session = params.store[finding.sessionKey] as Record | undefined; + if (!isRecord(session)) { + continue; + } + const jsonEscaped = JSON.stringify(finding.cachedPath).slice(1, -1); + const jsonEscapedExpected = JSON.stringify(finding.expectedPath).slice(1, -1); + + if (finding.field === "skillsSnapshot.prompt") { + const snapshot = session.skillsSnapshot; + if (!isRecord(snapshot) || typeof snapshot.prompt !== "string") { + continue; + } + const prompt = replaceStalePathsInText(snapshot.prompt, finding); + if (prompt !== snapshot.prompt) { + snapshot.prompt = prompt; + replacements += 1; + } + continue; + } + + const report = session.systemPromptReport; + if (!isRecord(report) || !Array.isArray(report.injectedWorkspaceFiles)) { + continue; + } + for (const entry of report.injectedWorkspaceFiles) { + if (!isRecord(entry) || typeof entry.path !== "string") { + continue; + } + let entryPath = entry.path; + const original = entryPath; + for (const { cached, expected } of [ + { cached: jsonEscaped, expected: jsonEscapedExpected }, + { cached: finding.cachedPath, expected: finding.expectedPath }, + ]) { + if (entryPath.includes(cached)) { + entryPath = entryPath.replaceAll(cached, expected); + } + } + if (entryPath !== original) { + entry.path = entryPath; + replacements += 1; + } + } + } + return replacements; +} + /** Reports and optionally repairs stale bundled skill paths in session snapshot metadata. */ export async function noteSessionSnapshotHealth(params?: { storePaths?: string[]; @@ -502,155 +564,34 @@ export async function noteSessionSnapshotHealth(params?: { for (const [storePath, findings] of findingsByStore) { try { - const raw = fs.readFileSync(storePath, "utf-8"); - const sessions = JSON.parse(raw) as Record>; - let modified = false; - - let storeCount = 0; - for (const finding of findings) { - const session = sessions[finding.sessionKey]; - if (!isRecord(session)) { - continue; - } - - const jsonEscaped = JSON.stringify(finding.cachedPath).slice(1, -1); - const jsonEscapedExpected = JSON.stringify(finding.expectedPath).slice(1, -1); - - if (finding.field === "skillsSnapshot.prompt") { - const snapshot = session.skillsSnapshot; - if (!isRecord(snapshot)) { - continue; + const repairResult = await updateSessionStore( + storePath, + async (store) => { + const raw = fs.readFileSync(storePath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + const rawStore = isRecord(parsed) ? parsed : {}; + const replacements = repairFreshSessionSnapshotPaths({ + store, + rawStore, + findings, + }); + if (replacements > 0) { + // The backup belongs inside the writer lane so it matches the store revision that + // the canonical writer is about to replace. + const backupPath = `${storePath}.bak.${Date.now()}`; + await writeTextAtomic(backupPath, raw, { mode: 0o600 }); } - const promptRef = isRecord(snapshot.promptRef) ? snapshot.promptRef : undefined; + return { replacements }; + }, + { + requireWriteSuccess: true, + skipMaintenance: true, + skipSaveWhenResult: (result) => result.replacements === 0, + }, + ); - if (promptRef && typeof promptRef.hash === "string") { - // Blob-backed prompt: read blob, replace paths, write new blob - const blobPath = resolveSessionSkillPromptBlobPath(storePath, promptRef.hash); - if (blobPath && fs.existsSync(blobPath)) { - const blobContent = fs.readFileSync(blobPath, "utf-8"); - const newBlob = replaceStalePathsInText(blobContent, finding); - if (newBlob !== blobContent) { - const newHash = crypto.createHash("sha256").update(newBlob, "utf8").digest("hex"); - const newBytes = Buffer.byteLength(newBlob, "utf8"); - const newBlobPath = resolveSessionSkillPromptBlobPath(storePath, newHash); - if (newBlobPath) { - await fs.promises.mkdir(path.dirname(newBlobPath), { recursive: true }); - await writeTextAtomic(newBlobPath, newBlob, { - durable: false, - mode: 0o600, - tempPrefix: path.basename(newBlobPath), - }); - (snapshot.promptRef as Record).hash = newHash; - (snapshot.promptRef as Record).bytes = newBytes; - storeCount++; - modified = true; - } - } - } - } else if (typeof snapshot.prompt === "string") { - // Inline prompt: replace in raw JSON - const newPrompt = replaceStalePathsInText(snapshot.prompt, finding); - if (newPrompt !== snapshot.prompt) { - snapshot.prompt = newPrompt; - storeCount++; - modified = true; - } - } - } else if (finding.field === "skillsSnapshot.resolvedSkills") { - const snapshot = session.skillsSnapshot; - if (!isRecord(snapshot) || !Array.isArray(snapshot.resolvedSkills)) { - continue; - } - for (const entry of snapshot.resolvedSkills) { - if (!isRecord(entry)) { - continue; - } - const replaceResolvedSkillField = ( - target: Record, - field: string, - ) => { - if (typeof target[field] !== "string") { - return; - } - let value = target[field]; - const original = value; - const candidates = [ - { cached: jsonEscaped, expected: jsonEscapedExpected }, - { cached: finding.cachedPath, expected: finding.expectedPath }, - ]; - if (field === "baseDir") { - for (const suffix of ["/SKILL.md", "\\SKILL.md"]) { - if (finding.cachedPath.endsWith(suffix)) { - const cachedDir = finding.cachedPath.slice(0, -suffix.length); - const expectedDir = finding.expectedPath.slice(0, -suffix.length); - candidates.push( - { - cached: JSON.stringify(cachedDir).slice(1, -1), - expected: JSON.stringify(expectedDir).slice(1, -1), - }, - { cached: cachedDir, expected: expectedDir }, - ); - } - } - } - for (const { cached, expected } of candidates) { - if (value.includes(cached)) { - value = value.replaceAll(cached, expected); - } - } - if (value !== original) { - target[field] = value; - storeCount++; - modified = true; - } - }; - - for (const field of ["filePath", "baseDir"]) { - replaceResolvedSkillField(entry, field); - } - if (isRecord(entry.sourceInfo)) { - for (const field of ["path", "baseDir"]) { - replaceResolvedSkillField(entry.sourceInfo, field); - } - } - } - } else if (finding.field === "systemPromptReport.injectedWorkspaceFiles") { - const report = session.systemPromptReport; - if (!isRecord(report) || !Array.isArray(report.injectedWorkspaceFiles)) { - continue; - } - for (const entry of report.injectedWorkspaceFiles) { - if (!isRecord(entry) || typeof entry.path !== "string") { - continue; - } - let entryPath = entry.path; - const original = entryPath; - for (const { cached, expected } of [ - { cached: jsonEscaped, expected: jsonEscapedExpected }, - { cached: finding.cachedPath, expected: finding.expectedPath }, - ]) { - if (entryPath.includes(cached)) { - entryPath = entryPath.replaceAll(cached, expected); - } - } - if (entryPath !== original) { - entry.path = entryPath; - storeCount++; - modified = true; - } - } - } - } - - if (modified && storeCount > 0) { - // Create backup before writing - const backupPath = `${storePath}.bak.${Date.now()}`; - await writeTextAtomic(backupPath, raw, { mode: 0o600 }); - - // Atomic write — only modified fields changed, no hydration side effects - const fixed = JSON.stringify(sessions, null, 2); - await writeTextAtomic(storePath, fixed, { mode: 0o600 }); - totalReplacements += storeCount; + if (repairResult.replacements > 0) { + totalReplacements += repairResult.replacements; repairedStores++; // Rescan to report leftover findings diff --git a/src/commands/doctor-session-state-providers.test.ts b/src/commands/doctor-session-state-providers.test.ts index 02bc95404641..e95bbacbb14e 100644 --- a/src/commands/doctor-session-state-providers.test.ts +++ b/src/commands/doctor-session-state-providers.test.ts @@ -76,6 +76,80 @@ describe("doctor session state provider routes", () => { ).toBe(true); }); + it("skips valid locked agent-harness rows during scan and repair", async () => { + const sessionKey = "agent:main:ordinary-locked"; + const entry: Record = { + sessionId: "sess-supervised-codex", + updatedAt: 1, + modelSelectionLocked: true, + agentHarnessId: "codex", + modelProvider: "openai-codex", + model: "gpt-5.5", + providerOverride: "openai-codex", + modelOverride: "gpt-5.5", + modelOverrideSource: "auto", + cliSessionBindings: { + "codex-cli": { sessionId: "native-codex-session" }, + }, + }; + const original = structuredClone(entry); + const store = { [sessionKey]: entry }; + const route = { + defaultProvider: "github-copilot", + configuredModelRefs: ["github-copilot/gpt-5-mini"], + runtime: "openclaw", + }; + + expect( + storeMayContainPluginSessionRouteState( + store as unknown as Parameters[0], + ), + ).toBe(false); + expect( + scanSessionRouteStateOwners({ + owners: [codexOwner], + store, + routes: { [sessionKey]: route }, + }), + ).toEqual({ repairs: [], manualReview: [] }); + expect( + applySessionRouteStateRepair({ + sessionKey, + entry, + repair: { + key: sessionKey, + ownerId: "codex", + ownerLabel: "Codex", + reasons: ["auto model override", "pinned runtime", "runtime model state"], + pinnedRuntimeKeys: ["agentHarnessId"], + cliSessionKeys: ["codex-cli"], + }, + now: 123, + }), + ).toBe(false); + + const warnings: string[] = []; + const changes: string[] = []; + const prompter: Parameters[0]["prompter"] = { + confirmRuntimeRepair: vi.fn(async () => true), + note: vi.fn(), + }; + await runPluginSessionStateDoctorRepairs({ + cfg: {}, + store: store as unknown as Parameters[0]["store"], + absoluteStorePath: "/tmp/nonexistent-supervised-store.json", + prompter, + env: {}, + warnings, + changes, + }); + + expect(entry).toEqual(original); + expect(warnings).toStrictEqual([]); + expect(changes).toStrictEqual([]); + expect(prompter.confirmRuntimeRepair).not.toHaveBeenCalled(); + }); + it("preserves configured provider CLI runtimes before harness policy normalization", () => { const route = resolveConfiguredDoctorSessionStateRoute({ cfg: { @@ -177,7 +251,9 @@ describe("doctor session state provider routes", () => { }, ]); - expect(applySessionRouteStateRepair({ entry, repair: scan.repairs[0], now: 123 })).toBe(true); + expect( + applySessionRouteStateRepair({ sessionKey, entry, repair: scan.repairs[0], now: 123 }), + ).toBe(true); expect(entry.sessionId).toBe("sess-stale-codex"); expect(entry.updatedAt).toBe(123); expect(entry.cliSessionBindings).toStrictEqual({ @@ -278,7 +354,9 @@ describe("doctor session state provider routes", () => { }, ]); - expect(applySessionRouteStateRepair({ entry, repair: scan.repairs[0], now: 123 })).toBe(true); + expect( + applySessionRouteStateRepair({ sessionKey, entry, repair: scan.repairs[0], now: 123 }), + ).toBe(true); expect(entry.updatedAt).toBe(123); expect(entry.providerOverride).toBe("openai-codex"); expect(entry.modelOverride).toBe("gpt-5.4"); @@ -358,7 +436,9 @@ describe("doctor session state provider routes", () => { }, ]); - expect(applySessionRouteStateRepair({ entry, repair: scan.repairs[0], now: 123 })).toBe(true); + expect( + applySessionRouteStateRepair({ sessionKey, entry, repair: scan.repairs[0], now: 123 }), + ).toBe(true); expect(entry.sessionId).toBe("sess-stale-claude-cli"); expect(entry.updatedAt).toBe(123); expect(entry.agentRuntimeOverride).toBeUndefined(); @@ -437,7 +517,9 @@ describe("doctor session state provider routes", () => { }, ]); - expect(applySessionRouteStateRepair({ entry, repair: scan.repairs[0], now: 123 })).toBe(true); + expect( + applySessionRouteStateRepair({ sessionKey, entry, repair: scan.repairs[0], now: 123 }), + ).toBe(true); expect(entry.updatedAt).toBe(123); expect(entry.agentRuntimeOverride).toBeUndefined(); }); @@ -475,7 +557,9 @@ describe("doctor session state provider routes", () => { }, ]); - expect(applySessionRouteStateRepair({ entry, repair: scan.repairs[0], now: 123 })).toBe(true); + expect( + applySessionRouteStateRepair({ sessionKey, entry, repair: scan.repairs[0], now: 123 }), + ).toBe(true); expect(entry.updatedAt).toBe(123); expect(entry.agentHarnessId).toBeUndefined(); expect(entry.agentRuntimeOverride).toBe("claude-cli"); diff --git a/src/commands/doctor-session-state-providers.ts b/src/commands/doctor-session-state-providers.ts index 2f2032e37b8f..8999696844d7 100644 --- a/src/commands/doctor-session-state-providers.ts +++ b/src/commands/doctor-session-state-providers.ts @@ -19,6 +19,7 @@ import { updateSessionStore } from "../config/sessions/store.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { listPluginDoctorSessionRouteStateOwners } from "../plugins/doctor-contract-registry.js"; import type { DoctorSessionRouteStateOwner } from "../plugins/doctor-session-route-state-owner-types.js"; +import { isValidAgentHarnessSessionStoreEntry } from "../sessions/agent-harness-session-key.js"; import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; type DoctorPrompterLike = { @@ -105,7 +106,10 @@ function resolvePluginDoctorSessionRouteStateOwners(params: { return listPluginDoctorSessionRouteStateOwners({ config: params.cfg, env: params.env }); } -function entryMayContainPluginSessionRouteState(entry: SessionEntry): boolean { +function entryMayContainPluginSessionRouteState(sessionKey: string, entry: SessionEntry): boolean { + if (isValidAgentHarnessSessionStoreEntry(sessionKey, entry)) { + return false; + } const record = entry as unknown as Record; return ( normalizeString(record.providerOverride) !== undefined || @@ -127,7 +131,9 @@ function entryMayContainPluginSessionRouteState(entry: SessionEntry): boolean { export function storeMayContainPluginSessionRouteState( store: Record, ): boolean { - return Object.values(store).some((entry) => entryMayContainPluginSessionRouteState(entry)); + return Object.entries(store).some(([sessionKey, entry]) => + entryMayContainPluginSessionRouteState(sessionKey, entry), + ); } type DoctorSessionRouteState = { @@ -340,7 +346,7 @@ export function scanSessionRouteStateOwners(params: { const repairs: DoctorSessionRouteStateRepair[] = []; const manualReview: DoctorSessionRouteStateManualReview[] = []; for (const [key, entry] of Object.entries(params.store)) { - if (!entry || typeof entry !== "object") { + if (!entry || typeof entry !== "object" || isValidAgentHarnessSessionStoreEntry(key, entry)) { continue; } for (const owner of params.owners) { @@ -392,10 +398,15 @@ function clearRecordKeys( /** Clears stale plugin-owned routing fields from a session entry and refreshes updatedAt. */ export function applySessionRouteStateRepair(params: { + sessionKey: string; entry: Record; repair: DoctorSessionRouteStateRepair; now: number; }): boolean { + // Revalidate at mutation time: the harness may have claimed and locked this row after the scan. + if (isValidAgentHarnessSessionStoreEntry(params.sessionKey, params.entry)) { + return false; + } let changed = false; const clear = (key: string) => { changed = clearEntryKey(params.entry, key) || changed; @@ -481,7 +492,7 @@ export async function runPluginSessionStateDoctorRepairs(params: { if (!entry || typeof entry !== "object") { continue; } - if (!entryMayContainPluginSessionRouteState(entry)) { + if (!entryMayContainPluginSessionRouteState(sessionKey, entry)) { continue; } scanStore[sessionKey] = entry as unknown as Record; @@ -528,7 +539,12 @@ export async function runPluginSessionStateDoctorRepairs(params: { const current = currentMutableStore[key]; if ( current && - applySessionRouteStateRepair({ entry: current, repair, now: repairedAt }) + applySessionRouteStateRepair({ + sessionKey: key, + entry: current, + repair, + now: repairedAt, + }) ) { repaired += 1; } diff --git a/src/commands/doctor/shared/codex-route-warnings.test.ts b/src/commands/doctor/shared/codex-route-warnings.test.ts index 76c9003474fd..b91780210881 100644 --- a/src/commands/doctor/shared/codex-route-warnings.test.ts +++ b/src/commands/doctor/shared/codex-route-warnings.test.ts @@ -3798,6 +3798,48 @@ describe("collectCodexRouteWarnings", () => { expect(store.other.agentHarnessId).toBe("codex"); }); + it("skips valid locked agent-harness rows while repairing ordinary legacy routes", () => { + const supervisedKey = "agent:main:harness:codex:supervision:abc123"; + const ordinaryLockedKey = "agent:main:ordinary-locked"; + const lockedEntry: SessionEntry = { + sessionId: "s-supervised", + updatedAt: 1, + modelSelectionLocked: true, + agentHarnessId: "codex", + agentRuntimeOverride: "codex", + modelProvider: "openai-codex", + model: "gpt-5.5", + providerOverride: "openai-codex", + modelOverride: "openai-codex/gpt-5.4", + fallbackNoticeSelectedModel: "openai-codex/gpt-5.5", + }; + const store: Record = { + [supervisedKey]: lockedEntry, + [ordinaryLockedKey]: { ...lockedEntry, sessionId: "s-ordinary-locked" }, + ordinary: { + sessionId: "s-ordinary", + updatedAt: 2, + modelProvider: "openai-codex", + model: "gpt-5.5", + agentHarnessId: "codex", + }, + }; + const supervised = structuredClone(store[supervisedKey]); + const ordinaryLocked = structuredClone(store[ordinaryLockedKey]); + + const result = repairCodexSessionStoreRoutes({ store, now: 123 }); + + expect(result).toEqual({ changed: true, sessionKeys: ["ordinary"] }); + expect(store[supervisedKey]).toEqual(supervised); + expect(store[ordinaryLockedKey]).toEqual(ordinaryLocked); + expect(store.ordinary).toMatchObject({ + updatedAt: 123, + modelProvider: "openai", + model: "gpt-5.5", + }); + expect(store.ordinary.agentHarnessId).toBeUndefined(); + }); + it("preserves explicit OpenClaw runtime pins while repairing legacy session routes", () => { const store: Record = { main: { diff --git a/src/commands/doctor/shared/codex-route-warnings.ts b/src/commands/doctor/shared/codex-route-warnings.ts index 7cecd1583ae9..64b1afbcd726 100644 --- a/src/commands/doctor/shared/codex-route-warnings.ts +++ b/src/commands/doctor/shared/codex-route-warnings.ts @@ -18,6 +18,7 @@ import type { AgentRuntimePolicyConfig } from "../../../config/types.agents-shar import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { detectWindowsSpawnCommandInlineArgs } from "../../../plugin-sdk/windows-spawn.js"; import { normalizeAgentId } from "../../../routing/session-key.js"; +import { isValidAgentHarnessSessionStoreEntry } from "../../../sessions/agent-harness-session-key.js"; type CodexRouteHit = { path: string; @@ -2969,7 +2970,7 @@ export function repairCodexSessionStoreRoutes(params: { const now = params.now ?? Date.now(); const sessionKeys: string[] = []; for (const [sessionKey, entry] of Object.entries(params.store)) { - if (!entry) { + if (!entry || isValidAgentHarnessSessionStoreEntry(sessionKey, entry)) { continue; } const changedRuntimeModelRoute = rewriteSessionModelPair({ @@ -3002,7 +3003,7 @@ export function repairCodexSessionStoreRoutes(params: { function scanCodexSessionStoreRoutes(store: Record): string[] { return Object.entries(store).flatMap(([sessionKey, entry]) => { - if (!entry) { + if (!entry || isValidAgentHarnessSessionStoreEntry(sessionKey, entry)) { return []; } const hasLegacyRoute = diff --git a/src/commands/doctor/shared/deprecation-compat.test.ts b/src/commands/doctor/shared/deprecation-compat.test.ts index c3f5c82dffd8..27068d69b86c 100644 --- a/src/commands/doctor/shared/deprecation-compat.test.ts +++ b/src/commands/doctor/shared/deprecation-compat.test.ts @@ -16,6 +16,7 @@ const requiredDoctorCompatCodes = [ "doctor-plugin-install-config-ledger", "doctor-bundled-plugin-load-paths", "doctor-bundled-provider-discovery-allowlist", + "doctor-codex-supervisor-plugin-config", "doctor-message-queue-steering-modes", "doctor-web-search-plugin-config", "doctor-web-fetch-plugin-config", diff --git a/src/commands/doctor/shared/deprecation-compat.ts b/src/commands/doctor/shared/deprecation-compat.ts index a355409255fe..c835e4bc2bb5 100644 --- a/src/commands/doctor/shared/deprecation-compat.ts +++ b/src/commands/doctor/shared/deprecation-compat.ts @@ -283,6 +283,21 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ notes: "Doctor preserves the shipped upgrade path only; runtime compatibility should stay behind explicit bundledDiscovery config.", }), + deprecatedCompatRecord({ + code: "doctor-codex-supervisor-plugin-config", + owner: "plugin", + introduced: "2026-05-29", + deprecated: "2026-07-09", + warningStarts: "2026-07-09", + removeAfter: "2026-10-09", + source: "plugins.entries.codex-supervisor and codex-supervisor plugin policy references", + migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts", + replacement: "plugins.entries.codex.config.supervision", + docsPath: "/plugins/codex-supervision", + tests: ["src/commands/doctor/shared/legacy-config-migrate.test.ts"], + notes: + "The core bootstrap migration must remain available when the external Codex plugin is not installed yet.", + }), deprecatedCompatRecord({ code: "doctor-web-search-plugin-config", owner: "provider", diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index 622b0c95a49c..f912f71e443e 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -1974,6 +1974,245 @@ describe("legacy migrate x_search auth", () => { }); }); +describe("legacy Codex Supervisor config migrate", () => { + it("normalizes padded legacy plugin ids during migration", () => { + const res = migrateLegacyConfigForTest({ + plugins: { + allow: [" CODEX-SUPERVISOR ", "codex"], + deny: [" codex-supervisor "], + entries: { + " CODEX ": { + config: { appServer: { transport: "stdio" } }, + }, + " CODEX-SUPERVISOR ": { + enabled: true, + config: { allowWriteControls: true }, + }, + }, + }, + }); + + expect(res.config?.plugins?.allow).toEqual(["codex"]); + expect(res.config?.plugins?.deny).toEqual([]); + expect(res.config?.plugins?.entries).not.toHaveProperty(" CODEX-SUPERVISOR "); + expect(res.config?.plugins?.entries).not.toHaveProperty("codex"); + expect(res.config?.plugins?.entries?.[" CODEX "]).toEqual({ + config: { + appServer: { transport: "stdio" }, + supervision: { + enabled: false, + allowWriteControls: true, + }, + }, + }); + }); + + it("moves active Supervisor config into Codex supervision and rewrites the allowlist", () => { + const raw = { + plugins: { + allow: ["telegram", "codex-supervisor", "codex"], + entries: { + "codex-supervisor": { + enabled: true, + config: { + endpoints: [ + { + id: "local", + transport: "stdio-proxy", + command: "codex", + }, + ], + allowRawTranscripts: true, + allowWriteControls: true, + }, + hooks: { enabled: true }, + }, + }, + }, + }; + + expect(findLegacyConfigIssues(raw).map((issue) => issue.path)).toContain("plugins"); + + const res = migrateLegacyConfigForTest(raw); + + expect(res.config?.plugins?.allow).toEqual(["telegram", "codex"]); + expect(res.config?.plugins?.entries?.codex).toEqual({ + enabled: true, + config: { + supervision: { + enabled: true, + endpoints: [ + { + id: "local", + transport: "stdio-proxy", + command: "codex", + }, + ], + allowRawTranscripts: true, + allowWriteControls: true, + }, + }, + }); + expect(res.config?.plugins?.entries?.["codex-supervisor"]).toBeUndefined(); + expect(res.changes).toContain( + "Moved plugins.entries.codex-supervisor to plugins.entries.codex.config.supervision.", + ); + expect(res.changes).toContain("Rewrote plugins.allow codex-supervisor references to codex."); + + const rerun = migrateLegacyConfigForTest(res.config); + expect(rerun).toEqual({ config: null, changes: [] }); + }); + + it.each([ + { canonicalEnabled: undefined, legacyEnabled: true, expected: true }, + { canonicalEnabled: undefined, legacyEnabled: false, expected: undefined }, + { canonicalEnabled: true, legacyEnabled: false, expected: true }, + { canonicalEnabled: false, legacyEnabled: true, expected: false }, + ])( + "only activates missing canonical Codex state for enabled supervision ($canonicalEnabled, $legacyEnabled)", + ({ canonicalEnabled, legacyEnabled, expected }) => { + const codexEntry: Record = { config: {} }; + if (canonicalEnabled !== undefined) { + codexEntry.enabled = canonicalEnabled; + } + const res = migrateLegacyConfigForTest({ + plugins: { + entries: { + codex: codexEntry, + "codex-supervisor": { enabled: legacyEnabled }, + }, + }, + }); + + expect(res.config?.plugins?.entries?.codex?.enabled).toBe(expected); + }, + ); + + it("does not disable an existing implicit Codex harness when old supervision was disabled", () => { + const res = migrateLegacyConfigForTest({ + agents: { + defaults: { + model: "openai/gpt-5.5", + }, + }, + plugins: { + entries: { + codex: { + config: { + appServer: { transport: "stdio" }, + }, + }, + "codex-supervisor": { + enabled: false, + }, + }, + }, + }); + + expect(res.config?.agents?.defaults?.model).toBe("openai/gpt-5.5"); + expect(res.config?.plugins?.entries?.codex).toEqual({ + config: { + appServer: { transport: "stdio" }, + supervision: { enabled: false }, + }, + }); + }); + + it("preserves canonical Codex values while filling missing supervision fields", () => { + const res = migrateLegacyConfigForTest({ + plugins: { + deny: ["codex-supervisor", "telegram"], + entries: { + codex: { + enabled: false, + config: { + appServer: { transport: "stdio" }, + supervision: { + enabled: true, + endpoints: [{ id: "canonical", transport: "stdio-proxy" }], + allowWriteControls: false, + }, + }, + }, + "codex-supervisor": { + enabled: true, + config: { + endpoints: [{ id: "legacy", transport: "stdio-proxy" }], + allowRawTranscripts: true, + allowWriteControls: true, + }, + }, + }, + }, + }); + + expect(res.config?.plugins?.deny).toEqual(["telegram"]); + expect(res.config?.plugins?.entries?.codex).toEqual({ + enabled: false, + config: { + appServer: { transport: "stdio" }, + supervision: { + enabled: true, + endpoints: [{ id: "canonical", transport: "stdio-proxy" }], + allowRawTranscripts: true, + allowWriteControls: false, + }, + }, + }); + expect(res.changes).toContain("Removed plugins.deny codex-supervisor references."); + }); + + it("keeps migrated supervision dormant when the old plugin was denied", () => { + const res = migrateLegacyConfigForTest({ + plugins: { + deny: ["codex-supervisor"], + entries: { + "codex-supervisor": { + enabled: true, + config: { allowWriteControls: true }, + }, + }, + }, + }); + + expect(res.config?.plugins?.deny).toEqual([]); + expect(res.config?.plugins?.entries?.codex).toEqual({ + config: { + supervision: { + enabled: false, + allowWriteControls: true, + }, + }, + }); + }); + + it("removes malformed legacy entries without creating Codex config", () => { + const res = migrateLegacyConfigForTest({ + plugins: { + entries: { + "codex-supervisor": "invalid", + }, + }, + }); + + expect(res.config?.plugins?.entries).toEqual({}); + expect(res.changes).toContain("Removed invalid plugins.entries.codex-supervisor config."); + }); + + it("repairs policy-only references without creating a Codex entry", () => { + const res = migrateLegacyConfigForTest({ + plugins: { + allow: ["codex-supervisor", "codex"], + deny: ["codex-supervisor"], + }, + }); + + expect(res.config?.plugins?.allow).toEqual(["codex"]); + expect(res.config?.plugins?.deny).toEqual([]); + expect(res.config?.plugins?.entries).toBeUndefined(); + }); +}); + describe("legacy bundled provider discovery migrate", () => { it("rewrites legacy OpenAI Codex plugin policy ids", () => { const res = migrateLegacyConfigForTest({ diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts index c76d67f07d6d..bfba4105216c 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts @@ -1,6 +1,7 @@ // Legacy provider runtime config migrations for plugin ids and bundled discovery policy. import { defineLegacyConfigMigration, + mergeMissing, type LegacyConfigMigrationSpec, type LegacyConfigRule, } from "../../../config/legacy.shared.js"; @@ -12,6 +13,12 @@ import { const LEGACY_OPENAI_CODEX_PLUGIN_ID = "openai-codex"; const OPENAI_PLUGIN_ID = "openai"; +const LEGACY_CODEX_SUPERVISOR_PLUGIN_ID = "codex-supervisor"; +const CODEX_PLUGIN_ID = "codex"; + +function normalizePluginIdForMigration(value: unknown): string | undefined { + return typeof value === "string" ? value.trim().toLowerCase() : undefined; +} const BUNDLED_DISCOVERY_COMPAT_RULE: LegacyConfigRule = { path: ["plugins", "allow"], @@ -41,7 +48,11 @@ const X_SEARCH_MODEL_RULE: LegacyConfigRule = { match: (value) => resolveLegacyXSearchModelTarget(value) !== undefined, }; -function rewritePluginIdList(value: unknown): { next: unknown; changed: boolean } { +function rewritePluginIdList( + value: unknown, + legacyPluginId: string, + replacementPluginId?: string, +): { next: unknown; changed: boolean } { if (!Array.isArray(value)) { return { next: value, changed: false }; } @@ -49,16 +60,22 @@ function rewritePluginIdList(value: unknown): { next: unknown; changed: boolean const seen = new Set(); const next: unknown[] = []; for (const entry of value) { - const replacement = entry === LEGACY_OPENAI_CODEX_PLUGIN_ID ? OPENAI_PLUGIN_ID : entry; + const matchesLegacy = normalizePluginIdForMigration(entry) === legacyPluginId; + if (matchesLegacy && replacementPluginId === undefined) { + changed = true; + continue; + } + const replacement = matchesLegacy ? replacementPluginId : entry; if (replacement !== entry) { changed = true; } if (typeof replacement === "string") { - if (seen.has(replacement)) { + const normalizedReplacement = normalizePluginIdForMigration(replacement) ?? replacement; + if (seen.has(normalizedReplacement)) { changed = true; continue; } - seen.add(replacement); + seen.add(normalizedReplacement); } next.push(replacement); } @@ -97,7 +114,11 @@ function rewriteLegacyOpenAICodexPluginPolicy(raw: Record): str } const changes: string[] = []; for (const key of ["allow", "deny"] as const) { - const rewritten = rewritePluginIdList(plugins[key]); + const rewritten = rewritePluginIdList( + plugins[key], + LEGACY_OPENAI_CODEX_PLUGIN_ID, + OPENAI_PLUGIN_ID, + ); if (rewritten.changed) { plugins[key] = rewritten.next; changes.push(`Rewrote plugins.${key} openai-codex references to openai.`); @@ -112,8 +133,130 @@ function rewriteLegacyOpenAICodexPluginPolicy(raw: Record): str return changes; } +function migrateLegacyCodexSupervisorEntry( + entries: Record, + legacySupervisorDenied: boolean, +): "migrated" | "removed-invalid" | null { + const legacyEntryKey = Object.keys(entries).find( + (key) => normalizePluginIdForMigration(key) === LEGACY_CODEX_SUPERVISOR_PLUGIN_ID, + ); + if (!legacyEntryKey) { + return null; + } + + const rawLegacyEntry = entries[legacyEntryKey]; + if (!isRecord(rawLegacyEntry)) { + delete entries[legacyEntryKey]; + return "removed-invalid"; + } + const legacyEntry = rawLegacyEntry; + const migratedEnabled = legacyEntry.enabled === true && !legacySupervisorDenied; + + const codexEntryKey = + Object.keys(entries).find((key) => normalizePluginIdForMigration(key) === CODEX_PLUGIN_ID) ?? + CODEX_PLUGIN_ID; + const rawCodexEntry = entries[codexEntryKey]; + let codexEntry: Record; + if (isRecord(rawCodexEntry)) { + codexEntry = rawCodexEntry; + } else { + codexEntry = {}; + entries[codexEntryKey] = codexEntry; + } + // Top-level false disables the Codex harness too; inactive supervision must + // stay nested while active migrated supervision explicitly activates Codex. + if (migratedEnabled && codexEntry.enabled === undefined) { + codexEntry.enabled = true; + } + + const codexConfig = isRecord(codexEntry.config) ? codexEntry.config : {}; + codexEntry.config = codexConfig; + const supervision = isRecord(codexConfig.supervision) ? codexConfig.supervision : {}; + codexConfig.supervision = supervision; + + const legacyConfig = isRecord(legacyEntry.config) ? legacyEntry.config : undefined; + const migratedSupervision: Record = { + enabled: migratedEnabled, + }; + if (Array.isArray(legacyConfig?.endpoints)) { + migratedSupervision.endpoints = legacyConfig.endpoints; + } + if (typeof legacyConfig?.allowRawTranscripts === "boolean") { + migratedSupervision.allowRawTranscripts = legacyConfig.allowRawTranscripts; + } + if (typeof legacyConfig?.allowWriteControls === "boolean") { + migratedSupervision.allowWriteControls = legacyConfig.allowWriteControls; + } + mergeMissing(supervision, migratedSupervision); + + delete entries[legacyEntryKey]; + return "migrated"; +} + +function migrateLegacyCodexSupervisorPlugin(raw: Record): string[] { + const plugins = isRecord(raw.plugins) ? raw.plugins : undefined; + if (!plugins) { + return []; + } + + const changes: string[] = []; + const legacySupervisorDenied = + Array.isArray(plugins.deny) && + plugins.deny.some( + (entry) => normalizePluginIdForMigration(entry) === LEGACY_CODEX_SUPERVISOR_PLUGIN_ID, + ); + const entries = isRecord(plugins.entries) ? plugins.entries : undefined; + const entryMigration = entries + ? migrateLegacyCodexSupervisorEntry(entries, legacySupervisorDenied) + : null; + if (entryMigration === "migrated") { + changes.push( + "Moved plugins.entries.codex-supervisor to plugins.entries.codex.config.supervision.", + ); + } else if (entryMigration === "removed-invalid") { + changes.push("Removed invalid plugins.entries.codex-supervisor config."); + } + + const rewrittenAllow = rewritePluginIdList( + plugins.allow, + LEGACY_CODEX_SUPERVISOR_PLUGIN_ID, + CODEX_PLUGIN_ID, + ); + if (rewrittenAllow.changed) { + plugins.allow = rewrittenAllow.next; + changes.push("Rewrote plugins.allow codex-supervisor references to codex."); + } + + // A Supervisor deny must not become a Codex deny because that would disable + // the whole harness. The nested enabled flag now owns supervision policy. + const rewrittenDeny = rewritePluginIdList(plugins.deny, LEGACY_CODEX_SUPERVISOR_PLUGIN_ID); + if (rewrittenDeny.changed) { + plugins.deny = rewrittenDeny.next; + changes.push("Removed plugins.deny codex-supervisor references."); + } + + return changes; +} + /** Legacy config migration specs for provider/plugin runtime config compatibility. */ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_PROVIDERS: LegacyConfigMigrationSpec[] = [ + defineLegacyConfigMigration({ + id: "plugins.codex-supervisor->plugins.codex.config.supervision", + describe: "Move retired Codex Supervisor config into the Codex plugin", + legacyRules: [ + { + path: ["plugins"], + message: + 'plugins.entries.codex-supervisor and related plugin policy references are retired; use plugins.entries.codex.config.supervision. Run "openclaw doctor --fix".', + requireSourceLiteral: true, + match: (_value, root) => + migrateLegacyCodexSupervisorPlugin(structuredClone(root)).length > 0, + }, + ], + apply: (raw, changes) => { + changes.push(...migrateLegacyCodexSupervisorPlugin(raw)); + }, + }), defineLegacyConfigMigration({ id: "plugins.openai-codex->plugins.openai", describe: "Rewrite retired OpenAI Codex plugin policy ids", diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index c2a5af61ff5e..1ff1e8cd8093 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../config/types.js"; import { resolveRegistryUpdateChannel } from "../../../infra/update-channels.js"; import { CLAWHUB_INSTALL_ERROR_CODE } from "../../../plugins/clawhub-error-codes.js"; import { @@ -10,6 +11,7 @@ import { resolveNpmInstallSpecsForUpdateChannel, } from "../../../plugins/install-channel-specs.js"; import { VERSION } from "../../../version.js"; +import { applyLegacyDoctorMigrations } from "./legacy-config-compat.js"; function expectedNpmInstallSpec(spec: string): string { return resolveNpmInstallSpecsForUpdateChannel({ @@ -73,6 +75,9 @@ const mocks = vi.hoisted(() => ({ resolveOfficialExternalWebProviderContractPluginIdsForEnv: vi.fn(), resolveDefaultPluginExtensionsDir: vi.fn(() => "/tmp/openclaw-plugins"), resolveDefaultPluginNpmDir: vi.fn(() => "/tmp/openclaw-npm"), + resolvePluginNpmProjectsDir: vi.fn((npmDir = "/tmp/openclaw-npm") => + path.join(npmDir, "projects"), + ), resolvePluginNpmPackageDir: vi.fn( ({ npmDir, packageName }: { npmDir?: string; packageName: string }) => path.join( @@ -136,6 +141,7 @@ vi.mock("../../../plugins/installed-plugin-index.js", async (importOriginal) => vi.mock("../../../plugins/install-paths.js", () => ({ resolveDefaultPluginExtensionsDir: mocks.resolveDefaultPluginExtensionsDir, resolveDefaultPluginNpmDir: mocks.resolveDefaultPluginNpmDir, + resolvePluginNpmProjectsDir: mocks.resolvePluginNpmProjectsDir, resolvePluginNpmPackageDir: mocks.resolvePluginNpmPackageDir, resolvePluginInstallDir: mocks.resolvePluginInstallDir, validatePluginId: mocks.validatePluginId, @@ -162,6 +168,11 @@ vi.mock("../../../plugins/plugin-metadata-snapshot.js", () => ({ resolvePluginMetadataSnapshot: mocks.loadPluginMetadataSnapshot, })); +vi.mock("../../../plugins/manifest-contract-eligibility.js", async (importOriginal) => ({ + ...(await importOriginal()), + loadManifestMetadataSnapshot: mocks.loadPluginMetadataSnapshot, +})); + vi.mock("../../../plugins/official-external-plugin-catalog.js", () => ({ getOfficialExternalPluginCatalogManifest: mocks.getOfficialExternalPluginCatalogManifest, listOfficialExternalChannelEnvVars: mocks.listOfficialExternalChannelEnvVars, @@ -2229,6 +2240,120 @@ describe("repairMissingConfiguredPluginInstalls", () => { ]); }); + it("upgrades v2026.7.1-beta.3 Codex Supervisor config and installs Codex", async () => { + // This is the bundled plugin id and config surface shipped by v2026.7.1-beta.3. + const migration = applyLegacyDoctorMigrations({ + plugins: { + allow: ["codex-supervisor"], + entries: { + "codex-supervisor": { + enabled: true, + config: { + endpoints: [ + { + id: "local", + label: "Local Codex", + transport: "stdio-proxy", + command: "codex", + args: ["app-server", "--listen", "stdio://"], + cwd: "/tmp/openclaw", + }, + ], + allowRawTranscripts: true, + allowWriteControls: false, + }, + }, + }, + }, + }); + + expect(migration.next).not.toBeNull(); + const cfg = migration.next as OpenClawConfig; + expect(cfg.plugins?.allow).toEqual(["codex"]); + expect(cfg.plugins?.entries?.codex).toEqual({ + enabled: true, + config: { + supervision: { + enabled: true, + endpoints: [ + { + id: "local", + label: "Local Codex", + transport: "stdio-proxy", + command: "codex", + args: ["app-server", "--listen", "stdio://"], + cwd: "/tmp/openclaw", + }, + ], + allowRawTranscripts: true, + allowWriteControls: false, + }, + }, + }); + expect(cfg.plugins?.entries).not.toHaveProperty("codex-supervisor"); + expect(migration.changes).toEqual( + expect.arrayContaining([ + "Moved plugins.entries.codex-supervisor to plugins.entries.codex.config.supervision.", + "Rewrote plugins.allow codex-supervisor references to codex.", + ]), + ); + + mocks.installPluginFromNpmSpec.mockResolvedValueOnce({ + ok: true, + pluginId: "codex", + targetDir: "/tmp/openclaw-plugins/codex", + version: "2026.7.2", + npmResolution: { + name: "@openclaw/codex", + version: "2026.7.2", + resolvedSpec: "@openclaw/codex@2026.7.2", + integrity: "sha512-codex-supervisor-upgrade", + resolvedAt: "2026-07-10T00:00:00.000Z", + }, + }); + mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([ + { + id: "codex", + label: "Codex", + install: { + npmSpec: "@openclaw/codex", + defaultChoice: "npm", + }, + }, + ]); + + const { repairMissingPluginInstallsForIds } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingPluginInstallsForIds({ + cfg, + pluginIds: ["codex"], + env: {}, + baselineRecords: {}, + }); + + expectRecordFields(mockCallArg(mocks.installPluginFromNpmSpec), { + spec: expectedNpmInstallSpec("@openclaw/codex"), + expectedPluginId: "codex", + trustedSourceLinkedOfficialInstall: true, + }); + const records = mockCallArg(mocks.writePersistedInstalledPluginIndexInstallRecords); + expectRecordFields((records as Record).codex, { + source: "npm", + spec: "@openclaw/codex", + installPath: "/tmp/openclaw-plugins/codex", + version: "2026.7.2", + resolvedName: "@openclaw/codex", + resolvedSpec: "@openclaw/codex@2026.7.2", + integrity: "sha512-codex-supervisor-upgrade", + }); + expect(result.changes).toEqual([ + `Installed missing configured plugin "codex" from ${expectedNpmInstallSpec("@openclaw/codex")}.`, + ]); + expect(result.warnings).toEqual([]); + expect(result.repairedPluginIds).toEqual(["codex"]); + expect(result.records).toEqual(records); + }); + it("installs a missing default Codex runtime plugin from the official external catalog", async () => { mocks.installPluginFromNpmSpec.mockResolvedValueOnce({ ok: true, diff --git a/src/commands/onboard-guided.test.ts b/src/commands/onboard-guided.test.ts index 4992aecdcbe9..572a008eefc2 100644 --- a/src/commands/onboard-guided.test.ts +++ b/src/commands/onboard-guided.test.ts @@ -72,6 +72,7 @@ function detection( ) { return { candidates: [candidate("claude-cli", "Claude Code")], + codexAppServerDetected: false, manualProviders: [], workspace: "/tmp/openclaw-workspace", setupComplete: false, diff --git a/src/commands/onboard-inference.test.ts b/src/commands/onboard-inference.test.ts index 04240c6cc2ea..e06b1c2ad8ea 100644 --- a/src/commands/onboard-inference.test.ts +++ b/src/commands/onboard-inference.test.ts @@ -7,6 +7,7 @@ import { CODEX_APP_SERVER_DEFAULT_MODEL_REF, OPENAI_API_DEFAULT_MODEL_REF, detectInferenceBackends, + detectNativeCodexAppServer, } from "./onboard-inference.js"; function probeDeps(found: Record) { @@ -172,6 +173,76 @@ describe("detectInferenceBackends", () => { expect(candidates[0]?.detail).toBe("installed"); }); + it("detects a native Codex App Server independently of inference ranking", async () => { + const command = "/Applications/ChatGPT.app/Contents/Resources/codex"; + + await expect( + detectNativeCodexAppServer({ + env: { HOME: "/Users/tester" }, + platform: "darwin", + probeLocalCommand: probeDeps({ [command]: true }), + }), + ).resolves.toEqual({ command, found: true }); + }); + + it.each([ + ["system ChatGPT", "/Applications/ChatGPT.app/Contents/Resources/codex", "/Users/tester"], + [ + "user ChatGPT", + "/Users/tester/Applications/ChatGPT.app/Contents/Resources/codex", + "/Users/tester", + ], + ["system", "/Applications/Codex.app/Contents/Resources/codex", "/Users/tester"], + ["user", "/Users/tester/Applications/Codex.app/Contents/Resources/codex", "/Users/tester"], + ["system beta", "/Applications/Codex Beta.app/Contents/Resources/codex", "/Users/tester"], + [ + "user beta", + "/Users/tester/Applications/Codex Beta.app/Contents/Resources/codex", + "/Users/tester", + ], + ])("finds the Codex CLI bundled in the %s macOS app directory", async (_scope, appCli, home) => { + const candidates = await detectInferenceBackends({ + env: { HOME: home }, + platform: "darwin", + deps: { + probeLocalCommand: probeDeps({ [appCli]: true }), + readClaudeCliCredentials: () => null, + readCodexCliCredentials: () => null, + }, + }); + + expect(candidates).toHaveLength(1); + expect(candidates[0]).toMatchObject({ + kind: "codex-cli", + detail: "installed", + }); + }); + + it("prefers a user ChatGPT app before a system legacy Codex app", async () => { + const probed: string[] = []; + const chatGPTCli = "/Users/tester/Applications/ChatGPT.app/Contents/Resources/codex"; + const legacyCodexCli = "/Applications/Codex.app/Contents/Resources/codex"; + const candidates = await detectInferenceBackends({ + env: { HOME: "/Users/tester" }, + platform: "darwin", + deps: { + probeLocalCommand: async (command) => { + probed.push(command); + return { + command, + found: command === chatGPTCli || command === legacyCodexCli, + }; + }, + readClaudeCliCredentials: () => null, + readCodexCliCredentials: () => null, + }, + }); + + expect(candidates).toMatchObject([{ kind: "codex-cli", detail: "installed" }]); + expect(probed).toContain(chatGPTCli); + expect(probed).not.toContain(legacyCodexCli); + }); + it("ignores blank env keys", async () => { const candidates = await detectInferenceBackends({ env: { OPENAI_API_KEY: " " }, diff --git a/src/commands/onboard-inference.ts b/src/commands/onboard-inference.ts index 491fb6a43d28..f1834332cce0 100644 --- a/src/commands/onboard-inference.ts +++ b/src/commands/onboard-inference.ts @@ -1,11 +1,13 @@ import { randomInt } from "node:crypto"; +// Inference backend detection shared by onboarding bootstrap and Crestodian setup. +import os from "node:os"; +import path from "node:path"; import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope-config.js"; import { readClaudeCliCredentialsCached, readCodexCliCredentialsCached, readGeminiCliCredentialsCached, } from "../agents/cli-credentials.js"; -// Inference backend detection shared by onboarding bootstrap and Crestodian setup. import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -60,6 +62,12 @@ export type DetectInferenceBackendsOptions = { deps?: DetectInferenceBackendsDeps; }; +export type DetectNativeCodexAppServerOptions = { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + probeLocalCommand?: typeof probeLocalCommand; +}; + function detectCliCredentialState(params: { probe: LocalCommandProbe; hasStoredCredentials: boolean; @@ -106,6 +114,43 @@ function randomizeClaudeCodexTie( ]; } +// ChatGPT.app is the current desktop owner; keep Codex stable/beta as fallbacks. +const CODEX_MACOS_APP_NAMES = ["ChatGPT.app", "Codex.app", "Codex Beta.app"] as const; + +async function probeCodexCommand(params: { + probe: typeof probeLocalCommand; + env: NodeJS.ProcessEnv; + platform: NodeJS.Platform; +}): Promise { + const pathProbe = await params.probe("codex"); + if (pathProbe.found || params.platform !== "darwin") { + return pathProbe; + } + const home = params.env.HOME?.trim() || os.homedir(); + const appExecutables = new Set( + CODEX_MACOS_APP_NAMES.flatMap((appName) => [ + path.join("/Applications", appName, "Contents", "Resources", "codex"), + path.join(home, "Applications", appName, "Contents", "Resources", "codex"), + ]), + ); + for (const executable of appExecutables) { + const appProbe = await params.probe(executable); + if (appProbe.found) { + return appProbe; + } + } + return pathProbe; +} +/** Detects a native Codex App Server without coupling it to inference selection. */ +export async function detectNativeCodexAppServer( + options: DetectNativeCodexAppServerOptions = {}, +): Promise { + return await probeCodexCommand({ + probe: options.probeLocalCommand ?? probeLocalCommand, + env: options.env ?? process.env, + platform: options.platform ?? process.platform, + }); +} /** * Detect usable inference backends in ladder order. Returns candidates only * for backends that exist on this machine; the first entry is the bootstrap @@ -172,7 +217,7 @@ export async function detectInferenceBackends( const [claudeProbe, codexProbe, geminiProbe] = await Promise.all([ probe("claude"), - probe("codex"), + detectNativeCodexAppServer({ probeLocalCommand: probe, env, platform }), probe("gemini"), ]); const cliCandidates: InferenceBackendCandidate[] = []; diff --git a/src/commands/sessions.acp-runtime-metadata.test.ts b/src/commands/sessions.acp-runtime-metadata.test.ts index d7df56fb5020..68aec2625feb 100644 --- a/src/commands/sessions.acp-runtime-metadata.test.ts +++ b/src/commands/sessions.acp-runtime-metadata.test.ts @@ -217,4 +217,29 @@ describe("sessions --json agentRuntime classifier (catalog #18)", () => { expect(agentRuntime.id).not.toBe("acpx"); expect(agentRuntime.source).not.toBe("session-key"); }); + + it("preserves locked Codex ownership ahead of stale OpenClaw session metadata", () => { + const agentRuntime = resolveModelAgentRuntimeMetadata({ + cfg: { + agents: { + defaults: { + models: { + "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } }, + }, + }, + }, + } as OpenClawConfig, + agentId: "main", + provider: "openai", + model: "gpt-5.5", + sessionKey: NON_ACP_SESSION_KEY, + sessionEntry: { + agentHarnessId: "codex", + agentRuntimeOverride: "openclaw", + modelSelectionLocked: true, + }, + }); + + expect(agentRuntime).toEqual({ id: "codex", source: "session" }); + }); }); diff --git a/src/commands/sessions.model-resolution.test.ts b/src/commands/sessions.model-resolution.test.ts index 789be2f49d94..9fe5a38e948c 100644 --- a/src/commands/sessions.model-resolution.test.ts +++ b/src/commands/sessions.model-resolution.test.ts @@ -133,4 +133,40 @@ describe("sessionsCommand model resolution", () => { expect(session?.modelProvider).toBe("anthropic"); expect(session?.model).toBe("claude-opus-4-7"); }); + + it("reports the owning Codex harness for locked sessions despite a stale OpenClaw override", async () => { + setMockSessionsConfig(() => ({ + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + models: { + "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } }, + }, + contextTokens: 200_000, + }, + }, + })); + const store = writeStore( + { + "agent:main:main": { + sessionId: "locked-codex-session", + updatedAt: Date.now() - 60_000, + modelProvider: "openai", + model: "gpt-5.5", + agentHarnessId: "codex", + agentRuntimeOverride: "openclaw", + modelSelectionLocked: true, + }, + }, + "sessions-locked-codex-runtime", + ); + + const payload = await runSessionsJson(sessionsCommand, store); + const session = payload.sessions?.find((row) => row.key === "agent:main:main"); + + expect(session?.agentRuntime).toEqual({ + id: "codex", + source: "session", + }); + }); }); diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 209e370b1797..fede21898881 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -392,6 +392,7 @@ export async function sessionsCommand( const agentRuntime = resolveModelAgentRuntimeMetadata({ cfg, agentId, + sessionEntry: entry, provider: modelRef.provider, model: modelRef.model, sessionKey: acpSessionKey, diff --git a/src/commands/status.summary.runtime.test.ts b/src/commands/status.summary.runtime.test.ts index 776d62cff43f..2e0fa3a2c5b2 100644 --- a/src/commands/status.summary.runtime.test.ts +++ b/src/commands/status.summary.runtime.test.ts @@ -271,6 +271,32 @@ describe("statusSummaryRuntime.resolveSessionRuntimeLabel", () => { }), ).toBe("OpenAI Codex"); }); + + it("reports the owning Codex harness for a locked session with stale OpenClaw metadata", () => { + expect( + statusSummaryRuntime.resolveSessionRuntimeLabel({ + cfg: { + agents: { + defaults: { + models: { + "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } }, + }, + }, + }, + } as never, + entry: { + sessionId: "locked-codex-session", + updatedAt: 0, + agentHarnessId: "codex", + agentRuntimeOverride: "openclaw", + modelSelectionLocked: true, + }, + provider: "openai", + model: "gpt-5.5", + sessionKey: "agent:main:main", + }), + ).toBe("OpenAI Codex"); + }); }); describe("statusSummaryRuntime.resolveSessionModelRef", () => { diff --git a/src/commands/status.summary.runtime.ts b/src/commands/status.summary.runtime.ts index 4efed516dbc2..207139ba4e12 100644 --- a/src/commands/status.summary.runtime.ts +++ b/src/commands/status.summary.runtime.ts @@ -211,6 +211,7 @@ function resolveSessionRuntimeLabel(params: { const runtime = resolveModelAgentRuntimeMetadata({ cfg: params.cfg, agentId: params.agentId ?? "", + sessionEntry: params.entry, provider: params.provider, model: params.model, sessionKey: acpSessionKey, diff --git a/src/config/sessions/cleanup-service.ts b/src/config/sessions/cleanup-service.ts index 7658af029d4c..692eefa6a80b 100644 --- a/src/config/sessions/cleanup-service.ts +++ b/src/config/sessions/cleanup-service.ts @@ -33,6 +33,7 @@ import { capEntryCount, pruneStaleModelRunEntries, pruneStaleEntries, + shouldPreserveMaintenanceEntry, shouldRunModelRunPrune, type ResolvedSessionMaintenanceConfig, } from "./store-maintenance.js"; @@ -317,6 +318,12 @@ function pruneMissingTranscriptEntries(params: { let removed = 0; let repaired = 0; for (const [key, entry] of Object.entries(params.store)) { + // `--fix-missing` is explicit repair for ordinary rows, but it cannot + // release a harness ownership lock. Header-only supervised transcripts are + // valid while their first native turn is still pending. + if (entry?.modelSelectionLocked === true && shouldPreserveMaintenanceEntry({ key, entry })) { + continue; + } if (!entry?.sessionId) { if (parseAgentSessionKey(key)) { // Agent-scoped keys without session ids are valid routing entries; keep them. diff --git a/src/config/sessions/disk-budget.test.ts b/src/config/sessions/disk-budget.test.ts index 94a40e0c1fe2..6c5eabd55a64 100644 --- a/src/config/sessions/disk-budget.test.ts +++ b/src/config/sessions/disk-budget.test.ts @@ -204,6 +204,42 @@ describe("enforceSessionDiskBudget", () => { }); }); + it("preserves model-locked harness sessions when removing entries for disk budget", async () => { + await withTempDir({ prefix: "openclaw-disk-budget-" }, async (dir) => { + const storePath = path.join(dir, "sessions.json"); + const lockedKey = "agent:main:harness-owned:locked"; + const removableKey = "agent:main:old-removable"; + const now = Date.now(); + const store: Record = { + [lockedKey]: { + sessionId: "locked-budget", + updatedAt: now - 10_000, + modelSelectionLocked: true, + }, + [removableKey]: { + sessionId: "old-removable", + updatedAt: now, + }, + }; + await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8"); + + const result = await enforceSessionDiskBudget({ + store, + storePath, + maintenance: { + maxDiskBytes: 120, + highWaterBytes: 80, + }, + warnOnly: false, + }); + + expectBudgetResult(result); + expect(result.removedEntries).toBe(1); + expect(store).toHaveProperty(lockedKey); + expect(store).not.toHaveProperty(removableKey); + }); + }); + it("accounts for deduped skills prompt blobs before evicting sessions", async () => { await withTempDir({ prefix: "openclaw-disk-budget-" }, async (dir) => { const storePath = path.join(dir, "sessions.json"); diff --git a/src/config/sessions/lifecycle.ts b/src/config/sessions/lifecycle.ts index 9a56f6646d26..2c6d499f09a9 100644 --- a/src/config/sessions/lifecycle.ts +++ b/src/config/sessions/lifecycle.ts @@ -15,7 +15,10 @@ type SessionLifecycleEntry = Pick< "sessionId" | "sessionFile" | "sessionStartedAt" | "lastInteractionAt" | "updatedAt" >; -type SessionWorkStartEntry = Pick; +type SessionWorkStartEntry = Pick< + SessionEntry, + "archivedAt" | "initializationPending" | "sessionId" +>; type SessionWorkStartOptions = { expectedSessionId?: string; @@ -46,7 +49,7 @@ export function isSessionWorkStartInvalidatedError( ); } -/** Archived sessions are read-only until the lifecycle owner restores them. */ +/** Lifecycle-owned initializing and archived sessions reject new work. */ export function resolveSessionWorkStartError( sessionKey: string, entry: SessionWorkStartEntry | null | undefined, @@ -58,6 +61,9 @@ export function resolveSessionWorkStartError( if (options?.expectedSessionId && entry?.sessionId !== options.expectedSessionId) { return `Session "${sessionKey}" changed while starting work. Retry.`; } + if (entry?.initializationPending === true) { + return `Session "${sessionKey}" is still initializing. Retry after initialization completes.`; + } return entry?.archivedAt === undefined ? undefined : `Session "${sessionKey}" is archived. Restore it before starting new work.`; diff --git a/src/config/sessions/plugin-host-cleanup.ts b/src/config/sessions/plugin-host-cleanup.ts index 6e00d927363d..6bbcdd992ef6 100644 --- a/src/config/sessions/plugin-host-cleanup.ts +++ b/src/config/sessions/plugin-host-cleanup.ts @@ -1,5 +1,6 @@ /** File-backed implementation for plugin host-owned session-state cleanup. */ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { normalizeOptionalAgentRuntimeId } from "../../agents/agent-runtime-id.js"; import { normalizeSessionEntrySlotKey } from "../../plugins/session-entry-slot-keys.js"; import { updateSessionStore } from "./store.js"; import type { SessionEntry } from "./types.js"; @@ -18,6 +19,8 @@ export type PluginHostSessionCleanupStoreParams = { sessionEntrySlotKeys?: ReadonlySet; /** Per-store file-backed transaction boundary. */ storePath: string; + /** Harness ids whose locked durable sessions survive plugin disable cleanup. */ + preserveLockedHarnessIds?: ReadonlySet; /** Cancels the cleanup before persistence when host lifecycle state changes. */ shouldCleanup?: () => boolean; }; @@ -209,6 +212,17 @@ function hasCleanupTarget( return hasPluginOwnedSessionState(entry, params.pluginId, params.sessionEntrySlotKeys); } +function isLockedHarnessSessionOwnedByPlugin( + entry: SessionEntry, + preserveLockedHarnessIds: ReadonlySet | undefined, +): boolean { + if (entry.modelSelectionLocked !== true || !preserveLockedHarnessIds?.size) { + return false; + } + const harnessId = normalizeOptionalAgentRuntimeId(entry.agentHarnessId); + return harnessId !== undefined && preserveLockedHarnessIds.has(harnessId); +} + function clearCleanupTarget( entry: SessionEntry, params: PluginHostSessionCleanupStoreParams, @@ -239,6 +253,12 @@ export async function cleanupPluginHostSessionStore( let clearedInStore = 0; const now = Date.now(); for (const [entryKey, entry] of Object.entries(store)) { + // Disabling an owning harness stops execution; it does not release its + // durable conversation. Keep the plugin marker so re-enable can recover + // the same binding instead of leaving a locked, undiscoverable session. + if (isLockedHarnessSessionOwnedByPlugin(entry, params.preserveLockedHarnessIds)) { + continue; + } if ( !matchesCleanupSession(entryKey, entry, params.sessionKey) || !hasCleanupTarget(entry, params) diff --git a/src/config/sessions/session-accessor.test.ts b/src/config/sessions/session-accessor.test.ts index 7ead8a6f3a5f..87f1726d77d6 100644 --- a/src/config/sessions/session-accessor.test.ts +++ b/src/config/sessions/session-accessor.test.ts @@ -588,6 +588,37 @@ describe("session accessor file-backed seam", () => { expect(loadSessionStore(storePath, { skipCache: true })[scope.sessionKey]).toBeUndefined(); }); + it("does not save the session store when entry preparation is rejected", async () => { + const scope = { + agentId: "main", + sessionKey: "agent:main:main", + storePath, + }; + await upsertSessionEntry(scope, { + sessionId: "pending-session", + updatedAt: 10, + initializationPending: true, + }); + const fixedTime = new Date("2020-01-01T00:00:00.000Z"); + fs.utimesSync(storePath, fixedTime, fixedTime); + + const rejected = await createSessionEntryWithTranscript(scope, () => ({ + ok: false, + error: "still initializing", + })); + + expect(rejected).toEqual({ + ok: false, + error: "still initializing", + phase: "entry", + }); + expect(fs.statSync(storePath).mtimeMs).toBe(fixedTime.getTime()); + expect(loadSessionEntry({ ...scope, readConsistency: "latest" })).toMatchObject({ + sessionId: "pending-session", + initializationPending: true, + }); + }); + it("commits reply session initialization with a guarded snapshot", async () => { const sessionKey = "agent:main:main"; const previousTranscript = path.join(tempDir, "previous.jsonl"); diff --git a/src/config/sessions/session-accessor.ts b/src/config/sessions/session-accessor.ts index b47721826046..66c7d4e7a111 100644 --- a/src/config/sessions/session-accessor.ts +++ b/src/config/sessions/session-accessor.ts @@ -61,6 +61,7 @@ import { recordSessionMetaFromInbound as recordFileSessionMetaFromInbound, resolveSessionStoreEntry, resetSessionEntryLifecycle as resetFileSessionEntryLifecycle, + rollbackAgentHarnessSessionEntryLifecycle as rollbackFileAgentHarnessSessionEntryLifecycle, updateLastRoute as updateFileSessionLastRoute, updateSessionStore, updateSessionStoreEntry as updateFileSessionStoreEntry, @@ -541,6 +542,7 @@ export type SessionCompactionCheckpointMutationResult = entry: SessionEntry; } | { status: "missing-session" } + | { status: "model-selection-locked" } | { status: "missing-checkpoint" } | { status: "missing-boundary" } | { status: "failed" }; @@ -650,8 +652,15 @@ export type SessionEntryCreateWithTranscriptPrepareResult = | { ok: true; entry: SessionEntry } | { ok: false; error: TError }; +export type SessionEntryCreateWithTranscriptOptions = { + /** Protect the newly created row from maintenance during its initial save. */ + activeSessionKey?: string; + /** Throw unless the initial session row is durably persisted. */ + requireWriteSuccess?: boolean; +}; + type CreatedSessionTranscriptResult = - | { ok: true; sessionFile: string } + | { ok: true; created: boolean; sessionFile: string } | { ok: false; error: string; phase: "transcript" }; export type SessionPatchProjectionContext = SessionEntryPatchProjectionContext; @@ -1158,41 +1167,71 @@ export async function createSessionEntryWithTranscript( ) => | Promise> | SessionEntryCreateWithTranscriptPrepareResult, + options: SessionEntryCreateWithTranscriptOptions = {}, ): Promise> { const storePath = resolveSessionStorePathForScope(scope); - return await updateSessionStore(storePath, async (store) => { - const resolved = resolveSessionStoreEntry({ store, sessionKey: scope.sessionKey }); - const created = await createEntry({ - existingEntry: resolved.existing ? { ...resolved.existing } : undefined, - sessionEntries: cloneSessionEntries(store), - }); - if (!created.ok) { - return { ok: false, error: created.error, phase: "entry" }; - } - - const ensured = ensureCreatedSessionTranscript({ - agentId: scope.agentId, - entry: created.entry, + let createdTranscriptFile: string | undefined; + try { + return await updateSessionStore( storePath, - }); - if (!ensured.ok) { - delete store[resolved.normalizedKey]; - return ensured; - } + async (store) => { + const resolved = resolveSessionStoreEntry({ store, sessionKey: scope.sessionKey }); + const created = await createEntry({ + existingEntry: resolved.existing ? { ...resolved.existing } : undefined, + sessionEntries: cloneSessionEntries(store), + }); + if (!created.ok) { + return { ok: false, error: created.error, phase: "entry" }; + } - const entry = - created.entry.sessionFile === ensured.sessionFile - ? created.entry - : { - ...created.entry, - sessionFile: ensured.sessionFile, - }; - store[resolved.normalizedKey] = entry; - for (const legacyKey of resolved.legacyKeys) { - delete store[legacyKey]; + const ensured = ensureCreatedSessionTranscript({ + agentId: scope.agentId, + entry: created.entry, + storePath, + }); + if (!ensured.ok) { + delete store[resolved.normalizedKey]; + return ensured; + } + if (ensured.created) { + createdTranscriptFile = ensured.sessionFile; + } + + const entry = + created.entry.sessionFile === ensured.sessionFile + ? created.entry + : { + ...created.entry, + sessionFile: ensured.sessionFile, + }; + store[resolved.normalizedKey] = entry; + for (const legacyKey of resolved.legacyKeys) { + delete store[legacyKey]; + } + return { ok: true, entry, sessionFile: ensured.sessionFile }; + }, + { + activeSessionKey: options.activeSessionKey, + requireWriteSuccess: options.requireWriteSuccess, + // Entry rejection is side-effect free; transcript failure deletes the target row + // and must persist that rollback instead of restoring the writer snapshot. + skipSaveWhenResult: (result) => !result.ok && result.phase === "entry", + }, + ); + } catch (error) { + if (!createdTranscriptFile) { + throw error; } - return { ok: true, entry, sessionFile: ensured.sessionFile }; - }); + try { + fs.rmSync(createdTranscriptFile, { force: true }); + } catch (cleanupError) { + throw new Error( + `Session entry persistence failed (${formatErrorMessage(error)}) and transcript cleanup did not complete: ${createdTranscriptFile}`, + { cause: cleanupError }, + ); + } + throw error; + } } function cloneSessionEntries(store: Record): Record { @@ -1356,7 +1395,8 @@ function ensureCreatedSessionTranscript(params: { sessionsDir: path.dirname(path.resolve(params.storePath)), }, ); - if (!fs.existsSync(sessionFile)) { + const created = !fs.existsSync(sessionFile); + if (created) { fs.mkdirSync(path.dirname(sessionFile), { recursive: true }); fs.writeFileSync( sessionFile, @@ -1367,7 +1407,7 @@ function ensureCreatedSessionTranscript(params: { }, ); } - return { ok: true, sessionFile }; + return { ok: true, created, sessionFile }; } catch (err) { return { ok: false, @@ -1573,13 +1613,9 @@ function resolveEntryFromStoreKeys(params: { store: Record; keys: readonly string[]; }): SessionEntry | undefined { - for (const key of params.keys) { - const entry = params.store[key]; - if (entry) { - return entry; - } - } - return undefined; + // Alias migration can leave multiple logical rows. Fork policy must inspect + // the freshest row or a stale canonical entry can shadow a newer lock. + return resolveFreshestTargetEntry(params.store, params.keys)?.entry; } function persistForkedSessionEntry(params: { @@ -1920,6 +1956,11 @@ async function applySessionCompactionCheckpointMutation( if (!currentEntry?.sessionId) { return { status: "missing-session" }; } + // A native harness owns the locked transcript identity. Rotating or + // cloning it here would strand that binding on the old session id. + if (currentEntry.modelSelectionLocked === true) { + return { status: "model-selection-locked" }; + } const checkpoint = findSessionCompactionCheckpoint({ entry: currentEntry, checkpointId: params.checkpointId, @@ -2109,6 +2150,13 @@ export async function deleteSessionEntryLifecycle( return await deleteFileSessionEntryLifecycle(params); } +/** Internal exact-row rollback for failed trusted agent-harness initialization. */ +export async function rollbackAgentHarnessSessionEntryLifecycle( + params: DeleteSessionEntryLifecycleParams & { expectedEntry: SessionEntry }, +): Promise { + return await rollbackFileAgentHarnessSessionEntryLifecycle(params); +} + /** Applies exact entry lifecycle mutations and artifact cleanup at the storage boundary. */ export async function applySessionEntryLifecycleMutation(params: { storePath: string; diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index c7b803f592d0..c582f4b9a410 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -8,7 +8,7 @@ import * as jsonFiles from "../../infra/json-files.js"; import { createSuiteTempRootTracker, withTempDirSync } from "../../test-helpers/temp-dir.js"; import type { OpenClawConfig } from "../config.js"; import type { SessionConfig } from "../types.base.js"; -import { resolveSessionLifecycleTimestamps } from "./lifecycle.js"; +import { resolveSessionLifecycleTimestamps, resolveSessionWorkStartError } from "./lifecycle.js"; import { resolveExplicitSessionFilePath, resolveSessionFilePath, @@ -351,6 +351,22 @@ describe("session lifecycle timestamps", () => { }); }); +describe("session work admission", () => { + it("fails closed while trusted session initialization is pending", () => { + expect( + resolveSessionWorkStartError("agent:main:pending", { + sessionId: "pending-session", + initializationPending: true, + }), + ).toContain("still initializing"); + expect( + resolveSessionWorkStartError("agent:main:pending", { + sessionId: "pending-session", + }), + ).toBeUndefined(); + }); +}); + describe("session store writer queue", () => { const writerFixtureRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-writer-test-" }); diff --git a/src/config/sessions/store-entry-shape.ts b/src/config/sessions/store-entry-shape.ts index dee820660ec6..5e9b0980f8ec 100644 --- a/src/config/sessions/store-entry-shape.ts +++ b/src/config/sessions/store-entry-shape.ts @@ -39,6 +39,7 @@ export function normalizePersistedSessionEntryShape(value: unknown): SessionEntr return undefined; } + const modelSelectionLocked = value.modelSelectionLocked === true; let next = value as unknown as SessionEntry; const sessionFile = typeof value.sessionFile === "string" ? value.sessionFile.trim() : undefined; if (value.sessionId !== undefined) { @@ -46,8 +47,16 @@ export function normalizePersistedSessionEntryShape(value: unknown): SessionEntr return undefined; } const sessionId = value.sessionId.trim(); + if (modelSelectionLocked && sessionId !== value.sessionId) { + // A harness lock protects the exact durable identity. Repairing it here + // would make a corrupted row look valid before ownership validation. + return undefined; + } const transcriptSessionId = normalizeTranscriptSessionId(sessionId); if (!transcriptSessionId && !sessionFile) { + if (modelSelectionLocked) { + return undefined; + } // Old non-transcript ids can survive only when a separate sessionFile pins the path. const { sessionId: _dropSessionId, ...rest } = next; next = rest as SessionEntry; diff --git a/src/config/sessions/store-load.ts b/src/config/sessions/store-load.ts index f04a69fbd184..fdc2440acc92 100644 --- a/src/config/sessions/store-load.ts +++ b/src/config/sessions/store-load.ts @@ -5,6 +5,7 @@ import { createSubsystemLogger } from "../../logging/subsystem.js"; import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js"; import { isPluginJsonValue, type PluginJsonValue } from "../../plugins/host-hook-json.js"; import { normalizeSessionEntrySlotKey } from "../../plugins/session-entry-slot-keys.js"; +import { resolveAgentHarnessSessionStoreError } from "../../sessions/agent-harness-session-key.js"; import { normalizeDeliveryChannelRoute, normalizeDeliveryContext, @@ -351,17 +352,29 @@ export function stripPersistedSkillsCache(entry: SessionEntry): SessionEntry { export function normalizeSessionStore(store: Record): boolean { let changed = false; for (const [key, entry] of Object.entries(store)) { + const modelSelectionLocked = isRecord(entry) && entry.modelSelectionLocked === true; const shaped = normalizePersistedSessionEntryShape(entry); if (!shaped) { + if (modelSelectionLocked) { + // Never normalize a protected row into absence. Writers must see the + // corruption and fail closed instead of persisting an implicit unlock. + throw new Error(`Invalid model-selection-locked session entry: ${key}`); + } delete store[key]; changed = true; continue; } + const normalizedRuntimeFields = normalizeSessionRuntimeModelFields(shaped); + if (modelSelectionLocked && normalizedRuntimeFields !== shaped) { + // The persisted provider/model pair is part of the harness-owned runtime + // identity. Do not repair it before validating the durable lock. + throw new Error(`Invalid model-selection-locked session entry: ${key}`); + } const normalized = stripPersistedSkillsCache( normalizePluginExtensionSlotKeys( normalizePluginExtensions( normalizePendingFinalDeliveryFields( - normalizeSessionEntryDelivery(normalizeSessionRuntimeModelFields(shaped)), + normalizeSessionEntryDelivery(modelSelectionLocked ? shaped : normalizedRuntimeFields), ), ), ), @@ -372,6 +385,10 @@ export function normalizeSessionStore(store: Record): bool changed = true; } } + const harnessStoreError = resolveAgentHarnessSessionStoreError(store); + if (harnessStoreError) { + throw new Error(harnessStoreError); + } return changed; } diff --git a/src/config/sessions/store-maintenance.ts b/src/config/sessions/store-maintenance.ts index ac961091a956..d8c8c60d8b01 100644 --- a/src/config/sessions/store-maintenance.ts +++ b/src/config/sessions/store-maintenance.ts @@ -285,7 +285,7 @@ export function pruneStaleModelRunEntries( const cutoffMs = Date.now() - overrideMaxAgeMs; let pruned = 0; for (const [key, entry] of Object.entries(store)) { - if (opts.preserveKeys?.has(key) === true) { + if (shouldPreserveMaintenanceEntry({ key, entry, preserveKeys: opts.preserveKeys })) { continue; } if (!isGatewayModelRunSessionKey(key)) { @@ -408,7 +408,12 @@ export function shouldPreserveMaintenanceEntry(params: { entry: SessionEntry | undefined; preserveKeys?: ReadonlySet; }): boolean { + // A model lock is durable harness ownership, not merely a UI restriction. + // Evicting the row can strand its native runtime binding and later recreate + // the same conversation under an incompatible model, so pressure may exceed + // configured retention limits while the lock remains. return ( + params.entry?.modelSelectionLocked === true || params.preserveKeys?.has(params.key) === true || isProtectedSessionMaintenanceEntry(params.key, params.entry) ); @@ -429,7 +434,7 @@ export function getActiveSessionMaintenanceWarning(params: { if (!activeEntry) { return null; } - if (isProtectedSessionMaintenanceEntry(activeSessionKey, activeEntry)) { + if (shouldPreserveMaintenanceEntry({ key: activeSessionKey, entry: activeEntry })) { return null; } const now = params.nowMs ?? Date.now(); @@ -475,7 +480,8 @@ function wouldCapActiveSession(params: { const protectedCount = params.keys.filter( (key) => - key !== params.activeSessionKey && isProtectedSessionMaintenanceEntry(key, params.store[key]), + key !== params.activeSessionKey && + shouldPreserveMaintenanceEntry({ key, entry: params.store[key] }), ).length; const maxRemovableEntries = Math.max(0, params.maxEntries - protectedCount); // If protected entries fill the cap, the active unprotected session would be the one removed. @@ -491,7 +497,7 @@ function wouldCapActiveSession(params: { seenActive = true; continue; } - if (isProtectedSessionMaintenanceEntry(key, params.store[key])) { + if (shouldPreserveMaintenanceEntry({ key, entry: params.store[key] })) { continue; } const entryUpdatedAt = getEntryUpdatedAt(params.store[key]); diff --git a/src/config/sessions/store.agent-harness-invariant.test.ts b/src/config/sessions/store.agent-harness-invariant.test.ts new file mode 100644 index 000000000000..56f2240fbd84 --- /dev/null +++ b/src/config/sessions/store.agent-harness-invariant.test.ts @@ -0,0 +1,628 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { loadSessionStore as loadPluginSessionStore } from "../../plugin-sdk/session-store-runtime.js"; +import { AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE } from "../../sessions/agent-harness-session-key.js"; +import { + applySessionEntryLifecycleMutation, + clearSessionStoreCacheForTest, + cleanupSessionLifecycleArtifacts, + deleteSessionEntryLifecycle, + loadSessionStore, + rollbackAgentHarnessSessionEntryLifecycle, + saveSessionStore, + updateSessionStore, +} from "./store.js"; +import type { SessionEntry } from "./types.js"; + +describe("agent harness session store invariant", () => { + let tempDir: string; + let storePath: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-harness-store-")); + storePath = path.join(tempDir, "sessions.json"); + }); + + afterEach(() => { + clearSessionStoreCacheForTest(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it.each([ + { modelSelectionLocked: true, sessionId: "native-session", updatedAt: 1 }, + { + modelSelectionLocked: true, + agentHarnessId: "other", + sessionId: "native-session", + updatedAt: 1, + }, + { + modelSelectionLocked: true, + agentHarnessId: "", + sessionId: "native-session", + updatedAt: 1, + }, + ] satisfies SessionEntry[])( + "rejects an invalid reserved row through public save", + async (entry) => { + const sessionKey = "agent:main:harness:codex:supervision:native-thread"; + + await expect( + saveSessionStore(storePath, { [sessionKey]: entry }, { skipMaintenance: true }), + ).rejects.toThrow(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE); + + expect(fs.existsSync(storePath)).toBe(false); + }, + ); + + it("loads and updates a pre-existing unlocked harness-prefixed session", async () => { + const sessionKey = "agent:main:harness:notes"; + const entry: SessionEntry = { + agentHarnessId: "openclaw", + sessionId: "legacy-session", + updatedAt: 1, + }; + fs.writeFileSync(storePath, JSON.stringify({ [sessionKey]: entry }), "utf-8"); + + expect(loadSessionStore(storePath, { skipCache: true })).toEqual({ [sessionKey]: entry }); + await updateSessionStore( + storePath, + (store) => { + store[sessionKey] = { ...store[sessionKey], label: "Legacy notes" }; + }, + { skipMaintenance: true }, + ); + + expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual({ + ...entry, + label: "Legacy notes", + }); + }); + + it("allows an ordinary legacy model lock to adopt a transcript id", async () => { + const sessionKey = "agent:main:legacy-model-lock"; + const entry = { + modelSelectionLocked: true, + updatedAt: 1, + } as SessionEntry; + await saveSessionStore(storePath, { [sessionKey]: entry }, { skipMaintenance: true }); + + await updateSessionStore( + storePath, + (store) => { + store[sessionKey] = { ...store[sessionKey], sessionId: "generated-session" }; + }, + { skipMaintenance: true }, + ); + + expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual({ + ...entry, + sessionId: "generated-session", + }); + }); + + it("rejects an invalid reserved lifecycle upsert before persistence", async () => { + await saveSessionStore( + storePath, + { "agent:main:ordinary": { sessionId: "ordinary-session", updatedAt: 1 } }, + { skipMaintenance: true }, + ); + + await expect( + applySessionEntryLifecycleMutation({ + storePath, + upserts: [ + { + sessionKey: "agent:main:harness:codex:supervision:native-thread", + entry: { + agentHarnessId: "other", + modelSelectionLocked: true, + sessionId: "native-session", + updatedAt: 1, + }, + }, + ], + skipMaintenance: true, + }), + ).rejects.toThrow(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE); + + expect(loadSessionStore(storePath, { skipCache: true })).toEqual({ + "agent:main:ordinary": { sessionId: "ordinary-session", updatedAt: 1 }, + }); + }); + + it("rejects a prefix-colliding harness owner through public save", async () => { + const sessionKey = "agent:main:harness:foo:bar:native-thread"; + + await expect( + saveSessionStore( + storePath, + { + [sessionKey]: { + agentHarnessId: "foo:bar", + modelSelectionLocked: true, + sessionId: "native-session", + updatedAt: 1, + }, + }, + { skipMaintenance: true }, + ), + ).rejects.toThrow(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE); + + expect(fs.existsSync(storePath)).toBe(false); + }); + + it("persists a locked reserved lifecycle upsert owned by its matching harness", async () => { + const sessionKey = "agent:main:harness:codex:supervision:native-thread"; + + await expect( + applySessionEntryLifecycleMutation({ + storePath, + upserts: [ + { + sessionKey, + entry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + updatedAt: 1, + }, + }, + ], + skipMaintenance: true, + }), + ).resolves.toMatchObject({ afterCount: 1 }); + + expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toMatchObject({ + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + }); + }); + + it("rejects removing or reassigning any durable model-selection lock", async () => { + const sessionKey = "agent:main:ordinary-locked"; + const entry: SessionEntry = { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "locked-session", + updatedAt: 1, + }; + await saveSessionStore(storePath, { [sessionKey]: entry }, { skipMaintenance: true }); + + await expect( + updateSessionStore( + storePath, + (store) => { + delete store[sessionKey]; + }, + { skipMaintenance: true }, + ), + ).rejects.toThrow("Model-selection-locked sessions cannot be removed"); + await expect( + updateSessionStore( + storePath, + (store) => { + store[sessionKey] = { ...store[sessionKey], agentHarnessId: "other" }; + }, + { skipMaintenance: true }, + ), + ).rejects.toThrow("Model-selection-locked sessions cannot be removed"); + await expect( + updateSessionStore( + storePath, + (store) => { + store[sessionKey] = { ...store[sessionKey], agentHarnessId: "codex-app-server" }; + }, + { skipMaintenance: true }, + ), + ).rejects.toThrow("Agent harness-owned session identity is locked"); + await expect( + updateSessionStore( + storePath, + (store) => { + store[sessionKey] = { ...store[sessionKey], sessionId: "replacement-session" }; + }, + { skipMaintenance: true }, + ), + ).rejects.toThrow("Agent harness-owned session identity is locked"); + + expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual(entry); + }); + + it("keeps a reserved harness session id immutable and exclusive", async () => { + const sessionKey = "agent:main:harness:codex:supervision:native-thread"; + const entry: SessionEntry = { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + updatedAt: 1, + }; + await saveSessionStore(storePath, { [sessionKey]: entry }, { skipMaintenance: true }); + + await expect( + updateSessionStore( + storePath, + (store) => { + store[sessionKey] = { ...store[sessionKey], sessionId: "replacement-session" }; + }, + { skipMaintenance: true }, + ), + ).rejects.toThrow("Agent harness-owned session identity is locked"); + await expect( + updateSessionStore( + storePath, + (store) => { + store["agent:main:ordinary-alias"] = { + sessionId: "native-session", + updatedAt: 2, + }; + }, + { skipMaintenance: true }, + ), + ).rejects.toThrow("Agent harness-owned session identity is locked"); + + expect(loadSessionStore(storePath, { skipCache: true })).toEqual({ [sessionKey]: entry }); + }); + + it("keeps an ordinary-key harness lock exclusive by session id", async () => { + const sessionKey = "agent:main:ordinary-locked"; + const entry: SessionEntry = { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + updatedAt: 1, + }; + await saveSessionStore(storePath, { [sessionKey]: entry }, { skipMaintenance: true }); + + await expect( + updateSessionStore( + storePath, + (store) => { + store["agent:main:ordinary-alias"] = { + sessionId: "native-session", + updatedAt: 2, + }; + }, + { skipMaintenance: true }, + ), + ).rejects.toThrow("Agent harness-owned session identity is locked"); + + expect(loadSessionStore(storePath, { skipCache: true })).toEqual({ [sessionKey]: entry }); + }); + + it("rejects duplicate ids across newly-created reserved rows", async () => { + const entry: SessionEntry = { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + updatedAt: 1, + }; + + await expect( + saveSessionStore( + storePath, + { + "agent:main:harness:codex:supervision:first": entry, + "agent:main:harness:codex:supervision:second": entry, + }, + { skipMaintenance: true }, + ), + ).rejects.toThrow("Agent harness-owned session identity is locked"); + }); + + it("requires every reserved row to own a durable session id", async () => { + await expect( + saveSessionStore( + storePath, + { + "agent:main:harness:codex:supervision:native-thread": { + agentHarnessId: "codex", + modelSelectionLocked: true, + updatedAt: 1, + } as SessionEntry, + }, + { skipMaintenance: true }, + ), + ).rejects.toThrow("Agent harness-owned session identity is locked"); + }); + + it("fails closed instead of normalizing a malformed reserved row away", async () => { + const sessionKey = "agent:main:harness:codex:supervision:native-thread"; + fs.writeFileSync( + storePath, + JSON.stringify({ + [sessionKey]: { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "../unsafe-session", + }, + }), + "utf-8", + ); + + expect(() => loadSessionStore(storePath, { skipCache: true })).toThrow( + `Invalid model-selection-locked session entry: ${sessionKey}`, + ); + await expect( + updateSessionStore( + storePath, + (store) => { + store["agent:main:ordinary"] = { sessionId: "ordinary", updatedAt: 1 }; + }, + { skipMaintenance: true }, + ), + ).rejects.toThrow(`Invalid model-selection-locked session entry: ${sessionKey}`); + expect(JSON.parse(fs.readFileSync(storePath, "utf-8"))).toHaveProperty(sessionKey); + }); + + it.each([ + { + name: "trimmed identity", + entry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: " native-session ", + updatedAt: 1, + }, + }, + { + name: "trimmed runtime pair", + entry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + updatedAt: 1, + modelProvider: " openai ", + model: " gpt-5.4 ", + }, + }, + { + name: "orphan runtime provider", + entry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + updatedAt: 1, + modelProvider: "openai", + }, + }, + ])("fails closed instead of normalizing a locked $name on cold load", ({ entry }) => { + const sessionKey = "agent:main:harness:codex:supervision:native-thread"; + const serialized = JSON.stringify({ [sessionKey]: entry }); + fs.writeFileSync(storePath, serialized, "utf-8"); + + expect(() => loadSessionStore(storePath, { skipCache: true })).toThrow( + `Invalid model-selection-locked session entry: ${sessionKey}`, + ); + expect(fs.readFileSync(storePath, "utf-8")).toBe(serialized); + }); + + it("keeps canonical locked identity and runtime fields stable on cold load", () => { + const sessionKey = "agent:main:harness:codex:supervision:native-thread"; + const entry: SessionEntry = { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + updatedAt: 1, + modelProvider: "openai", + model: "gpt-5.4", + }; + const serialized = JSON.stringify({ [sessionKey]: entry }); + fs.writeFileSync(storePath, serialized, "utf-8"); + + expect(loadSessionStore(storePath, { skipCache: true })).toEqual({ [sessionKey]: entry }); + expect(fs.readFileSync(storePath, "utf-8")).toBe(serialized); + }); + + it("retains unlocked harness-prefix compatibility normalization on cold load", () => { + const sessionKey = "agent:main:harness:notes"; + fs.writeFileSync( + storePath, + JSON.stringify({ + [sessionKey]: { + agentHarnessId: "openclaw", + sessionId: " legacy-session ", + updatedAt: 1, + modelProvider: " openai ", + model: " gpt-5.4 ", + }, + }), + "utf-8", + ); + + expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual({ + agentHarnessId: "openclaw", + sessionId: "legacy-session", + updatedAt: 1, + modelProvider: "openai", + model: "gpt-5.4", + }); + }); + + it("fails closed on a cold-load alias of a reserved transcript identity", () => { + const sessionKey = "agent:main:harness:codex:supervision:native-thread"; + fs.writeFileSync( + storePath, + JSON.stringify({ + [sessionKey]: { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + }, + "agent:main:ordinary-alias": { + sessionId: "native-session", + }, + }), + "utf-8", + ); + + expect(() => loadSessionStore(storePath, { skipCache: true })).toThrow( + "Agent harness-owned session identity is locked", + ); + }); + + it("cannot poison the cache by mutating a locked row in a skipped write", async () => { + const sessionKey = "agent:main:harness:codex:supervision:native-thread"; + const entry: SessionEntry = { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + updatedAt: 1, + }; + await saveSessionStore(storePath, { [sessionKey]: entry }, { skipMaintenance: true }); + + await expect( + updateSessionStore( + storePath, + (store) => { + delete store[sessionKey]; + return 0; + }, + { skipMaintenance: true, skipSaveWhenResult: (result) => result === 0 }, + ), + ).rejects.toThrow("Model-selection-locked sessions cannot be removed"); + await updateSessionStore( + storePath, + (store) => { + store["agent:main:ordinary"] = { sessionId: "ordinary-session", updatedAt: 2 }; + }, + { skipMaintenance: true }, + ); + + expect(loadSessionStore(storePath, { skipCache: true })).toEqual({ + [sessionKey]: entry, + "agent:main:ordinary": { sessionId: "ordinary-session", updatedAt: 2 }, + }); + }); + + it("does not expose the writer-owned cache through the plugin SDK", async () => { + const sessionKey = "agent:main:harness:codex:supervision:native-thread"; + const entry: SessionEntry = { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + updatedAt: 1, + }; + await saveSessionStore(storePath, { [sessionKey]: entry }, { skipMaintenance: true }); + + const exposed = loadPluginSessionStore(storePath, { clone: false }); + delete exposed[sessionKey]; + await updateSessionStore( + storePath, + (store) => { + store["agent:main:ordinary"] = { sessionId: "ordinary-session", updatedAt: 2 }; + }, + { skipMaintenance: true }, + ); + + expect(loadSessionStore(storePath, { skipCache: true })).toEqual({ + [sessionKey]: entry, + "agent:main:ordinary": { sessionId: "ordinary-session", updatedAt: 2 }, + }); + }); + + it("rejects locked lifecycle cleanup before transcript side effects", async () => { + const sessionKey = "agent:main:harness:codex:supervision:native-thread"; + const transcriptPath = path.join(tempDir, "native-session.jsonl"); + const entry: SessionEntry = { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionFile: transcriptPath, + sessionId: "native-session", + updatedAt: 1, + }; + await saveSessionStore(storePath, { [sessionKey]: entry }, { skipMaintenance: true }); + fs.writeFileSync(transcriptPath, '{"runId":"codex-supervision-marker"}\n', "utf-8"); + fs.utimesSync(transcriptPath, new Date(1), new Date(1)); + + await expect( + cleanupSessionLifecycleArtifacts({ + storePath, + sessionKeySegmentPrefix: "harness:codex:supervision:", + transcriptContentMarker: "codex-supervision-marker", + orphanTranscriptMinAgeMs: 1, + nowMs: Date.now(), + }), + ).rejects.toThrow("Model-selection-locked sessions cannot be removed"); + + expect(fs.existsSync(transcriptPath)).toBe(true); + expect(fs.readdirSync(tempDir).some((name) => name.includes(".jsonl.deleted."))).toBe(false); + await updateSessionStore( + storePath, + (store) => { + store["agent:main:ordinary"] = { sessionId: "ordinary-session", updatedAt: 2 }; + }, + { skipMaintenance: true }, + ); + expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual(entry); + }); + + it("allows only the dedicated exact harness-initialization rollback", async () => { + const sessionKey = "agent:main:harness:codex:supervision:native-thread"; + const entry: SessionEntry = { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + updatedAt: 1, + }; + const params = { + archiveTranscript: false, + expectedEntry: entry, + expectedSessionId: entry.sessionId, + expectedUpdatedAt: entry.updatedAt, + storePath, + target: { canonicalKey: sessionKey, storeKeys: [sessionKey] }, + }; + await saveSessionStore(storePath, { [sessionKey]: entry }, { skipMaintenance: true }); + + await expect(deleteSessionEntryLifecycle(params)).rejects.toThrow( + "Model-selection-locked sessions cannot be removed", + ); + await expect(rollbackAgentHarnessSessionEntryLifecycle(params)).resolves.toMatchObject({ + deleted: true, + }); + expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toBeUndefined(); + }); + + it("does not expose privileged rollback for an unlocked legacy prefix collision", async () => { + const sessionKey = "agent:main:harness:notes"; + const entry: SessionEntry = { + agentHarnessId: "openclaw", + sessionId: "legacy-session", + updatedAt: 1, + }; + await saveSessionStore(storePath, { [sessionKey]: entry }, { skipMaintenance: true }); + + await expect( + rollbackAgentHarnessSessionEntryLifecycle({ + archiveTranscript: false, + expectedEntry: entry, + expectedSessionId: entry.sessionId, + expectedUpdatedAt: entry.updatedAt, + storePath, + target: { canonicalKey: sessionKey, storeKeys: [sessionKey] }, + }), + ).rejects.toThrow("Model-selection-locked sessions cannot be removed"); + expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual(entry); + }); + + it("does not treat reserved harness keys as relocatable aliases", async () => { + const sourceKey = "agent:main:harness:codex:supervision:source-thread"; + const targetKey = "agent:main:harness:codex:supervision:other-thread"; + const entry: SessionEntry = { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + updatedAt: 1, + }; + await saveSessionStore(storePath, { [sourceKey]: entry }, { skipMaintenance: true }); + + await expect( + saveSessionStore(storePath, { [targetKey]: entry }, { skipMaintenance: true }), + ).rejects.toThrow("Model-selection-locked sessions cannot be removed"); + expect(loadSessionStore(storePath, { skipCache: true })[sourceKey]).toEqual(entry); + }); +}); diff --git a/src/config/sessions/store.pruning.integration.test.ts b/src/config/sessions/store.pruning.integration.test.ts index fe6e2ad4777e..a29030cd4525 100644 --- a/src/config/sessions/store.pruning.integration.test.ts +++ b/src/config/sessions/store.pruning.integration.test.ts @@ -578,6 +578,76 @@ describe("Integration: saveSessionStore with pruning", () => { await expectPathExists(userOnlyPresentTranscript); }); + it("sessions cleanup fix-missing preserves locked header-only harness sessions", async () => { + applyEnforcedMaintenanceConfig(mockLoadConfig); + + const now = Date.now(); + const lockedKey = "agent:main:harness-owned:locked"; + const lockedTranscript = path.join(testDir, "locked-header-only.jsonl"); + const removableTranscript = path.join(testDir, "removable-header-only.jsonl"); + await fs.writeFile( + storePath, + JSON.stringify( + { + [lockedKey]: { + sessionId: "locked-header-only", + updatedAt: now, + agentHarnessId: "fixture-harness", + modelSelectionLocked: true, + }, + "removable-header-only": { + sessionId: "removable-header-only", + updatedAt: now, + }, + } satisfies Record, + null, + 2, + ), + "utf-8", + ); + await fs.writeFile( + lockedTranscript, + '{"type":"session","id":"locked-header-only","cwd":"/tmp"}\n', + "utf-8", + ); + await fs.writeFile( + removableTranscript, + '{"type":"session","id":"removable-header-only","cwd":"/tmp"}\n', + "utf-8", + ); + + const dryRun = await runSessionsCleanup({ + cfg: {}, + opts: { store: storePath, dryRun: true, enforce: true, fixMissing: true }, + targets: [{ agentId: "main", storePath }], + }); + + const preview = dryRun.previewResults[0]; + expect(preview?.summary.missing).toBe(1); + expect(preview?.summary.beforeCount).toBe(2); + expect(preview?.summary.afterCount).toBe(1); + expect(preview?.missingKeys.has(lockedKey)).toBe(false); + expect(preview?.missingKeys.has("removable-header-only")).toBe(true); + expect(loadSessionStore(storePath, { skipCache: true })).toHaveProperty(lockedKey); + + const applied = await runSessionsCleanup({ + cfg: {}, + opts: { store: storePath, enforce: true, fixMissing: true }, + targets: [{ agentId: "main", storePath }], + }); + + expect(applied.appliedSummaries[0]?.missing).toBe(1); + expect(applied.appliedSummaries[0]?.afterCount).toBe(1); + const persisted = loadSessionStore(storePath, { skipCache: true }); + expect(persisted[lockedKey]).toMatchObject({ + sessionId: "locked-header-only", + agentHarnessId: "fixture-harness", + modelSelectionLocked: true, + }); + expect(persisted["removable-header-only"]).toBeUndefined(); + await expectPathExists(lockedTranscript); + }); + it("sessions cleanup repairs stale generated sessionFile metadata before pruning", async () => { applyEnforcedMaintenanceConfig(mockLoadConfig); diff --git a/src/config/sessions/store.pruning.test.ts b/src/config/sessions/store.pruning.test.ts index a10a740ef8c4..61849d021dfb 100644 --- a/src/config/sessions/store.pruning.test.ts +++ b/src/config/sessions/store.pruning.test.ts @@ -93,6 +93,21 @@ describe("pruneStaleEntries", () => { expect(store).toHaveProperty("agent:main:telegram:group:-100123"); expect(store).toHaveProperty("agent:main:discord:channel:ops"); }); + + it("preserves model-locked harness sessions even when stale", () => { + const now = Date.now(); + const lockedKey = "agent:main:harness-owned:locked"; + const store = makeStore([ + [lockedKey, { ...makeEntry(now - 31 * DAY_MS), modelSelectionLocked: true }], + ["old", makeEntry(now - 31 * DAY_MS)], + ]); + + const pruned = pruneStaleEntries(store, 30 * DAY_MS); + + expect(pruned).toBe(1); + expect(store).toHaveProperty(lockedKey); + expect(store.old).toBeUndefined(); + }); }); describe("resolveQuotaSuspensionEntryMaintenance", () => { @@ -507,6 +522,16 @@ describe("pruneStaleModelRunEntries", () => { expect(store).toHaveProperty(staleModelRun); }); + it("preserves model-locked harness sessions from model-run pruning", () => { + const staleModelRun = "agent:main:explicit:model-run-123e4567-e89b-12d3-a456-426614174000"; + const store = makeStore([ + [staleModelRun, { ...makeEntry(Date.now() - 10 * DAY_MS), modelSelectionLocked: true }], + ]); + + expect(pruneStaleModelRunEntries(store, DAY_MS)).toBe(0); + expect(store).toHaveProperty(staleModelRun); + }); + it("matches only explicit model-run uuid session keys", () => { expect( isGatewayModelRunSessionKey( @@ -618,6 +643,23 @@ describe("capEntryCount", () => { expect(store.old).toBeUndefined(); }); + it("preserves model-locked harness sessions when capping", () => { + const now = Date.now(); + const lockedKey = "agent:main:harness-owned:locked"; + const store = makeStore([ + [lockedKey, { ...makeEntry(now - 10 * DAY_MS), modelSelectionLocked: true }], + ["recent", makeEntry(now)], + ["old", makeEntry(now - DAY_MS)], + ]); + + const evicted = capEntryCount(store, 2); + + expect(evicted).toBe(1); + expect(store).toHaveProperty(lockedKey); + expect(store).toHaveProperty("recent"); + expect(store.old).toBeUndefined(); + }); + it("preserves runtime-provided pending subagent sessions when capping", () => { const now = Date.now(); const childKey = "agent:main:subagent:child"; diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 19fdf4b4b3d5..2b2951d2de44 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -2,10 +2,18 @@ import fs from "node:fs"; import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { normalizeOptionalAgentRuntimeId } from "../../agents/agent-runtime-id.js"; import type { MsgContext } from "../../auto-reply/templating.js"; import { resolveStoredSessionOwnerAgentId } from "../../gateway/session-store-key.js"; import { writeTextAtomic } from "../../infra/json-files.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { + AGENT_HARNESS_SESSION_ID_LOCKED_MESSAGE, + isAgentHarnessSessionKey, + isValidAgentHarnessSessionStoreEntry, + resolveAgentHarnessSessionStoreError, + resolveAgentHarnessSessionStoreEntryError, +} from "../../sessions/agent-harness-session-key.js"; import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import { createLazyRuntimeModule } from "../../shared/lazy-runtime.js"; import { @@ -121,6 +129,18 @@ const writerStoreFileStats = new WeakMap< Record, ReturnType | null >(); +const writerLockedSessionEntries = new WeakMap< + Record, + ReadonlyMap +>(); + +const MODEL_SELECTION_LOCK_REMOVAL_MESSAGE = + "Model-selection-locked sessions cannot be removed, unlocked, or reassigned."; + +type SessionStoreInvariantContext = { + allowedLockedEntryRemovals?: ReadonlyMap; + lockedEntriesBefore?: ReadonlyMap; +}; const loadSessionArchiveRuntime = createLazyRuntimeModule( () => import("../../gateway/session-archive.runtime.js"), @@ -338,6 +358,118 @@ function cloneSessionEntry(entry: SessionEntry): SessionEntry { return cloneSessionStoreRecord({ entry }).entry; } +function cloneSessionEntries(store: Record): Record { + return Object.fromEntries( + Object.entries(store).map(([sessionKey, entry]) => [sessionKey, cloneSessionEntry(entry)]), + ); +} + +function replaceSessionEntries( + target: Record, + source: Record, +): void { + for (const sessionKey of Object.keys(target)) { + delete target[sessionKey]; + } + Object.assign(target, cloneSessionEntries(source)); +} + +function snapshotLockedSessionEntries( + store: Record, +): ReadonlyMap { + const lockedEntries = new Map(); + for (const [sessionKey, entry] of Object.entries(store)) { + // Legacy model locks select a model only. Durable harness ownership opts + // into the stronger transcript identity fence enforced during writes. + if (isValidAgentHarnessSessionStoreEntry(sessionKey, entry)) { + lockedEntries.set(sessionKey, cloneSessionEntry(entry)); + } + } + return lockedEntries; +} + +function sessionLockOwnerMatches(previous: SessionEntry, next: SessionEntry): boolean { + const previousOwner = normalizeOptionalString(previous.agentHarnessId)?.toLowerCase(); + const nextOwner = normalizeOptionalString(next.agentHarnessId)?.toLowerCase(); + return ( + previousOwner === nextOwner && + normalizeOptionalAgentRuntimeId(previousOwner) === normalizeOptionalAgentRuntimeId(nextOwner) + ); +} + +function hasEquivalentRelocatedLockedEntry(params: { + previousKey: string; + previousEntry: SessionEntry; + store: Record; +}): boolean { + // Reserved keys are source identities, not aliases. Moving one would let a + // writer detach a native harness binding while retaining only its session id. + if (isAgentHarnessSessionKey(params.previousKey)) { + return false; + } + const sessionId = normalizeOptionalString(params.previousEntry.sessionId); + if (!sessionId) { + return false; + } + for (const [sessionKey, entry] of Object.entries(params.store)) { + if ( + sessionKey === params.previousKey || + entry.modelSelectionLocked !== true || + entry.sessionId !== sessionId || + !sessionLockOwnerMatches(params.previousEntry, entry) + ) { + continue; + } + return true; + } + return false; +} + +function assertLockedSessionEntriesPreserved(params: { + allowedRemovals?: ReadonlyMap; + before?: ReadonlyMap; + store: Record; +}): void { + for (const [sessionKey, previousEntry] of params.before ?? []) { + const nextEntry = params.store[sessionKey]; + if ( + nextEntry?.modelSelectionLocked === true && + sessionLockOwnerMatches(previousEntry, nextEntry) + ) { + if (nextEntry.sessionId !== previousEntry.sessionId) { + throw new Error(AGENT_HARNESS_SESSION_ID_LOCKED_MESSAGE); + } + continue; + } + const allowedRemoval = params.allowedRemovals?.get(sessionKey); + if ( + nextEntry === undefined && + allowedRemoval !== undefined && + JSON.stringify(previousEntry) === JSON.stringify(allowedRemoval) + ) { + continue; + } + if ( + nextEntry === undefined && + hasEquivalentRelocatedLockedEntry({ + previousKey: sessionKey, + previousEntry, + store: params.store, + }) + ) { + continue; + } + throw new Error(MODEL_SELECTION_LOCK_REMOVAL_MESSAGE); + } +} + +function assertValidAgentHarnessSessionEntries(store: Record): void { + const error = resolveAgentHarnessSessionStoreError(store); + if (error) { + throw new Error(error); + } +} + export function projectSessionEntryForPersistenceRevision(params: { storePath: string; entry: SessionEntry; @@ -630,11 +762,13 @@ function loadMutableSessionStoreForWriter(storePath: string): Record, opts?: SaveSessionStoreOptions, + invariantContext?: SessionStoreInvariantContext, ): Promise { normalizeSessionStore(store); + const lockedEntriesBefore = + invariantContext?.lockedEntriesBefore ?? writerLockedSessionEntries.get(store); + assertLockedSessionEntriesPreserved({ + allowedRemovals: invariantContext?.allowedLockedEntryRemovals, + before: lockedEntriesBefore, + store, + }); + assertValidAgentHarnessSessionEntries(store); let maintenanceChangedStore = false; if (!opts?.skipMaintenance) { @@ -860,6 +1003,15 @@ async function saveSessionStoreUnlocked( maintenanceChangedStore = maintenance.changedStore; } + // Maintenance shares the mutable writer-owned object. Recheck after it runs so + // no pruning or future cleanup path can bypass the durable lock invariant. + assertLockedSessionEntriesPreserved({ + allowedRemovals: invariantContext?.allowedLockedEntryRemovals, + before: lockedEntriesBefore, + store, + }); + assertValidAgentHarnessSessionEntries(store); + if ( opts?.skipSerializeForUnchangedStore && !maintenanceChangedStore && @@ -1014,7 +1166,10 @@ export async function saveSessionStore( opts?: SaveSessionStoreOptions, ): Promise { await runExclusiveSessionStoreWrite(storePath, async () => { - await saveSessionStoreUnlocked(storePath, store, opts); + const currentStore = loadSessionStore(storePath, { skipCache: true, clone: false }); + await saveSessionStoreUnlocked(storePath, store, opts, { + lockedEntriesBefore: snapshotLockedSessionEntries(currentStore), + }); }); } @@ -1027,9 +1182,22 @@ export async function updateSessionStore( storePath, async () => { const store = loadMutableSessionStoreForWriter(storePath); + const storeBeforeMutation = opts?.skipSaveWhenResult ? cloneSessionEntries(store) : undefined; const result = await mutator(store); if (opts?.skipSaveWhenResult?.(result)) { - restoreUnchangedSessionStoreCache(storePath, store); + if (!storeBeforeMutation) { + throw new Error("Skipped session-store write is missing its original snapshot."); + } + try { + const lockedEntriesBefore = writerLockedSessionEntries.get(store); + assertLockedSessionEntriesPreserved({ before: lockedEntriesBefore, store }); + assertValidAgentHarnessSessionEntries(store); + } finally { + // A skipped write must return the exact disk-backed snapshot to the object cache, + // including when validation rejects it. Otherwise the next writer can persist poison. + replaceSessionEntries(store, storeBeforeMutation); + restoreUnchangedSessionStoreCache(storePath, store); + } return result; } await saveSessionStoreUnlocked(storePath, store, { @@ -1225,8 +1393,7 @@ export async function resetSessionEntryLifecycle(params: { }); } -/** Deletes one persisted session entry and archives its file-backed transcript artifacts. */ -export async function deleteSessionEntryLifecycle(params: { +type DeleteSessionEntryLifecycleParams = { agentId?: string; archiveTranscript: boolean; expectedEntry?: SessionEntry; @@ -1236,7 +1403,12 @@ export async function deleteSessionEntryLifecycle(params: { requireWriteSuccess?: boolean; storePath: string; target: SessionLifecycleStoreTarget; -}): Promise { +}; + +async function deleteSessionEntryLifecycleInternal( + params: DeleteSessionEntryLifecycleParams, + allowLockedEntryRemoval: boolean, +): Promise { return await runExclusiveSessionStoreWrite(params.storePath, async () => { const store = loadMutableSessionStoreForWriter(params.storePath); // Compare against an unmodified snapshot. Alias promotion is itself a @@ -1283,9 +1455,20 @@ export async function deleteSessionEntryLifecycle(params: { const deletedSessionId = deletedEntry.sessionId; const deletedSessionFile = deletedEntry.sessionFile; delete store[params.target.canonicalKey]; - await saveSessionStoreUnlocked(params.storePath, store, { - requireWriteSuccess: params.requireWriteSuccess, - }); + await saveSessionStoreUnlocked( + params.storePath, + store, + { + requireWriteSuccess: params.requireWriteSuccess, + }, + allowLockedEntryRemoval && deletedEntry.modelSelectionLocked === true + ? { + allowedLockedEntryRemovals: new Map([ + [params.target.canonicalKey, cloneSessionEntry(deletedEntry)], + ]), + } + : undefined, + ); const archivedTranscripts = params.archiveTranscript ? await archiveLifecycleSessionTranscripts({ sessionId: deletedSessionId, @@ -1310,6 +1493,37 @@ export async function deleteSessionEntryLifecycle(params: { }); } +/** Deletes one persisted session entry and archives its file-backed transcript artifacts. */ +export async function deleteSessionEntryLifecycle( + params: DeleteSessionEntryLifecycleParams, +): Promise { + return await deleteSessionEntryLifecycleInternal(params, false); +} + +/** + * Rolls back the exact locked row created by a failed trusted harness initialization. + * This stays separate from public deletion so the lock-removal capability cannot leak. + */ +export async function rollbackAgentHarnessSessionEntryLifecycle( + params: DeleteSessionEntryLifecycleParams & { expectedEntry: SessionEntry }, +): Promise { + const hasExactTarget = + params.target.storeKeys.length === 1 && + params.target.storeKeys[0] === params.target.canonicalKey; + const expectedEntryError = resolveAgentHarnessSessionStoreEntryError( + params.target.canonicalKey, + params.expectedEntry, + ); + if ( + !hasExactTarget || + expectedEntryError || + !isValidAgentHarnessSessionStoreEntry(params.target.canonicalKey, params.expectedEntry) + ) { + throw new Error(expectedEntryError ?? MODEL_SELECTION_LOCK_REMOVAL_MESSAGE); + } + return await deleteSessionEntryLifecycleInternal(params, true); +} + function shouldRemoveSessionEntry( entry: SessionEntry | undefined, removal: SessionEntryLifecycleRemoval, @@ -1487,6 +1701,7 @@ export async function purgeDeletedAgentSessionEntries( await runExclusiveSessionStoreWrite(storePath, async () => { const store = loadMutableSessionStoreForWriter(storePath); + const allowedLockedEntryRemovals = new Map(); for (const sessionKey of Object.keys(store)) { const ownerAgentId = resolveStoredSessionOwnerAgentId({ cfg: params.cfg, @@ -1494,15 +1709,24 @@ export async function purgeDeletedAgentSessionEntries( sessionKey, }); if (ownerAgentId === params.agentId) { + const entry = store[sessionKey]; + if (entry?.modelSelectionLocked === true) { + allowedLockedEntryRemovals.set(sessionKey, cloneSessionEntry(entry)); + } delete store[sessionKey]; removedSessionKeys.push(sessionKey); } } - await saveSessionStoreUnlocked(storePath, store, { - onMaintenanceApplied: (report) => { - maintenanceReport = report; + await saveSessionStoreUnlocked( + storePath, + store, + { + onMaintenanceApplied: (report) => { + maintenanceReport = report; + }, }, - }); + { allowedLockedEntryRemovals }, + ); afterCount = Object.keys(store).length; }); @@ -1604,7 +1828,8 @@ export async function cleanupSessionLifecycleArtifacts( let archivedTranscriptArtifacts = 0; await runExclusiveSessionStoreWrite(storePath, async () => { - const store = loadMutableSessionStoreForWriter(storePath); + const mutableStore = loadMutableSessionStoreForWriter(storePath); + const store = cloneSessionEntries(mutableStore); // Delete only rows owned by the named lifecycle. Orphan transcript cleanup // reacquires this writer lock later so its reference set cannot go stale. for (const [sessionKey, entry] of Object.entries(store)) { @@ -1630,8 +1855,16 @@ export async function cleanupSessionLifecycleArtifacts( } } + // Reject protected-row cleanup before archiving transcripts or removing + // trajectory artifacts; persistence-time validation is too late for those side effects. + assertLockedSessionEntriesPreserved({ + before: writerLockedSessionEntries.get(mutableStore), + store, + }); + assertValidAgentHarnessSessionEntries(store); + if (removedEntries === 0) { - restoreUnchangedSessionStoreCache(storePath, store); + restoreUnchangedSessionStoreCache(storePath, mutableStore); return; } @@ -1658,7 +1891,8 @@ export async function cleanupSessionLifecycleArtifacts( storePath, restrictToStoreDir: true, }); - await saveSessionStoreUnlocked(storePath, store, { skipMaintenance: true }); + replaceSessionEntries(mutableStore, store); + await saveSessionStoreUnlocked(storePath, mutableStore, { skipMaintenance: true }); }); return { diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index a83bb730742a..a2346fa302e2 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -246,6 +246,8 @@ export type SessionEntry = { heartbeatTaskState?: Record; /** Plugin-owned session state, grouped by plugin id then extension namespace. */ pluginExtensions?: Record>; + /** Trusted session initialization is incomplete; all work admission stays blocked. */ + initializationPending?: true; /** Top-level SessionEntry mirror slots owned by plugin session extensions. */ pluginExtensionSlotKeys?: Record>; /** Durable one-shot prompt additions drained before the next agent turn. */ @@ -435,6 +437,11 @@ export type SessionEntry = { cacheWrite?: number; modelProvider?: string; model?: string; + /** + * Prevents OpenClaw model changes and automatic maintenance eviction until + * the owning harness explicitly retires the session. + */ + modelSelectionLocked?: boolean; /** * Embedded agent harness selected for this session id. * Prevents config/env changes from moving an existing transcript between diff --git a/src/crestodian/setup-apply.test.ts b/src/crestodian/setup-apply.test.ts index b173f3fe8d05..0720566b2d72 100644 --- a/src/crestodian/setup-apply.test.ts +++ b/src/crestodian/setup-apply.test.ts @@ -285,6 +285,25 @@ describe("applyCrestodianSetup transaction boundaries", () => { expect(result.configPath).toBe("/tmp/openclaw.json"); }); + it("finalizes setup against the source config held by the commit lock", async () => { + const sourceConfig = { + plugins: { entries: { codex: { config: { supervision: { enabled: false } } } } }, + } satisfies OpenClawConfig; + mocks.state.commitSnapshot = { + ...snapshot("probe", mocks.state.commitConfig), + sourceConfig, + }; + const finalizeConfig = vi.fn((config: OpenClawConfig, source: OpenClawConfig) => ({ + ...config, + plugins: source.plugins, + })); + + await applyCrestodianSetup(baseParams({ expectedConfigHash: "probe", finalizeConfig })); + + expect(finalizeConfig).toHaveBeenCalledWith(expect.any(Object), sourceConfig); + expect(mocks.state.persistedConfig?.plugins).toEqual(sourceConfig.plugins); + }); + it("returns visible post-commit workspace, approval, registry, and service failures", async () => { mocks.ensureWorkspace.mockRejectedValueOnce(new Error("workspace exploded")); mocks.updateExecApprovals.mockRejectedValueOnce(new Error("approval exploded")); diff --git a/src/crestodian/setup-apply.ts b/src/crestodian/setup-apply.ts index ca486f46d23a..f29bd58cf8d3 100644 --- a/src/crestodian/setup-apply.ts +++ b/src/crestodian/setup-apply.ts @@ -30,6 +30,8 @@ export type CrestodianSetupApplyParams = { expectedConfigHash?: string | null; /** Provider-auth config produced in the isolated manual-key flow. */ configPatch?: unknown; + /** Success-gated final normalization against the config held by the write lock. */ + finalizeConfig?: (config: OpenClawConfig, sourceConfig: OpenClawConfig) => OpenClawConfig; /** Plugin whose enablement belongs to the successful setup transaction. */ enablePluginId?: string; /** Refresh an installed plugin after its success-gated enablement commits. */ @@ -173,6 +175,7 @@ export async function applyCrestodianSetup( expectedModelRef, expectedConfigHash, configPatch, + finalizeConfig, enablePluginId, refreshPluginRegistry, assertCommitPreconditions, @@ -307,8 +310,9 @@ export async function applyCrestodianSetup( // ordered after setup, exactly like a credential change after return. // Never hold the synchronous SQLite transaction across async config I/O. assertCommitPreconditions?.(); + const merged = mergeWizardConfigOntoLatest(currentConfig, baseConfig, nextConfig); return { - nextConfig: mergeWizardConfigOntoLatest(currentConfig, baseConfig, nextConfig), + nextConfig: finalizeConfig ? finalizeConfig(merged, context.snapshot.sourceConfig) : merged, }; }, }); diff --git a/src/crestodian/setup-inference.test.ts b/src/crestodian/setup-inference.test.ts index 89225c955e23..b5e46328d446 100644 --- a/src/crestodian/setup-inference.test.ts +++ b/src/crestodian/setup-inference.test.ts @@ -7,6 +7,7 @@ import { removeOAuthTestTempRoot, } from "../agents/auth-profiles/oauth-test-utils.js"; import { upsertAuthProfileWithLock } from "../agents/auth-profiles/profiles.js"; +import { applyMergePatch } from "../config/merge-patch.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { withoutPluginInstallRecords } from "../plugins/installed-plugin-index-records.js"; @@ -46,6 +47,11 @@ vi.mock("../commands/onboard-inference.js", async (importActual) => { const actual = await importActual(); return { ...actual, + detectNativeCodexAppServer: vi.fn(async () => ({ + command: "codex", + found: false, + error: "not found", + })), detectInferenceBackends: vi.fn(async () => [ { kind: "claude-cli", @@ -164,6 +170,7 @@ describe("detectSetupInference", () => { expect(detection.candidates).toHaveLength(2); expect(detection.candidates[0]).toMatchObject({ kind: "claude-cli", recommended: false }); expect(detection.candidates[1]).toMatchObject({ kind: "codex-cli", recommended: false }); + expect(detection.codexAppServerDetected).toBe(true); expect(detection.setupComplete).toBe(false); expect(detection.workspace.length).toBeGreaterThan(0); expect(resolveManifestProviderAuthChoices).toHaveBeenCalledWith( @@ -262,6 +269,75 @@ describe("activateSetupInference", () => { vi.restoreAllMocks(); }); + async function runCodexSetupWithFinalConfig(params: { + initialConfig?: OpenClawConfig; + currentConfig: OpenClawConfig; + sourceConfig: OpenClawConfig; + }) { + const initialConfig = params.initialConfig ?? params.sourceConfig; + let persistedConfig = structuredClone(params.currentConfig); + const applySetup = vi.fn( + async (input: { + configPatch?: unknown; + finalizeConfig?: (config: OpenClawConfig, sourceConfig: OpenClawConfig) => OpenClawConfig; + }) => { + const patched = + input.configPatch === undefined + ? persistedConfig + : (applyMergePatch(persistedConfig, input.configPatch) as OpenClawConfig); + persistedConfig = input.finalizeConfig + ? input.finalizeConfig(patched, params.sourceConfig) + : patched; + return { configPath: "/tmp/openclaw.json", lines: ["ok"] }; + }, + ); + const refreshPluginRegistry = vi.fn(async () => {}); + const transformConfig = vi.fn( + async (input: { + transform: ( + config: OpenClawConfig, + context: { snapshot: { sourceConfig: OpenClawConfig } }, + ) => { nextConfig: OpenClawConfig }; + }) => { + const transformed = input.transform(persistedConfig, { + snapshot: { sourceConfig: params.sourceConfig }, + }); + persistedConfig = withoutPluginInstallRecords(transformed.nextConfig); + return { nextConfig: persistedConfig }; + }, + ); + const result = await activateSetupInference({ + kind: "codex-cli", + workspace: "/tmp/openclaw-workspace", + surface: "gateway", + runtime, + deps: { + readConfigFileSnapshot: vi.fn(async () => ({ + exists: true, + valid: true, + path: "/tmp/openclaw.json", + issues: [], + config: initialConfig, + runtimeConfig: initialConfig, + })) as never, + runEmbeddedAgent: vi.fn(async () => ({ + meta: { finalAssistantVisibleText: "OK" }, + })) as never, + ensureCodexRuntimePlugin: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ + cfg, + required: true, + installed: true, + status: "installed" as const, + })) as never, + transformConfigWithPendingPluginInstalls: transformConfig as never, + refreshPluginRegistryAfterConfigMutation: refreshPluginRegistry as never, + applySetup: applySetup as never, + createTempDir: makeTempDir, + }, + }); + return { result, persistedConfig, applySetup, refreshPluginRegistry, transformConfig }; + } + it("persists setup only after the live test succeeds", async () => { const applySetup = vi.fn(async (_params: unknown) => ({ configPath: "/tmp/openclaw.json", @@ -469,6 +545,173 @@ describe("activateSetupInference", () => { ); }); + it("enables detected Codex supervision while selecting Claude as the primary backend", async () => { + const sourceConfig = {} satisfies OpenClawConfig; + let persistedConfig: OpenClawConfig = {}; + const pendingCodexInstall = { + source: "npm" as const, + spec: "@openclaw/codex", + installPath: "/tmp/plugins/codex", + }; + const transformConfig = vi.fn( + async (input: { + transform: ( + config: OpenClawConfig, + context: { snapshot: { sourceConfig: OpenClawConfig } }, + ) => { nextConfig: OpenClawConfig }; + }) => { + const transformed = input.transform(persistedConfig, { + snapshot: { sourceConfig }, + }); + persistedConfig = withoutPluginInstallRecords(transformed.nextConfig); + return { nextConfig: persistedConfig }; + }, + ); + const ensureCodexRuntimePlugin = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ + cfg: { + ...cfg, + plugins: { + ...cfg.plugins, + installs: { codex: pendingCodexInstall }, + }, + }, + required: true, + installed: true, + status: "installed" as const, + })); + const runCliAgent = vi.fn(async () => ({ + meta: { finalAssistantVisibleText: "OK" }, + })); + const refreshPluginRegistry = vi.fn(async () => {}); + const applySetup = vi.fn(async () => ({ configPath: "/tmp/openclaw.json", lines: ["ok"] })); + + const result = await activateSetupInference({ + kind: "claude-cli", + surface: "gateway", + runtime, + deps: { + readConfigFileSnapshot: vi.fn(async () => ({ + exists: true, + valid: true, + path: "/tmp/openclaw.json", + issues: [], + sourceConfig, + config: sourceConfig, + runtimeConfig: sourceConfig, + })) as never, + detectNativeCodexAppServer: vi.fn(async () => ({ command: "codex", found: true })), + ensureCodexRuntimePlugin: ensureCodexRuntimePlugin as never, + transformConfigWithPendingPluginInstalls: transformConfig as never, + refreshPluginRegistryAfterConfigMutation: refreshPluginRegistry as never, + runCliAgent: runCliAgent as never, + applySetup: applySetup as never, + createTempDir: makeTempDir, + }, + }); + + expect(result.ok).toBe(true); + expect(runCliAgent).toHaveBeenCalledWith( + expect.objectContaining({ provider: "claude-cli", model: "claude-opus-4-8" }), + ); + expect(ensureCodexRuntimePlugin).toHaveBeenCalledOnce(); + expect(transformConfig).toHaveBeenCalledTimes(2); + expect(persistedConfig).toEqual({ + plugins: { + entries: { + codex: { + enabled: true, + config: { supervision: { enabled: true } }, + }, + }, + }, + }); + expect(refreshPluginRegistry).toHaveBeenCalledOnce(); + expect(applySetup).toHaveBeenCalledWith( + expect.objectContaining({ model: "claude-cli/claude-opus-4-8" }), + ); + }); + + it("does not configure Codex supervision when native App Server detection fails", async () => { + const ensureCodexRuntimePlugin = vi.fn(); + const transformConfig = vi.fn(); + const detectNativeCodexAppServer = vi.fn(async () => ({ + command: "codex", + found: false, + error: "not found", + })); + + const result = await activateSetupInference({ + kind: "claude-cli", + surface: "gateway", + runtime, + deps: { + detectNativeCodexAppServer, + ensureCodexRuntimePlugin: ensureCodexRuntimePlugin as never, + transformConfigWithPendingPluginInstalls: transformConfig as never, + runCliAgent: vi.fn(async () => ({ + meta: { finalAssistantVisibleText: "OK" }, + })) as never, + applySetup: vi.fn(async () => ({ configPath: "/tmp/openclaw.json", lines: [] })) as never, + createTempDir: makeTempDir, + }, + }); + + expect(result.ok).toBe(true); + expect(detectNativeCodexAppServer).toHaveBeenCalledOnce(); + expect(ensureCodexRuntimePlugin).not.toHaveBeenCalled(); + expect(transformConfig).not.toHaveBeenCalled(); + }); + + it.each([ + [ + "an explicitly disabled Codex plugin", + { plugins: { entries: { codex: { enabled: false } } } } satisfies OpenClawConfig, + ], + [ + "an explicit supervision opt-out", + { + plugins: { + entries: { codex: { config: { supervision: { enabled: false } } } }, + }, + } satisfies OpenClawConfig, + ], + ["plugin policy", { plugins: { deny: ["codex"] } } satisfies OpenClawConfig], + ])("preserves %s while selecting another backend", async (_label, config) => { + const detectNativeCodexAppServer = vi.fn(async () => ({ command: "codex", found: true })); + const ensureCodexRuntimePlugin = vi.fn(); + const transformConfig = vi.fn(); + + const result = await activateSetupInference({ + kind: "claude-cli", + surface: "gateway", + runtime, + deps: { + readConfigFileSnapshot: vi.fn(async () => ({ + exists: true, + valid: true, + path: "/tmp/openclaw.json", + issues: [], + sourceConfig: config, + config, + runtimeConfig: config, + })) as never, + detectNativeCodexAppServer, + ensureCodexRuntimePlugin: ensureCodexRuntimePlugin as never, + transformConfigWithPendingPluginInstalls: transformConfig as never, + runCliAgent: vi.fn(async () => ({ + meta: { finalAssistantVisibleText: "OK" }, + })) as never, + applySetup: vi.fn(async () => ({ configPath: "/tmp/openclaw.json", lines: [] })) as never, + createTempDir: makeTempDir, + }, + }); + + expect(result.ok).toBe(true); + expect(detectNativeCodexAppServer).not.toHaveBeenCalled(); + expect(ensureCodexRuntimePlugin).not.toHaveBeenCalled(); + expect(transformConfig).not.toHaveBeenCalled(); + }); + it("does not touch config when the live test fails", async () => { const providerSecret = "gsk_abcdefghijklmnop"; const applySetup = vi.fn(async () => ({ configPath: "/tmp/openclaw.json", lines: [] })); @@ -1420,10 +1663,22 @@ describe("activateSetupInference", () => { }, }, } satisfies OpenClawConfig; - const applySetup = vi.fn(async () => { - events.push("persist-setup"); - return { configPath: "/tmp/openclaw.json", lines: ["ok"] }; - }); + const applySetup = vi.fn( + async (input: { + configPatch?: unknown; + finalizeConfig?: (config: OpenClawConfig, sourceConfig: OpenClawConfig) => OpenClawConfig; + }) => { + events.push("persist-setup"); + const patched = + input.configPatch === undefined + ? persistedConfig + : (applyMergePatch(persistedConfig, input.configPatch) as OpenClawConfig); + persistedConfig = input.finalizeConfig + ? input.finalizeConfig(patched, persistedConfig) + : patched; + return { configPath: "/tmp/openclaw.json", lines: ["ok"] }; + }, + ); const ensureCodex = vi.fn(async (params: { cfg: OpenClawConfig }) => { events.push("install-plugin"); return { @@ -1463,9 +1718,23 @@ describe("activateSetupInference", () => { }; const pendingCodexInstalls: unknown[] = []; const transformConfig = vi.fn( - async (params: { transform: (config: OpenClawConfig) => { nextConfig: OpenClawConfig } }) => { - const transformed = params.transform(persistedConfig).nextConfig; - events.push("persist-plugin-install"); + async (params: { + transform: ( + config: OpenClawConfig, + context: { snapshot: { sourceConfig: OpenClawConfig } }, + ) => { nextConfig: OpenClawConfig }; + }) => { + const transformed = params.transform(persistedConfig, { + snapshot: { sourceConfig: persistedConfig }, + }).nextConfig; + const configuredRuntime = + transformed.agents?.defaults?.models?.["openai/gpt-5.6-sol"]?.agentRuntime?.id ?? + transformed.agents?.list?.find((agent) => agent.id === "ops")?.models?.[ + "openai/gpt-5.6-sol" + ]?.agentRuntime?.id; + events.push( + configuredRuntime === "codex" ? "persist-plugin-config" : "persist-plugin-install", + ); pendingCodexInstalls.push(transformed.plugins?.installs?.codex); persistedConfig = withoutPluginInstallRecords(transformed); return { nextConfig: persistedConfig }; @@ -1587,18 +1856,24 @@ describe("activateSetupInference", () => { expect.objectContaining({ id: "ops", model: { - primary: "anthropic/claude-opus-4-8", + primary: "openai/gpt-5.6-sol", fallbacks: ["google/gemini-3.1-pro-preview"], }, - models: { "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } } }, + models: { + "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } }, + "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } }, + }, }), ], }, plugins: { entries: { codex: { - enabled: false, - config: { appServer: { command: "codex", mode: "yolo" } }, + enabled: true, + config: { + appServer: { command: "codex", mode: "yolo" }, + supervision: { enabled: true }, + }, }, }, }, @@ -1687,7 +1962,16 @@ describe("activateSetupInference", () => { }), ]), }), - plugins: { entries: { codex: { enabled: true } } }, + plugins: expect.objectContaining({ + entries: expect.objectContaining({ + codex: expect.objectContaining({ + enabled: true, + config: expect.objectContaining({ + supervision: { enabled: true }, + }), + }), + }), + }), }), }), ); @@ -1753,8 +2037,15 @@ describe("activateSetupInference", () => { let installIndex: Record = structuredClone(canonicalRecords); const pendingInstallRecords: unknown[] = []; const transformConfig = vi.fn( - async (params: { transform: (config: OpenClawConfig) => { nextConfig: OpenClawConfig } }) => { - const transformed = params.transform(persistedConfig).nextConfig; + async (params: { + transform: ( + config: OpenClawConfig, + context: { snapshot: { sourceConfig: OpenClawConfig } }, + ) => { nextConfig: OpenClawConfig }; + }) => { + const transformed = params.transform(persistedConfig, { + snapshot: { sourceConfig: persistedConfig }, + }).nextConfig; const pending = transformed.plugins?.installs; pendingInstallRecords.push(pending); installIndex = { ...installIndex, ...pending }; @@ -2020,6 +2311,131 @@ describe("activateSetupInference", () => { }); expect(applySetup).not.toHaveBeenCalled(); }); + + it.each([ + ["omitted", {} satisfies OpenClawConfig], + [ + "an empty object", + { + plugins: { + entries: { codex: { config: { supervision: {} } } }, + }, + } satisfies OpenClawConfig, + ], + ])("enables Codex supervision when it is %s", async (_label, config) => { + const { result, persistedConfig, applySetup, transformConfig } = + await runCodexSetupWithFinalConfig({ + currentConfig: config, + sourceConfig: config, + }); + + expect(result.ok).toBe(true); + expect(persistedConfig.plugins?.entries?.codex).toMatchObject({ + enabled: true, + config: { supervision: { enabled: true } }, + }); + expect(transformConfig).not.toHaveBeenCalled(); + expect(applySetup).toHaveBeenCalledOnce(); + }); + + it("preserves an explicit Codex supervision opt-out from the latest config", async () => { + const config = { + plugins: { + entries: { + codex: { + enabled: false, + config: { + discovery: { enabled: true }, + supervision: { enabled: false, allowRawTranscripts: true }, + }, + }, + }, + }, + } satisfies OpenClawConfig; + + const { result, persistedConfig } = await runCodexSetupWithFinalConfig({ + currentConfig: config, + sourceConfig: config, + }); + + expect(result.ok).toBe(true); + expect(persistedConfig.plugins?.entries?.codex).toEqual({ + enabled: true, + config: { + discovery: { enabled: true }, + supervision: { enabled: false, allowRawTranscripts: true }, + }, + }); + }); + + it("preserves a normalized Codex supervision opt-out", async () => { + const config = { + plugins: { + allow: [" CODEX "], + entries: { + " CODEX ": { + config: { + appServer: { transport: "websocket", url: "ws://127.0.0.1:4500" }, + supervision: { enabled: false }, + }, + }, + }, + }, + } satisfies OpenClawConfig; + + const { result, persistedConfig } = await runCodexSetupWithFinalConfig({ + currentConfig: config, + sourceConfig: config, + }); + + expect(result.ok).toBe(true); + expect(persistedConfig.plugins?.allow).toEqual(["codex"]); + expect(persistedConfig.plugins?.entries).toEqual({ + codex: { + enabled: true, + config: { + appServer: { transport: "websocket", url: "ws://127.0.0.1:4500" }, + supervision: { enabled: false }, + }, + }, + }); + }); + + it("preserves an include-owned Codex supervision opt-out without copying it to root", async () => { + const resolvedSource = { + plugins: { + entries: { + codex: { config: { supervision: { enabled: false } } }, + }, + }, + } satisfies OpenClawConfig; + + const { result, persistedConfig } = await runCodexSetupWithFinalConfig({ + initialConfig: resolvedSource, + currentConfig: {}, + sourceConfig: resolvedSource, + }); + + expect(result.ok).toBe(true); + expect(persistedConfig.plugins?.entries?.codex).toEqual({ enabled: true }); + }); + + it("fails closed when effective plugin policy changes before the success commit", async () => { + const denied = { plugins: { deny: ["codex"] } } satisfies OpenClawConfig; + const { result, applySetup, refreshPluginRegistry } = await runCodexSetupWithFinalConfig({ + initialConfig: {}, + currentConfig: denied, + sourceConfig: denied, + }); + + expect(result).toMatchObject({ + ok: false, + status: "unavailable", + error: expect.stringContaining("blocked by denylist"), + }); + expect(refreshPluginRegistry).not.toHaveBeenCalled(); + expect(applySetup).toHaveBeenCalledOnce(); + }); }); describe("verifySetupInference", () => { diff --git a/src/crestodian/setup-inference.ts b/src/crestodian/setup-inference.ts index 10158589f247..c356abef704f 100644 --- a/src/crestodian/setup-inference.ts +++ b/src/crestodian/setup-inference.ts @@ -1,9 +1,9 @@ -// First-run inference activation: detect candidates, live-test, persist only on success. import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { isDeepStrictEqual } from "node:util"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { resolveAgentDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { normalizeAuthProfileCredential } from "../agents/auth-profiles/credential-normalize.js"; import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js"; @@ -22,17 +22,20 @@ import { CODEX_APP_SERVER_DEFAULT_MODEL_REF, GEMINI_CLI_DEFAULT_MODEL_REF, OPENAI_API_DEFAULT_MODEL_REF, + detectNativeCodexAppServer, detectInferenceBackends, type InferenceBackendKind, } from "../commands/onboard-inference.js"; import { resolveConfigSnapshotHash } from "../config/config.js"; import { createMergePatch } from "../config/io.write-prepare.js"; +import { applyMergePatch } from "../config/merge-patch.js"; import { normalizeAgentModelRefForConfig, resolveAgentModelPrimaryValue, } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; +import { normalizePluginsConfig, normalizePluginTargetConfig } from "../plugins/config-state.js"; import { enablePluginInConfig } from "../plugins/enable.js"; import { applyProviderPluginAuthMethodResultConfig, @@ -55,6 +58,7 @@ import { createQuickstartNotePrompter, } from "./setup-apply.js"; +// First-run inference activation: detect candidates, live-test, persist only on success. /** * Inference is the one required onboarding step (docs/cli/crestodian.md * "Setup bootstrap"). This module gives structured clients (macOS app) the @@ -85,6 +89,8 @@ export type SetupInferenceManualProvider = { export type SetupInferenceDetection = { candidates: SetupInferenceCandidate[]; + /** A native Codex binary can provide supervision independently of the selected model. */ + codexAppServerDetected: boolean; /** Text-inference key/token methods exposed by installed provider manifests. */ manualProviders: SetupInferenceManualProvider[]; /** Resolved workspace the setup apply would use (display + default). */ @@ -134,7 +140,9 @@ export type ActivateSetupInferenceDeps = { runCliAgent?: typeof import("../agents/cli-runner.js").runCliAgent; applySetup?: typeof applyCrestodianSetup; ensureCodexRuntimePlugin?: typeof import("../commands/codex-runtime-plugin-install.js").ensureCodexRuntimePluginForModelSelection; + detectNativeCodexAppServer?: typeof detectNativeCodexAppServer; transformConfigWithPendingPluginInstalls?: typeof import("../plugins/install-record-commit.js").transformConfigWithPendingPluginInstalls; + refreshPluginRegistryAfterConfigMutation?: typeof import("../plugins/registry-refresh.js").refreshPluginRegistryAfterConfigMutation; resolvePluginProviders?: typeof resolvePluginProviders; resolveManifestProviderAuthChoice?: typeof resolveManifestProviderAuthChoice; enablePluginInConfig?: typeof enablePluginInConfig; @@ -175,6 +183,84 @@ function probedTargetChangedError(params: { return undefined; } +function hasExplicitCodexSupervisionOptOut(config: OpenClawConfig): boolean { + const pluginConfig = normalizePluginsConfig(config.plugins).entries.codex?.config; + const supervision = isRecord(pluginConfig) ? pluginConfig.supervision : undefined; + return isRecord(supervision) && supervision.enabled === false; +} + +function canAutoEnableCodexSupervision(config: OpenClawConfig): boolean { + const normalizedConfig = normalizePluginTargetConfig(config, "codex"); + if ( + normalizedConfig.plugins?.entries?.codex?.enabled === false || + hasExplicitCodexSupervisionOptOut(normalizedConfig) + ) { + return false; + } + return enablePluginInConfig(normalizedConfig, "codex").enabled; +} +function enableCodexSupervisionForGuidedSetup( + config: OpenClawConfig, + sourceConfig: OpenClawConfig = config, +): OpenClawConfig { + // Policy and include-owned opt-outs live in the resolved source config. + // Runtime defaults cannot distinguish an omitted value from an authored false. + const sourceEnabled = enablePluginInConfig( + normalizePluginTargetConfig(sourceConfig, "codex"), + "codex", + ); + if (!sourceEnabled.enabled) { + throw new CodexPluginPolicyBlockedError(sourceEnabled.reason); + } + const enabled = enablePluginInConfig(normalizePluginTargetConfig(config, "codex"), "codex"); + if (!enabled.enabled) { + throw new CodexPluginPolicyBlockedError(enabled.reason); + } + + const codex = enabled.config.plugins?.entries?.codex; + const pluginConfig = codex?.config ?? {}; + const sourceSupervision = sourceEnabled.config.plugins?.entries?.codex?.config?.supervision; + // A nested false is the explicit supervision opt-out. Selecting Codex still + // enables its harness, but onboarding must not silently reverse that choice. + if (isRecord(sourceSupervision) && sourceSupervision.enabled === false) { + return enabled.config; + } + const supervision = isRecord(pluginConfig.supervision) ? pluginConfig.supervision : {}; + + return { + ...enabled.config, + plugins: { + ...enabled.config.plugins, + entries: { + ...enabled.config.plugins?.entries, + codex: { + ...codex, + config: { + ...pluginConfig, + supervision: { + ...supervision, + enabled: true, + }, + }, + }, + }, + }, + }; +} + +class CodexPluginPolicyBlockedError extends Error { + constructor(readonly reason?: string) { + super(reason ?? "plugin policy"); + } +} + +function codexPluginPolicyError(reason?: string): ActivateSetupInferenceResult { + return { + ok: false, + status: "unavailable", + error: `Codex plugin activation is blocked (${reason ?? "plugin policy"}); update plugin policy and retry setup.`, + }; +} async function resolveSetupInferenceWorkspace(params: { configExists: boolean; configValid: boolean; @@ -248,6 +334,7 @@ export async function detectSetupInference( }).filter((choice) => enablePluginInConfig(cfg, choice.pluginId).enabled); return { candidates, + codexAppServerDetected: candidates.some((candidate) => candidate.kind === "codex-cli"), manualProviders: listSetupInferenceManualProviders(authChoices), workspace, ...(configuredModel ? { configuredModel } : {}), @@ -713,6 +800,18 @@ async function activateSetupInferenceUnredacted( } let probedConfigHash = resolveConfigSnapshotHash(snapshot); const cfg: OpenClawConfig = snapshot.exists ? (snapshot.runtimeConfig ?? snapshot.config) : {}; + const sourceConfig: OpenClawConfig = + snapshot.exists && snapshot.valid ? (snapshot.sourceConfig ?? snapshot.config) : {}; + const explicitCodexSelection = params.kind === "codex-cli"; + const detectedCodexAppServer = + !explicitCodexSelection && canAutoEnableCodexSupervision(sourceConfig) + ? await (deps.detectNativeCodexAppServer ?? detectNativeCodexAppServer)() + : undefined; + const codexPluginActivation = explicitCodexSelection + ? "selected" + : detectedCodexAppServer?.found === true + ? "detected" + : undefined; const workspace = params.workspace?.trim() ? resolveUserPath(params.workspace) : ( @@ -759,13 +858,19 @@ async function activateSetupInferenceUnredacted( }; } - if (params.kind === "codex-cli") { + let codexPluginPatch: unknown; + if (codexPluginActivation) { const { stripPendingPluginInstallRecords } = await import("../plugins/install-record-commit.js"); - // This explicit Codex CLI choice owns its runtime independently of the - // user's existing OpenAI provider route (which may use a custom base URL). - const codexInstallBase = stripPendingPluginInstallRecords(testPlan.config); - const enabledCodexBase = enablePluginInConfig(codexInstallBase, "codex"); + // An explicit Codex choice owns its model-scoped runtime. Opportunistic + // supervision only adds the plugin and leaves the selected model untouched. + const codexInstallBase = stripPendingPluginInstallRecords( + codexPluginActivation === "selected" ? testPlan.config : cfg, + ); + const enabledCodexBase = enablePluginInConfig( + normalizePluginTargetConfig(codexInstallBase, "codex"), + "codex", + ); if (!enabledCodexBase.enabled) { return { ok: false, @@ -773,10 +878,12 @@ async function activateSetupInferenceUnredacted( error: `Could not enable the Codex runtime plugin: ${enabledCodexBase.reason ?? "plugin disabled"}.`, }; } + const codexInstall = await import("../commands/codex-runtime-plugin-install.js"); const ensureCodex = deps.ensureCodexRuntimePlugin ?? - (await import("../commands/codex-runtime-plugin-install.js")) - .ensureCodexRuntimePluginForModelSelection; + (codexPluginActivation === "selected" + ? codexInstall.ensureCodexRuntimePluginForModelSelection + : codexInstall.ensureCodexRuntimePluginForSupervision); const ensured = await ensureCodex({ cfg: enabledCodexBase.config, model: plan.modelRef, @@ -786,103 +893,123 @@ async function activateSetupInferenceUnredacted( workspaceDir: tempDir, }); if (!ensured.installed) { - return { - ok: false, - status: ensured.status === "timed_out" ? "timeout" : "unavailable", - error: + if (codexPluginActivation === "detected") { + params.runtime.log?.( ensured.status === "timed_out" - ? "Codex runtime plugin installation timed out. Try again." - : ensured.reason - ? `Could not enable the Codex runtime plugin: ${ensured.reason}.` - : "Could not install the Codex runtime plugin. Try again once the plugin is available.", - }; - } - const pendingCodexInstall = ensured.cfg.plugins?.installs?.codex; - if (pendingCodexInstall) { - // The package is already in the managed global root. Record ownership now so a - // failed or abandoned live probe cannot leave an untracked install behind. - const transformConfig = - deps.transformConfigWithPendingPluginInstalls ?? - (await import("../plugins/install-record-commit.js")) - .transformConfigWithPendingPluginInstalls; - const committed = await transformConfig({ - afterWrite: { - mode: "none", - reason: "Crestodian records the installed Codex runtime before probing", - }, - transform: (current) => { - const strippedCurrent = stripPendingPluginInstallRecords(current); - return { - nextConfig: { - ...strippedCurrent, - plugins: { - ...strippedCurrent.plugins, - installs: { codex: pendingCodexInstall }, - }, - }, - }; - }, - }); - try { - await appendCrestodianAuditEntry({ - operation: "plugin.install", - summary: "Installed Codex runtime plugin", - configPath: committed.path, - configHashBefore: committed.previousHash, - configHashAfter: committed.persistedHash, - details: { pluginId: "codex", via: "crestodian.setup" }, - }); - } catch (error) { - const warning = `Codex was installed, but OpenClaw could not record its audit entry: ${formatErrorMessage(error)}`; - params.runtime.error?.(warning); - setupWarnings.push(warning); + ? "Codex supervision plugin installation timed out; continuing setup without supervision." + : `Codex supervision plugin could not be enabled${ensured.reason ? `: ${ensured.reason}` : ""}; continuing setup without supervision.`, + ); + } else { + return { + ok: false, + status: ensured.status === "timed_out" ? "timeout" : "unavailable", + error: + ensured.status === "timed_out" + ? "Codex runtime plugin installation timed out. Try again." + : ensured.reason + ? `Could not enable the Codex runtime plugin: ${ensured.reason}.` + : "Could not install the Codex runtime plugin. Try again once the plugin is available.", + }; } } + if (ensured.installed) { + const pendingCodexInstall = ensured.cfg.plugins?.installs?.codex; + if (pendingCodexInstall) { + // The package is already in the managed global root. Record ownership now so a + // failed or abandoned live probe cannot leave an untracked install behind. + const transformConfig = + deps.transformConfigWithPendingPluginInstalls ?? + (await import("../plugins/install-record-commit.js")) + .transformConfigWithPendingPluginInstalls; + const committed = await transformConfig({ + afterWrite: { + mode: "none", + reason: "Crestodian records the installed Codex runtime before probing", + }, + transform: (current) => { + const strippedCurrent = stripPendingPluginInstallRecords(current); + return { + nextConfig: { + ...strippedCurrent, + plugins: { + ...strippedCurrent.plugins, + installs: { codex: pendingCodexInstall }, + }, + }, + }; + }, + }); + try { + await appendCrestodianAuditEntry({ + operation: "plugin.install", + summary: "Installed Codex runtime plugin", + configPath: committed.path, + configHashBefore: committed.previousHash, + configHashAfter: committed.persistedHash, + details: { pluginId: "codex", via: "crestodian.setup" }, + }); + } catch (error) { + const warning = `Codex was installed, but OpenClaw could not record its audit entry: ${formatErrorMessage(error)}`; + params.runtime.error?.(warning); + setupWarnings.push(warning); + } + } - // Installation can take several minutes. Rebuild the probe input from - // the current config so a concurrent policy or agent edit is never - // replaced by the pre-install snapshot returned from the installer. - const codexSnapshot = await readSnapshot(); - if (codexSnapshot.exists && !codexSnapshot.valid) { - throw new Error(invalidSetupConfigError(codexSnapshot)); + // Installation can take several minutes. Rebuild the probe input from + // the current config so a concurrent policy or agent edit is never + // replaced by the pre-install snapshot returned from the installer. + const codexSnapshot = await readSnapshot(); + if (codexSnapshot.exists && !codexSnapshot.valid) { + throw new Error(invalidSetupConfigError(codexSnapshot)); + } + probedConfigHash = resolveConfigSnapshotHash(codexSnapshot); + const currentCodexConfig: OpenClawConfig = codexSnapshot.exists + ? (codexSnapshot.runtimeConfig ?? codexSnapshot.config) + : {}; + const targetError = probedTargetChangedError({ + config: currentCodexConfig, + ...(testPlan.agentId ? { expectedAgentId: testPlan.agentId } : {}), + }); + if (targetError) { + throw new Error(targetError); + } + const currentCodexSelection = + codexPluginActivation === "selected" && plan.persistModelRef + ? await applyCrestodianModelSelection({ + config: currentCodexConfig, + model: plan.persistModelRef, + ...(agentRuntimeId ? { agentRuntimeId } : {}), + }) + : currentCodexConfig; + const enabledCodex = enablePluginInConfig( + normalizePluginTargetConfig(currentCodexSelection, "codex"), + "codex", + ); + if (!enabledCodex.enabled) { + return { + ok: false, + status: "unavailable", + error: `Could not enable the Codex runtime plugin: ${enabledCodex.reason ?? "plugin disabled"}.`, + }; + } + // Enablement and the model-scoped runtime pin remain transient probe inputs. + // Persist them only after completion; the managed install record is durable above. + const stagedCodexConfig = stripPendingPluginInstallRecords(enabledCodex.config); + codexPluginPatch = createMergePatch(currentCodexConfig, stagedCodexConfig); + if (codexPluginActivation === "selected") { + const codexSourceConfig = codexSnapshot.exists + ? (codexSnapshot.sourceConfig ?? codexSnapshot.config) + : {}; + testPlan = { + ...testPlan, + // Probe the policy that will actually persist. Codex rejects deny and + // allowlist exec modes during initialization; masking that here would + // pass onboarding and fail the user's first normal run. + config: enableCodexSupervisionForGuidedSetup(stagedCodexConfig, codexSourceConfig), + agentId: resolveDefaultAgentId(stagedCodexConfig), + }; + } } - probedConfigHash = resolveConfigSnapshotHash(codexSnapshot); - const currentCodexConfig: OpenClawConfig = codexSnapshot.exists - ? (codexSnapshot.runtimeConfig ?? codexSnapshot.config) - : {}; - const targetError = probedTargetChangedError({ - config: currentCodexConfig, - ...(testPlan.agentId ? { expectedAgentId: testPlan.agentId } : {}), - }); - if (targetError) { - throw new Error(targetError); - } - const currentCodexSelection = plan.persistModelRef - ? await applyCrestodianModelSelection({ - config: currentCodexConfig, - model: plan.persistModelRef, - ...(agentRuntimeId ? { agentRuntimeId } : {}), - }) - : currentCodexConfig; - const enabledCodex = enablePluginInConfig(currentCodexSelection, "codex"); - if (!enabledCodex.enabled) { - return { - ok: false, - status: "unavailable", - error: `Could not enable the Codex runtime plugin: ${enabledCodex.reason ?? "plugin disabled"}.`, - }; - } - // Enablement and the model-scoped runtime pin remain transient probe inputs. - // Persist them only after completion; the managed install record is durable above. - const stagedCodexConfig = stripPendingPluginInstallRecords(enabledCodex.config); - testPlan = { - ...testPlan, - // Probe the policy that will actually persist. Codex rejects deny and - // allowlist exec modes during initialization; masking that here would - // pass onboarding and fail the user's first normal run. - config: stagedCodexConfig, - agentId: resolveDefaultAgentId(stagedCodexConfig), - }; } if (plan.manualAuth) { @@ -911,7 +1038,7 @@ async function activateSetupInferenceUnredacted( if (resolveConfigSnapshotHash(latestSnapshot) !== probedConfigHash) { throw new Error("OpenClaw config changed while AI access was being tested. Try setup again."); } - const latestConfig: OpenClawConfig = latestSnapshot.exists + let latestConfig: OpenClawConfig = latestSnapshot.exists ? (latestSnapshot.runtimeConfig ?? latestSnapshot.config) : {}; const postProbeTargetError = probedTargetChangedError({ @@ -923,6 +1050,59 @@ async function activateSetupInferenceUnredacted( throw new Error(postProbeTargetError); } + if (codexPluginActivation === "detected" && codexPluginPatch !== undefined) { + // Persist success-gated enablement and the model-scoped runtime pin. The managed + // install record was committed before the live probe. + const { stripPendingPluginInstallRecords } = + await import("../plugins/install-record-commit.js"); + const transformConfig = + deps.transformConfigWithPendingPluginInstalls ?? + (await import("../plugins/install-record-commit.js")) + .transformConfigWithPendingPluginInstalls; + let committed; + try { + committed = await transformConfig({ + // Keep the setup RPC alive until the final model/setup write completes. The explicit + // registry refresh below makes the newly installed plugin available without a restart. + afterWrite: { mode: "none", reason: "Crestodian setup finalizes config after refresh" }, + transform: (current, context) => { + if ( + codexPluginActivation === "detected" && + !canAutoEnableCodexSupervision(context.snapshot.sourceConfig) + ) { + return { nextConfig: current }; + } + const patched = applyMergePatch( + stripPendingPluginInstallRecords(current), + codexPluginPatch, + ) as OpenClawConfig; + return { + nextConfig: enableCodexSupervisionForGuidedSetup( + patched, + context.snapshot.sourceConfig, + ), + }; + }, + }); + } catch (error) { + if (error instanceof CodexPluginPolicyBlockedError) { + return codexPluginPolicyError(error.reason); + } + throw error; + } + const refreshPluginRegistry = + deps.refreshPluginRegistryAfterConfigMutation ?? + (await import("../plugins/registry-refresh.js")).refreshPluginRegistryAfterConfigMutation; + await refreshPluginRegistry({ + config: committed.nextConfig, + reason: "source-changed", + workspaceDir: workspace, + logger: { warn: (message) => params.runtime.log?.(message) }, + }); + probedConfigHash = committed.persistedHash; + latestConfig = committed.nextConfig; + } + let manualAuthWrite: ManualAuthWrite | undefined; let expectedAgentDir: string | undefined; if (plan.manualAuth) { @@ -957,7 +1137,17 @@ async function activateSetupInferenceUnredacted( ...(expectedAgentDir ? { expectedAgentDir } : {}), ...(params.kind === "existing-model" ? { expectedModelRef: plan.modelRef } : {}), expectedConfigHash: probedConfigHash, - ...(plan.manualAuth ? { configPatch: plan.manualAuth.configPatch } : {}), + ...(plan.manualAuth + ? { configPatch: plan.manualAuth.configPatch } + : codexPluginActivation === "selected" && codexPluginPatch !== undefined + ? { configPatch: codexPluginPatch } + : {}), + ...(codexPluginActivation === "selected" && codexPluginPatch !== undefined + ? { + finalizeConfig: (config: OpenClawConfig, currentSourceConfig: OpenClawConfig) => + enableCodexSupervisionForGuidedSetup(config, currentSourceConfig), + } + : {}), ...(plan.manualAuth?.pluginId ? { enablePluginId: plan.manualAuth.pluginId } : params.kind === "codex-cli" @@ -978,6 +1168,9 @@ async function activateSetupInferenceUnredacted( ); } } + if (error instanceof CodexPluginPolicyBlockedError) { + return codexPluginPolicyError(error.reason); + } throw error; } let lines = [...applied.lines, ...setupWarnings]; diff --git a/src/cron/isolated-agent/run.session-key-isolation.test.ts b/src/cron/isolated-agent/run.session-key-isolation.test.ts index 0609b1ab1ba0..f44762e38387 100644 --- a/src/cron/isolated-agent/run.session-key-isolation.test.ts +++ b/src/cron/isolated-agent/run.session-key-isolation.test.ts @@ -4,8 +4,10 @@ import { makeIsolatedAgentJobFixture, makeIsolatedAgentParamsFixture } from "./j import { setupRunCronIsolatedAgentTurnSuite } from "./run.suite-helpers.js"; import { isCliProviderMock, + loadSessionEntryMock, loadRunCronIsolatedAgentTurn, makeCronSession, + makeCronSessionEntry, mockRunCronFallbackPassthrough, resolveCronSessionMock, runCliAgentMock, @@ -160,6 +162,110 @@ describe("runCronIsolatedAgentTurn isolated session identity", () => { expect(runRequest.bootstrapContextRunKind).toBe("cron"); }); + it.each([ + "harness:codex:supervision:native-thread", + "agent:default:harness:codex:supervision:native-thread", + ])("rejects detached execution for a missing reserved harness key %s", async (sessionKey) => { + await expect( + runCronIsolatedAgentTurn( + makeIsolatedAgentParamsFixture({ + sessionKey, + job: makeIsolatedAgentJobFixture({ sessionTarget: `session:${sessionKey}` }), + }), + ), + ).rejects.toThrow(/reserved for agent harness-owned sessions/i); + + expect(resolveCronSessionMock).toHaveBeenCalledOnce(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); + }); + + it("continues a pre-existing unlocked harness-prefixed session as an ordinary session", async () => { + const sessionKey = "agent:default:harness:legacy-notes"; + const legacyEntry = makeCronSessionEntry({ + agentHarnessId: "codex", + sessionId: "legacy-session", + }); + resolveCronSessionMock.mockReturnValue( + makeCronSession({ + initialSessionEntry: legacyEntry, + isNewSession: false, + sessionEntry: { ...legacyEntry }, + store: { [sessionKey]: { ...legacyEntry } }, + }), + ); + loadSessionEntryMock.mockReturnValue(legacyEntry); + mockRunCronFallbackPassthrough(); + + const result = await runCronIsolatedAgentTurn( + makeIsolatedAgentParamsFixture({ + sessionKey, + job: makeIsolatedAgentJobFixture({ sessionTarget: `session:${sessionKey}` }), + }), + ); + + expect(result.status).toBe("ok"); + expect(result.sessionKey).toBe(sessionKey); + expect(resolveCronSessionMock).toHaveBeenCalledOnce(); + expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); + }); + + it("rejects detached execution for an existing locked harness session", async () => { + const sessionKey = "agent:default:harness:codex:supervision:native-thread"; + const protectedEntry = makeCronSessionEntry({ + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + }); + resolveCronSessionMock.mockReturnValue( + makeCronSession({ + initialSessionEntry: protectedEntry, + isNewSession: false, + sessionEntry: protectedEntry, + store: { [sessionKey]: protectedEntry }, + }), + ); + + await expect( + runCronIsolatedAgentTurn( + makeIsolatedAgentParamsFixture({ + sessionKey, + job: makeIsolatedAgentJobFixture({ sessionTarget: `session:${sessionKey}` }), + }), + ), + ).rejects.toThrow(/reserved for agent harness-owned sessions/i); + + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); + }); + + it("rejects detached execution for an existing locked ordinary session", async () => { + const sessionKey = "agent:default:project-native-session"; + const protectedEntry = makeCronSessionEntry({ + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + }); + resolveCronSessionMock.mockReturnValue( + makeCronSession({ + initialSessionEntry: protectedEntry, + isNewSession: false, + sessionEntry: protectedEntry, + store: { [sessionKey]: protectedEntry }, + }), + ); + + await expect( + runCronIsolatedAgentTurn( + makeIsolatedAgentParamsFixture({ + sessionKey, + job: makeIsolatedAgentJobFixture({ sessionTarget: `session:${sessionKey}` }), + }), + ), + ).rejects.toThrow(/identity is locked and cannot be replaced or shared/i); + + expect(resolveCronSessionMock).toHaveBeenCalledOnce(); + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); + }); + it("uses a run-scoped key for CLI isolated cron execution", async () => { isCliProviderMock.mockReturnValue(true); resolveCronSessionMock.mockReturnValue( diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 53df849b706b..98f1eac12d4a 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -37,6 +37,11 @@ import { import { createDiagnosticMessageLifecycle } from "../../logging/message-lifecycle.js"; import { isCommandLaneTaskTimeoutError } from "../../process/command-queue.js"; import { CommandLane } from "../../process/lanes.js"; +import { + AGENT_HARNESS_SESSION_ID_LOCKED_MESSAGE, + AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE, + isAgentHarnessSessionKey, +} from "../../sessions/agent-harness-session-key.js"; import { beginSessionWorkAdmission, type SessionWorkAdmissionLease, @@ -622,6 +627,28 @@ async function prepareCronRunContext(params: { mainKey: input.cfg.session?.mainKey, cfg: input.cfg, }); + const cronSession = resolveCronSession({ + cfg: input.cfg, + sessionKey: agentSessionKey, + agentId, + nowMs: Date.now(), + forceNew: input.job.sessionTarget === "isolated", + }); + const reservedKey = isAgentHarnessSessionKey(agentSessionKey); + if (cronSession.initialSessionEntry?.modelSelectionLocked === true) { + // The generic detached executor cannot preserve a harness-owned runtime + // lock. Reject before model selection can route the row through that path. + throw new Error( + reservedKey + ? AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE + : AGENT_HARNESS_SESSION_ID_LOCKED_MESSAGE, + ); + } + if (reservedKey && !cronSession.initialSessionEntry) { + // `harness:*` was historically a valid public key. Existing unlocked rows + // stay ordinary, while missing keys remain reserved for trusted creation. + throw new Error(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE); + } const payloadHookExternalContentSource = input.job.payload.kind === "agentTurn" ? input.job.payload.externalContentSource : undefined; const hookExternalContentSource = @@ -645,13 +672,6 @@ async function prepareCronRunContext(params: { const isGmailHook = hookExternalContentSource === "gmail"; const now = Date.now(); - const cronSession = resolveCronSession({ - cfg: input.cfg, - sessionKey: agentSessionKey, - agentId, - nowMs: now, - forceNew: input.job.sessionTarget === "isolated", - }); const runSessionId = cronSession.sessionEntry.sessionId; const currentRunSessionId = () => cronSession.sessionEntry.sessionId ?? runSessionId; if (!cronSession.sessionEntry.sessionFile?.trim()) { diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index 114baff0bcb8..0dbe5042c168 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -2856,6 +2856,42 @@ describe("doctor health contributions", () => { ); }); + it("reports local audio acceleration as information without failing doctor health", async () => { + const contribution = requireDoctorContribution("doctor:local-audio-acceleration"); + mocks.getHealthCheck.mockReturnValue({ + id: "core/doctor/local-audio-acceleration", + detect: vi.fn(async () => [ + { + checkId: "core/doctor/local-audio-acceleration", + severity: "info", + message: "Local STT auto-selection: mlx-whisper is available.", + path: "tools.media.audio.models", + }, + ]), + }); + const ctx = { + cfg: {}, + configResult: { cfg: {} }, + sourceConfigValid: true, + prompter: buildDoctorPrompter(false), + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: {}, + cfgForPersistence: {}, + configPath: "/tmp/fake-openclaw.json", + env: {}, + healthOk: true, + } as Parameters<(typeof contribution)["run"]>[0]; + + await contribution.run(ctx); + + expect(ctx.healthOk).toBe(true); + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining("Local STT auto-selection"), + "Doctor information", + ); + expect(mocks.note).not.toHaveBeenCalledWith(expect.anything(), "Doctor warnings"); + }); + it.each([false, true])( "reports default-account routing warnings during doctor runs (repair=%s)", async (shouldRepair) => { diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 01addfe5c08f..84beb5b5b7fd 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -1494,8 +1494,15 @@ async function runCoreHealthFindingNote( if (findings.length === 0) { return; } - ctx.healthOk = false; - note(formatHealthFindings(findings), "Doctor warnings"); + const information = findings.filter((finding) => finding.severity === "info"); + const warnings = findings.filter((finding) => finding.severity !== "info"); + if (information.length > 0) { + note(formatHealthFindings(information), "Doctor information"); + } + if (warnings.length > 0) { + ctx.healthOk = false; + note(formatHealthFindings(warnings), "Doctor warnings"); + } } async function runProviderCatalogProjectionHealth(ctx: DoctorHealthFlowContext): Promise { diff --git a/src/gateway/http-utils.request-context.test.ts b/src/gateway/http-utils.request-context.test.ts index 250b8805b336..3bb6259a806d 100644 --- a/src/gateway/http-utils.request-context.test.ts +++ b/src/gateway/http-utils.request-context.test.ts @@ -2,7 +2,7 @@ * Tests HTTP request context extraction for gateway auth and routing. */ import type { IncomingMessage } from "node:http"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { authorizeOpenAiCompatibleHttpModelOverride, GatewaySessionKeyOverrideError, @@ -13,6 +13,18 @@ import { resolveTrustedHttpOperatorScopes, } from "./http-utils.js"; +const sessionEntries = vi.hoisted(() => new Map>()); + +vi.mock("../config/sessions/session-accessor.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveSessionEntryAccessTarget: (params: { sessionKey: string }) => ({ + entry: sessionEntries.get(params.sessionKey), + }), + }; +}); + function createReq(headers: Record = {}): IncomingMessage { return { headers } as IncomingMessage; } @@ -20,6 +32,8 @@ function createReq(headers: Record = {}): IncomingMessage { const tokenAuth = { mode: "token" as const }; const noneAuth = { mode: "none" as const }; +beforeEach(() => sessionEntries.clear()); + describe("resolveGatewayRequestContext", () => { it("uses normalized x-openclaw-message-channel when enabled", () => { const result = resolveGatewayRequestContext({ @@ -72,9 +86,11 @@ describe("resolveGatewayRequestContext", () => { "subagent:worker", "cron:daily", "acp:run-1", + "harness:codex:supervision:native-thread", "agent:main:subagent:worker", "agent:main:cron:daily", "agent:main:acp:run-1", + "agent:main:harness:codex:supervision:native-thread", ])("rejects reserved internal session-key override %s", (sessionKey) => { expect(() => resolveGatewayRequestContext({ @@ -86,6 +102,38 @@ describe("resolveGatewayRequestContext", () => { ).toThrow(GatewaySessionKeyOverrideError); }); + it("preserves an existing unlocked legacy harness-prefixed override", () => { + const sessionKey = "agent:main:harness:legacy-notes"; + sessionEntries.set(sessionKey, { sessionId: "legacy-session", modelSelectionLocked: false }); + + const result = resolveGatewayRequestContext({ + req: createReq({ "x-openclaw-session-key": sessionKey }), + model: "openclaw", + sessionPrefix: "openai", + defaultMessageChannel: "webchat", + }); + + expect(result.sessionKey).toBe(sessionKey); + }); + + it("rejects an existing locked harness-prefixed override", () => { + const sessionKey = "agent:main:harness:codex:supervision:native-thread"; + sessionEntries.set(sessionKey, { + sessionId: "locked-session", + agentHarnessId: "codex", + modelSelectionLocked: true, + }); + + expect(() => + resolveGatewayRequestContext({ + req: createReq({ "x-openclaw-session-key": sessionKey }), + model: "openclaw", + sessionPrefix: "openai", + defaultMessageChannel: "webchat", + }), + ).toThrow(GatewaySessionKeyOverrideError); + }); + it("does not build session state for explicit unknown agent ids", () => { expect(() => resolveGatewayRequestContext({ diff --git a/src/gateway/http-utils.ts b/src/gateway/http-utils.ts index c293c49bc256..a0066ddade09 100644 --- a/src/gateway/http-utils.ts +++ b/src/gateway/http-utils.ts @@ -10,6 +10,7 @@ import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { modelKey, parseModelRef, resolveDefaultModelForAgent } from "../agents/model-selection.js"; import { createModelVisibilityPolicy } from "../agents/model-visibility-policy.js"; import { getRuntimeConfig } from "../config/io.js"; +import { resolveSessionEntryAccessTarget } from "../config/sessions/session-accessor.js"; import { loadManifestMetadataSnapshot } from "../plugins/manifest-contract-eligibility.js"; import { buildAgentMainSessionKey, @@ -19,9 +20,14 @@ import { isValidAgentId, normalizeAgentId, } from "../routing/session-key.js"; +import { + isAgentHarnessSessionKey, + isAgentHarnessSessionStoreEntryProtected, +} from "../sessions/agent-harness-session-key.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { getHeader } from "./http-auth-utils.js"; import { loadGatewayModelCatalog } from "./server-model-catalog.js"; +import { canonicalizeSessionKeyForAgent } from "./session-store-key.js"; export { authorizeOpenAiCompatibleHttpModelOverride, @@ -201,7 +207,7 @@ function resolveSessionKey(params: { }): string { const explicit = getHeader(params.req, "x-openclaw-session-key")?.trim(); if (explicit) { - if (isReservedSessionKeyOverride(explicit)) { + if (isReservedSessionKeyOverride(explicit, params.agentId)) { throw new GatewaySessionKeyOverrideError(); } return explicit; @@ -212,12 +218,25 @@ function resolveSessionKey(params: { return buildAgentMainSessionKey({ agentId: params.agentId, mainKey }); } -function isReservedSessionKeyOverride(sessionKey: string): boolean { +function isReservedSessionKeyOverride(sessionKey: string, agentId: string): boolean { const lowered = normalizeLowercaseStringOrEmpty(sessionKey); + const harnessLookupKey = sessionKey.startsWith("agent:") + ? sessionKey + : canonicalizeSessionKeyForAgent(agentId, sessionKey); + const harnessEntry = isAgentHarnessSessionKey(sessionKey) + ? resolveSessionEntryAccessTarget({ + cfg: getRuntimeConfig(), + sessionKey: harnessLookupKey, + }).entry + : undefined; + const harnessKeyReserved = + isAgentHarnessSessionKey(sessionKey) && + (!harnessEntry || isAgentHarnessSessionStoreEntryProtected(sessionKey, harnessEntry)); return ( lowered.startsWith("subagent:") || lowered.startsWith("cron:") || lowered.startsWith("acp:") || + harnessKeyReserved || isSubagentSessionKey(sessionKey) || isCronSessionKey(sessionKey) || isAcpSessionKey(sessionKey) diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index 7c9f81c2a500..e3ecc8d47b74 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -152,6 +152,7 @@ const resolveGatewayScopedToolsMock = vi.hoisted(() => ); const logWarnMock = vi.hoisted(() => vi.fn<(message: string) => void>()); +const sessionEntries = vi.hoisted(() => new Map>()); vi.mock("../config/io.js", () => ({ getRuntimeConfig: () => ({ session: { mainKey: "main" } }), @@ -169,6 +170,16 @@ vi.mock("../config/sessions.js", () => ({ resolveMainSessionKey: () => "agent:main:main", })); +vi.mock("../config/sessions/session-accessor.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveSessionEntryAccessTarget: (params: { sessionKey: string }) => ({ + entry: sessionEntries.get(params.sessionKey), + }), + }; +}); + vi.mock("../agents/agent-tools.before-tool-call.js", () => ({ runBeforeToolCallHook: (...args: Parameters) => runBeforeToolCallHookMock(...args), @@ -660,6 +671,7 @@ beforeEach(() => { clearMcpToolSchemaWarningsForTest(); logWarnMock.mockClear(); clearMcpLoopbackToolCallCapturesForTest(); + sessionEntries.clear(); resolveGatewayScopedToolsMock.mockClear(); runBeforeToolCallHookMock.mockClear(); runBeforeToolCallHookMock.mockImplementation( @@ -851,6 +863,59 @@ describe("buildMcpToolSchema", () => { }); describe("mcp loopback server", () => { + it("rejects reserved harness contexts before tool resolution", async () => { + const { runtime } = await startLoopbackServerForTest(); + const response = await sendLoopbackToolsList({ + token: runtime.ownerToken, + headers: { + "x-session-key": "agent:main:harness:codex:supervision:native-thread", + }, + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + error: { code: -32600, message: expect.stringContaining("reserved") }, + }); + expect(resolveGatewayScopedToolsMock).not.toHaveBeenCalled(); + }); + + it("allows an existing unlocked legacy harness-prefixed context", async () => { + const { runtime } = await startLoopbackServerForTest(); + const sessionKey = "agent:main:harness:legacy-notes"; + sessionEntries.set(sessionKey, { sessionId: "legacy-session", modelSelectionLocked: false }); + + const response = await sendLoopbackToolsList({ + token: runtime.ownerToken, + headers: { "x-session-key": sessionKey }, + }); + + expect(response.status).toBe(200); + expect((await response.json()).result).toBeDefined(); + expect(resolveGatewayScopedToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ sessionKey }), + ); + }); + + it("rejects an existing locked harness context", async () => { + const { runtime } = await startLoopbackServerForTest(); + const sessionKey = "agent:main:harness:codex:supervision:native-thread"; + sessionEntries.set(sessionKey, { + sessionId: "locked-session", + agentHarnessId: "codex", + modelSelectionLocked: true, + }); + + const response = await sendLoopbackToolsList({ + token: runtime.ownerToken, + headers: { "x-session-key": sessionKey }, + }); + + expect(await response.json()).toMatchObject({ + error: { code: -32600, message: expect.stringContaining("reserved") }, + }); + expect(resolveGatewayScopedToolsMock).not.toHaveBeenCalled(); + }); + it("passes session, account, message channel, and inbound event headers into shared tool resolution", async () => { const port = await getFreePortBlockWithPermissionFallback({ offsets: [0], diff --git a/src/gateway/mcp-http.ts b/src/gateway/mcp-http.ts index bcf2599c693e..4f93dc656e45 100644 --- a/src/gateway/mcp-http.ts +++ b/src/gateway/mcp-http.ts @@ -9,9 +9,15 @@ import { import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { resolveToolLoopDetectionConfig } from "../agents/tool-loop-detection-config.js"; import { getRuntimeConfig } from "../config/io.js"; +import { resolveSessionEntryAccessTarget } from "../config/sessions/session-accessor.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { formatErrorMessage } from "../infra/errors.js"; import { logDebug, logWarn } from "../logger.js"; +import { + AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE, + isAgentHarnessSessionKey, + isAgentHarnessSessionStoreEntryProtected, +} from "../sessions/agent-harness-session-key.js"; import { resolveMcpLoopbackClientGrant, revokeMcpLoopbackClientGrantsForRuntime, @@ -243,6 +249,28 @@ export async function startMcpLoopbackServer(port = 0): Promise<{ }), ) : undefined; + const harnessEntry = isAgentHarnessSessionKey(requestContext.sessionKey) + ? resolveSessionEntryAccessTarget({ cfg, sessionKey: requestContext.sessionKey }).entry + : undefined; + if ( + isAgentHarnessSessionKey(requestContext.sessionKey) && + (!harnessEntry || + isAgentHarnessSessionStoreEntryProtected(requestContext.sessionKey, harnessEntry)) + ) { + const errors = messages.map((message) => + jsonRpcError( + readJsonRpcRequestId(message), + -32600, + AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE, + ), + ); + const payload = Array.isArray(parsed) + ? JSON.stringify(errors) + : JSON.stringify(errors[0]); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(payload); + return; + } const yieldContext = resolveMcpLoopbackYieldContext(cliRequestCaptureHandle); const scopedTools = toolCache.resolve({ cfg, diff --git a/src/gateway/openai-http.test.ts b/src/gateway/openai-http.test.ts index 49e058a73008..6de668348877 100644 --- a/src/gateway/openai-http.test.ts +++ b/src/gateway/openai-http.test.ts @@ -264,6 +264,24 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { expect(agentCommand).toHaveBeenCalledTimes(0); } + { + agentCommand.mockClear(); + const res = await postChatCompletions( + port, + { model: "openclaw", messages: [{ role: "user", content: "hi" }] }, + { + "x-openclaw-session-key": "agent:main:harness:codex:supervision:spoofed-native-thread", + }, + ); + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: { type?: string; message?: string } }; + expect(json.error?.type).toBe("invalid_request_error"); + expect(json.error?.message).toBe( + "`x-openclaw-session-key` cannot use reserved internal session namespaces.", + ); + expect(agentCommand).toHaveBeenCalledTimes(0); + } + { mockAgentOnce([{ text: "hello" }]); const res = await postChatCompletions( diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index 4b22f8949901..dc9feb41951a 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -352,6 +352,23 @@ describe("OpenResponses HTTP API (e2e)", () => { ); expect(agentCommand).toHaveBeenCalledTimes(0); + const resHarnessSessionOverride = await postResponses( + port, + { model: "openclaw", input: "hi" }, + { + "x-openclaw-session-key": "agent:main:harness:codex:supervision:spoofed-native-thread", + }, + ); + expect(resHarnessSessionOverride.status).toBe(400); + const harnessSessionJson = (await resHarnessSessionOverride.json()) as { + error?: { type?: string; message?: string }; + }; + expect(harnessSessionJson.error?.type).toBe("invalid_request_error"); + expect(harnessSessionJson.error?.message).toBe( + "`x-openclaw-session-key` cannot use reserved internal session namespaces.", + ); + expect(agentCommand).toHaveBeenCalledTimes(0); + mockAgentOnce([{ text: "hello" }]); const resModel = await postResponses(port, { model: "openclaw/beta", input: "hi" }); expect(resModel.status).toBe(200); diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 05be08b1e5c3..262fb71c324d 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -1102,6 +1102,156 @@ describe("gateway agent handler", () => { expect(mocks.agentCommand).not.toHaveBeenCalled(); }); + it("rejects agent RPC creation in an agent harness-owned namespace", async () => { + const sessionKey = "agent:main:harness:codex:supervision:native-thread"; + const runId = "agent-harness-reserved"; + mocks.loadSessionEntry.mockReturnValue({ + cfg: {}, + storePath: "/tmp/sessions.json", + entry: undefined, + canonicalKey: sessionKey, + }); + mocks.agentCommand.mockClear(); + const context = makeContext(); + const respond = vi.fn(); + + await invokeAgent( + { + message: "claim reserved session", + agentId: "main", + sessionKey, + idempotencyKey: runId, + }, + { context, respond, reqId: runId, flushDispatch: false }, + ); + + expectRespondError(respond, { + code: ErrorCodes.INVALID_REQUEST, + message: "Session key namespace is reserved for agent harness-owned sessions.", + }); + expect(context.dedupe.has(`agent:${runId}`)).toBe(false); + expect(mocks.agentCommand).not.toHaveBeenCalled(); + }); + + it.each(["agent:main:harness:codex:supervision:native-thread", "agent:main:ordinary-locked"])( + "rejects agent RPC session-id rotation for locked session %s", + async (sessionKey) => { + const runId = "agent-harness-session-id-rotation"; + mocks.loadSessionEntry.mockReturnValue({ + cfg: {}, + storePath: "/tmp/sessions.json", + entry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + }, + canonicalKey: sessionKey, + }); + mocks.agentCommand.mockClear(); + const updateSessionStoreCallsBefore = mocks.updateSessionStore.mock.calls.length; + const context = makeContext(); + const respond = vi.fn(); + + await invokeAgent( + { + message: "replace native transcript identity", + agentId: "main", + sessionKey, + sessionId: "replacement-session", + idempotencyKey: runId, + }, + { context, respond, reqId: runId, flushDispatch: false }, + ); + + expectRespondError(respond, { + code: ErrorCodes.INVALID_REQUEST, + message: "Agent harness-owned session identity is locked and cannot be replaced or shared.", + }); + expect(context.dedupe.has(`agent:${runId}`)).toBe(false); + expect(mocks.updateSessionStore).toHaveBeenCalledTimes(updateSessionStoreCallsBefore); + expect(mocks.agentCommand).not.toHaveBeenCalled(); + }, + ); + + it("rejects one-shot model runs against harness-owned sessions", async () => { + const sessionKey = "agent:main:harness:codex:supervision:native-thread"; + const runId = "agent-harness-model-run"; + mocks.loadSessionEntry.mockReturnValue({ + cfg: {}, + storePath: "/tmp/sessions.json", + entry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + }, + canonicalKey: sessionKey, + }); + mocks.agentCommand.mockClear(); + const context = makeContext(); + const respond = vi.fn(); + + await invokeAgent( + { + message: "run through another model", + agentId: "main", + sessionKey, + modelRun: true, + idempotencyKey: runId, + }, + { context, respond, reqId: runId, flushDispatch: false }, + ); + + expectRespondError(respond, { + code: ErrorCodes.INVALID_REQUEST, + message: "Agent harness-owned sessions cannot be used for one-shot model runs.", + }); + expect(context.dedupe.has(`agent:${runId}`)).toBe(false); + expect(mocks.agentCommand).not.toHaveBeenCalled(); + }); + + it("allows raw model runs against grandfathered unlocked harness-prefixed sessions", async () => { + const sessionKey = "agent:main:harness:notes"; + const runId = "legacy-harness-model-run"; + mocks.loadSessionEntry.mockReturnValue({ + cfg: {}, + storePath: "/tmp/sessions.json", + entry: { + agentHarnessId: "codex", + modelSelectionLocked: false, + sessionId: "legacy-session", + updatedAt: Date.now(), + }, + canonicalKey: sessionKey, + }); + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "pong" }], + meta: { durationMs: 100 }, + }); + + await invokeAgent( + { + message: "Reply exactly: pong", + agentId: "main", + sessionKey, + modelRun: true, + promptMode: "none", + idempotencyKey: runId, + }, + { + reqId: runId, + client: operatorWriteCliClient(), + }, + ); + + expectRecordFields(await waitForAgentCommandCall(), { + modelRun: true, + promptMode: "none", + sessionEffects: "internal", + sessionId: "legacy-session", + sessionKey, + }); + }); + it("uses single-entry persistence for ordinary gateway admission touches", async () => { mockMainSessionEntry({}); let capturedOptions: @@ -4103,6 +4253,29 @@ describe("gateway agent handler", () => { resetTimeConfig(); }); + it("rejects promptMode none without the stateless model-run contract", async () => { + primeMainAgentRun({ cfg: mocks.loadConfigReturn }); + mocks.agentCommand.mockClear(); + + const respond = await invokeAgent( + { + message: "unsafe raw run", + agentId: "main", + sessionKey: "agent:main:main", + promptMode: "none", + idempotencyKey: "test-raw-run-with-visible-session-effects", + }, + { reqId: "raw-run-with-visible-session-effects", flushDispatch: false }, + ); + + expectRespondError(respond, { + code: ErrorCodes.INVALID_REQUEST, + message: + 'promptMode="none" requires modelRun=true so the run cannot mutate a durable session.', + }); + expect(mocks.agentCommand).not.toHaveBeenCalled(); + }); + it("keeps CLI model runs out of durable and visible gateway state", async () => { const sessionId = "model-run-123e4567-e89b-12d3-a456-426614174000"; const sessionKey = `agent:main:explicit:${sessionId}`; @@ -5854,6 +6027,70 @@ describe("gateway agent handler", () => { } }); + it("keeps a model-locked session across configured gateway expiry", async () => { + const now = Date.parse("2026-04-25T12:00:00.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); + try { + mocks.resolveExplicitAgentSessionKey.mockReturnValue("agent:main:main"); + mockMainSessionEntry( + { + sessionId: "model-locked-session-id", + updatedAt: now, + sessionStartedAt: now - 25 * 60 * 60_000, + lastInteractionAt: now - 25 * 60 * 60_000, + agentHarnessId: "codex", + modelSelectionLocked: true, + }, + { + session: { + reset: { + mode: "daily", + atHour: 4, + }, + }, + }, + ); + const loaded = mocks.loadSessionEntry(); + let capturedEntry: Record | undefined; + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = { + [loaded.canonicalKey]: structuredClone(loaded.entry), + }; + const result = await updater(store); + capturedEntry = result as Record; + return result; + }); + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + + await invokeAgent( + { + message: "model-locked daily boundary", + agentId: "main", + sessionKey: "agent:main:main", + idempotencyKey: "model-locked-daily-boundary", + }, + { reqId: "model-locked-daily-boundary" }, + ); + + const call = await waitForAgentCommandCall<{ + sessionId?: string; + sessionKey?: string; + }>(); + expect(call.sessionKey).toBe("agent:main:main"); + expect(call.sessionId).toBe("model-locked-session-id"); + expect(capturedEntry?.sessionStartedAt).toBe(now - 25 * 60 * 60_000); + expect(capturedEntry?.modelSelectionLocked).toBe(true); + expect(mocks.emitGatewaySessionEndPluginHook).not.toHaveBeenCalled(); + expect(mocks.emitGatewaySessionStartPluginHook).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it("emits idle lifecycle reason when inactivity rotates a gateway agent session", async () => { const now = Date.parse("2026-04-25T12:00:00.000Z"); vi.useFakeTimers(); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index ad4b96c5a22f..746b951dfb21 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -124,6 +124,11 @@ import { parseAgentSessionKey, } from "../../routing/session-key.js"; import { defaultRuntime } from "../../runtime.js"; +import { + AGENT_HARNESS_MODEL_RUN_FORBIDDEN_MESSAGE, + resolveAgentHarnessSessionContextError, + resolveAgentHarnessSessionIdMismatchError, +} from "../../sessions/agent-harness-session-key.js"; import { annotateInterSessionPromptText, normalizeInputProvenance, @@ -280,6 +285,8 @@ function respondDeletedAgentSession(params: { function respondUnavailableAgentSessionForKey(params: { sessionKey: string; + requestedSessionId?: string; + isRawModelRun: boolean; agentId?: string; respond: GatewayRequestHandlerOptions["respond"]; }): boolean { @@ -298,6 +305,27 @@ function respondUnavailableAgentSessionForKey(params: { ) { return true; } + const harnessSessionError = resolveAgentHarnessSessionContextError(canonicalKey, entry); + if (harnessSessionError) { + params.respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, harnessSessionError)); + return true; + } + const harnessSessionIdError = resolveAgentHarnessSessionIdMismatchError( + entry, + params.requestedSessionId, + ); + if (harnessSessionIdError) { + params.respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, harnessSessionIdError)); + return true; + } + if (params.isRawModelRun && entry?.modelSelectionLocked === true) { + params.respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, AGENT_HARNESS_MODEL_RUN_FORBIDDEN_MESSAGE), + ); + return true; + } const archivedSessionError = resolveSessionWorkStartError(canonicalKey, entry); if (!archivedSessionError) { return false; @@ -1322,6 +1350,17 @@ export const agentHandlers: GatewayRequestHandlers = { const requestedPromptPersistenceSuppression = request.suppressPromptPersistence === true; const isOneShotModelRun = request.modelRun === true; const isRawModelRun = isOneShotModelRun || request.promptMode === "none"; + if (request.promptMode === "none" && !isOneShotModelRun) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + 'promptMode="none" requires modelRun=true so the run cannot mutate a durable session.', + ), + ); + return; + } if (requestedModelOverride && !allowModelOverride) { respond( false, @@ -1717,7 +1756,13 @@ export const agentHandlers: GatewayRequestHandlers = { // and dispatch so agent RPC shares the chat.send / sessions.send boundary. if ( requestedSessionKey && - respondUnavailableAgentSessionForKey({ sessionKey: requestedSessionKey, agentId, respond }) + respondUnavailableAgentSessionForKey({ + sessionKey: requestedSessionKey, + requestedSessionId, + isRawModelRun, + agentId, + respond, + }) ) { clearUnacceptedAgentDedupe(); return; @@ -2606,6 +2651,7 @@ export const agentHandlers: GatewayRequestHandlers = { : undefined; const skipImplicitExpiry = restoredCronContinuationIdentity !== undefined || + entry?.modelSelectionLocked === true || (resetPolicy.configured !== true && hasProviderOwnedSession(entry)); let freshness = entry ? skipImplicitExpiry @@ -2802,6 +2848,7 @@ export const agentHandlers: GatewayRequestHandlers = { : undefined; const freshSkipImplicitExpiry = restoredCronContinuationIdentity !== undefined || + freshEntry?.modelSelectionLocked === true || (resetPolicy.configured !== true && hasProviderOwnedSession(freshEntry)); const freshFreshness = freshEntry ? freshSkipImplicitExpiry diff --git a/src/gateway/server-methods/attach.test.ts b/src/gateway/server-methods/attach.test.ts index 874db0640f42..a0a43d0f34c5 100644 --- a/src/gateway/server-methods/attach.test.ts +++ b/src/gateway/server-methods/attach.test.ts @@ -4,6 +4,19 @@ import { closeMcpLoopbackServer } from "../mcp-http.js"; import { attachHandlers } from "./attach.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; +const loadSessionEntryMock = vi.hoisted(() => + vi.fn((_sessionKey: string) => ({ entry: undefined as Record | undefined })), +); + +vi.mock("../../config/sessions/session-accessor.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveSessionEntryAccessTarget: (params: { sessionKey: string }) => + loadSessionEntryMock(params.sessionKey), + }; +}); + const grantOpts = (sessionKey: string, respond: ReturnType) => ({ params: { sessionKey }, @@ -12,7 +25,11 @@ const grantOpts = (sessionKey: string, respond: ReturnType) => }) as unknown as GatewayRequestHandlerOptions; describe("attach gateway methods", () => { - beforeEach(() => resetAttachGrantsForTest()); + beforeEach(() => { + resetAttachGrantsForTest(); + loadSessionEntryMock.mockReset(); + loadSessionEntryMock.mockReturnValue({ entry: undefined }); + }); afterEach(async () => { resetAttachGrantsForTest(); // attach.grant lazily starts the loopback singleton; close it so it doesn't leak across files. @@ -40,6 +57,55 @@ describe("attach gateway methods", () => { expect(resolveAttachGrant(body.token)?.sessionKey).toBe("agent:main:attach-method"); }); + it("rejects attach grants for reserved harness sessions", async () => { + const respond = vi.fn(); + await attachHandlers["attach.grant"]( + grantOpts("agent:main:harness:codex:supervision:native-thread", respond), + ); + + const [ok, , error] = respond.mock.calls[0]; + expect(ok).toBe(false); + expect(error).toMatchObject({ code: "INVALID_REQUEST" }); + expect((error as { message: string }).message).toContain("reserved"); + }); + + it("allows an existing unlocked legacy harness-prefixed session", async () => { + loadSessionEntryMock.mockReturnValue({ + entry: { sessionId: "legacy-session", modelSelectionLocked: false }, + }); + const respond = vi.fn(); + const sessionKey = "agent:main:harness:legacy-notes"; + + await attachHandlers["attach.grant"](grantOpts(sessionKey, respond)); + + expect(respond.mock.calls[0]?.[0]).toBe(true); + const response = respond.mock.calls[0]?.[1] as { token: string } | undefined; + expect(response).toBeDefined(); + const token = response?.token ?? ""; + expect(resolveAttachGrant(token)?.sessionKey).toBe(sessionKey); + }); + + it("rejects attach grants for existing locked harness sessions", async () => { + loadSessionEntryMock.mockReturnValue({ + entry: { + sessionId: "locked-session", + agentHarnessId: "codex", + modelSelectionLocked: true, + }, + }); + const respond = vi.fn(); + + await attachHandlers["attach.grant"]( + grantOpts("agent:main:harness:codex:supervision:native-thread", respond), + ); + + expect(respond.mock.calls[0]?.[0]).toBe(false); + expect(respond.mock.calls[0]?.[2]).toMatchObject({ + code: "INVALID_REQUEST", + message: expect.stringContaining("reserved"), + }); + }); + it("returns an attach MCP config whose env placeholders are all supplied", async () => { const respond = vi.fn(); await attachHandlers["attach.grant"](grantOpts("agent:main:attach-method", respond)); diff --git a/src/gateway/server-methods/attach.ts b/src/gateway/server-methods/attach.ts index 8ff57b3d55ae..d84914060aa8 100644 --- a/src/gateway/server-methods/attach.ts +++ b/src/gateway/server-methods/attach.ts @@ -1,5 +1,11 @@ import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; import { resolveMainSessionKey } from "../../config/sessions.js"; +import { resolveSessionEntryAccessTarget } from "../../config/sessions/session-accessor.js"; +import { + AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE, + isAgentHarnessSessionKey, + isAgentHarnessSessionStoreEntryProtected, +} from "../../sessions/agent-harness-session-key.js"; import { mintAttachGrant, revokeAttachGrant } from "../mcp-grant-store.js"; import { ensureMcpLoopbackServer } from "../mcp-http.js"; import { @@ -25,6 +31,22 @@ function readPositiveNumber(params: Record, key: string): numbe export const attachHandlers: GatewayRequestHandlers = { "attach.grant": async ({ params, respond, context }) => { const grantParams = paramRecord(params); + const cfg = context.getRuntimeConfig(); + const sessionKey = readString(grantParams, "sessionKey") ?? resolveMainSessionKey(cfg); + const harnessEntry = isAgentHarnessSessionKey(sessionKey) + ? resolveSessionEntryAccessTarget({ cfg, sessionKey }).entry + : undefined; + if ( + isAgentHarnessSessionKey(sessionKey) && + (!harnessEntry || isAgentHarnessSessionStoreEntryProtected(sessionKey, harnessEntry)) + ) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE), + ); + return; + } await ensureMcpLoopbackServer(); const runtime = getActiveMcpLoopbackRuntime(); if (!runtime) { @@ -35,8 +57,6 @@ export const attachHandlers: GatewayRequestHandlers = { ); return; } - const sessionKey = - readString(grantParams, "sessionKey") ?? resolveMainSessionKey(context.getRuntimeConfig()); const grant = mintAttachGrant({ sessionKey, ttlMs: readPositiveNumber(grantParams, "ttlMs") }); respond(true, { sessionKey: grant.sessionKey, diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index c456bbfdb877..6df5ebe43608 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -83,6 +83,7 @@ const mockState = vi.hoisted(() => ({ onAfterAgentRunStart: null as (() => void) | null, agentRunId: "run-agent-1", sessionEntry: {} as Record, + sessionMissing: false, loadSessionEntryCalls: [] as Array<{ rawKey: string; opts?: { agentId?: string } }>, lastDispatchCtx: undefined as MsgContext | undefined, lastDispatchImages: undefined as Array<{ mimeType: string; data: string }> | undefined, @@ -159,11 +160,13 @@ vi.mock("../session-utils.js", async () => { typeof mockState.sessionEntry.canonicalKey === "string" ? mockState.sessionEntry.canonicalKey : rawKey || "main"; - const entry = { - sessionId: mockState.sessionId, - sessionFile: mockState.transcriptPath, - ...mockState.sessionEntry, - }; + const entry = mockState.sessionMissing + ? undefined + : { + sessionId: mockState.sessionId, + sessionFile: mockState.transcriptPath, + ...mockState.sessionEntry, + }; return { ...(typeof mockState.sessionEntry.canonicalKey === "string" ? { canonicalKey } : {}), cfg: { @@ -174,7 +177,7 @@ vi.mock("../session-utils.js", async () => { }, }, storePath: path.join(path.dirname(mockState.transcriptPath), "sessions.json"), - store: { [canonicalKey]: entry }, + store: entry ? { [canonicalKey]: entry } : {}, entry, canonicalKey, }; @@ -847,6 +850,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.onAfterAgentRunStart = null; mockState.agentRunId = "run-agent-1"; mockState.sessionEntry = {}; + mockState.sessionMissing = false; mockState.loadSessionEntryCalls = []; mockState.lastDispatchCtx = undefined; mockState.lastDispatchImages = undefined; @@ -3444,6 +3448,35 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(context.broadcast).not.toHaveBeenCalled(); }); + it("rejects chat.send creation in an agent harness-owned namespace", async () => { + await createTranscriptFixture("openclaw-chat-send-harness-reserved-"); + mockState.sessionMissing = true; + const respond = vi.fn(); + const context = createChatContext(); + + await chatHandlers["chat.send"]({ + params: { + sessionKey: "agent:main:harness:codex:supervision:native-thread", + message: "claim reserved session", + idempotencyKey: "idem-harness-reserved", + }, + respond, + req: {} as never, + client: null as never, + isWebchatConnect: () => false, + context: context as GatewayRequestContext, + }); + + const response = lastRespondCall(respond); + expect(response?.[0]).toBe(false); + expect(response?.[2]).toMatchObject({ + code: ErrorCodes.INVALID_REQUEST, + message: "Session key namespace is reserved for agent harness-owned sessions.", + }); + expect(mockState.lastDispatchCtx).toBeUndefined(); + expect(context.broadcast).not.toHaveBeenCalled(); + }); + it("chat.inject strips external untrusted wrapper metadata from final payload text", async () => { await createTranscriptFixture("openclaw-chat-inject-untrusted-meta-"); const respond = vi.fn(); diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index e413a0c207fb..825fa0c01cfc 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -110,6 +110,7 @@ import { runWithGatewayIndependentRootWorkContinuation, } from "../../process/gateway-work-admission.js"; import { normalizeAgentId, scopeLegacySessionKeyToAgent } from "../../routing/session-key.js"; +import { resolveMissingAgentHarnessSessionError } from "../../sessions/agent-harness-session-key.js"; import { normalizeInputProvenance, type InputProvenance } from "../../sessions/input-provenance.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; @@ -3895,6 +3896,11 @@ export const chatHandlers: GatewayRequestHandlers = { ); const sessionLoadMs = roundedChatSendTimingMs(performance.now() - sessionLoadStartedAtMs); const { cfg, storePath, entry, canonicalKey: sessionKey, legacyKey } = sessionLoadResult; + const missingHarnessSessionError = resolveMissingAgentHarnessSessionError(sessionKey, entry); + if (missingHarnessSessionError) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, missingHarnessSessionError)); + return; + } const expectedSessionRoutingContract = normalizeOptionalText(p.expectedSessionRoutingContract); const sessionRoutingChanged = (candidateConfig: OpenClawConfig) => expectedSessionRoutingContract !== undefined && diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index a9382bccddac..5b29960bbfd4 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -33,13 +33,23 @@ import type { CronListPageOptions, CronListPageResult, } from "../../cron/service/list-page-types.js"; -import { isInvalidCronSessionTargetIdError } from "../../cron/session-target.js"; +import { + isInvalidCronSessionTargetIdError, + resolveCronSessionTargetSessionKey, +} from "../../cron/session-target.js"; import type { CronJob, CronJobCreate, CronJobPatch } from "../../cron/types.js"; import { validateScheduleTimestamp } from "../../cron/validate-timestamp.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { resolveTargetPrefixedChannel } from "../../infra/outbound/channel-target-prefix.js"; import { isSubagentSessionKey, normalizeAgentId } from "../../routing/session-key.js"; +import { + AGENT_HARNESS_SESSION_ID_LOCKED_MESSAGE, + AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE, + isAgentHarnessSessionKey, + resolveAgentHarnessSessionStoreEntryError, +} from "../../sessions/agent-harness-session-key.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; +import { loadSessionEntry } from "../session-utils.js"; import { applyCronCreateCallerScopeDefault, cronCreateMatchesCallerScope, @@ -203,6 +213,13 @@ async function assertValidCronUpdatePatch(params: { defaultAgentId: params.defaultAgentId, cronConfig: params.cfg.cron, }); + if ( + "agentId" in params.patch || + "sessionTarget" in params.patch || + "sessionKey" in params.patch + ) { + assertCronDoesNotTargetAgentHarness(nextJob); + } if ("delivery" in params.patch) { const delivery = params.patch.delivery?.channel === null && @@ -219,6 +236,44 @@ async function assertValidCronUpdatePatch(params: { } } +function assertCronDoesNotTargetAgentHarness(input: { + agentId?: string | null; + sessionTarget?: string | null; + sessionKey?: string | null; +}): void { + const targetSessionKey = + resolveCronSessionTargetSessionKey(input.sessionTarget) ?? + (input.sessionTarget === "current" ? input.sessionKey?.trim() : undefined); + if (!targetSessionKey) { + return; + } + + const loaded = loadSessionEntry( + targetSessionKey, + input.agentId?.trim() ? { agentId: input.agentId.trim() } : {}, + ); + const reservedKey = + isAgentHarnessSessionKey(targetSessionKey) || isAgentHarnessSessionKey(loaded.canonicalKey); + if (loaded.entry?.modelSelectionLocked === true) { + // Detached cron execution is a generic model path and cannot preserve a + // harness-owned runtime lock, even when the durable row uses an ordinary key. + throw new Error( + reservedKey + ? AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE + : AGENT_HARNESS_SESSION_ID_LOCKED_MESSAGE, + ); + } + if (!reservedKey || loaded.entry) { + // `harness:*` was historically a valid public key. Preserve an existing + // unlocked row while reserving missing keys for trusted harness creation. + return; + } + + // Cron's detached runner does not carry the owning harness lock. Harness + // execution targets must enter through ordinary session dispatch instead. + throw new Error(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE); +} + function resolveCronJobId(params: CronJobIdParams): string | undefined { return params.id ?? params.jobId; } @@ -276,6 +331,16 @@ export const cronHandlers: GatewayRequestHandlers = { }; const sessionKey = p.sessionKey?.trim() || undefined; const agentId = p.agentId?.trim() || undefined; + if (sessionKey && isAgentHarnessSessionKey(sessionKey)) { + const loaded = loadSessionEntry(sessionKey, agentId ? { agentId } : {}); + const harnessSessionError = loaded.entry + ? resolveAgentHarnessSessionStoreEntryError(loaded.canonicalKey, loaded.entry) + : AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE; + if (harnessSessionError) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, harnessSessionError)); + return; + } + } if (sessionKey && isSubagentSessionKey(sessionKey)) { // Wake requests resume user-visible sessions only; subagent sessions are // internal task execution targets and should not receive operator wakes. @@ -504,6 +569,12 @@ export const cronHandlers: GatewayRequestHandlers = { const callerScope = readCronCallerScope(client); const jobCreate = applyCronCreateCallerScopeDefault(candidate as CronJobCreate, callerScope); const cfg = context.getRuntimeConfig(); + try { + assertCronDoesNotTargetAgentHarness(jobCreate); + } catch (err) { + respondInvalidCronParams(respond, "cron.add", formatErrorMessage(err)); + return; + } if ( !cronCreateMatchesCallerScope({ job: jobCreate, diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index 866cdb4a5bf1..6763eaf983be 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -14,6 +14,20 @@ import type { GatewayClient } from "./types.js"; const getRuntimeConfig = vi.hoisted(() => vi.fn<() => OpenClawConfig>(() => ({}) as OpenClawConfig), ); +const loadGatewaySessionEntry = vi.hoisted(() => + vi.fn( + ( + sessionKey: string, + ): { + canonicalKey: string; + entry?: { + agentHarnessId?: unknown; + modelSelectionLocked?: unknown; + sessionId?: unknown; + }; + } => ({ canonicalKey: sessionKey, entry: undefined }), + ), +); vi.mock("../../config/config.js", async () => { const actual = @@ -24,6 +38,10 @@ vi.mock("../../config/config.js", async () => { }; }); +vi.mock("../session-utils.js", () => ({ + loadSessionEntry: loadGatewaySessionEntry, +})); + import { cronHandlers } from "./cron.js"; function createPrefixOnlyChannelPlugin( @@ -432,6 +450,9 @@ function expectInvalidCronPatternError(respond: ReturnType): void describe("cron method validation", () => { beforeEach(() => { getRuntimeConfig.mockReset().mockReturnValue({} as OpenClawConfig); + loadGatewaySessionEntry + .mockReset() + .mockImplementation((sessionKey: string) => ({ canonicalKey: sessionKey, entry: undefined })); setCronValidationTestRegistry(); }); @@ -676,6 +697,236 @@ describe("cron method validation", () => { expectCronSuccess(respond); }); + it.each([ + { + name: "explicit reserved target", + params: { sessionTarget: "session:harness:codex:supervision:native-thread" }, + }, + { + name: "current target resolved from a reserved caller session", + params: { + sessionTarget: "current", + sessionKey: "agent:main:harness:codex:supervision:native-thread", + }, + }, + ])("rejects cron.add for $name", async ({ params }) => { + const { context, respond } = await invokeCronAdd(agentTurnCronParams(params)); + + expect(context.cron.add).not.toHaveBeenCalled(); + expectResponseError(respond, { + code: "INVALID_REQUEST", + messageIncludes: "reserved for agent harness-owned sessions", + }); + }); + + it("rejects cron.update retargeting into a reserved harness session", async () => { + const { context, respond } = await invokeCronUpdate( + { + id: "cron-1", + patch: { sessionTarget: "session:agent:main:harness:codex:supervision:native-thread" }, + }, + createCronJob(), + ); + + expect(context.cron.update).not.toHaveBeenCalled(); + expectResponseError(respond, { + code: "INVALID_REQUEST", + messageIncludes: "reserved for agent harness-owned sessions", + }); + }); + + it.each(["add", "update"] as const)( + "allows cron.%s to target a pre-existing unlocked harness-prefixed session", + async (method) => { + const sessionKey = "agent:main:harness:legacy-notes"; + loadGatewaySessionEntry.mockReturnValue({ + canonicalKey: sessionKey, + entry: { agentHarnessId: "codex", sessionId: "legacy-session" }, + }); + + const result = + method === "add" + ? await invokeCronAdd( + agentTurnCronParams({ + agentId: "main", + sessionTarget: `session:${sessionKey}`, + }), + ) + : await invokeCronUpdate( + { id: "cron-1", patch: { sessionTarget: `session:${sessionKey}` } }, + createCronJob({ agentId: "main" }), + ); + + if (method === "add") { + expect(result.context.cron.add).toHaveBeenCalled(); + } else { + expect(result.context.cron.update).toHaveBeenCalled(); + } + expect(result.respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ id: "cron-1" }), + undefined, + ); + }, + ); + + it("rejects cron.add targeting an existing locked harness session", async () => { + const sessionKey = "agent:main:harness:codex:supervision:native-thread"; + loadGatewaySessionEntry.mockReturnValue({ + canonicalKey: sessionKey, + entry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + }, + }); + + const { context, respond } = await invokeCronAdd( + agentTurnCronParams({ sessionTarget: `session:${sessionKey}` }), + ); + + expect(context.cron.add).not.toHaveBeenCalled(); + expectResponseError(respond, { + code: "INVALID_REQUEST", + messageIncludes: "reserved for agent harness-owned sessions", + }); + }); + + it.each(["add", "update"] as const)( + "rejects cron.%s targeting an existing locked ordinary session", + async (method) => { + const sessionKey = "agent:main:project-native-session"; + loadGatewaySessionEntry.mockReturnValue({ + canonicalKey: sessionKey, + entry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + }, + }); + + const result = + method === "add" + ? await invokeCronAdd( + agentTurnCronParams({ + agentId: "main", + sessionTarget: `session:${sessionKey}`, + }), + ) + : await invokeCronUpdate( + { id: "cron-1", patch: { sessionTarget: `session:${sessionKey}` } }, + createCronJob({ agentId: "main" }), + ); + + if (method === "add") { + expect(result.context.cron.add).not.toHaveBeenCalled(); + } else { + expect(result.context.cron.update).not.toHaveBeenCalled(); + } + expectResponseError(result.respond, { + code: "INVALID_REQUEST", + messageIncludes: "identity is locked and cannot be replaced or shared", + }); + }, + ); + + it("revalidates an unchanged session target when cron.update changes its agent", async () => { + const sessionKey = "project-native-session"; + loadGatewaySessionEntry.mockReturnValue({ + canonicalKey: `agent:worker:${sessionKey}`, + entry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + }, + }); + + const { context, respond } = await invokeCronUpdate( + { id: "cron-1", patch: { agentId: "worker" } }, + createCronJob({ agentId: "main", sessionTarget: `session:${sessionKey}` }), + ); + + expect(context.cron.update).not.toHaveBeenCalled(); + expectResponseError(respond, { + code: "INVALID_REQUEST", + messageIncludes: "identity is locked and cannot be replaced or shared", + }); + }); + + it("keeps a harness-origin key as routing context for a main-session job", async () => { + const { context, respond } = await invokeCronAdd({ + name: "main reminder", + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + sessionKey: "agent:main:harness:codex:supervision:native-thread", + wakeMode: "now", + payload: { kind: "systemEvent", text: "remind me" }, + }); + + expect(context.cron.add).toHaveBeenCalled(); + expectCronSuccess(respond); + }); + + it("rejects wake requests targeting reserved harness sessions", async () => { + const { context, respond } = await invokeWake({ + mode: "now", + text: "ping", + sessionKey: "agent:main:harness:codex:supervision:native-thread", + }); + + expect(context.cron.wake).not.toHaveBeenCalled(); + expectResponseError(respond, { + code: "INVALID_REQUEST", + messageIncludes: "reserved for agent harness-owned sessions", + }); + }); + + it("allows wake requests for an existing locked harness-owned session", async () => { + const sessionKey = "agent:main:harness:codex:supervision:native-thread"; + loadGatewaySessionEntry.mockReturnValueOnce({ + canonicalKey: sessionKey, + entry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + }, + }); + + const { context, respond } = await invokeWake({ + mode: "now", + text: "ping", + sessionKey, + }); + + expect(context.cron.wake).toHaveBeenCalledWith({ + mode: "now", + text: "ping", + sessionKey, + }); + expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); + }); + + it("allows wake requests for a pre-existing unlocked harness-prefixed session", async () => { + const sessionKey = "agent:main:harness:legacy-notes"; + loadGatewaySessionEntry.mockReturnValueOnce({ + canonicalKey: sessionKey, + entry: { agentHarnessId: "codex", sessionId: "legacy-session" }, + }); + + const { context, respond } = await invokeWake({ + mode: "now", + text: "ping", + sessionKey, + }); + + expect(context.cron.wake).toHaveBeenCalledWith({ + mode: "now", + text: "ping", + sessionKey, + }); + expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); + }); + it("stamps declaration ownership from the trusted caller and scopes key lookup", async () => { const { context, respond } = await invokeCronAdd( agentTurnCronParams({ diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 84a99231298c..9600c637e468 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -11,6 +11,7 @@ import { import { jsonResult } from "../../agents/tools/common.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE } from "../../sessions/agent-harness-session-key.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { captureEnv, setTestEnvValue } from "../../test-utils/env.js"; import type { GatewayRequestContext } from "./types.js"; @@ -40,6 +41,12 @@ const mocks = vi.hoisted(() => ({ applyPluginAutoEnable: vi.fn(), getRuntimeConfigSnapshot: vi.fn(), getRuntimeConfigSourceSnapshot: vi.fn(), + loadSessionEntry: vi.fn( + (sessionKey: string): { canonicalKey: string; entry: { sessionId: string } | undefined } => ({ + canonicalKey: sessionKey, + entry: undefined, + }), + ), })); vi.mock("../../config/config.js", async () => { @@ -172,6 +179,14 @@ vi.mock("../../config/sessions.js", async () => { }; }); +vi.mock("../session-utils.js", async () => { + const actual = await vi.importActual("../session-utils.js"); + return { + ...actual, + loadSessionEntry: mocks.loadSessionEntry, + }; +}); + async function loadSendHandlersForTest() { ({ sendHandlers } = await import("./send.js")); } @@ -428,6 +443,10 @@ describe("gateway send mirroring", () => { })); mocks.getRuntimeConfigSnapshot.mockReturnValue(null); mocks.getRuntimeConfigSourceSnapshot.mockReturnValue(null); + mocks.loadSessionEntry.mockImplementation((sessionKey: string) => ({ + canonicalKey: sessionKey, + entry: undefined, + })); mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "resolved" }); mocks.resolveOutboundSessionRoute.mockImplementation( async ({ agentId, channel }: { agentId?: string; channel?: string }) => ({ @@ -1438,6 +1457,46 @@ describe("gateway send mirroring", () => { }); }); + it("rejects a missing reserved agent-harness session before persistence or delivery", async () => { + const sessionKey = "agent:main:harness:codex:supervision:missing"; + + const { respond } = await runSend({ + to: "channel:C1", + message: "hello", + channel: "slack", + sessionKey, + idempotencyKey: "idem-missing-agent-harness-session", + }); + + const response = firstRespondCall(respond); + expect(response[0]).toBe(false); + expect(response[2]?.message).toBe(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE); + expect(mocks.ensureOutboundSessionEntry).not.toHaveBeenCalled(); + expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled(); + }); + + it("allows delivery through an existing reserved agent-harness session", async () => { + const sessionKey = "agent:main:harness:codex:supervision:existing"; + mocks.loadSessionEntry.mockReturnValueOnce({ + canonicalKey: sessionKey, + entry: { sessionId: "native-session" }, + }); + mockDeliverySuccess("m-existing-agent-harness-session"); + + const { respond } = await runSend({ + to: "channel:C1", + message: "hello", + channel: "slack", + sessionKey, + idempotencyKey: "idem-existing-agent-harness-session", + }); + + const response = firstRespondCall(respond); + expect(response[0]).toBe(true); + expect(ensureSessionEntryCall()?.route?.sessionKey).toBe(sessionKey); + expectDeliverySessionMirror({ agentId: "main", sessionKey }); + }); + it("still resolves outbound routing metadata when a sessionKey is provided", async () => { mockDeliverySuccess("m-matrix-session-route"); mocks.resolveOutboundSessionRoute.mockResolvedValueOnce({ diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 423c6fd9dc45..ba901d27ff93 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -48,6 +48,10 @@ import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { extractToolPayload } from "../../plugin-sdk/tool-payload.js"; import { normalizePollInput } from "../../polls.js"; import { normalizeAgentId } from "../../routing/session-key.js"; +import { + isAgentHarnessSessionKey, + resolveMissingAgentHarnessSessionError, +} from "../../sessions/agent-harness-session-key.js"; import { normalizeSessionKeyPreservingOpaquePeerIds, parseAgentSessionKey, @@ -57,6 +61,7 @@ import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/m import { resolveGatewayConversationReadOrigin } from "../conversation-read-origin.js"; import { ADMIN_SCOPE } from "../operator-scopes.js"; import { resolveGatewayPluginConfig } from "../runtime-plugin-config.js"; +import { loadSessionEntry } from "../session-utils.js"; import { formatForLog } from "../ws-log.js"; import type { GatewayRequestContext, GatewayRequestHandlers, RespondFn } from "./types.js"; @@ -837,6 +842,21 @@ export const sendHandlers: GatewayRequestHandlers = { } : derivedRoute : null; + const outboundSessionKey = outboundRoute?.sessionKey ?? providedSessionKey; + if (outboundSessionKey && isAgentHarnessSessionKey(outboundSessionKey)) { + const { canonicalKey, entry } = loadSessionEntry(outboundSessionKey); + const missingHarnessSessionError = resolveMissingAgentHarnessSessionError( + canonicalKey, + entry, + ); + if (missingHarnessSessionError) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, missingHarnessSessionError), + meta: { channel }, + }; + } + } if (outboundRoute) { await ensureOutboundSessionEntry({ cfg, @@ -845,7 +865,6 @@ export const sendHandlers: GatewayRequestHandlers = { route: outboundRoute, }); } - const outboundSessionKey = outboundRoute?.sessionKey ?? providedSessionKey; const outboundSession = buildOutboundSessionContext({ cfg, agentId: effectiveAgentId, diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index ea3d72572a7f..8e99b7242ce7 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -43,6 +43,7 @@ import { waitForEmbeddedAgentRunEnd, } from "../../agents/embedded-agent-runner/runs.js"; import { compactEmbeddedAgentSession } from "../../agents/embedded-agent.js"; +import { resolvePersistedSessionRuntimeId } from "../../agents/session-runtime-compat.js"; import { resolveIngressWorkspaceOverrideForSessionRun } from "../../agents/spawned-context.js"; import { insideGitCheckout } from "../../agents/worktrees/git.js"; import { managedWorktrees } from "../../agents/worktrees/service.js"; @@ -75,6 +76,8 @@ import { createSubsystemLogger } from "../../logging/subsystem.js"; import { patchPluginSessionExtension } from "../../plugins/host-hook-state.js"; import { isPluginJsonValue } from "../../plugins/host-hooks.js"; import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; +import { resolveMissingAgentHarnessSessionError } from "../../sessions/agent-harness-session-key.js"; +import { isModelSelectionLocked } from "../../sessions/model-overrides.js"; import { interruptSessionWorkAdmissions, isSessionLifecycleMutationActive, @@ -156,6 +159,8 @@ import { assertValidParams } from "./validation.js"; const log = createSubsystemLogger("gateway/sessions"); const compactionCheckpointStore = createFileBackedCompactionCheckpointStore(); +const MODEL_SELECTION_LOCKED_CHECKPOINT_MESSAGE = + "Checkpoint branch and restore are unavailable while model selection is locked."; function filterSessionStoreToConfiguredAgents( cfg: OpenClawConfig, @@ -1607,6 +1612,14 @@ export const sessionsHandlers: GatewayRequestHandlers = { ); return; } + if (branchedSession.status === "model-selection-locked") { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, MODEL_SELECTION_LOCKED_CHECKPOINT_MESSAGE), + ); + return; + } if (branchedSession.status === "failed") { respond( false, @@ -1706,6 +1719,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { ]; let admittedWorkReleased = true; let restoreTargetStillCurrent = true; + let restoreBlockedByModelLock = false; // Restore replaces the active transcript identity. Hold the same lifecycle fence as // compaction so neither operation can publish state from the other's obsolete session. await runExclusiveSessionLifecycleMutation({ @@ -1721,6 +1735,10 @@ export const sessionsHandlers: GatewayRequestHandlers = { if (!restoreTargetStillCurrent) { return; } + restoreBlockedByModelLock = current.entry?.modelSelectionLocked === true; + if (restoreBlockedByModelLock) { + return; + } clearSessionQueues([ key, current.canonicalKey, @@ -1746,6 +1764,14 @@ export const sessionsHandlers: GatewayRequestHandlers = { ); return; } + if (restoreBlockedByModelLock) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, MODEL_SELECTION_LOCKED_CHECKPOINT_MESSAGE), + ); + return; + } if (!admittedWorkReleased) { respond( false, @@ -1763,6 +1789,14 @@ export const sessionsHandlers: GatewayRequestHandlers = { ); return; } + if (current.entry.modelSelectionLocked === true) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, MODEL_SELECTION_LOCKED_CHECKPOINT_MESSAGE), + ); + return; + } if (!getSessionCompactionCheckpoint({ entry: current.entry, checkpointId })) { respond( false, @@ -1811,6 +1845,14 @@ export const sessionsHandlers: GatewayRequestHandlers = { ); return; } + if (restoredSession.status === "model-selection-locked") { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, MODEL_SELECTION_LOCKED_CHECKPOINT_MESSAGE), + ); + return; + } if (restoredSession.status === "failed") { respond( false, @@ -2060,6 +2102,14 @@ export const sessionsHandlers: GatewayRequestHandlers = { }); const canonicalKey = target.canonicalKey ?? key; const lifecycleEntry = loadSessionEntry(key, { agentId: requestedAgentId }).entry; + const missingHarnessSessionError = resolveMissingAgentHarnessSessionError( + canonicalKey, + lifecycleEntry, + ); + if (missingHarnessSessionError) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, missingHarnessSessionError)); + return; + } const lifecycleIdentities = [canonicalKey, key, lifecycleEntry?.sessionId]; if (p.archived === true && isSessionLifecycleMutationActive(storePath, lifecycleIdentities)) { respond( @@ -2384,6 +2434,23 @@ export const sessionsHandlers: GatewayRequestHandlers = { const initialDeleteEntry = loadSessionEntry(key, { agentId: requestedAgentId, }).entry; + const rejectModelSelectionLockedDelete = (entry: SessionEntry | undefined): boolean => { + if (!isModelSelectionLocked(entry)) { + return false; + } + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "This session cannot be deleted while model selection is locked.", + ), + ); + return true; + }; + if (rejectModelSelectionLockedDelete(initialDeleteEntry)) { + return; + } // archivedOnly is the archive-then-delete contract: the dispatcher grants // it to write-scope operators, so the target must actually be archived. if (p.archivedOnly === true && initialDeleteEntry?.archivedAt === undefined) { @@ -2455,13 +2522,17 @@ export const sessionsHandlers: GatewayRequestHandlers = { ]; let admittedWorkReleased = true; let expectedSessionStillCurrent = true; + let deleteBlockedByModelLock = false; const deletion = await runExclusiveSessionLifecycleMutation({ scope: storePath, identities: deleteLifecycleIdentities, prepare: async () => { - expectedSessionStillCurrent = !rejectExpectedSessionMismatch( - loadSessionEntry(key, { agentId: requestedAgentId }).entry, - ); + const preparedEntry = loadSessionEntry(key, { agentId: requestedAgentId }).entry; + deleteBlockedByModelLock = rejectModelSelectionLockedDelete(preparedEntry); + if (deleteBlockedByModelLock) { + return; + } + expectedSessionStillCurrent = !rejectExpectedSessionMismatch(preparedEntry); if (!expectedSessionStillCurrent) { return; } @@ -2472,7 +2543,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { }); }, run: async () => { - if (!expectedSessionStillCurrent) { + if (deleteBlockedByModelLock || !expectedSessionStillCurrent) { return undefined; } if (!admittedWorkReleased) { @@ -2486,6 +2557,9 @@ export const sessionsHandlers: GatewayRequestHandlers = { const { entry, legacyKey, canonicalKey } = loadSessionEntry(key, { agentId: requestedAgentId, }); + if (rejectModelSelectionLockedDelete(entry)) { + return undefined; + } if (rejectExpectedSessionMismatch(entry)) { return undefined; } @@ -3040,7 +3114,11 @@ export const sessionsHandlers: GatewayRequestHandlers = { provider: resolvedModel.provider, model: resolvedModel.model, authProfileId: latestEntry.authProfileOverride, - agentHarnessId: latestEntry.agentHarnessId, + agentHarnessId: + latestEntry.modelSelectionLocked === true + ? resolvePersistedSessionRuntimeId(latestEntry) + : latestEntry.agentHarnessId, + modelSelectionLocked: latestEntry.modelSelectionLocked === true, thinkLevel: normalizeThinkLevel(latestEntry.thinkingLevel), reasoningLevel: normalizeReasoningLevel(latestEntry.reasoningLevel), bashElevated: { diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index f79e2c61ce97..6225a630ca76 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -10,6 +10,8 @@ import type { loadSessionEntry as loadSessionEntryType } from "./session-utils.j const buildSessionLookup = ( sessionKey: string, entry: { + agentHarnessId?: string; + modelSelectionLocked?: boolean; sessionId?: string; model?: string; modelProvider?: string; @@ -27,6 +29,8 @@ const buildSessionLookup = ( storePath: "/tmp/sessions.json", store: {} as ReturnType["store"], entry: { + agentHarnessId: entry.agentHarnessId, + modelSelectionLocked: entry.modelSelectionLocked, sessionId: entry.sessionId ?? `sid-${sessionKey}`, updatedAt: entry.updatedAt ?? Date.now(), model: entry.model, @@ -792,8 +796,11 @@ describe("node exec events", () => { describe("voice transcript events", () => { beforeEach(() => { + resetNodeEventDeduplicationForTests(); agentCommandMock.mockClear(); canonicalizeSessionEntryAliasesMock.mockClear(); + loadSessionEntryMock.mockClear(); + loadSessionEntryMock.mockImplementation((sessionKey: string) => buildSessionLookup(sessionKey)); agentCommandMock.mockResolvedValue({ status: "ok" } as never); canonicalizeSessionEntryAliasesMock.mockImplementation(async ({ target, update }) => { const entry = update ? await update(undefined) : undefined; @@ -825,6 +832,71 @@ describe("voice transcript events", () => { expect(canonicalizeSessionEntryAliasesMock).toHaveBeenCalledTimes(1); }); + it("rejects a missing harness-owned session before touching the store", async () => { + const sessionKey = "agent:main:harness:codex:supervision:missing-voice"; + loadSessionEntryMock.mockReturnValueOnce({ + ...buildSessionLookup(sessionKey), + entry: undefined, + }); + const addChatRun = vi.fn(); + const ctx = buildCtx(); + ctx.addChatRun = addChatRun; + + await handleNodeEvent(ctx, "node-harness-voice-missing", { + event: "voice.transcript", + payloadJSON: JSON.stringify({ text: "do not create this", sessionKey }), + }); + await Promise.resolve(); + + expect(canonicalizeSessionEntryAliasesMock).not.toHaveBeenCalled(); + expect(addChatRun).not.toHaveBeenCalled(); + expect(agentCommandMock).not.toHaveBeenCalled(); + }); + + it("dispatches voice transcripts to an existing harness-owned session", async () => { + const sessionKey = "agent:main:harness:codex:supervision:existing-voice"; + loadSessionEntryMock.mockReturnValueOnce( + buildSessionLookup(sessionKey, { + agentHarnessId: "codex", + modelSelectionLocked: true, + }), + ); + + await handleNodeEvent(buildCtx(), "node-harness-voice-existing", { + event: "voice.transcript", + payloadJSON: JSON.stringify({ text: "continue supervised work", sessionKey }), + }); + await Promise.resolve(); + + expect(canonicalizeSessionEntryAliasesMock).toHaveBeenCalledTimes(1); + expect(agentCommandMock).toHaveBeenCalledTimes(1); + expectFields(mockCallArg(agentCommandMock), { sessionKey }); + }); + + it.each([ + ["wrong owner", { agentHarnessId: "other", modelSelectionLocked: true }], + ["missing session id", { agentHarnessId: "codex", modelSelectionLocked: true, sessionId: "" }], + ] as const)( + "rejects a harness-owned voice session with %s before side effects", + async (_label, entry) => { + const sessionKey = `agent:main:harness:codex:supervision:invalid-voice-${_label.replaceAll(" ", "-")}`; + loadSessionEntryMock.mockReturnValueOnce(buildSessionLookup(sessionKey, entry)); + const addChatRun = vi.fn(); + const ctx = buildCtx(); + ctx.addChatRun = addChatRun; + + await handleNodeEvent(ctx, "node-harness-voice-invalid", { + event: "voice.transcript", + payloadJSON: JSON.stringify({ text: "do not dispatch this", sessionKey }), + }); + await Promise.resolve(); + + expect(canonicalizeSessionEntryAliasesMock).not.toHaveBeenCalled(); + expect(addChatRun).not.toHaveBeenCalled(); + expect(agentCommandMock).not.toHaveBeenCalled(); + }, + ); + it("does not dedupe identical text when source event IDs differ", async () => { const ctx = buildCtx(); @@ -1077,6 +1149,40 @@ describe("notifications changed events", () => { }); }); + it("rejects missing reserved notification contexts before enqueue", async () => { + const sessionKey = "agent:main:harness:codex:supervision:missing-notification"; + loadSessionEntryMock.mockReturnValueOnce({ + ...buildSessionLookup(sessionKey), + entry: undefined, + }); + + await handleNodeEvent(buildCtx(), "node-harness-missing", { + event: "notifications.changed", + payloadJSON: JSON.stringify({ change: "posted", key: "notif", sessionKey }), + }); + + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + expect(requestHeartbeatMock).not.toHaveBeenCalled(); + }); + + it("preserves valid durable harness notification contexts", async () => { + const sessionKey = "agent:main:harness:codex:supervision:existing-notification"; + loadSessionEntryMock.mockReturnValueOnce( + buildSessionLookup(sessionKey, { + agentHarnessId: "codex", + modelSelectionLocked: true, + }), + ); + + await handleNodeEvent(buildCtx(), "node-harness-existing", { + event: "notifications.changed", + payloadJSON: JSON.stringify({ change: "posted", key: "notif", sessionKey }), + }); + + expect(enqueueSystemEventMock).toHaveBeenCalledOnce(); + expect(requestHeartbeatMock).toHaveBeenCalledWith(expect.objectContaining({ sessionKey })); + }); + it("ignores notifications.changed payloads missing required fields", async () => { const ctx = buildCtx(); await handleNodeEvent(ctx, "node-n3", { @@ -1158,6 +1264,10 @@ describe("agent request events", () => { beforeEach(() => { agentCommandMock.mockClear(); parseMessageWithAttachmentsMock.mockReset(); + runtimeMocks.resolveSessionAgentId.mockClear(); + runtimeMocks.resolveSessionModelRef.mockClear(); + runtimeMocks.resolveGatewayModelSupportsImages.mockClear(); + persistInboundImagesForTranscriptMock.mockClear(); canonicalizeSessionEntryAliasesMock.mockClear(); loadSessionEntryMock.mockClear(); normalizeChannelIdVi.mockClear(); @@ -1176,6 +1286,69 @@ describe("agent request events", () => { loadSessionEntryMock.mockImplementation((sessionKey: string) => buildSessionLookup(sessionKey)); }); + it("rejects a missing harness-owned session before touching the store", async () => { + const sessionKey = "agent:main:harness:codex:supervision:missing-request"; + loadSessionEntryMock.mockReturnValueOnce({ + ...buildSessionLookup(sessionKey), + entry: undefined, + }); + + await handleNodeEvent(buildCtx(), "node-harness-request-missing", { + event: "agent.request", + payloadJSON: JSON.stringify({ message: "do not create this", sessionKey }), + }); + + expect(canonicalizeSessionEntryAliasesMock).not.toHaveBeenCalled(); + expect(agentCommandMock).not.toHaveBeenCalled(); + }); + + it("dispatches agent requests to an existing harness-owned session", async () => { + const sessionKey = "agent:main:harness:codex:supervision:existing-request"; + loadSessionEntryMock.mockReturnValueOnce( + buildSessionLookup(sessionKey, { + agentHarnessId: "codex", + modelSelectionLocked: true, + }), + ); + + await handleNodeEvent(buildCtx(), "node-harness-request-existing", { + event: "agent.request", + payloadJSON: JSON.stringify({ message: "continue supervised work", sessionKey }), + }); + + expect(canonicalizeSessionEntryAliasesMock).toHaveBeenCalledTimes(1); + expect(agentCommandMock).toHaveBeenCalledTimes(1); + expectFields(mockCallArg(agentCommandMock), { sessionKey }); + }); + + it.each([ + ["wrong owner", { agentHarnessId: "other", modelSelectionLocked: true }], + ["missing session id", { agentHarnessId: "codex", modelSelectionLocked: true, sessionId: "" }], + ] as const)( + "rejects a harness-owned agent request with %s before side effects", + async (_label, entry) => { + const sessionKey = `agent:main:harness:codex:supervision:invalid-request-${_label.replaceAll(" ", "-")}`; + loadSessionEntryMock.mockReturnValueOnce(buildSessionLookup(sessionKey, entry)); + + await handleNodeEvent(buildCtx(), "node-harness-request-invalid", { + event: "agent.request", + payloadJSON: JSON.stringify({ + message: "do not dispatch this", + sessionKey, + attachments: [{ type: "image", mimeType: "image/png", content: "aGVsbG8=" }], + }), + }); + + expect(runtimeMocks.resolveSessionAgentId).not.toHaveBeenCalled(); + expect(runtimeMocks.resolveSessionModelRef).not.toHaveBeenCalled(); + expect(runtimeMocks.resolveGatewayModelSupportsImages).not.toHaveBeenCalled(); + expect(parseMessageWithAttachmentsMock).not.toHaveBeenCalled(); + expect(canonicalizeSessionEntryAliasesMock).not.toHaveBeenCalled(); + expect(persistInboundImagesForTranscriptMock).not.toHaveBeenCalled(); + expect(agentCommandMock).not.toHaveBeenCalled(); + }, + ); + it("disables delivery when route is unresolved instead of falling back globally", async () => { const warn = vi.fn(); const ctx = buildCtx(); diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index fe57f1c76eaf..62e7ed8e8eb6 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -15,6 +15,7 @@ import { scopedHeartbeatWakeOptionsForPolicy, } from "../infra/event-session-routing.js"; import type { PromptImageOrderEntry } from "../media/prompt-image-order.js"; +import { resolveAgentHarnessSessionContextError } from "../sessions/agent-harness-session-key.js"; import { NODE_PRESENCE_ALIVE_EVENT, normalizeNodePresenceAliveReason, @@ -401,6 +402,9 @@ export const handleNodeEvent = async ( const rawMainKey = normalizeMainKey(cfg.session?.mainKey); const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : rawMainKey; const { storePath, entry, canonicalKey, storeKeys } = loadSessionEntry(sessionKey); + if (resolveAgentHarnessSessionContextError(canonicalKey, entry)) { + return undefined; + } const now = Date.now(); const fingerprint = resolveVoiceTranscriptFingerprint(obj, text); if (shouldDropDuplicateVoiceTranscript({ sessionKey: canonicalKey, fingerprint, now })) { @@ -476,6 +480,9 @@ export const handleNodeEvent = async ( const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : `node-${nodeId}`; const cfg = getRuntimeConfig(); const { storePath, entry, canonicalKey, storeKeys } = loadSessionEntry(sessionKey); + if (resolveAgentHarnessSessionContextError(canonicalKey, entry)) { + return undefined; + } let message = (link?.message ?? "").trim(); const transcriptMessage = message; @@ -646,7 +653,10 @@ export const handleNodeEvent = async ( } const key = sanitizeInboundSystemTags(keyRaw); const sessionKeyRaw = normalizeOptionalString(obj.sessionKey) ?? `node-${nodeId}`; - const { canonicalKey: sessionKey } = loadSessionEntry(sessionKeyRaw); + const { canonicalKey: sessionKey, entry } = loadSessionEntry(sessionKeyRaw); + if (resolveAgentHarnessSessionContextError(sessionKey, entry)) { + return undefined; + } const packageNameRaw = normalizeOptionalString(obj.packageName); const packageName = packageNameRaw ? sanitizeInboundSystemTags(packageNameRaw) : null; const title = compactNotificationEventText( diff --git a/src/gateway/server.sessions.compaction.test.ts b/src/gateway/server.sessions.compaction.test.ts index 5773e0684381..2beb1bea2c5a 100644 --- a/src/gateway/server.sessions.compaction.test.ts +++ b/src/gateway/server.sessions.compaction.test.ts @@ -32,6 +32,26 @@ import { expectSessionQueueCleanup, } from "./test/server-sessions.test-helpers.js"; +const sessionLifecycleMutationObserver = vi.hoisted(() => ({ + onEnter: undefined as + | ((params: { prepare?: () => Promise; scope: string }) => void) + | undefined, +})); + +vi.mock("../sessions/session-lifecycle-admission.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + runExclusiveSessionLifecycleMutation: async ( + params: Parameters[0], + ) => { + sessionLifecycleMutationObserver.onEnter?.(params); + return await actual.runExclusiveSessionLifecycleMutation(params); + }, + }; +}); + const { createSessionStoreDir, createSelectedGlobalSessionStore, openClient } = setupGatewaySessionsTestHarness(); @@ -335,6 +355,57 @@ test("sessions.compaction.* lists checkpoints and branches or restores from comp ws.close(); }); +test("sessions.compaction.branch rejects model-selection-locked session identities", async () => { + const { dir, storePath } = await createSessionStoreDir(); + const fixture = await createCheckpointFixture(dir, { legacyPreCompactionSnapshot: false }); + const checkpointEntry = compactionCheckpointEntry(fixture, { + checkpointId: "checkpoint-locked-branch", + sessionKey: "agent:main:main", + createdAt: Date.now(), + reason: "manual", + summary: "locked checkpoint", + }); + await writeSessionStore({ + entries: { + main: sessionStoreEntry(fixture.sessionId, { + sessionFile: fixture.sessionFile, + compactionCheckpoints: [checkpointEntry], + modelSelectionLocked: true, + }), + }, + }); + const filesBefore = (await fs.readdir(dir)).toSorted(); + const { ws } = await openClient(); + + try { + await expect( + rpcReq(ws, "sessions.compaction.branch", { + key: "main", + checkpointId: "checkpoint-locked-branch", + }), + ).resolves.toMatchObject({ + ok: false, + error: { + code: "INVALID_REQUEST", + message: "Checkpoint branch and restore are unavailable while model selection is locked.", + }, + }); + const nextStore = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< + string, + { modelSelectionLocked?: boolean; sessionId?: string } + >; + expect(nextStore).toEqual({ + "agent:main:main": expect.objectContaining({ + modelSelectionLocked: true, + sessionId: fixture.sessionId, + }), + }); + expect((await fs.readdir(dir)).toSorted()).toEqual(filesBefore); + } finally { + ws.close(); + } +}); + test("sessions.compaction.* scopes selected global checkpoints to the requested agent", async () => { const { mainStorePath, workStorePath } = await createSelectedGlobalSessionStore(); const workDir = path.dirname(workStorePath); @@ -568,6 +639,8 @@ test("sessions.compact records terminal Codex native compaction", async () => { entries: { main: sessionStoreEntry("sess-codex", { agentHarnessId: "codex", + agentRuntimeOverride: "openclaw", + modelSelectionLocked: true, compactionCount: 2, totalTokens: 54_321, totalTokensFresh: true, @@ -619,6 +692,12 @@ test("sessions.compact records terminal Codex native compaction", async () => { sessionKey: "agent:main:main", completed: true, }); + expect(embeddedRunMock.compactEmbeddedAgentSession).toHaveBeenCalledWith( + expect.objectContaining({ + agentHarnessId: "codex", + modelSelectionLocked: true, + }), + ); const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< string, @@ -940,6 +1019,88 @@ test("sessions.compaction.restore leaves replacement-session work untouched when } }); +test("sessions.compaction.restore rechecks the model-selection lock inside the lifecycle fence", async () => { + const { dir, storePath } = await createSessionStoreDir(); + const fixture = await createCheckpointFixture(dir, { legacyPreCompactionSnapshot: false }); + const checkpointEntry = compactionCheckpointEntry(fixture, { + checkpointId: "checkpoint-lock-race", + sessionKey: "agent:main:main", + createdAt: Date.now(), + reason: "manual", + summary: "checkpoint summary", + }); + const unlockedEntry = sessionStoreEntry(fixture.sessionId, { + sessionFile: fixture.sessionFile, + compactionCheckpoints: [checkpointEntry], + }); + await writeSessionStore({ entries: { main: unlockedEntry } }); + const blockerStarted = createDeferred(); + const releaseBlocker = createDeferred(); + const blocker = runExclusiveSessionLifecycleMutation({ + scope: storePath, + identities: ["main", "agent:main:main", fixture.sessionId], + run: async () => { + blockerStarted.resolve(); + await releaseBlocker.promise; + }, + }); + await blockerStarted.promise; + + const { ws } = await openClient(); + const restoreReachedLifecycleFence = createDeferred(); + sessionLifecycleMutationObserver.onEnter = (params) => { + // The handler has completed its initial store read, while the blocker still + // keeps lifecycle prepare from observing the replacement row. + if (params.scope === storePath && params.prepare) { + restoreReachedLifecycleFence.resolve(); + } + }; + + try { + let restoreSettled = false; + const restore = rpcReq(ws, "sessions.compaction.restore", { + key: "main", + checkpointId: "checkpoint-lock-race", + }).finally(() => { + restoreSettled = true; + }); + await restoreReachedLifecycleFence.promise; + expect(restoreSettled).toBe(false); + await writeSessionStore({ + entries: { + main: { + ...unlockedEntry, + modelSelectionLocked: true, + updatedAt: Date.now(), + }, + }, + }); + + releaseBlocker.resolve(); + await blocker; + await expect(restore).resolves.toMatchObject({ + ok: false, + error: { + code: "INVALID_REQUEST", + message: "Checkpoint branch and restore are unavailable while model selection is locked.", + }, + }); + const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< + string, + { modelSelectionLocked?: boolean; sessionId?: string } + >; + expect(store["agent:main:main"]).toMatchObject({ + modelSelectionLocked: true, + sessionId: fixture.sessionId, + }); + } finally { + sessionLifecycleMutationObserver.onEnter = undefined; + releaseBlocker.resolve(); + await blocker; + ws.close(); + } +}); + test("sessions.compact blocks new work admission through terminal persistence", async () => { const { dir, storePath } = await createSessionStoreDir(); const sessionId = "sess-compact-admission"; diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index 065eabaa8868..be877d2d1eaa 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -11,6 +11,7 @@ import { listRegistryWorktrees, } from "../agents/worktrees/registry.js"; import { managedWorktrees } from "../agents/worktrees/service.js"; +import { loadSessionStore } from "../config/sessions/store.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { agentCommand, @@ -1196,6 +1197,127 @@ test("sessions.create forks the parent transcript into the new session", async ( testState.sessionConfig = undefined; }); +test("public session mutations reserve agent harness-owned session keys", async () => { + const { storePath } = await createSessionStoreDir(); + + for (const key of [ + "harness:codex:supervision:native-thread", + "agent:main:harness:codex:supervision:native-thread", + ]) { + for (const [method, params] of [ + ["sessions.create", { agentId: "main", key }], + ["sessions.patch", { agentId: "main", key, label: "Public overwrite" }], + ["sessions.reset", { agentId: "main", key }], + ] as const) { + const rejected = await directSessionReq(method, params); + expect(rejected.ok).toBe(false); + expect(rejected.error).toMatchObject({ + code: "INVALID_REQUEST", + message: "Session key namespace is reserved for agent harness-owned sessions.", + }); + } + } + + const ordinary = await directSessionReq<{ key: string }>("sessions.create", { + agentId: "main", + key: "ordinary-session", + }); + expect(ordinary.ok).toBe(true); + expect(ordinary.payload?.key).toBe("agent:main:ordinary-session"); + + const stored = loadSessionStore(storePath, { skipCache: true }); + expect(stored["agent:main:harness:codex:supervision:native-thread"]).toBeUndefined(); + expect(stored["agent:main:ordinary-session"]).toBeDefined(); +}); + +test("sessions.create preserves a pre-existing unlocked harness-prefixed session", async () => { + const { storePath } = await createSessionStoreDir(); + const key = "agent:main:harness:legacy-notes"; + await writeSessionStore({ + entries: { + [key]: sessionStoreEntry("legacy-session", { label: "Legacy notes" }), + }, + }); + + const created = await directSessionReq<{ + key: string; + sessionId: string; + }>("sessions.create", { + agentId: "main", + key, + label: "Updated notes", + }); + + expect(created.ok).toBe(true); + expect(created.payload).toMatchObject({ key, sessionId: "legacy-session" }); + expect(loadSessionStore(storePath, { skipCache: true })[key]).toMatchObject({ + sessionId: "legacy-session", + label: "Updated notes", + }); +}); + +test("sessions.create rejects a pre-existing locked harness session", async () => { + await createSessionStoreDir(); + const key = "agent:main:harness:codex:supervision:native-thread"; + await writeSessionStore({ + entries: { + [key]: sessionStoreEntry("locked-session", { + agentHarnessId: "codex", + modelSelectionLocked: true, + }), + }, + }); + + const created = await directSessionReq("sessions.create", { + agentId: "main", + key, + }); + + expect(created.ok).toBe(false); + expect(created.error).toMatchObject({ + code: "INVALID_REQUEST", + message: "Session key namespace is reserved for agent harness-owned sessions.", + }); +}); + +test("sessions.create rejects children of model-selection-locked sessions", async () => { + const { dir } = await createSessionStoreDir(); + testState.sessionConfig = { dmScope: "main", scope: "per-sender" }; + const parent = await createCheckpointFixture(dir); + await writeSessionStore({ + entries: { + main: sessionStoreEntry(parent.sessionId, { + sessionFile: parent.sessionFile, + modelSelectionLocked: true, + }), + }, + }); + + const linkedChild = await directSessionReq("sessions.create", { + agentId: "main", + parentSessionKey: "main", + }); + const forkedChild = await directSessionReq("sessions.create", { + agentId: "main", + parentSessionKey: "main", + fork: true, + }); + const resetParent = await directSessionReq("sessions.create", { + agentId: "main", + parentSessionKey: "main", + emitCommandHooks: true, + }); + + for (const created of [linkedChild, forkedChild, resetParent]) { + expect(created.ok).toBe(false); + expect(created.error).toMatchObject({ + code: "INVALID_REQUEST", + message: "Model-selection-locked sessions cannot create child sessions from parent context.", + }); + } + testState.sessionConfig = undefined; +}); + test("sessions.create rejects fork without parentSessionKey", async () => { await createSessionStoreDir(); diff --git a/src/gateway/server.sessions.delete-lifecycle.test.ts b/src/gateway/server.sessions.delete-lifecycle.test.ts index da0c24408b9c..f079413bfd09 100644 --- a/src/gateway/server.sessions.delete-lifecycle.test.ts +++ b/src/gateway/server.sessions.delete-lifecycle.test.ts @@ -77,6 +77,7 @@ function expectObject(value: unknown) { type SessionDeleteRequest = { key: string; agentId?: string; + archivedOnly?: boolean; deleteTranscript?: boolean; emitLifecycleHooks?: boolean; expectedSessionId?: string; @@ -233,6 +234,48 @@ test("sessions.delete rejects main and aborts active runs", async () => { }); }); +test("sessions.delete preserves locked archived sessions and deletes ordinary archived sessions", async () => { + const { dir, storePath } = await createSessionStoreDir(); + const lockedKey = "agent:main:harness:codex:supervision:native-thread"; + const ordinaryKey = "agent:main:ordinary-archived"; + const lockedSessionId = "sess-locked-archived"; + const ordinarySessionId = "sess-ordinary-archived"; + await writeSingleLineSession(dir, lockedSessionId, "locked"); + await writeSingleLineSession(dir, ordinarySessionId, "ordinary"); + await writeSessionStore({ + entries: { + [lockedKey]: sessionStoreEntry(lockedSessionId, { + agentHarnessId: "codex", + archivedAt: Date.now(), + modelSelectionLocked: true, + }), + [ordinaryKey]: sessionStoreEntry(ordinarySessionId, { archivedAt: Date.now() }), + }, + }); + const lockedEntryBefore = structuredClone( + loadSessionStore(storePath, { skipCache: true })[lockedKey], + ); + const lockedTranscriptPath = path.join(dir, `${lockedSessionId}.jsonl`); + const lockedTranscriptBefore = await fs.readFile(lockedTranscriptPath, "utf8"); + + const rejected = await directSessionReq("sessions.delete", { + key: lockedKey, + archivedOnly: true, + }); + expect(rejected.ok).toBe(false); + expect(rejected.error).toMatchObject({ + code: "INVALID_REQUEST", + message: "This session cannot be deleted while model selection is locked.", + }); + expect(loadSessionStore(storePath, { skipCache: true })[lockedKey]).toEqual(lockedEntryBefore); + expect(await fs.readFile(lockedTranscriptPath, "utf8")).toBe(lockedTranscriptBefore); + + await expectSessionDeleteSucceeds({ key: ordinaryKey, archivedOnly: true }); + const storedAfterOrdinaryDelete = loadSessionStore(storePath, { skipCache: true }); + expect(storedAfterOrdinaryDelete[ordinaryKey]).toBeUndefined(); + expect(storedAfterOrdinaryDelete[lockedKey]).toEqual(lockedEntryBefore); +}); + test("sessions.delete interrupts work admitted before runtime registration", async () => { const { storePath } = await createSessionStoreDir(); await writeSessionStore({ diff --git a/src/gateway/server.sessions.list-changed.test.ts b/src/gateway/server.sessions.list-changed.test.ts index 7038e78ccf99..49a89a0c182a 100644 --- a/src/gateway/server.sessions.list-changed.test.ts +++ b/src/gateway/server.sessions.list-changed.test.ts @@ -370,6 +370,7 @@ test("sessions.list keeps bulk rows lightweight and uses persisted model fields" updatedAt: Date.now() - 1_000, modelProvider: "anthropic", model: "test-model-without-catalog-context", + modelSelectionLocked: true, parentSessionKey: "agent:main:main", totalTokens: 0, totalTokensFresh: false, @@ -393,6 +394,7 @@ test("sessions.list keeps bulk rows lightweight and uses persisted model fields" estimatedCostUsd?: number; modelProvider?: string; model?: string; + modelSelectionLocked?: boolean; }>; }>(ws, "sessions.list", {}); @@ -409,6 +411,7 @@ test("sessions.list keeps bulk rows lightweight and uses persisted model fields" expect(child?.estimatedCostUsd).toBeUndefined(); expect(child?.modelProvider).toBe("anthropic"); expect(child?.model).toBe("test-model-without-catalog-context"); + expect(child?.modelSelectionLocked).toBe(true); ws.close(); }); diff --git a/src/gateway/server.sessions.reset-models.test.ts b/src/gateway/server.sessions.reset-models.test.ts index afdca3b9161a..49d4b98a1aee 100644 --- a/src/gateway/server.sessions.reset-models.test.ts +++ b/src/gateway/server.sessions.reset-models.test.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { expect, test } from "vitest"; +import { MODEL_SELECTION_LOCKED_RESET_MESSAGE } from "../sessions/model-overrides.js"; import { testState, writeSessionStore } from "./test-helpers.js"; import { setupGatewaySessionsTestHarness, @@ -192,6 +193,31 @@ async function expectMainResetModelFields(params: { expect(store["agent:main:main"]?.model).toBeUndefined(); } +test("sessions.reset rejects a model-locked session without replacing native state", async () => { + const { storePath } = await createSessionStoreDir(); + await writeSessionStore({ + entries: { + main: sessionStoreEntry("sess-model-locked", { + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { threadId: "codex-thread-1" }, + }, + }), + }, + }); + const before = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record; + + const reset = await directSessionReq("sessions.reset", { key: "main" }); + + expect(reset).toMatchObject({ + ok: false, + error: { message: MODEL_SELECTION_LOCKED_RESET_MESSAGE }, + }); + const after = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record; + expect(after).toEqual(before); +}); + test("sessions.reset recomputes model from defaults instead of stale runtime model", async () => { await createSessionStoreDir(); testState.agentConfig = { diff --git a/src/gateway/session-compaction-checkpoints.test.ts b/src/gateway/session-compaction-checkpoints.test.ts index 1d61ccc80efa..de75d70fdc25 100644 --- a/src/gateway/session-compaction-checkpoints.test.ts +++ b/src/gateway/session-compaction-checkpoints.test.ts @@ -724,6 +724,71 @@ describe("session-compaction-checkpoints", () => { expect(nextStore[MAIN_SESSION_KEY]?.totalTokens).toBe(45); }); + test("file-backed checkpoint store rejects identity changes for model-selection-locked sessions", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-checkpoint-locked-")); + tempDirs.push(dir); + + const session = SessionManager.create(dir, dir); + session.appendMessage({ + role: "user", + content: "locked checkpoint source", + timestamp: Date.now(), + }); + const checkpointLeafId = requireNonEmptyString( + session.getLeafId(), + "checkpoint leaf id missing", + ); + const sessionFile = requireNonEmptyString(session.getSessionFile(), "session file missing"); + const storePath = path.join(dir, "sessions.json"); + await writeSessionStore(storePath, MAIN_SESSION_KEY, { + sessionId: "locked-session", + sessionFile, + updatedAt: Date.now(), + modelSelectionLocked: true, + compactionCheckpoints: [ + { + checkpointId: "checkpoint-locked", + sessionKey: MAIN_SESSION_KEY, + sessionId: "locked-session", + createdAt: Date.now(), + reason: "manual", + preCompaction: { sessionId: "locked-session", leafId: checkpointLeafId }, + postCompaction: { + sessionId: "locked-session", + sessionFile, + leafId: checkpointLeafId, + }, + }, + ], + }); + const filesBefore = (await fs.readdir(dir)).toSorted(); + const store = createFileBackedCompactionCheckpointStore(); + + await expect( + store.branchCheckpointSession({ + storePath, + sourceKey: MAIN_SESSION_KEY, + nextKey: "agent:main:dashboard:locked-checkpoint-branch", + checkpointId: "checkpoint-locked", + }), + ).resolves.toEqual({ status: "model-selection-locked" }); + await expect( + store.restoreCheckpointSession({ + storePath, + sessionKey: MAIN_SESSION_KEY, + checkpointId: "checkpoint-locked", + }), + ).resolves.toEqual({ status: "model-selection-locked" }); + + expect((await fs.readdir(dir)).toSorted()).toEqual(filesBefore); + expect(await readSessionStore(storePath)).toEqual({ + [MAIN_SESSION_KEY]: expect.objectContaining({ + modelSelectionLocked: true, + sessionId: "locked-session", + }), + }); + }); + test("async fork migrates legacy checkpoint snapshots before writing a current header", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-checkpoint-legacy-fork-")); tempDirs.push(dir); diff --git a/src/gateway/session-create-service.ts b/src/gateway/session-create-service.ts index 1b4888556437..e4c1d644e4e0 100644 --- a/src/gateway/session-create-service.ts +++ b/src/gateway/session-create-service.ts @@ -8,6 +8,7 @@ import { type ErrorShape, errorShape, } from "../../packages/gateway-protocol/src/index.js"; +import { normalizeOptionalAgentRuntimeId } from "../agents/agent-runtime-id.js"; import { listAgentIds, resolveAgentWorkspaceDir, @@ -18,11 +19,15 @@ import type { ModelCatalogEntry } from "../agents/model-catalog.types.js"; import { resolveSessionModelRef } from "../agents/session-model-ref.js"; import { forkSessionFromParent, + MODEL_SELECTION_LOCKED_PARENT_FORK_MESSAGE, resolveParentForkDecision, } from "../auto-reply/reply/session-fork.js"; import type { SessionEntry } from "../config/sessions.js"; import { resolveAgentMainSessionKey } from "../config/sessions/main-session.js"; -import { createSessionEntryWithTranscript } from "../config/sessions/session-accessor.js"; +import { + createSessionEntryWithTranscript, + resolveSessionEntryAccessTarget, +} from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createInternalHookEvent, @@ -35,6 +40,12 @@ import { resolveAgentIdFromSessionKey, toAgentStoreSessionKey, } from "../routing/session-key.js"; +import { + AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE, + isAgentHarnessSessionKey, + isAgentHarnessSessionKeyOwnedBy, +} from "../sessions/agent-harness-session-key.js"; +import { isModelSelectionLocked } from "../sessions/model-overrides.js"; import { isSessionWorkAdmissionActive, runExclusiveSessionLifecycleMutation, @@ -142,6 +153,13 @@ type CreatedGatewaySession = { storePath: string; }; +type TrustedInitialSessionEntry = { + agentHarnessId: NonNullable; + initializationPending?: true; + modelSelectionLocked?: true; + pluginExtensions?: SessionEntry["pluginExtensions"]; +}; + type CreateGatewaySessionResult = | { ok: true; @@ -171,6 +189,10 @@ export async function createGatewaySession(params: { resetMainWhenUnspecified?: boolean; commandSource: string; loadGatewayModelCatalog?: () => Promise; + /** Trusted in-process initializer; never populated from public Gateway params. */ + initialEntry?: TrustedInitialSessionEntry; + /** Exact harness namespace authorized by the scoped plugin runtime. */ + authorizedAgentHarnessId?: string; afterCreate?: (created: CreatedGatewaySession) => Promise; }): Promise { const requestedKey = normalizeOptionalString(params.key); @@ -204,6 +226,29 @@ export async function createGatewaySession(params: { }) : undefined; + const authorizedHarnessCreation = Boolean( + explicitTargetKey && + params.initialEntry && + normalizeOptionalAgentRuntimeId(params.authorizedAgentHarnessId) === + normalizeOptionalAgentRuntimeId(params.initialEntry.agentHarnessId) && + isAgentHarnessSessionKeyOwnedBy(explicitTargetKey, params.authorizedAgentHarnessId), + ); + const existingHarnessEntry = + explicitTargetKey && isAgentHarnessSessionKey(explicitTargetKey) + ? resolveSessionEntryAccessTarget({ cfg: params.cfg, sessionKey: explicitTargetKey }).entry + : undefined; + if ( + explicitTargetKey && + isAgentHarnessSessionKey(explicitTargetKey) && + !authorizedHarnessCreation && + (!existingHarnessEntry || existingHarnessEntry.modelSelectionLocked === true) + ) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE), + }; + } + const parentSessionKey = normalizeOptionalString(params.parentSessionKey); if (params.fork === true && !parentSessionKey) { return { @@ -244,6 +289,12 @@ export async function createGatewaySession(params: { ), }; } + if (isModelSelectionLocked(parent.entry)) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, MODEL_SELECTION_LOCKED_PARENT_FORK_MESSAGE), + }; + } canonicalParentSessionKey = parent.canonicalKey; parentSessionEntry = parent.entry; parentSessionTarget = resolveGatewaySessionStoreTarget({ @@ -338,6 +389,12 @@ export async function createGatewaySession(params: { }; } currentParentSessionEntry = currentParentEntry; + if (isModelSelectionLocked(currentParentEntry)) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, MODEL_SELECTION_LOCKED_PARENT_FORK_MESSAGE), + }; + } const parentHasActiveWork = isEmbeddedAgentRunActive(currentParentEntry.sessionId) || isSessionWorkAdmissionActive(parentSessionTarget.storePath, [ @@ -393,7 +450,38 @@ export async function createGatewaySession(params: { sessionKey: target.canonicalKey, storePath: target.storePath, }, - async ({ sessionEntries }) => { + async ({ existingEntry, sessionEntries }) => { + if ( + isAgentHarnessSessionKey(target.canonicalKey) && + !authorizedHarnessCreation && + (!existingEntry || existingEntry.modelSelectionLocked === true) + ) { + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE, + ), + }; + } + if (!params.initialEntry && existingEntry?.initializationPending === true) { + return { + ok: false, + error: errorShape( + ErrorCodes.UNAVAILABLE, + `Session ${target.canonicalKey} is still initializing; retry creation later.`, + ), + }; + } + if (params.initialEntry && existingEntry !== undefined) { + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + "trusted initial session state requires a new session", + ), + }; + } const patched = await applySessionsPatchToStore({ cfg: params.cfg, store: sessionEntries, @@ -405,36 +493,70 @@ export async function createGatewaySession(params: { model: normalizeOptionalString(params.model), }, loadGatewayModelCatalog: params.loadGatewayModelCatalog, + authorizedAgentHarnessId: params.authorizedAgentHarnessId, }); + if (!patched.ok) { + return patched; + } const spawnedCwd = normalizeOptionalString(params.spawnedCwd); - if (patched.ok && spawnedCwd) { + const execNode = normalizeOptionalString(params.execNode); + const initialAgentHarnessId = params.initialEntry + ? normalizeOptionalString(params.initialEntry.agentHarnessId) + : undefined; + if (params.initialEntry && !initialAgentHarnessId) { + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + "initial agentHarnessId must be non-empty", + ), + }; + } + if ( + params.initialEntry?.modelSelectionLocked !== undefined && + !params.initialEntry.modelSelectionLocked + ) { + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + "initial modelSelectionLocked must be true when provided", + ), + }; + } + const initializedEntry: SessionEntry = { + ...patched.entry, // Session worktrees adopt cwd only during admin-gated creation; public patching stays // restricted to spawned subagent and ACP lineage. - patched.entry.spawnedCwd = spawnedCwd; - sessionEntries[target.canonicalKey] = patched.entry; - } - if (patched.ok && params.worktree) { - patched.entry.worktree = params.worktree; - sessionEntries[target.canonicalKey] = patched.entry; - } - if (patched.ok && normalizeOptionalString(params.execNode)) { - patched.entry.execHost = "node"; - patched.entry.execNode = normalizeOptionalString(params.execNode); - sessionEntries[target.canonicalKey] = patched.entry; - } - if (!patched.ok || !canonicalParentSessionKey) { - return patched; + ...(spawnedCwd ? { spawnedCwd } : {}), + ...(params.worktree ? { worktree: params.worktree } : {}), + ...(execNode ? { execHost: "node", execNode } : {}), + ...(initialAgentHarnessId ? { agentHarnessId: initialAgentHarnessId } : {}), + ...(params.initialEntry?.initializationPending === true + ? { initializationPending: true } + : {}), + ...(params.initialEntry?.modelSelectionLocked === true + ? { modelSelectionLocked: true } + : {}), + ...(params.initialEntry?.pluginExtensions !== undefined + ? { pluginExtensions: structuredClone(params.initialEntry.pluginExtensions) } + : {}), + }; + sessionEntries[target.canonicalKey] = initializedEntry; + const initialized = { ...patched, entry: initializedEntry }; + if (!canonicalParentSessionKey) { + return initialized; } const inheritedSelection = normalizeOptionalString(params.model) ? {} : inheritSessionSelection(currentParentSessionEntry); const entry: SessionEntry = { - ...patched.entry, + ...initializedEntry, ...inheritedSelection, parentSessionKey: canonicalParentSessionKey, }; if (params.fork !== true) { - return { ...patched, entry }; + return { ...initialized, entry }; } if (!currentParentSessionEntry || !parentSessionTarget) { return { @@ -475,7 +597,7 @@ export async function createGatewaySession(params: { }; } return { - ...patched, + ...initialized, entry: { ...entry, sessionId: fork.sessionId, @@ -486,6 +608,12 @@ export async function createGatewaySession(params: { }, }; }, + params.initialEntry + ? { + activeSessionKey: target.canonicalKey, + requireWriteSuccess: true, + } + : undefined, ); if (!created.ok) { return { diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 894483de001b..e24da74dd66a 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -56,6 +56,11 @@ import { normalizeAgentId, parseAgentSessionKey, } from "../routing/session-key.js"; +import { resolveMissingAgentHarnessSessionError } from "../sessions/agent-harness-session-key.js"; +import { + isModelSelectionLocked, + MODEL_SELECTION_LOCKED_RESET_MESSAGE, +} from "../sessions/model-overrides.js"; import { hasOnlySessionLifecycleMutationKindActive, interruptSessionWorkAdmissions, @@ -920,6 +925,24 @@ export async function performGatewaySessionReset(params: { params.key, resetTarget.requestedAgentId ? { agentId: resetTarget.requestedAgentId } : undefined, ).entry; + const missingHarnessSessionError = resolveMissingAgentHarnessSessionError( + resetTarget.target.canonicalKey, + initialResetEntry, + ); + if (missingHarnessSessionError) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, missingHarnessSessionError), + }; + } + // Reject before interrupting admitted work or firing reset hooks. The model lock is + // session-id scoped, so rotating first would silently detach native harness ownership. + if (isModelSelectionLocked(initialResetEntry)) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, MODEL_SELECTION_LOCKED_RESET_MESSAGE), + }; + } const resetLifecycleIdentities = [ resetTarget.target.canonicalKey, params.key, @@ -979,6 +1002,12 @@ export async function performGatewaySessionReset(params: { error: errorShape(ErrorCodes.INVALID_REQUEST, archivedSessionError), }; } + if (isModelSelectionLocked(entry)) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, MODEL_SELECTION_LOCKED_RESET_MESSAGE), + }; + } const hadExistingEntry = Boolean(entry); const agentId = normalizeAgentId(target.agentId ?? resolveDefaultAgentId(cfg)); const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); diff --git a/src/gateway/session-utils.search.test.ts b/src/gateway/session-utils.search.test.ts index 7522609796a8..fb3eaa18922e 100644 --- a/src/gateway/session-utils.search.test.ts +++ b/src/gateway/session-utils.search.test.ts @@ -703,10 +703,26 @@ describe("listSessionsFromStore search", () => { }); }); - test("chat history session metadata keeps model-derived contextTokens without transcript usage", () => { + test("chat history session metadata keeps model context and projects a catalog-pinned harness", () => { withAnthropicTranscriptFixture({ prefix: "openclaw-session-info-context-", run: ({ storePath, now }) => { + const entry: SessionEntry = { + sessionId: MAIN_SESSION_ID, + updatedAt: now, + modelProvider: "local-test", + model: "test-model", + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { + supervision: { + sourceThreadId: "019f-codex-thread", + modelLocked: true, + }, + }, + }, + }; const row = buildGatewaySessionInfo({ cfg: { models: { @@ -719,20 +735,16 @@ describe("listSessionsFromStore search", () => { } as unknown as OpenClawConfig, storePath, key: MAIN_SESSION_KEY, - store: { - [MAIN_SESSION_KEY]: { - sessionId: MAIN_SESSION_ID, - updatedAt: now, - modelProvider: "local-test", - model: "test-model", - } as SessionEntry, - }, + entry, + store: { [MAIN_SESSION_KEY]: entry }, }); expect(row.totalTokens).toBeUndefined(); expect(row.totalTokensFresh).toBe(false); expect(row.estimatedCostUsd).toBeUndefined(); expect(row.contextTokens).toBe(123_456); + expect(row.modelSelectionLocked).toBe(true); + expect(row.agentRuntime).toEqual({ id: "codex", source: "session" }); }, }); }); diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 74b08063b6ef..40058b43e86b 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -693,6 +693,13 @@ describe("gateway session utils", () => { thinkingLevel: "ultra", agentHarnessId: "openclaw", } as SessionEntry); + const lockedCodex = row({ + sessionId: "locked-codex", + thinkingLevel: "ultra", + agentHarnessId: "codex", + agentRuntimeOverride: "openclaw", + modelSelectionLocked: true, + } as SessionEntry); expect(defaults.agentRuntime?.id).toBe("codex"); expect(codex.thinkingLevel).toBe("ultra"); @@ -702,6 +709,9 @@ describe("gateway session utils", () => { expect(legacyObservedOpenClaw.thinkingLevel).toBe("ultra"); expect(legacyObservedOpenClaw.agentRuntime?.id).toBe("codex"); expect(legacyObservedOpenClaw.thinkingLevels?.map((level) => level.id)).not.toContain("ultra"); + expect(lockedCodex.thinkingLevel).toBe("ultra"); + expect(lockedCodex.agentRuntime).toEqual({ id: "codex", source: "session" }); + expect(lockedCodex.thinkingLevels?.map((level) => level.id)).not.toContain("ultra"); }); test.each(["xhigh", "max"] as const)( diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index cee44f43b1d2..59ca3cecc3f6 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -1630,10 +1630,15 @@ function resolveGatewaySessionThinkingProjectionInternal( entry: params.entry, cfg: params.cfg, }); + const persistedAgentRuntimeSource: "session" | "session-key" = + params.entry?.modelSelectionLocked === true ? "session" : "session-key"; const agentRuntime = acpMeta || !persistedAgentRuntime ? configuredAgentRuntime - : { id: persistedAgentRuntime, source: "session-key" as const }; + : { + id: persistedAgentRuntime, + source: persistedAgentRuntimeSource, + }; const thinkingRuntime = acpMeta ? concretizeAgentRuntime(acpMeta.backend ?? agentRuntime.id) : resolveEffectiveAgentRuntime({ @@ -2239,6 +2244,7 @@ export function buildGatewaySessionRow(params: { ), modelProvider: rowModelProvider, model: rowModel, + modelSelectionLocked: entry?.modelSelectionLocked, agentRuntime: thinkingProjection.agentRuntime, contextTokens, contextBudgetStatus: entry?.contextBudgetStatus, diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index a161a4d6e740..6c8d93552c07 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -111,6 +111,7 @@ export type GatewaySessionRow = { effectiveResponseUsage?: "on" | "off" | "tokens" | "full"; modelProvider?: string; model?: string; + modelSelectionLocked?: boolean; agentRuntime?: GatewayAgentRuntime; contextTokens?: number; contextBudgetStatus?: SessionEntry["contextBudgetStatus"]; diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index 0cb2a4072a37..82c451aa180b 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -6,6 +6,8 @@ import type { OpenClawConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; +import { AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE } from "../sessions/agent-harness-session-key.js"; +import { MODEL_SELECTION_LOCKED_MESSAGE } from "../sessions/model-overrides.js"; import { applySessionsPatchToStore } from "./sessions-patch.js"; const acpSessionMetaMocks = vi.hoisted(() => ({ @@ -225,6 +227,48 @@ describe("gateway sessions patch", () => { resetPluginRuntimeStateForTest(); }); + test("rejects creating a missing agent harness session through patch", async () => { + const key = "agent:main:harness:codex:supervision:missing"; + const store: Record = {}; + + expectPatchError( + await runPatch({ + store, + storeKey: key, + patch: { key, label: "squat" }, + }), + AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE, + ); + expect(store[key]).toBeUndefined(); + }); + + test("allows patching an existing agent harness session", async () => { + const key = "agent:main:harness:codex:supervision:existing"; + const store: Record = { + [key]: { + sessionId: "harness-session", + updatedAt: 1, + agentHarnessId: "codex", + modelSelectionLocked: true, + }, + }; + + const entry = expectPatchOk( + await runPatch({ + store, + storeKey: key, + patch: { key, label: "kept" }, + }), + ); + expect(entry.label).toBe("kept"); + expect(store[key]).toMatchObject({ + sessionId: "harness-session", + label: "kept", + agentHarnessId: "codex", + modelSelectionLocked: true, + }); + }); + test("archives and restores sessions without retaining a pin", async () => { const archived = expectPatchOk( await runPatch({ @@ -610,6 +654,43 @@ describe("gateway sessions patch", () => { ); }); + test.each([ + { name: "change", model: ANTHROPIC_SONNET_MODEL }, + { name: "reset", model: null }, + ])("rejects locked model $name patches before catalog loading", async ({ model }) => { + const store = mainStoreEntry({ + sessionId: "sess-model-locked", + providerOverride: "openai", + modelOverride: OPENAI_GPT_ID, + modelSelectionLocked: true, + }); + const before = { ...store[MAIN_SESSION_KEY] }; + const loadGatewayModelCatalog = vi.fn(loadCatalog(ANTHROPIC_SONNET_MODEL)); + + const result = await runPatch({ + store, + cfg: createAllowlistedAnthropicModelCfg(), + patch: { key: MAIN_SESSION_KEY, model }, + loadGatewayModelCatalog, + }); + + expectPatchError(result, MODEL_SELECTION_LOCKED_MESSAGE); + expect(loadGatewayModelCatalog).not.toHaveBeenCalled(); + expect(store[MAIN_SESSION_KEY]).toEqual(before); + }); + + test("allows non-model metadata patches for model-locked sessions", async () => { + const entry = expectPatchOk( + await runPatch({ + store: mainStoreEntry({ modelSelectionLocked: true }), + patch: { key: MAIN_SESSION_KEY, label: "Remote Codex task" }, + }), + ); + + expect(entry.modelSelectionLocked).toBe(true); + expect(entry.label).toBe("Remote Codex task"); + }); + test("marks explicit model patches as pending live model switches", async () => { const store = mainStoreEntry({ sessionId: "sess-live", diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index b9fb18f1ae83..b83c7a8d9724 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -45,13 +45,21 @@ import { normalizeAgentId, parseAgentSessionKey, } from "../routing/session-key.js"; +import { + isAgentHarnessSessionKeyOwnedBy, + resolveMissingAgentHarnessSessionError, +} from "../sessions/agent-harness-session-key.js"; import { applyTraceOverride, applyVerboseOverride, parseTraceOverride, parseVerboseOverride, } from "../sessions/level-overrides.js"; -import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js"; +import { + applyModelOverrideToSessionEntry, + isModelSelectionLocked, + MODEL_SELECTION_LOCKED_MESSAGE, +} from "../sessions/model-overrides.js"; import { normalizeSendPolicy } from "../sessions/send-policy.js"; import { parseSessionLabel, SESSION_LABEL_MAX_LENGTH } from "../sessions/session-label.js"; @@ -146,8 +154,22 @@ export async function projectSessionsPatchEntry(params: { agentId?: string; patch: SessionsPatchParams; loadGatewayModelCatalog?: () => Promise; + /** Exact harness owner authorized to project its new reserved session row. */ + authorizedAgentHarnessId?: string; }): Promise<{ ok: true; entry: SessionEntry } | { ok: false; error: ErrorShape }> { const { cfg, storeKey, patch } = params; + const authorizedHarnessCreation = + params.existingEntry === undefined && + isAgentHarnessSessionKeyOwnedBy(storeKey, params.authorizedAgentHarnessId); + const harnessSessionError = authorizedHarnessCreation + ? undefined + : resolveMissingAgentHarnessSessionError(storeKey, params.existingEntry); + if (harnessSessionError) { + return invalid(harnessSessionError); + } + if ("model" in patch && isModelSelectionLocked(params.existingEntry)) { + return invalid(MODEL_SELECTION_LOCKED_MESSAGE); + } const now = Date.now(); const parsedAgent = parseAgentSessionKey(storeKey); const sessionAgentId = normalizeAgentId( @@ -726,6 +748,8 @@ export async function applySessionsPatchToStore(params: { agentId?: string; patch: SessionsPatchParams; loadGatewayModelCatalog?: () => Promise; + /** Exact harness owner authorized to project its new reserved session row. */ + authorizedAgentHarnessId?: string; }): Promise<{ ok: true; entry: SessionEntry } | { ok: false; error: ErrorShape }> { const projected = await projectSessionsPatchEntry({ cfg: params.cfg, @@ -735,6 +759,7 @@ export async function applySessionsPatchToStore(params: { agentId: params.agentId, patch: params.patch, loadGatewayModelCatalog: params.loadGatewayModelCatalog, + authorizedAgentHarnessId: params.authorizedAgentHarnessId, }); if (projected.ok) { params.store[params.storeKey] = projected.entry; diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index a9a8c9e4e1b1..0a04663ace58 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -27,6 +27,8 @@ const hookMocks = vi.hoisted(() => ({ ), })); +const sessionEntries = vi.hoisted(() => new Map>()); + let cfg: Record = {}; let lastCreateOpenClawToolsContext: Record | undefined; @@ -56,6 +58,16 @@ vi.mock("../config/sessions.js", () => ({ }, })); +vi.mock("../config/sessions/session-accessor.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveSessionEntryAccessTarget: (params: { sessionKey: string }) => ({ + entry: sessionEntries.get(params.sessionKey), + }), + }; +}); + vi.mock("./auth.js", () => ({ authorizeHttpGatewayConnect: vi.fn(async () => ({ ok: true })), })); @@ -282,6 +294,7 @@ beforeEach(() => { cfg = {}; lastCreateOpenClawToolsContext = undefined; pluginToolMetaState.clear(); + sessionEntries.clear(); pluginToolMetaState.set("plugin_doctor", { pluginId: "test-plugin", optional: true }); hookMocks.resolveToolLoopDetectionConfig.mockClear(); hookMocks.resolveToolLoopDetectionConfig.mockImplementation(() => ({ warnAt: 3 })); @@ -464,6 +477,46 @@ const setMainAllowedTools = (params: { }; describe("POST /tools/invoke", () => { + it("rejects reserved harness session contexts before tool resolution", async () => { + allowAgentsListForMain(); + const res = await invokeAgentsListAuthed({ + sessionKey: "agent:main:harness:codex:supervision:native-thread", + }); + + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + ok: false, + error: { type: "invalid_request", message: expect.stringContaining("reserved") }, + }); + expect(lastCreateOpenClawToolsContext).toBeUndefined(); + }); + + it("allows tools for an existing unlocked legacy harness-prefixed session", async () => { + allowAgentsListForMain(); + const sessionKey = "agent:main:harness:legacy-notes"; + sessionEntries.set(sessionKey, { sessionId: "legacy-session", modelSelectionLocked: false }); + + const res = await invokeAgentsListAuthed({ sessionKey }); + + expect(res.status).toBe(200); + await expectOkInvokeResponse(res); + }); + + it("rejects tools for an existing locked harness session", async () => { + allowAgentsListForMain(); + const sessionKey = "agent:main:harness:codex:supervision:native-thread"; + sessionEntries.set(sessionKey, { + sessionId: "locked-session", + agentHarnessId: "codex", + modelSelectionLocked: true, + }); + + const res = await invokeAgentsListAuthed({ sessionKey }); + + expect(res.status).toBe(400); + expect(lastCreateOpenClawToolsContext).toBeUndefined(); + }); + it("invokes a tool and returns {ok:true,result}", async () => { allowAgentsListForMain(); const res = await invokeAgentsListAuthed({ sessionKey: "main" }); @@ -1059,6 +1112,37 @@ describe("POST /tools/invoke", () => { }); describe("tools.invoke Gateway RPC", () => { + it("rejects reserved harness session contexts", async () => { + allowAgentsListForMain(); + const call = await invokeToolsRpc({ + name: "agents_list", + args: {}, + sessionKey: "agent:main:harness:codex:supervision:native-thread", + }); + + expect(call?.[0]).toBe(true); + expect(call?.[1]).toMatchObject({ + ok: false, + error: { code: "validation_error", message: expect.stringContaining("reserved") }, + }); + expect(lastCreateOpenClawToolsContext).toBeUndefined(); + }); + + it("allows existing unlocked legacy harness-prefixed sessions", async () => { + allowAgentsListForMain(); + const sessionKey = "agent:main:harness:legacy-notes"; + sessionEntries.set(sessionKey, { sessionId: "legacy-session" }); + + const call = await invokeToolsRpc({ + name: "agents_list", + args: {}, + sessionKey, + }); + + expect(call?.[1]?.ok).toBe(true); + expect(call?.[1]?.output).toBeDefined(); + }); + it("invokes a tool through the SDK-facing RPC envelope", async () => { allowAgentsListForMain(); diff --git a/src/gateway/tools-invoke-shared.ts b/src/gateway/tools-invoke-shared.ts index 3abf1138b95f..2c39932b24d6 100644 --- a/src/gateway/tools-invoke-shared.ts +++ b/src/gateway/tools-invoke-shared.ts @@ -14,11 +14,17 @@ import { type ConversationReadInvocationOrigin, } from "../channels/plugins/conversation-read-origin.js"; import { resolveMainSessionKey } from "../config/sessions.js"; +import { resolveSessionEntryAccessTarget } from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logWarn } from "../logger.js"; import { isTestDefaultMemorySlotDisabled } from "../plugins/config-state.js"; import { defaultSlotIdForKey } from "../plugins/slots.js"; import { getPluginToolMeta } from "../plugins/tools.js"; +import { + AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE, + isAgentHarnessSessionKey, + isAgentHarnessSessionStoreEntryProtected, +} from "../sessions/agent-harness-session-key.js"; import { canonicalizeSessionKeyForAgent } from "./session-store-key.js"; import { resolveGatewayScopedTools } from "./tool-resolution.js"; @@ -205,6 +211,23 @@ export async function invokeGatewayTool(params: { ? (argsRaw as Record) : {}; const sessionKey = resolveSessionKey({ cfg: params.cfg, input: params.input }); + const harnessEntry = isAgentHarnessSessionKey(sessionKey) + ? resolveSessionEntryAccessTarget({ cfg: params.cfg, sessionKey }).entry + : undefined; + if ( + isAgentHarnessSessionKey(sessionKey) && + (!harnessEntry || isAgentHarnessSessionStoreEntryProtected(sessionKey, harnessEntry)) + ) { + return { + ok: false, + status: 400, + toolName, + error: { + type: "invalid_request", + message: AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE, + }, + }; + } const resolveTools = (disablePluginTools: boolean) => resolveGatewayScopedTools({ cfg: params.cfg, diff --git a/src/mcp/codex-supervision-tools-serve.test.ts b/src/mcp/codex-supervision-tools-serve.test.ts new file mode 100644 index 000000000000..69c97a7a05e5 --- /dev/null +++ b/src/mcp/codex-supervision-tools-serve.test.ts @@ -0,0 +1,159 @@ +// Codex supervision MCP tests cover the retired Supervisor command bridge. +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AnyAgentTool } from "../agents/tools/common.js"; +import { + createCodexSupervisionToolsMcpServer, + serveCodexSupervisionToolsMcp, +} from "./codex-supervision-tools-serve.js"; + +type EnsureStandalonePluginToolRegistryLoaded = + typeof import("../plugins/tools.js").ensureStandalonePluginToolRegistryLoaded; +type ConnectToolsMcpServerToStdio = + typeof import("./tools-stdio-server.js").connectToolsMcpServerToStdio; + +const ensureStandalonePluginToolRegistryLoadedMock = vi.hoisted(() => + vi.fn(() => undefined), +); +const resolvePluginToolsMock = vi.hoisted(() => vi.fn<() => AnyAgentTool[]>(() => [])); +const connectToolsMcpServerToStdioMock = vi.hoisted(() => + vi.fn(async () => {}), +); +const disposeRegisteredAgentHarnessesMock = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock("../agents/harness/registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, disposeRegisteredAgentHarnesses: disposeRegisteredAgentHarnessesMock }; +}); + +vi.mock("../plugins/tools.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ensureStandalonePluginToolRegistryLoaded: ensureStandalonePluginToolRegistryLoadedMock, + resolvePluginTools: resolvePluginToolsMock, + }; +}); + +vi.mock("./tools-stdio-server.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, connectToolsMcpServerToStdio: connectToolsMcpServerToStdioMock }; +}); + +const TOOL_NAMES = [ + "codex_endpoint_probe", + "codex_sessions_list", + "codex_session_read", + "codex_session_send", + "codex_session_interrupt", +] as const; + +function createTools(): AnyAgentTool[] { + return TOOL_NAMES.map( + (name) => + ({ + name, + label: name, + description: name, + parameters: { type: "object", properties: {} }, + execute: vi.fn(), + }) as unknown as AnyAgentTool, + ); +} + +describe("createCodexSupervisionToolsMcpServer", () => { + beforeEach(() => { + ensureStandalonePluginToolRegistryLoadedMock.mockClear(); + resolvePluginToolsMock.mockReset(); + resolvePluginToolsMock.mockReturnValue([]); + connectToolsMcpServerToStdioMock.mockClear(); + disposeRegisteredAgentHarnessesMock.mockClear(); + }); + + it("fails closed when the external Codex plugin tools are unavailable", () => { + expect(() => + createCodexSupervisionToolsMcpServer({ + config: {}, + tools: [], + }), + ).toThrow("Install or update @openclaw/codex"); + }); + + it("lists official tools through the trusted standalone owner context", async () => { + resolvePluginToolsMock.mockReturnValue(createTools()); + const server = createCodexSupervisionToolsMcpServer({ config: {} }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client( + { name: "codex-supervision-owner-test", version: "0.0.0" }, + { capabilities: {} }, + ); + + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + try { + const listed = await client.listTools(); + expect(listed.tools.map((tool) => tool.name)).toEqual(TOOL_NAMES); + expect(ensureStandalonePluginToolRegistryLoadedMock).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ senderIsOwner: true }), + }), + ); + expect(resolvePluginToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ senderIsOwner: true }), + }), + ); + } finally { + await client.close(); + await server.close(); + } + }); + + it("preserves normalized Codex endpoint config while forcing bridge activation", () => { + resolvePluginToolsMock.mockReturnValue(createTools()); + + createCodexSupervisionToolsMcpServer({ + config: { + plugins: { + allow: [" CODEX "], + deny: ["CoDeX"], + entries: { + " CODEX ": { + config: { + appServer: { transport: "websocket", url: "ws://127.0.0.1:4500" }, + supervision: { enabled: false }, + }, + }, + }, + }, + }, + }); + + const context = ensureStandalonePluginToolRegistryLoadedMock.mock.calls[0]?.[0]?.context; + expect(context?.config?.plugins).toMatchObject({ + allow: ["codex"], + deny: [], + entries: { + codex: { + enabled: true, + config: { + appServer: { transport: "websocket", url: "ws://127.0.0.1:4500" }, + supervision: { enabled: true }, + }, + }, + }, + }); + expect(context?.config?.plugins?.entries).not.toHaveProperty(" CODEX "); + }); + + it("disposes the Codex harness when the stdio bridge shuts down", async () => { + resolvePluginToolsMock.mockReturnValue(createTools()); + + await serveCodexSupervisionToolsMcp(); + + const shutdown = connectToolsMcpServerToStdioMock.mock.calls[0]?.[1]?.onShutdown; + expect(shutdown).toBe(disposeRegisteredAgentHarnessesMock); + await shutdown?.(); + expect(disposeRegisteredAgentHarnessesMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/mcp/codex-supervision-tools-serve.ts b/src/mcp/codex-supervision-tools-serve.ts new file mode 100644 index 000000000000..447845af90cd --- /dev/null +++ b/src/mcp/codex-supervision-tools-serve.ts @@ -0,0 +1,120 @@ +/** + * Compatibility MCP server for the retired Codex Supervisor tool names. + * + * The tools are resolved from the bundled Codex plugin so MCP and agent calls + * share one implementation and one app-server client pool. + */ +import { pathToFileURL } from "node:url"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { disposeRegisteredAgentHarnesses } from "../agents/harness/registry.js"; +import type { AnyAgentTool } from "../agents/tools/common.js"; +import { getRuntimeConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { routeLogsToStderr } from "../logging/console.js"; +import { normalizePluginTargetConfig } from "../plugins/config-state.js"; +import { ensureStandalonePluginToolRegistryLoaded, resolvePluginTools } from "../plugins/tools.js"; +import { connectToolsMcpServerToStdio, createToolsMcpServer } from "./tools-stdio-server.js"; + +const LEGACY_TOOL_NAMES = [ + "codex_endpoint_probe", + "codex_sessions_list", + "codex_session_read", + "codex_session_send", + "codex_session_interrupt", +] as const; +const LEGACY_TOOL_NAME_SET = new Set(LEGACY_TOOL_NAMES); +const TRUSTED_STANDALONE_MCP_OWNER_CONTEXT = { senderIsOwner: true as const }; + +function withCodexSupervisionEnabled(config: OpenClawConfig): OpenClawConfig { + const next = structuredClone(normalizePluginTargetConfig(config, "codex")) as OpenClawConfig & + Record; + const plugins = (next.plugins ??= {}) as Record; + plugins.enabled = true; + const deny = Array.isArray(plugins.deny) + ? plugins.deny.filter((entry) => entry !== "codex") + : undefined; + if (deny) { + plugins.deny = deny; + } + if (Array.isArray(plugins.allow) && !plugins.allow.includes("codex")) { + plugins.allow = [...plugins.allow, "codex"]; + } + const entries = (plugins.entries ??= {}) as Record; + const codex = (entries.codex ??= {}) as Record; + codex.enabled = true; + const codexConfig = (codex.config ??= {}) as Record; + const supervision = (codexConfig.supervision ??= {}) as Record; + supervision.enabled = true; + if (process.env.OPENCLAW_CODEX_SUPERVISOR_ALLOW_RAW_TRANSCRIPTS === "1") { + supervision.allowRawTranscripts = true; + } + if (process.env.OPENCLAW_CODEX_SUPERVISOR_ALLOW_WRITE_CONTROLS === "1") { + supervision.allowWriteControls = true; + } + return next; +} + +function resolveCodexSupervisionTools(config: OpenClawConfig): AnyAgentTool[] { + const context = { + config, + runtimeConfig: config, + getRuntimeConfig: () => config, + // This local stdio bridge is operator-launched and intentionally receives + // the same trusted owner capability as an owner-authenticated agent turn. + ...TRUSTED_STANDALONE_MCP_OWNER_CONTEXT, + }; + const toolAllowlist = [...LEGACY_TOOL_NAMES]; + const runtimeRegistry = ensureStandalonePluginToolRegistryLoaded({ + context, + toolAllowlist, + env: process.env, + }); + return resolvePluginTools({ + context, + toolAllowlist, + suppressNameConflicts: true, + runtimeRegistry, + env: process.env, + }).filter((tool) => LEGACY_TOOL_NAME_SET.has(tool.name)); +} + +function requireCompleteCodexSupervisionToolSet(tools: readonly AnyAgentTool[]): void { + const loadedNames = new Set(tools.map((tool) => tool.name)); + const missing = LEGACY_TOOL_NAMES.filter((name) => !loadedNames.has(name)); + if ( + missing.length === 0 && + loadedNames.size === LEGACY_TOOL_NAMES.length && + tools.length === LEGACY_TOOL_NAMES.length + ) { + return; + } + throw new Error( + `Codex supervision MCP could not load the official @openclaw/codex plugin tools (missing: ${missing.join(", ") || "none"}). Install or update @openclaw/codex, then enable Codex supervision.`, + ); +} + +export function createCodexSupervisionToolsMcpServer( + params: { config?: OpenClawConfig; tools?: AnyAgentTool[] } = {}, +): Server { + const config = withCodexSupervisionEnabled(params.config ?? getRuntimeConfig()); + const tools = params.tools ?? resolveCodexSupervisionTools(config); + requireCompleteCodexSupervisionToolSet(tools); + return createToolsMcpServer({ name: "openclaw-codex-supervisor", tools }); +} + +export async function serveCodexSupervisionToolsMcp(): Promise { + routeLogsToStderr(); + const config = withCodexSupervisionEnabled(getRuntimeConfig()); + const tools = resolveCodexSupervisionTools(config); + await connectToolsMcpServerToStdio(createCodexSupervisionToolsMcpServer({ config, tools }), { + onShutdown: disposeRegisteredAgentHarnesses, + }); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + serveCodexSupervisionToolsMcp().catch((error: unknown) => { + process.stderr.write(`codex-supervisor-serve: ${formatErrorMessage(error)}\n`); + process.exit(1); + }); +} diff --git a/src/mcp/tools-stdio-server.ts b/src/mcp/tools-stdio-server.ts index af2b20b3ace0..2d00ec7320ac 100644 --- a/src/mcp/tools-stdio-server.ts +++ b/src/mcp/tools-stdio-server.ts @@ -3,6 +3,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { routeLogsToStderr } from "../logging/console.js"; import { VERSION } from "../version.js"; import { createPluginToolsMcpHandlers } from "./plugin-tools-handlers.js"; @@ -22,12 +23,19 @@ export function createToolsMcpServer(params: { name: string; tools: AnyAgentTool return server; } -export async function connectToolsMcpServerToStdio(server: Server): Promise { +export async function connectToolsMcpServerToStdio( + server: Server, + options: { onShutdown?: () => Promise | void } = {}, +): Promise { // MCP stdio requires stdout to stay protocol-only. routeLogsToStderr(); const transport = new StdioServerTransport(); let shuttingDown = false; + let resolveShutdown: (() => void) | undefined; + const shutdownComplete = new Promise((resolve) => { + resolveShutdown = resolve; + }); const shutdown = () => { if (shuttingDown) { return; @@ -37,7 +45,24 @@ export async function connectToolsMcpServerToStdio(server: Server): Promise { + let shutdownError: unknown; + try { + await server.close(); + } catch (error) { + shutdownError = error; + } + try { + await options.onShutdown?.(); + } catch (error) { + shutdownError ??= error; + } finally { + resolveShutdown?.(); + } + if (shutdownError) { + process.stderr.write(`MCP stdio shutdown failed: ${formatErrorMessage(shutdownError)}\n`); + } + })(); }; process.stdin.once("end", shutdown); @@ -46,4 +71,7 @@ export async function connectToolsMcpServerToStdio(server: Server): Promise { }); }); + it("reads config through normalized plugin entry ids", () => { + const config = { + plugins: { + entries: { + " CODEX ": { + enabled: true, + config: { supervision: { enabled: true } }, + }, + }, + }, + } as unknown as OpenClawConfig; + + expect(resolvePluginConfigObject(config, "codex")).toEqual({ + supervision: { enabled: true }, + }); + }); + it("returns undefined for missing or non-object plugin configs", () => { const config = { plugins: { diff --git a/src/plugin-sdk/config-runtime.ts b/src/plugin-sdk/config-runtime.ts index 454b215c47d3..6b1c44d4c1cb 100644 --- a/src/plugin-sdk/config-runtime.ts +++ b/src/plugin-sdk/config-runtime.ts @@ -4,24 +4,16 @@ * config-mutation, and runtime-config-snapshot. */ -import { loadSessionStore as loadSessionStoreImpl } from "../config/sessions/store-load.js"; export { getSessionEntry, listSessionEntries, + loadSessionStore, patchSessionEntry, readSessionUpdatedAt, updateSessionStoreEntry, upsertSessionEntry, } from "./session-store-runtime.js"; -/** - * @deprecated Use getSessionEntry/listSessionEntries for reads and - * patchSessionEntry/upsertSessionEntry for writes. This whole-store helper is - * kept only during the transition before SQLite migration. Callers must - * migrate away from reading sessions.json directly. - */ -export const loadSessionStore = loadSessionStoreImpl; - export { resolveDefaultAgentId } from "../agents/agent-scope.js"; export { requireRuntimeConfig, diff --git a/src/plugin-sdk/model-session-runtime.ts b/src/plugin-sdk/model-session-runtime.ts index 7fb421dce3a5..4361675c661f 100644 --- a/src/plugin-sdk/model-session-runtime.ts +++ b/src/plugin-sdk/model-session-runtime.ts @@ -3,4 +3,10 @@ */ export { resolveChannelModelOverride } from "../channels/model-overrides.js"; export { resolveAgentMaxConcurrent } from "../config/agent-limits.js"; -export { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js"; +export { resolvePersistedSessionRuntimeId } from "../agents/session-runtime-compat.js"; +export { + applyModelOverrideToSessionEntry, + isModelSelectionLocked, + MODEL_SELECTION_LOCKED_MESSAGE, + ModelSelectionLockedError, +} from "../sessions/model-overrides.js"; diff --git a/src/plugin-sdk/plugin-config-runtime.ts b/src/plugin-sdk/plugin-config-runtime.ts index 65b9f9cce4cc..eba56722bc18 100644 --- a/src/plugin-sdk/plugin-config-runtime.ts +++ b/src/plugin-sdk/plugin-config-runtime.ts @@ -1,7 +1,8 @@ // Plugin config runtime helpers load and normalize plugin-owned configuration at execution time. import type { OpenClawConfig } from "../config/types.js"; +import { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js"; -export { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js"; +export { normalizePluginsConfig, resolveEffectiveEnableState }; /** Requires an already-resolved runtime config at plugin runtime boundaries. */ export function requireRuntimeConfig(config: OpenClawConfig, context: string): OpenClawConfig { @@ -18,19 +19,7 @@ export function resolvePluginConfigObject( config: OpenClawConfig | undefined, pluginId: string, ): Record | undefined { - const plugins = - config?.plugins && typeof config.plugins === "object" && !Array.isArray(config.plugins) - ? (config.plugins as Record) - : undefined; - const entries = - plugins?.entries && typeof plugins.entries === "object" && !Array.isArray(plugins.entries) - ? (plugins.entries as Record) - : undefined; - const entry = entries?.[pluginId]; - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - return undefined; - } - const pluginConfig = (entry as { config?: unknown }).config; + const pluginConfig = normalizePluginsConfig(config?.plugins).entries[pluginId]?.config; return pluginConfig && typeof pluginConfig === "object" && !Array.isArray(pluginConfig) ? (pluginConfig as Record) : undefined; diff --git a/src/plugin-sdk/realtime-voice.ts b/src/plugin-sdk/realtime-voice.ts index 5f2e8db90d8b..432bacc4df3a 100644 --- a/src/plugin-sdk/realtime-voice.ts +++ b/src/plugin-sdk/realtime-voice.ts @@ -119,6 +119,7 @@ export { type RealtimeVoiceAgentConsultTranscriptEntry, } from "../talk/agent-consult-tool.js"; export { + assertRealtimeVoiceAgentConsultModelSelectionUnlocked, consultRealtimeVoiceAgent, type RealtimeVoiceAgentConsultResult, type RealtimeVoiceAgentConsultRuntime, diff --git a/src/plugin-sdk/session-store-runtime.ts b/src/plugin-sdk/session-store-runtime.ts index ab0926b5bf51..f41a3cf7fece 100644 --- a/src/plugin-sdk/session-store-runtime.ts +++ b/src/plugin-sdk/session-store-runtime.ts @@ -17,7 +17,10 @@ import { type SessionAccessScope, updateSessionEntry, } from "../config/sessions/session-accessor.js"; -import { loadSessionStore as loadSessionStoreImpl } from "../config/sessions/store-load.js"; +import { + loadSessionStore as loadSessionStoreImpl, + type LoadSessionStoreOptions, +} from "../config/sessions/store-load.js"; import { normalizeResolvedMaintenanceConfigInput } from "../config/sessions/store-maintenance.js"; import type { ResolvedSessionMaintenanceConfigInput } from "../config/sessions/store.js"; import type { AmbientTranscriptWatermark, SessionEntry } from "../config/sessions/types.js"; @@ -110,7 +113,14 @@ function toSessionAccessScope(params: SessionStoreReadParams): SessionAccessScop * kept only during the transition before SQLite migration. Callers must * migrate away from reading sessions.json directly. */ -export const loadSessionStore = loadSessionStoreImpl; +export function loadSessionStore( + storePath: string, + options: LoadSessionStoreOptions = {}, +): Record { + // SDK callers never receive the writer-owned cache object. Returning it lets + // a read mutate the baseline that protects locked harness session rows. + return loadSessionStoreImpl(storePath, { ...options, clone: true }); +} /** Loads one session entry by agent/session identity. */ export function getSessionEntry(params: SessionStoreReadParams): SessionEntry | undefined { @@ -228,6 +238,7 @@ export { resolveSessionKey } from "../config/sessions/session-key.js"; export { resolveGroupSessionKey } from "../config/sessions/group.js"; export { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js"; export { clearSessionStoreCacheForTest } from "../config/sessions/store.js"; +export { isValidAgentHarnessSessionStoreEntry } from "../sessions/agent-harness-session-key.js"; // SDK-facing names are a shipped plugin contract; internals route through the // session accessor so the storage backend can change beneath them. export { diff --git a/src/plugin-sdk/test-helpers/plugin-runtime-mock.test.ts b/src/plugin-sdk/test-helpers/plugin-runtime-mock.test.ts index 54665f257f76..da0d04eb654f 100644 --- a/src/plugin-sdk/test-helpers/plugin-runtime-mock.test.ts +++ b/src/plugin-sdk/test-helpers/plugin-runtime-mock.test.ts @@ -5,6 +5,38 @@ import { createPluginRuntimeMock } from "openclaw/plugin-sdk/plugin-test-runtime import { describe, expect, it, vi } from "vitest"; describe("createPluginRuntimeMock", () => { + it("clones the initializer callback input and applies its final extension patch", async () => { + const runtime = createPluginRuntimeMock(); + const pluginExtensions = { codex: { marker: "original" } }; + const afterCreate = vi.fn(async (initialized) => { + initialized.entry.pluginExtensions = { codex: { marker: "callback mutation" } }; + return { pluginExtensions: { codex: { marker: "final" } } }; + }); + + const created = await runtime.agent.session.createSessionEntry({ + cfg: {}, + key: "agent:main:dashboard:mock-created", + initialEntry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions, + }, + afterCreate, + }); + pluginExtensions.codex.marker = "input mutation"; + + expect(afterCreate).toHaveBeenCalledOnce(); + expect(afterCreate.mock.calls[0]?.[0]).not.toBe(created); + expect(afterCreate.mock.calls[0]?.[0]).toMatchObject({ + key: created.key, + agentId: created.agentId, + sessionId: created.sessionId, + entry: { initializationPending: true }, + }); + expect(created.entry.pluginExtensions).toEqual({ codex: { marker: "final" } }); + expect(created.entry.initializationPending).toBeUndefined(); + }); + it("keeps the inbound debouncer mock aligned with the runtime contract", () => { const runtime = createPluginRuntimeMock(); const debouncer = runtime.channel.debounce.createInboundDebouncer({ diff --git a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts index 54ce460f1f1d..9d52b91e3773 100644 --- a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts +++ b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts @@ -440,6 +440,50 @@ export function createPluginRuntimeMock(overrides: DeepPartial = resolveStorePath: vi.fn( () => "/tmp/agent-sessions.json", ) as unknown as PluginRuntime["agent"]["session"]["resolveStorePath"], + createSessionEntry: vi.fn( + async ( + params: Parameters[0], + ) => { + const sessionId = "plugin-runtime-mock-session"; + const key = params.key; + const initialEntry = { + sessionId, + updatedAt: Date.now(), + ...(params.label !== undefined ? { label: params.label } : {}), + ...(params.spawnedCwd !== undefined ? { spawnedCwd: params.spawnedCwd } : {}), + ...structuredClone(params.initialEntry), + ...(params.afterCreate ? { initializationPending: true as const } : {}), + }; + const initialized = { + key, + agentId: params.agentId ?? "main", + sessionId, + entry: initialEntry, + }; + const finalPatch = await params.afterCreate?.(structuredClone(initialized)); + if (finalPatch !== undefined) { + const patchKeys = Object.keys(finalPatch); + if (patchKeys.length !== 1 || patchKeys[0] !== "pluginExtensions") { + throw new Error("session creation final patch may only contain pluginExtensions"); + } + } + return { + ...initialized, + entry: + params.afterCreate === undefined + ? initialEntry + : { + ...initialEntry, + ...(finalPatch === undefined + ? {} + : { + pluginExtensions: structuredClone(finalPatch.pluginExtensions), + }), + initializationPending: undefined, + }, + }; + }, + ) as PluginRuntime["agent"]["session"]["createSessionEntry"], loadSessionStore: vi.fn( () => ({}), ) as unknown as PluginRuntime["agent"]["session"]["loadSessionStore"], diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index 950f6d14603e..29a822cddb82 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -36,7 +36,6 @@ const EXPECTED_BUNDLED_STARTUP_PLUGIN_IDS = [ "bonjour", "browser", "canvas", - "codex-supervisor", "device-pair", "diagnostics-otel", "diagnostics-prometheus", @@ -579,10 +578,8 @@ describe("bundled plugin metadata", () => { expect(entry?.manifest.activation?.onCommands).toStrictEqual(["voicecall"]); }); - it("scopes Codex Supervisor CLI activation to the codex command", () => { - const entry = listRepoBundledPluginManifests().find( - ({ manifest }) => manifest.id === "codex-supervisor", - ); + it("scopes Codex CLI activation to the codex command", () => { + const entry = listRepoBundledPluginManifests().find(({ manifest }) => manifest.id === "codex"); expect(entry?.manifest.activation?.onCommands).toStrictEqual(["codex"]); }); diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index 04fb98caedf3..5f84c1d96b5d 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -395,6 +395,18 @@ function createManifestRegistryFixture(): PluginManifestRegistry { providers: [], cliBackends: [], }, + { + id: "external-config-startup", + channels: [], + activation: { + onStartup: false, + onConfigPaths: ["plugins.entries.external-config-startup.config.autoStart"], + }, + origin: "global", + enabledByDefault: undefined, + providers: [], + cliBackends: [], + }, { id: "external-hook-capability", channels: [], @@ -1557,6 +1569,120 @@ describe("resolveGatewayStartupPluginIds", () => { }); }); + it("loads startup-lazy external plugins from config only when explicitly enabled", () => { + expectStartupPluginIdsCase({ + config: { + channels: {}, + plugins: { + slots: { memory: "none" }, + entries: { + "external-config-startup": { + enabled: true, + config: { autoStart: { enabled: true } }, + }, + }, + }, + } as OpenClawConfig, + expected: ["browser", "external-config-startup"], + }); + + expectStartupPluginIdsCase({ + config: { + channels: {}, + plugins: { + slots: { memory: "none" }, + entries: { + "external-config-startup": { + config: { autoStart: { enabled: true } }, + }, + }, + }, + } as OpenClawConfig, + expected: ["browser"], + }); + }); + + it("keeps startup-lazy external plugins behind config and activation policy", () => { + const externalEntry = { + enabled: true, + config: { autoStart: { enabled: true } }, + }; + const cases: Array<{ plugins: OpenClawConfig["plugins"]; expected: readonly string[] }> = [ + { + plugins: { + slots: { memory: "none" }, + entries: { + "external-config-startup": { + enabled: true, + config: { autoStart: { enabled: false } }, + }, + }, + }, + expected: ["browser"], + }, + { + plugins: { + slots: { memory: "none" }, + deny: ["external-config-startup"], + entries: { "external-config-startup": externalEntry }, + }, + expected: ["browser"], + }, + { + plugins: { + slots: { memory: "none" }, + allow: ["browser"], + entries: { "external-config-startup": externalEntry }, + }, + expected: ["browser"], + }, + { + plugins: { + enabled: false, + slots: { memory: "none" }, + entries: { "external-config-startup": externalEntry }, + }, + expected: [], + }, + ]; + + for (const testCase of cases) { + expectStartupPluginIdsCase({ + config: { channels: {}, plugins: testCase.plugins } as OpenClawConfig, + expected: testCase.expected, + }); + } + }); + + it("does not let effective config broaden authored external config-path activation", () => { + const activationSourceConfig = { + channels: {}, + plugins: { + allow: ["browser"], + slots: { memory: "none" }, + entries: { + "external-config-startup": { + enabled: true, + config: { autoStart: { enabled: true } }, + }, + }, + }, + } as OpenClawConfig; + const runtimeConfig = { + ...activationSourceConfig, + plugins: { + ...activationSourceConfig.plugins, + allow: ["browser", "external-config-startup"], + }, + } as OpenClawConfig; + + expectStartupPluginIdsCase({ + config: runtimeConfig, + activationSourceConfig, + expected: ["browser"], + }); + }); + it("loads explicit hook-capability plugins at startup", () => { expectStartupPluginIdsCase({ config: createStartupConfig({ diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index 2b3b972c5179..a6f689b74925 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -209,6 +209,20 @@ describe("normalizePluginsConfig", () => { expect(discoverPlugins).not.toHaveBeenCalled(); }); + it("normalizes unknown plugin ids to lowercase canonical keys", () => { + const result = normalizePluginsConfig({ + allow: [" Demo-Plugin "], + deny: [" OTHER-PLUGIN "], + entries: { + " CODEX ": { enabled: true }, + }, + }); + + expect(result.allow).toEqual(["demo-plugin"]); + expect(result.deny).toEqual(["other-plugin"]); + expect(result.entries.codex?.enabled).toBe(true); + }); + it("does not consult discovery or manifests for alias lookup", async () => { const discoverPlugins = vi.spyOn(discovery, "discoverOpenClawPlugins").mockReturnValue({ candidates: [ diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 3a4d64a22630..e6f7f8d9f2cf 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -1,3 +1,4 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; /** Normalizes plugin config and resolves effective enablement, slots, and activation sources. */ import { normalizeOptionalLowercaseString, @@ -62,7 +63,7 @@ function normalizePluginIdWithLookup( if (builtInAlias) { return builtInAlias; } - return getAliasLookup().get(normalized) ?? trimmed; + return getAliasLookup().get(normalized) ?? normalized; } function createScopedPluginIdNormalizer(): NormalizePluginId { @@ -85,6 +86,38 @@ export const normalizePluginsConfig = ( return normalizePluginsConfigWithResolver(config, createScopedPluginIdNormalizer()); }; +/** Canonicalizes one plugin entry and its policy-list ids before a targeted mutation. */ +export function normalizePluginTargetConfig( + config: OpenClawConfig, + pluginId: string, +): OpenClawConfig { + const normalizedId = normalizePluginId(pluginId); + const normalized = normalizePluginsConfig(config.plugins); + const rawEntries = config.plugins?.entries ?? {}; + const hasTargetEntry = Object.keys(rawEntries).some( + (entryId) => normalizePluginId(entryId) === normalizedId, + ); + const entries = Object.fromEntries( + Object.entries(rawEntries).filter(([entryId]) => normalizePluginId(entryId) !== normalizedId), + ); + if (hasTargetEntry) { + const { config: pluginConfig, ...entry } = normalized.entries[normalizedId] ?? {}; + entries[normalizedId] = { + ...entry, + ...(isRecord(pluginConfig) ? { config: pluginConfig } : {}), + }; + } + return { + ...config, + plugins: { + ...config.plugins, + ...(Array.isArray(config.plugins?.allow) ? { allow: normalized.allow } : {}), + ...(Array.isArray(config.plugins?.deny) ? { deny: normalized.deny } : {}), + entries, + }, + }; +} + export function createPluginActivationSource(params: { config?: OpenClawConfig; plugins?: NormalizedPluginsConfig; diff --git a/src/plugins/gateway-startup-plugin-ids.ts b/src/plugins/gateway-startup-plugin-ids.ts index c2cae62a99f0..6060b48063ff 100644 --- a/src/plugins/gateway-startup-plugin-ids.ts +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -1684,30 +1684,55 @@ function canStartConfiguredRootPlugin(params: { manifest: PluginManifestRecord | undefined; config: OpenClawConfig; pluginsConfig: ReturnType; - activationSourcePlugins: ReturnType; + activationSource: { + plugins: ReturnType; + rootConfig?: OpenClawConfig; + }; + platform?: NodeJS.Platform; }): boolean { - if (params.plugin.origin !== "bundled") { + if ( + !hasConfiguredActivationPath({ + manifest: params.manifest, + config: params.activationSource.rootConfig ?? params.config, + }) + ) { return false; } - if (!hasConfiguredActivationPath({ manifest: params.manifest, config: params.config })) { - return false; - } - if (!params.pluginsConfig.enabled || !params.activationSourcePlugins.enabled) { + if (!params.pluginsConfig.enabled || !params.activationSource.plugins.enabled) { return false; } if ( params.pluginsConfig.deny.includes(params.plugin.pluginId) || - params.activationSourcePlugins.deny.includes(params.plugin.pluginId) + params.activationSource.plugins.deny.includes(params.plugin.pluginId) ) { return false; } if ( params.pluginsConfig.entries[params.plugin.pluginId]?.enabled === false || - params.activationSourcePlugins.entries[params.plugin.pluginId]?.enabled === false + params.activationSource.plugins.entries[params.plugin.pluginId]?.enabled === false ) { return false; } - return true; + if (params.plugin.origin === "bundled") { + return true; + } + if ( + params.activationSource.plugins.allow.length > 0 && + !params.activationSource.plugins.allow.includes(params.plugin.pluginId) + ) { + return false; + } + const activationState = resolveEffectivePluginActivationState({ + id: params.plugin.pluginId, + origin: params.plugin.origin, + config: params.pluginsConfig, + rootConfig: params.config, + enabledByDefault: isPluginEnabledByDefaultForPlatform(params.plugin, params.platform), + activationSource: params.activationSource, + }); + // External manifests may name broad config paths. Requiring authored + // enablement prevents an installed plugin from activating on ambient config. + return activationState.enabled && activationState.explicitlyEnabled; } function hasExplicitHookPolicyConfig( @@ -2095,9 +2120,10 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { canStartConfiguredRootPlugin({ plugin, manifest, - config: activationSourceConfig, + config: params.config, pluginsConfig, - activationSourcePlugins, + activationSource, + platform: params.platform, }) ) { pluginIds.push(plugin.pluginId); diff --git a/src/plugins/host-hook-cleanup.session-store.test.ts b/src/plugins/host-hook-cleanup.session-store.test.ts index 3a329574c382..11aaf4020e78 100644 --- a/src/plugins/host-hook-cleanup.session-store.test.ts +++ b/src/plugins/host-hook-cleanup.session-store.test.ts @@ -175,4 +175,123 @@ describe("plugin host cleanup session stores", () => { expect(secondStore["agent:b:other"]?.pluginExtensions).toBeUndefined(); expect(secondStore["agent:b:other"]?.updatedAt).toBeGreaterThan(beforeUpdatedAt); }); + + it("preserves locked sessions for every harness owned by a disabled plugin", async () => { + stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-cleanup-locked-harness-"), + ); + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); + const storePath = path.join(stateDir, "sessions.json"); + const updatedAt = 100; + const registry = createEmptyPluginRegistry(); + for (const harnessId of ["fixture-harness-a", "fixture-harness-b"]) { + registry.agentHarnesses.push({ + pluginId: "fixture-plugin", + source: "test", + harness: { + id: harnessId, + label: harnessId, + supports: () => ({ supported: true }), + runAttempt: async () => { + throw new Error("unused test harness"); + }, + }, + }); + } + registry.agentHarnesses.push({ + pluginId: "other-plugin", + source: "test", + harness: { + id: "other-harness", + label: "other-harness", + supports: () => ({ supported: true }), + runAttempt: async () => { + throw new Error("unused test harness"); + }, + }, + }); + await saveSessionStore( + storePath, + { + "agent:main:harness-a:locked": { + sessionId: "locked-session-a", + updatedAt, + agentHarnessId: "fixture-harness-a", + modelSelectionLocked: true, + pluginExtensions: { + "fixture-plugin": { + supervision: { + sourceThreadId: "native-thread-a", + modelLocked: true, + }, + }, + }, + } satisfies SessionEntry, + "agent:main:harness-b:locked": { + sessionId: "locked-session-b", + updatedAt, + agentHarnessId: "fixture-harness-b", + modelSelectionLocked: true, + pluginExtensions: { + "fixture-plugin": { + supervision: { + sourceThreadId: "native-thread-b", + modelLocked: true, + }, + }, + }, + } satisfies SessionEntry, + "agent:main:other-harness:locked": { + sessionId: "other-locked-session", + updatedAt, + agentHarnessId: "other-harness", + modelSelectionLocked: true, + pluginExtensions: { + "fixture-plugin": { transient: true }, + }, + } satisfies SessionEntry, + "agent:main:ordinary": { + sessionId: "ordinary-session", + updatedAt, + pluginExtensions: { + "fixture-plugin": { transient: true }, + }, + } satisfies SessionEntry, + }, + { skipMaintenance: true }, + ); + + const result = await runPluginHostCleanup({ + cfg: { session: { store: storePath } }, + registry, + pluginId: "fixture-plugin", + reason: "disable", + sessionStorePaths: [storePath], + }); + + expect(result).toEqual({ cleanupCount: 2, failures: [] }); + const store = loadSessionStore(storePath, { skipCache: true }); + expect(store["agent:main:harness-a:locked"]).toMatchObject({ + updatedAt, + agentHarnessId: "fixture-harness-a", + modelSelectionLocked: true, + pluginExtensions: { + "fixture-plugin": { + supervision: { sourceThreadId: "native-thread-a", modelLocked: true }, + }, + }, + }); + expect(store["agent:main:harness-b:locked"]).toMatchObject({ + updatedAt, + agentHarnessId: "fixture-harness-b", + modelSelectionLocked: true, + pluginExtensions: { + "fixture-plugin": { + supervision: { sourceThreadId: "native-thread-b", modelLocked: true }, + }, + }, + }); + expect(store["agent:main:other-harness:locked"]?.pluginExtensions).toBeUndefined(); + expect(store["agent:main:ordinary"]?.pluginExtensions).toBeUndefined(); + }); }); diff --git a/src/plugins/host-hook-cleanup.ts b/src/plugins/host-hook-cleanup.ts index de45e31e34da..2f1b1e14dc5c 100644 --- a/src/plugins/host-hook-cleanup.ts +++ b/src/plugins/host-hook-cleanup.ts @@ -1,5 +1,6 @@ /** Runs plugin cleanup callbacks and clears host-side plugin session/runtime state. */ import fs from "node:fs"; +import { normalizeOptionalAgentRuntimeId } from "../agents/agent-runtime-id.js"; import { getRuntimeConfig } from "../config/config.js"; import { cleanupPluginHostSessionStore, @@ -79,6 +80,7 @@ async function clearPluginOwnedSessionStores(params: { sessionEntrySlotKeys?: ReadonlySet; storePaths?: readonly string[]; resolveStorePaths?: ResolveCleanupSessionStorePaths; + preserveLockedHarnessIds?: ReadonlySet; shouldCleanup?: () => boolean; }): Promise { if (!params.pluginId && !params.sessionKey) { @@ -96,6 +98,7 @@ async function clearPluginOwnedSessionStores(params: { pluginId: params.pluginId, sessionKey: params.sessionKey, sessionEntrySlotKeys: params.sessionEntrySlotKeys, + preserveLockedHarnessIds: params.preserveLockedHarnessIds, shouldCleanup: params.shouldCleanup, }); } @@ -153,6 +156,23 @@ function collectSessionEntrySlotKeys( return slotKeys; } +function collectAgentHarnessIds( + registry: PluginRegistry | null | undefined, + pluginId?: string, +): Set { + const harnessIds = new Set(); + for (const registration of registry?.agentHarnesses ?? []) { + if (!shouldCleanPlugin(registration.pluginId, pluginId)) { + continue; + } + const harnessId = normalizeOptionalAgentRuntimeId(registration.harness.id); + if (harnessId) { + harnessIds.add(harnessId); + } + } + return harnessIds; +} + /** Runs persistent and in-memory cleanup for a plugin, session, or host lifecycle event. */ /** Runs cleanup callbacks for one plugin and returns failures instead of throwing. */ export async function runPluginHostCleanup(params: { @@ -176,10 +196,12 @@ export async function runPluginHostCleanup(params: { return { cleanupCount: 0, failures }; } const registry = params.registry; - const sessionEntrySlotKeys = collectSessionEntrySlotKeys( - registry ?? getActivePluginRegistry(), - params.pluginId, - ); + const cleanupRegistry = registry ?? getActivePluginRegistry(); + const sessionEntrySlotKeys = collectSessionEntrySlotKeys(cleanupRegistry, params.pluginId); + const preserveLockedHarnessIds = + params.reason === "disable" + ? collectAgentHarnessIds(cleanupRegistry, params.pluginId) + : undefined; const restartPromotedSessionEntrySlotKeys = params.restartPromotedSessionEntrySlotKeys ?? sessionEntrySlotKeys; let persistentCleanupCount = 0; @@ -203,6 +225,7 @@ export async function runPluginHostCleanup(params: { sessionEntrySlotKeys, storePaths: params.sessionStorePaths, resolveStorePaths: params.resolveSessionStorePaths, + preserveLockedHarnessIds, shouldCleanup, }); } catch (error) { diff --git a/src/plugins/registry.runtime-config.test.ts b/src/plugins/registry.runtime-config.test.ts index 971ccf3bbb34..7e7412b67a6c 100644 --- a/src/plugins/registry.runtime-config.test.ts +++ b/src/plugins/registry.runtime-config.test.ts @@ -199,4 +199,517 @@ describe("plugin registry runtime config scope", () => { pluginSource: "/plugins/google-meet/index.js", }); }); + + it("limits harness session creation to the registering plugin", async () => { + const runtime = createPluginRuntime(); + let createScope = getPluginRuntimeGatewayRequestScope(); + const createSessionEntry: PluginRuntime["agent"]["session"]["createSessionEntry"] = vi.fn( + async (params) => { + createScope = getPluginRuntimeGatewayRequestScope(); + const entry = { + sessionId: "session-1", + updatedAt: 1, + agentHarnessId: params.initialEntry.agentHarnessId, + }; + return { + key: params.key, + agentId: "main", + sessionId: entry.sessionId, + entry, + }; + }, + ); + runtime.agent.session.createSessionEntry = createSessionEntry; + const pluginRegistry = createTestRegistry(runtime); + const ownerRecord = createPluginRecord({ + id: "codex-owner", + source: "/plugins/codex-owner/index.js", + origin: "bundled", + enabled: true, + configSchema: false, + }); + const otherRecord = createPluginRecord({ + id: "other-plugin", + source: "/plugins/other-plugin/index.js", + origin: "bundled", + enabled: true, + configSchema: false, + }); + const ownerApi = pluginRegistry.createApi(ownerRecord, { config: {} as OpenClawConfig }); + const otherApi = pluginRegistry.createApi(otherRecord, { config: {} as OpenClawConfig }); + ownerApi.registerAgentHarness({ + id: "codex", + label: "Codex", + supports: () => ({ supported: true }), + runAttempt: async () => { + throw new Error("unused"); + }, + }); + const createParams = { + cfg: {}, + key: "agent:main:harness:codex:thread-1", + initialEntry: { agentHarnessId: "codex" }, + }; + + await expect(ownerApi.runtime.agent.session.createSessionEntry(createParams)).resolves.toEqual( + expect.objectContaining({ sessionId: "session-1" }), + ); + expect(createScope).toMatchObject({ + pluginId: "codex-owner", + pluginSource: "/plugins/codex-owner/index.js", + }); + await expect(otherApi.runtime.agent.session.createSessionEntry(createParams)).rejects.toThrow( + 'Agent harness "codex" is owned by plugin "codex-owner", not "other-plugin".', + ); + await expect( + otherApi.runtime.agent.session.createSessionEntry({ + cfg: {}, + key: "agent:main:ordinary", + initialEntry: { agentHarnessId: "codex", modelSelectionLocked: true }, + }), + ).rejects.toThrow( + 'Agent harness "codex" is owned by plugin "codex-owner", not "other-plugin".', + ); + await expect( + ownerApi.runtime.agent.session.createSessionEntry({ + cfg: {}, + key: "agent:main:ordinary", + initialEntry: { agentHarnessId: "codex", modelSelectionLocked: true }, + }), + ).resolves.toEqual(expect.objectContaining({ sessionId: "session-1" })); + expect(createSessionEntry).toHaveBeenCalledTimes(2); + }); + + it("limits locked harness session mutation and execution to the harness owner", async () => { + const reservedKey = "agent:main:harness:codex:thread-1"; + const ordinaryKey = "agent:main:ordinary"; + const lockedOrdinaryKey = "agent:main:ordinary-locked"; + const legacyPrefixedKey = "agent:main:harness:notes"; + const reservedEntry = { + sessionId: "reserved-session", + sessionFile: "/tmp/reserved.jsonl", + updatedAt: 1, + agentHarnessId: "codex", + modelSelectionLocked: true as const, + }; + const ordinaryEntry = { sessionId: "ordinary-session", updatedAt: 1 }; + const lockedOrdinaryEntry = { + sessionId: "locked-ordinary-session", + updatedAt: 1, + agentHarnessId: "codex", + modelSelectionLocked: true as const, + }; + const legacyPrefixedEntry = { + sessionId: "legacy-prefixed-session", + updatedAt: 1, + agentHarnessId: "legacy-runtime", + }; + const entries = { + [reservedKey]: reservedEntry, + [ordinaryKey]: ordinaryEntry, + [lockedOrdinaryKey]: lockedOrdinaryEntry, + [legacyPrefixedKey]: legacyPrefixedEntry, + }; + const subagent = { + run: vi.fn(async () => ({ runId: "subagent-run" })), + waitForRun: vi.fn(async () => ({ status: "ok" as const })), + getSessionMessages: vi.fn(async () => ({ messages: [] })), + getSession: vi.fn(async () => ({ messages: [] })), + deleteSession: vi.fn(async () => {}), + } satisfies PluginRuntime["subagent"]; + const runtime = createPluginRuntime({ subagent }); + const session = runtime.agent.session; + const loadSessionStore = vi.fn(() => structuredClone(entries)); + session.loadSessionStore = loadSessionStore; + session.getSessionEntry = vi.fn((params) => entries[params.sessionKey as keyof typeof entries]); + session.listSessionEntries = vi.fn(() => + Object.entries(entries).map(([sessionKey, entry]) => ({ sessionKey, entry })), + ); + session.patchSessionEntry = vi.fn(async (params) => { + const entry = entries[params.sessionKey as keyof typeof entries]; + if (!entry) { + return null; + } + const patch = await params.update(structuredClone(entry), { + existingEntry: structuredClone(entry), + }); + return patch ? { ...entry, ...patch } : entry; + }); + session.upsertSessionEntry = vi.fn(async () => {}); + session.updateSessionStoreEntry = vi.fn( + async (params) => entries[params.sessionKey as keyof typeof entries], + ); + let admissionScope = getPluginRuntimeGatewayRequestScope(); + session.runWithWorkAdmission = vi.fn(async (_params, run) => { + admissionScope = getPluginRuntimeGatewayRequestScope(); + return await run(new AbortController().signal); + }); + session.saveSessionStore = vi.fn(async () => {}); + session.updateSessionStore = vi.fn( + async (_storePath, mutator) => + await mutator({ + ...structuredClone(entries), + }), + ) as typeof session.updateSessionStore; + let embeddedRunScope = getPluginRuntimeGatewayRequestScope(); + const runEmbeddedAgent = vi.fn(async () => { + embeddedRunScope = getPluginRuntimeGatewayRequestScope(); + return { ok: true }; + }) as unknown as PluginRuntime["agent"]["runEmbeddedAgent"]; + Object.defineProperties(runtime.agent, { + runEmbeddedAgent: { configurable: true, value: runEmbeddedAgent }, + runEmbeddedPiAgent: { configurable: true, value: runEmbeddedAgent }, + }); + const gatewayRequest = vi.fn(async () => ({ ok: true })); + runtime.gateway = { + isAvailable: vi.fn(async () => true), + request: gatewayRequest as unknown as PluginRuntime["gateway"]["request"], + }; + + const pluginRegistry = createTestRegistry(runtime); + const ownerRecord = createPluginRecord({ + id: "codex-owner", + source: "/plugins/codex-owner/index.js", + origin: "bundled", + enabled: true, + configSchema: false, + }); + const otherRecord = createPluginRecord({ + id: "other-plugin", + source: "/plugins/other-plugin/index.js", + origin: "bundled", + enabled: true, + configSchema: false, + }); + const voiceRecord = createPluginRecord({ + id: "voice-call", + source: "/plugins/voice-call/index.js", + origin: "bundled", + enabled: true, + configSchema: false, + }); + const ownerApi = pluginRegistry.createApi(ownerRecord, { config: {} as OpenClawConfig }); + const otherApi = pluginRegistry.createApi(otherRecord, { config: {} as OpenClawConfig }); + const voiceApi = pluginRegistry.createApi(voiceRecord, { config: {} as OpenClawConfig }); + ownerApi.registerAgentHarness({ + id: "codex", + label: "Codex", + delegatedExecutionPluginIds: ["voice-call"], + supports: () => ({ supported: true }), + runAttempt: async () => { + throw new Error("unused"); + }, + }); + const runParams = { + sessionId: reservedEntry.sessionId, + sessionKey: reservedKey, + workspaceDir: "/tmp", + prompt: "continue", + timeoutMs: 1, + runId: "run-1", + } as Parameters[0]; + const delegatedRunParams = { + ...runParams, + agentId: "main", + agentHarnessId: "codex", + agentHarnessRuntimeOverride: "codex", + modelSelectionLocked: true, + sessionTarget: { + agentId: "main", + sessionId: reservedEntry.sessionId, + sessionKey: reservedKey, + storePath: "/tmp/sessions.json", + }, + }; + + await expect( + ownerApi.runtime.agent.session.patchSessionEntry({ + sessionKey: reservedKey, + update: () => ({ archivedAt: undefined }), + }), + ).resolves.toMatchObject(reservedEntry); + await expect(ownerApi.runtime.agent.runEmbeddedAgent(runParams)).resolves.toEqual({ ok: true }); + await expect( + ownerApi.runtime.gateway.request("agent", { + sessionKey: reservedKey, + message: "continue", + }), + ).resolves.toEqual({ ok: true }); + + let delegatedCallbackScope = getPluginRuntimeGatewayRequestScope(); + await expect( + voiceApi.runtime.agent.session.runWithWorkAdmission( + { storePath: "/tmp/sessions.json", sessionKey: reservedKey }, + async () => { + delegatedCallbackScope = getPluginRuntimeGatewayRequestScope(); + return "admitted"; + }, + ), + ).resolves.toBe("admitted"); + expect(admissionScope).toMatchObject({ pluginId: "codex-owner" }); + expect(delegatedCallbackScope).toMatchObject({ pluginId: "voice-call" }); + await expect(voiceApi.runtime.agent.runEmbeddedAgent(delegatedRunParams)).resolves.toEqual({ + ok: true, + }); + expect(embeddedRunScope).toMatchObject({ pluginId: "codex-owner" }); + await expect( + voiceApi.runtime.agent.runEmbeddedAgent({ + ...delegatedRunParams, + agentHarnessRuntimeOverride: "openclaw", + }), + ).rejects.toThrow("only with its exact persisted identity and harness"); + await expect( + voiceApi.runtime.agent.session.patchSessionEntry({ + sessionKey: reservedKey, + update: () => ({ label: "must stay owner-only" }), + }), + ).rejects.toThrow('owned by plugin "codex-owner"'); + + await expect( + otherApi.runtime.agent.session.patchSessionEntry({ + sessionKey: reservedKey, + update: () => ({ archivedAt: undefined }), + }), + ).rejects.toThrow('owned by plugin "codex-owner"'); + await expect(otherApi.runtime.agent.runEmbeddedAgent(runParams)).rejects.toThrow( + 'owned by plugin "codex-owner"', + ); + await expect( + otherApi.runtime.agent.runEmbeddedAgent({ + ...runParams, + sessionKey: undefined, + }), + ).rejects.toThrow('owned by plugin "codex-owner"'); + await expect( + otherApi.runtime.agent.runEmbeddedAgent({ + ...runParams, + sessionId: ordinaryEntry.sessionId, + sessionKey: ordinaryKey, + sessionFile: reservedEntry.sessionFile, + }), + ).rejects.toThrow('owned by plugin "codex-owner"'); + await expect( + otherApi.runtime.agent.runEmbeddedAgent({ + ...runParams, + agentId: "main", + sessionId: ordinaryEntry.sessionId, + sessionKey: ordinaryKey, + sessionFile: reservedEntry.sessionFile, + sessionTarget: { + agentId: "main", + sessionId: ordinaryEntry.sessionId, + sessionKey: ordinaryKey, + storePath: "/tmp/unrelated-sessions.json", + }, + }), + ).rejects.toThrow("only with its exact session target identity"); + await expect( + otherApi.runtime.subagent.run({ sessionKey: reservedKey, message: "continue" }), + ).rejects.toThrow('owned by plugin "codex-owner"'); + await expect( + otherApi.runtime.subagent.deleteSession({ sessionKey: reservedKey }), + ).rejects.toThrow('owned by plugin "codex-owner"'); + await expect( + otherApi.runtime.gateway.request("sessions.patch", { + key: reservedKey, + archived: true, + }), + ).rejects.toThrow('owned by plugin "codex-owner"'); + await expect( + otherApi.runtime.gateway.request("agent", { + sessionId: reservedEntry.sessionId, + message: "continue", + }), + ).rejects.toThrow('owned by plugin "codex-owner"'); + await expect( + otherApi.runtime.agent.session.patchSessionEntry({ + sessionKey: lockedOrdinaryKey, + update: () => ({ archivedAt: undefined }), + }), + ).rejects.toThrow('owned by plugin "codex-owner"'); + await expect( + otherApi.runtime.agent.runEmbeddedAgent({ + ...runParams, + sessionId: lockedOrdinaryEntry.sessionId, + sessionKey: lockedOrdinaryKey, + }), + ).rejects.toThrow('owned by plugin "codex-owner"'); + await expect( + otherApi.runtime.gateway.request("agent", { + sessionKey: lockedOrdinaryKey, + message: "continue", + }), + ).rejects.toThrow('owned by plugin "codex-owner"'); + + await expect( + otherApi.runtime.agent.session.patchSessionEntry({ + sessionKey: legacyPrefixedKey, + update: () => ({ label: "still ordinary" }), + }), + ).resolves.toMatchObject({ ...legacyPrefixedEntry, label: "still ordinary" }); + await expect( + otherApi.runtime.agent.session.patchSessionEntry({ + sessionKey: legacyPrefixedKey, + update: () => ({ agentHarnessId: "codex", modelSelectionLocked: true }), + }), + ).rejects.toThrow("does not match its reserved session key"); + await expect( + otherApi.runtime.agent.session.upsertSessionEntry({ + sessionKey: legacyPrefixedKey, + entry: { ...legacyPrefixedEntry, label: "still ordinary" }, + }), + ).resolves.toBeUndefined(); + await expect( + otherApi.runtime.agent.session.upsertSessionEntry({ + sessionKey: legacyPrefixedKey, + entry: { + ...legacyPrefixedEntry, + agentHarnessId: "codex", + modelSelectionLocked: true, + }, + }), + ).rejects.toThrow("does not match its reserved session key"); + await expect( + otherApi.runtime.agent.session.runWithWorkAdmission( + { storePath: "/tmp/sessions.json", sessionKey: legacyPrefixedKey }, + async () => "admitted", + ), + ).resolves.toBe("admitted"); + const ownershipChangedRun = vi.fn(async () => "must-not-run"); + vi.mocked(session.getSessionEntry) + .mockImplementationOnce(() => legacyPrefixedEntry) + .mockImplementationOnce(() => reservedEntry); + await expect( + otherApi.runtime.agent.session.runWithWorkAdmission( + { storePath: "/tmp/sessions.json", sessionKey: legacyPrefixedKey }, + ownershipChangedRun, + ), + ).rejects.toThrow("does not match its reserved session key"); + expect(ownershipChangedRun).not.toHaveBeenCalled(); + await expect( + otherApi.runtime.agent.session.updateSessionStoreEntry({ + storePath: "/tmp/sessions.json", + sessionKey: legacyPrefixedKey, + update: () => ({ label: "still ordinary" }), + }), + ).resolves.toEqual(legacyPrefixedEntry); + await expect( + otherApi.runtime.agent.runEmbeddedAgent({ + ...runParams, + sessionId: legacyPrefixedEntry.sessionId, + sessionKey: legacyPrefixedKey, + }), + ).resolves.toEqual({ ok: true }); + await expect( + otherApi.runtime.subagent.deleteSession({ sessionKey: legacyPrefixedKey }), + ).resolves.toBeUndefined(); + await expect( + otherApi.runtime.gateway.request("sessions.patch", { + key: legacyPrefixedKey, + archived: true, + }), + ).resolves.toEqual({ ok: true }); + + await expect( + otherApi.runtime.agent.session.updateSessionStore("/tmp/sessions.json", (store) => { + store[ordinaryKey] = { ...ordinaryEntry, label: "allowed" }; + }), + ).resolves.toBeUndefined(); + await expect( + otherApi.runtime.agent.session.updateSessionStore("/tmp/sessions.json", (store) => { + store[reservedKey] = { ...reservedEntry, label: "blocked" }; + }), + ).rejects.toThrow('owned by plugin "codex-owner"'); + await expect( + otherApi.runtime.agent.session.updateSessionStore("/tmp/sessions.json", (store) => { + store[lockedOrdinaryKey] = { ...lockedOrdinaryEntry, label: "blocked" }; + }), + ).rejects.toThrow('owned by plugin "codex-owner"'); + await expect( + otherApi.runtime.agent.session.updateSessionStore("/tmp/sessions.json", (store) => { + store[legacyPrefixedKey] = { ...legacyPrefixedEntry, label: "allowed" }; + }), + ).resolves.toBeUndefined(); + await expect( + otherApi.runtime.agent.session.updateSessionStore("/tmp/sessions.json", (store) => { + store["agent:main:harness:codex:new"] = { + sessionId: "new-prefixed-session", + updatedAt: 1, + }; + }), + ).rejects.toThrow('owned by plugin "codex-owner"'); + await expect( + otherApi.runtime.agent.session.saveSessionStore("/tmp/sessions.json", { + [reservedKey]: reservedEntry, + }), + ).rejects.toThrow('owned by plugin "codex-owner"'); + expect( + otherApi.runtime.agent.session.loadSessionStore("/tmp/sessions.json", { clone: false }), + ).toEqual(entries); + expect(loadSessionStore).toHaveBeenLastCalledWith("/tmp/sessions.json", { clone: true }); + + await expect( + otherApi.runtime.agent.runEmbeddedAgent({ + ...runParams, + sessionId: ordinaryEntry.sessionId, + sessionKey: ordinaryKey, + }), + ).resolves.toEqual({ ok: true }); + await expect( + otherApi.runtime.gateway.request("voicecall.start", { to: "+15550001234" }), + ).resolves.toEqual({ ok: true }); + }); + + it("keeps grandfathered unlocked harness-prefixed rows ordinary in whole-store APIs", async () => { + const legacyKey = "agent:main:harness:notes"; + const legacyEntry = { + sessionId: "legacy-session", + updatedAt: 1, + agentHarnessId: "legacy-runtime", + }; + const runtime = createPluginRuntime(); + const session = runtime.agent.session; + session.loadSessionStore = vi.fn(() => ({ [legacyKey]: structuredClone(legacyEntry) })); + session.saveSessionStore = vi.fn(async () => {}); + session.updateSessionStore = vi.fn( + async (_storePath, mutator) => await mutator({ [legacyKey]: structuredClone(legacyEntry) }), + ) as typeof session.updateSessionStore; + const pluginRegistry = createTestRegistry(runtime); + const record = createPluginRecord({ + id: "legacy-plugin", + source: "/plugins/legacy-plugin/index.js", + origin: "global", + enabled: true, + configSchema: false, + }); + const api = pluginRegistry.createApi(record, { config: {} as OpenClawConfig }); + + await expect( + api.runtime.agent.session.saveSessionStore("/tmp/sessions.json", { + [legacyKey]: { ...legacyEntry, label: "allowed" }, + }), + ).resolves.toBeUndefined(); + await expect( + api.runtime.agent.session.updateSessionStore("/tmp/sessions.json", (store) => { + store[legacyKey] = { ...legacyEntry, label: "allowed" }; + }), + ).resolves.toBeUndefined(); + await expect( + api.runtime.agent.session.saveSessionStore("/tmp/sessions.json", { + [legacyKey]: legacyEntry, + "agent:main:harness:codex:new": { + sessionId: "new-prefixed-session", + updatedAt: 1, + }, + }), + ).rejects.toThrow("because its harness is not registered"); + await expect( + api.runtime.agent.session.updateSessionStore("/tmp/sessions.json", (store) => { + store["agent:main:harness:codex:new"] = { + sessionId: "new-prefixed-session", + updatedAt: 1, + }; + }), + ).rejects.toThrow("because its harness is not registered"); + }); }); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index e7917821663f..13732dc54241 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -9,6 +9,7 @@ import { normalizeStringEntries, normalizeUniqueStringEntries, } from "@openclaw/normalization-core/string-normalization"; +import { normalizeOptionalAgentRuntimeId } from "../agents/agent-runtime-id.js"; import { clearCodeModeNamespacesForPlugin } from "../agents/code-mode-namespaces.js"; import { getRegisteredAgentHarness, @@ -22,6 +23,7 @@ import { normalizeCommandDescriptorName, sanitizeCommandDescriptorDescription, } from "../cli/program/command-descriptor-utils.js"; +import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { clearContextEnginesForOwner, @@ -45,6 +47,10 @@ import { type PluginStateKeyedStore, type PluginStateSyncKeyedStore, } from "../plugin-state/plugin-state-store.js"; +import { + isAgentHarnessSessionKey, + isAgentHarnessSessionKeyOwnedBy, +} from "../sessions/agent-harness-session-key.js"; import { normalizePluginGatewayMethodScope } from "../shared/gateway-method-policy.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { @@ -211,6 +217,34 @@ const GATEWAY_METHOD_DISPATCH_CONTRACT = "authenticated-request"; const LEGACY_DEACTIVATE_HOOK_ALIAS_COMPAT = getPluginCompatRecord("legacy-deactivate-hook-alias"); const LEGACY_SUBAGENT_SPAWNING_HOOK_COMPAT = getPluginCompatRecord("legacy-subagent-spawning-hook"); +const PLUGIN_GATEWAY_SESSION_MUTATION_METHODS = new Set([ + "agent", + "chat.abort", + "chat.inject", + "chat.send", + "message.action", + "plugins.sessionAction", + "send", + "sessions.abort", + "sessions.compact", + "sessions.compaction.branch", + "sessions.compaction.restore", + "sessions.create", + "sessions.delete", + "sessions.patch", + "sessions.pluginPatch", + "sessions.reset", + "sessions.send", + "sessions.steer", + "wake", +]); + +const PLUGIN_GATEWAY_GLOBAL_SESSION_MUTATION_METHODS = new Set([ + "sessions.cleanup", + "sessions.groups.delete", + "sessions.groups.rename", +]); + function formatLegacyDeactivateHookAliasDiagnostic(): string { const removeAfter = LEGACY_DEACTIVATE_HOOK_ALIAS_COMPAT.removeAfter ?? "a future breaking release"; @@ -2685,6 +2719,393 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { if (cached) { return cached; } + const resolveHarnessRegistration = (harnessId: unknown) => { + const normalizedHarnessId = normalizeOptionalAgentRuntimeId(harnessId); + return normalizedHarnessId + ? registry.agentHarnesses.find( + (entry) => normalizeOptionalAgentRuntimeId(entry.harness.id) === normalizedHarnessId, + ) + : undefined; + }; + const resolveHarnessRegistrationForSessionKey = (sessionKey: string) => + registry.agentHarnesses.find((entry) => { + const rawHarnessId = normalizeOptionalString(entry.harness.id)?.toLowerCase(); + return ( + rawHarnessId === normalizeOptionalAgentRuntimeId(rawHarnessId) && + isAgentHarnessSessionKeyOwnedBy(sessionKey, rawHarnessId) + ); + }); + const assertOwnedHarness = (harnessId: unknown, action: string): string => { + const normalizedHarnessId = normalizeOptionalAgentRuntimeId(harnessId); + if (!normalizedHarnessId) { + throw new Error( + `Plugin "${pluginId}" must provide a registered agent harness id to ${action}.`, + ); + } + const registration = resolveHarnessRegistration(normalizedHarnessId); + if (!registration) { + throw new Error( + `Plugin "${pluginId}" must register agent harness "${normalizedHarnessId}" before it can ${action}.`, + ); + } + if (registration.pluginId !== pluginId) { + throw new Error( + `Agent harness "${normalizedHarnessId}" is owned by plugin "${registration.pluginId}", not "${pluginId}".`, + ); + } + return normalizedHarnessId; + }; + const assertReservedSessionKeyOwned = (sessionKey: unknown, action: string): void => { + const normalizedSessionKey = normalizeOptionalString(sessionKey); + if (!normalizedSessionKey || !isAgentHarnessSessionKey(normalizedSessionKey)) { + return; + } + const registration = resolveHarnessRegistrationForSessionKey(normalizedSessionKey); + if (!registration) { + throw new Error( + `Plugin "${pluginId}" cannot ${action} reserved agent harness session "${normalizedSessionKey}" because its harness is not registered.`, + ); + } + if (registration.pluginId !== pluginId) { + throw new Error( + `Plugin "${pluginId}" cannot ${action} reserved agent harness session "${normalizedSessionKey}" owned by plugin "${registration.pluginId}".`, + ); + } + }; + const resolveLockedSessionHarnessRegistration = ( + sessionKey: string, + entry: SessionEntry, + action: string, + ) => { + if (entry.modelSelectionLocked !== true) { + return undefined; + } + const harnessId = normalizeOptionalAgentRuntimeId(entry.agentHarnessId); + if (!harnessId) { + throw new Error( + `Plugin "${pluginId}" must provide a registered agent harness id to ${action} locked sessions.`, + ); + } + const registration = resolveHarnessRegistration(harnessId); + if (!registration) { + throw new Error( + `Plugin "${pluginId}" must register agent harness "${harnessId}" before it can ${action} locked sessions.`, + ); + } + if ( + isAgentHarnessSessionKey(sessionKey) && + !isAgentHarnessSessionKeyOwnedBy(sessionKey, harnessId) + ) { + throw new Error( + `Locked session "${sessionKey}" belongs to agent harness "${harnessId}", which does not match its reserved session key.`, + ); + } + return { harnessId, registration }; + }; + const assertLockedSessionEntryOwned = ( + sessionKey: string, + entry: SessionEntry, + action: string, + ): void => { + const resolved = resolveLockedSessionHarnessRegistration(sessionKey, entry, action); + if (!resolved) { + return; + } + if (resolved.registration.pluginId !== pluginId) { + throw new Error( + `Agent harness "${resolved.harnessId}" is owned by plugin "${resolved.registration.pluginId}", not "${pluginId}".`, + ); + } + }; + const assertSessionEntryOwned = (params: { + action: string; + entry?: SessionEntry; + sessionKey: string; + }): void => { + if (params.entry) { + // Before harness locking shipped, plugins could create ordinary sessions + // whose user-chosen key happened to start with `harness:`. + assertLockedSessionEntryOwned(params.sessionKey, params.entry, params.action); + return; + } + assertReservedSessionKeyOwned(params.sessionKey, params.action); + }; + const assertStoredSessionEntryOwned = (params: { + action: string; + agentId?: string; + env?: NodeJS.ProcessEnv; + sessionKey: string; + storePath?: string; + }): SessionEntry | undefined => { + const entry = registryParams.runtime.agent.session.getSessionEntry({ + sessionKey: params.sessionKey, + readConsistency: "latest", + ...(params.agentId !== undefined ? { agentId: params.agentId } : {}), + ...(params.env !== undefined ? { env: params.env } : {}), + ...(params.storePath !== undefined ? { storePath: params.storePath } : {}), + }); + assertSessionEntryOwned({ action: params.action, entry, sessionKey: params.sessionKey }); + return entry; + }; + const resolveStoredSessionExecutionOwner = (params: { + action: string; + agentId?: string; + sessionKey: string; + storePath?: string; + }): string | undefined => { + const entry = registryParams.runtime.agent.session.getSessionEntry({ + sessionKey: params.sessionKey, + readConsistency: "latest", + ...(params.agentId !== undefined ? { agentId: params.agentId } : {}), + ...(params.storePath !== undefined ? { storePath: params.storePath } : {}), + }); + const locked = entry + ? resolveLockedSessionHarnessRegistration(params.sessionKey, entry, params.action) + : undefined; + if (!entry || !locked || locked.registration.pluginId === pluginId) { + assertSessionEntryOwned({ action: params.action, entry, sessionKey: params.sessionKey }); + return undefined; + } + if (!locked.registration.harness.delegatedExecutionPluginIds?.includes(pluginId)) { + assertLockedSessionEntryOwned(params.sessionKey, entry, params.action); + } + return locked.registration.pluginId; + }; + const assertSessionIdentitiesOwned = (params: { + action: string; + agentId?: unknown; + sessionFiles?: unknown[]; + sessionIds?: unknown[]; + sessionKeys?: unknown[]; + storePath?: unknown; + }): void => { + const agentId = normalizeOptionalString(params.agentId); + const storePath = normalizeOptionalString(params.storePath); + const sessionKeys = new Set(); + for (const value of params.sessionKeys ?? []) { + const sessionKey = normalizeOptionalString(value); + if (sessionKey) { + sessionKeys.add(sessionKey); + } + } + for (const sessionKey of sessionKeys) { + assertStoredSessionEntryOwned({ + action: params.action, + sessionKey, + ...(agentId ? { agentId } : {}), + ...(storePath ? { storePath } : {}), + }); + } + + const sessionIds = new Set(); + for (const value of params.sessionIds ?? []) { + const sessionId = normalizeOptionalString(value); + if (sessionId) { + sessionIds.add(sessionId); + } + } + const sessionFiles = new Set(); + for (const value of params.sessionFiles ?? []) { + const sessionFile = normalizeOptionalString(value); + if (sessionFile) { + sessionFiles.add(sessionFile); + } + } + if (sessionIds.size === 0 && sessionFiles.size === 0) { + return; + } + const entries = registryParams.runtime.agent.session.listSessionEntries({ + ...(agentId ? { agentId } : {}), + ...(storePath ? { storePath } : {}), + }); + for (const { sessionKey, entry } of entries) { + if ( + sessionIds.has(entry.sessionId) || + (entry.sessionFile ? sessionFiles.has(entry.sessionFile) : false) + ) { + assertSessionEntryOwned({ action: params.action, entry, sessionKey }); + } + } + }; + const resolveRunSessionExecutionOwner = ( + params: Parameters[0], + ): string | undefined => { + const target = params.sessionTarget; + const targetSessionKey = normalizeOptionalString(target?.sessionKey); + const directSessionKey = normalizeOptionalString(params.sessionKey); + if (targetSessionKey && directSessionKey && targetSessionKey !== directSessionKey) { + throw new Error("Delegated agent execution requires one exact session key."); + } + const sessionKey = targetSessionKey ?? directSessionKey; + const storePath = normalizeOptionalString(target?.storePath); + const agentId = normalizeOptionalString(target?.agentId ?? params.agentId); + const entry = sessionKey + ? registryParams.runtime.agent.session.getSessionEntry({ + sessionKey, + readConsistency: "latest", + ...(agentId ? { agentId } : {}), + ...(storePath ? { storePath } : {}), + }) + : undefined; + const targetSessionId = normalizeOptionalString(target?.sessionId); + const targetAgentId = normalizeOptionalString(target?.agentId); + const directSessionId = normalizeOptionalString(params.sessionId); + const directAgentId = normalizeOptionalString(params.agentId); + const sessionFile = normalizeOptionalString(params.sessionFile); + if (target) { + const targetIdentityMatches = + targetSessionKey === sessionKey && + Boolean(storePath) && + Boolean(entry) && + targetSessionId === entry?.sessionId && + directSessionId === entry?.sessionId && + targetAgentId === directAgentId && + (!sessionFile || sessionFile === entry?.sessionFile); + if (!targetIdentityMatches) { + throw new Error( + `Plugin "${pluginId}" may execute a persisted session only with its exact session target identity.`, + ); + } + } + const locked = + sessionKey && entry + ? resolveLockedSessionHarnessRegistration(sessionKey, entry, "run") + : undefined; + const ownerPluginId = locked?.registration.pluginId; + if (locked && entry && sessionKey && ownerPluginId !== pluginId) { + if (!locked.registration.harness.delegatedExecutionPluginIds?.includes(pluginId)) { + assertLockedSessionEntryOwned(sessionKey, entry, "run"); + } + const requestedHarnessId = normalizeOptionalAgentRuntimeId(params.agentHarnessId); + const requestedRuntimeOverride = normalizeOptionalAgentRuntimeId( + params.agentHarnessRuntimeOverride, + ); + const identityMatches = + Boolean(target) && + targetSessionId === entry.sessionId && + directSessionId === entry.sessionId; + const harnessMatches = + params.modelSelectionLocked === true && + requestedHarnessId === locked.harnessId && + requestedRuntimeOverride === locked.harnessId; + if (!identityMatches || !harnessMatches) { + throw new Error( + `Plugin "${pluginId}" may execute locked session "${sessionKey}" only with its exact persisted identity and harness.`, + ); + } + return ownerPluginId; + } + assertSessionIdentitiesOwned({ + action: "run", + agentId: target?.agentId ?? params.agentId, + sessionFiles: [params.sessionFile], + sessionIds: [target?.sessionId ?? params.sessionId], + sessionKeys: [target?.sessionKey ?? params.sessionKey], + storePath: target?.storePath, + }); + return undefined; + }; + const assertGatewaySessionRequestOwned = ( + method: string, + params: Record | undefined, + ): void => { + if (PLUGIN_GATEWAY_GLOBAL_SESSION_MUTATION_METHODS.has(method)) { + throw new Error(`Plugin "${pluginId}" cannot request global session mutation "${method}".`); + } + if (!PLUGIN_GATEWAY_SESSION_MUTATION_METHODS.has(method)) { + return; + } + const request = params ?? {}; + const sessionKeys = [request.sessionKey, request.key, request.parentSessionKey]; + const sessionIds = [request.sessionId]; + assertSessionIdentitiesOwned({ + action: `request gateway method "${method}" for`, + agentId: request.agentId, + sessionIds, + sessionKeys, + }); + if ( + method === "sessions.abort" && + !sessionKeys.some((value) => normalizeOptionalString(value)) && + !sessionIds.some((value) => normalizeOptionalString(value)) + ) { + throw new Error( + `Plugin "${pluginId}" must provide a session key when requesting gateway method "${method}".`, + ); + } + }; + const assertStoreEntryOwned = (params: { + action: string; + before?: SessionEntry; + entry: SessionEntry; + sessionKey: string; + }): void => { + if (params.entry.modelSelectionLocked === true) { + assertLockedSessionEntryOwned(params.sessionKey, params.entry, params.action); + return; + } + if (params.before?.modelSelectionLocked === true) { + assertLockedSessionEntryOwned(params.sessionKey, params.before, params.action); + return; + } + if (isAgentHarnessSessionKey(params.sessionKey) && !params.before) { + assertReservedSessionKeyOwned(params.sessionKey, params.action); + } + }; + const assertStoreContainsOnlyOwnedProtectedSessions = (params: { + action: string; + after: Record; + before: Record; + }): void => { + // Whole-store replacement is not atomic with this read. Refuse it when + // the current store contains any locked row owned by another plugin. + for (const [sessionKey, entry] of Object.entries(params.before)) { + if (entry.modelSelectionLocked === true) { + assertLockedSessionEntryOwned(sessionKey, entry, params.action); + } + } + for (const [sessionKey, entry] of Object.entries(params.after)) { + assertStoreEntryOwned({ + action: params.action, + before: params.before[sessionKey], + entry, + sessionKey, + }); + } + }; + const assertForeignLockedSessionsPreserved = (params: { + action: string; + after: Record; + before: Record; + }): void => { + const foreignBefore = new Map(); + for (const [sessionKey, entry] of Object.entries(params.before)) { + if (entry.modelSelectionLocked !== true) { + continue; + } + const registration = resolveHarnessRegistration(entry.agentHarnessId); + if (registration?.pluginId !== pluginId) { + foreignBefore.set(sessionKey, { entry, serialized: JSON.stringify(entry) }); + } + } + for (const [sessionKey, protectedEntry] of foreignBefore) { + const { entry, serialized } = protectedEntry; + if (JSON.stringify(params.after[sessionKey]) !== serialized) { + assertLockedSessionEntryOwned(sessionKey, entry, params.action); + } + } + for (const [sessionKey, entry] of Object.entries(params.after)) { + if (foreignBefore.get(sessionKey)?.serialized !== JSON.stringify(entry)) { + assertStoreEntryOwned({ + action: params.action, + before: params.before[sessionKey], + entry, + sessionKey, + }); + } + } + }; + let scopedAgentRuntime: PluginRuntime["agent"] | undefined; const runtime = new Proxy(registryParams.runtime, { get(target, prop, receiver) { const runWithPluginScope = (run: () => T): T => { @@ -2768,8 +3189,11 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { const gateway = getRuntimeProperty(); return { isAvailable: () => runWithPluginScope(() => gateway.isAvailable()), - request: (method, params, options) => - runWithPluginScope(() => gateway.request(method, params, options)), + request: async (method, params, options) => + await runWithPluginScope(async () => { + assertGatewaySessionRequestOwned(method, params); + return await gateway.request(method, params, options); + }), } satisfies PluginRuntime["gateway"]; } if (prop === "nodes") { @@ -2779,20 +3203,217 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { invoke: (params) => runWithPluginScope(() => nodes.invoke(params)), } satisfies PluginRuntime["nodes"]; } + if (prop === "agent") { + if (scopedAgentRuntime) { + return scopedAgentRuntime; + } + const agent: PluginRuntime["agent"] = getRuntimeProperty(); + const session = agent.session; + const saveSessionStore: typeof session.saveSessionStore = async ( + storePath, + store, + options, + ) => + await runWithPluginScope(async () => { + // Whole-store replacement cannot prove that a foreign row stayed current + // across the write lock, so keep it limited to rows this plugin owns. + const before = session.loadSessionStore(storePath, { clone: true }); + assertStoreContainsOnlyOwnedProtectedSessions({ + action: "save", + after: store, + before, + }); + await session.saveSessionStore(storePath, store, options); + }); + const updateSessionStore: typeof session.updateSessionStore = async ( + storePath, + mutator, + options, + ) => + await runWithPluginScope( + async () => + await session.updateSessionStore( + storePath, + async (store) => { + const before = structuredClone(store); + const result = await mutator(store); + assertForeignLockedSessionsPreserved({ + action: "update", + before, + after: store, + }); + return result; + }, + options, + ), + ); + const scopedSession = { + ...session, + loadSessionStore: (storePath, options) => + runWithPluginScope(() => + session.loadSessionStore(storePath, { ...options, clone: true }), + ), + createSessionEntry: async (params) => + await runWithPluginScope(async () => { + // Session ownership follows the registered harness capability, + // independently of whether the caller chooses its reserved namespace. + assertOwnedHarness(params.initialEntry.agentHarnessId, "create its sessions"); + assertReservedSessionKeyOwned(params.key, "create"); + return await session.createSessionEntry(params); + }), + patchSessionEntry: async (params) => + await runWithPluginScope(async () => { + assertStoredSessionEntryOwned({ + action: "patch", + sessionKey: params.sessionKey, + ...(params.agentId !== undefined ? { agentId: params.agentId } : {}), + ...(params.env !== undefined ? { env: params.env } : {}), + ...(params.storePath !== undefined ? { storePath: params.storePath } : {}), + }); + return await session.patchSessionEntry({ + ...params, + update: async (entry, context) => { + const patch = await params.update(entry, context); + if (!patch) { + return patch; + } + const next = params.replaceEntry + ? (patch as SessionEntry) + : ({ ...entry, ...patch } satisfies SessionEntry); + assertStoreEntryOwned({ + action: "patch", + before: context.existingEntry ?? entry, + entry: next, + sessionKey: params.sessionKey, + }); + return patch; + }, + }); + }), + upsertSessionEntry: async (params) => + await runWithPluginScope(async () => { + const before = assertStoredSessionEntryOwned({ + action: "upsert", + sessionKey: params.sessionKey, + ...(params.agentId !== undefined ? { agentId: params.agentId } : {}), + ...(params.env !== undefined ? { env: params.env } : {}), + ...(params.storePath !== undefined ? { storePath: params.storePath } : {}), + }); + assertStoreEntryOwned({ + action: "upsert", + before, + entry: params.entry, + sessionKey: params.sessionKey, + }); + await session.upsertSessionEntry(params); + }), + runWithWorkAdmission: async (params, run) => + await runWithPluginScope(async () => { + const resolveCurrentExecutionOwner = () => + resolveStoredSessionExecutionOwner({ + action: "admit work on", + sessionKey: params.sessionKey, + storePath: params.storePath, + }); + const ownerPluginId = resolveCurrentExecutionOwner(); + const admissionSession = ownerPluginId + ? resolvePluginRuntime(ownerPluginId).agent.session + : session; + return await admissionSession.runWithWorkAdmission(params, async (signal) => { + // Admission can wait behind another run that changes ownership. + // Recheck delegation inside the admitted callback before plugin work starts. + if (resolveCurrentExecutionOwner() !== ownerPluginId) { + throw new Error( + `Session "${params.sessionKey}" changed execution ownership while starting work.`, + ); + } + // The owner supplies the admission primitive, but the caller's + // callback must not inherit the owner's plugin identity. + return await runWithPluginScope(() => run(signal)); + }); + }), + saveSessionStore, + updateSessionStore, + updateSessionStoreEntry: async (params) => + await runWithPluginScope(async () => { + assertStoredSessionEntryOwned({ + action: "update", + sessionKey: params.sessionKey, + storePath: params.storePath, + }); + return await session.updateSessionStoreEntry({ + ...params, + update: async (entry) => { + const patch = await params.update(entry); + if (!patch) { + return patch; + } + assertStoreEntryOwned({ + action: "update", + before: entry, + entry: { ...entry, ...patch }, + sessionKey: params.sessionKey, + }); + return patch; + }, + }); + }), + } satisfies PluginRuntime["agent"]["session"]; + const runEmbeddedAgent: PluginRuntime["agent"]["runEmbeddedAgent"] = async (params) => + await runWithPluginScope(async () => { + const ownerPluginId = resolveRunSessionExecutionOwner(params); + return ownerPluginId + ? await resolvePluginRuntime(ownerPluginId).agent.runEmbeddedAgent(params) + : await agent.runEmbeddedAgent(params); + }); + const scopedAgent = Object.create( + Object.getPrototypeOf(agent), + Object.getOwnPropertyDescriptors(agent), + ) as PluginRuntime["agent"]; + Object.defineProperties(scopedAgent, { + runEmbeddedAgent: { + configurable: true, + enumerable: true, + value: runEmbeddedAgent, + }, + runEmbeddedPiAgent: { + configurable: true, + enumerable: true, + value: runEmbeddedAgent, + }, + session: { + configurable: true, + enumerable: true, + value: scopedSession, + }, + }); + scopedAgentRuntime = scopedAgent; + return scopedAgentRuntime; + } if (prop !== "subagent") { return getRuntimeProperty(); } const subagent = getRuntimeProperty(); return { - run: (params) => withPluginRuntimePluginIdScope(pluginId, () => subagent.run(params)), + run: async (params) => + await withPluginRuntimePluginIdScope(pluginId, async () => { + assertSessionIdentitiesOwned({ + action: "run", + sessionKeys: [params.sessionKey], + }); + return await subagent.run(params); + }), waitForRun: (params) => withPluginRuntimePluginIdScope(pluginId, () => subagent.waitForRun(params)), getSessionMessages: (params) => withPluginRuntimePluginIdScope(pluginId, () => subagent.getSessionMessages(params)), getSession: (params) => withPluginRuntimePluginIdScope(pluginId, () => subagent.getSession(params)), - deleteSession: (params) => - withPluginRuntimePluginIdScope(pluginId, () => subagent.deleteSession(params)), + deleteSession: async (params) => + await withPluginRuntimePluginIdScope(pluginId, async () => { + assertStoredSessionEntryOwned({ action: "delete", sessionKey: params.sessionKey }); + await subagent.deleteSession(params); + }), } satisfies PluginRuntime["subagent"]; }, }); diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index bf5e78f7ac4f..176a9fda2acf 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -323,6 +323,7 @@ describe("plugin runtime command execution", () => { expect(runtime.agent.runEmbeddedPiAgent).toBe(runtime.agent.runEmbeddedAgent); expectFunctionKeys(runtime.agent.session as Record, [ "loadSessionStore", + "createSessionEntry", "getSessionEntry", "listSessionEntries", "patchSessionEntry", diff --git a/src/plugins/runtime/runtime-agent.test.ts b/src/plugins/runtime/runtime-agent.test.ts index 27c8aa5de584..446874c58c80 100644 --- a/src/plugins/runtime/runtime-agent.test.ts +++ b/src/plugins/runtime/runtime-agent.test.ts @@ -2,10 +2,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createGatewaySession } from "../../gateway/session-create-service.js"; import { interruptSessionWorkAdmissions, + isSessionLifecycleMutationActive, runExclusiveSessionLifecycleMutation, } from "../../sessions/session-lifecycle-admission.js"; +import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; import { createRuntimeAgent } from "./runtime-agent.js"; function createDeferred(): { promise: Promise; resolve: () => void } { @@ -16,6 +19,756 @@ function createDeferred(): { promise: Promise; resolve: () => void } { return { promise, resolve }; } +function assertRecoveryInitializerTypeContract( + create: ReturnType["session"]["createSessionEntry"], +): void { + // @ts-expect-error Recovery must return the final trusted plugin extension patch. + void create({ + cfg: {}, + key: "type-contract-only", + recoverMatchingInitialEntry: true, + initialEntry: { agentHarnessId: "codex" }, + afterCreate: async () => {}, + }); +} +void assertRecoveryInitializerTypeContract; + +describe("plugin runtime session creation", () => { + it("creates a canonical transcript with trusted initial session state", async () => { + await withOpenClawTestState({ label: "plugin-runtime-session-create" }, async () => { + const runtime = createRuntimeAgent(); + const key = "agent:main:harness:codex:supervision:codex-native-thread"; + const initialPluginExtensions = { + codex: { + supervision: { + initializing: true, + modelLocked: true, + }, + }, + }; + const finalPluginExtensions = { + codex: { + supervision: { + nativeThreadId: "thread-native-1", + modelLocked: true, + }, + }, + }; + let callbackSessionId: string | undefined; + + const created = await runtime.session.createSessionEntry({ + cfg: {}, + key, + label: "Native Codex thread", + initialEntry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: initialPluginExtensions, + }, + afterCreate: async (initialized) => { + callbackSessionId = initialized.sessionId; + expect(initialized.entry.initializationPending).toBe(true); + return { pluginExtensions: finalPluginExtensions }; + }, + }); + initialPluginExtensions.codex.supervision.initializing = false; + finalPluginExtensions.codex.supervision.nativeThreadId = "mutated-after-create"; + + expect(callbackSessionId).toBe(created.sessionId); + expect(created.entry.initializationPending).toBeUndefined(); + expect(created).toMatchObject({ + key, + agentId: "main", + sessionId: created.entry.sessionId, + entry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + label: "Native Codex thread", + pluginExtensions: { + codex: { + supervision: { + nativeThreadId: "thread-native-1", + modelLocked: true, + }, + }, + }, + }, + }); + const stored = runtime.session.getSessionEntry({ + sessionKey: key, + readConsistency: "latest", + }); + expect(stored).toEqual(created.entry); + await expect( + runtime.session.createSessionEntry({ + cfg: {}, + key, + initialEntry: { agentHarnessId: "other" }, + }), + ).rejects.toThrow("Session key namespace is reserved for agent harness-owned sessions."); + await expect( + runtime.session.createSessionEntry({ + cfg: {}, + key, + initialEntry: { agentHarnessId: "codex" }, + }), + ).rejects.toThrow("trusted initial session state requires a new session"); + expect( + runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" }), + ).toEqual(created.entry); + expect(stored?.sessionFile).toBeTruthy(); + const [headerLine] = fs + .readFileSync(stored?.sessionFile ?? "", "utf8") + .trim() + .split("\n"); + expect(JSON.parse(headerLine ?? "{}")).toMatchObject({ + type: "session", + id: created.sessionId, + }); + }); + }); + + it("rolls back the exact created entry and transcript when initialization fails", async () => { + await withOpenClawTestState({ label: "plugin-runtime-session-create-rollback" }, async () => { + const runtime = createRuntimeAgent(); + const key = "agent:main:dashboard:codex-binding-failure"; + let sessionFile: string | undefined; + + await expect( + runtime.session.createSessionEntry({ + cfg: {}, + key, + initialEntry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + }, + afterCreate: async (created) => { + sessionFile = created.entry.sessionFile; + throw new Error("native binding failed"); + }, + }), + ).rejects.toThrow("native binding failed"); + + expect(runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" })).toBe( + undefined, + ); + expect(sessionFile).toBeTruthy(); + expect(fs.existsSync(sessionFile ?? "")).toBe(false); + const transcriptName = path.basename(sessionFile ?? ""); + expect( + fs + .readdirSync(path.dirname(sessionFile ?? "")) + .some((name) => name.startsWith(`${transcriptName}.deleted.`)), + ).toBe(true); + }); + }); + + it("rolls back an unlocked harness entry through the ordinary lifecycle path", async () => { + await withOpenClawTestState( + { label: "plugin-runtime-unlocked-session-create-rollback" }, + async () => { + const runtime = createRuntimeAgent(); + const key = "agent:main:dashboard:unlocked-binding-failure"; + let sessionFile: string | undefined; + + await expect( + runtime.session.createSessionEntry({ + cfg: {}, + key, + initialEntry: { agentHarnessId: "codex" }, + afterCreate: async (created) => { + sessionFile = created.entry.sessionFile; + throw new Error("unlocked native binding failed"); + }, + }), + ).rejects.toThrow("unlocked native binding failed"); + + expect( + runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" }), + ).toBeUndefined(); + expect(sessionFile).toBeTruthy(); + expect(fs.existsSync(sessionFile ?? "")).toBe(false); + }, + ); + }); + + it("does not run initialization when the durable initial row cannot be written", async () => { + await withOpenClawTestState( + { label: "plugin-runtime-session-create-initial-write-failure" }, + async (state) => { + const runtime = createRuntimeAgent(); + const key = "agent:main:dashboard:codex-initial-write-failure"; + fs.mkdirSync(state.sessionsDir(), { recursive: true }); + fs.mkdirSync(runtime.session.resolveStorePath(undefined, { agentId: "main" }), { + recursive: true, + }); + const beforeTranscripts = fs + .readdirSync(state.sessionsDir()) + .filter((name) => name.endsWith(".jsonl")); + let initializerRan = false; + + await expect( + runtime.session.createSessionEntry({ + cfg: {}, + key, + initialEntry: { + agentHarnessId: "codex", + pluginExtensions: { + codex: { supervision: { initializing: true } }, + }, + }, + afterCreate: async () => { + initializerRan = true; + return { pluginExtensions: {} }; + }, + }), + ).rejects.toThrow(); + + expect(initializerRan).toBe(false); + expect( + runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" }), + ).toBeUndefined(); + expect( + fs.readdirSync(state.sessionsDir()).filter((name) => name.endsWith(".jsonl")), + ).toEqual(beforeTranscripts); + }, + ); + }); + + it("rolls back the original entry and transcript when final patch persistence fails", async () => { + await withOpenClawTestState( + { label: "plugin-runtime-session-create-final-patch-rollback" }, + async () => { + const runtime = createRuntimeAgent(); + const key = "agent:main:dashboard:codex-final-patch-failure"; + let sessionFile: string | undefined; + + await expect( + runtime.session.createSessionEntry({ + cfg: {}, + key, + initialEntry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { supervision: { initializing: true } }, + }, + }, + afterCreate: async (created) => { + sessionFile = created.entry.sessionFile; + return { + pluginExtensions: { + codex: { supervision: { invalidJsonValue: 1n as never } }, + }, + }; + }, + }), + ).rejects.toThrow(); + + expect( + runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" }), + ).toBeUndefined(); + expect(sessionFile).toBeTruthy(); + expect(fs.existsSync(sessionFile ?? "")).toBe(false); + const transcriptName = path.basename(sessionFile ?? ""); + expect( + fs + .readdirSync(path.dirname(sessionFile ?? "")) + .some((name) => name.startsWith(`${transcriptName}.deleted.`)), + ).toBe(true); + }, + ); + }); + + it("rolls back an unlocked harness entry when final patch persistence fails", async () => { + await withOpenClawTestState( + { label: "plugin-runtime-unlocked-final-patch-rollback" }, + async () => { + const runtime = createRuntimeAgent(); + const key = "agent:main:dashboard:unlocked-final-patch-failure"; + let sessionFile: string | undefined; + + await expect( + runtime.session.createSessionEntry({ + cfg: {}, + key, + initialEntry: { agentHarnessId: "codex" }, + afterCreate: async (created) => { + sessionFile = created.entry.sessionFile; + return { + pluginExtensions: { + codex: { supervision: { invalidJsonValue: 1n as never } }, + }, + }; + }, + }), + ).rejects.toThrow(); + + expect( + runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" }), + ).toBeUndefined(); + expect(sessionFile).toBeTruthy(); + expect(fs.existsSync(sessionFile ?? "")).toBe(false); + }, + ); + }); + + it("fences work admission until trusted initialization completes", async () => { + await withOpenClawTestState({ label: "plugin-runtime-session-create-fence" }, async () => { + const runtime = createRuntimeAgent(); + const key = "agent:main:dashboard:codex-binding-fence"; + const callbackStarted = createDeferred(); + const releaseCallback = createDeferred(); + const storePath = runtime.session.resolveStorePath(undefined, { agentId: "main" }); + + const creation = runtime.session.createSessionEntry({ + cfg: {}, + key, + initialEntry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { supervision: { initializing: true } }, + }, + }, + afterCreate: async () => { + callbackStarted.resolve(); + await releaseCallback.promise; + return { + pluginExtensions: { + codex: { supervision: { modelLocked: true } }, + }, + }; + }, + }); + await callbackStarted.promise; + expect(isSessionLifecycleMutationActive(storePath, [key])).toBe(true); + expect( + runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" }), + ).toMatchObject({ + initializationPending: true, + pluginExtensions: { + codex: { supervision: { initializing: true } }, + }, + }); + + let workRan = false; + const work = runtime.session.runWithWorkAdmission( + { storePath, sessionKey: key }, + async () => { + workRan = true; + }, + ); + await new Promise((resolve) => { + setImmediate(resolve); + }); + expect(workRan).toBe(false); + + releaseCallback.resolve(); + const created = await creation; + await work; + expect(workRan).toBe(true); + expect(isSessionLifecycleMutationActive(storePath, [key])).toBe(false); + expect(created.entry.pluginExtensions).toEqual({ + codex: { supervision: { modelLocked: true } }, + }); + expect(created.entry.initializationPending).toBeUndefined(); + expect( + runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" }), + ).toEqual(created.entry); + }); + }); + + it("rejects an ordinary same-key create while trusted initialization is pending", async () => { + await withOpenClawTestState( + { label: "plugin-runtime-session-create-ordinary-race" }, + async () => { + const runtime = createRuntimeAgent(); + const key = "agent:main:dashboard:codex-initialization-race"; + const callbackStarted = createDeferred(); + const releaseCallback = createDeferred(); + const creation = runtime.session.createSessionEntry({ + cfg: {}, + key, + label: "Trusted initializer", + initialEntry: { + agentHarnessId: "codex", + pluginExtensions: { codex: { supervision: { initializing: true } } }, + }, + afterCreate: async () => { + callbackStarted.resolve(); + await releaseCallback.promise; + return { + pluginExtensions: { codex: { supervision: { modelLocked: true } } }, + }; + }, + }); + await callbackStarted.promise; + + const raced = await createGatewaySession({ + cfg: {}, + key, + label: "Public overwrite", + commandSource: "test", + }); + + expect(raced).toMatchObject({ + ok: false, + error: { message: expect.stringContaining("is still initializing") }, + }); + expect( + runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" }), + ).toMatchObject({ + initializationPending: true, + label: "Trusted initializer", + pluginExtensions: { codex: { supervision: { initializing: true } } }, + }); + + releaseCallback.resolve(); + const created = await creation; + expect(created.entry).not.toHaveProperty("initializationPending"); + expect(created.entry).toMatchObject({ + label: "Trusted initializer", + pluginExtensions: { codex: { supervision: { modelLocked: true } } }, + }); + }, + ); + }); + + it("rejects creation while pre-existing session work is admitted", async () => { + await withOpenClawTestState({ label: "plugin-runtime-session-create-active" }, async () => { + const runtime = createRuntimeAgent(); + const key = "agent:main:dashboard:codex-binding-active"; + const workStarted = createDeferred(); + const releaseWork = createDeferred(); + const storePath = runtime.session.resolveStorePath(undefined, { agentId: "main" }); + const work = runtime.session.runWithWorkAdmission( + { storePath, sessionKey: key }, + async () => { + workStarted.resolve(); + await releaseWork.promise; + }, + ); + await workStarted.promise; + + await expect( + runtime.session.createSessionEntry({ + cfg: {}, + key, + initialEntry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + }, + }), + ).rejects.toThrow(`Session "${key}" is still active; retry creation later.`); + expect(runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" })).toBe( + undefined, + ); + + releaseWork.resolve(); + await work; + }); + }); + + it("recovers an exact persisted initializer and returns its finalized generation", async () => { + await withOpenClawTestState( + { label: "plugin-runtime-session-create-recovery" }, + async (state) => { + const runtime = createRuntimeAgent(); + const key = "agent:main:dashboard:codex-recovery"; + const sessionId = "interrupted-initializer"; + const sessionFile = path.join(state.sessionsDir(), `${sessionId}.jsonl`); + const storePath = runtime.session.resolveStorePath(undefined, { agentId: "main" }); + const initialPluginExtensions = { + codex: { supervision: { sourceThreadId: "source-1", initializing: true } }, + }; + const persistedPluginExtensions = { + codex: { supervision: { initializing: true, sourceThreadId: "source-1" } }, + }; + fs.mkdirSync(path.dirname(sessionFile), { recursive: true }); + fs.writeFileSync( + sessionFile, + `${JSON.stringify({ type: "session", version: 3, id: sessionId })}\n`, + ); + await runtime.session.upsertSessionEntry({ + storePath, + sessionKey: key, + entry: { + sessionId, + sessionFile, + updatedAt: Date.now(), + initializationPending: true, + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: persistedPluginExtensions, + spawnedCwd: "/workspace/project", + }, + }); + + const recovered = await runtime.session.createSessionEntry({ + cfg: {}, + key, + spawnedCwd: "/workspace/project", + recoverMatchingInitialEntry: true, + initialEntry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: initialPluginExtensions, + }, + afterCreate: async (created) => { + expect(created.sessionId).toBe(sessionId); + expect(created.entry.initializationPending).toBe(true); + return { + pluginExtensions: { + codex: { supervision: { sourceThreadId: "source-1", modelLocked: true } }, + }, + }; + }, + }); + + expect(recovered.sessionId).toBe(sessionId); + expect(recovered.entry.initializationPending).toBeUndefined(); + expect(recovered.entry.pluginExtensions).toEqual({ + codex: { supervision: { sourceThreadId: "source-1", modelLocked: true } }, + }); + expect( + runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" }), + ).toEqual(recovered.entry); + }, + ); + }); + + it("does not recover an initializer from a different spawned workspace", async () => { + await withOpenClawTestState( + { label: "plugin-runtime-session-create-recovery-cwd-mismatch" }, + async () => { + const runtime = createRuntimeAgent(); + const key = "agent:main:dashboard:codex-recovery-cwd-mismatch"; + const storePath = runtime.session.resolveStorePath(undefined, { agentId: "main" }); + const existing = { + sessionId: "foreign-workspace-initializer", + updatedAt: Date.now(), + initializationPending: true as const, + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { supervision: { sourceThreadId: "source-1", initializing: true } }, + }, + spawnedCwd: "/workspace/other", + }; + await runtime.session.upsertSessionEntry({ + storePath, + sessionKey: key, + entry: existing, + }); + + await expect( + runtime.session.createSessionEntry({ + cfg: {}, + key, + spawnedCwd: "/workspace/project", + recoverMatchingInitialEntry: true, + initialEntry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { supervision: { sourceThreadId: "source-1", initializing: true } }, + }, + }, + afterCreate: async () => ({ pluginExtensions: {} }), + }), + ).rejects.toThrow("does not match its trusted recovery state"); + expect( + runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" }), + ).toEqual(existing); + }, + ); + }); + + it("does not recover an initializing row with different trusted ownership", async () => { + await withOpenClawTestState( + { label: "plugin-runtime-session-create-recovery-mismatch" }, + async () => { + const runtime = createRuntimeAgent(); + const key = "agent:main:dashboard:codex-recovery-mismatch"; + const storePath = runtime.session.resolveStorePath(undefined, { agentId: "main" }); + const existing = { + sessionId: "foreign-initializer", + updatedAt: Date.now(), + initializationPending: true as const, + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { supervision: { sourceThreadId: "different-source", initializing: true } }, + }, + }; + await runtime.session.upsertSessionEntry({ + storePath, + sessionKey: key, + entry: existing, + }); + + await expect( + runtime.session.createSessionEntry({ + cfg: {}, + key, + recoverMatchingInitialEntry: true, + initialEntry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + pluginExtensions: { + codex: { supervision: { sourceThreadId: "source-1", initializing: true } }, + }, + }, + afterCreate: async () => ({ pluginExtensions: {} }), + }), + ).rejects.toThrow("does not match its trusted recovery state"); + expect( + runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" }), + ).toEqual(existing); + }, + ); + }); + + it("rejects work for a persisted initializer without an active process fence", async () => { + await withOpenClawTestState( + { label: "plugin-runtime-session-create-restart-admission" }, + async () => { + const runtime = createRuntimeAgent(); + const key = "agent:main:dashboard:codex-restart-pending"; + const storePath = runtime.session.resolveStorePath(undefined, { agentId: "main" }); + await runtime.session.upsertSessionEntry({ + storePath, + sessionKey: key, + entry: { + sessionId: "interrupted-initializer", + updatedAt: Date.now(), + initializationPending: true, + }, + }); + expect(isSessionLifecycleMutationActive(storePath, [key])).toBe(false); + let workRan = false; + + await expect( + runtime.session.runWithWorkAdmission({ storePath, sessionKey: key }, async () => { + workRan = true; + }), + ).rejects.toThrow("is still initializing"); + expect(workRan).toBe(false); + }, + ); + }); + + it("preserves a created entry claimed before finalization", async () => { + await withOpenClawTestState( + { label: "plugin-runtime-session-create-rollback-race" }, + async () => { + const runtime = createRuntimeAgent(); + const key = "agent:main:dashboard:codex-binding-race"; + let sessionId: string | undefined; + + await expect( + runtime.session.createSessionEntry({ + cfg: {}, + key, + initialEntry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + }, + afterCreate: async (created) => { + sessionId = created.sessionId; + await runtime.session.patchSessionEntry({ + sessionKey: created.key, + update: () => ({ label: "claimed concurrently" }), + }); + return { + pluginExtensions: { + codex: { supervision: { modelLocked: true } }, + }, + }; + }, + }), + ).rejects.toThrow("guarded rollback did not complete"); + + expect( + runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" }), + ).toMatchObject({ + sessionId, + label: "claimed concurrently", + agentHarnessId: "codex", + modelSelectionLocked: true, + }); + }, + ); + }); + + it("rejects an empty harness initializer without leaving a session entry", async () => { + await withOpenClawTestState({ label: "plugin-runtime-session-create-invalid" }, async () => { + const runtime = createRuntimeAgent(); + const key = "agent:main:dashboard:invalid-harness"; + + await expect( + runtime.session.createSessionEntry({ + cfg: {}, + key, + initialEntry: { agentHarnessId: " " }, + }), + ).rejects.toThrow("initial agentHarnessId must be non-empty"); + expect(runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" })).toBe( + undefined, + ); + }); + }); + + it("does not initialize over an existing placeholder entry", async () => { + await withOpenClawTestState( + { label: "plugin-runtime-session-create-placeholder" }, + async (state) => { + const runtime = createRuntimeAgent(); + const key = "agent:main:metadata"; + const updatedAt = Date.now(); + const storePath = path.join(state.sessionsDir(), "sessions.json"); + fs.mkdirSync(path.dirname(storePath), { recursive: true }); + fs.writeFileSync( + storePath, + JSON.stringify({ + [key]: { + sessionId: key, + updatedAt, + groupActivation: "always", + }, + }), + ); + expect( + runtime.session.getSessionEntry({ + sessionKey: key, + storePath, + readConsistency: "latest", + }), + ).toEqual({ updatedAt, groupActivation: "always" }); + + await expect( + runtime.session.createSessionEntry({ + cfg: {}, + key, + initialEntry: { + agentHarnessId: "codex", + modelSelectionLocked: true, + }, + }), + ).rejects.toThrow("trusted initial session state requires a new session"); + expect( + runtime.session.getSessionEntry({ + sessionKey: key, + storePath, + readConsistency: "latest", + }), + ).toEqual({ updatedAt, groupActivation: "always" }); + }, + ); + }); +}); + describe("plugin runtime session work admission", () => { let tempDir: string; let storePath: string; diff --git a/src/plugins/runtime/runtime-agent.ts b/src/plugins/runtime/runtime-agent.ts index c4edb672df36..0faeb38bf575 100644 --- a/src/plugins/runtime/runtime-agent.ts +++ b/src/plugins/runtime/runtime-agent.ts @@ -1,4 +1,5 @@ // Runtime agent helpers resolve agent-scoped directories and config for plugin execution. +import { isDeepStrictEqual } from "node:util"; import { resolveAgentDir, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; import { resolveAgentIdentity } from "../../agents/identity.js"; @@ -17,10 +18,12 @@ import { getRuntimeConfig } from "../../config/config.js"; import { resolveSessionWorkStartError } from "../../config/sessions/lifecycle.js"; import { resolveSessionFilePath, resolveStorePath } from "../../config/sessions/paths.js"; import { + deleteSessionEntryLifecycle, listSessionEntries as listAccessorSessionEntries, loadSessionEntry, patchSessionEntry as patchAccessorSessionEntry, replaceSessionEntry, + rollbackAgentHarnessSessionEntryLifecycle, type SessionAccessScope, updateSessionEntry, } from "../../config/sessions/session-accessor.js"; @@ -32,7 +35,11 @@ import { type ResolvedSessionMaintenanceConfigInput, } from "../../config/sessions/store.js"; import type { SessionEntry } from "../../config/sessions/types.js"; -import { beginSessionWorkAdmission } from "../../sessions/session-lifecycle-admission.js"; +import { + beginSessionWorkAdmission, + isSessionWorkAdmissionActive, + runExclusiveSessionLifecycleMutation, +} from "../../sessions/session-lifecycle-admission.js"; import { createLazyRuntimeMethod, createLazyRuntimeModule } from "../../shared/lazy-runtime.js"; import { defineCachedValue } from "./runtime-cache.js"; import type { PluginRuntime } from "./types.js"; @@ -164,6 +171,211 @@ async function upsertSessionEntry(params: RuntimeUpsertSessionEntryParams): Prom await replaceSessionEntry(toSessionAccessScope(params), params.entry); } +async function createSessionEntry( + params: Parameters[0], +): Promise>> { + // Session creation stays behind the canonical Gateway lifecycle boundary while + // keeping that heavier runtime out of plugin discovery and cold startup. + const [{ createGatewaySession }, { resolveGatewaySessionStoreTarget }] = await Promise.all([ + import("../../gateway/session-create-service.js"), + import("../../gateway/session-utils.js"), + ]); + type CreatedContext = Parameters< + NonNullable[0]["afterCreate"]> + >[0]; + const target = resolveGatewaySessionStoreTarget({ + cfg: params.cfg, + key: params.key, + ...(params.agentId !== undefined ? { agentId: params.agentId } : {}), + }); + const identities = new Set([target.canonicalKey, ...target.storeKeys]); + return await runExclusiveSessionLifecycleMutation({ + scope: target.storePath, + identities, + prepare: async () => { + // Activate the mutation fence before checking admission state. New work + // then queues, while pre-existing work makes creation fail without interruption. + if (isSessionWorkAdmissionActive(target.storePath, identities)) { + throw new Error(`Session "${target.canonicalKey}" is still active; retry creation later.`); + } + }, + run: async () => { + const afterCreate = params.afterCreate; + let callbackContext: CreatedContext | undefined; + let finalEntryPatch: { pluginExtensions: SessionEntry["pluginExtensions"] } | undefined; + let rollbackExpectedEntry: SessionEntry | undefined; + const runAfterCreate = async (context: CreatedContext): Promise => { + callbackContext = context; + rollbackExpectedEntry = structuredClone(context.entry); + if (!afterCreate) { + return; + } + const finalPatch = await afterCreate({ + key: context.key, + agentId: context.agentId, + sessionId: context.entry.sessionId, + entry: structuredClone(context.entry), + }); + if (finalPatch === undefined) { + return; + } + const patchKeys = Object.keys(finalPatch); + if (patchKeys.length !== 1 || patchKeys[0] !== "pluginExtensions") { + throw new Error("session creation final patch may only contain pluginExtensions"); + } + finalEntryPatch = { + pluginExtensions: structuredClone(finalPatch.pluginExtensions), + }; + }; + try { + const matchingEntry = + params.recoverMatchingInitialEntry === true + ? getSessionEntry({ + sessionKey: target.canonicalKey, + storePath: target.storePath, + readConsistency: "latest", + }) + : undefined; + let recovered = false; + let created: { key: string; agentId: string; entry: SessionEntry }; + if (matchingEntry) { + const expectedSpawnedCwd = params.spawnedCwd?.trim() || undefined; + const initialEntryMatches = + matchingEntry.initializationPending === true && + matchingEntry.agentHarnessId === params.initialEntry.agentHarnessId && + matchingEntry.modelSelectionLocked === params.initialEntry.modelSelectionLocked && + matchingEntry.spawnedCwd === expectedSpawnedCwd && + isDeepStrictEqual(matchingEntry.pluginExtensions, params.initialEntry.pluginExtensions); + if (!initialEntryMatches) { + throw new Error( + `Session "${target.canonicalKey}" does not match its trusted recovery state.`, + ); + } + if (!afterCreate) { + throw new Error("session creation recovery requires an initializer"); + } + recovered = true; + created = { + key: target.canonicalKey, + agentId: target.agentId, + entry: matchingEntry, + }; + await runAfterCreate({ + ...created, + storePath: target.storePath, + }); + } else { + const result = await createGatewaySession({ + cfg: params.cfg, + key: params.key, + ...(params.agentId !== undefined ? { agentId: params.agentId } : {}), + ...(params.label !== undefined ? { label: params.label } : {}), + ...(params.spawnedCwd !== undefined ? { spawnedCwd: params.spawnedCwd } : {}), + initialEntry: afterCreate + ? { ...params.initialEntry, initializationPending: true } + : params.initialEntry, + authorizedAgentHarnessId: params.initialEntry.agentHarnessId, + commandSource: "plugin-runtime", + ...(afterCreate ? { afterCreate: runAfterCreate } : {}), + }); + if (!result.ok) { + throw new Error(result.error.message); + } + created = result; + } + if (recovered && !finalEntryPatch) { + throw new Error("session creation recovery requires a final patch"); + } + let finalEntry = created.entry; + if (afterCreate) { + const patch: Partial = { + ...finalEntryPatch, + initializationPending: undefined, + }; + const expectedEntry = rollbackExpectedEntry; + if (!callbackContext || !expectedEntry) { + throw new Error("session creation final patch is missing its created entry"); + } + const createdContext = callbackContext; + const finalized = await patchAccessorSessionEntry( + { + sessionKey: createdContext.key, + storePath: createdContext.storePath, + }, + (currentEntry) => { + if (JSON.stringify(currentEntry) !== JSON.stringify(expectedEntry)) { + throw new Error( + `created session ${createdContext.key} changed before finalization`, + ); + } + return patch; + }, + { + preserveActivity: true, + requireWriteSuccess: true, + }, + ); + if (!finalized) { + throw new Error( + `created session ${createdContext.key} disappeared before finalization`, + ); + } + finalEntry = finalized; + // Any failure after persistence must compare rollback against the + // finalized snapshot, not the now-stale initializing entry. + rollbackExpectedEntry = structuredClone(finalized); + } + return { + key: created.key, + agentId: created.agentId, + sessionId: finalEntry.sessionId, + entry: finalEntry, + }; + } catch (error) { + if (!callbackContext) { + throw error; + } + try { + // Delete only the untouched row created for this callback. A concurrent + // claimant changes the snapshot and must survive failed initialization. + const expectedEntry = rollbackExpectedEntry ?? callbackContext.entry; + const rollbackParams = { + agentId: callbackContext.agentId, + archiveTranscript: true, + expectedEntry, + expectedSessionId: callbackContext.entry.sessionId, + expectedUpdatedAt: expectedEntry.updatedAt, + storePath: callbackContext.storePath, + target: { + canonicalKey: callbackContext.key, + storeKeys: [callbackContext.key], + }, + }; + // Locked rows require the narrow harness rollback capability. Unlocked + // initializers stay on the ordinary guarded lifecycle deletion path. + const rolledBack = + expectedEntry.modelSelectionLocked === true + ? await rollbackAgentHarnessSessionEntryLifecycle(rollbackParams) + : await deleteSessionEntryLifecycle(rollbackParams); + if (!rolledBack.deleted) { + throw new Error(`created session ${callbackContext.key} changed before rollback`, { + cause: error, + }); + } + } catch (rollbackError) { + const aggregateError = new AggregateError( + [error, rollbackError], + `Session initialization failed and guarded rollback did not complete for ${callbackContext.key}.`, + { cause: rollbackError }, + ); + throw aggregateError; + } + throw error; + } + }, + }); +} + async function runWithSessionWorkAdmission( params: { storePath: string; sessionKey: string; signal?: AbortSignal }, run: (signal: AbortSignal) => Promise, @@ -262,6 +474,7 @@ export function createRuntimeAgent(): PluginRuntime["agent"] { ); defineCachedValue(agentRuntime, "session", () => ({ resolveStorePath, + createSessionEntry, getSessionEntry, listSessionEntries, patchSessionEntry, diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index d8b1381bb29e..a0f6e324b22f 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -73,6 +73,43 @@ type RuntimeSessionStoreEntrySummary = { sessionKey: string; entry: RuntimeSessionEntry; }; +type RuntimeCreateSessionEntryResult = { + key: string; + agentId: string; + sessionId: string; + entry: RuntimeSessionEntry; +}; +type RuntimeCreateSessionEntryFinalPatch = { + pluginExtensions: RuntimeSessionEntry["pluginExtensions"]; +}; +type RuntimeCreateSessionEntryBaseParams = { + cfg: import("../../config/types.openclaw.js").OpenClawConfig; + key: string; + agentId?: string; + label?: string; + spawnedCwd?: string; + initialEntry: { + agentHarnessId: string; + modelSelectionLocked?: true; + pluginExtensions?: RuntimeSessionEntry["pluginExtensions"]; + }; +}; +type RuntimeCreateSessionEntryParams = RuntimeCreateSessionEntryBaseParams & + ( + | { + /** Retry an interrupted initializer only when persisted trusted state matches exactly. */ + recoverMatchingInitialEntry: true; + afterCreate: ( + created: RuntimeCreateSessionEntryResult, + ) => Promise; + } + | { + recoverMatchingInitialEntry?: never; + afterCreate?: ( + created: RuntimeCreateSessionEntryResult, + ) => Promise; + } + ); type RuntimeSessionStoreEntryPatchParams = RuntimeSessionStoreReadParams & { fallbackEntry?: RuntimeSessionEntry; maintenanceConfig?: import("../../config/sessions/store.js").ResolvedSessionMaintenanceConfigInput; @@ -248,6 +285,9 @@ export type PluginRuntimeCore = { ensureAgentWorkspace: typeof import("../../agents/workspace.js").ensureAgentWorkspace; session: { resolveStorePath: typeof import("../../config/sessions/paths.js").resolveStorePath; + createSessionEntry: ( + params: RuntimeCreateSessionEntryParams, + ) => Promise; getSessionEntry: (params: RuntimeSessionStoreReadParams) => RuntimeSessionEntry | undefined; listSessionEntries: ( params?: RuntimeSessionStoreListParams, diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index 90a83fe40814..c97ddf12724c 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -10,6 +10,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "heartbeatIsolatedBaseSessionKey", "heartbeatTaskState", "pluginExtensions", + "initializationPending", "pluginExtensionSlotKeys", "pluginNextTurnInjections", "sessionId", @@ -103,6 +104,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "cacheWrite", "modelProvider", "model", + "modelSelectionLocked", "agentHarnessId", "fallbackNoticeSelectedModel", "fallbackNoticeActiveModel", diff --git a/src/sessions/agent-harness-session-key.test.ts b/src/sessions/agent-harness-session-key.test.ts new file mode 100644 index 000000000000..5c218b7779f2 --- /dev/null +++ b/src/sessions/agent-harness-session-key.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { + AGENT_HARNESS_SESSION_ID_LOCKED_MESSAGE, + AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE, + isAgentHarnessSessionKey, + isAgentHarnessSessionKeyOwnedBy, + isValidAgentHarnessSessionStoreEntry, + resolveAgentHarnessSessionIdMismatchError, + resolveAgentHarnessSessionContextError, + resolveAgentHarnessSessionStoreEntryError, + resolveMissingAgentHarnessSessionError, +} from "./agent-harness-session-key.js"; + +describe("agent harness session keys", () => { + it.each([ + "harness:codex:supervision:native-thread", + "agent:main:harness:codex:supervision:native-thread", + ])("recognizes the reserved namespace for %s", (sessionKey) => { + expect(isAgentHarnessSessionKey(sessionKey)).toBe(true); + expect(resolveMissingAgentHarnessSessionError(sessionKey, undefined)).toMatch(/reserved/i); + expect(resolveMissingAgentHarnessSessionError(sessionKey, { sessionId: "existing" })).toBe( + undefined, + ); + }); + + it("ties trusted creation to the matching persisted harness owner", () => { + const key = "agent:main:harness:codex:supervision:native-thread"; + expect(isAgentHarnessSessionKeyOwnedBy(key, "codex")).toBe(true); + expect(isAgentHarnessSessionKeyOwnedBy(key, "CODEX-APP-SERVER")).toBe(true); + expect(isAgentHarnessSessionKeyOwnedBy(key, "other")).toBe(false); + expect(isAgentHarnessSessionKeyOwnedBy("agent:main:ordinary", "codex")).toBe(false); + }); + + it("compares the exact owner segment instead of an owner-id prefix", () => { + const key = "agent:main:harness:foo:bar:native-thread"; + expect(isAgentHarnessSessionKeyOwnedBy(key, "foo")).toBe(true); + expect(isAgentHarnessSessionKeyOwnedBy(key, "foo:bar")).toBe(false); + expect( + resolveAgentHarnessSessionStoreEntryError(key, { + agentHarnessId: "foo:bar", + modelSelectionLocked: true, + sessionId: "native-session", + }), + ).toBe(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE); + expect( + resolveAgentHarnessSessionStoreEntryError(key, { + agentHarnessId: "foo", + modelSelectionLocked: true, + sessionId: "native-session", + }), + ).toBeUndefined(); + }); + + it("validates durable lock metadata for reserved and ordinary rows", () => { + const key = "agent:main:harness:codex:supervision:native-thread"; + expect( + resolveAgentHarnessSessionStoreEntryError(key, { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + }), + ).toBeUndefined(); + expect( + resolveAgentHarnessSessionStoreEntryError(key, { + agentHarnessId: "codex", + modelSelectionLocked: false, + sessionId: "native-session", + }), + ).toBeUndefined(); + expect( + resolveAgentHarnessSessionStoreEntryError("agent:main:ordinary", { + modelSelectionLocked: false, + }), + ).toBeUndefined(); + expect( + isValidAgentHarnessSessionStoreEntry("agent:main:ordinary", { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + }), + ).toBe(true); + expect( + resolveAgentHarnessSessionStoreEntryError("agent:main:ordinary", { + modelSelectionLocked: true, + sessionId: "native-session", + }), + ).toBeUndefined(); + expect( + isValidAgentHarnessSessionStoreEntry("agent:main:ordinary", { + modelSelectionLocked: true, + sessionId: "native-session", + }), + ).toBe(false); + }); + + it("requires a valid durable row for protected reserved runtime contexts", () => { + const key = "agent:main:harness:codex:supervision:native-thread"; + expect(resolveAgentHarnessSessionContextError(key, undefined)).toMatch(/reserved/i); + expect( + resolveAgentHarnessSessionContextError(key, { + agentHarnessId: "codex", + modelSelectionLocked: false, + sessionId: "native-session", + }), + ).toBeUndefined(); + expect( + resolveAgentHarnessSessionContextError(key, { + agentHarnessId: "codex", + modelSelectionLocked: true, + }), + ).toBe(AGENT_HARNESS_SESSION_ID_LOCKED_MESSAGE); + expect( + resolveAgentHarnessSessionContextError(key, { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + }), + ).toBeUndefined(); + expect( + resolveAgentHarnessSessionContextError("agent:main:ordinary", undefined), + ).toBeUndefined(); + }); + + it("keeps pre-existing unlocked harness-prefixed sessions ordinary", () => { + const key = "agent:main:harness:notes"; + const entry = { + agentHarnessId: "openclaw", + sessionId: "legacy-session", + }; + + expect(resolveAgentHarnessSessionContextError(key, entry)).toBeUndefined(); + expect(resolveAgentHarnessSessionStoreEntryError(key, entry)).toBeUndefined(); + expect(resolveAgentHarnessSessionIdMismatchError(entry, "replacement-session")).toBeUndefined(); + expect(isValidAgentHarnessSessionStoreEntry(key, entry)).toBe(false); + }); + + it("rejects a caller-selected session id that would rotate a durable lock", () => { + const entry = { + agentHarnessId: "codex", + modelSelectionLocked: true, + sessionId: "native-session", + }; + + expect(resolveAgentHarnessSessionIdMismatchError(entry, "native-session")).toBeUndefined(); + expect(resolveAgentHarnessSessionIdMismatchError(entry, "replacement-session")).toBe( + AGENT_HARNESS_SESSION_ID_LOCKED_MESSAGE, + ); + }); + + it("does not turn a legacy model-selection lock into harness ownership", () => { + const entry = { + modelSelectionLocked: true, + sessionId: "ordinary-session", + }; + + expect(resolveAgentHarnessSessionIdMismatchError(entry, "replacement-session")).toBeUndefined(); + }); +}); diff --git a/src/sessions/agent-harness-session-key.ts b/src/sessions/agent-harness-session-key.ts new file mode 100644 index 000000000000..5f445da10ad0 --- /dev/null +++ b/src/sessions/agent-harness-session-key.ts @@ -0,0 +1,175 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { normalizeOptionalAgentRuntimeId } from "../agents/agent-runtime-id.js"; +import { parseAgentSessionKey } from "../routing/session-key.js"; + +export const AGENT_HARNESS_SESSION_KEY_PREFIX = "harness:"; +export const AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE = + "Session key namespace is reserved for agent harness-owned sessions."; +export const AGENT_HARNESS_SESSION_ID_LOCKED_MESSAGE = + "Agent harness-owned session identity is locked and cannot be replaced or shared."; +export const AGENT_HARNESS_MODEL_RUN_FORBIDDEN_MESSAGE = + "Agent harness-owned sessions cannot be used for one-shot model runs."; + +function resolveAgentHarnessSessionKeyRest(sessionKey: string): string { + const trimmed = sessionKey.trim().toLowerCase(); + return parseAgentSessionKey(trimmed)?.rest ?? trimmed; +} + +function resolveAgentHarnessSessionKeyOwner(sessionKey: string): string | undefined { + const rest = resolveAgentHarnessSessionKeyRest(sessionKey); + if (!rest.startsWith(AGENT_HARNESS_SESSION_KEY_PREFIX)) { + return undefined; + } + const ownerSegment = rest.slice(AGENT_HARNESS_SESSION_KEY_PREFIX.length).split(":", 1)[0]; + return normalizeOptionalAgentRuntimeId(ownerSegment); +} + +/** Agent harnesses own this namespace; public session APIs must not create rows in it. */ +export function isAgentHarnessSessionKey(sessionKey: string): boolean { + return resolveAgentHarnessSessionKeyRest(sessionKey).startsWith(AGENT_HARNESS_SESSION_KEY_PREFIX); +} + +export function resolveMissingAgentHarnessSessionError( + sessionKey: string, + entry: unknown, +): string | undefined { + return entry === undefined && isAgentHarnessSessionKey(sessionKey) + ? AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE + : undefined; +} + +/** Missing reserved keys fail closed; pre-feature unlocked collisions stay ordinary. */ +export function resolveAgentHarnessSessionContextError( + sessionKey: string, + entry: AgentHarnessSessionStoreEntry | undefined, +): string | undefined { + if (!isAgentHarnessSessionKey(sessionKey)) { + return undefined; + } + return entry + ? resolveAgentHarnessSessionStoreEntryError(sessionKey, entry) + : AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE; +} + +/** Trusted creation must bind the namespace owner to the persisted harness owner. */ +export function isAgentHarnessSessionKeyOwnedBy( + sessionKey: string, + agentHarnessId: unknown, +): boolean { + const normalizedHarnessId = normalizeOptionalAgentRuntimeId(agentHarnessId); + return Boolean( + normalizedHarnessId && normalizedHarnessId === resolveAgentHarnessSessionKeyOwner(sessionKey), + ); +} + +type AgentHarnessSessionStoreEntry = { + agentHarnessId?: unknown; + modelSelectionLocked?: unknown; + sessionId?: unknown; +}; + +/** True when a reserved-looking row carries the durable harness lock added with this feature. */ +export function isAgentHarnessSessionStoreEntryProtected( + sessionKey: string, + entry: AgentHarnessSessionStoreEntry, +): boolean { + return isAgentHarnessSessionKey(sessionKey) && entry.modelSelectionLocked === true; +} + +/** Validates durable harness locks and prevents transcript identity aliases. */ +export function resolveAgentHarnessSessionStoreError( + store: Record, +): string | undefined { + const lockedSessionIds = new Map(); + for (const [sessionKey, entry] of Object.entries(store)) { + const entryError = resolveAgentHarnessSessionStoreEntryError(sessionKey, entry); + if (entryError) { + return entryError; + } + if (!isValidAgentHarnessSessionStoreEntry(sessionKey, entry)) { + continue; + } + const sessionId = normalizeOptionalString(entry.sessionId); + if (!sessionId || lockedSessionIds.has(sessionId)) { + return AGENT_HARNESS_SESSION_ID_LOCKED_MESSAGE; + } + lockedSessionIds.set(sessionId, sessionKey); + } + for (const [sessionKey, entry] of Object.entries(store)) { + const sessionId = normalizeOptionalString(entry.sessionId); + const lockedOwner = sessionId ? lockedSessionIds.get(sessionId) : undefined; + if (lockedOwner && lockedOwner !== sessionKey) { + return AGENT_HARNESS_SESSION_ID_LOCKED_MESSAGE; + } + } + return undefined; +} + +/** Rejects caller-selected transcript identities that would rotate a durable harness lock. */ +export function resolveAgentHarnessSessionIdMismatchError( + entry: AgentHarnessSessionStoreEntry | undefined, + requestedSessionId: unknown, +): string | undefined { + if ( + !entry || + entry.modelSelectionLocked !== true || + !normalizeOptionalAgentRuntimeId(entry.agentHarnessId) + ) { + return undefined; + } + const requested = normalizeOptionalString(requestedSessionId); + if (!requested) { + return undefined; + } + return requested === normalizeOptionalString(entry.sessionId) + ? undefined + : AGENT_HARNESS_SESSION_ID_LOCKED_MESSAGE; +} + +/** Locked rows require durable identity; reserved rows must also match the key owner. */ +export function resolveAgentHarnessSessionStoreEntryError( + sessionKey: string, + entry: AgentHarnessSessionStoreEntry, +): string | undefined { + if (entry.modelSelectionLocked !== true) { + return undefined; + } + const rawHarnessId = normalizeOptionalString(entry.agentHarnessId)?.toLowerCase(); + const hasCanonicalHarnessOwner = + Boolean(rawHarnessId) && rawHarnessId === normalizeOptionalAgentRuntimeId(rawHarnessId); + if ( + !normalizeOptionalString(entry.sessionId) && + (isAgentHarnessSessionKey(sessionKey) || entry.agentHarnessId !== undefined) + ) { + return AGENT_HARNESS_SESSION_ID_LOCKED_MESSAGE; + } + if (isAgentHarnessSessionKey(sessionKey)) { + return hasCanonicalHarnessOwner && + isAgentHarnessSessionKeyOwnedBy(sessionKey, entry.agentHarnessId) + ? undefined + : AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE; + } + // modelSelectionLocked predates harness-owned sessions and still protects + // ordinary UI sessions. Only rows with an explicit harness owner opt into + // the stronger transcript identity invariant. + if (entry.agentHarnessId === undefined) { + return undefined; + } + if (!hasCanonicalHarnessOwner) { + return AGENT_HARNESS_SESSION_ID_LOCKED_MESSAGE; + } + return undefined; +} + +/** True for any valid durable harness lock, including supported ordinary-key rows. */ +export function isValidAgentHarnessSessionStoreEntry( + sessionKey: string, + entry: AgentHarnessSessionStoreEntry, +): boolean { + return ( + entry.modelSelectionLocked === true && + (isAgentHarnessSessionKey(sessionKey) || + normalizeOptionalAgentRuntimeId(entry.agentHarnessId) !== undefined) && + resolveAgentHarnessSessionStoreEntryError(sessionKey, entry) === undefined + ); +} diff --git a/src/sessions/model-overrides.test.ts b/src/sessions/model-overrides.test.ts index faa7e503aa84..3276334d72be 100644 --- a/src/sessions/model-overrides.test.ts +++ b/src/sessions/model-overrides.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest"; import type { SessionEntry } from "../config/sessions.js"; import { applyModelOverrideToSessionEntry, + MODEL_SELECTION_LOCKED_MESSAGE, + ModelSelectionLockedError, repairProviderWrappedModelOverride, } from "./model-overrides.js"; @@ -52,6 +54,38 @@ function contextBudgetStatus(params: { } describe("applyModelOverrideToSessionEntry", () => { + it("rejects locked session model mutations before changing any fields", () => { + const entry: SessionEntry = { + sessionId: "sess-locked", + updatedAt: 10, + providerOverride: "openai", + modelOverride: "gpt-5.4", + modelSelectionLocked: true, + }; + const before = { ...entry }; + + expect(() => + applyModelOverrideToSessionEntry({ + entry, + selection: { + provider: "anthropic", + model: "claude-sonnet-4-6", + }, + }), + ).toThrow(ModelSelectionLockedError); + expect(() => + applyModelOverrideToSessionEntry({ + entry, + selection: { + provider: "openai", + model: "gpt-5.4", + isDefault: true, + }, + }), + ).toThrow(MODEL_SELECTION_LOCKED_MESSAGE); + expect(entry).toEqual(before); + }); + it("clears stale runtime model fields when switching overrides", () => { const before = Date.now() - 5_000; const entry: SessionEntry = { @@ -292,6 +326,30 @@ describe("applyModelOverrideToSessionEntry", () => { }); describe("repairProviderWrappedModelOverride", () => { + it("rejects provider-wrapped repair for locked sessions without mutating them", () => { + const entry: SessionEntry = { + sessionId: "sess-locked-openrouter-repair", + updatedAt: Date.now() - 5_000, + providerOverride: "anthropic", + modelOverride: "claude-haiku-4.5", + modelOverrideSource: "user", + modelProvider: "openrouter", + model: "anthropic/claude-haiku-4.5", + contextTokens: 200_000, + modelSelectionLocked: true, + }; + const before = { ...entry }; + + expect(() => + repairProviderWrappedModelOverride({ + entry, + defaultProvider: "openai", + defaultModel: "gpt-5.4", + }), + ).toThrow(ModelSelectionLockedError); + expect(entry).toEqual(before); + }); + it("restores a provider-wrapped override from aligned runtime model fields", () => { const before = Date.now() - 5_000; const entry: SessionEntry = { diff --git a/src/sessions/model-overrides.ts b/src/sessions/model-overrides.ts index dcfd36d057c9..2761415147b8 100644 --- a/src/sessions/model-overrides.ts +++ b/src/sessions/model-overrides.ts @@ -9,6 +9,29 @@ export type ModelOverrideSelection = { isDefault?: boolean; }; +export const MODEL_SELECTION_LOCKED_MESSAGE = "Model selection is locked for this session."; +export const MODEL_SELECTION_LOCKED_RESET_MESSAGE = + "This session cannot be reset while model selection is locked."; + +/** Raised when a caller attempts to mutate a locked session model selection. */ +export class ModelSelectionLockedError extends Error { + constructor(message = MODEL_SELECTION_LOCKED_MESSAGE) { + super(message); + this.name = "ModelSelectionLockedError"; + } +} + +export function isModelSelectionLocked(entry: SessionEntry | undefined): boolean { + return entry?.modelSelectionLocked === true; +} + +/** Enforces the durable model-selection lock before any session model fields change. */ +export function assertModelSelectionUnlocked(entry: SessionEntry): void { + if (isModelSelectionLocked(entry)) { + throw new ModelSelectionLockedError(); + } +} + function clearFallbackOrigin(entry: SessionEntry): boolean { let updated = false; if (entry.modelOverrideFallbackOriginProvider !== undefined) { @@ -33,6 +56,7 @@ export function applyModelOverrideToSessionEntry(params: { markLiveSwitchPending?: boolean; }): { updated: boolean } { const { entry, selection, profileOverride } = params; + assertModelSelectionUnlocked(entry); const profileOverrideSource = params.profileOverrideSource ?? "user"; const selectionSource = params.selectionSource ?? "user"; let updated = false; diff --git a/src/shared/session-types.ts b/src/shared/session-types.ts index 865a75762330..62362930c8b6 100644 --- a/src/shared/session-types.ts +++ b/src/shared/session-types.ts @@ -17,7 +17,15 @@ export type GatewayAgentModel = { export type GatewayAgentRuntime = { id: string; fallback?: "openclaw" | "none"; - source: "env" | "agent" | "defaults" | "model" | "provider" | "implicit" | "session-key"; + source: + | "env" + | "agent" + | "defaults" + | "model" + | "provider" + | "implicit" + | "session" + | "session-key"; }; /** Thinking-level option exposed to UI clients. */ diff --git a/src/talk/agent-consult-runtime.test.ts b/src/talk/agent-consult-runtime.test.ts index c6f312ebd013..d885b9ba1a12 100644 --- a/src/talk/agent-consult-runtime.test.ts +++ b/src/talk/agent-consult-runtime.test.ts @@ -5,6 +5,7 @@ import type { ForkSessionEntryFromParentResult, } from "../auto-reply/reply/session-fork.js"; import type { SessionEntry } from "../config/sessions/types.js"; +import { MODEL_SELECTION_LOCKED_MESSAGE } from "../sessions/model-overrides.js"; import { runExclusiveSessionLifecycleMutation } from "../sessions/session-lifecycle-admission.js"; import { setRealtimeVoiceAgentConsultDepsForTest, @@ -23,6 +24,8 @@ function createAgentRuntime(payloads: unknown[] = [{ text: "Speak this." }]) { archivedAt?: number; sessionFile?: string; spawnedBy?: string; + agentHarnessId?: string; + modelSelectionLocked?: boolean; forkedFromParent?: boolean; totalTokens?: number; deliveryContext?: { @@ -247,6 +250,71 @@ describe("realtime voice agent consult runtime", () => { expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); + it("fails closed before dispatching a model for a locked Codex consult session", async () => { + const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime(); + sessionStore["voice:locked"] = { + sessionId: "locked-session", + updatedAt: 1, + agentHarnessId: "codex", + modelSelectionLocked: true, + }; + + await expect( + consultRealtimeVoiceAgent({ + cfg: {} as never, + agentRuntime: runtime as never, + logger: { warn: vi.fn() }, + sessionKey: "voice:locked", + messageProvider: "voice", + lane: "voice", + runIdPrefix: "voice-realtime-consult:locked", + args: { question: "Continue this session." }, + transcript: [], + surface: "a live phone call", + userLabel: "Caller", + provider: "openai", + model: "gpt-5.4", + }), + ).rejects.toThrow(MODEL_SELECTION_LOCKED_MESSAGE); + expect(runtime.ensureAgentWorkspace).not.toHaveBeenCalled(); + expect(runtime.session.patchSessionEntry).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); + }); + + it("fails closed before forking or dispatching from a locked requester session", async () => { + const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime(); + sessionStore["agent:main:main"] = { + sessionId: "locked-requester", + updatedAt: 1, + agentHarnessId: "codex", + modelSelectionLocked: true, + }; + const forkSessionEntryFromParent = vi.fn(); + setRealtimeVoiceAgentConsultDepsForTest({ forkSessionEntryFromParent }); + + await expect( + consultRealtimeVoiceAgent({ + cfg: {} as never, + agentRuntime: runtime as never, + logger: { warn: vi.fn() }, + sessionKey: "agent:main:subagent:google-meet:meet-locked", + spawnedBy: "agent:main:main", + contextMode: "fork", + messageProvider: "google-meet", + lane: "google-meet", + runIdPrefix: "google-meet:meet-locked", + args: { question: "Continue this session." }, + transcript: [], + surface: "a private Google Meet", + userLabel: "Participant", + }), + ).rejects.toThrow(MODEL_SELECTION_LOCKED_MESSAGE); + expect(forkSessionEntryFromParent).not.toHaveBeenCalled(); + expect(runtime.ensureAgentWorkspace).not.toHaveBeenCalled(); + expect(runtime.session.patchSessionEntry).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); + }); + it("fresh-checks archive state after a queued lifecycle mutation", async () => { const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime(); const sessionKey = "voice:archive-race"; diff --git a/src/talk/agent-consult-runtime.ts b/src/talk/agent-consult-runtime.ts index ba1ebf711dae..d1ab31ca20ab 100644 --- a/src/talk/agent-consult-runtime.ts +++ b/src/talk/agent-consult-runtime.ts @@ -8,6 +8,7 @@ import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { RuntimeLogger, PluginRuntimeCore } from "../plugins/runtime/types-core.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; +import { isModelSelectionLocked, ModelSelectionLockedError } from "../sessions/model-overrides.js"; import { beginSessionWorkAdmission } from "../sessions/session-lifecycle-admission.js"; import { deliveryContextFromSession, @@ -52,6 +53,56 @@ const defaultRealtimeVoiceAgentConsultDeps: RealtimeVoiceAgentConsultDeps = { let realtimeVoiceAgentConsultDeps = defaultRealtimeVoiceAgentConsultDeps; +/** + * Fails closed when a realtime consult would cross a model-selection lock. + */ +export function assertRealtimeVoiceAgentConsultModelSelectionUnlocked(params: { + cfg: OpenClawConfig; + agentRuntime: RealtimeVoiceAgentConsultRuntime; + agentId: string; + sessionKey: string; + spawnedBy?: string | null; + storePath?: string; +}): void { + const candidates = new Map(); + const remember = (sessionKey: string, fallbackAgentId: string, storePath?: string) => { + const candidateAgentId = parseAgentSessionKey(sessionKey)?.agentId ?? fallbackAgentId; + const candidateStorePath = + storePath ?? + params.agentRuntime.session.resolveStorePath(params.cfg.session?.store, { + agentId: candidateAgentId, + }); + candidates.set(`${candidateStorePath}\u0000${sessionKey}`, { + sessionKey, + storePath: candidateStorePath, + }); + }; + + remember(params.sessionKey, params.agentId, params.storePath); + const requesterSessionKey = params.spawnedBy?.trim(); + if (requesterSessionKey) { + const requesterAgentId = parseAgentSessionKey(requesterSessionKey)?.agentId ?? params.agentId; + remember(requesterSessionKey, requesterAgentId); + const { baseSessionKey } = parseSessionThreadInfoFast(requesterSessionKey); + if (baseSessionKey && baseSessionKey !== requesterSessionKey) { + remember(baseSessionKey, requesterAgentId); + } + } + + for (const { sessionKey, storePath } of candidates.values()) { + const entry = params.agentRuntime.session.getSessionEntry({ + storePath, + sessionKey, + readConsistency: "latest", + }); + // Realtime consults select a configured provider/model and may run fast-context first. + // Until they preserve native bindings, a locked transcript must never cross runtimes. + if (isModelSelectionLocked(entry)) { + throw new ModelSelectionLockedError(); + } + } +} + /** * Overrides consult runtime dependencies for deterministic tests. */ @@ -246,6 +297,15 @@ export async function consultRealtimeVoiceAgent(params: { sessionKey: params.sessionKey, readConsistency: "latest", }); + const modelLockParams = { + cfg: params.cfg, + agentRuntime: params.agentRuntime, + agentId, + sessionKey: params.sessionKey, + spawnedBy: params.spawnedBy, + storePath, + }; + assertRealtimeVoiceAgentConsultModelSelectionUnlocked(modelLockParams); const lifecycleAbortController = new AbortController(); const sessionWorkAdmission = await beginSessionWorkAdmission({ scope: storePath, @@ -270,6 +330,7 @@ export async function consultRealtimeVoiceAgent(params: { if (archivedSessionError) { throw new Error(archivedSessionError); } + assertRealtimeVoiceAgentConsultModelSelectionUnlocked(modelLockParams); }, }); @@ -299,6 +360,7 @@ export async function consultRealtimeVoiceAgent(params: { const consultDeliveryContext = resolvedDeliveryContext ?? deliveryContextFromSession(sessionEntry); const sessionId = sessionEntry.sessionId; + assertRealtimeVoiceAgentConsultModelSelectionUnlocked(modelLockParams); // Voice consults suppress verbose/reasoning output because the bridge needs a short, // speakable answer, not agent-run diagnostics or hidden reasoning artifacts. diff --git a/src/tui/embedded-backend.test.ts b/src/tui/embedded-backend.test.ts index 022ecd3dd271..5bf614df5e6f 100644 --- a/src/tui/embedded-backend.test.ts +++ b/src/tui/embedded-backend.test.ts @@ -10,6 +10,7 @@ import { getEmbeddedPluginApprovalBroker, } from "../infra/embedded-plugin-approval-broker.js"; import { defaultRuntime } from "../runtime.js"; +import { AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE } from "../sessions/agent-harness-session-key.js"; import { notifyListeners } from "../shared/listeners.js"; import { withEnvAsync } from "../test-utils/env.js"; @@ -711,6 +712,68 @@ describe("EmbeddedTuiBackend", () => { expect(loadGatewayModelCatalogMock).toHaveBeenCalledWith({ readOnly: false }); }); + it("rejects a missing harness-owned session before a local patch can create it", async () => { + const sessionKey = "agent:main:harness:codex:supervision:missing-patch"; + projectSessionsPatchEntryMock.mockResolvedValueOnce({ + ok: false, + error: { + code: "INVALID_REQUEST", + message: AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE, + }, + }); + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + const backend = new EmbeddedTuiBackend(); + + await expect(backend.patchSession({ key: sessionKey, label: "squat" })).rejects.toThrow( + AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE, + ); + + expect(projectSessionsPatchEntryMock).toHaveBeenCalledWith( + expect.objectContaining({ storeKey: sessionKey, existingEntry: undefined }), + ); + }); + + it("allows local patches to an existing harness-owned session", async () => { + const sessionKey = "agent:main:harness:codex:supervision:existing-patch"; + const existingEntry = { + sessionId: "existing-harness-session", + updatedAt: embeddedEventTimestamp, + agentHarnessId: "codex", + modelSelectionLocked: true, + }; + applySessionPatchProjectionMock.mockImplementationOnce( + async (params: { + project: (context: { + entries: Array<{ sessionKey: string; entry: typeof existingEntry }>; + existingEntry?: typeof existingEntry; + primaryKey: string; + }) => Promise; + resolveTarget: (snapshot: { + entries: Array<{ sessionKey: string; entry: typeof existingEntry }>; + }) => { primaryKey: string }; + }) => { + const entries = [{ sessionKey, entry: existingEntry }]; + const target = params.resolveTarget({ entries }); + return await params.project({ ...target, entries, existingEntry }); + }, + ); + projectSessionsPatchEntryMock.mockResolvedValueOnce({ + ok: true, + entry: { ...existingEntry, label: "kept" }, + }); + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + const backend = new EmbeddedTuiBackend(); + + await expect(backend.patchSession({ key: sessionKey, label: "kept" })).resolves.toMatchObject({ + ok: true, + key: sessionKey, + entry: { sessionId: "existing-harness-session", label: "kept" }, + }); + expect(projectSessionsPatchEntryMock).toHaveBeenCalledWith( + expect.objectContaining({ storeKey: sessionKey, existingEntry }), + ); + }); + it("scopes local session lists to the selected agent store", async () => { const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); const backend = new EmbeddedTuiBackend(); diff --git a/test/scripts/bundled-plugin-build-entries.test.ts b/test/scripts/bundled-plugin-build-entries.test.ts index 212035e98291..f0dac2aedbcf 100644 --- a/test/scripts/bundled-plugin-build-entries.test.ts +++ b/test/scripts/bundled-plugin-build-entries.test.ts @@ -72,14 +72,12 @@ describe("bundled plugin build entries", () => { expect(pickEntries(entries, Object.keys(expectedEntries))).toStrictEqual(expectedEntries); }); - it("keeps Codex Supervisor CLI metadata in bundled build and pack entries", () => { + it("keeps Codex CLI metadata in bundled build and standalone pack entries", () => { const entries = listBundledPluginBuildEntries(); - const artifacts = listBundledPluginPackArtifacts(); + const artifacts = listBundledPluginPackArtifacts({ includeRootPackageExcludedDirs: true }); - expect(entries["extensions/codex-supervisor/cli-metadata"]).toBe( - "extensions/codex-supervisor/cli-metadata.ts", - ); - expect(artifacts).toContain("dist/extensions/codex-supervisor/cli-metadata.js"); + expect(entries["extensions/codex/cli-metadata"]).toBe("extensions/codex/cli-metadata.ts"); + expect(artifacts).toContain("dist/extensions/codex/cli-metadata.js"); }); it("filters bundled plugin build entries for bounded script lanes", () => { diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 68d3b663a621..d36581159272 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -2227,6 +2227,10 @@ describe("ci workflow guards", () => { ".github/codeql/codeql-network-runtime-boundary-critical-quality.yml", "utf8", ); + const rawSocketQuery = readFileSync( + ".github/codeql/openclaw-boundary/queries/raw-socket-callsite-classification.ql", + "utf8", + ); const networkSelector = workflow.slice( workflow.indexOf(".github/codeql/codeql-network-runtime-boundary-critical-quality.yml"), workflow.indexOf("network-runtime-boundary:"), @@ -2254,6 +2258,28 @@ describe("ci workflow guards", () => { '| select(.filename | test("(^|/)[^/]+\\\\.(?:e2e\\\\.)?test\\\\.tsx?$") | not)', ); expect(workflow).toContain("Network runtime boundary-sensitive added lines"); - expect(workflow).toContain("if: ${{ github.event_name != 'pull_request' }}"); + expect(workflow).toContain( + 'codex_transport="extensions/codex/src/app-server/transport-websocket.ts"', + ); + expect(workflow).toContain( + '| select(.filename != "extensions/codex/src/app-server/transport-websocket.ts")', + ); + expect(workflow).not.toContain('grep -Fv "$codex_transport: " "$added_lines"'); + // Raw-socket exclusions are filename-structural. A monitored package line may + // contain the transport path as data without disappearing from the scan. + expect(workflow).toContain("packages/net-policy/src/"); + expect(workflow).toContain( + "grep -En 'HTTP_PROXY|HTTPS_PROXY|NO_PROXY|GLOBAL_AGENT_|OPENCLAW_PROXY_' \"$added_lines\"", + ); + expect(workflow).toContain('echo "full_codeql=true" >> "$GITHUB_OUTPUT"'); + expect(workflow).toContain( + "if: ${{ github.event_name != 'pull_request' || steps.network-diff-scan.outputs.full_codeql == 'true' }}", + ); + expect(rawSocketQuery).toContain( + 'allowedOwnerScope(call, "extensions/codex/src/app-server/transport-websocket.ts", "connectCodexAppServerUnixSocket")', + ); + expect(rawSocketQuery).not.toContain( + 'call.getFile().getRelativePath() = "extensions/codex/src/app-server/transport-websocket.ts"', + ); }); }); diff --git a/ui/public/plugin-art/codex-supervisor.webp b/ui/public/plugin-art/codex-supervisor.webp deleted file mode 100644 index d3cb6b2badc9..000000000000 Binary files a/ui/public/plugin-art/codex-supervisor.webp and /dev/null differ diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 35d328b62fc3..31d3c62bbc3b 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -521,6 +521,7 @@ export type GatewaySessionRow = { childSessions?: string[]; model?: string; modelProvider?: string; + modelSelectionLocked?: boolean; effectiveResponseUsage?: "on" | "off" | "tokens" | "full"; agentRuntime?: GatewayAgentRuntime; contextTokens?: number; diff --git a/ui/src/components/app-sidebar.test.ts b/ui/src/components/app-sidebar.test.ts index e081237e4a94..9d3d82f2620f 100644 --- a/ui/src/components/app-sidebar.test.ts +++ b/ui/src/components/app-sidebar.test.ts @@ -32,6 +32,7 @@ if (!customElements.get(PROVIDER_ELEMENT_NAME)) { } type SidebarLifecycleState = HTMLElement & { + connected: boolean; sessionRowsByAgent: Record; sessionCreatedOrder: Map; sessionsAgentId: string | null; @@ -44,6 +45,11 @@ type LobsterPetElement = HTMLElement & { runOutcome: "ok" | "error" | "aborted"; }; +type TestSessionMenu = HTMLElement & { + forkDisabled: boolean; + readonly updateComplete: Promise; +}; + function createGatewayHarness(client: GatewayBrowserClient) { let snapshot: ApplicationGatewaySnapshot = { client, @@ -233,6 +239,38 @@ describe("AppSidebar lobster outcome wiring", () => { }); describe("AppSidebar session source lifecycle", () => { + it("disables Fork session for model-selection-locked rows", async () => { + const gateway = createGateway({} as GatewayBrowserClient); + const sessions = createSessionsHarness("main", ["agent:main:locked"]); + const lockedState = createSessionState("main", ["agent:main:locked"]); + const lockedRow = lockedState.result?.sessions[0]; + if (!lockedRow) { + throw new Error("Expected locked session row"); + } + lockedRow.modelSelectionLocked = true; + sessions.publishList({ result: lockedState.result, agentId: lockedState.agentId }); + const { sidebar } = await mountSidebar(gateway, sessions.sessions); + sidebar.connected = true; + await sidebar.updateComplete; + + const menuButton = sidebar.querySelector( + '[data-session-key="agent:main:locked"] [data-session-menu="true"]', + ); + if (!menuButton) { + throw new Error("Expected sidebar session menu button"); + } + menuButton.click(); + await sidebar.updateComplete; + + const menu = sidebar.querySelector("openclaw-session-menu"); + if (!menu) { + throw new Error("Expected sidebar session menu"); + } + await menu.updateComplete; + expect(menu.forkDisabled).toBe(true); + expect(menu.querySelector('[data-shortcut="f"]')?.disabled).toBe(true); + }); + it("resets cached rows and creation order when the sessions source changes", async () => { const client = {} as GatewayBrowserClient; const gateway = createGateway(client); diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index 903401e42573..6c3ac81ebacb 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -87,6 +87,7 @@ type SidebarRecentSession = { active: boolean; visuallyActive: boolean; hasActiveRun: boolean; + modelSelectionLocked: boolean; kind?: string; pinned: boolean; category?: string; @@ -458,6 +459,7 @@ class AppSidebar extends OpenClawLightDomContentsElement { active: row.key === navigation.activeRowKey, visuallyActive: highlightCurrentSession && row.key === navigation.currentSessionKey, hasActiveRun: Boolean(row.hasActiveRun), + modelSelectionLocked: row.modelSelectionLocked === true, kind: row.kind, pinned: row.pinned === true, category: normalizeOptionalString(row.category), @@ -1164,7 +1166,7 @@ class AppSidebar extends OpenClawLightDomContentsElement { .y=${menu.y} .trigger=${this.sessionMenuTrigger} .disabled=${!this.connected} - .forkDisabled=${this.sessionsLoading} + .forkDisabled=${this.sessionsLoading || session.modelSelectionLocked} .archiveAllowed=${archiveAllowed} .groups=${this.knownSessionGroups()} .canOpenChat=${true} diff --git a/ui/src/e2e/codex-sessions.e2e.test.ts b/ui/src/e2e/codex-sessions.e2e.test.ts index 92de2632a1c5..35abaa86012c 100644 --- a/ui/src/e2e/codex-sessions.e2e.test.ts +++ b/ui/src/e2e/codex-sessions.e2e.test.ts @@ -1,5 +1,5 @@ -// Control UI E2E covers the host-federated, read-only Codex Sessions plugin tab. -import { mkdir } from "node:fs/promises"; +// Control UI E2E covers the host-federated, interactive Codex Sessions plugin tab. +import { mkdir, rm } from "node:fs/promises"; import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -15,17 +15,32 @@ const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium. const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; +const continuedSessionKey = "agent:main:continued-codex-thread"; +const importedUserText = "Make these Codex sessions actionable without resuming the source thread."; +const importedAssistantText = + "I’ll preserve the source and continue through a Codex App Server branch."; let browser: Browser; let server: ControlUiE2eServer; +const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; +const artifactDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "codex-supervision"); async function captureUiProof(page: Page, fileName: string) { - if (process.env.OPENCLAW_CAPTURE_UI_PROOF !== "1") { + if (!captureUiProofEnabled) { return; } - const artifactDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "codex-sessions"); await mkdir(artifactDir, { recursive: true }); - await page.screenshot({ fullPage: true, path: path.join(artifactDir, fileName) }); + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(artifactDir, fileName), + }); +} + +async function holdUiProof(page: Page) { + if (captureUiProofEnabled) { + await page.waitForTimeout(500); + } } function session(threadId: string, name: string, status = "notLoaded") { @@ -42,6 +57,46 @@ function session(threadId: string, name: string, status = "notLoaded") { }; } +function lockedSessionListResponse() { + const now = Date.now(); + return { + count: 2, + defaults: { contextTokens: null, model: "gpt-5.5", modelProvider: "openai" }, + path: "", + sessions: [ + { + contextTokens: null, + displayName: "Main", + hasActiveRun: false, + key: "main", + kind: "direct", + label: "Main", + model: "gpt-5.5", + modelProvider: "openai", + status: "done", + totalTokens: 0, + updatedAt: now, + }, + { + agentRuntime: { id: "codex", source: "session" }, + contextTokens: null, + displayName: "Local release checklist", + hasActiveRun: false, + key: continuedSessionKey, + kind: "direct", + label: "Local release checklist", + model: "gpt-5.5", + modelProvider: "openai", + modelSelectionLocked: true, + status: "done", + totalTokens: 0, + updatedAt: now, + }, + ], + ts: now, + }; +} + describeControlUiE2e("Codex Sessions mocked Gateway E2E", () => { beforeAll(async () => { if (!chromiumAvailable) { @@ -58,13 +113,21 @@ describeControlUiE2e("Codex Sessions mocked Gateway E2E", () => { await server?.close(); }); - it("searches, paginates, and switches archives without hiding offline hosts", async () => { + it("searches, paginates, continues, and archives without hiding offline hosts", async () => { + if (captureUiProofEnabled) { + await rm(artifactDir, { force: true, recursive: true }); + await mkdir(artifactDir, { recursive: true }); + } const context = await browser.newContext({ locale: "en-US", + recordVideo: captureUiProofEnabled + ? { dir: path.join(artifactDir, "raw-video"), size: { height: 900, width: 1440 } } + : undefined, serviceWorkers: "block", viewport: { height: 980, width: 1440 }, }); const page = await context.newPage(); + const video = page.video(); const devbox = { connected: true, hostId: "node:devbox", @@ -82,7 +145,10 @@ describeControlUiE2e("Codex Sessions mocked Gateway E2E", () => { hostId: "gateway:local", kind: "gateway", label: "Studio Gateway", - sessions: [session("demo-local-thread", "Local release checklist")], + sessions: [ + session("demo-local-thread", "Local release checklist"), + session("demo-archive-thread", "Archive after testing"), + ], }; const offline = { connected: false, @@ -91,7 +157,7 @@ describeControlUiE2e("Codex Sessions mocked Gateway E2E", () => { kind: "node", label: "Travel Mac", nodeId: "travel-mac", - sessions: [], + sessions: [session("demo-offline-thread", "Stored on the travel Mac")], }; const gateway = await installMockGateway(page, { controlUiTabs: [ @@ -100,11 +166,11 @@ describeControlUiE2e("Codex Sessions mocked Gateway E2E", () => { icon: "terminal", id: "sessions", label: "Codex Sessions", - pluginId: "codex-supervisor", + pluginId: "codex", }, ], methodResponses: { - "codex-supervisor.sessions.list": { + "codex.sessions.list": { cases: [ { match: { @@ -121,37 +187,25 @@ describeControlUiE2e("Codex Sessions mocked Gateway E2E", () => { ], }, }, - { - match: { archived: true }, - response: { - hosts: [ - { - ...studio, - sessions: [ - { - ...session("demo-archived-thread", "Archived migration"), - archived: true, - }, - ], - }, - offline, - ], - }, - }, { match: { search: "Current" }, response: { hosts: [{ ...devbox, nextCursor: undefined }] }, }, - { match: { archived: false }, response: { hosts: [devbox, studio, offline] } }, + { match: {}, response: { hosts: [devbox, studio, offline] } }, ], }, + "codex.sessions.continue": { + sessionKey: continuedSessionKey, + disposition: "forked", + }, + "codex.sessions.archive": { archived: true }, }, }); try { - await page.goto(`${server.baseUrl}plugin?plugin=codex-supervisor&id=sessions`); - const initialRequest = await gateway.waitForRequest("codex-supervisor.sessions.list"); - expect(initialRequest.params).toEqual({ archived: false, limitPerHost: 40 }); + await page.goto(`${server.baseUrl}plugin?plugin=codex&id=sessions`); + const initialRequest = await gateway.waitForRequest("codex.sessions.list"); + expect(initialRequest.params).toEqual({ limitPerHost: 40 }); await expect .poll(() => @@ -162,48 +216,203 @@ describeControlUiE2e("Codex Sessions mocked Gateway E2E", () => { await expect .poll(() => page.getByText("00000000-0000-4000-8000-000000000001").isVisible()) .toBe(true); - await expect.poll(() => page.getByText("Travel Mac").isVisible()).toBe(true); + await expect + .poll(() => page.getByRole("heading", { name: "Travel Mac", exact: true }).isVisible()) + .toBe(true); await expect.poll(() => page.getByText("Node is not connected").isVisible()).toBe(true); + await expect + .poll(() => + page + .locator('[data-thread-id="00000000-0000-4000-8000-000000000001"]') + .locator(".codex-session__view-only") + .getByText("Paired-computer sessions are view-only for now.", { exact: true }) + .isVisible(), + ) + .toBe(true); + await expect + .poll(() => + page + .locator('[data-thread-id="demo-offline-thread"]') + .locator(".codex-session__view-only") + .getByText("Paired-computer sessions are view-only for now.", { exact: true }) + .isVisible(), + ) + .toBe(true); + await expect + .poll(() => + page + .locator('[data-thread-id="00000000-0000-4000-8000-000000000001"]') + .getByRole("button", { name: "Continue Current Codex UI session" }) + .isDisabled(), + ) + .toBe(true); + await expect + .poll(() => + page + .locator('[data-thread-id="demo-offline-thread"]') + .getByRole("button", { name: "Archive Stored on the travel Mac" }) + .isDisabled(), + ) + .toBe(true); + await holdUiProof(page); await captureUiProof(page, "01-hosts-and-partial-error.png"); - await page.getByRole("button", { name: "Load more" }).click(); + await page.getByRole("button", { name: "Load more — Development Box", exact: true }).click(); await expect - .poll(async () => (await gateway.getRequests("codex-supervisor.sessions.list")).length) + .poll(async () => (await gateway.getRequests("codex.sessions.list")).length) .toBeGreaterThanOrEqual(2); await expect.poll(() => page.getByText("Follow-up on the dev box").isVisible()).toBe(true); + await expect + .poll(() => + page + .locator('[data-thread-id="demo-next-thread"]') + .locator(".codex-session__view-only") + .getByText("Paired-computer sessions are view-only for now.", { exact: true }) + .isVisible(), + ) + .toBe(true); + await expect + .poll(() => + page + .locator('[data-thread-id="demo-next-thread"]') + .getByRole("button", { name: "Continue Follow-up on the dev box as a branch" }) + .isDisabled(), + ) + .toBe(true); + await expect + .poll(() => + page + .locator('[data-thread-id="demo-next-thread"]') + .getByRole("button", { name: "Continue Follow-up on the dev box as a branch" }) + .getAttribute("title"), + ) + .toBe("Paired-computer sessions are view-only for now."); + await holdUiProof(page); await captureUiProof(page, "02-paginated.png"); const searchInput = page.getByRole("searchbox", { name: "Search Codex sessions" }); await searchInput.fill("Current"); await expect .poll(async () => - (await gateway.getRequests("codex-supervisor.sessions.list")).some( + (await gateway.getRequests("codex.sessions.list")).some( (request) => (request.params as { search?: string })?.search === "Current", ), ) .toBe(true); await expect.poll(() => page.getByText("Local release checklist").count()).toBe(0); await expect.poll(() => page.getByText("Travel Mac").count()).toBe(0); + await holdUiProof(page); + await captureUiProof(page, "03-search-filtered.png"); await searchInput.fill(""); await expect .poll(async () => { - const requests = await gateway.getRequests("codex-supervisor.sessions.list"); - return requests.filter( - (request) => - (request.params as { archived?: boolean; search?: string })?.archived === false && - !(request.params as { search?: string })?.search, - ).length; + const requests = await gateway.getRequests("codex.sessions.list"); + return requests.filter((request) => !(request.params as { search?: string })?.search) + .length; }) .toBeGreaterThanOrEqual(2); await expect.poll(() => page.getByText("Local release checklist").isVisible()).toBe(true); - await expect.poll(() => page.getByText("Travel Mac").isVisible()).toBe(true); - await page.getByRole("button", { name: "Archived" }).click(); - await expect.poll(() => page.getByText("Archived migration").isVisible()).toBe(true); - await expect.poll(() => page.getByText("Current Codex UI session").count()).toBe(0); - await captureUiProof(page, "03-archived.png"); + await expect + .poll(() => page.getByRole("heading", { name: "Travel Mac", exact: true }).isVisible()) + .toBe(true); + await expect.poll(() => page.getByRole("button", { name: "Archived" }).count()).toBe(0); + + const storedRow = page.locator('[data-thread-id="demo-local-thread"]'); + const branchButton = storedRow.getByRole("button", { + name: "Continue Local release checklist as a branch", + }); + await expect.poll(() => branchButton.isEnabled()).toBe(true); + await expect + .poll(() => branchButton.getAttribute("title")) + .toBe( + "Create a Chat from persisted visible history. On your first message, Codex App Server selects the model and provider for the new harness thread. Later selection remains Codex-controlled; OpenClaw never substitutes another runtime, model, or fallback. The source remains untouched, and in-flight work may be absent.", + ); + await expect + .poll(() => storedRow.getByText("Stored / activity unknown").isVisible()) + .toBe(true); + const unsafeArchive = storedRow.getByRole("button", { + name: "Archive Local release checklist", + }); + await expect.poll(() => unsafeArchive.isEnabled()).toBe(true); + await expect + .poll(() => unsafeArchive.getAttribute("title")) + .toBe( + "Activity is unknown because status is process-local. Archive only after confirming that no other Codex client or runner is using this session.", + ); + + const archiveDialog = page.waitForEvent("dialog"); + const archiveClick = page + .getByRole("button", { name: "Archive Archive after testing" }) + .click(); + const dialog = await archiveDialog; + expect(dialog.message()).toContain("no other Codex client or OpenClaw runner is using them"); + await dialog.accept(); + await archiveClick; + const archiveRequest = await gateway.waitForRequest("codex.sessions.archive"); + expect(archiveRequest.params).toEqual({ + hostId: "gateway:local", + threadId: "demo-archive-thread", + confirmNoOtherRunner: true, + }); + await expect.poll(() => page.getByText("Archive after testing").count()).toBe(0); + await holdUiProof(page); + await captureUiProof(page, "04-archived-active-row.png"); + + await gateway.setHistoryMessages([ + { + content: [{ text: importedUserText, type: "text" }], + role: "user", + timestamp: Date.parse("2026-07-09T20:00:00.000Z"), + }, + { + content: [{ text: importedAssistantText, type: "text" }], + role: "assistant", + timestamp: Date.parse("2026-07-09T20:01:00.000Z"), + }, + ]); + await branchButton.click(); + const continueRequest = await gateway.waitForRequest("codex.sessions.continue"); + expect(continueRequest.params).toEqual({ + hostId: "gateway:local", + threadId: "demo-local-thread", + }); + await expect + .poll(() => new URL(page.url()).searchParams.get("session")) + .toBe(continuedSessionKey); + await page.getByText(importedUserText, { exact: true }).waitFor({ state: "visible" }); + await page.getByText(importedAssistantText, { exact: true }).waitFor({ state: "visible" }); + + await gateway.setMethodResponse("sessions.list", lockedSessionListResponse()); + const sessionListCount = (await gateway.getRequests("sessions.list")).length; + await gateway.emitGatewayEvent("sessions.changed", { + agentId: "main", + key: continuedSessionKey, + reason: "create", + sessionKey: continuedSessionKey, + }); + await expect + .poll(async () => (await gateway.getRequests("sessions.list")).length) + .toBeGreaterThan(sessionListCount); + + const lockedModelSelector = page.locator('[data-chat-model-locked="true"]'); + await lockedModelSelector.waitFor({ state: "visible" }); + await expect + .poll(() => lockedModelSelector.textContent()) + .toContain("Codex-controlled model"); + await lockedModelSelector.click(); + await expect + .poll(() => page.locator(".chat-controls__locked-model-value").textContent()) + .toBe("Codex-controlled model"); + expect(await page.locator("[data-chat-model-option]").count()).toBe(0); + await holdUiProof(page); + await captureUiProof(page, "05-continued-chat.png"); } finally { await context.close(); + if (video) { + await video.saveAs(path.join(artifactDir, "codex-supervision-flow.webm")); + } + await rm(path.join(artifactDir, "raw-video"), { force: true, recursive: true }); } }); }); diff --git a/ui/src/i18n/.i18n/ar.meta.json b/ui/src/i18n/.i18n/ar.meta.json index 079f0321c81a..163740c1a199 100644 --- a/ui/src/i18n/.i18n/ar.meta.json +++ b/ui/src/i18n/.i18n/ar.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-11T07:01:33.125Z", + "generatedAt": "2026-07-11T07:11:31.154Z", "locale": "ar", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "ccbf12e11a31bead2f6ed101cfc7f3ef28fb6087d5d99194927b88776cfc7579", - "totalKeys": 2090, - "translatedKeys": 2090, + "sourceHash": "347c235f241ad329d8a934851cdc2038b00dfa1cbbdd6fe46d07447910ffa3aa", + "totalKeys": 2108, + "translatedKeys": 2108, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/de.meta.json b/ui/src/i18n/.i18n/de.meta.json index 06290051a3f8..edf923e4fbe6 100644 --- a/ui/src/i18n/.i18n/de.meta.json +++ b/ui/src/i18n/.i18n/de.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-11T07:01:31.172Z", + "generatedAt": "2026-07-11T07:11:30.296Z", "locale": "de", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "ccbf12e11a31bead2f6ed101cfc7f3ef28fb6087d5d99194927b88776cfc7579", - "totalKeys": 2090, - "translatedKeys": 2090, + "sourceHash": "347c235f241ad329d8a934851cdc2038b00dfa1cbbdd6fe46d07447910ffa3aa", + "totalKeys": 2108, + "translatedKeys": 2108, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/es.meta.json b/ui/src/i18n/.i18n/es.meta.json index 731f96564e14..42c6eb46ccea 100644 --- a/ui/src/i18n/.i18n/es.meta.json +++ b/ui/src/i18n/.i18n/es.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-11T07:01:31.399Z", + "generatedAt": "2026-07-11T07:11:30.476Z", "locale": "es", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "ccbf12e11a31bead2f6ed101cfc7f3ef28fb6087d5d99194927b88776cfc7579", - "totalKeys": 2090, - "translatedKeys": 2090, + "sourceHash": "347c235f241ad329d8a934851cdc2038b00dfa1cbbdd6fe46d07447910ffa3aa", + "totalKeys": 2108, + "translatedKeys": 2108, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/fa.meta.json b/ui/src/i18n/.i18n/fa.meta.json index acbf93347c6b..3ba9740cd455 100644 --- a/ui/src/i18n/.i18n/fa.meta.json +++ b/ui/src/i18n/.i18n/fa.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-11T07:01:36.370Z", + "generatedAt": "2026-07-11T07:11:32.849Z", "locale": "fa", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "ccbf12e11a31bead2f6ed101cfc7f3ef28fb6087d5d99194927b88776cfc7579", - "totalKeys": 2090, - "translatedKeys": 2090, + "sourceHash": "347c235f241ad329d8a934851cdc2038b00dfa1cbbdd6fe46d07447910ffa3aa", + "totalKeys": 2108, + "translatedKeys": 2108, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/fr.meta.json b/ui/src/i18n/.i18n/fr.meta.json index d3b24d628a62..07fb601b7917 100644 --- a/ui/src/i18n/.i18n/fr.meta.json +++ b/ui/src/i18n/.i18n/fr.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-11T07:01:32.433Z", + "generatedAt": "2026-07-11T07:11:30.844Z", "locale": "fr", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "ccbf12e11a31bead2f6ed101cfc7f3ef28fb6087d5d99194927b88776cfc7579", - "totalKeys": 2090, - "translatedKeys": 2090, + "sourceHash": "347c235f241ad329d8a934851cdc2038b00dfa1cbbdd6fe46d07447910ffa3aa", + "totalKeys": 2108, + "translatedKeys": 2108, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/hi.meta.json b/ui/src/i18n/.i18n/hi.meta.json index a9fca791d1f0..9a8fa0c4f197 100644 --- a/ui/src/i18n/.i18n/hi.meta.json +++ b/ui/src/i18n/.i18n/hi.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-11T07:01:32.718Z", + "generatedAt": "2026-07-11T07:11:30.989Z", "locale": "hi", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "ccbf12e11a31bead2f6ed101cfc7f3ef28fb6087d5d99194927b88776cfc7579", - "totalKeys": 2090, - "translatedKeys": 2090, + "sourceHash": "347c235f241ad329d8a934851cdc2038b00dfa1cbbdd6fe46d07447910ffa3aa", + "totalKeys": 2108, + "translatedKeys": 2108, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/id.meta.json b/ui/src/i18n/.i18n/id.meta.json index 452451e5f292..bcc62ccc2af7 100644 --- a/ui/src/i18n/.i18n/id.meta.json +++ b/ui/src/i18n/.i18n/id.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-11T07:01:34.588Z", + "generatedAt": "2026-07-11T07:11:31.963Z", "locale": "id", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "ccbf12e11a31bead2f6ed101cfc7f3ef28fb6087d5d99194927b88776cfc7579", - "totalKeys": 2090, - "translatedKeys": 2090, + "sourceHash": "347c235f241ad329d8a934851cdc2038b00dfa1cbbdd6fe46d07447910ffa3aa", + "totalKeys": 2108, + "translatedKeys": 2108, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/it.meta.json b/ui/src/i18n/.i18n/it.meta.json index 9fd0036f0de1..b64620a3f5f7 100644 --- a/ui/src/i18n/.i18n/it.meta.json +++ b/ui/src/i18n/.i18n/it.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-11T07:01:33.444Z", + "generatedAt": "2026-07-11T07:11:31.518Z", "locale": "it", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "ccbf12e11a31bead2f6ed101cfc7f3ef28fb6087d5d99194927b88776cfc7579", - "totalKeys": 2090, - "translatedKeys": 2090, + "sourceHash": "347c235f241ad329d8a934851cdc2038b00dfa1cbbdd6fe46d07447910ffa3aa", + "totalKeys": 2108, + "translatedKeys": 2108, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ja-JP.meta.json b/ui/src/i18n/.i18n/ja-JP.meta.json index ab4490ca98ed..b03f35f7f2e8 100644 --- a/ui/src/i18n/.i18n/ja-JP.meta.json +++ b/ui/src/i18n/.i18n/ja-JP.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-11T07:01:31.776Z", + "generatedAt": "2026-07-11T07:11:30.577Z", "locale": "ja-JP", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "ccbf12e11a31bead2f6ed101cfc7f3ef28fb6087d5d99194927b88776cfc7579", - "totalKeys": 2090, - "translatedKeys": 2090, + "sourceHash": "347c235f241ad329d8a934851cdc2038b00dfa1cbbdd6fe46d07447910ffa3aa", + "totalKeys": 2108, + "translatedKeys": 2108, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ko.meta.json b/ui/src/i18n/.i18n/ko.meta.json index 998044f89291..5ab6619a83ea 100644 --- a/ui/src/i18n/.i18n/ko.meta.json +++ b/ui/src/i18n/.i18n/ko.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-11T07:01:32.105Z", + "generatedAt": "2026-07-11T07:11:30.709Z", "locale": "ko", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "ccbf12e11a31bead2f6ed101cfc7f3ef28fb6087d5d99194927b88776cfc7579", - "totalKeys": 2090, - "translatedKeys": 2090, + "sourceHash": "347c235f241ad329d8a934851cdc2038b00dfa1cbbdd6fe46d07447910ffa3aa", + "totalKeys": 2108, + "translatedKeys": 2108, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/nl.meta.json b/ui/src/i18n/.i18n/nl.meta.json index eb6fa02495ab..68d8c4c397a7 100644 --- a/ui/src/i18n/.i18n/nl.meta.json +++ b/ui/src/i18n/.i18n/nl.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-11T07:01:35.967Z", + "generatedAt": "2026-07-11T07:11:32.680Z", "locale": "nl", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "ccbf12e11a31bead2f6ed101cfc7f3ef28fb6087d5d99194927b88776cfc7579", - "totalKeys": 2090, - "translatedKeys": 2090, + "sourceHash": "347c235f241ad329d8a934851cdc2038b00dfa1cbbdd6fe46d07447910ffa3aa", + "totalKeys": 2108, + "translatedKeys": 2108, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/pl.meta.json b/ui/src/i18n/.i18n/pl.meta.json index a22bd689c002..bd52a2777bdb 100644 --- a/ui/src/i18n/.i18n/pl.meta.json +++ b/ui/src/i18n/.i18n/pl.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-11T07:01:34.900Z", + "generatedAt": "2026-07-11T07:11:32.106Z", "locale": "pl", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "ccbf12e11a31bead2f6ed101cfc7f3ef28fb6087d5d99194927b88776cfc7579", - "totalKeys": 2090, - "translatedKeys": 2090, + "sourceHash": "347c235f241ad329d8a934851cdc2038b00dfa1cbbdd6fe46d07447910ffa3aa", + "totalKeys": 2108, + "translatedKeys": 2108, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/pt-BR.meta.json b/ui/src/i18n/.i18n/pt-BR.meta.json index 0fe5bec90e20..6ad08dfdcc23 100644 --- a/ui/src/i18n/.i18n/pt-BR.meta.json +++ b/ui/src/i18n/.i18n/pt-BR.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-11T07:01:30.947Z", + "generatedAt": "2026-07-11T07:11:30.155Z", "locale": "pt-BR", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "ccbf12e11a31bead2f6ed101cfc7f3ef28fb6087d5d99194927b88776cfc7579", - "totalKeys": 2090, - "translatedKeys": 2090, + "sourceHash": "347c235f241ad329d8a934851cdc2038b00dfa1cbbdd6fe46d07447910ffa3aa", + "totalKeys": 2108, + "translatedKeys": 2108, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ru.meta.json b/ui/src/i18n/.i18n/ru.meta.json index 4dc4526b3ea3..e78baff796b4 100644 --- a/ui/src/i18n/.i18n/ru.meta.json +++ b/ui/src/i18n/.i18n/ru.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-11T07:01:36.798Z", + "generatedAt": "2026-07-11T07:11:33.043Z", "locale": "ru", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "ccbf12e11a31bead2f6ed101cfc7f3ef28fb6087d5d99194927b88776cfc7579", - "totalKeys": 2090, - "translatedKeys": 2090, + "sourceHash": "347c235f241ad329d8a934851cdc2038b00dfa1cbbdd6fe46d07447910ffa3aa", + "totalKeys": 2108, + "translatedKeys": 2108, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/th.meta.json b/ui/src/i18n/.i18n/th.meta.json index 586f851b1367..f33915e48e67 100644 --- a/ui/src/i18n/.i18n/th.meta.json +++ b/ui/src/i18n/.i18n/th.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-11T07:01:35.157Z", + "generatedAt": "2026-07-11T07:11:32.328Z", "locale": "th", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "ccbf12e11a31bead2f6ed101cfc7f3ef28fb6087d5d99194927b88776cfc7579", - "totalKeys": 2090, - "translatedKeys": 2090, + "sourceHash": "347c235f241ad329d8a934851cdc2038b00dfa1cbbdd6fe46d07447910ffa3aa", + "totalKeys": 2108, + "translatedKeys": 2108, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/tr.meta.json b/ui/src/i18n/.i18n/tr.meta.json index dd0ce748fdda..3a4069457ecf 100644 --- a/ui/src/i18n/.i18n/tr.meta.json +++ b/ui/src/i18n/.i18n/tr.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-11T07:01:33.864Z", + "generatedAt": "2026-07-11T07:11:31.648Z", "locale": "tr", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "ccbf12e11a31bead2f6ed101cfc7f3ef28fb6087d5d99194927b88776cfc7579", - "totalKeys": 2090, - "translatedKeys": 2090, + "sourceHash": "347c235f241ad329d8a934851cdc2038b00dfa1cbbdd6fe46d07447910ffa3aa", + "totalKeys": 2108, + "translatedKeys": 2108, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/uk.meta.json b/ui/src/i18n/.i18n/uk.meta.json index fac94c20af64..0ac78aef8815 100644 --- a/ui/src/i18n/.i18n/uk.meta.json +++ b/ui/src/i18n/.i18n/uk.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-11T07:01:34.318Z", + "generatedAt": "2026-07-11T07:11:31.843Z", "locale": "uk", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "ccbf12e11a31bead2f6ed101cfc7f3ef28fb6087d5d99194927b88776cfc7579", - "totalKeys": 2090, - "translatedKeys": 2090, + "sourceHash": "347c235f241ad329d8a934851cdc2038b00dfa1cbbdd6fe46d07447910ffa3aa", + "totalKeys": 2108, + "translatedKeys": 2108, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/vi.meta.json b/ui/src/i18n/.i18n/vi.meta.json index 7eeface3fbc5..1ec5126ae0c1 100644 --- a/ui/src/i18n/.i18n/vi.meta.json +++ b/ui/src/i18n/.i18n/vi.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-11T07:01:35.578Z", + "generatedAt": "2026-07-11T07:11:32.475Z", "locale": "vi", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "ccbf12e11a31bead2f6ed101cfc7f3ef28fb6087d5d99194927b88776cfc7579", - "totalKeys": 2090, - "translatedKeys": 2090, + "sourceHash": "347c235f241ad329d8a934851cdc2038b00dfa1cbbdd6fe46d07447910ffa3aa", + "totalKeys": 2108, + "translatedKeys": 2108, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/zh-CN.meta.json b/ui/src/i18n/.i18n/zh-CN.meta.json index 926d9bb3434d..a4a069cd5a2b 100644 --- a/ui/src/i18n/.i18n/zh-CN.meta.json +++ b/ui/src/i18n/.i18n/zh-CN.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-11T07:01:30.173Z", + "generatedAt": "2026-07-11T07:11:29.786Z", "locale": "zh-CN", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "ccbf12e11a31bead2f6ed101cfc7f3ef28fb6087d5d99194927b88776cfc7579", - "totalKeys": 2090, - "translatedKeys": 2090, + "sourceHash": "347c235f241ad329d8a934851cdc2038b00dfa1cbbdd6fe46d07447910ffa3aa", + "totalKeys": 2108, + "translatedKeys": 2108, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/zh-TW.meta.json b/ui/src/i18n/.i18n/zh-TW.meta.json index d35761c02654..5bbaefe0819a 100644 --- a/ui/src/i18n/.i18n/zh-TW.meta.json +++ b/ui/src/i18n/.i18n/zh-TW.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-11T07:01:30.718Z", + "generatedAt": "2026-07-11T07:11:30.012Z", "locale": "zh-TW", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "ccbf12e11a31bead2f6ed101cfc7f3ef28fb6087d5d99194927b88776cfc7579", - "totalKeys": 2090, - "translatedKeys": 2090, + "sourceHash": "347c235f241ad329d8a934851cdc2038b00dfa1cbbdd6fe46d07447910ffa3aa", + "totalKeys": 2108, + "translatedKeys": 2108, "workflow": 1 } diff --git a/ui/src/i18n/locales/ar.ts b/ui/src/i18n/locales/ar.ts index 444bf7647400..cebf9754af9c 100644 --- a/ui/src/i18n/locales/ar.ts +++ b/ui/src/i18n/locales/ar.ts @@ -934,7 +934,8 @@ export const ar: TranslationMap = { codexSessions: { eyebrow: "أسطول Codex", title: "الجلسات على جميع أجهزتك", - subtitle: "عرض للقراءة فقط لجلسات Codex على بوابة Gateway هذه وعلى كل كمبيوتر متصل يشاركها.", + interactiveSubtitle: + "ابدأ فرع Chat يبقى اختيار النموذج فيه تحت تحكم Codex App Server، وأرشف الجلسات المحلية المؤهلة بعد التأكيد، واعرض جلسات الكمبيوتر المقترنة.", summaryLabel: "ملخص جلسات Codex", summary: { sessions: "الجلسات", @@ -943,11 +944,6 @@ export const ar: TranslationMap = { }, searchLabel: "البحث في جلسات Codex", searchPlaceholder: "البحث في عناوين الجلسات", - scopeLabel: "عامل تصفية أرشيف الجلسات", - scope: { - active: "نشطة", - archived: "مؤرشفة", - }, refresh: "تحديث", disconnected: "أعد الاتصال بـ Gateway لتحديث جلسات Codex.", partial: "عدد المضيفين غير المتاحين: {count}. لا يزال بقية المضيفين متاحين.", @@ -956,11 +952,33 @@ export const ar: TranslationMap = { loadingMore: "جارٍ التحميل…", untitled: "جلسة Codex بلا عنوان", threadId: "السلسلة", + actions: { + continue: "متابعة", + continueAsBranch: "متابعة كفرع", + openChat: "فتح Chat", + continuing: "جارٍ المتابعة…", + archive: "أرشفة", + continueLabel: "متابعة {title}", + continueAsBranchLabel: "متابعة {title} كفرع", + openChatLabel: "فتح Chat لـ {title}", + continueAsBranchHint: + "أنشئ Chat من السجل المرئي المحفوظ. عند رسالتك الأولى، يختار Codex App Server النموذج والمزوّد لسلسلة harness الجديدة. يظل الاختيار لاحقًا تحت تحكم Codex؛ ولا يستبدل OpenClaw أبدًا ببيئة تشغيل أو نموذج أو خيار احتياطي آخر. يظل المصدر دون تغيير، وقد لا يكون العمل الجاري موجودًا.", + archiveLabel: "أرشفة {title}", + archiveConfirmation: + "هل تريد أرشفة {title} وأي عناصر فرعية منشأة منه؟ أكّد أنه لا يوجد أي عميل Codex آخر أو مشغّل OpenClaw يستخدمها. قد تؤدي الأرشفة أثناء نشاط مشغّل آخر إلى مقاطعة عمله.", + active: "لا يمكن للجلسات النشطة بدء فرع أو أرشفتها.", + archiveActivityUnknownHint: + "النشاط غير معروف لأن الحالة محلية للعملية. لا تقم بالأرشفة إلا بعد التأكد من عدم وجود أي عميل Codex آخر أو مشغّل يستخدم هذه الجلسة.", + statusUnavailable: "لا يمكن إدارة جلسة Codex هذه في حالتها الحالية.", + hostOffline: "أعِد توصيل هذا الكمبيوتر قبل إدارة جلسات Codex الخاصة به.", + gatewayOffline: "أعِد الاتصال بـ gateway قبل إدارة جلسات Codex.", + remoteReadOnly: "جلسات الكمبيوتر المقترن للعرض فقط في الوقت الحالي.", + }, status: { active: "نشط", idle: "خامل", archived: "مؤرشف", - notLoaded: "مخزّن", + storedActivityUnknown: "محفوظ / النشاط غير معروف", systemError: "خطأ في النظام", unknown: "غير معروف", }, @@ -974,10 +992,9 @@ export const ar: TranslationMap = { }, empty: { title: "لم يتم العثور على أي مضيف لـ Codex", - subtitle: "فعّل مشاركة جلسات Codex على Gateway أو كمبيوتر مقترن، ثم حدّث هذا العرض.", + supervisionSubtitle: "فعّل إشراف Codex على Gateway أو على كمبيوتر مقترن، ثم حدّث هذا العرض.", search: "لا توجد جلسات على هذا المضيف تطابق بحثك.", - active: "لا توجد جلسات نشطة على هذا المضيف.", - archived: "لا توجد جلسات مؤرشفة على هذا المضيف.", + nonArchived: "لا توجد جلسات غير مؤرشفة على هذا المضيف.", }, }, logbook: { @@ -2073,6 +2090,11 @@ export const ar: TranslationMap = { clearSessionSearch: "مسح البحث في الجلسات", loadMoreSessions: "تحميل المزيد من الجلسات", model: "Chat model", + modelSection: "النموذج", + modelLocked: "مقفَل", + modelLockedLabel: "اختيار النموذج متحكَّم به لهذه الجلسة", + lockedSessionModel: "نموذج الجلسة", + nativeCodexModel: "النموذج المتحكَّم به من Codex", thinkingLevel: "Chat thinking level", }, toolCards: { diff --git a/ui/src/i18n/locales/de.ts b/ui/src/i18n/locales/de.ts index 2bbd08ad2e49..3dc92a07af82 100644 --- a/ui/src/i18n/locales/de.ts +++ b/ui/src/i18n/locales/de.ts @@ -954,8 +954,8 @@ export const de: TranslationMap = { codexSessions: { eyebrow: "Codex-Flotte", title: "Sitzungen auf allen deinen Computern", - subtitle: - "Eine schreibgeschützte Ansicht der Codex-Sitzungen auf diesem Gateway und allen verbundenen Computern, die sie freigeben.", + interactiveSubtitle: + "Starten Sie einen Chat-Branch, dessen Modellauswahl unter der Kontrolle des Codex App Server bleibt, archivieren Sie berechtigte lokale Sitzungen nach Bestätigung und zeigen Sie Sitzungen gekoppelter Computer an.", summaryLabel: "Zusammenfassung der Codex-Sitzungen", summary: { sessions: "Sitzungen", @@ -964,11 +964,6 @@ export const de: TranslationMap = { }, searchLabel: "Codex-Sitzungen durchsuchen", searchPlaceholder: "Sitzungstitel durchsuchen", - scopeLabel: "Archivfilter für Sitzungen", - scope: { - active: "Aktiv", - archived: "Archiviert", - }, refresh: "Aktualisieren", disconnected: "Verbinde dich erneut mit dem Gateway, um die Codex-Sitzungen zu aktualisieren.", partial: "Nicht verfügbare Hosts: {count}. Andere Hosts bleiben verfügbar.", @@ -977,11 +972,36 @@ export const de: TranslationMap = { loadingMore: "Wird geladen…", untitled: "Unbenannte Codex-Sitzung", threadId: "Thread", + actions: { + continue: "Fortfahren", + continueAsBranch: "Als Branch fortfahren", + openChat: "Chat öffnen", + continuing: "Wird fortgesetzt…", + archive: "Archivieren", + continueLabel: "{title} fortsetzen", + continueAsBranchLabel: "{title} als Branch fortsetzen", + openChatLabel: "Chat für {title} öffnen", + continueAsBranchHint: + "Erstellen Sie einen Chat aus dem gespeicherten sichtbaren Verlauf. Bei Ihrer ersten Nachricht wählt der Codex App Server das Modell und den Anbieter für den neuen Harness-Thread aus. Die spätere Auswahl bleibt Codex-gesteuert; OpenClaw ersetzt niemals eine andere Laufzeitumgebung, ein anderes Modell oder einen Fallback. Die Quelle bleibt unverändert, und laufende Arbeit ist möglicherweise nicht enthalten.", + archiveLabel: "{title} archivieren", + archiveConfirmation: + "{title} und alle erzeugten Nachfolger archivieren? Bestätigen Sie, dass kein anderer Codex-Client oder OpenClaw-Runner sie verwendet. Das Archivieren, während ein anderer Runner aktiv ist, kann dessen Arbeit unterbrechen.", + active: "Aktive Sitzungen können keinen Branch starten und nicht archiviert werden.", + archiveActivityUnknownHint: + "Die Aktivität ist unbekannt, da der Status prozesslokal ist. Archivieren Sie erst, nachdem Sie bestätigt haben, dass kein anderer Codex-Client oder Runner diese Sitzung verwendet.", + statusUnavailable: + "Diese Codex-Sitzung kann in ihrem aktuellen Zustand nicht verwaltet werden.", + hostOffline: + "Verbinden Sie diesen Computer erneut, bevor Sie seine Codex-Sitzungen verwalten.", + gatewayOffline: + "Stellen Sie erneut eine Verbindung zum Gateway her, bevor Sie Codex-Sitzungen verwalten.", + remoteReadOnly: "Sitzungen gekoppelter Computer können vorerst nur angezeigt werden.", + }, status: { active: "Aktiv", idle: "Inaktiv", archived: "Archiviert", - notLoaded: "Gespeichert", + storedActivityUnknown: "Gespeichert / Aktivität unbekannt", systemError: "Systemfehler", unknown: "Unbekannt", }, @@ -995,11 +1015,10 @@ export const de: TranslationMap = { }, empty: { title: "Keine Codex-Hosts gefunden", - subtitle: - "Aktiviere die Freigabe von Codex-Sitzungen auf dem Gateway oder einem gekoppelten Computer und aktualisiere dann diese Ansicht.", + supervisionSubtitle: + "Aktiviere die Codex-Überwachung auf dem Gateway oder einem gekoppelten Computer und aktualisiere dann diese Ansicht.", search: "Keine Sitzungen auf diesem Host entsprechen deiner Suche.", - active: "Keine aktiven Sitzungen auf diesem Host.", - archived: "Keine archivierten Sitzungen auf diesem Host.", + nonArchived: "Keine nicht archivierten Sitzungen auf diesem Host.", }, }, logbook: { @@ -2122,6 +2141,11 @@ export const de: TranslationMap = { clearSessionSearch: "Sitzungssuche löschen", loadMoreSessions: "Weitere Sitzungen laden", model: "Chat model", + modelSection: "Modell", + modelLocked: "Gesperrt", + modelLockedLabel: "Modellauswahl für diese Sitzung gesteuert", + lockedSessionModel: "Sitzungsmodell", + nativeCodexModel: "Von Codex gesteuertes Modell", thinkingLevel: "Chat thinking level", }, toolCards: { diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 078cde63ba9e..95a562ab587c 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -936,8 +936,8 @@ export const en: TranslationMap = { codexSessions: { eyebrow: "Codex fleet", title: "Sessions across your computers", - subtitle: - "A read-only view of Codex sessions on this gateway and every connected computer that shares them.", + interactiveSubtitle: + "Start a Chat branch whose model selection stays under Codex App Server control, archive eligible local sessions with confirmation, and view paired-computer sessions.", summaryLabel: "Codex session summary", summary: { sessions: "sessions", @@ -946,11 +946,6 @@ export const en: TranslationMap = { }, searchLabel: "Search Codex sessions", searchPlaceholder: "Search session titles", - scopeLabel: "Session archive filter", - scope: { - active: "Active", - archived: "Archived", - }, refresh: "Refresh", disconnected: "Reconnect to the gateway to refresh Codex sessions.", partial: "Unavailable hosts: {count}. Other hosts remain available.", @@ -959,11 +954,33 @@ export const en: TranslationMap = { loadingMore: "Loading…", untitled: "Untitled Codex session", threadId: "Thread", + actions: { + continue: "Continue", + continueAsBranch: "Continue as branch", + openChat: "Open Chat", + continuing: "Continuing…", + archive: "Archive", + continueLabel: "Continue {title}", + continueAsBranchLabel: "Continue {title} as a branch", + openChatLabel: "Open Chat for {title}", + continueAsBranchHint: + "Create a Chat from persisted visible history. On your first message, Codex App Server selects the model and provider for the new harness thread. Later selection remains Codex-controlled; OpenClaw never substitutes another runtime, model, or fallback. The source remains untouched, and in-flight work may be absent.", + archiveLabel: "Archive {title}", + archiveConfirmation: + "Archive {title} and any spawned descendants? Confirm that no other Codex client or OpenClaw runner is using them. Archiving while another runner is active may interrupt its work.", + active: "Active sessions cannot start a branch or be archived.", + archiveActivityUnknownHint: + "Activity is unknown because status is process-local. Archive only after confirming that no other Codex client or runner is using this session.", + statusUnavailable: "This Codex session cannot be managed in its current state.", + hostOffline: "Reconnect this computer before managing its Codex sessions.", + gatewayOffline: "Reconnect to the gateway before managing Codex sessions.", + remoteReadOnly: "Paired-computer sessions are view-only for now.", + }, status: { active: "Active", idle: "Idle", archived: "Archived", - notLoaded: "Stored", + storedActivityUnknown: "Stored / activity unknown", systemError: "System error", unknown: "Unknown", }, @@ -977,11 +994,10 @@ export const en: TranslationMap = { }, empty: { title: "No Codex hosts found", - subtitle: - "Enable Codex session sharing on the gateway or a paired computer, then refresh this view.", + supervisionSubtitle: + "Enable Codex supervision on the gateway or a paired computer, then refresh this view.", search: "No sessions on this host match your search.", - active: "No active sessions on this host.", - archived: "No archived sessions on this host.", + nonArchived: "No non-archived sessions on this host.", }, }, logbook: { @@ -2084,6 +2100,11 @@ export const en: TranslationMap = { clearSessionSearch: "Clear session search", loadMoreSessions: "Load more sessions", model: "Chat model", + modelSection: "Model", + modelLocked: "Locked", + modelLockedLabel: "Model selection controlled for this session", + lockedSessionModel: "Session model", + nativeCodexModel: "Codex-controlled model", thinkingLevel: "Chat thinking level", }, toolCards: { diff --git a/ui/src/i18n/locales/es.ts b/ui/src/i18n/locales/es.ts index bac24696d6c8..3847b0a3de6d 100644 --- a/ui/src/i18n/locales/es.ts +++ b/ui/src/i18n/locales/es.ts @@ -945,8 +945,8 @@ export const es: TranslationMap = { codexSessions: { eyebrow: "Flota de Codex", title: "Sesiones en todos tus equipos", - subtitle: - "Una vista de solo lectura de las sesiones de Codex en este Gateway y en todos los equipos conectados que las comparten.", + interactiveSubtitle: + "Inicia una rama de Chat cuya selección de modelo permanece bajo el control de Codex App Server, archiva sesiones locales elegibles con confirmación y consulta sesiones de computadoras emparejadas.", summaryLabel: "Resumen de sesiones de Codex", summary: { sessions: "sesiones", @@ -955,11 +955,6 @@ export const es: TranslationMap = { }, searchLabel: "Buscar sesiones de Codex", searchPlaceholder: "Buscar títulos de sesiones", - scopeLabel: "Filtro de archivado de sesiones", - scope: { - active: "Activas", - archived: "Archivadas", - }, refresh: "Actualizar", disconnected: "Vuelve a conectarte al Gateway para actualizar las sesiones de Codex.", partial: "Hosts no disponibles: {count}. Los demás hosts siguen disponibles.", @@ -968,11 +963,33 @@ export const es: TranslationMap = { loadingMore: "Cargando…", untitled: "Sesión de Codex sin título", threadId: "Hilo", + actions: { + continue: "Continuar", + continueAsBranch: "Continuar como rama", + openChat: "Abrir Chat", + continuing: "Continuando…", + archive: "Archivar", + continueLabel: "Continuar {title}", + continueAsBranchLabel: "Continuar {title} como rama", + openChatLabel: "Abrir Chat para {title}", + continueAsBranchHint: + "Crea un Chat a partir del historial visible persistido. En tu primer mensaje, Codex App Server selecciona el modelo y el proveedor para el nuevo hilo del harness. La selección posterior permanece controlada por Codex; OpenClaw nunca sustituye otro runtime, modelo ni fallback. La fuente permanece intacta y el trabajo en curso puede estar ausente.", + archiveLabel: "Archivar {title}", + archiveConfirmation: + "¿Archivar {title} y cualquier descendiente generado? Confirma que ningún otro cliente de Codex ni runner de OpenClaw los esté usando. Archivar mientras otro runner está activo puede interrumpir su trabajo.", + active: "Las sesiones activas no pueden iniciar una rama ni archivarse.", + archiveActivityUnknownHint: + "La actividad es desconocida porque el estado es local al proceso. Archiva solo después de confirmar que ningún otro cliente de Codex ni runner esté usando esta sesión.", + statusUnavailable: "Esta sesión de Codex no se puede gestionar en su estado actual.", + hostOffline: "Vuelve a conectar esta computadora antes de gestionar sus sesiones de Codex.", + gatewayOffline: "Vuelve a conectarte al gateway antes de gestionar las sesiones de Codex.", + remoteReadOnly: "Las sesiones de computadoras emparejadas son de solo lectura por ahora.", + }, status: { active: "Activa", idle: "Inactiva", archived: "Archivada", - notLoaded: "Almacenada", + storedActivityUnknown: "Almacenada / actividad desconocida", systemError: "Error del sistema", unknown: "Desconocido", }, @@ -986,11 +1003,10 @@ export const es: TranslationMap = { }, empty: { title: "No se encontraron hosts de Codex", - subtitle: - "Activa el uso compartido de sesiones de Codex en el Gateway o en un equipo emparejado y actualiza esta vista.", + supervisionSubtitle: + "Activa la supervisión de Codex en el gateway o en un ordenador emparejado y, a continuación, actualiza esta vista.", search: "Ninguna sesión de este host coincide con tu búsqueda.", - active: "No hay sesiones activas en este host.", - archived: "No hay sesiones archivadas en este host.", + nonArchived: "No hay sesiones no archivadas en este host.", }, }, logbook: { @@ -2115,6 +2131,11 @@ export const es: TranslationMap = { clearSessionSearch: "Borrar búsqueda de sesiones", loadMoreSessions: "Cargar más sesiones", model: "Chat model", + modelSection: "Modelo", + modelLocked: "Bloqueado", + modelLockedLabel: "Selección de modelo controlada para esta sesión", + lockedSessionModel: "Modelo de sesión", + nativeCodexModel: "Modelo controlado por Codex", thinkingLevel: "Chat thinking level", }, toolCards: { diff --git a/ui/src/i18n/locales/fa.ts b/ui/src/i18n/locales/fa.ts index c6a7faafcc55..00b547593d48 100644 --- a/ui/src/i18n/locales/fa.ts +++ b/ui/src/i18n/locales/fa.ts @@ -938,8 +938,8 @@ export const fa: TranslationMap = { codexSessions: { eyebrow: "ناوگان Codex", title: "نشست‌ها در همهٔ رایانه‌های شما", - subtitle: - "نمایی فقط‌خواندنی از نشست‌های Codex روی این Gateway و همهٔ رایانه‌های متصلی که آن‌ها را به اشتراک می‌گذارند.", + interactiveSubtitle: + "یک شاخهٔ Chat را شروع کنید که انتخاب مدل آن تحت کنترل Codex App Server باقی می‌ماند، نشست‌های محلی واجد شرایط را با تأیید بایگانی کنید، و نشست‌های رایانهٔ جفت‌شده را مشاهده کنید.", summaryLabel: "خلاصهٔ نشست‌های Codex", summary: { sessions: "نشست‌ها", @@ -948,11 +948,6 @@ export const fa: TranslationMap = { }, searchLabel: "جست‌وجوی نشست‌های Codex", searchPlaceholder: "جست‌وجوی عنوان نشست‌ها", - scopeLabel: "فیلتر بایگانی نشست‌ها", - scope: { - active: "فعال", - archived: "بایگانی‌شده", - }, refresh: "تازه‌سازی", disconnected: "برای تازه‌سازی نشست‌های Codex دوباره به Gateway متصل شوید.", partial: "میزبان‌های غیرقابل‌دسترسی: {count}. سایر میزبان‌ها همچنان در دسترس‌اند.", @@ -961,11 +956,33 @@ export const fa: TranslationMap = { loadingMore: "در حال بارگیری…", untitled: "نشست بی‌عنوانِ Codex", threadId: "رشته", + actions: { + continue: "ادامه", + continueAsBranch: "ادامه به‌عنوان شاخه", + openChat: "باز کردن Chat", + continuing: "در حال ادامه…", + archive: "بایگانی", + continueLabel: "ادامهٔ {title}", + continueAsBranchLabel: "ادامهٔ {title} به‌عنوان شاخه", + openChatLabel: "باز کردن Chat برای {title}", + continueAsBranchHint: + "از تاریخچهٔ قابل‌مشاهدهٔ ذخیره‌شده یک Chat ایجاد کنید. در نخستین پیام شما، Codex App Server مدل و ارائه‌دهنده را برای رشتهٔ harness جدید انتخاب می‌کند. انتخاب‌های بعدی همچنان تحت کنترل Codex باقی می‌ماند؛ OpenClaw هرگز runtime، مدل، یا fallback دیگری را جایگزین نمی‌کند. منبع بدون تغییر باقی می‌ماند، و ممکن است کارهای در حال انجام وجود نداشته باشند.", + archiveLabel: "بایگانی {title}", + archiveConfirmation: + "{title} و هر نوادهٔ ایجادشده از آن را بایگانی کنید؟ تأیید کنید که هیچ کلاینت Codex یا اجراکنندهٔ OpenClaw دیگری از آن‌ها استفاده نمی‌کند. بایگانی کردن در حالی که اجراکنندهٔ دیگری فعال است ممکن است کار آن را مختل کند.", + active: "نشست‌های فعال نمی‌توانند شاخه‌ای را شروع کنند یا بایگانی شوند.", + archiveActivityUnknownHint: + "وضعیت فعالیت نامشخص است، زیرا وضعیت محدود به فرایند محلی است. فقط پس از تأیید اینکه هیچ کلاینت Codex یا اجراکنندهٔ دیگری از این نشست استفاده نمی‌کند، آن را بایگانی کنید.", + statusUnavailable: "این نشست Codex در وضعیت فعلی خود قابل مدیریت نیست.", + hostOffline: "پیش از مدیریت نشست‌های Codex این رایانه، آن را دوباره متصل کنید.", + gatewayOffline: "پیش از مدیریت نشست‌های Codex، دوباره به gateway متصل شوید.", + remoteReadOnly: "نشست‌های رایانهٔ جفت‌شده فعلاً فقط قابل مشاهده هستند.", + }, status: { active: "فعال", idle: "بی‌کار", archived: "بایگانی‌شده", - notLoaded: "ذخیره‌شده", + storedActivityUnknown: "ذخیره‌شده / فعالیت نامشخص", systemError: "خطای سیستم", unknown: "نامشخص", }, @@ -979,11 +996,10 @@ export const fa: TranslationMap = { }, empty: { title: "هیچ میزبان Codex پیدا نشد", - subtitle: - "اشتراک‌گذاری نشست‌های Codex را روی Gateway یا یک رایانهٔ جفت‌شده فعال کنید، سپس این نما را تازه‌سازی کنید.", + supervisionSubtitle: + "نظارت Codex را روی gateway یا یک رایانه جفت‌شده فعال کنید، سپس این نما را بازآوری کنید.", search: "هیچ نشستی روی این میزبان با جست‌وجوی شما مطابقت ندارد.", - active: "هیچ نشست فعالی روی این میزبان وجود ندارد.", - archived: "هیچ نشست بایگانی‌شده‌ای روی این میزبان وجود ندارد.", + nonArchived: "هیچ نشست بایگانی‌نشده‌ای روی این میزبان وجود ندارد.", }, }, logbook: { @@ -2095,6 +2111,11 @@ export const fa: TranslationMap = { clearSessionSearch: "پاک کردن جستجوی نشست", loadMoreSessions: "بارگذاری نشست‌های بیشتر", model: "Chat model", + modelSection: "مدل", + modelLocked: "قفل‌شده", + modelLockedLabel: "انتخاب مدل برای این نشست کنترل‌شده است", + lockedSessionModel: "مدل جلسه", + nativeCodexModel: "مدل کنترل‌شده توسط Codex", thinkingLevel: "Chat thinking level", }, toolCards: { diff --git a/ui/src/i18n/locales/fr.ts b/ui/src/i18n/locales/fr.ts index 81ce7a3e5dd5..28eb67fbca44 100644 --- a/ui/src/i18n/locales/fr.ts +++ b/ui/src/i18n/locales/fr.ts @@ -958,8 +958,8 @@ export const fr: TranslationMap = { codexSessions: { eyebrow: "Parc Codex", title: "Sessions sur tous vos ordinateurs", - subtitle: - "Vue en lecture seule des sessions Codex sur ce Gateway et chaque ordinateur connecté qui les partage.", + interactiveSubtitle: + "Démarrez une branche de Chat dont la sélection du modèle reste sous le contrôle de Codex App Server, archivez les sessions locales éligibles avec confirmation et consultez les sessions d’ordinateurs appairés.", summaryLabel: "Résumé des sessions Codex", summary: { sessions: "sessions", @@ -968,11 +968,6 @@ export const fr: TranslationMap = { }, searchLabel: "Rechercher des sessions Codex", searchPlaceholder: "Rechercher dans les titres de session", - scopeLabel: "Filtre d’archivage des sessions", - scope: { - active: "Actives", - archived: "Archivées", - }, refresh: "Actualiser", disconnected: "Reconnectez-vous au Gateway pour actualiser les sessions Codex.", partial: "Hôtes indisponibles : {count}. Les autres hôtes restent disponibles.", @@ -981,11 +976,33 @@ export const fr: TranslationMap = { loadingMore: "Chargement…", untitled: "Session Codex sans titre", threadId: "Fil", + actions: { + continue: "Continuer", + continueAsBranch: "Continuer en tant que branche", + openChat: "Ouvrir Chat", + continuing: "Continuation…", + archive: "Archiver", + continueLabel: "Continuer {title}", + continueAsBranchLabel: "Continuer {title} en tant que branche", + openChatLabel: "Ouvrir Chat pour {title}", + continueAsBranchHint: + "Créez un Chat à partir de l’historique visible persistant. Lors de votre premier message, Codex App Server sélectionne le modèle et le fournisseur pour le nouveau fil de harness. La sélection ultérieure reste contrôlée par Codex ; OpenClaw ne substitue jamais un autre runtime, modèle ou fallback. La source reste inchangée, et le travail en cours peut être absent.", + archiveLabel: "Archiver {title}", + archiveConfirmation: + "Archiver {title} et tous les descendants générés ? Confirmez qu’aucun autre client Codex ni runner OpenClaw ne les utilise. L’archivage pendant qu’un autre runner est actif peut interrompre son travail.", + active: "Les sessions actives ne peuvent pas démarrer une branche ni être archivées.", + archiveActivityUnknownHint: + "L’activité est inconnue, car le statut est local au processus. Archivez uniquement après avoir confirmé qu’aucun autre client Codex ni runner n’utilise cette session.", + statusUnavailable: "Cette session Codex ne peut pas être gérée dans son état actuel.", + hostOffline: "Reconnectez cet ordinateur avant de gérer ses sessions Codex.", + gatewayOffline: "Reconnectez-vous au gateway avant de gérer les sessions Codex.", + remoteReadOnly: "Les sessions d’ordinateurs jumelés sont en lecture seule pour le moment.", + }, status: { active: "Actives", idle: "Inactive", archived: "Archivée", - notLoaded: "Stockée", + storedActivityUnknown: "Stockée / activité inconnue", systemError: "Erreur système", unknown: "Inconnu", }, @@ -999,11 +1016,10 @@ export const fr: TranslationMap = { }, empty: { title: "Aucun hôte Codex trouvé", - subtitle: - "Activez le partage des sessions Codex sur le Gateway ou un ordinateur jumelé, puis actualisez cette vue.", + supervisionSubtitle: + "Activez la supervision Codex sur le gateway ou un ordinateur jumelé, puis actualisez cette vue.", search: "Aucune session de cet hôte ne correspond à votre recherche.", - active: "Aucune session active sur cet hôte.", - archived: "Aucune session archivée sur cet hôte.", + nonArchived: "Aucune session non archivée sur cet hôte.", }, }, logbook: { @@ -2133,6 +2149,11 @@ export const fr: TranslationMap = { clearSessionSearch: "Effacer la recherche de sessions", loadMoreSessions: "Charger plus de sessions", model: "Chat model", + modelSection: "Modèle", + modelLocked: "Verrouillé", + modelLockedLabel: "Sélection du modèle contrôlée pour cette session", + lockedSessionModel: "Modèle de session", + nativeCodexModel: "Modèle contrôlé par Codex", thinkingLevel: "Chat thinking level", }, toolCards: { diff --git a/ui/src/i18n/locales/hi.ts b/ui/src/i18n/locales/hi.ts index 38a310522f16..baa332426d8e 100644 --- a/ui/src/i18n/locales/hi.ts +++ b/ui/src/i18n/locales/hi.ts @@ -932,7 +932,8 @@ export const hi: TranslationMap = { codexSessions: { eyebrow: "Codex फ़्लीट", title: "आपके सभी कंप्यूटरों के सेशन", - subtitle: "इस Gateway और सेशन शेयर करने वाले हर कनेक्टेड कंप्यूटर के Codex सेशन का केवल-पढ़ने योग्य व्यू।", + interactiveSubtitle: + "एक Chat शाखा शुरू करें जिसका मॉडल चयन Codex App Server के नियंत्रण में रहे, पुष्टि के साथ पात्र स्थानीय सत्रों को आर्काइव करें, और paired-computer सत्र देखें।", summaryLabel: "Codex सेशन सारांश", summary: { sessions: "सेशन", @@ -941,11 +942,6 @@ export const hi: TranslationMap = { }, searchLabel: "Codex सेशन खोजें", searchPlaceholder: "सेशन के शीर्षक खोजें", - scopeLabel: "सेशन आर्काइव फ़िल्टर", - scope: { - active: "सक्रिय", - archived: "आर्काइव किया गया", - }, refresh: "रिफ्रेश करें", disconnected: "Codex सेशन रीफ़्रेश करने के लिए Gateway से फिर से कनेक्ट करें।", partial: "अनुपलब्ध होस्ट: {count}। अन्य होस्ट उपलब्ध हैं।", @@ -954,11 +950,33 @@ export const hi: TranslationMap = { loadingMore: "लोड हो रहा है…", untitled: "बिना शीर्षक वाला Codex सेशन", threadId: "थ्रेड", + actions: { + continue: "जारी रखें", + continueAsBranch: "शाखा के रूप में जारी रखें", + openChat: "Chat खोलें", + continuing: "जारी है…", + archive: "आर्काइव करें", + continueLabel: "{title} जारी रखें", + continueAsBranchLabel: "{title} को शाखा के रूप में जारी रखें", + openChatLabel: "{title} के लिए Chat खोलें", + continueAsBranchHint: + "सहेजे गए दृश्यमान इतिहास से एक Chat बनाएँ। आपके पहले संदेश पर, Codex App Server नए harness thread के लिए मॉडल और provider चुनता है। बाद का चयन Codex-नियंत्रित रहता है; OpenClaw कभी भी कोई अन्य runtime, model, या fallback प्रतिस्थापित नहीं करता। स्रोत अपरिवर्तित रहता है, और जारी काम अनुपस्थित हो सकता है।", + archiveLabel: "{title} आर्काइव करें", + archiveConfirmation: + "{title} और उससे बने सभी descendants को आर्काइव करें? पुष्टि करें कि कोई अन्य Codex client या OpenClaw runner उनका उपयोग नहीं कर रहा है। किसी अन्य runner के सक्रिय रहते हुए आर्काइव करने से उसका काम बाधित हो सकता है।", + active: "सक्रिय सत्र शाखा शुरू नहीं कर सकते या आर्काइव नहीं किए जा सकते।", + archiveActivityUnknownHint: + "गतिविधि अज्ञात है क्योंकि स्थिति process-local है। इस सत्र का उपयोग कोई अन्य Codex client या runner नहीं कर रहा है, इसकी पुष्टि करने के बाद ही आर्काइव करें।", + statusUnavailable: "इस Codex सत्र को इसकी वर्तमान स्थिति में प्रबंधित नहीं किया जा सकता।", + hostOffline: "इसके Codex सत्रों को प्रबंधित करने से पहले इस कंप्यूटर को फिर से कनेक्ट करें।", + gatewayOffline: "Codex सत्रों को प्रबंधित करने से पहले gateway से फिर से कनेक्ट करें।", + remoteReadOnly: "Paired-computer सत्र अभी के लिए केवल देखने योग्य हैं।", + }, status: { active: "सक्रिय", idle: "निष्क्रिय", archived: "आर्काइव किया गया", - notLoaded: "संग्रहीत", + storedActivityUnknown: "संग्रहित / गतिविधि अज्ञात", systemError: "सिस्टम त्रुटि", unknown: "अज्ञात", }, @@ -972,11 +990,10 @@ export const hi: TranslationMap = { }, empty: { title: "कोई Codex होस्ट नहीं मिला", - subtitle: - "Gateway या किसी पेयर किए गए कंप्यूटर पर Codex सेशन शेयरिंग चालू करें, फिर इस व्यू को रीफ़्रेश करें।", + supervisionSubtitle: + "gateway या जोड़े गए कंप्यूटर पर Codex supervision सक्षम करें, फिर इस दृश्य को रीफ़्रेश करें।", search: "इस होस्ट पर कोई सेशन आपकी खोज से मेल नहीं खाता।", - active: "इस होस्ट पर कोई सक्रिय सेशन नहीं है।", - archived: "इस होस्ट पर कोई आर्काइव किया गया सेशन नहीं है।", + nonArchived: "इस होस्ट पर कोई गैर-आर्काइव किए गए सेशन नहीं हैं।", }, }, logbook: { @@ -2073,6 +2090,11 @@ export const hi: TranslationMap = { clearSessionSearch: "सत्र खोज साफ़ करें", loadMoreSessions: "और सत्र लोड करें", model: "चैट मॉडल", + modelSection: "मॉडल", + modelLocked: "लॉक किया गया", + modelLockedLabel: "इस सत्र के लिए मॉडल चयन नियंत्रित है", + lockedSessionModel: "सत्र मॉडल", + nativeCodexModel: "Codex-नियंत्रित मॉडल", thinkingLevel: "चैट सोच स्तर", }, toolCards: { diff --git a/ui/src/i18n/locales/id.ts b/ui/src/i18n/locales/id.ts index 96efbbb44bbc..a0a58b3dbd38 100644 --- a/ui/src/i18n/locales/id.ts +++ b/ui/src/i18n/locales/id.ts @@ -942,8 +942,8 @@ export const id: TranslationMap = { codexSessions: { eyebrow: "Armada Codex", title: "Sesi di semua komputer Anda", - subtitle: - "Tampilan hanya baca untuk sesi Codex di Gateway ini dan semua komputer terhubung yang membagikan sesi tersebut.", + interactiveSubtitle: + "Mulai cabang Chat yang pemilihan modelnya tetap di bawah kendali Codex App Server, arsipkan sesi lokal yang memenuhi syarat dengan konfirmasi, dan lihat sesi komputer yang dipasangkan.", summaryLabel: "Ringkasan sesi Codex", summary: { sessions: "sesi", @@ -952,11 +952,6 @@ export const id: TranslationMap = { }, searchLabel: "Cari sesi Codex", searchPlaceholder: "Cari judul sesi", - scopeLabel: "Filter arsip sesi", - scope: { - active: "Aktif", - archived: "Diarsipkan", - }, refresh: "Muat ulang", disconnected: "Hubungkan kembali ke Gateway untuk memuat ulang sesi Codex.", partial: "{count} host tidak tersedia. Host lainnya tetap tersedia.", @@ -965,11 +960,33 @@ export const id: TranslationMap = { loadingMore: "Memuat…", untitled: "Sesi Codex tanpa judul", threadId: "Thread", + actions: { + continue: "Lanjutkan", + continueAsBranch: "Lanjutkan sebagai cabang", + openChat: "Buka Chat", + continuing: "Melanjutkan…", + archive: "Arsipkan", + continueLabel: "Lanjutkan {title}", + continueAsBranchLabel: "Lanjutkan {title} sebagai cabang", + openChatLabel: "Buka Chat untuk {title}", + continueAsBranchHint: + "Buat Chat dari riwayat terlihat yang tersimpan. Pada pesan pertama Anda, Codex App Server memilih model dan penyedia untuk thread harness baru. Pemilihan berikutnya tetap dikendalikan Codex; OpenClaw tidak pernah mengganti dengan runtime, model, atau fallback lain. Sumber tetap tidak berubah, dan pekerjaan yang sedang berjalan mungkin tidak ada.", + archiveLabel: "Arsipkan {title}", + archiveConfirmation: + "Arsipkan {title} dan semua turunan yang dihasilkan? Konfirmasikan bahwa tidak ada klien Codex atau runner OpenClaw lain yang menggunakannya. Mengarsipkan saat runner lain aktif dapat mengganggu pekerjaannya.", + active: "Sesi aktif tidak dapat memulai cabang atau diarsipkan.", + archiveActivityUnknownHint: + "Aktivitas tidak diketahui karena status bersifat lokal pada proses. Arsipkan hanya setelah mengonfirmasi bahwa tidak ada klien Codex atau runner lain yang menggunakan sesi ini.", + statusUnavailable: "Sesi Codex ini tidak dapat dikelola dalam statusnya saat ini.", + hostOffline: "Hubungkan kembali komputer ini sebelum mengelola sesi Codex-nya.", + gatewayOffline: "Hubungkan kembali ke gateway sebelum mengelola sesi Codex.", + remoteReadOnly: "Sesi komputer yang dipasangkan saat ini hanya dapat dilihat.", + }, status: { active: "Aktif", idle: "Siaga", archived: "Diarsipkan", - notLoaded: "Tersimpan", + storedActivityUnknown: "Tersimpan / aktivitas tidak diketahui", systemError: "Kesalahan sistem", unknown: "Tidak diketahui", }, @@ -983,11 +1000,10 @@ export const id: TranslationMap = { }, empty: { title: "Tidak ditemukan host Codex", - subtitle: - "Aktifkan berbagi sesi Codex di Gateway atau komputer yang dipasangkan, lalu muat ulang tampilan ini.", + supervisionSubtitle: + "Aktifkan supervisi Codex di gateway atau komputer yang dipasangkan, lalu segarkan tampilan ini.", search: "Tidak ada sesi di host ini yang cocok dengan pencarian Anda.", - active: "Tidak ada sesi aktif di host ini.", - archived: "Tidak ada sesi yang diarsipkan di host ini.", + nonArchived: "Tidak ada sesi non-arsip di host ini.", }, }, logbook: { @@ -2100,6 +2116,11 @@ export const id: TranslationMap = { clearSessionSearch: "Hapus pencarian sesi", loadMoreSessions: "Muat sesi lainnya", model: "Chat model", + modelSection: "Model", + modelLocked: "Terkunci", + modelLockedLabel: "Pemilihan model dikendalikan untuk sesi ini", + lockedSessionModel: "Model sesi", + nativeCodexModel: "Model yang dikontrol Codex", thinkingLevel: "Chat thinking level", }, toolCards: { diff --git a/ui/src/i18n/locales/it.ts b/ui/src/i18n/locales/it.ts index 83930e8afdf3..ca8c7587e181 100644 --- a/ui/src/i18n/locales/it.ts +++ b/ui/src/i18n/locales/it.ts @@ -952,8 +952,8 @@ export const it: TranslationMap = { codexSessions: { eyebrow: "Flotta Codex", title: "Sessioni su tutti i tuoi computer", - subtitle: - "Una vista di sola lettura delle sessioni Codex su questo Gateway e su ogni computer connesso che le condivide.", + interactiveSubtitle: + "Avvia un ramo Chat la cui selezione del modello rimane sotto il controllo di Codex App Server, archivia le sessioni locali idonee con conferma e visualizza le sessioni dei computer associati.", summaryLabel: "Riepilogo delle sessioni Codex", summary: { sessions: "sessioni", @@ -962,11 +962,6 @@ export const it: TranslationMap = { }, searchLabel: "Cerca sessioni Codex", searchPlaceholder: "Cerca nei titoli delle sessioni", - scopeLabel: "Filtro di archiviazione delle sessioni", - scope: { - active: "Attive", - archived: "Archiviate", - }, refresh: "Aggiorna", disconnected: "Riconnettiti al Gateway per aggiornare le sessioni Codex.", partial: "Host non disponibili: {count}. Gli altri host restano disponibili.", @@ -975,11 +970,33 @@ export const it: TranslationMap = { loadingMore: "Caricamento…", untitled: "Sessione Codex senza titolo", threadId: "Thread", + actions: { + continue: "Continua", + continueAsBranch: "Continua come ramo", + openChat: "Apri Chat", + continuing: "Continuazione…", + archive: "Archivia", + continueLabel: "Continua {title}", + continueAsBranchLabel: "Continua {title} come ramo", + openChatLabel: "Apri Chat per {title}", + continueAsBranchHint: + "Crea una Chat dalla cronologia visibile persistente. Al tuo primo messaggio, Codex App Server seleziona il modello e il provider per il nuovo thread harness. La selezione successiva rimane controllata da Codex; OpenClaw non sostituisce mai un altro runtime, modello o fallback. L'origine rimane invariata e il lavoro in corso potrebbe essere assente.", + archiveLabel: "Archivia {title}", + archiveConfirmation: + "Archiviare {title} e tutti i discendenti generati? Conferma che nessun altro client Codex o runner OpenClaw li stia utilizzando. L'archiviazione mentre un altro runner è attivo potrebbe interromperne il lavoro.", + active: "Le sessioni attive non possono avviare un ramo né essere archiviate.", + archiveActivityUnknownHint: + "L'attività è sconosciuta perché lo stato è locale al processo. Archivia solo dopo aver confermato che nessun altro client Codex o runner sta utilizzando questa sessione.", + statusUnavailable: "Questa sessione Codex non può essere gestita nel suo stato attuale.", + hostOffline: "Riconnetti questo computer prima di gestirne le sessioni Codex.", + gatewayOffline: "Riconnettiti al gateway prima di gestire le sessioni Codex.", + remoteReadOnly: "Le sessioni del computer associato sono per ora di sola visualizzazione.", + }, status: { active: "Attiva", idle: "Inattiva", archived: "Archiviata", - notLoaded: "Memorizzata", + storedActivityUnknown: "Salvata / attività sconosciuta", systemError: "Errore di sistema", unknown: "Sconosciuto", }, @@ -993,11 +1010,10 @@ export const it: TranslationMap = { }, empty: { title: "Nessun host Codex trovato", - subtitle: - "Abilita la condivisione delle sessioni Codex sul Gateway o su un computer associato, quindi aggiorna questa vista.", + supervisionSubtitle: + "Abilita la supervisione Codex sul gateway o su un computer abbinato, quindi aggiorna questa vista.", search: "Nessuna sessione su questo host corrisponde alla ricerca.", - active: "Nessuna sessione attiva su questo host.", - archived: "Nessuna sessione archiviata su questo host.", + nonArchived: "Nessuna sessione non archiviata su questo host.", }, }, logbook: { @@ -2120,6 +2136,11 @@ export const it: TranslationMap = { clearSessionSearch: "Cancella ricerca sessioni", loadMoreSessions: "Carica altre sessioni", model: "Chat model", + modelSection: "Modello", + modelLocked: "Bloccato", + modelLockedLabel: "Selezione del modello controllata per questa sessione", + lockedSessionModel: "Modello della sessione", + nativeCodexModel: "Modello controllato da Codex", thinkingLevel: "Chat thinking level", }, toolCards: { diff --git a/ui/src/i18n/locales/ja-JP.ts b/ui/src/i18n/locales/ja-JP.ts index 16154806f1da..03c881536240 100644 --- a/ui/src/i18n/locales/ja-JP.ts +++ b/ui/src/i18n/locales/ja-JP.ts @@ -947,8 +947,8 @@ export const ja_JP: TranslationMap = { codexSessions: { eyebrow: "Codex フリート", title: "すべてのコンピューター上のセッション", - subtitle: - "この Gateway と、接続済みでセッション共有を有効にしているすべてのコンピューター上の Codex セッションを読み取り専用で表示します。", + interactiveSubtitle: + "モデル選択が Codex App Server の制御下にある Chat ブランチを開始し、対象となるローカルセッションを確認のうえアーカイブし、ペアリングされたコンピューターのセッションを表示します。", summaryLabel: "Codex セッションの概要", summary: { sessions: "セッション", @@ -957,11 +957,6 @@ export const ja_JP: TranslationMap = { }, searchLabel: "Codex セッションを検索", searchPlaceholder: "セッションタイトルを検索", - scopeLabel: "セッションのアーカイブフィルター", - scope: { - active: "アクティブ", - archived: "アーカイブ済み", - }, refresh: "更新", disconnected: "Codex セッションを更新するには、Gateway に再接続してください。", partial: "利用できないホストが {count} 台あります。他のホストは引き続き利用できます。", @@ -970,11 +965,33 @@ export const ja_JP: TranslationMap = { loadingMore: "読み込み中…", untitled: "無題の Codex セッション", threadId: "スレッド", + actions: { + continue: "続行", + continueAsBranch: "ブランチとして続行", + openChat: "Chat を開く", + continuing: "続行中…", + archive: "アーカイブ", + continueLabel: "{title} を続行", + continueAsBranchLabel: "{title} をブランチとして続行", + openChatLabel: "{title} の Chat を開く", + continueAsBranchHint: + "永続化された表示中の履歴から Chat を作成します。最初のメッセージで、Codex App Server が新しいハーネススレッドのモデルとプロバイダーを選択します。その後の選択も Codex の制御下に留まり、OpenClaw が別のランタイム、モデル、またはフォールバックに置き換えることはありません。ソースは変更されず、進行中の作業は含まれない場合があります。", + archiveLabel: "{title} をアーカイブ", + archiveConfirmation: + "{title} と、そこから生成されたすべての子孫をアーカイブしますか?他の Codex クライアントまたは OpenClaw ランナーがそれらを使用していないことを確認してください。別のランナーがアクティブな状態でアーカイブすると、その作業が中断される場合があります。", + active: "アクティブなセッションでは、ブランチを開始したりアーカイブしたりできません。", + archiveActivityUnknownHint: + "ステータスはプロセスローカルであるため、アクティビティは不明です。他の Codex クライアントまたはランナーがこのセッションを使用していないことを確認してからアーカイブしてください。", + statusUnavailable: "この Codex セッションは現在の状態では管理できません。", + hostOffline: "Codex セッションを管理する前に、このコンピューターに再接続してください。", + gatewayOffline: "Codex セッションを管理する前に、gateway に再接続してください。", + remoteReadOnly: "ペアリング済みコンピューターのセッションは、現時点では表示専用です。", + }, status: { active: "アクティブ", idle: "アイドル", archived: "アーカイブ済み", - notLoaded: "保存済み", + storedActivityUnknown: "保存済み / アクティビティ不明", systemError: "システムエラー", unknown: "不明", }, @@ -988,11 +1005,10 @@ export const ja_JP: TranslationMap = { }, empty: { title: "Codex ホストが見つかりません", - subtitle: - "Gateway またはペアリング済みのコンピューターで Codex セッション共有を有効にしてから、このビューを更新してください。", + supervisionSubtitle: + "gateway またはペアリング済みのコンピューターで Codex supervision を有効にしてから、このビューを更新してください。", search: "このホストに検索条件と一致するセッションはありません。", - active: "このホストにアクティブなセッションはありません。", - archived: "このホストにアーカイブ済みのセッションはありません。", + nonArchived: "このホストにはアーカイブされていないセッションがありません。", }, }, logbook: { @@ -2106,6 +2122,11 @@ export const ja_JP: TranslationMap = { clearSessionSearch: "セッション検索をクリア", loadMoreSessions: "さらにセッションを読み込む", model: "Chat model", + modelSection: "モデル", + modelLocked: "ロック済み", + modelLockedLabel: "このセッションではモデル選択が制御されています", + lockedSessionModel: "セッションモデル", + nativeCodexModel: "Codex 制御のモデル", thinkingLevel: "Chat thinking level", }, toolCards: { diff --git a/ui/src/i18n/locales/ko.ts b/ui/src/i18n/locales/ko.ts index 308a7f2ee0f8..09e3360eeeec 100644 --- a/ui/src/i18n/locales/ko.ts +++ b/ui/src/i18n/locales/ko.ts @@ -940,8 +940,8 @@ export const ko: TranslationMap = { codexSessions: { eyebrow: "Codex 호스트", title: "내 모든 컴퓨터의 세션", - subtitle: - "이 Gateway와 세션 공유를 활성화한 모든 연결된 컴퓨터의 Codex 세션을 읽기 전용으로 표시합니다.", + interactiveSubtitle: + "모델 선택이 Codex App Server 제어하에 유지되는 Chat 브랜치를 시작하고, 확인 후 보관 대상 로컬 세션을 보관하며, 페어링된 컴퓨터 세션을 봅니다.", summaryLabel: "Codex 세션 요약", summary: { sessions: "세션", @@ -950,11 +950,6 @@ export const ko: TranslationMap = { }, searchLabel: "Codex 세션 검색", searchPlaceholder: "세션 제목 검색", - scopeLabel: "세션 보관 상태 필터", - scope: { - active: "활성", - archived: "보관됨", - }, refresh: "새로고침", disconnected: "Codex 세션을 새로고침하려면 Gateway에 다시 연결하세요.", partial: "사용할 수 없는 호스트: {count}개. 다른 호스트는 계속 사용할 수 있습니다.", @@ -963,11 +958,33 @@ export const ko: TranslationMap = { loadingMore: "불러오는 중…", untitled: "제목 없는 Codex 세션", threadId: "스레드", + actions: { + continue: "계속", + continueAsBranch: "브랜치로 계속", + openChat: "Chat 열기", + continuing: "계속하는 중…", + archive: "보관", + continueLabel: "{title} 계속", + continueAsBranchLabel: "{title}을(를) 브랜치로 계속", + openChatLabel: "{title}의 Chat 열기", + continueAsBranchHint: + "지속 저장된 표시 기록에서 Chat을 만듭니다. 첫 메시지에서 Codex App Server가 새 하네스 스레드의 모델과 공급자를 선택합니다. 이후 선택은 Codex 제어하에 유지되며, OpenClaw는 다른 런타임, 모델 또는 폴백으로 대체하지 않습니다. 소스는 그대로 유지되며, 진행 중인 작업은 없을 수 있습니다.", + archiveLabel: "{title} 보관", + archiveConfirmation: + "{title} 및 생성된 모든 하위 항목을 보관하시겠습니까? 다른 Codex 클라이언트 또는 OpenClaw 러너가 이를 사용하고 있지 않은지 확인하세요. 다른 러너가 활성 상태일 때 보관하면 해당 작업이 중단될 수 있습니다.", + active: "활성 세션은 브랜치를 시작하거나 보관할 수 없습니다.", + archiveActivityUnknownHint: + "상태가 프로세스 로컬이므로 활동 여부를 알 수 없습니다. 다른 Codex 클라이언트 또는 러너가 이 세션을 사용하고 있지 않은지 확인한 후에만 보관하세요.", + statusUnavailable: "현재 상태에서는 이 Codex 세션을 관리할 수 없습니다.", + hostOffline: "Codex 세션을 관리하기 전에 이 컴퓨터를 다시 연결하세요.", + gatewayOffline: "Codex 세션을 관리하기 전에 gateway에 다시 연결하세요.", + remoteReadOnly: "페어링된 컴퓨터 세션은 현재 보기 전용입니다.", + }, status: { active: "활성", idle: "유휴", archived: "보관됨", - notLoaded: "저장됨", + storedActivityUnknown: "저장됨 / 활동 상태 알 수 없음", systemError: "시스템 오류", unknown: "알 수 없음", }, @@ -981,11 +998,10 @@ export const ko: TranslationMap = { }, empty: { title: "Codex 호스트가 없습니다", - subtitle: - "Gateway나 페어링된 컴퓨터에서 Codex 세션 공유를 활성화한 후 이 화면을 새로고침하세요.", + supervisionSubtitle: + "Gateway 또는 페어링된 컴퓨터에서 Codex supervision을 활성화한 다음 이 보기를 새로 고치세요.", search: "이 호스트에 검색어와 일치하는 세션이 없습니다.", - active: "이 호스트에 활성 세션이 없습니다.", - archived: "이 호스트에 보관된 세션이 없습니다.", + nonArchived: "이 호스트에 보관 처리되지 않은 세션이 없습니다.", }, }, logbook: { @@ -2091,6 +2107,11 @@ export const ko: TranslationMap = { clearSessionSearch: "세션 검색 지우기", loadMoreSessions: "세션 더 불러오기", model: "Chat model", + modelSection: "모델", + modelLocked: "잠김", + modelLockedLabel: "이 세션의 모델 선택이 제어됨", + lockedSessionModel: "세션 모델", + nativeCodexModel: "Codex 제어 모델", thinkingLevel: "Chat thinking level", }, toolCards: { diff --git a/ui/src/i18n/locales/nl.ts b/ui/src/i18n/locales/nl.ts index 49fd13b5a070..c50547eab6ab 100644 --- a/ui/src/i18n/locales/nl.ts +++ b/ui/src/i18n/locales/nl.ts @@ -946,8 +946,8 @@ export const nl: TranslationMap = { codexSessions: { eyebrow: "Codex-vloot", title: "Sessies op al je computers", - subtitle: - "Een alleen-lezen weergave van Codex-sessies op deze Gateway en elke verbonden computer die ze deelt.", + interactiveSubtitle: + "Start een Chat-branch waarvan de modelselectie onder controle van Codex App Server blijft, archiveer geschikte lokale sessies met bevestiging en bekijk sessies van gekoppelde computers.", summaryLabel: "Overzicht van Codex-sessies", summary: { sessions: "sessies", @@ -956,11 +956,6 @@ export const nl: TranslationMap = { }, searchLabel: "Codex-sessies zoeken", searchPlaceholder: "Sessietitels zoeken", - scopeLabel: "Archieffilter voor sessies", - scope: { - active: "Actief", - archived: "Gearchiveerd", - }, refresh: "Vernieuwen", disconnected: "Maak opnieuw verbinding met de Gateway om Codex-sessies te vernieuwen.", partial: "Onbeschikbare hosts: {count}. Andere hosts blijven beschikbaar.", @@ -969,11 +964,33 @@ export const nl: TranslationMap = { loadingMore: "Laden…", untitled: "Codex-sessie zonder titel", threadId: "Thread", + actions: { + continue: "Doorgaan", + continueAsBranch: "Doorgaan als branch", + openChat: "Chat openen", + continuing: "Bezig met doorgaan…", + archive: "Archiveren", + continueLabel: "Doorgaan met {title}", + continueAsBranchLabel: "Doorgaan met {title} als branch", + openChatLabel: "Chat openen voor {title}", + continueAsBranchHint: + "Maak een Chat aan vanuit blijvende zichtbare geschiedenis. Bij je eerste bericht selecteert Codex App Server het model en de provider voor de nieuwe harness-thread. Latere selectie blijft door Codex beheerd; OpenClaw vervangt nooit door een andere runtime, een ander model of een fallback. De bron blijft onaangetast en werk dat nog wordt uitgevoerd kan ontbreken.", + archiveLabel: "{title} archiveren", + archiveConfirmation: + "{title} en eventuele voortgekomen onderliggende sessies archiveren? Bevestig dat geen andere Codex-client of OpenClaw-runner deze gebruikt. Archiveren terwijl een andere runner actief is, kan diens werk onderbreken.", + active: "Actieve sessies kunnen geen branch starten en kunnen niet worden gearchiveerd.", + archiveActivityUnknownHint: + "Activiteit is onbekend omdat de status proceslokaal is. Archiveer alleen nadat je hebt bevestigd dat geen andere Codex-client of runner deze sessie gebruikt.", + statusUnavailable: "Deze Codex-sessie kan in de huidige status niet worden beheerd.", + hostOffline: "Verbind deze computer opnieuw voordat je de Codex-sessies ervan beheert.", + gatewayOffline: "Maak opnieuw verbinding met de gateway voordat je Codex-sessies beheert.", + remoteReadOnly: "Sessies van gekoppelde computers zijn voorlopig alleen-lezen.", + }, status: { active: "Actief", idle: "Inactief", archived: "Gearchiveerd", - notLoaded: "Opgeslagen", + storedActivityUnknown: "Opgeslagen / activiteit onbekend", systemError: "Systeemfout", unknown: "Onbekend", }, @@ -987,11 +1004,10 @@ export const nl: TranslationMap = { }, empty: { title: "Geen Codex-hosts gevonden", - subtitle: - "Schakel het delen van Codex-sessies in op de Gateway of een gekoppelde computer en vernieuw daarna deze weergave.", + supervisionSubtitle: + "Schakel Codex-toezicht in op de gateway of een gekoppelde computer en vernieuw daarna deze weergave.", search: "Geen sessies op deze host komen overeen met je zoekopdracht.", - active: "Geen actieve sessies op deze host.", - archived: "Geen gearchiveerde sessies op deze host.", + nonArchived: "Geen niet-gearchiveerde sessies op deze host.", }, }, logbook: { @@ -2109,6 +2125,11 @@ export const nl: TranslationMap = { clearSessionSearch: "Sessiezoekopdracht wissen", loadMoreSessions: "Meer sessies laden", model: "Chat model", + modelSection: "Model", + modelLocked: "Vergrendeld", + modelLockedLabel: "Modelselectie wordt voor deze sessie beheerd", + lockedSessionModel: "Sessiemodel", + nativeCodexModel: "Door Codex beheerd model", thinkingLevel: "Chat thinking level", }, toolCards: { diff --git a/ui/src/i18n/locales/pl.ts b/ui/src/i18n/locales/pl.ts index fb3db2475cb2..529aa084e385 100644 --- a/ui/src/i18n/locales/pl.ts +++ b/ui/src/i18n/locales/pl.ts @@ -947,8 +947,8 @@ export const pl: TranslationMap = { codexSessions: { eyebrow: "Flota Codex", title: "Sesje na Twoich komputerach", - subtitle: - "Widok tylko do odczytu sesji Codex na tym Gateway i każdym połączonym komputerze, który je udostępnia.", + interactiveSubtitle: + "Rozpocznij gałąź Chat, której wybór modelu pozostaje pod kontrolą Codex App Server, archiwizuj kwalifikujące się sesje lokalne po potwierdzeniu i wyświetlaj sesje ze sparowanymi komputerami.", summaryLabel: "Podsumowanie sesji Codex", summary: { sessions: "sesje", @@ -957,11 +957,6 @@ export const pl: TranslationMap = { }, searchLabel: "Wyszukaj sesje Codex", searchPlaceholder: "Wyszukaj tytuły sesji", - scopeLabel: "Filtr archiwum sesji", - scope: { - active: "Aktywne", - archived: "Zarchiwizowane", - }, refresh: "Odśwież", disconnected: "Połącz się ponownie z Gateway, aby odświeżyć sesje Codex.", partial: "Niedostępne hosty: {count}. Pozostałe hosty są nadal dostępne.", @@ -970,11 +965,33 @@ export const pl: TranslationMap = { loadingMore: "Wczytywanie…", untitled: "Sesja Codex bez tytułu", threadId: "Wątek", + actions: { + continue: "Kontynuuj", + continueAsBranch: "Kontynuuj jako gałąź", + openChat: "Otwórz Chat", + continuing: "Kontynuowanie…", + archive: "Archiwizuj", + continueLabel: "Kontynuuj {title}", + continueAsBranchLabel: "Kontynuuj {title} jako gałąź", + openChatLabel: "Otwórz Chat dla {title}", + continueAsBranchHint: + "Utwórz Chat na podstawie utrwalonej widocznej historii. Przy pierwszej wiadomości Codex App Server wybiera model i dostawcę dla nowego wątku harness. Późniejszy wybór pozostaje kontrolowany przez Codex; OpenClaw nigdy nie zastępuje go innym środowiskiem uruchomieniowym, modelem ani mechanizmem awaryjnym. Źródło pozostaje nienaruszone, a prace w toku mogą być nieobecne.", + archiveLabel: "Archiwizuj {title}", + archiveConfirmation: + "Zarchiwizować {title} i wszystkie utworzone z niego elementy podrzędne? Potwierdź, że żaden inny klient Codex ani runner OpenClaw ich nie używa. Archiwizacja, gdy aktywny jest inny runner, może przerwać jego pracę.", + active: "Aktywne sesje nie mogą rozpocząć gałęzi ani zostać zarchiwizowane.", + archiveActivityUnknownHint: + "Aktywność jest nieznana, ponieważ status jest lokalny dla procesu. Zarchiwizuj dopiero po potwierdzeniu, że żaden inny klient Codex ani runner nie używa tej sesji.", + statusUnavailable: "Tą sesją Codex nie można zarządzać w jej bieżącym stanie.", + hostOffline: "Połącz ponownie ten komputer, zanim zaczniesz zarządzać jego sesjami Codex.", + gatewayOffline: "Połącz ponownie z gateway, zanim zaczniesz zarządzać sesjami Codex.", + remoteReadOnly: "Sesje sparowanego komputera są na razie tylko do odczytu.", + }, status: { active: "Aktywna", idle: "Bezczynna", archived: "Zarchiwizowana", - notLoaded: "Zapisana", + storedActivityUnknown: "Zapisana / aktywność nieznana", systemError: "Błąd systemu", unknown: "Nieznany", }, @@ -988,11 +1005,10 @@ export const pl: TranslationMap = { }, empty: { title: "Nie znaleziono hostów Codex", - subtitle: - "Włącz udostępnianie sesji Codex na Gateway lub sparowanym komputerze, a następnie odśwież ten widok.", + supervisionSubtitle: + "Włącz nadzór Codex w gateway lub na sparowanym komputerze, a następnie odśwież ten widok.", search: "Żadna sesja na tym hoście nie pasuje do wyszukiwania.", - active: "Brak aktywnych sesji na tym hoście.", - archived: "Brak zarchiwizowanych sesji na tym hoście.", + nonArchived: "Brak niezarchiwizowanych sesji na tym hoście.", }, }, logbook: { @@ -2110,6 +2126,11 @@ export const pl: TranslationMap = { clearSessionSearch: "Wyczyść wyszukiwanie sesji", loadMoreSessions: "Wczytaj więcej sesji", model: "Chat model", + modelSection: "Model", + modelLocked: "Zablokowany", + modelLockedLabel: "Wybór modelu kontrolowany dla tej sesji", + lockedSessionModel: "Model sesji", + nativeCodexModel: "Model kontrolowany przez Codex", thinkingLevel: "Chat thinking level", }, toolCards: { diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index 3ae844005ccb..8267c53e5aea 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -940,8 +940,8 @@ export const pt_BR: TranslationMap = { codexSessions: { eyebrow: "Frota do Codex", title: "Sessões em todos os seus computadores", - subtitle: - "Uma visualização somente leitura das sessões do Codex neste Gateway e em todos os computadores conectados que as compartilham.", + interactiveSubtitle: + "Inicie uma ramificação de Chat cuja seleção de modelo permanece sob controle do Codex App Server, arquive sessões locais elegíveis com confirmação e veja sessões de computadores pareados.", summaryLabel: "Resumo das sessões do Codex", summary: { sessions: "sessões", @@ -950,11 +950,6 @@ export const pt_BR: TranslationMap = { }, searchLabel: "Pesquisar sessões do Codex", searchPlaceholder: "Pesquisar títulos de sessões", - scopeLabel: "Filtro de arquivamento de sessões", - scope: { - active: "Ativas", - archived: "Arquivadas", - }, refresh: "Atualizar", disconnected: "Reconecte-se ao Gateway para atualizar as sessões do Codex.", partial: "Hosts indisponíveis: {count}. Os demais hosts continuam disponíveis.", @@ -963,11 +958,34 @@ export const pt_BR: TranslationMap = { loadingMore: "Carregando…", untitled: "Sessão do Codex sem título", threadId: "Thread", + actions: { + continue: "Continuar", + continueAsBranch: "Continuar como ramificação", + openChat: "Abrir Chat", + continuing: "Continuando…", + archive: "Arquivar", + continueLabel: "Continuar {title}", + continueAsBranchLabel: "Continuar {title} como ramificação", + openChatLabel: "Abrir Chat para {title}", + continueAsBranchHint: + "Crie um Chat a partir do histórico visível persistido. Na sua primeira mensagem, o Codex App Server seleciona o modelo e o provedor para a nova thread do harness. A seleção posterior permanece controlada pelo Codex; o OpenClaw nunca substitui por outro runtime, modelo ou fallback. A origem permanece intocada, e trabalhos em andamento podem estar ausentes.", + archiveLabel: "Arquivar {title}", + archiveConfirmation: + "Arquivar {title} e todos os descendentes gerados? Confirme que nenhum outro cliente Codex ou runner do OpenClaw está usando-os. Arquivar enquanto outro runner está ativo pode interromper o trabalho dele.", + active: "Sessões ativas não podem iniciar uma ramificação nem ser arquivadas.", + archiveActivityUnknownHint: + "A atividade é desconhecida porque o status é local ao processo. Arquive somente depois de confirmar que nenhum outro cliente Codex ou runner está usando esta sessão.", + statusUnavailable: "Esta sessão do Codex não pode ser gerenciada no estado atual.", + hostOffline: "Reconecte este computador antes de gerenciar suas sessões do Codex.", + gatewayOffline: "Reconecte-se ao gateway antes de gerenciar sessões do Codex.", + remoteReadOnly: + "Sessões de computadores pareados são somente para visualização por enquanto.", + }, status: { active: "Ativa", idle: "Ociosa", archived: "Arquivada", - notLoaded: "Armazenada", + storedActivityUnknown: "Armazenada / atividade desconhecida", systemError: "Erro de sistema", unknown: "Desconhecido", }, @@ -981,11 +999,10 @@ export const pt_BR: TranslationMap = { }, empty: { title: "Nenhum host do Codex encontrado", - subtitle: - "Ative o compartilhamento de sessões do Codex no Gateway ou em um computador pareado e atualize esta visualização.", + supervisionSubtitle: + "Ative a supervisão do Codex no gateway ou em um computador pareado e, em seguida, atualize esta visualização.", search: "Nenhuma sessão neste host corresponde à sua busca.", - active: "Nenhuma sessão ativa neste host.", - archived: "Nenhuma sessão arquivada neste host.", + nonArchived: "Nenhuma sessão não arquivada neste host.", }, }, logbook: { @@ -2103,6 +2120,11 @@ export const pt_BR: TranslationMap = { clearSessionSearch: "Limpar pesquisa de sessões", loadMoreSessions: "Carregar mais sessões", model: "Chat model", + modelSection: "Modelo", + modelLocked: "Bloqueado", + modelLockedLabel: "Seleção de modelo controlada para esta sessão", + lockedSessionModel: "Modelo da sessão", + nativeCodexModel: "Modelo controlado pelo Codex", thinkingLevel: "Chat thinking level", }, toolCards: { diff --git a/ui/src/i18n/locales/ru.ts b/ui/src/i18n/locales/ru.ts index 2dbe4c819a73..3b853c8ef373 100644 --- a/ui/src/i18n/locales/ru.ts +++ b/ui/src/i18n/locales/ru.ts @@ -946,8 +946,8 @@ export const ru: TranslationMap = { codexSessions: { eyebrow: "Парк Codex", title: "Сеансы на всех ваших компьютерах", - subtitle: - "Сеансы Codex на этом Gateway и всех подключённых компьютерах с общим доступом. Только для чтения.", + interactiveSubtitle: + "Запустите ветку Chat, выбор модели для которой остается под управлением Codex App Server, архивируйте подходящие локальные сеансы с подтверждением и просматривайте сеансы сопряженных компьютеров.", summaryLabel: "Сводка сеансов Codex", summary: { sessions: "сеансы", @@ -956,11 +956,6 @@ export const ru: TranslationMap = { }, searchLabel: "Поиск сеансов Codex", searchPlaceholder: "Поиск по названиям сеансов", - scopeLabel: "Фильтр архива сеансов", - scope: { - active: "Активно", - archived: "В архиве", - }, refresh: "Обновить", disconnected: "Повторно подключитесь к Gateway, чтобы обновить сеансы Codex.", partial: "Недоступные хосты: {count}. Остальные хосты по-прежнему доступны.", @@ -969,11 +964,33 @@ export const ru: TranslationMap = { loadingMore: "Загрузка…", untitled: "Сеанс Codex без названия", threadId: "Ветка", + actions: { + continue: "Продолжить", + continueAsBranch: "Продолжить как ветку", + openChat: "Открыть Chat", + continuing: "Продолжение…", + archive: "Архивировать", + continueLabel: "Продолжить {title}", + continueAsBranchLabel: "Продолжить {title} как ветку", + openChatLabel: "Открыть Chat для {title}", + continueAsBranchHint: + "Создать Chat из сохраненной видимой истории. При первом сообщении Codex App Server выбирает модель и провайдера для нового потока harness. Дальнейший выбор остается под управлением Codex; OpenClaw никогда не подставляет другую среду выполнения, модель или резервный вариант. Источник остается нетронутым, а выполняемая работа может отсутствовать.", + archiveLabel: "Архивировать {title}", + archiveConfirmation: + "Архивировать {title} и все порожденные дочерние сеансы? Подтвердите, что их не использует другой клиент Codex или runner OpenClaw. Архивация при активном другом runner может прервать его работу.", + active: "Для активных сеансов нельзя запускать ветку или архивировать их.", + archiveActivityUnknownHint: + "Активность неизвестна, потому что статус локален для процесса. Архивируйте только после подтверждения, что этот сеанс не использует другой клиент Codex или runner.", + statusUnavailable: "Этим сеансом Codex нельзя управлять в его текущем состоянии.", + hostOffline: "Повторно подключите этот компьютер, прежде чем управлять его сеансами Codex.", + gatewayOffline: "Повторно подключитесь к gateway, прежде чем управлять сеансами Codex.", + remoteReadOnly: "Сеансы сопряженного компьютера пока доступны только для просмотра.", + }, status: { active: "Активно", idle: "Бездействует", archived: "В архиве", - notLoaded: "Сохранён", + storedActivityUnknown: "Сохранено / активность неизвестна", systemError: "Системная ошибка", unknown: "Неизвестно", }, @@ -987,11 +1004,10 @@ export const ru: TranslationMap = { }, empty: { title: "Хосты Codex не найдены", - subtitle: - "Включите общий доступ к сеансам Codex на Gateway или сопряжённом компьютере, затем обновите эту страницу.", + supervisionSubtitle: + "Включите надзор Codex на Gateway или сопряжённом компьютере, затем обновите это представление.", search: "Ни один сеанс на этом хосте не соответствует поиску.", - active: "На этом хосте нет активных сеансов.", - archived: "На этом хосте нет архивных сеансов.", + nonArchived: "На этом хосте нет неархивированных сеансов.", }, }, logbook: { @@ -2114,6 +2130,11 @@ export const ru: TranslationMap = { clearSessionSearch: "Очистить поиск сеансов", loadMoreSessions: "Загрузить еще сеансы", model: "Модель чата", + modelSection: "Модель", + modelLocked: "Заблокировано", + modelLockedLabel: "Выбор модели управляется для этого сеанса", + lockedSessionModel: "Модель сеанса", + nativeCodexModel: "Модель, управляемая Codex", thinkingLevel: "Уровень мышления чата", }, toolCards: { diff --git a/ui/src/i18n/locales/th.ts b/ui/src/i18n/locales/th.ts index dbef4f634d73..116d806f7be3 100644 --- a/ui/src/i18n/locales/th.ts +++ b/ui/src/i18n/locales/th.ts @@ -929,7 +929,8 @@ export const th: TranslationMap = { codexSessions: { eyebrow: "กลุ่มเครื่อง Codex", title: "เซสชันบนคอมพิวเตอร์ของคุณ", - subtitle: "มุมมองแบบอ่านอย่างเดียวของเซสชัน Codex บน Gateway นี้และคอมพิวเตอร์ที่เชื่อมต่อทุกเครื่องซึ่งแชร์เซสชัน", + interactiveSubtitle: + "เริ่มสาขา Chat ที่การเลือกโมเดลยังคงอยู่ภายใต้การควบคุมของ Codex App Server, เก็บถาวรเซสชันภายในเครื่องที่เข้าเกณฑ์พร้อมการยืนยัน, และดูเซสชันของคอมพิวเตอร์ที่จับคู่ไว้", summaryLabel: "สรุปเซสชัน Codex", summary: { sessions: "เซสชัน", @@ -938,11 +939,6 @@ export const th: TranslationMap = { }, searchLabel: "ค้นหาเซสชัน Codex", searchPlaceholder: "ค้นหาชื่อเซสชัน", - scopeLabel: "ตัวกรองคลังเซสชัน", - scope: { - active: "ใช้งานอยู่", - archived: "เก็บถาวรแล้ว", - }, refresh: "รีเฟรช", disconnected: "เชื่อมต่อ Gateway ใหม่เพื่อรีเฟรชเซสชัน Codex", partial: "มีโฮสต์ที่ไม่พร้อมใช้งาน {count} โฮสต์ ส่วนโฮสต์อื่นยังคงพร้อมใช้งาน", @@ -951,11 +947,33 @@ export const th: TranslationMap = { loadingMore: "กำลังโหลด…", untitled: "เซสชัน Codex ที่ไม่มีชื่อ", threadId: "เธรด", + actions: { + continue: "ดำเนินการต่อ", + continueAsBranch: "ดำเนินการต่อเป็นสาขา", + openChat: "เปิด Chat", + continuing: "กำลังดำเนินการต่อ…", + archive: "เก็บถาวร", + continueLabel: "ดำเนินการต่อ {title}", + continueAsBranchLabel: "ดำเนินการต่อ {title} เป็นสาขา", + openChatLabel: "เปิด Chat สำหรับ {title}", + continueAsBranchHint: + "สร้าง Chat จากประวัติที่มองเห็นได้ซึ่งบันทึกไว้ เมื่อส่งข้อความแรก Codex App Server จะเลือกโมเดลและผู้ให้บริการสำหรับเธรด harness ใหม่ การเลือกในภายหลังยังคงควบคุมโดย Codex; OpenClaw จะไม่แทนที่ด้วยรันไทม์ โมเดล หรือทางเลือกสำรองอื่น แหล่งที่มาจะไม่ถูกแตะต้อง และงานที่กำลังดำเนินอยู่อาจไม่มีอยู่", + archiveLabel: "เก็บถาวร {title}", + archiveConfirmation: + "เก็บถาวร {title} และรายการสืบทอดที่ถูกสร้างขึ้นทั้งหมดหรือไม่? ยืนยันว่าไม่มีไคลเอนต์ Codex หรือ OpenClaw runner อื่นกำลังใช้งานอยู่ การเก็บถาวรขณะที่ runner อื่นทำงานอยู่อาจขัดจังหวะงานของมัน", + active: "เซสชันที่ใช้งานอยู่ไม่สามารถเริ่มสาขาหรือถูกเก็บถาวรได้", + archiveActivityUnknownHint: + "ไม่ทราบกิจกรรมเนื่องจากสถานะเป็นแบบเฉพาะกระบวนการเท่านั้น ให้เก็บถาวรหลังจากยืนยันแล้วว่าไม่มีไคลเอนต์ Codex หรือ runner อื่นกำลังใช้งานเซสชันนี้อยู่", + statusUnavailable: "ไม่สามารถจัดการเซสชัน Codex นี้ได้ในสถานะปัจจุบัน", + hostOffline: "เชื่อมต่อคอมพิวเตอร์เครื่องนี้อีกครั้งก่อนจัดการเซสชัน Codex ของเครื่องนี้", + gatewayOffline: "เชื่อมต่อกับ gateway อีกครั้งก่อนจัดการเซสชัน Codex", + remoteReadOnly: "เซสชันของคอมพิวเตอร์ที่จับคู่ไว้เป็นแบบดูอย่างเดียวในขณะนี้", + }, status: { active: "ใช้งานอยู่", idle: "ไม่ได้ใช้งาน", archived: "เก็บถาวรแล้ว", - notLoaded: "จัดเก็บแล้ว", + storedActivityUnknown: "จัดเก็บไว้ / ไม่ทราบกิจกรรม", systemError: "ข้อผิดพลาดของระบบ", unknown: "ไม่ทราบ", }, @@ -969,10 +987,9 @@ export const th: TranslationMap = { }, empty: { title: "ไม่พบโฮสต์ Codex", - subtitle: "เปิดใช้การแชร์เซสชัน Codex บน Gateway หรือคอมพิวเตอร์ที่จับคู่ไว้ แล้วรีเฟรชมุมมองนี้", + supervisionSubtitle: "เปิดใช้งานการกำกับดูแล Codex บน Gateway หรือคอมพิวเตอร์ที่จับคู่ไว้ แล้วรีเฟรชมุมมองนี้", search: "ไม่มีเซสชันบนโฮสต์นี้ตรงกับการค้นหาของคุณ", - active: "ไม่มีเซสชันที่ใช้งานอยู่บนโฮสต์นี้", - archived: "ไม่มีเซสชันที่เก็บถาวรบนโฮสต์นี้", + nonArchived: "ไม่มีเซสชันที่ไม่ได้เก็บถาวรบนโฮสต์นี้", }, }, logbook: { @@ -2052,6 +2069,11 @@ export const th: TranslationMap = { clearSessionSearch: "ล้างการค้นหาเซสชัน", loadMoreSessions: "โหลดเซสชันเพิ่มเติม", model: "Chat model", + modelSection: "โมเดล", + modelLocked: "ล็อกแล้ว", + modelLockedLabel: "การเลือกโมเดลถูกควบคุมสำหรับเซสชันนี้", + lockedSessionModel: "โมเดลของเซสชัน", + nativeCodexModel: "โมเดลที่ควบคุมโดย Codex", thinkingLevel: "Chat thinking level", }, toolCards: { diff --git a/ui/src/i18n/locales/tr.ts b/ui/src/i18n/locales/tr.ts index 66c050398e93..46e3eab2c89d 100644 --- a/ui/src/i18n/locales/tr.ts +++ b/ui/src/i18n/locales/tr.ts @@ -950,8 +950,8 @@ export const tr: TranslationMap = { codexSessions: { eyebrow: "Codex filosu", title: "Bilgisayarlarınızdaki oturumlar", - subtitle: - "Bu Gateway'deki ve oturumlarını paylaşan tüm bağlı bilgisayarlardaki Codex oturumlarının salt okunur görünümü.", + interactiveSubtitle: + "Model seçimi Codex App Server denetiminde kalan bir Chat dalı başlatın, uygun yerel oturumları onayla arşivleyin ve eşlenmiş bilgisayar oturumlarını görüntüleyin.", summaryLabel: "Codex oturum özeti", summary: { sessions: "oturum", @@ -960,11 +960,6 @@ export const tr: TranslationMap = { }, searchLabel: "Codex oturumlarında ara", searchPlaceholder: "Oturum başlıklarında ara", - scopeLabel: "Oturum arşivi filtresi", - scope: { - active: "Etkin", - archived: "Arşivlenmiş", - }, refresh: "Yenile", disconnected: "Codex oturumlarını yenilemek için Gateway'e yeniden bağlanın.", partial: @@ -974,11 +969,33 @@ export const tr: TranslationMap = { loadingMore: "Yükleniyor…", untitled: "Başlıksız Codex oturumu", threadId: "Konuşma", + actions: { + continue: "Devam et", + continueAsBranch: "Dal olarak devam et", + openChat: "Chat'i aç", + continuing: "Devam ediliyor…", + archive: "Arşivle", + continueLabel: "{title} ile devam et", + continueAsBranchLabel: "{title} ile dal olarak devam et", + openChatLabel: "{title} için Chat'i aç", + continueAsBranchHint: + "Kalıcı hale getirilmiş görünür geçmişten bir Chat oluşturun. İlk mesajınızda, Codex App Server yeni harness iş parçacığı için modeli ve sağlayıcıyı seçer. Sonraki seçimler Codex denetiminde kalır; OpenClaw hiçbir zaman başka bir çalışma zamanı, model veya yedek seçenekle değiştirme yapmaz. Kaynak dokunulmadan kalır ve devam eden çalışma eksik olabilir.", + archiveLabel: "{title} oturumunu arşivle", + archiveConfirmation: + "{title} ve ondan türetilen alt öğeler arşivlensin mi? Başka hiçbir Codex istemcisinin veya OpenClaw çalıştırıcısının bunları kullanmadığını onaylayın. Başka bir çalıştırıcı etkinken arşivleme yapmak çalışmasını kesintiye uğratabilir.", + active: "Etkin oturumlar dal başlatamaz veya arşivlenemez.", + archiveActivityUnknownHint: + "Durum sürece yerel olduğundan etkinlik bilinmiyor. Yalnızca başka hiçbir Codex istemcisinin veya çalıştırıcısının bu oturumu kullanmadığını onayladıktan sonra arşivleyin.", + statusUnavailable: "Bu Codex oturumu mevcut durumunda yönetilemez.", + hostOffline: "Codex oturumlarını yönetmeden önce bu bilgisayarı yeniden bağlayın.", + gatewayOffline: "Codex oturumlarını yönetmeden önce gateway'e yeniden bağlanın.", + remoteReadOnly: "Eşleştirilmiş bilgisayar oturumları şimdilik yalnızca görüntülenebilir.", + }, status: { active: "Etkin", idle: "Boşta", archived: "Arşivlenmiş", - notLoaded: "Depolanmış", + storedActivityUnknown: "Saklandı / etkinlik bilinmiyor", systemError: "Sistem hatası", unknown: "Bilinmiyor", }, @@ -992,11 +1009,10 @@ export const tr: TranslationMap = { }, empty: { title: "Codex ana makinesi bulunamadı", - subtitle: - "Gateway'de veya eşleştirilmiş bir bilgisayarda Codex oturum paylaşımını etkinleştirin, ardından bu görünümü yenileyin.", + supervisionSubtitle: + "Gateway’de veya eşleştirilmiş bir bilgisayarda Codex denetimini etkinleştirin, ardından bu görünümü yenileyin.", search: "Bu ana makinedeki hiçbir oturum aramanızla eşleşmiyor.", - active: "Bu ana makinede etkin oturum yok.", - archived: "Bu ana makinede arşivlenmiş oturum yok.", + nonArchived: "Bu ana makinede arşivlenmemiş oturum yok.", }, }, logbook: { @@ -2112,6 +2128,11 @@ export const tr: TranslationMap = { clearSessionSearch: "Oturum aramasını temizle", loadMoreSessions: "Daha fazla oturum yükle", model: "Chat model", + modelSection: "Model", + modelLocked: "Kilitli", + modelLockedLabel: "Bu oturum için model seçimi denetleniyor", + lockedSessionModel: "Oturum modeli", + nativeCodexModel: "Codex kontrollü model", thinkingLevel: "Chat thinking level", }, toolCards: { diff --git a/ui/src/i18n/locales/uk.ts b/ui/src/i18n/locales/uk.ts index ff6fc16808b8..02f6adefb7a5 100644 --- a/ui/src/i18n/locales/uk.ts +++ b/ui/src/i18n/locales/uk.ts @@ -940,8 +940,8 @@ export const uk: TranslationMap = { codexSessions: { eyebrow: "Парк Codex", title: "Сеанси на всіх ваших комп’ютерах", - subtitle: - "Сеанси Codex на цьому Gateway й усіх підключених комп’ютерах зі спільним доступом. Лише для читання.", + interactiveSubtitle: + "Запустіть гілку Chat, вибір моделі якої залишається під керуванням Codex App Server, архівуйте придатні локальні сесії з підтвердженням і переглядайте сесії зі спарених комп’ютерів.", summaryLabel: "Підсумок сеансів Codex", summary: { sessions: "сеанси", @@ -950,11 +950,6 @@ export const uk: TranslationMap = { }, searchLabel: "Пошук сеансів Codex", searchPlaceholder: "Пошук за назвами сеансів", - scopeLabel: "Фільтр архіву сеансів", - scope: { - active: "Активні", - archived: "Заархівовані", - }, refresh: "Оновити", disconnected: "Повторно підключіться до Gateway, щоб оновити сеанси Codex.", partial: "Недоступних хостів: {count}. Решта хостів доступні.", @@ -963,11 +958,33 @@ export const uk: TranslationMap = { loadingMore: "Завантаження…", untitled: "Сеанс Codex без назви", threadId: "Гілка", + actions: { + continue: "Продовжити", + continueAsBranch: "Продовжити як гілку", + openChat: "Відкрити Chat", + continuing: "Продовження…", + archive: "Архівувати", + continueLabel: "Продовжити {title}", + continueAsBranchLabel: "Продовжити {title} як гілку", + openChatLabel: "Відкрити Chat для {title}", + continueAsBranchHint: + "Створіть Chat зі збереженої видимої історії. Після вашого першого повідомлення Codex App Server вибере модель і провайдера для нового потоку harness. Подальший вибір залишається під керуванням Codex; OpenClaw ніколи не підставляє інше середовище виконання, модель або резервний варіант. Джерело залишається незмінним, а поточна робота може бути відсутня.", + archiveLabel: "Архівувати {title}", + archiveConfirmation: + "Архівувати {title} і всі породжені нащадки? Підтвердьте, що жоден інший клієнт Codex або runner OpenClaw не використовує їх. Архівування під час активності іншого runner може перервати його роботу.", + active: "Активні сеанси не можна запускати як гілку або архівувати.", + archiveActivityUnknownHint: + "Активність невідома, оскільки статус є локальним для процесу. Архівуйте лише після підтвердження, що жоден інший клієнт Codex або runner не використовує цю сесію.", + statusUnavailable: "Цим сеансом Codex не можна керувати в його поточному стані.", + hostOffline: "Повторно підключіть цей комп’ютер, перш ніж керувати його сеансами Codex.", + gatewayOffline: "Повторно підключіться до gateway, перш ніж керувати сеансами Codex.", + remoteReadOnly: "Сеанси спареного комп’ютера наразі доступні лише для перегляду.", + }, status: { active: "Активний", idle: "Неактивний", archived: "Заархівований", - notLoaded: "Збережений", + storedActivityUnknown: "Збережено / активність невідома", systemError: "Системна помилка", unknown: "Невідомо", }, @@ -981,11 +998,10 @@ export const uk: TranslationMap = { }, empty: { title: "Хости Codex не знайдено", - subtitle: - "Увімкніть спільний доступ до сеансів Codex на Gateway або спареному комп’ютері, а потім оновіть цю сторінку.", + supervisionSubtitle: + "Увімкніть нагляд Codex на Gateway або спареному комп’ютері, а потім оновіть це подання.", search: "Жоден сеанс на цьому хості не відповідає пошуку.", - active: "На цьому хості немає активних сеансів.", - archived: "На цьому хості немає архівних сеансів.", + nonArchived: "На цьому хості немає неархівованих сеансів.", }, }, logbook: { @@ -2100,6 +2116,11 @@ export const uk: TranslationMap = { clearSessionSearch: "Очистити пошук сеансів", loadMoreSessions: "Завантажити більше сеансів", model: "Chat model", + modelSection: "Модель", + modelLocked: "Заблоковано", + modelLockedLabel: "Вибір моделі керується для цієї сесії", + lockedSessionModel: "Модель сеансу", + nativeCodexModel: "Модель, керована Codex", thinkingLevel: "Chat thinking level", }, toolCards: { diff --git a/ui/src/i18n/locales/vi.ts b/ui/src/i18n/locales/vi.ts index 2e68ea5d63cb..85e98382099e 100644 --- a/ui/src/i18n/locales/vi.ts +++ b/ui/src/i18n/locales/vi.ts @@ -940,8 +940,8 @@ export const vi: TranslationMap = { codexSessions: { eyebrow: "Nhóm thiết bị Codex", title: "Phiên trên các máy tính của bạn", - subtitle: - "Chế độ xem chỉ đọc, hiển thị các phiên Codex trên Gateway này và mọi máy tính được kết nối đã bật chia sẻ phiên.", + interactiveSubtitle: + "Bắt đầu một nhánh Chat có lựa chọn mô hình luôn nằm dưới sự kiểm soát của Codex App Server, lưu trữ các phiên cục bộ đủ điều kiện sau khi xác nhận, và xem các phiên trên máy tính đã ghép đôi.", summaryLabel: "Tóm tắt phiên Codex", summary: { sessions: "phiên", @@ -950,11 +950,6 @@ export const vi: TranslationMap = { }, searchLabel: "Tìm kiếm phiên Codex", searchPlaceholder: "Tìm theo tiêu đề phiên", - scopeLabel: "Lọc phiên theo trạng thái lưu trữ", - scope: { - active: "Đang hoạt động", - archived: "Đã lưu trữ", - }, refresh: "Làm mới", disconnected: "Kết nối lại với Gateway để làm mới các phiên Codex.", partial: "{count} máy không khả dụng. Các máy khác vẫn khả dụng.", @@ -963,11 +958,33 @@ export const vi: TranslationMap = { loadingMore: "Đang tải…", untitled: "Phiên Codex chưa có tiêu đề", threadId: "Mã luồng", + actions: { + continue: "Tiếp tục", + continueAsBranch: "Tiếp tục dưới dạng nhánh", + openChat: "Mở Chat", + continuing: "Đang tiếp tục…", + archive: "Lưu trữ", + continueLabel: "Tiếp tục {title}", + continueAsBranchLabel: "Tiếp tục {title} dưới dạng nhánh", + openChatLabel: "Mở Chat cho {title}", + continueAsBranchHint: + "Tạo một Chat từ lịch sử hiển thị đã được lưu giữ. Ở tin nhắn đầu tiên của bạn, Codex App Server sẽ chọn mô hình và nhà cung cấp cho luồng harness mới. Các lựa chọn về sau vẫn do Codex kiểm soát; OpenClaw không bao giờ thay thế bằng runtime, mô hình hoặc phương án dự phòng khác. Nguồn vẫn được giữ nguyên, và công việc đang thực hiện có thể không có.", + archiveLabel: "Lưu trữ {title}", + archiveConfirmation: + "Lưu trữ {title} và mọi phiên con được tạo ra? Hãy xác nhận rằng không có Codex client hoặc OpenClaw runner nào khác đang sử dụng chúng. Lưu trữ trong khi một runner khác đang hoạt động có thể làm gián đoạn công việc của runner đó.", + active: "Không thể bắt đầu một nhánh hoặc lưu trữ các phiên đang hoạt động.", + archiveActivityUnknownHint: + "Hoạt động không xác định vì trạng thái chỉ cục bộ theo tiến trình. Chỉ lưu trữ sau khi xác nhận rằng không có Codex client hoặc runner nào khác đang sử dụng phiên này.", + statusUnavailable: "Không thể quản lý phiên Codex này ở trạng thái hiện tại.", + hostOffline: "Kết nối lại máy tính này trước khi quản lý các phiên Codex của máy.", + gatewayOffline: "Kết nối lại với gateway trước khi quản lý các phiên Codex.", + remoteReadOnly: "Các phiên trên máy tính đã ghép nối hiện chỉ có thể xem.", + }, status: { active: "Đang hoạt động", idle: "Đang chờ", archived: "Đã lưu trữ", - notLoaded: "Đã lưu", + storedActivityUnknown: "Đã lưu trữ / không rõ hoạt động", systemError: "Lỗi hệ thống", unknown: "Không xác định", }, @@ -981,11 +998,10 @@ export const vi: TranslationMap = { }, empty: { title: "Không tìm thấy máy nào chia sẻ phiên Codex", - subtitle: - "Bật chia sẻ phiên Codex trên Gateway hoặc máy tính đã ghép nối, rồi làm mới chế độ xem này.", + supervisionSubtitle: + "Bật tính năng giám sát Codex trên gateway hoặc một máy tính đã ghép đôi, rồi làm mới chế độ xem này.", search: "Không có phiên nào trên máy này khớp với từ khóa tìm kiếm.", - active: "Không có phiên đang hoạt động trên máy này.", - archived: "Không có phiên đã lưu trữ trên máy này.", + nonArchived: "Không có phiên chưa lưu trữ nào trên máy chủ này.", }, }, logbook: { @@ -2088,6 +2104,11 @@ export const vi: TranslationMap = { clearSessionSearch: "Xóa tìm kiếm phiên", loadMoreSessions: "Tải thêm phiên", model: "Chat model", + modelSection: "Mô hình", + modelLocked: "Đã khóa", + modelLockedLabel: "Lựa chọn mô hình được kiểm soát cho phiên này", + lockedSessionModel: "Mô hình phiên", + nativeCodexModel: "Mô hình do Codex kiểm soát", thinkingLevel: "Chat thinking level", }, toolCards: { diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index 72401a13dd17..0eedbd2fa88b 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -927,7 +927,8 @@ export const zh_CN: TranslationMap = { codexSessions: { eyebrow: "Codex 设备群", title: "所有电脑上的会话", - subtitle: "以只读方式查看此 Gateway 和所有已连接且启用会话共享的电脑上的 Codex 会话。", + interactiveSubtitle: + "启动一个 Chat 分支,其模型选择由 Codex App Server 控制;在确认后归档符合条件的本地会话;并查看已配对计算机的会话。", summaryLabel: "Codex 会话摘要", summary: { sessions: "会话", @@ -936,11 +937,6 @@ export const zh_CN: TranslationMap = { }, searchLabel: "搜索 Codex 会话", searchPlaceholder: "搜索会话标题", - scopeLabel: "会话归档筛选", - scope: { - active: "活跃", - archived: "已归档", - }, refresh: "刷新", disconnected: "请重新连接 Gateway 以刷新 Codex 会话。", partial: "有 {count} 台主机不可用。其他主机仍可用。", @@ -949,11 +945,33 @@ export const zh_CN: TranslationMap = { loadingMore: "正在加载…", untitled: "未命名的 Codex 会话", threadId: "线程", + actions: { + continue: "继续", + continueAsBranch: "作为分支继续", + openChat: "打开 Chat", + continuing: "正在继续…", + archive: "归档", + continueLabel: "继续 {title}", + continueAsBranchLabel: "将 {title} 作为分支继续", + openChatLabel: "为 {title} 打开 Chat", + continueAsBranchHint: + "基于已持久化的可见历史创建一个 Chat。在你的第一条消息发送后,Codex App Server 会为新的 harness 线程选择模型和提供方。之后的选择仍由 Codex 控制;OpenClaw 绝不会替换为其他运行时、模型或备用方案。源会话保持不变,进行中的工作可能不会包含在内。", + archiveLabel: "归档 {title}", + archiveConfirmation: + "要归档 {title} 及其生成的所有后代吗?请确认没有其他 Codex 客户端或 OpenClaw runner 正在使用它们。在其他 runner 处于活动状态时归档可能会中断其工作。", + active: "活动会话无法启动分支或被归档。", + archiveActivityUnknownHint: + "由于状态仅限本进程本地可见,因此活动状态未知。请仅在确认没有其他 Codex 客户端或 runner 正在使用此会话后再归档。", + statusUnavailable: "此 Codex 会话在当前状态下无法管理。", + hostOffline: "请重新连接此计算机,然后再管理其 Codex 会话。", + gatewayOffline: "请重新连接到 gateway,然后再管理 Codex 会话。", + remoteReadOnly: "配对计算机的会话目前仅可查看。", + }, status: { active: "活跃", idle: "空闲", archived: "已归档", - notLoaded: "已存储", + storedActivityUnknown: "已存储 / 活动状态未知", systemError: "系统错误", unknown: "未知", }, @@ -967,10 +985,9 @@ export const zh_CN: TranslationMap = { }, empty: { title: "未找到 Codex 主机", - subtitle: "请在 Gateway 或已配对的电脑上启用 Codex 会话共享,然后刷新此视图。", + supervisionSubtitle: "在 Gateway 或已配对的计算机上启用 Codex 监督,然后刷新此视图。", search: "此主机上没有符合搜索条件的会话。", - active: "此主机上没有活跃会话。", - archived: "此主机上没有已归档的会话。", + nonArchived: "此主机上没有未归档的会话。", }, }, logbook: { @@ -2044,6 +2061,11 @@ export const zh_CN: TranslationMap = { clearSessionSearch: "清除会话搜索", loadMoreSessions: "加载更多会话", model: "聊天模型", + modelSection: "模型", + modelLocked: "已锁定", + modelLockedLabel: "此会话的模型选择已受控", + lockedSessionModel: "会话模型", + nativeCodexModel: "Codex 控制的模型", thinkingLevel: "聊天思考级别", }, toolCards: { diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index 3285a1cbc2ec..242456d1190c 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -928,7 +928,8 @@ export const zh_TW: TranslationMap = { codexSessions: { eyebrow: "Codex 裝置群", title: "所有電腦上的工作階段", - subtitle: "以唯讀方式查看此 Gateway 和所有已連線且啟用工作階段分享的電腦上的 Codex 工作階段。", + interactiveSubtitle: + "啟動一個 Chat 分支,其模型選擇維持由 Codex App Server 控制;在確認後封存符合資格的本機工作階段,並檢視已配對電腦的工作階段。", summaryLabel: "Codex 工作階段摘要", summary: { sessions: "工作階段", @@ -937,11 +938,6 @@ export const zh_TW: TranslationMap = { }, searchLabel: "搜尋 Codex 工作階段", searchPlaceholder: "搜尋工作階段標題", - scopeLabel: "工作階段封存篩選器", - scope: { - active: "進行中", - archived: "已封存", - }, refresh: "重新整理", disconnected: "請重新連線至 Gateway 以重新整理 Codex 工作階段。", partial: "有 {count} 台主機無法使用。其他主機仍可使用。", @@ -950,11 +946,33 @@ export const zh_TW: TranslationMap = { loadingMore: "正在載入…", untitled: "未命名的 Codex 工作階段", threadId: "執行緒", + actions: { + continue: "繼續", + continueAsBranch: "作為分支繼續", + openChat: "開啟 Chat", + continuing: "正在繼續…", + archive: "封存", + continueLabel: "繼續 {title}", + continueAsBranchLabel: "將 {title} 作為分支繼續", + openChatLabel: "開啟 {title} 的 Chat", + continueAsBranchHint: + "從已持久保存的可見歷程建立 Chat。在你的第一則訊息中,Codex App Server 會為新的 harness 執行緒選擇模型和提供者。後續選擇仍由 Codex 控制;OpenClaw 絕不會替換為其他執行階段、模型或備援。來源保持不變,進行中的工作可能不存在。", + archiveLabel: "封存 {title}", + archiveConfirmation: + "要封存 {title} 及其產生的任何後代嗎?請確認沒有其他 Codex 用戶端或 OpenClaw runner 正在使用它們。在另一個 runner 仍在活動時封存,可能會中斷其工作。", + active: "作用中的工作階段無法啟動分支或封存。", + archiveActivityUnknownHint: + "由於狀態是程序本機的,因此活動狀態未知。請僅在確認沒有其他 Codex 用戶端或 runner 正在使用此工作階段後再封存。", + statusUnavailable: "此 Codex 工作階段目前狀態無法管理。", + hostOffline: "請重新連線此電腦後,再管理其 Codex 工作階段。", + gatewayOffline: "請重新連線至 gateway 後,再管理 Codex 工作階段。", + remoteReadOnly: "已配對電腦的工作階段目前僅供檢視。", + }, status: { active: "進行中", idle: "閒置", archived: "已封存", - notLoaded: "已儲存", + storedActivityUnknown: "已儲存 / 活動狀態不明", systemError: "系統錯誤", unknown: "未知", }, @@ -968,10 +986,9 @@ export const zh_TW: TranslationMap = { }, empty: { title: "找不到 Codex 主機", - subtitle: "請在 Gateway 或已配對的電腦上啟用 Codex 工作階段分享,然後重新整理此檢視。", + supervisionSubtitle: "在 Gateway 或已配對的電腦上啟用 Codex 監督,然後重新整理此檢視。", search: "此主機上沒有符合搜尋條件的工作階段。", - active: "此主機上沒有進行中的工作階段。", - archived: "此主機上沒有已封存的工作階段。", + nonArchived: "此主機上沒有未封存的工作階段。", }, }, logbook: { @@ -2047,6 +2064,11 @@ export const zh_TW: TranslationMap = { clearSessionSearch: "清除工作階段搜尋", loadMoreSessions: "載入更多工作階段", model: "Chat model", + modelSection: "模型", + modelLocked: "已鎖定", + modelLockedLabel: "此工作階段的模型選擇受到控制", + lockedSessionModel: "工作階段模型", + nativeCodexModel: "Codex 控制的模型", thinkingLevel: "Chat thinking level", }, toolCards: { diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index 2ad27b2a5769..01c60c737c31 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -1266,6 +1266,9 @@ class ChatPane extends OpenClawLightDomElement { const agentDefaultModel = this.context.agents.state.agentsList?.agents.find( (agent) => agent.id === currentAgentId, )?.model?.primary; + const selectedSession = state.sessionsResult?.sessions.find((row) => + areUiSessionKeysEquivalent(row.key, state.sessionKey), + ); const selectedSessionArchived = state.selectedChatSessionArchived || state.sessionsResult?.sessions.some( @@ -1347,6 +1350,8 @@ class ChatPane extends OpenClawLightDomElement { loading: state.chatLoading, modelCatalog: state.chatModelCatalog, modelOverrides: state.sessions.state.modelOverrides, + modelSelectionLocked: selectedSession?.modelSelectionLocked === true, + modelSelectionRuntimeId: selectedSession?.agentRuntime?.id, modelSwitching: Boolean(state.chatModelSwitchPromises[state.sessionKey]), modelsLoading: state.chatModelsLoading, sending: state.chatSending, diff --git a/ui/src/pages/chat/chat-session.ts b/ui/src/pages/chat/chat-session.ts index a34093ce3f88..9170f75a50f4 100644 --- a/ui/src/pages/chat/chat-session.ts +++ b/ui/src/pages/chat/chat-session.ts @@ -420,6 +420,12 @@ export async function switchChatModel( if (!host.client || !host.connected) { return false; } + const activeRow = host.sessionsResult?.sessions.find((row) => + areUiSessionKeysEquivalent(row.key, targetSessionKey), + ); + if (activeRow?.modelSelectionLocked === true) { + return false; + } const currentOverride = resolveChatModelOverrideValue({ chatModelCatalog: host.chatModelCatalog, modelOverrides: host.sessions.state.modelOverrides, diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 227c34d48f4c..5b18df4b415f 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -3663,6 +3663,146 @@ describe("chat model controls", () => { expect(onModelSelect).toHaveBeenCalledWith(modelOption?.dataset.chatModelOption, "main"); }); + it("hides model choices for locked sessions while preserving reasoning and speed", () => { + const { state } = createChatHeaderState({ + model: "gpt-5.5", + modelProvider: "openai", + models: [ + { id: "gpt-5.5", name: "GPT-5.5", provider: "openai" }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", provider: "anthropic" }, + ], + thinkingDefault: "high", + }); + state.sessionsResult = createSessionsListResult({ + model: "gpt-5.5", + modelProvider: "openai", + defaultsModel: "gpt-5.5", + defaultsProvider: "openai", + defaultsThinkingDefault: "high", + defaultsThinkingLevels: [ + { id: "low", label: "low" }, + { id: "high", label: "high" }, + ], + }); + const onModelSelect = vi.fn(async () => true); + const onThinkingSelect = vi.fn(async () => true); + const onFastModeSelect = vi.fn(async () => true); + const container = document.createElement("div"); + render( + renderChatModelControls({ + ...createChatModelControlsProps(state), + modelSelectionLocked: true, + modelSelectionRuntimeId: "codex", + onFastModeSelect, + onModelSelect, + onThinkingSelect, + }), + container, + ); + + const modelSelect = getChatModelSelect(container); + expect(modelSelect.dataset.chatModelLocked).toBe("true"); + expect(modelSelect.getAttribute("aria-disabled")).toBe("false"); + expect(container.querySelector(".chat-controls__locked-model-value")?.textContent).toBe( + "Codex-controlled model", + ); + expect( + container.querySelector(".chat-controls__inline-select-label")?.textContent, + ).not.toContain("GPT-5.5"); + expect(container.querySelectorAll("[data-chat-model-provider]")).toHaveLength(0); + expect(container.querySelectorAll("[data-chat-model-option]")).toHaveLength(0); + expect(onModelSelect).not.toHaveBeenCalled(); + + const slider = getThinkingSlider(container); + expect(slider).toBeInstanceOf(HTMLInputElement); + if (slider) { + slider.value = "0"; + slider.dispatchEvent(new Event("change", { bubbles: true })); + } + expect(onThinkingSelect).toHaveBeenCalledWith("low", "main"); + + const speedToggle = container.querySelector("[data-chat-speed-toggle]"); + expect(speedToggle).toBeInstanceOf(HTMLButtonElement); + speedToggle?.click(); + expect(onFastModeSelect).toHaveBeenCalledWith("on", "main"); + }); + + it("labels a locked session without native model metadata", () => { + const { state } = createChatHeaderState({ + model: "gpt-5.5", + modelProvider: "openai", + models: [{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai" }], + }); + state.sessionsResult = createSessionsListResult({ + defaultsModel: "gpt-5.5", + defaultsProvider: "openai", + }); + const container = document.createElement("div"); + render( + renderChatModelControls({ + ...createChatModelControlsProps(state), + modelSelectionLocked: true, + modelSelectionRuntimeId: "codex", + }), + container, + ); + + expect(container.querySelector(".chat-controls__locked-model-value")?.textContent).toBe( + "Codex-controlled model", + ); + expect(container.querySelector(".chat-controls__inline-select-label")?.textContent).toContain( + "Codex-controlled model", + ); + }); + + it("uses a neutral model label for non-Codex locked sessions", () => { + const { state } = createChatHeaderState({ + model: "gpt-5.5", + modelProvider: "openai", + models: [{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai" }], + }); + const container = document.createElement("div"); + render( + renderChatModelControls({ + ...createChatModelControlsProps(state), + modelSelectionLocked: true, + modelSelectionRuntimeId: "openclaw", + }), + container, + ); + + expect(container.querySelector(".chat-controls__locked-model-value")?.textContent).toBe( + "Session model", + ); + expect(container.textContent).not.toContain("Codex-controlled model"); + }); + + it("does not patch the model for a locked session", async () => { + const { state, request } = createChatHeaderState({ + model: "gpt-5.5", + modelProvider: "openai", + models: [ + { id: "gpt-5.4", name: "GPT-5.4", provider: "openai" }, + { id: "gpt-5.5", name: "GPT-5.5", provider: "openai" }, + ], + }); + state.sessionsResult = createSessionsResultFromRows([ + { + key: "agent:main:main", + kind: "direct", + model: "gpt-5.5", + modelProvider: "openai", + modelSelectionLocked: true, + updatedAt: 1, + }, + ]); + + await expect( + switchChatModel(state as unknown as Parameters[0], "openai/gpt-5.4"), + ).resolves.toBe(false); + expect(request).not.toHaveBeenCalled(); + }); + it("ignores model clicks while a run is active", () => { const { state } = createChatHeaderState({ model: "gpt-5.5", diff --git a/ui/src/pages/chat/components/chat-model-controls.ts b/ui/src/pages/chat/components/chat-model-controls.ts index 3a04749761b4..50ea15cac76e 100644 --- a/ui/src/pages/chat/components/chat-model-controls.ts +++ b/ui/src/pages/chat/components/chat-model-controls.ts @@ -22,6 +22,7 @@ import { formatThinkingOverrideLabel, resolveChatThinkingSelectState, } from "../../../lib/chat/thinking.ts"; +import { areUiSessionKeysEquivalent } from "../../../lib/sessions/session-key.ts"; export type ChatModelControlsProps = { activeRunId: string | null; @@ -31,6 +32,8 @@ export type ChatModelControlsProps = { loading: boolean; modelCatalog: ModelCatalogEntry[]; modelOverrides?: Readonly>; + modelSelectionLocked?: boolean; + modelSelectionRuntimeId?: string; modelSwitching: boolean; modelsLoading?: boolean; sending: boolean; @@ -183,7 +186,9 @@ export function renderChatModelControls(props: ChatModelControlsProps) { // the previous model while a switch is pending; keep both locked until the // refreshed session list lands so stale levels cannot be committed. const fastMode = props.modelSwitching ? { ...fastModeSelect, disabled: true } : fastModeSelect; - const activeSession = props.sessionsResult?.sessions.find((row) => row.key === props.sessionKey); + const activeSession = props.sessionsResult?.sessions.find((row) => + areUiSessionKeysEquivalent(row.key, props.sessionKey), + ); const currentProviderHint = activeSession?.modelProvider ?? ""; const defaultProviderHint = props.sessionsResult?.defaults?.modelProvider ?? ""; const canonicalDefaultLabel = resolveChatModelPickerLabel( @@ -216,13 +221,19 @@ export function renderChatModelControls(props: ChatModelControlsProps) { ), }; }); + const lockedModelLabel = + props.modelSelectionRuntimeId?.trim().toLowerCase() === "codex" + ? t("chat.selectors.nativeCodexModel") + : t("chat.selectors.lockedSessionModel"); const committedModelLabel = - modelOptions.find((entry) => entry.value === currentOverride)?.label ?? - resolveChatModelPickerLabel( - currentOverride, - currentOverride || pickerDefaultLabel, - props.modelCatalog, - ); + props.modelSelectionLocked === true + ? lockedModelLabel + : (modelOptions.find((entry) => entry.value === currentOverride)?.label ?? + resolveChatModelPickerLabel( + currentOverride, + currentOverride || pickerDefaultLabel, + props.modelCatalog, + )); const committedThinkingLabel = thinking.currentOverride === "" ? thinking.defaultLabel @@ -245,6 +256,7 @@ export function renderChatModelControls(props: ChatModelControlsProps) { return renderChatModelReasoningSelect({ disabled, fastMode, + modelSelectionLocked: props.modelSelectionLocked === true, modelOptions, onRequestUpdate: props.onRequestUpdate, selectedModelValue: currentOverride, @@ -289,6 +301,7 @@ function formatCombinedPickerThinkingLabel(label: string): string { function renderChatModelReasoningSelect(params: { fastMode: ChatFastModeSelectState; disabled: boolean; + modelSelectionLocked: boolean; modelOptions: ChatModelProviderOption[]; selectedModelValue: string; selectedThinkingValue: string; @@ -306,6 +319,7 @@ function renderChatModelReasoningSelect(params: { const { disabled, fastMode, + modelSelectionLocked, modelOptions, selectedModelValue, selectedThinkingValue, @@ -355,6 +369,9 @@ function renderChatModelReasoningSelect(params: { // Send gating uses a separate aggregate of all settings patches; keep the // model-only switching state here so reasoning and speed can still overlap. const commitModel = (value: string) => { + if (modelSelectionLocked) { + return; + } void onModelSelect(value, sessionKey).finally(() => onRequestUpdate?.()); onRequestUpdate?.(); }; @@ -453,10 +470,10 @@ function renderChatModelReasoningSelect(params: { role="option" aria-selected=${selected ? "true" : "false"} type="button" - ?disabled=${disabled} + ?disabled=${disabled || modelSelectionLocked} @click=${(event: MouseEvent) => { event.stopPropagation(); - if (disabled || entry.commitValue === selectedModelValue) { + if (disabled || modelSelectionLocked || entry.commitValue === selectedModelValue) { event.preventDefault(); return; } @@ -493,6 +510,7 @@ function renderChatModelReasoningSelect(params: { ? "chat-controls__inline-select-trigger--disabled" : ""}" data-chat-model-select="true" + data-chat-model-locked=${modelSelectionLocked ? "true" : "false"} data-chat-thinking-select="true" data-chat-select-value=${selectedModelValue} data-chat-thinking-value=${selectedThinkingValue} @@ -514,56 +532,73 @@ function renderChatModelReasoningSelect(params: { class="chat-controls__inline-select-menu chat-controls__inline-select-menu--combined" aria-label=${t("chat.selectors.model")} > -
-
- - ${repeat( - orderedProviderGroups, - ([provider]) => provider, - ([provider]) => { - const active = provider === selectedProvider; - return html` - - `; - }, - )} -
-
- ${repeat( - orderedProviderGroups, - ([provider]) => provider, - ([provider, options]) => html` -
+ ${modelSelectionLocked + ? html` +
+ + ${triggerModel} + + ${t("chat.selectors.modelLocked")} + +
+ ` + : html` +
+
+ ${repeat( - options, - (entry) => entry.value, - (entry) => renderModelOption(entry), + orderedProviderGroups, + ([provider]) => provider, + ([provider]) => { + const active = provider === selectedProvider; + return html` + + `; + }, )}
- `, - )} -
-
+
+ ${repeat( + orderedProviderGroups, + ([provider]) => provider, + ([provider, options]) => html` +
+ ${repeat( + options, + (entry) => entry.value, + (entry) => renderModelOption(entry), + )} +
+ `, + )} +
+
+ `} ${showReasoningPanel ? html`
diff --git a/ui/src/pages/plugin/codex-sessions-controller.test.ts b/ui/src/pages/plugin/codex-sessions-controller.test.ts index 1fb2f62f2f8e..060f74c4fcd3 100644 --- a/ui/src/pages/plugin/codex-sessions-controller.test.ts +++ b/ui/src/pages/plugin/codex-sessions-controller.test.ts @@ -1,7 +1,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import { + archiveCodexSession, configureCodexSessionsPolling, + continueCodexSession, getCodexSessionsState, loadCodexSessions, loadMoreCodexSessions, @@ -18,16 +20,22 @@ function clientWithRequest( function deferred() { let resolve!: (value: T) => void; - const promise = new Promise((resolvePromise) => { + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; + reject = rejectPromise; }); - return { promise, resolve }; + return { promise, reject, resolve }; } -function payload( - sessions: Array<{ threadId: string; name: string }>, - nextCursor?: string, -): CodexSessionsPayload { +type SessionFixture = { + threadId: string; + name: string; + status?: string; + openClawSessionKey?: string; +}; + +function payload(sessions: SessionFixture[], nextCursor?: string): CodexSessionsPayload { return { hosts: [ { @@ -35,10 +43,10 @@ function payload( label: "MacBook", kind: "node", connected: true, - sessions: sessions.map((session) => ({ + sessions: sessions.map(({ status = "idle", ...session }) => ({ ...session, archived: false, - status: "idle", + status, })), nextCursor, }, @@ -46,6 +54,24 @@ function payload( }; } +function gatewayPayload(sessions: SessionFixture[], nextCursor?: string): CodexSessionsPayload { + const result = payload(sessions, nextCursor); + const host = result.hosts[0]; + if (!host) { + return result; + } + return { + hosts: [ + { + ...host, + hostId: "gateway:local", + label: "Gateway", + kind: "gateway", + }, + ], + }; +} + describe("Codex sessions controller", () => { const hosts: object[] = []; @@ -56,7 +82,7 @@ describe("Codex sessions controller", () => { vi.useRealTimers(); }); - it("loads a first page with the active archive scope", async () => { + it("loads a first page from the non-archived Codex catalog", async () => { const host = {}; hosts.push(host); const request = vi.fn(async () => payload([{ threadId: "thread-1", name: "Fix tests" }])); @@ -64,14 +90,336 @@ describe("Codex sessions controller", () => { await loadCodexSessions(state, clientWithRequest(request)); - expect(request).toHaveBeenCalledWith("codex-supervisor.sessions.list", { - archived: false, + expect(request).toHaveBeenCalledWith("codex.sessions.list", { limitPerHost: 40, }); expect(state.hosts[0]?.sessions[0]?.threadId).toBe("thread-1"); expect(state.refreshedAtMs).not.toBeNull(); }); + it("continues an idle Codex thread and returns its OpenClaw session key", async () => { + const host = {}; + hosts.push(host); + const state = getCodexSessionsState(host); + state.hosts = gatewayPayload([{ threadId: "thread-1", name: "Fix tests" }]).hosts; + const request = vi.fn(async () => ({ + sessionKey: "agent:main:codex-thread-1", + disposition: "forked", + })); + const onContinue = vi.fn(); + + await continueCodexSession( + state, + clientWithRequest(request), + "gateway:local", + "thread-1", + onContinue, + ); + + expect(request).toHaveBeenCalledWith("codex.sessions.continue", { + hostId: "gateway:local", + threadId: "thread-1", + }); + expect(onContinue).toHaveBeenCalledWith("agent:main:codex-thread-1"); + expect(state.pendingSessionActions.size).toBe(0); + expect(state.actionError).toBeNull(); + }); + + it("revalidates a mapped active session before opening its OpenClaw chat", async () => { + const host = {}; + hosts.push(host); + const state = getCodexSessionsState(host); + state.hosts = gatewayPayload([ + { + threadId: "thread-active", + name: "Already supervised", + status: "active", + openClawSessionKey: "agent:main:stale-catalog-key", + }, + ]).hosts; + const request = vi.fn(async () => ({ + sessionKey: "agent:main:current-codex-session", + disposition: "existing", + })); + const onContinue = vi.fn(); + + await continueCodexSession( + state, + clientWithRequest(request), + "gateway:local", + "thread-active", + onContinue, + ); + + expect(request).toHaveBeenCalledWith("codex.sessions.continue", { + hostId: "gateway:local", + threadId: "thread-active", + }); + expect(onContinue).toHaveBeenCalledWith("agent:main:current-codex-session"); + }); + + it("continues or confirmed-archives a not-loaded thread", async () => { + const host = {}; + hosts.push(host); + const state = getCodexSessionsState(host); + state.hosts = gatewayPayload([ + { threadId: "thread-stored", name: "Stored work", status: "notLoaded" }, + ]).hosts; + const request = vi.fn(async (method: string) => { + if (method === "codex.sessions.continue") { + return { sessionKey: "agent:main:codex-fork", disposition: "forked" }; + } + if (method === "codex.sessions.archive") { + return { archived: true }; + } + throw new Error(`Unexpected method: ${method}`); + }); + const client = clientWithRequest(request); + const onContinue = vi.fn(); + + await continueCodexSession(state, client, "gateway:local", "thread-stored", onContinue); + await archiveCodexSession(state, client, "gateway:local", "thread-stored", true); + + expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenCalledWith("codex.sessions.continue", { + hostId: "gateway:local", + threadId: "thread-stored", + }); + expect(request).toHaveBeenCalledWith("codex.sessions.archive", { + hostId: "gateway:local", + threadId: "thread-stored", + confirmNoOtherRunner: true, + }); + expect(onContinue).toHaveBeenCalledWith("agent:main:codex-fork"); + expect(state.hosts[0]?.sessions).toEqual([]); + }); + + it("refuses actions for Codex sessions in an unsupported status", async () => { + const host = {}; + hosts.push(host); + const state = getCodexSessionsState(host); + state.hosts = gatewayPayload([ + { threadId: "thread-error", name: "Broken work", status: "systemError" }, + ]).hosts; + const request = vi.fn(async () => ({ sessionKey: "unexpected" })); + const client = clientWithRequest(request); + + await continueCodexSession(state, client, "gateway:local", "thread-error", vi.fn()); + await archiveCodexSession(state, client, "gateway:local", "thread-error", true); + + expect(request).not.toHaveBeenCalled(); + }); + + it("keeps paired-node sessions metadata-only even while the node is connected", async () => { + const host = {}; + hosts.push(host); + const state = getCodexSessionsState(host); + state.hosts = payload([{ threadId: "thread-remote", name: "Remote work" }]).hosts; + const request = vi.fn(async () => ({ sessionKey: "unexpected" })); + const client = clientWithRequest(request); + + await continueCodexSession(state, client, "node:macbook", "thread-remote", vi.fn()); + await archiveCodexSession(state, client, "node:macbook", "thread-remote", true); + + expect(request).not.toHaveBeenCalled(); + expect(state.hosts[0]?.sessions[0]?.threadId).toBe("thread-remote"); + }); + + it("keeps the canonical row while archiving and removes it after confirmation", async () => { + const host = {}; + hosts.push(host); + const state = getCodexSessionsState(host); + state.hosts = gatewayPayload([{ threadId: "thread-1", name: "Fix tests" }]).hosts; + const response = deferred<{ archived: boolean }>(); + const request = vi.fn(() => response.promise); + const client = clientWithRequest(request); + + await archiveCodexSession(state, client, "gateway:local", "thread-1", false); + expect(request).not.toHaveBeenCalled(); + expect(state.hosts[0]?.sessions[0]?.threadId).toBe("thread-1"); + + const archiving = archiveCodexSession(state, client, "gateway:local", "thread-1", true); + expect(state.hosts[0]?.sessions[0]?.threadId).toBe("thread-1"); + expect(state.pendingSessionActions.get('["gateway:local","thread-1"]')).toBe("archive"); + expect(request).toHaveBeenCalledWith("codex.sessions.archive", { + hostId: "gateway:local", + threadId: "thread-1", + confirmNoOtherRunner: true, + }); + + response.resolve({ archived: true }); + await archiving; + + expect(state.hosts[0]?.sessions).toEqual([]); + expect(state.pendingSessionActions.size).toBe(0); + }); + + it("does not resurrect a confirmed archive from an older first-page response", async () => { + const host = {}; + hosts.push(host); + const state = getCodexSessionsState(host); + state.hosts = gatewayPayload([{ threadId: "thread-1", name: "Archived" }]).hosts; + const listResponse = deferred(); + const request = vi.fn((method: string) => { + if (method === "codex.sessions.list") { + return listResponse.promise; + } + if (method === "codex.sessions.archive") { + return Promise.resolve({ archived: true }); + } + return Promise.reject(new Error(`Unexpected method: ${method}`)); + }); + const client = clientWithRequest(request); + + const loading = loadCodexSessions(state, client); + await archiveCodexSession(state, client, "gateway:local", "thread-1", true); + listResponse.resolve( + gatewayPayload([ + { threadId: "thread-1", name: "Stale archived row" }, + { threadId: "thread-2", name: "Survivor" }, + ]), + ); + await loading; + + expect(state.hosts[0]?.sessions.map((session) => session.threadId)).toEqual(["thread-2"]); + expect(state.pendingSessionActions.size).toBe(0); + }); + + it("restores a refreshed row immediately when an in-flight archive fails", async () => { + const host = {}; + hosts.push(host); + const state = getCodexSessionsState(host); + state.hosts = gatewayPayload([{ threadId: "thread-1", name: "Before refresh" }]).hosts; + const archiveResponse = deferred<{ archived: boolean }>(); + const listResponse = deferred(); + const request = vi.fn((method: string) => { + if (method === "codex.sessions.archive") { + return archiveResponse.promise; + } + if (method === "codex.sessions.list") { + return listResponse.promise; + } + return Promise.reject(new Error(`Unexpected method: ${method}`)); + }); + const client = clientWithRequest(request); + + const archiving = archiveCodexSession(state, client, "gateway:local", "thread-1", true); + const loading = loadCodexSessions(state, client); + listResponse.resolve( + gatewayPayload([ + { threadId: "thread-1", name: "Refreshed while pending" }, + { threadId: "thread-2", name: "Second" }, + ]), + ); + await loading; + + expect(state.hosts[0]?.sessions.map((session) => session.threadId)).toEqual([ + "thread-1", + "thread-2", + ]); + expect(state.pendingSessionActions.get('["gateway:local","thread-1"]')).toBe("archive"); + + archiveResponse.reject(new Error("thread is still active")); + await archiving; + + expect(state.hosts[0]?.sessions.map((session) => session.name)).toEqual([ + "Refreshed while pending", + "Second", + ]); + expect(state.actionError).toBe("thread is still active"); + expect(state.pendingSessionActions.size).toBe(0); + expect(request).toHaveBeenCalledTimes(2); + }); + + it("shows a thread again after a fresh catalog reports it unarchived", async () => { + const host = {}; + hosts.push(host); + const state = getCodexSessionsState(host); + state.hosts = gatewayPayload([{ threadId: "thread-1", name: "Before archive" }]).hosts; + const request = vi.fn((method: string) => { + if (method === "codex.sessions.archive") { + return Promise.resolve({ archived: true }); + } + if (method === "codex.sessions.list") { + return Promise.resolve( + gatewayPayload([{ threadId: "thread-1", name: "Unarchived elsewhere" }]), + ); + } + return Promise.reject(new Error(`Unexpected method: ${method}`)); + }); + const client = clientWithRequest(request); + + await archiveCodexSession(state, client, "gateway:local", "thread-1", true); + expect(state.hosts[0]?.sessions).toEqual([]); + + await loadCodexSessions(state, client); + + expect(state.hosts[0]?.sessions[0]?.threadId).toBe("thread-1"); + }); + + it("restores an optimistically archived row when the Gateway rejects it", async () => { + const host = {}; + hosts.push(host); + const state = getCodexSessionsState(host); + state.hosts = gatewayPayload([ + { threadId: "thread-1", name: "First" }, + { threadId: "thread-2", name: "Second" }, + ]).hosts; + const response = deferred<{ archived: boolean }>(); + + const archiving = archiveCodexSession( + state, + clientWithRequest(() => response.promise), + "gateway:local", + "thread-1", + true, + ); + expect(state.hosts[0]?.sessions.map((session) => session.threadId)).toEqual([ + "thread-1", + "thread-2", + ]); + + response.reject(new Error("thread is still active")); + await archiving; + + expect(state.hosts[0]?.sessions.map((session) => session.threadId)).toEqual([ + "thread-1", + "thread-2", + ]); + expect(state.actionError).toBe("thread is still active"); + expect(state.pendingSessionActions.size).toBe(0); + }); + + it("does not restore a failed archive into a newer catalog result", async () => { + const host = {}; + hosts.push(host); + const state = getCodexSessionsState(host); + state.hosts = gatewayPayload([{ threadId: "thread-old", name: "Old result" }]).hosts; + const archiveResponse = deferred<{ archived: boolean }>(); + const archiveRequest = vi.fn(() => archiveResponse.promise); + const archiving = archiveCodexSession( + state, + clientWithRequest(archiveRequest), + "gateway:local", + "thread-old", + true, + ); + expect(state.hosts[0]?.sessions[0]?.threadId).toBe("thread-old"); + + await loadCodexSessions( + state, + clientWithRequest(async () => + gatewayPayload([{ threadId: "thread-new", name: "New result" }]), + ), + ); + archiveResponse.reject(new Error("thread is still active")); + await archiving; + + expect(state.hosts[0]?.sessions.map((session) => session.threadId)).toEqual(["thread-new"]); + expect(state.actionError).toBe("thread is still active"); + expect(state.pendingSessionActions.size).toBe(0); + }); + it("discards an older response as soon as the search changes", async () => { vi.useFakeTimers(); const host = {}; @@ -92,9 +440,8 @@ describe("Codex sessions controller", () => { expect(state.hosts).toEqual([]); await vi.advanceTimersByTimeAsync(250); - expect(request).toHaveBeenLastCalledWith("codex-supervisor.sessions.list", { + expect(request).toHaveBeenLastCalledWith("codex.sessions.list", { search: "release", - archived: false, limitPerHost: 40, }); second.resolve(payload([{ threadId: "fresh", name: "Release" }])); @@ -119,8 +466,7 @@ describe("Codex sessions controller", () => { await loadMoreCodexSessions(state, clientWithRequest(request), "node:macbook"); - expect(request).toHaveBeenCalledWith("codex-supervisor.sessions.list", { - archived: false, + expect(request).toHaveBeenCalledWith("codex.sessions.list", { limitPerHost: 40, hostIds: ["node:macbook"], cursors: { "node:macbook": "cursor-2" }, @@ -132,6 +478,46 @@ describe("Codex sessions controller", () => { expect(state.hosts[0]?.nextCursor).toBeUndefined(); }); + it("does not resurrect a confirmed archive from an older host page", async () => { + const host = {}; + hosts.push(host); + const state = getCodexSessionsState(host); + state.hosts = gatewayPayload( + [ + { threadId: "thread-1", name: "Archived" }, + { threadId: "thread-2", name: "Survivor" }, + ], + "cursor-2", + ).hosts; + const pageResponse = deferred(); + const request = vi.fn((method: string) => { + if (method === "codex.sessions.list") { + return pageResponse.promise; + } + if (method === "codex.sessions.archive") { + return Promise.resolve({ archived: true }); + } + return Promise.reject(new Error(`Unexpected method: ${method}`)); + }); + const client = clientWithRequest(request); + + const loadingMore = loadMoreCodexSessions(state, client, "gateway:local"); + await archiveCodexSession(state, client, "gateway:local", "thread-1", true); + pageResponse.resolve( + gatewayPayload([ + { threadId: "thread-1", name: "Stale archived row" }, + { threadId: "thread-3", name: "Next page" }, + ]), + ); + await loadingMore; + + expect(state.hosts[0]?.sessions.map((session) => session.threadId)).toEqual([ + "thread-2", + "thread-3", + ]); + expect(state.pendingSessionActions.size).toBe(0); + }); + it("stops pagination when the requested host disappears", async () => { const host = {}; hosts.push(host); @@ -251,8 +637,7 @@ describe("Codex sessions controller", () => { configureCodexSessionsPolling(state, secondClient, true); await vi.waitFor(() => expect(secondRequest).toHaveBeenCalledTimes(1)); - expect(secondRequest).toHaveBeenCalledWith("codex-supervisor.sessions.list", { - archived: false, + expect(secondRequest).toHaveBeenCalledWith("codex.sessions.list", { limitPerHost: 40, }); expect(state.search).toBe(""); diff --git a/ui/src/pages/plugin/codex-sessions-controller.ts b/ui/src/pages/plugin/codex-sessions-controller.ts index 4084f7b32952..9c8e1507cb23 100644 --- a/ui/src/pages/plugin/codex-sessions-controller.ts +++ b/ui/src/pages/plugin/codex-sessions-controller.ts @@ -1,4 +1,4 @@ -// Control UI controller for the Codex Sessions tab: filters, paging, and refresh polling. +// Control UI controller for the Codex Sessions tab: actions, paging, and refresh polling. import type { GatewayBrowserClient } from "../../api/gateway.ts"; export type CodexSessionPayload = { @@ -15,6 +15,7 @@ export type CodexSessionPayload = { modelProvider?: string; cliVersion?: string; gitBranch?: string; + openClawSessionKey?: string; archived: boolean; }; @@ -38,7 +39,6 @@ export type CodexSessionsPayload = { export type CodexSessionsUiState = { hosts: CodexSessionHostPayload[]; search: string; - archived: boolean; loading: boolean; activeLoadCount: number; loadingMoreHostIds: Set; @@ -54,9 +54,22 @@ export type CodexSessionsUiState = { pollRequestToken: object | null; pollClient: GatewayBrowserClient | null; requestUpdate: (() => void) | null; + pendingSessionActions: Map; + archivedSessionKeys: Set; + actionError: string | null; + actionGeneration: number; }; -const LIST_METHOD = "codex-supervisor.sessions.list"; +export type CodexSessionPendingAction = "continue" | "archive"; +export type CodexContinueDisposition = "existing" | "forked"; +export type CodexContinueResult = { + sessionKey: string; + disposition?: CodexContinueDisposition; +}; + +const LIST_METHOD = "codex.sessions.list"; +const CONTINUE_METHOD = "codex.sessions.continue"; +const ARCHIVE_METHOD = "codex.sessions.archive"; const PAGE_SIZE = 40; const POLL_INTERVAL_MS = 30_000; const SEARCH_DEBOUNCE_MS = 250; @@ -69,7 +82,6 @@ export function getCodexSessionsState(host: object): CodexSessionsUiState { state = { hosts: [], search: "", - archived: false, loading: false, activeLoadCount: 0, loadingMoreHostIds: new Set(), @@ -85,6 +97,10 @@ export function getCodexSessionsState(host: object): CodexSessionsUiState { pollRequestToken: null, pollClient: null, requestUpdate: null, + pendingSessionActions: new Map(), + archivedSessionKeys: new Set(), + actionError: null, + actionGeneration: 0, }; codexSessionStates.set(host, state); } @@ -103,11 +119,36 @@ function currentQuery(state: CodexSessionsUiState) { const search = state.search.trim(); return { ...(search ? { search } : {}), - archived: state.archived, limitPerHost: PAGE_SIZE, }; } +function sessionActionKey(hostId: string, threadId: string): string { + return JSON.stringify([hostId, threadId]); +} + +export function getCodexSessionPendingAction( + state: CodexSessionsUiState, + hostId: string, + threadId: string, +): CodexSessionPendingAction | undefined { + return state.pendingSessionActions.get(sessionActionKey(hostId, threadId)); +} + +function filterCatalogSessions( + state: CodexSessionsUiState, + hosts: CodexSessionHostPayload[], +): CodexSessionHostPayload[] { + return hosts.map((host) => ({ + ...host, + sessions: host.sessions.filter( + (session) => + !session.archived && + !state.archivedSessionKeys.has(sessionActionKey(host.hostId, session.threadId)), + ), + })); +} + function mergeRefreshedHosts( state: CodexSessionsUiState, refreshedHosts: CodexSessionHostPayload[], @@ -122,11 +163,12 @@ function mergeRefreshedHosts( return refreshed; } const refreshedThreadIds = new Set(refreshed.sessions.map((session) => session.threadId)); + const retainedSessions = filterCatalogSessions(state, [current])[0]?.sessions ?? []; const sessions = refreshed.error - ? current.sessions + ? retainedSessions : [ ...refreshed.sessions, - ...current.sessions.filter((session) => !refreshedThreadIds.has(session.threadId)), + ...retainedSessions.filter((session) => !refreshedThreadIds.has(session.threadId)), ]; // The retained tail belongs to the last page the user loaded. Its cursor // must stay paired with that tail or the next click would reload page two. @@ -150,9 +192,9 @@ function hasCatalogContext(state: CodexSessionsUiState): boolean { /** Catalog metadata never survives a tab or Gateway security-context teardown. */ function clearCatalogContext(state: CodexSessionsUiState): void { state.requestGeneration += 1; + state.actionGeneration += 1; state.hosts = []; state.search = ""; - state.archived = false; state.loading = false; state.loadingMoreHostIds = new Set(); state.loadingMoreTokens = new Map(); @@ -160,6 +202,9 @@ function clearCatalogContext(state: CodexSessionsUiState): void { state.error = null; state.refreshedAtMs = null; state.hasAttemptedLoad = false; + state.pendingSessionActions = new Map(); + state.archivedSessionKeys = new Set(); + state.actionError = null; } /** Loads a fresh first page for every visible Codex host. */ @@ -174,11 +219,16 @@ export async function loadCodexSessions( state.hasAttemptedLoad = true; state.activeLoadCount += 1; const generation = ++state.requestGeneration; + // A fresh first page supersedes every older list/page request through the + // generation check, so archive race tombstones are no longer needed. This + // also lets a thread reappear after another Codex client unarchives it. + state.archivedSessionKeys = new Set(); if (!options?.silent || state.hosts.length === 0) { state.loading = true; } if (!options?.silent) { state.error = null; + state.actionError = null; } notify(state); try { @@ -186,9 +236,10 @@ export async function loadCodexSessions( if (generation !== state.requestGeneration) { return; } + const visibleHosts = filterCatalogSessions(state, result.hosts); state.hosts = options?.preservePagination - ? mergeRefreshedHosts(state, result.hosts) - : result.hosts; + ? mergeRefreshedHosts(state, visibleHosts) + : visibleHosts; if (options?.preservePagination) { const refreshedHostIds = new Set(result.hosts.map((host) => host.hostId)); state.paginatedHostIds = new Set( @@ -241,7 +292,7 @@ export async function loadMoreCodexSessions( if (generation !== state.requestGeneration) { return; } - const page = result.hosts.find((host) => host.hostId === hostId); + const page = filterCatalogSessions(state, result.hosts).find((host) => host.hostId === hostId); if (!page) { state.hosts = state.hosts.map((host) => host.hostId === hostId @@ -257,13 +308,18 @@ export async function loadMoreCodexSessions( ); return; } - const seenThreadIds = new Set(currentHost.sessions.map((session) => session.threadId)); + const liveHost = state.hosts.find((host) => host.hostId === hostId); + if (!liveHost) { + return; + } + const retainedSessions = filterCatalogSessions(state, [liveHost])[0]?.sessions ?? []; + const seenThreadIds = new Set(retainedSessions.map((session) => session.threadId)); const appendedSessions = page.sessions.filter( (session) => !seenThreadIds.has(session.threadId), ); state.hosts = state.hosts.map((host) => host.hostId === hostId - ? { ...page, sessions: [...currentHost.sessions, ...appendedSessions] } + ? { ...page, sessions: [...retainedSessions, ...appendedSessions] } : host, ); state.paginatedHostIds = new Set(state.paginatedHostIds).add(hostId); @@ -306,20 +362,142 @@ export function setCodexSessionsSearch( notify(state); } -export function setCodexSessionsArchived( +function canRunSessionAction( state: CodexSessionsUiState, - client: GatewayBrowserClient | null, - archived: boolean, + hostId: string, + threadId: string, + action: CodexSessionPendingAction, +): boolean { + const host = state.hosts.find((candidate) => candidate.hostId === hostId); + const session = host?.sessions.find((candidate) => candidate.threadId === threadId); + if (!host?.connected || host.kind !== "gateway" || !session || session.archived) { + return false; + } + const statusSupported = + action === "continue" && Boolean(session.openClawSessionKey?.trim()) + ? true + : session.status === "idle" || session.status === "notLoaded"; + return statusSupported; +} + +function beginSessionAction( + state: CodexSessionsUiState, + hostId: string, + threadId: string, + action: CodexSessionPendingAction, +): { actionKey: string; generation: number } | null { + const actionKey = sessionActionKey(hostId, threadId); + if (state.pendingSessionActions.has(actionKey)) { + return null; + } + state.pendingSessionActions = new Map(state.pendingSessionActions).set(actionKey, action); + state.actionError = null; + notify(state); + return { actionKey, generation: state.actionGeneration }; +} + +function finishSessionAction( + state: CodexSessionsUiState, + actionKey: string, + generation: number, ): void { - if (state.archived === archived) { + if (state.actionGeneration !== generation) { return; } - state.archived = archived; - if (state.searchTimer) { - clearTimeout(state.searchTimer); - state.searchTimer = null; + const pendingSessionActions = new Map(state.pendingSessionActions); + pendingSessionActions.delete(actionKey); + state.pendingSessionActions = pendingSessionActions; + notify(state); +} + +function removeSession(state: CodexSessionsUiState, hostId: string, threadId: string): void { + state.hosts = state.hosts.map((host) => + host.hostId === hostId + ? { + ...host, + sessions: host.sessions.filter((session) => session.threadId !== threadId), + } + : host, + ); +} + +/** Opens existing supervision or a safe fork, then hands its OpenClaw session to Chat. */ +export async function continueCodexSession( + state: CodexSessionsUiState, + client: GatewayBrowserClient | null, + hostId: string, + threadId: string, + onContinue: (sessionKey: string) => void, +): Promise { + if (!client || !canRunSessionAction(state, hostId, threadId, "continue")) { + return; + } + const pending = beginSessionAction(state, hostId, threadId, "continue"); + if (!pending) { + return; + } + try { + const result = await client.request(CONTINUE_METHOD, { + hostId, + threadId, + }); + if (state.actionGeneration !== pending.generation) { + return; + } + const sessionKey = result?.sessionKey?.trim(); + if (!sessionKey) { + throw new Error("Codex continue response did not include a session key"); + } + onContinue(sessionKey); + } catch (error) { + if (state.actionGeneration === pending.generation) { + state.actionError = messageForError(error); + } + } finally { + finishSessionAction(state, pending.actionKey, pending.generation); + } +} + +/** Archives optimistically while the catalog remains authoritative for rollback. */ +export async function archiveCodexSession( + state: CodexSessionsUiState, + client: GatewayBrowserClient | null, + hostId: string, + threadId: string, + confirmNoOtherRunner: boolean, +): Promise { + if (!client || !confirmNoOtherRunner) { + return; + } + if (!canRunSessionAction(state, hostId, threadId, "archive")) { + return; + } + const pending = beginSessionAction(state, hostId, threadId, "archive"); + if (!pending) { + return; + } + try { + const result = await client.request<{ archived: boolean }>(ARCHIVE_METHOD, { + hostId, + threadId, + confirmNoOtherRunner: true, + }); + if (!result?.archived) { + throw new Error("Codex archive response did not confirm the archive"); + } + if (state.actionGeneration === pending.generation) { + // Keep a tombstone until the next fresh first-page request. An older list or page + // request can otherwise resolve after the pending action clears and restore this row. + state.archivedSessionKeys = new Set(state.archivedSessionKeys).add(pending.actionKey); + removeSession(state, hostId, threadId); + } + } catch (error) { + if (state.actionGeneration === pending.generation) { + state.actionError = messageForError(error); + } + } finally { + finishSessionAction(state, pending.actionKey, pending.generation); } - void loadCodexSessions(state, state.pollClient ?? client); } function clearPollTimer(state: CodexSessionsUiState): void { diff --git a/ui/src/pages/plugin/codex-sessions-view.test.ts b/ui/src/pages/plugin/codex-sessions-view.test.ts index dc1b053aba65..49d74c7b24bb 100644 --- a/ui/src/pages/plugin/codex-sessions-view.test.ts +++ b/ui/src/pages/plugin/codex-sessions-view.test.ts @@ -1,5 +1,5 @@ import { render } from "lit"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import { getCodexSessionsState, stopCodexSessionsPolling } from "./codex-sessions-controller.ts"; import { renderCodexSessions } from "./codex-sessions-view.ts"; @@ -12,6 +12,7 @@ describe("Codex sessions view", () => { for (const host of hosts.splice(0)) { stopCodexSessionsPolling(host); } + vi.restoreAllMocks(); }); it("groups session metadata by host while preserving partial host errors", () => { @@ -62,6 +63,249 @@ describe("Codex sessions view", () => { expect(container.querySelector(".codex-sessions__partial")?.textContent).toContain( "Unavailable hosts: 1", ); + expect( + (container.querySelector(".codex-session__continue") as HTMLButtonElement).disabled, + ).toBe(true); + expect((container.querySelector(".codex-session__archive") as HTMLButtonElement).disabled).toBe( + true, + ); + }); + + it("continues an idle row as a branch through the supplied navigation callback", async () => { + const host = {}; + hosts.push(host); + const state = getCodexSessionsState(host); + state.refreshedAtMs = Date.now(); + state.hosts = [ + { + hostId: "gateway:local", + label: "Gateway", + kind: "gateway", + connected: true, + sessions: [ + { + threadId: "thread-idle", + name: "Finish the release", + status: "idle", + archived: false, + }, + ], + }, + ]; + const request = vi.fn(async () => ({ + sessionKey: "agent:main:codex-release", + disposition: "forked", + })); + const actionClient = { request } as unknown as GatewayBrowserClient; + const onContinueSession = vi.fn(); + const container = document.createElement("div"); + + render( + renderCodexSessions({ + host, + client: actionClient, + connected: true, + onContinueSession, + }), + container, + ); + const continueButton = container.querySelector(".codex-session__continue") as HTMLButtonElement; + expect(continueButton.getAttribute("aria-label")).toBe( + "Continue Finish the release as a branch", + ); + expect(continueButton.textContent).toContain("Continue as branch"); + continueButton.click(); + + await vi.waitFor(() => + expect(request).toHaveBeenCalledWith("codex.sessions.continue", { + hostId: "gateway:local", + threadId: "thread-idle", + }), + ); + await vi.waitFor(() => + expect(onContinueSession).toHaveBeenCalledWith("agent:main:codex-release"), + ); + }); + + it("revalidates an already adopted active session through Continue before opening it", async () => { + const host = {}; + hosts.push(host); + const state = getCodexSessionsState(host); + state.refreshedAtMs = Date.now(); + state.hosts = [ + { + hostId: "gateway:local", + label: "Gateway", + kind: "gateway", + connected: true, + sessions: [ + { + threadId: "thread-adopted", + name: "Already supervised", + status: "active", + openClawSessionKey: " agent:main:adopted-codex ", + archived: false, + }, + ], + }, + ]; + const request = vi.fn(async () => ({ + sessionKey: "agent:main:current-adopted-codex", + disposition: "existing", + })); + const onContinueSession = vi.fn(); + const container = document.createElement("div"); + + render( + renderCodexSessions({ + host, + client: { request } as unknown as GatewayBrowserClient, + connected: true, + onContinueSession, + }), + container, + ); + const openButton = container.querySelector(".codex-session__continue") as HTMLButtonElement; + expect(openButton.disabled).toBe(false); + expect(openButton.getAttribute("aria-label")).toBe("Open Chat for Already supervised"); + expect(openButton.textContent).toContain("Open Chat"); + openButton.click(); + + await vi.waitFor(() => + expect(request).toHaveBeenCalledWith("codex.sessions.continue", { + hostId: "gateway:local", + threadId: "thread-adopted", + }), + ); + await vi.waitFor(() => + expect(onContinueSession).toHaveBeenCalledWith("agent:main:current-adopted-codex"), + ); + }); + + it("requires an explicit no-other-runner confirmation before archiving", async () => { + const host = {}; + hosts.push(host); + const state = getCodexSessionsState(host); + state.refreshedAtMs = Date.now(); + state.hosts = [ + { + hostId: "gateway:local", + label: "Gateway", + kind: "gateway", + connected: true, + sessions: [ + { + threadId: "thread-archive", + name: "Finished work", + status: "idle", + archived: false, + }, + ], + }, + ]; + const request = vi.fn(async () => ({ archived: true })); + const confirm = vi.spyOn(globalThis, "confirm").mockReturnValue(true); + const container = document.createElement("div"); + + render( + renderCodexSessions({ + host, + client: { request } as unknown as GatewayBrowserClient, + connected: true, + }), + container, + ); + (container.querySelector(".codex-session__archive") as HTMLButtonElement).click(); + + expect(confirm).toHaveBeenCalledWith( + "Archive Finished work and any spawned descendants? Confirm that no other Codex client or OpenClaw runner is using them. Archiving while another runner is active may interrupt its work.", + ); + await vi.waitFor(() => + expect(request).toHaveBeenCalledWith("codex.sessions.archive", { + hostId: "gateway:local", + threadId: "thread-archive", + confirmNoOtherRunner: true, + }), + ); + }); + + it("keeps actions disabled for sessions on an offline host", () => { + const host = {}; + hosts.push(host); + const state = getCodexSessionsState(host); + state.refreshedAtMs = Date.now(); + state.hosts = [ + { + hostId: "node:offline", + label: "Offline Mac", + kind: "node", + connected: false, + sessions: [ + { + threadId: "thread-offline", + name: "Stored elsewhere", + status: "notLoaded", + archived: false, + }, + ], + }, + ]; + const container = document.createElement("div"); + + render(renderCodexSessions({ host, client, connected: true }), container); + + expect( + (container.querySelector(".codex-session__continue") as HTMLButtonElement).disabled, + ).toBe(true); + expect((container.querySelector(".codex-session__archive") as HTMLButtonElement).disabled).toBe( + true, + ); + expect(container.querySelector(".codex-session__continue")?.getAttribute("title")).toBe( + "Reconnect this computer before managing its Codex sessions.", + ); + expect(container.querySelector(".codex-session__view-only")?.textContent).toContain( + "Paired-computer sessions are view-only for now.", + ); + }); + + it("explains that connected paired-computer sessions are view-only", () => { + const host = {}; + hosts.push(host); + const state = getCodexSessionsState(host); + state.refreshedAtMs = Date.now(); + state.hosts = [ + { + hostId: "node:remote", + label: "Remote Mac", + kind: "node", + connected: true, + nextCursor: "next-page", + sessions: [ + { + threadId: "thread-remote", + name: "Stored remotely", + status: "notLoaded", + archived: false, + }, + ], + }, + ]; + const container = document.createElement("div"); + + render(renderCodexSessions({ host, client, connected: true }), container); + + const continueButton = container.querySelector(".codex-session__continue") as HTMLButtonElement; + expect(continueButton.disabled).toBe(true); + expect(continueButton.title).toBe("Paired-computer sessions are view-only for now."); + expect(container.querySelector(".codex-session__view-only")?.textContent).toContain( + "Paired-computer sessions are view-only for now.", + ); + expect((container.querySelector(".codex-session__archive") as HTMLButtonElement).disabled).toBe( + true, + ); + expect(container.querySelector(".codex-host__footer button")?.getAttribute("aria-label")).toBe( + "Load more — Remote Mac", + ); }); it("keeps transcript-derived preview text out of the session rows", () => { @@ -93,6 +337,20 @@ describe("Codex sessions view", () => { "Untitled Codex session", ); expect(container.querySelector(".codex-session__preview")).toBeNull(); + expect(container.querySelector(".codex-sessions__scope")).toBeNull(); + expect(container.textContent).not.toContain("read-only"); + expect(container.textContent).toContain("Stored / activity unknown"); + const continueButton = container.querySelector(".codex-session__continue") as HTMLButtonElement; + expect(continueButton.disabled).toBe(false); + expect(continueButton.textContent).toContain("Continue as branch"); + expect(continueButton.title).toBe( + "Create a Chat from persisted visible history. On your first message, Codex App Server selects the model and provider for the new harness thread. Later selection remains Codex-controlled; OpenClaw never substitutes another runtime, model, or fallback. The source remains untouched, and in-flight work may be absent.", + ); + const archiveButton = container.querySelector(".codex-session__archive") as HTMLButtonElement; + expect(archiveButton.disabled).toBe(false); + expect(archiveButton.title).toBe( + "Activity is unknown because status is process-local. Archive only after confirming that no other Codex client or runner is using this session.", + ); }); it("renders a Codex system error as a localized error status", () => { @@ -123,6 +381,12 @@ describe("Codex sessions view", () => { const status = container.querySelector(".codex-session__status"); expect(status?.textContent).toContain("System error"); expect(status?.classList.contains("codex-session__status--error")).toBe(true); + expect( + (container.querySelector(".codex-session__continue") as HTMLButtonElement).disabled, + ).toBe(true); + expect((container.querySelector(".codex-session__archive") as HTMLButtonElement).disabled).toBe( + true, + ); }); it("disables every request control while the Gateway is offline", () => { diff --git a/ui/src/pages/plugin/codex-sessions-view.ts b/ui/src/pages/plugin/codex-sessions-view.ts index e7568a12e479..72396aacc659 100644 --- a/ui/src/pages/plugin/codex-sessions-view.ts +++ b/ui/src/pages/plugin/codex-sessions-view.ts @@ -1,15 +1,17 @@ -// Control UI view for read-only Codex sessions federated through the Gateway. +// Control UI view for continuing and archiving Codex sessions through the Gateway. import { html, nothing, type TemplateResult } from "lit"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import { icons } from "../../components/icons.ts"; import { t } from "../../i18n/index.ts"; import { formatDateTimeMs, formatRelativeTimestamp } from "../../lib/format.ts"; import { + archiveCodexSession, configureCodexSessionsPolling, + continueCodexSession, + getCodexSessionPendingAction, getCodexSessionsState, loadCodexSessions, loadMoreCodexSessions, - setCodexSessionsArchived, setCodexSessionsSearch, type CodexSessionHostPayload, type CodexSessionPayload, @@ -21,6 +23,7 @@ type CodexSessionsProps = { client: GatewayBrowserClient | null; connected: boolean; onRequestUpdate?: () => void; + onContinueSession?: (sessionKey: string) => void; }; function timestampMs(value: number | null | undefined): number | undefined { @@ -47,7 +50,7 @@ function displayStatus(session: CodexSessionPayload): string { case "idle": return t("codexSessions.status.idle"); case "notLoaded": - return t("codexSessions.status.notLoaded"); + return t("codexSessions.status.storedActivityUnknown"); case "systemError": return t("codexSessions.status.systemError"); default: @@ -101,9 +104,113 @@ function renderThreadMeta(session: CodexSessionPayload): TemplateResult { `; } -function renderSession(session: CodexSessionPayload): TemplateResult { +function hostActionUnavailableReason( + host: CodexSessionHostPayload, + interactionsEnabled: boolean, +): string | undefined { + if (!interactionsEnabled) { + return t("codexSessions.actions.gatewayOffline"); + } + if (!host.connected) { + return t("codexSessions.actions.hostOffline"); + } + if (host.kind !== "gateway") { + return t("codexSessions.actions.remoteReadOnly"); + } + return undefined; +} + +function continueUnavailableReason( + host: CodexSessionHostPayload, + session: CodexSessionPayload, + interactionsEnabled: boolean, + opensExistingChat: boolean, +): string | undefined { + const hostReason = hostActionUnavailableReason(host, interactionsEnabled); + if (hostReason) { + return hostReason; + } + if (opensExistingChat) { + return undefined; + } + if (session.status === "active") { + return t("codexSessions.actions.active"); + } + if (session.status !== "idle" && session.status !== "notLoaded") { + return t("codexSessions.actions.statusUnavailable"); + } + return undefined; +} + +function confirmCodexArchive(title: string): boolean { + return ( + typeof globalThis.confirm === "function" && + globalThis.confirm(t("codexSessions.actions.archiveConfirmation", { title })) + ); +} + +function archiveUnavailableReason( + host: CodexSessionHostPayload, + session: CodexSessionPayload, + interactionsEnabled: boolean, +): string | undefined { + const hostReason = hostActionUnavailableReason(host, interactionsEnabled); + if (hostReason) { + return hostReason; + } + if (session.status === "active") { + return t("codexSessions.actions.active"); + } + if (session.status !== "idle" && session.status !== "notLoaded") { + return t("codexSessions.actions.statusUnavailable"); + } + return undefined; +} + +function renderSession( + state: CodexSessionsUiState, + client: GatewayBrowserClient | null, + host: CodexSessionHostPayload, + interactionsEnabled: boolean, + onContinueSession: ((sessionKey: string) => void) | undefined, + session: CodexSessionPayload, +): TemplateResult { + const title = displayTitle(session); + const pendingAction = getCodexSessionPendingAction(state, host.hostId, session.threadId); + const openClawSessionKey = session.openClawSessionKey?.trim(); + const opensExistingChat = Boolean(openClawSessionKey); + const continueAsBranch = + !opensExistingChat && (session.status === "idle" || session.status === "notLoaded"); + const continueLabel = opensExistingChat + ? t("codexSessions.actions.openChatLabel", { title }) + : continueAsBranch + ? t("codexSessions.actions.continueAsBranchLabel", { title }) + : t("codexSessions.actions.continueLabel", { title }); + const continueReason = continueUnavailableReason( + host, + session, + interactionsEnabled, + opensExistingChat, + ); + const archiveReason = archiveUnavailableReason(host, session, interactionsEnabled); + const continueTitle = + continueReason ?? + (continueAsBranch ? t("codexSessions.actions.continueAsBranchHint") : continueLabel); + const archiveTitle = + archiveReason ?? + (session.status === "notLoaded" + ? t("codexSessions.actions.archiveActivityUnknownHint") + : t("codexSessions.actions.archiveLabel", { title })); + const continueDisabled = Boolean(continueReason || pendingAction); + const archiveDisabled = Boolean(archiveReason || pendingAction); + const remoteViewOnly = host.kind !== "gateway"; return html` -
+
@@ -117,18 +224,77 @@ function renderSession(session: CodexSessionPayload): TemplateResult {
${t("codexSessions.threadId")} ${session.threadId}
+ ${remoteViewOnly + ? html`
+ ${icons.eye}${t("codexSessions.actions.remoteReadOnly")} +
` + : nothing} +
+
+ +
`; } +function visibleSessionsForHost( + state: CodexSessionsUiState, + host: CodexSessionHostPayload, +): CodexSessionPayload[] { + return host.sessions.filter( + (session) => getCodexSessionPendingAction(state, host.hostId, session.threadId) !== "archive", + ); +} + function renderHost( state: CodexSessionsUiState, client: GatewayBrowserClient | null, host: CodexSessionHostPayload, interactionsEnabled: boolean, + onContinueSession: ((sessionKey: string) => void) | undefined, ): TemplateResult { const loadingMore = state.loadingMoreHostIds.has(host.hostId); + const visibleSessions = visibleSessionsForHost(state, host); const statusLabel = host.connected ? t("codexSessions.host.connected") : t("codexSessions.host.offline"); @@ -152,7 +318,7 @@ function renderHost( ${statusLabel} - ${t("codexSessions.host.sessionCount", { count: String(host.sessions.length) })} + ${t("codexSessions.host.sessionCount", { count: String(visibleSessions.length) })}
${host.nodeId || host.endpointId @@ -172,15 +338,17 @@ function renderHost( ` : nothing} - ${host.sessions.length > 0 - ? html`
${host.sessions.map(renderSession)}
` + ${visibleSessions.length > 0 + ? html`
+ ${visibleSessions.map((session) => + renderSession(state, client, host, interactionsEnabled, onContinueSession, session), + )} +
` : !host.error ? html`
${state.search.trim() ? t("codexSessions.empty.search") - : state.archived - ? t("codexSessions.empty.archived") - : t("codexSessions.empty.active")} + : t("codexSessions.empty.nonArchived")}
` : nothing} ${host.nextCursor @@ -188,6 +356,7 @@ function renderHost( - -