diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index c9ef784d5a82..f20d080a97ea 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -108,7 +108,7 @@ permissions: concurrency: group: full-release-validation-${{ inputs.ref }}-${{ inputs.rerun_group }} - cancel-in-progress: ${{ inputs.ref == 'main' && inputs.rerun_group == 'all' }} + cancel-in-progress: ${{ (inputs.ref == 'main' && inputs.rerun_group == 'all') || startsWith(inputs.ref, 'tideclaw/alpha/') }} env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" diff --git a/.github/workflows/mantis-telegram-desktop-proof.yml b/.github/workflows/mantis-telegram-desktop-proof.yml index 95c72f2dc436..ad4cb00a072b 100644 --- a/.github/workflows/mantis-telegram-desktop-proof.yml +++ b/.github/workflows/mantis-telegram-desktop-proof.yml @@ -484,6 +484,7 @@ jobs: - name: Release leaked Telegram proof leases if: ${{ always() }} env: + CRABBOX_PROVIDER: ${{ needs.resolve_request.outputs.crabbox_provider }} OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }} OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }} shell: bash @@ -492,17 +493,34 @@ jobs: if [[ ! -d .artifacts/qa-e2e ]]; then exit 0 fi + status=0 + mapfile -d '' session_files < <(sudo find .artifacts/qa-e2e -path '*/telegram-user-crabbox/*/session.json' -type f -print0) + for session_file in "${session_files[@]}"; do + lease_file="${session_file%/session.json}/.session/lease.json" + if [[ ! -f "$lease_file" ]]; then + continue + fi + if ! sudo -u codex env \ + OPENCLAW_QA_CONVEX_SECRET_CI="$OPENCLAW_QA_CONVEX_SECRET_CI" \ + OPENCLAW_QA_CONVEX_SITE_URL="$OPENCLAW_QA_CONVEX_SITE_URL" \ + OPENCLAW_TELEGRAM_USER_CRABBOX_BIN=/usr/local/bin/crabbox \ + OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER="$CRABBOX_PROVIDER" \ + node --import tsx "$GITHUB_WORKSPACE/scripts/e2e/telegram-user-crabbox-proof.ts" \ + finish --session "$session_file" --preview-crop telegram-window; then + status=1 + fi + done mapfile -d '' lease_files < <(sudo find .artifacts/qa-e2e -path '*/telegram-user-crabbox/*/.session/lease.json' -type f -print0) - if [[ "${#lease_files[@]}" -eq 0 ]]; then - exit 0 - fi for lease_file in "${lease_files[@]}"; do - sudo -u codex env \ + if ! sudo -u codex env \ OPENCLAW_QA_CONVEX_SECRET_CI="$OPENCLAW_QA_CONVEX_SECRET_CI" \ OPENCLAW_QA_CONVEX_SITE_URL="$OPENCLAW_QA_CONVEX_SITE_URL" \ node --import tsx "$GITHUB_WORKSPACE/scripts/e2e/telegram-user-credential.ts" \ - release --lease-file "$lease_file" + release --lease-file "$lease_file"; then + status=1 + fi done + exit "$status" - name: Inspect Mantis evidence manifest id: inspect diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index 12bb52a21bfd..9d303524cb9c 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -32,7 +32,7 @@ on: concurrency: group: openclaw-npm-release-${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}', inputs.tag, inputs.npm_dist_tag) || github.ref }} - cancel-in-progress: false + cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' && inputs.preflight_only && inputs.npm_dist_tag == 'alpha' }} env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 3b99940d462d..8a5a044e246d 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -81,7 +81,7 @@ on: concurrency: group: openclaw-release-checks-${{ inputs.expected_sha || inputs.ref }}-${{ inputs.rerun_group }} - cancel-in-progress: false + cancel-in-progress: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }} env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fe43fa055f1..863a989da2da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/message: include a stable top-level `messageId` in `openclaw message --json` output when channel sends return one. (#84191) Thanks @100menotu001. +- Plugins/hooks: apply a default 30-second timeout to `before_compaction` and `after_compaction` hooks so a hung plugin handler no longer blocks compaction completion. (#84153) +- Discord: preserve disabled presentation buttons when adapting and rendering Discord message controls. (#84188) Thanks @100menotu001. +- Plugins/perf: thread explicit plugin discovery results through `loadBundledCapabilityRuntimeRegistry`, `resolveBundledPluginSources`, and `listChannelCatalogEntries` so callers that already hold a discovery result skip redundant filesystem walks. Thanks @SebTardif. +- harden update restart script creation [AI]. (#84088) Thanks @pgondhi987. - Docker: keep the bundled Codex plugin in official release image keep lists so the default OpenAI agent harness remains available after Docker pruning. Fixes #83613. (#83626) Thanks @YuanHanzhong. - CLI/channels: preserve the first line of `openclaw channels logs` output when the rolling tail window starts exactly on a line boundary, mirroring the already-fixed `readLogSlice` behavior in `src/logging/log-tail.ts`. - Control UI: treat terminal session status as authoritative over stale active-run flags so completed terminal runs stop showing abort/live UI. (#84057) @@ -19,15 +24,22 @@ Docs: https://docs.openclaw.ai - CLI: format `openclaw acp client` failures through the shared error formatter so object-shaped errors stay readable instead of printing `[object Object]`. Fixes #83904. (#84080) - Providers/Ollama: default unknown-capabilities models to tool-capable so discovered native Ollama models can use tools when `/api/show` omits capabilities. (#84055) Thanks @dutifulbob. - Installer/Windows: launch `install.ps1` onboarding as an attached child process so fresh native Windows installs do not freeze visibly at `Starting setup...` or corrupt the wizard's terminal rendering. +- CLI/update: keep restart health checks working across one-version CLI/Gateway protocol skew and use the managed Gateway service Node for all follow-up commands even when the package root is unchanged, so `openclaw update` no longer silently switches the gateway to a different Node binary when multiple Node installations are present. Thanks @amknight. - Memory/search: close local embedding providers when active-memory searches time out so pending local model loads and embedding contexts are aborted and released. (#83858) Thanks @brokemac79. + - Agents: include bounded trajectory queued-writer diagnostics in `pi-trajectory-flush` timeout warnings so flush stalls show pending writes, queued bytes, and append state. Fixes #82961. (#82962) Thanks @galiniliev. - Agents/subagents: recover stale completion announces by retrying unsupported transcript-wait wakes without transcript waiting and forcing a message-tool handoff when the requester run is already stale. Fixes #83699. (#83700) Thanks @galiniliev. +- Agents: honor explicit `models.providers..timeoutSeconds` values above the default idle watchdog for cloud and self-hosted providers, so long first-token waits no longer fall back at ~120s when the provider timeout is higher. (#83979) Thanks @yujiawei. - Agents/subagents: skip stale embedded-run wake probes for dormant completion requesters, so late subagent completions go straight to requester-agent/direct handoff instead of producing `reason=no_active_run` queue noise. (#82964) Thanks @galiniliev. - CLI: retry config snapshot reads after a transient failure so one rejected read no longer poisons later commands in the same process. (#83931) Thanks @honor2030. - Media: decode URL path basenames before using them as remote media fallback filenames, so files like `My%20Report.pdf` are surfaced as `My Report.pdf`. Fixes #84050. (#84052) Thanks @jbetala7. - WhatsApp: clarify inbound group diagnostics so observed but unregistered groups point to `channels.whatsapp.groups` without changing routing or sender authorization. (#83846) Thanks @neeravmakwana. - WhatsApp: drain pending outbound deliveries on a 30s periodic timer in addition to the reconnect handler, so messages enqueued while the provider is already connected no longer wait for the next reconnect to send. (#79083) Thanks @Oviemudiaga. - CLI/TUI: include gateway plugin slash commands in TUI autocomplete, so connected sessions can suggest plugin-owned commands exposed by the running Gateway. (#83640) Thanks @se7en-agent. +- Gateway/mobile: restore QR setup-code handoff of bounded operator tokens for iOS and Android onboarding while keeping admin and pairing scopes out of bootstrap. (#83684) Thanks @ngutman. +- iOS: repair Release archive compilation for the TestFlight build. (#84255) Thanks @ngutman. +- Agents/compaction: bound plugin-owned CLI transcript compaction with the host safety timeout so a hung context engine can no longer stall post-turn cleanup. (#84083) Thanks @100yenadmin. +- Control UI/usage: truncate long context skill, tool, and file names in the usage panel while keeping the full name available on hover. (#42197) Thanks @Rain120. ## 2026.5.19 @@ -55,6 +67,7 @@ Docs: https://docs.openclaw.ai - Agents/skills: tighten bundled skill prompts and metadata, quote skill descriptions, refresh current CLI/API guidance, and update embedded sherpa-onnx runtime downloads. - Skills: update the Obsidian skill to target the official `obsidian` CLI and require its registered binary instead of the third-party `obsidian-cli`. - Skills: add a Python debugging skill for pdb, breakpoint(), post-mortem inspection, and debugpy remote attach. +- Codex: add `/codex plugins list`, `enable`, and `disable` for managing configured native Codex plugins from chat without editing config by hand. - Plugins/messages: add presentation capability limits for channel renderers, adapt rich message controls before native rendering, and mark legacy `interactive`/Slack directive producer APIs as deprecated. - Plugins/subagents: store channel delivery routes as canonical session metadata and deprecate ad hoc subagent hook delivery-origin fields in favor of core route projection. - Proxy: support HTTPS managed forward-proxy endpoints and scoped `proxy.tls.caFile` CA trust for proxy endpoint TLS. (#79171) Thanks @jesse-merhi. @@ -86,6 +99,7 @@ Docs: https://docs.openclaw.ai - Telegram: keep queued forum-topic follow-up messages from inheriting superseded source abort signals, so later same-topic user turns can still run and reply after an active turn is replaced. (#83827) Thanks @VACInc. - CLI/update: bypass npm freshness filters consistently during managed package and plugin installs so freshly published release plugins remain installable. Thanks @jalehman. - CLI/update: guide root-owned npm install EACCES recovery by stopping the managed Gateway before manual package replacement, then reinstalling and restarting the service. Fixes #83747. (#83757) Thanks @brokemac79. +- Twitch: register refreshing chat tokens with Twurple's chat intent so automatic token refresh keeps chat access available. (#83750) Thanks @TurboTheTurtle. - Agents/subagents: keep collect-mode announce queues batching unresolved-origin items with compatible same-route messages and resume collection after a true cross-channel drain when a later compatible batch remains. Fixes #83577. - Skills: refresh existing session skill snapshots when watched skill roots change, so changed extra skill directories take effect without starting a new session. Fixes #83782. (#83800) Thanks @hclsys. - Providers/Anthropic: preserve native image input for current Claude model rows when stale local catalog data marks them text-only. (#83756) Thanks @TurboTheTurtle. @@ -113,6 +127,7 @@ Docs: https://docs.openclaw.ai - Release stability: repair broad-gate regressions in requester-agent completion handoff, QA-Lab mock spawn attribution, Slack monitor test isolation, plugin uninstall peer fixtures, and Node-floor launcher contract coverage. - Agents/replies: persist queued follow-up user messages and assistant error stubs only once across model-fallback retries, preventing repeated provider rejections from corrupted same-role session transcripts. Fixes #83404. (#83417) Thanks @yetval. - Telegram: preserve reply-target context for bare mention replies on runtime-only turns so the model sees the replied-to message body. Fixes #83767. (#83953) Thanks @joshavant. +- ClawHub: preserve configured base URL path prefixes when building API request URLs, so self-hosted ClawHub instances mounted under a subpath keep routing correctly. (#83982) Thanks @ThiagoCAltoe. - Slack: persist delivered inbound message IDs and fail closed when same-channel thread replies lose their thread context, preventing delayed duplicate replies and accidental channel-root posts. Fixes #83521. Thanks @shannon0430. - Codex app-server: complete OpenClaw dynamic tool diagnostics at the request boundary so successful, failed, timed out, aborted, and blocked tool calls do not leave active tool state behind. Fixes #83474. Thanks @rozmiarD. - Gateway/config: keep config writes from failing on unrelated unresolved auth-profile SecretRefs while preserving live auth-profile runtime snapshots. diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index 75fda7c2941e..26a0379a8af4 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -605,7 +605,6 @@ class GatewaySession( setOf( "operator.approvals", "operator.read", - "operator.talk.secrets", "operator.write", ) scopes.filter { allowedOperatorScopes.contains(it) }.distinct().sorted() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt index 3ba5af815036..5c2471e26c9a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt @@ -162,7 +162,14 @@ class ConnectionManager( fun buildOperatorConnectOptions(): GatewayConnectOptions = GatewayConnectOptions( role = "operator", - scopes = listOf("operator.read", "operator.write", "operator.talk.secrets"), + // QR bootstrap hands Android a bounded operator token that includes approvals; keep the + // default operator reconnect request aligned so the post-bootstrap loop can approve work. + scopes = + listOf( + "operator.approvals", + "operator.read", + "operator.write", + ), caps = emptyList(), commands = emptyList(), permissions = emptyMap(), diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt index ab4a27f8bd43..270a2b4f5fea 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt @@ -365,7 +365,7 @@ class GatewaySessionInvokeTest { assertEquals(emptyList(), nodeEntry?.scopes) assertEquals("bootstrap-operator-token", operatorEntry?.token) assertEquals( - listOf("operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"), + listOf("operator.approvals", "operator.read", "operator.write"), operatorEntry?.scopes, ) } finally { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt index b2402f544c38..49df1ab14c3a 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt @@ -368,6 +368,20 @@ class ConnectionManagerTest { assertEquals(false, params?.allowTOFU) } + @Test + fun buildOperatorConnectOptions_requestsQrBootstrapHandoffScopes() { + val options = newManager().buildOperatorConnectOptions() + + assertEquals( + listOf( + "operator.approvals", + "operator.read", + "operator.write", + ), + options.scopes, + ) + } + @Test fun buildNodeConnectOptions_advertisesRequestableSmsSearchWithoutSmsCapability() { val options = diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift new file mode 100644 index 000000000000..1db8a9345c34 --- /dev/null +++ b/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift @@ -0,0 +1,31 @@ +import Foundation +import OpenClawKit + +enum GatewayOnboardingReset { + @MainActor + static func reset( + appModel: NodeAppModel, + instanceId: String, + defaults: UserDefaults = .standard) + { + appModel.disconnectGateway() + + let trimmedInstanceId = instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedInstanceId.isEmpty { + GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId) + } + + GatewaySettingsStore.clearLastGatewayConnection() + GatewaySettingsStore.clearPreferredGatewayStableID() + GatewaySettingsStore.clearLastDiscoveredGatewayStableID() + GatewayTLSStore.clearAllFingerprints() + OnboardingStateStore.reset(defaults: defaults) + + defaults.set(false, forKey: "gateway.onboardingComplete") + defaults.set(false, forKey: "gateway.hasConnectedOnce") + defaults.set(false, forKey: "gateway.manual.enabled") + defaults.set("", forKey: "gateway.manual.host") + defaults.set("", forKey: "gateway.setupCode") + defaults.set(defaults.integer(forKey: "onboarding.requestID") + 1, forKey: "onboarding.requestID") + } +} diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 61abd4460190..0e85c542e683 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -1016,10 +1016,24 @@ struct OnboardingWizardView: View { } private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String { - problem.canTrustRotatedCertificate ? "Trust certificate" : "Retry connection" + if problem.suggestsOnboardingReset { return "Scan QR again" } + return problem.canTrustRotatedCertificate ? "Trust certificate" : "Retry connection" } private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) async { + if problem.suggestsOnboardingReset { + GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId) + self.gatewayToken = "" + self.gatewayPassword = "" + self.connectingGatewayID = nil + self.connectMessage = nil + self.issue = .none + self.pairingRequestId = nil + self.statusLine = "Scan a fresh setup QR code from this gateway." + self.step = .connect + self.showQRScanner = true + return + } if problem.canTrustRotatedCertificate { self.connectingGatewayID = "trust-certificate" self.connectMessage = "Updating gateway certificate…" diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 7a92b47ad627..fdf5d9310a17 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -15,6 +15,7 @@ struct RootCanvas: View { @AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0 @AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false @AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false + @AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString @AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = "" @AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false @AppStorage("gateway.manual.host") private var manualGatewayHost: String = "" @@ -102,6 +103,9 @@ struct RootCanvas: View { }, retryGatewayConnection: { Task { await self.gatewayController.connectLastKnown() } + }, + resetOnboarding: { + self.resetOnboardingFromGatewayProblem() }) .preferredColorScheme(.dark) @@ -429,6 +433,13 @@ struct RootCanvas: View { guard shouldPresent else { return } self.presentedSheet = .quickSetup } + + private func resetOnboardingFromGatewayProblem() { + GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId) + self.presentedSheet = nil + self.onboardingAllowSkip = false + self.showOnboarding = true + } } private struct HomeCanvasPayload: Codable { @@ -469,6 +480,7 @@ private struct CanvasContent: View { var openChat: () -> Void var openSettings: () -> Void var retryGatewayConnection: () -> Void + var resetOnboarding: () -> Void private var brightenButtons: Bool { self.systemColorScheme == .light @@ -578,12 +590,15 @@ private struct CanvasContent: View { private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String { if problem.canTrustRotatedCertificate { return "Trust certificate" } + if problem.suggestsOnboardingReset { return "Reset onboarding" } return problem.retryable ? "Retry" : "Open Settings" } private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) { if problem.canTrustRotatedCertificate { Task { await self.gatewayController.trustRotatedGatewayCertificate(from: problem) } + } else if problem.suggestsOnboardingReset { + self.resetOnboarding() } else if problem.retryable { self.retryGatewayConnection() } else { diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 5524c81e1242..904108406abb 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -1057,10 +1057,15 @@ struct SettingsTab: View { } private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String { - problem.canTrustRotatedCertificate ? "Trust certificate" : "Retry connection" + if problem.suggestsOnboardingReset { return "Reset onboarding" } + return problem.canTrustRotatedCertificate ? "Trust certificate" : "Retry connection" } private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) async { + if problem.suggestsOnboardingReset { + self.resetOnboarding() + return + } if problem.canTrustRotatedCertificate { _ = await self.gatewayController.trustRotatedGatewayCertificate(from: problem) return @@ -1070,7 +1075,6 @@ struct SettingsTab: View { private func resetOnboarding() { // Disconnect first so RootCanvas doesn't instantly mark onboarding complete again. - self.appModel.disconnectGateway() self.connectingGatewayID = nil self.setupStatusText = nil self.setupCode = "" @@ -1082,19 +1086,7 @@ struct SettingsTab: View { self.gatewayToken = "" self.gatewayPassword = "" - let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmedInstanceId.isEmpty { - GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId) - } - - // Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks). - GatewaySettingsStore.clearLastGatewayConnection() - GatewaySettingsStore.clearPreferredGatewayStableID() - GatewaySettingsStore.clearLastDiscoveredGatewayStableID() - // Resetting onboarding should also forget trusted gateway TLS fingerprints. - // Otherwise a restarted dev gateway can stay stuck in a local TLS cancel loop. - GatewayTLSStore.clearAllFingerprints() - OnboardingStateStore.reset() + GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId) // RootCanvas also short-circuits onboarding when these are true. self.onboardingComplete = false diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index 9cfb07dc6d55..33be521e391f 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -35,6 +35,7 @@ Sources/Model/NodeAppModel+WatchNotifyNormalization.swift Sources/Model/NodeAppModel.swift Sources/Model/WatchReplyCoordinator.swift Sources/Motion/MotionService.swift +Sources/Onboarding/GatewayOnboardingReset.swift Sources/Onboarding/GatewayOnboardingView.swift Sources/Onboarding/OnboardingStateStore.swift Sources/Onboarding/OnboardingWizardView.swift diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 51e5ac9e6a17..8d2d70405fa5 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -601,7 +601,6 @@ public actor GatewayChannelActor { let allowedOperatorScopes: Set = [ "operator.approvals", "operator.read", - "operator.talk.secrets", "operator.write", ] return Array(Set(scopes.filter { allowedOperatorScopes.contains($0) })).sorted() diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift index 6ee313ba6500..44ba145447c9 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift @@ -121,6 +121,10 @@ public struct GatewayConnectionProblem: Equatable, Sendable { } } + public var suggestsOnboardingReset: Bool { + self.kind == .gatewayAuthTokenMismatch + } + public var statusText: String { switch self.kind { case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift index b6bfa1383e91..1ac5cc00c36c 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift @@ -70,6 +70,19 @@ import Testing #expect(problem?.needsCredentialUpdate == false) } + @Test func tokenMismatchSuggestsOnboardingReset() { + let error = GatewayConnectAuthError( + message: "token mismatch", + detailCode: GatewayConnectAuthDetailCode.authTokenMismatch.rawValue, + canRetryWithDeviceToken: false) + + let problem = GatewayConnectionProblemMapper.map(error: error) + + #expect(problem?.kind == .gatewayAuthTokenMismatch) + #expect(problem?.suggestsOnboardingReset == true) + #expect(problem?.needsCredentialUpdate == true) + } + @Test func cancelledTransportDoesNotReplaceStructuredPairingProblem() { let pairing = GatewayConnectAuthError( message: "pairing required", diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index f9dac81baff7..06b0b9934e55 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -405,7 +405,6 @@ struct GatewayNodeSessionTests { #expect(operatorEntry.scopes == [ "operator.approvals", "operator.read", - "operator.talk.secrets", "operator.write", ]) diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index b1f9a0309cae..c9976fb40219 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -c3d3f4331b8e49a5f54aa4e322a0b03ab057715ed4f50b2b3e20fbbcbaf332db plugin-sdk-api-baseline.json -7b925ff856294bc8afc54aea9bf12a038d73821b4df297c60908032e1a4d85d9 plugin-sdk-api-baseline.jsonl +81675fa8adf4a3a7cc696ba77760e69224dadd15255daab2bdc83dfd8d290fed plugin-sdk-api-baseline.json +78d3e47f075a6645b771071aaa27832b25b97c797cdb4777a697614157d944ca plugin-sdk-api-baseline.jsonl diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index 7102e3643a85..a4a5504347f0 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -123,10 +123,13 @@ The setup code is a base64-encoded JSON payload that contains: That bootstrap token carries the built-in pairing bootstrap profile: -- the built-in setup profile allows only the `node` role -- after approval, the handed-off `node` token stays `scopes: []` -- the built-in setup-code flow does not hand off an `operator` token -- operator access requires a separate approved operator pairing or token flow +- the built-in setup profile allows the fresh QR/setup-code baseline only: + `node` plus a bounded `operator` handoff +- the handed-off `node` token stays `scopes: []` +- the handed-off `operator` token is limited to `operator.approvals`, + `operator.read`, and `operator.write` +- `operator.admin` and `operator.pairing` are not granted by QR/setup-code + bootstrap; they require a separate approved operator pairing or token flow - later token rotation/revocation remains bounded by both the device's approved role contract and the caller session's operator scopes diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index e93fb1e6a7fd..be402be9828c 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -127,7 +127,9 @@ Inline `--password` can be exposed in local process listings. Prefer `--password - Set `OPENCLAW_GATEWAY_STARTUP_TRACE=1` to log phase timings during Gateway startup, including per-phase `eventLoopMax` delay and plugin lookup-table timings for installed-index, manifest registry, startup planning, and owner-map work. - Set `OPENCLAW_GATEWAY_RESTART_TRACE=1` to log restart-scoped `restart trace:` lines for restart signal handling, active-work drain, shutdown phases, next start, ready timing, and memory metrics. - Set `OPENCLAW_DIAGNOSTICS=timeline` with `OPENCLAW_DIAGNOSTICS_TIMELINE_PATH=` to write a best-effort JSONL startup diagnostics timeline for external QA harnesses. You can also enable the flag with `diagnostics.flags: ["timeline"]` in config; the path is still env-provided. Add `OPENCLAW_DIAGNOSTICS_EVENT_LOOP=1` to include event-loop samples. -- Run `pnpm test:startup:gateway -- --runs 5 --warmup 1` to benchmark Gateway startup. The benchmark records first process output, `/healthz`, `/readyz`, startup trace timings, event-loop delay, and plugin lookup-table timing details. +- Run `pnpm build` first, then `pnpm test:startup:gateway -- --runs 5 --warmup 1` to benchmark Gateway startup against the built CLI entry. The benchmark records first process output, `/healthz`, `/readyz`, startup trace timings, event-loop delay, and plugin lookup-table timing details. +- Run `pnpm build` first, then `pnpm test:restart:gateway -- --case skipChannels --runs 1 --restarts 5` to benchmark in-process Gateway restart against the built CLI entry on macOS or Linux. The restart benchmark uses SIGUSR1, enables both startup and restart traces in the child process, and records next `/healthz`, next `/readyz`, downtime, ready timing, CPU, RSS, and restart trace metrics. +- Treat `/healthz` as liveness and `/readyz` as usable readiness. Trace lines and benchmark output are for owner attribution; do not treat one trace span or one sample as a complete performance conclusion. ## Query a running Gateway diff --git a/docs/cli/qr.md b/docs/cli/qr.md index 52be232d987b..45e5719ba8a6 100644 --- a/docs/cli/qr.md +++ b/docs/cli/qr.md @@ -35,8 +35,8 @@ openclaw qr --url wss://gateway.example/ws - `--token` and `--password` are mutually exclusive. - The setup code itself now carries an opaque short-lived `bootstrapToken`, not the shared gateway token/password. -- Built-in setup-code bootstrap is node-only. After approval, the primary node token lands with `scopes: []`. -- The built-in setup-code flow does not return a handed-off operator token; operator access requires a separate approved operator pairing or token flow. +- Built-in setup-code bootstrap returns a primary `node` token with `scopes: []` plus a bounded `operator` handoff token for trusted mobile onboarding. +- The handed-off operator token is limited to `operator.approvals`, `operator.read`, and `operator.write`; `operator.admin`, `operator.pairing`, and `operator.talk.secrets` require a separate approved operator pairing or token flow. - Mobile pairing fails closed for Tailscale/public `ws://` gateway URLs. Private LAN addresses and `.local` Bonjour hosts remain supported over `ws://`, but Tailscale/public mobile routes should use Tailscale Serve/Funnel or a `wss://` gateway URL. - With `--remote`, OpenClaw requires either `gateway.remote.url` or `gateway.tailscale.mode=serve|funnel`. diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 12ab503a91cb..cf5e2f9e5ee5 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -314,7 +314,7 @@ See [/providers/kilocode](/providers/kilocode) for setup details. | Venice | `venice` | `VENICE_API_KEY` | - | | Vercel AI Gateway | `vercel-ai-gateway` | `AI_GATEWAY_API_KEY` | `vercel-ai-gateway/anthropic/claude-opus-4.6` | | Volcano Engine (Doubao) | `volcengine` / `volcengine-plan` | `VOLCANO_ENGINE_API_KEY` | `volcengine-plan/ark-code-latest` | -| xAI | `xai` | `XAI_API_KEY` | `xai/grok-4.3` | +| xAI | `xai` | SuperGrok/X Premium OAuth or `XAI_API_KEY` | `xai/grok-4.3` | | Xiaomi | `xiaomi` | `XIAOMI_API_KEY` | `xiaomi/mimo-v2-flash` | #### Quirks worth knowing @@ -333,7 +333,7 @@ See [/providers/kilocode](/providers/kilocode) for setup details. Model ids use a `nvidia//` namespace (for example `nvidia/nvidia/nemotron-...` alongside `nvidia/moonshotai/kimi-k2.5`); pickers preserve the literal `/` composition while the canonical key sent to the API stays single-prefixed. - Uses the xAI Responses path. `grok-4.3` is the bundled default chat model. `/fast` or `params.fastMode: true` rewrites `grok-3`, `grok-3-mini`, `grok-4`, and `grok-4-0709` to their `*-fast` variants. `tool_stream` defaults on; disable via `agents.defaults.models["xai/"].params.tool_stream=false`. + Uses the xAI Responses path. The recommended path is SuperGrok/X Premium OAuth; API keys still work via `XAI_API_KEY` or plugin config. `grok-4.3` is the bundled default chat model. `/fast` or `params.fastMode: true` rewrites `grok-3`, `grok-3-mini`, `grok-4`, and `grok-4-0709` to their `*-fast` variants. `tool_stream` defaults on; disable via `agents.defaults.models["xai/"].params.tool_stream=false`. Ships as the bundled `cerebras` provider plugin. GLM uses `zai-glm-4.7`; OpenAI-compatible base URL is `https://api.cerebras.ai/v1`. diff --git a/docs/concepts/soul.md b/docs/concepts/soul.md index 089098a168dd..6d89fbeb6099 100644 --- a/docs/concepts/soul.md +++ b/docs/concepts/soul.md @@ -105,10 +105,10 @@ Sharp is good. Annoying is not. - Workspace files OpenClaw injects into the system prompt. + Workspace files OpenClaw injects into model context. - How `SOUL.md` is composed into the per-turn system prompt. + How `SOUL.md` is composed into OpenClaw and Codex runtime context. Starter template for a personality file. diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index cdb965e255a8..d3cad7b397de 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -166,7 +166,8 @@ PR. ## Workspace bootstrap injection -Bootstrap files are trimmed and appended under **Project Context** so the model sees identity and profile context without needing explicit reads: +Bootstrap files are resolved from the active workspace, then routed to the +prompt surface that matches their lifetime: - `AGENTS.md` - `SOUL.md` @@ -177,23 +178,24 @@ Bootstrap files are trimmed and appended under **Project Context** so the model - `BOOTSTRAP.md` (only on brand-new workspaces) - `MEMORY.md` when present -All of these files are **injected into the context window** on every turn unless -a file-specific gate applies. `HEARTBEAT.md` is omitted on normal runs when -heartbeats are disabled for the default agent or +On the native Codex harness, OpenClaw avoids repeating stable workspace files +in every user turn. Codex loads `AGENTS.md` through its own project-doc +discovery. `SOUL.md`, `IDENTITY.md`, `TOOLS.md`, and `USER.md` are forwarded as +Codex developer instructions. `HEARTBEAT.md` content is not injected; heartbeat +turns get a collaboration-mode note pointing to the file when it exists and is +non-empty. `MEMORY.md` and active `BOOTSTRAP.md` content keep the normal +turn-context role for now. + +On non-Codex harnesses, bootstrap files continue to be composed into the +OpenClaw prompt according to their existing gates. `HEARTBEAT.md` is omitted on +normal runs when heartbeats are disabled for the default agent or `agents.defaults.heartbeat.includeSystemPromptSection` is false. Keep injected -files concise, especially `MEMORY.md`. `MEMORY.md` is intended to stay a -curated long-term summary; detailed daily notes belong in `memory/*.md` where +files concise, especially `MEMORY.md`. `MEMORY.md` is intended to stay a curated +long-term summary; detailed daily notes belong in `memory/*.md` where `memory_search` and `memory_get` can retrieve them on demand. Oversized `MEMORY.md` files increase prompt usage and can be partially injected because of the bootstrap file limits below. -When a session runs on the native Codex harness, Codex loads `AGENTS.md` -through its own project-doc discovery. OpenClaw still resolves the remaining -bootstrap files and forwards them as Codex config instructions, so `SOUL.md`, -`TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md`, and -`MEMORY.md` keep the same workspace-context role without duplicating -`AGENTS.md`. - `memory/*.md` daily files are **not** part of the normal bootstrap Project Context. On ordinary turns they are accessed on demand via the `memory_search` and `memory_get` tools, so they do not count against the context window unless the model explicitly reads them. Bare `/new` and `/reset` turns are the exception: the runtime can prepend recent daily memory as a one-shot startup-context block for that first turn. diff --git a/docs/docs.json b/docs/docs.json index da1437120896..f13eb5f83ce0 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1204,6 +1204,7 @@ "group": "Bundled plugin guides", "pages": [ "plugins/codex-harness", + "plugins/codex-native-plugins", "plugins/codex-computer-use", "plugins/google-meet", "plugins/webhooks", @@ -1703,8 +1704,7 @@ "group": "Codex harness", "pages": [ "plugins/codex-harness-reference", - "plugins/codex-harness-runtime", - "plugins/codex-native-plugins" + "plugins/codex-harness-runtime" ] }, { diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index 3f11f64739ac..313893b4cf45 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -376,10 +376,12 @@ channels: ## HEARTBEAT.md (optional) -If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the agent to read it. Think of it as your "heartbeat checklist": small, stable, and safe to include every 30 minutes. +If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the agent to read it. Think of it as your "heartbeat checklist": small, stable, and safe to consider every 30 minutes. On normal runs, `HEARTBEAT.md` is only injected when heartbeat guidance is enabled for the default agent. Disabling the heartbeat cadence with `0m` or setting `includeSystemPromptSection: false` omits it from normal bootstrap context. +On the native Codex harness, `HEARTBEAT.md` content is not injected into the turn. If the file exists and has non-whitespace content, the heartbeat collaboration-mode instructions point Codex at the file and tell it to read before proceeding. + If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), OpenClaw skips the heartbeat run to save API calls. That skip is reported as `reason=empty-heartbeat-file`. If the file is missing, the heartbeat still runs and the model decides what to do. Keep it tiny (short checklist or reminders) to avoid prompt bloat. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 15cc55ef2549..f9dd5180d6f1 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -147,24 +147,33 @@ When a device token is issued, `hello-ok` also includes: } ``` -Built-in QR/setup-code bootstrap is node-only. After the owner approves the -pending node request, `hello-ok.auth` includes the primary node token: +Built-in QR/setup-code bootstrap is a fresh mobile handoff path. A successful +baseline setup-code connect returns a primary node token plus one bounded +operator token: ```json { "auth": { "deviceToken": "…", "role": "node", - "scopes": [] + "scopes": [], + "deviceTokens": [ + { + "deviceToken": "…", + "role": "operator", + "scopes": ["operator.approvals", "operator.read", "operator.write"] + } + ] } } ``` -The built-in setup-code flow does not include additional `deviceTokens` entries -or hand off an operator token. Client authors should treat the optional -`hello-ok.auth.deviceTokens` field as legacy/custom bootstrap extension data: -persist it only when present on a trusted transport, and do not require it for -built-in pairing. +The operator handoff is intentionally bounded so QR onboarding can start the +mobile operator loop without granting `operator.admin`, `operator.pairing`, or +`operator.talk.secrets`. Those scopes require a separate approved operator +pairing or token flow. Clients should persist `hello-ok.auth.deviceTokens` only +when the connect used bootstrap auth on trusted transport such as `wss://` or +loopback/local pairing. ### Node example @@ -691,17 +700,16 @@ rather than the pre-handshake defaults. `AUTH_TOKEN_MISMATCH` retry is gated to **trusted endpoints only** — loopback, or `wss://` with a pinned `tlsFingerprint`. Public `wss://` without pinning does not qualify. -- Built-in setup-code bootstrap returns only the primary node - `hello-ok.auth.deviceToken`; clients must not expect an additional operator - token in `hello-ok.auth.deviceTokens`. -- While built-in setup-code bootstrap is waiting for approval, `PAIRING_REQUIRED` +- Built-in setup-code bootstrap returns the primary node + `hello-ok.auth.deviceToken` plus a bounded operator token in + `hello-ok.auth.deviceTokens` for trusted mobile handoff. The operator token + excludes `operator.admin`, `operator.pairing`, and `operator.talk.secrets`. +- While a non-baseline setup-code bootstrap is waiting for approval, `PAIRING_REQUIRED` details include `recommendedNextStep: "wait_then_retry"`, `retryable: true`, and `pauseReconnect: false`. Clients should keep reconnecting with the same bootstrap token until the request is approved or the token becomes invalid. -- If an older or custom trusted bootstrap flow includes optional - `hello-ok.auth.deviceTokens` entries, persist them only when the connect used - bootstrap auth on a trusted transport such as `wss://` or loopback/local - pairing. +- Persist `hello-ok.auth.deviceTokens` only when the connect used bootstrap auth + on a trusted transport such as `wss://` or loopback/local pairing. - If a client supplies an **explicit** `deviceToken` or explicit `scopes`, that caller-requested scope set remains authoritative; cached scopes are only reused when the client is reusing the stored per-device token. diff --git a/docs/install/updating.md b/docs/install/updating.md index 4060c0f65bd6..3dec3f78b85a 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -67,6 +67,13 @@ from that checkout. The `stable` and `beta` channels use package installs. If th gateway is already installed, `openclaw update` refreshes the service metadata and restarts it unless you pass `--no-restart`. +For package installs with a managed Gateway service, `openclaw update` targets +the package root used by that service. If the shell `openclaw` command comes +from a different install, the updater prints both roots and the managed service +Node path. The package update uses the package manager that owns the service +root and checks the managed service Node against the target release engine +before replacing the package. + ## Alternative: re-run the installer ```bash diff --git a/docs/plugins/codex-harness-reference.md b/docs/plugins/codex-harness-reference.md index 549f5b486b12..9c930071eaad 100644 --- a/docs/plugins/codex-harness-reference.md +++ b/docs/plugins/codex-harness-reference.md @@ -361,11 +361,12 @@ filenames for persona files, because Codex fallbacks only apply when `AGENTS.md` is missing. For OpenClaw workspace parity, the Codex harness resolves the other bootstrap -files, including `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, -`HEARTBEAT.md`, `BOOTSTRAP.md`, and `MEMORY.md` when present, and forwards them -as OpenClaw turn input reference context. This keeps workspace persona and -profile context visible to the native Codex turn without promoting it above -Codex-owned system/developer instructions or duplicating `AGENTS.md`. +files. `SOUL.md`, `IDENTITY.md`, `TOOLS.md`, and `USER.md` are forwarded as +OpenClaw Codex developer instructions because they define the active agent, +available workspace guidance, and user profile. `HEARTBEAT.md` content is not +injected; heartbeat turns get a collaboration-mode pointer to read the file when +it exists and is non-empty. `BOOTSTRAP.md` and `MEMORY.md` when present are +forwarded as OpenClaw turn input reference context. ## Environment overrides diff --git a/docs/plugins/codex-harness-runtime.md b/docs/plugins/codex-harness-runtime.md index 15c1e019bcbb..c0c2397e6dac 100644 --- a/docs/plugins/codex-harness-runtime.md +++ b/docs/plugins/codex-harness-runtime.md @@ -31,11 +31,11 @@ when it uses Codex-flavored OpenAI auth or transport. Native Codex keeps Codex-owned base/model/personality instructions and project-doc behavior according to the active Codex thread config. Lightweight OpenClaw runs still preserve their existing project-doc suppression. OpenClaw -developer instructions are limited to OpenClaw runtime concerns such as -source-channel delivery, OpenClaw dynamic tools, ACP delegation, and adapter -context. OpenClaw skill catalogs and non-AGENTS -workspace bootstrap files are projected as turn input reference context for -native Codex instead of being promoted into Codex developer instructions. +developer instructions cover OpenClaw runtime concerns such as source-channel +delivery, OpenClaw dynamic tools, ACP delegation, adapter context, and the +active agent workspace profile files. OpenClaw skill catalogs plus `MEMORY.md` +and active `BOOTSTRAP.md` content are projected as turn input reference context +for native Codex. ## Thread bindings and model changes @@ -61,7 +61,9 @@ quiet or notify without encoding that control flow in final text. Heartbeat-specific initiative guidance is sent as a Codex collaboration-mode developer instruction on the heartbeat turn itself. Ordinary chat turns restore Codex Default mode instead of carrying heartbeat philosophy in their normal -runtime prompt. +runtime prompt. When a non-empty `HEARTBEAT.md` exists, the heartbeat +collaboration-mode instructions point Codex at the file instead of inlining its +contents. ## Hook boundaries diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 4db6761f0b91..4fa60a9132ef 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -202,6 +202,8 @@ Common command routing: | Attach the current chat | `/codex bind [--cwd ]` | | Resume an existing Codex thread | `/codex resume ` | | 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` | | Send Codex feedback only | `/codex diagnostics [note]` | | Start an ACP/acpx task | ACP/acpx session commands, not `/codex` | diff --git a/docs/plugins/codex-native-plugins.md b/docs/plugins/codex-native-plugins.md index 4c13f4617b34..1716930e3203 100644 --- a/docs/plugins/codex-native-plugins.md +++ b/docs/plugins/codex-native-plugins.md @@ -81,8 +81,35 @@ config looks like this: } ``` -After changing `codexPlugins`, use `/new`, `/reset`, or restart the gateway so -future Codex harness sessions start with the updated app set. +After changing `codexPlugins`, new Codex conversations pick up the updated app +set automatically. Use `/new` or `/reset` to refresh the current conversation. +A gateway restart is not required for plugin enable or disable changes. + +## Manage plugins from chat + +Use `/codex plugins` when you want to inspect or change configured native Codex +plugins from the same chat where you operate the Codex harness: + +```text +/codex plugins +/codex plugins list +/codex plugins disable google-calendar +/codex plugins enable google-calendar +``` + +`/codex plugins` is an alias for `/codex plugins list`. The list output shows +the configured plugin keys, on/off state, Codex plugin name, and marketplace +from `plugins.entries.codex.config.codexPlugins.plugins`. + +`enable` and `disable` write only to OpenClaw config at +`~/.openclaw/openclaw.json`; they do not edit `~/.codex/config.toml` or install +new Codex plugins. Only the owner or a gateway client with the +`operator.admin` scope can change plugin state. + +Enabling a configured plugin also turns on the global +`codexPlugins.enabled` switch. If the plugin was written disabled because +migration returned `auth_required`, reauthorize the app in Codex before enabling +it in OpenClaw. ## How native plugin setup works @@ -110,7 +137,10 @@ check after migration. Codex harness session setup then computes a restrictive thread app config for the enabled and accessible plugin apps. Thread app config is computed when OpenClaw establishes a Codex harness session -or replaces a stale Codex thread binding. It is not recomputed on every turn. +or replaces a stale Codex thread binding. It is not recomputed on every turn, so +`/codex plugins enable` and `/codex plugins disable` affect new Codex +conversations. Use `/new` or `/reset` when the current conversation should pick +up the updated app set. ## V1 support boundary @@ -228,10 +258,10 @@ apps until ownership and readiness are known. **`app_ownership_ambiguous`:** app inventory only matched by display name, so the app is not exposed to the Codex thread. -**Config changed but the agent cannot see the plugin:** use `/new`, `/reset`, or -restart the gateway. Existing Codex thread bindings keep the app config they -started with until OpenClaw establishes a new harness session or replaces a -stale binding. +**Config changed but the agent cannot see the plugin:** use `/codex plugins +list` to confirm the configured state, then use `/new` or `/reset`. Existing +Codex thread bindings keep the app config they started with until OpenClaw +establishes a new harness session or replaces a stale binding. **Destructive action is declined:** check the global and per-plugin `allow_destructive_actions` values. Even when policy is true, unsafe elicitation diff --git a/docs/providers/xai.md b/docs/providers/xai.md index 6a1a9952b6ec..590f5a4b880b 100644 --- a/docs/providers/xai.md +++ b/docs/providers/xai.md @@ -6,30 +6,74 @@ read_when: title: "xAI" --- -OpenClaw ships a bundled `xai` provider plugin for Grok models. +OpenClaw ships a bundled `xai` provider plugin for Grok models. For most +users, the recommended path is Grok OAuth with an eligible SuperGrok or X Premium +subscription. OpenClaw stays local-first: the Gateway, config, routing, and +tools run on your machine, while Grok model requests authenticate through xAI +and are sent to xAI's API. -## Getting started +OAuth does not require an xAI API key, and it does not require the Grok Build +app. xAI may still show Grok Build on the consent screen because OpenClaw uses +xAI's shared OAuth client. + +## Choose your setup path + +Use the path that matches your OpenClaw install state: - - Use either an API key from the [xAI console](https://console.x.ai/), - xAI OAuth browser sign-in with an eligible xAI account, or xAI device-code - sign-in for remote/VPS hosts where a localhost browser callback is awkward. - OAuth does not require an xAI API key, and OpenClaw does not require the - Grok Build app. xAI may still label the consent app as Grok Build because - OpenClaw uses xAI's shared OAuth client. - - - Set `XAI_API_KEY`, run the API-key wizard, or start the OAuth flow: + + Run onboarding with daemon install when you are setting up a new local + Gateway, then choose the xAI/Grok OAuth option in the model/auth step: + + ```bash + openclaw onboard --install-daemon + ``` + + On a VPS or over SSH, use device-code during onboarding: + + ```bash + openclaw onboard --install-daemon --auth-choice xai-device-code + ``` + + OAuth does not require an xAI API key. OpenClaw does not require the Grok + Build app. xAI may still label the consent app as Grok Build because + OpenClaw uses xAI's shared OAuth client. + + + + If OpenClaw is already configured, sign in to xAI only. Do not rerun full + onboarding or reinstall the daemon just to connect Grok: ```bash - openclaw onboard --auth-choice xai-api-key - openclaw onboard --auth-choice xai-oauth - openclaw onboard --auth-choice xai-device-code openclaw models auth login --provider xai --method oauth + ``` + + Use the device-code flow instead when the Gateway runs over SSH, Docker, or + a VPS and a localhost browser callback is awkward: + + ```bash openclaw models auth login --provider xai --device-code ``` + To make Grok the default model after signing in, apply it separately: + + ```bash + openclaw models set xai/grok-4.3 + ``` + + Rerun full onboarding only if you intentionally want to change Gateway, + daemon, channel, workspace, or other setup choices. + + + + API-key setup still works for xAI Console keys and for media surfaces that + require key-backed provider config: + + ```bash + openclaw models auth login --provider xai --method api-key + export XAI_API_KEY=xai-... + ``` + ```json5 @@ -42,9 +86,9 @@ OpenClaw ships a bundled `xai` provider plugin for Grok models. OpenClaw uses the xAI Responses API as the bundled xAI transport. The same -credential from `openclaw onboard --auth-choice xai-api-key` or -`openclaw onboard --auth-choice xai-oauth` / -`openclaw onboard --auth-choice xai-device-code` can also power first-class +credential from `openclaw models auth login --provider xai --method oauth`, +`openclaw models auth login --provider xai --device-code`, or +`openclaw models auth login --provider xai --method api-key` can also power first-class `x_search`, remote `code_execution`, and xAI image/video generation. Speech and transcription currently require `XAI_API_KEY` or provider config. `XAI_API_KEY` or plugin web-search config can power Grok-backed `web_search` too. @@ -55,6 +99,22 @@ and, by default, `x_search` through an operator xAI Responses proxy. `code_execution` tuning lives under `plugins.entries.xai.config.codeExecution`. +## OAuth troubleshooting + +- If browser OAuth cannot reach `127.0.0.1:56121`, use + `openclaw models auth login --provider xai --device-code`. +- If sign-in succeeds but Grok is not the default model, run + `openclaw models set xai/grok-4.3`. +- To inspect saved xAI auth profiles, run: + + ```bash + openclaw models auth list --provider xai + openclaw models status + ``` + +- xAI decides which accounts can receive OAuth API tokens. If an account is not + eligible, try the API-key path or check the subscription on xAI's side. + Use `xai-device-code` when signing in from SSH, Docker, or a VPS. OpenClaw prints an xAI URL and short code; finish sign-in in any local browser while the @@ -427,11 +487,12 @@ Legacy aliases still normalize to the canonical bundled ids: - xAI auth can use an API key, environment variable, plugin config fallback, - or xAI OAuth browser sign-in with an eligible xAI account. OAuth uses a - local callback on `127.0.0.1:56121`; for remote hosts, forward that port - before opening the sign-in URL. xAI decides which accounts can receive - OAuth API tokens, and the consent page may show Grok Build even though - OpenClaw does not require the Grok Build app. + browser OAuth, or device-code OAuth with an eligible xAI account. Browser + OAuth uses a local callback on `127.0.0.1:56121`; for remote hosts, use + `xai-device-code` unless you want to forward that port before opening the + sign-in URL. xAI decides which accounts can receive OAuth API tokens, and + the consent page may show Grok Build even though OpenClaw does not require + the Grok Build app. - `grok-4.20-multi-agent-experimental-beta-0304` is not supported on the normal xAI provider path because it requires a different upstream API surface than the standard OpenClaw xAI transport. diff --git a/docs/reference/test.md b/docs/reference/test.md index 6996aefa3aa9..a27e38dfac99 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -124,6 +124,94 @@ Checked-in fixture: - Refresh with `pnpm test:startup:bench:update` - Compare current results against the fixture with `pnpm test:startup:bench:check` +## Gateway startup bench + +Script: [`scripts/bench-gateway-startup.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/bench-gateway-startup.ts) + +The benchmark defaults to the built CLI entry at `dist/entry.js`; run +`pnpm build` before using the package-script commands. To measure the source +runner instead, pass `--entry scripts/run-node.mjs` and keep those results +separate from built-entry baselines. + +Usage: + +- `pnpm test:startup:gateway -- --runs 5 --warmup 1` +- `pnpm test:startup:gateway -- --case default --runs 10 --warmup 1` +- `pnpm test:startup:gateway -- --case skipChannels --case fiftyPlugins --runs 5` +- `node --import tsx scripts/bench-gateway-startup.ts --case default --runs 5 --output .artifacts/gateway-startup.json` +- `node --import tsx scripts/bench-gateway-startup.ts --case default --runs 3 --cpu-prof-dir .artifacts/gateway-startup-cpu` + +Case ids: + +- `default`: normal Gateway startup. +- `skipChannels`: Gateway startup with channel startup skipped. +- `oneInternalHook`: one configured internal hook. +- `allInternalHooks`: all internal hooks. +- `fiftyPlugins`: 50 manifest plugins. +- `fiftyStartupLazyPlugins`: 50 startup-lazy manifest plugins. + +Output includes first process output, `/healthz`, `/readyz`, HTTP listen log time, +Gateway ready log time, CPU time, CPU core ratio, max RSS, heap, startup trace +metrics, event-loop delay, and plugin lookup-table detail metrics. The script +enables `OPENCLAW_GATEWAY_STARTUP_TRACE=1` in the child Gateway environment. + +Read `/healthz` as liveness: the HTTP server can answer. Read `/readyz` as +usable readiness: startup plugin sidecars, channels, and ready-critical +post-attach work have settled. Gateway startup hooks are dispatched +asynchronously and are not part of the readiness guarantee. Ready log time is the +Gateway's internal ready log timestamp; it is useful for process-side +attribution but is not a substitute for the external `/readyz` probe. + +Use JSON output or `--output` when comparing changes. Use `--cpu-prof-dir` only +after the trace output points at import, compile, or CPU-bound work that cannot +be explained from phase timings alone. Do not compare source-runner results with +built `dist/entry.js` results as the same baseline. + +## Gateway restart bench + +Script: [`scripts/bench-gateway-restart.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/bench-gateway-restart.ts) + +The restart benchmark is supported on macOS and Linux only. It uses SIGUSR1 for +in-process restarts and fails immediately on Windows. + +The benchmark defaults to the built CLI entry at `dist/entry.js`; run +`pnpm build` before using the package-script commands. To measure the source +runner instead, pass `--entry scripts/run-node.mjs` and keep those results +separate from built-entry baselines. + +Usage: + +- `pnpm test:restart:gateway -- --case skipChannels --runs 1 --restarts 5` +- `pnpm test:restart:gateway -- --case default --runs 3 --restarts 3 --warmup 1` +- `pnpm test:restart:gateway -- --case skipChannelsAcpxProbe --case skipChannelsNoAcpxProbe --runs 1 --restarts 5` +- `node --import tsx scripts/bench-gateway-restart.ts --case fiftyPlugins --runs 1 --restarts 5 --output .artifacts/gateway-restart.json` +- `node --import tsx scripts/bench-gateway-restart.ts --json` + +Case ids: + +- `skipChannels`: restart with channels skipped. +- `skipChannelsAcpxProbe`: restart with channels skipped and ACPX startup probe on. +- `skipChannelsNoAcpxProbe`: restart with channels skipped and ACPX startup probe off. +- `default`: normal restart. +- `fiftyPlugins`: restart with 50 manifest plugins. + +Output includes next `/healthz`, next `/readyz`, downtime, restart ready timing, +CPU, RSS, startup trace metrics for the replacement process, and restart trace +metrics for signal handling, active-work drain, close phases, next start, ready +timing, and memory snapshots. The script enables +`OPENCLAW_GATEWAY_STARTUP_TRACE=1` and `OPENCLAW_GATEWAY_RESTART_TRACE=1` in the +child Gateway environment. + +Use this benchmark when a change touches restart signaling, close handlers, +startup-after-restart, sidecar shutdown, service handoff, or readiness after +restart. Start with `skipChannels` when isolating Gateway mechanics from channel +startup. Use `default` or plugin-heavy cases only after the narrow case explains +the restart path. + +Trace metrics are attribution hints, not verdicts. A restart change should be +judged from multiple samples, the matching owner span, `/healthz` and `/readyz` +behavior, and the user-visible restart contract. + ## Onboarding E2E (Docker) Docker is optional; this is only needed for containerized onboarding smoke tests. diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index 28c29e2eb105..74551a8cbc1c 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -153,8 +153,18 @@ What you set: Sets `agents.defaults.model` to `openai/gpt-5.5` when model is unset, `openai/*`, or `openai-codex/*`. + + Browser sign-in for eligible SuperGrok or X Premium accounts. This is the + recommended xAI path for most users. OpenClaw stores the resulting auth + profile for Grok models, `x_search`, and `code_execution`. + + + Remote-friendly browser sign-in with a short code instead of a localhost + callback. Use this from SSH, Docker, or VPS hosts. + - Prompts for `XAI_API_KEY` and configures xAI as a model provider. + Prompts for `XAI_API_KEY` and configures xAI as a model provider. Use this + when you want an xAI Console API key instead of subscription OAuth. Prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`) and lets you choose the Zen or Go catalog. diff --git a/docs/tools/code-execution.md b/docs/tools/code-execution.md index a10d1ec2c3ff..c111d6e6a0bd 100644 --- a/docs/tools/code-execution.md +++ b/docs/tools/code-execution.md @@ -9,14 +9,14 @@ title: "Code execution" `code_execution` runs sandboxed remote Python analysis on xAI's Responses API. It is registered by the bundled `xai` plugin (under the `tools` contract) and dispatches to the same `https://api.x.ai/v1/responses` endpoint used by `x_search`. -| Property | Value | -| ------------------ | --------------------------------------------------------------------------------- | -| Tool name | `code_execution` | -| Provider plugin | `xai` (bundled, `enabledByDefault: true`) | -| Auth | xAI auth profile, `XAI_API_KEY`, or `plugins.entries.xai.config.webSearch.apiKey` | -| Default model | `grok-4-1-fast` | -| Default timeout | 30 seconds | -| Default `maxTurns` | unset (xAI applies its own internal limit) | +| Property | Value | +| ------------------ | --------------------------------------------------------------------------------------- | +| Tool name | `code_execution` | +| Provider plugin | `xai` (bundled, `enabledByDefault: true`) | +| Auth | xAI OAuth auth profile, `XAI_API_KEY`, or `plugins.entries.xai.config.webSearch.apiKey` | +| Default model | `grok-4-1-fast` | +| Default timeout | 30 seconds | +| Default `maxTurns` | unset (xAI applies its own internal limit) | This is different from local [`exec`](/tools/exec): @@ -36,12 +36,29 @@ Do **not** use it when you need local files, your shell, your repo, or paired de ## Setup - - Run `openclaw onboard --auth-choice xai-api-key` for `code_execution` and - `x_search`, or set `XAI_API_KEY` / configure the key under the xAI plugin - when you also want Grok web search to use the same credential: + + Sign in with Grok OAuth using an eligible SuperGrok or X Premium subscription, + use the remote-friendly device-code flow, or store an API key. OAuth works + for `code_execution` and `x_search`; `XAI_API_KEY` or plugin web-search + config can also power Grok `web_search`. ```bash + openclaw models auth login --provider xai --method oauth + openclaw models auth login --provider xai --device-code + ``` + + During a fresh install, the same auth choices are available inside + onboarding: + + ```bash + openclaw onboard --install-daemon + openclaw onboard --install-daemon --auth-choice xai-device-code + ``` + + Or use an API key: + + ```bash + openclaw models auth login --provider xai --method api-key export XAI_API_KEY=xai-... ``` @@ -66,7 +83,9 @@ Do **not** use it when you need local files, your shell, your repo, or paired de - The tool is gated on `plugins.entries.xai.config.codeExecution.enabled`. Default is off. + `code_execution` is available when xAI credentials are available. Set + `plugins.entries.xai.config.codeExecution.enabled` to `false` to disable it, + or use the same block to tune the model and timeout. ```json5 { @@ -124,7 +143,7 @@ When the tool runs without auth, it returns a structured `missing_xai_api_key` e ```json { "error": "missing_xai_api_key", - "message": "code_execution needs an xAI API key. Run openclaw onboard --auth-choice xai-api-key, set XAI_API_KEY in the Gateway environment, or configure plugins.entries.xai.config.webSearch.apiKey.", + "message": "code_execution needs xAI credentials. Run `openclaw models auth login --provider xai --method oauth` to sign in with Grok, run `openclaw models auth login --provider xai --method api-key`, set `XAI_API_KEY` in the Gateway environment, or configure `plugins.entries.xai.config.webSearch.apiKey`.", "docs": "https://docs.openclaw.ai/tools/code-execution" } ``` diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 4404c284031f..5679c8d31067 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -124,7 +124,7 @@ Current source-of-truth: - - `/new [model]` starts a new session; `/reset` is the reset alias. + - `/new [model]` archives the current session and starts a fresh one; `/reset` wipes the current session in place. They are not aliases. - Control UI intercepts typed `/new` to create and switch to a fresh dashboard session, except when `session.dmScope: "main"` is configured and the current parent is the agent's main session; in that case `/new` resets the main session in place. Typed `/reset` still runs the Gateway's in-place reset. - `/reset soft [message]` keeps the current transcript, drops reused CLI backend session ids, and reruns startup/system-prompt loading in-place. - `/compact [instructions]` compacts the session context. See [Compaction](/concepts/compaction). diff --git a/extensions/codex/index.ts b/extensions/codex/index.ts index c83ce601b1b3..c32da221340f 100644 --- a/extensions/codex/index.ts +++ b/extensions/codex/index.ts @@ -1,9 +1,11 @@ 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 { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createCodexAppServerAgentHarness } from "./harness.js"; import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { buildCodexProvider } from "./provider.js"; +import type { CodexPluginsConfigBlock } from "./src/command-plugins-management.js"; import { createCodexCommand } from "./src/commands.js"; import { handleCodexConversationBindingResolved, @@ -53,6 +55,60 @@ export default definePluginEntry({ listCodexCliSessionsOnNode({ runtime: api.runtime, ...params }), resolveCodexCliSessionForBindingOnNode: (params) => resolveCodexCliSessionForBindingOnNode({ runtime: api.runtime, ...params }), + codexPluginsManagementIo: { + readConfig: () => { + const current = (api.runtime.config?.current?.() ?? {}) as OpenClawConfig; + const plugins = (current as Record).plugins; + if (!plugins || typeof plugins !== "object") { + return Promise.resolve({}); + } + const entries = (plugins as Record).entries; + if (!entries || typeof entries !== "object") { + return Promise.resolve({}); + } + const codexEntry = (entries as Record).codex; + if (!codexEntry || typeof codexEntry !== "object") { + return Promise.resolve({}); + } + const config = (codexEntry as Record).config; + if (!config || typeof config !== "object") { + return Promise.resolve({}); + } + const codexPlugins = (config as Record).codexPlugins; + if (!codexPlugins || typeof codexPlugins !== "object") { + return Promise.resolve({}); + } + const declared = (codexPlugins as Record).plugins; + if (!declared || typeof declared !== "object") { + return Promise.resolve({ + enabled: (codexPlugins as Record).enabled === true, + }); + } + return Promise.resolve({ + enabled: (codexPlugins as Record).enabled === true, + plugins: declared as Record, + }); + }, + mutate: async (update) => { + await mutateConfigFile({ + mutate: (draft) => { + const root = draft as Record; + root.plugins = (root.plugins ?? {}) as Record; + const pluginsBlock = root.plugins as Record; + pluginsBlock.entries = (pluginsBlock.entries ?? {}) as Record; + const entries = pluginsBlock.entries as Record; + entries.codex = (entries.codex ?? {}) as Record; + const codexEntry = entries.codex as Record; + codexEntry.config = (codexEntry.config ?? {}) as Record; + const config = codexEntry.config as Record; + config.codexPlugins = (config.codexPlugins ?? {}) as Record; + const codexPlugins = config.codexPlugins as Record; + codexPlugins.plugins = (codexPlugins.plugins ?? {}) as Record; + update(codexPlugins as CodexPluginsConfigBlock); + }, + }); + }, + }, }, }), ); diff --git a/extensions/codex/src/app-server/compact.test.ts b/extensions/codex/src/app-server/compact.test.ts index 9cfeb5a683a5..d5b3bab86fd9 100644 --- a/extensions/codex/src/app-server/compact.test.ts +++ b/extensions/codex/src/app-server/compact.test.ts @@ -461,17 +461,20 @@ describe("maybeCompactCodexAppServerSession", () => { expect(details.codexThreadBindingInvalidated).toBe(true); expect(await readCodexAppServerBinding(sessionFile)).toBeUndefined(); expect(compact).toHaveBeenCalledTimes(1); - expect(compact).toHaveBeenCalledWith({ - sessionId: "session-1", - sessionKey: "agent:main:session-1", - sessionFile, - tokenBudget: 777, - currentTokenCount: 123, - compactionTarget: "threshold", - customInstructions: undefined, - force: true, - runtimeContext: { workspaceDir: tempDir, provider: "codex" }, - }); + expect(compact).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile, + tokenBudget: 777, + currentTokenCount: 123, + compactionTarget: "threshold", + customInstructions: undefined, + force: true, + runtimeContext: { workspaceDir: tempDir, provider: "codex" }, + abortSignal: expect.any(AbortSignal), + }), + ); expect(maintain).toHaveBeenCalledTimes(1); const [maintainCall] = maintain.mock.calls[0] ?? []; const maintainParams = maintainCall as @@ -683,6 +686,100 @@ describe("maybeCompactCodexAppServerSession", () => { expect(compactResult.reason).toBe("below threshold"); expect(maintain).not.toHaveBeenCalled(); }); + + describe("owning context-engine compaction safety timeout", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("bounds a hung owning context-engine compact() and reports a clean ok:false", async () => { + const sessionFile = await writeTestBinding(); + const compact = vi.fn(() => new Promise(() => {})); + const contextEngine: ContextEngine = { + info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true }, + assemble: vi.fn() as never, + ingest: vi.fn() as never, + compact, + }; + + vi.useFakeTimers(); + const pendingResult = maybeCompactCodexAppServerSession({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile, + workspaceDir: tempDir, + contextEngine, + // 1 s host-resolved compaction timeout. + config: { agents: { defaults: { compaction: { timeoutSeconds: 1 } } } }, + }); + + await vi.advanceTimersByTimeAsync(1_000); + const result = requireCompactResult(await pendingResult); + + expect(result.ok).toBe(false); + expect(result.compacted).toBe(false); + expect(result.reason).toContain("timed out"); + expect(compact).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it("threads a composed caller abort signal into the owning context-engine compact()", async () => { + const sessionFile = await writeTestBinding(); + const controller = new AbortController(); + const compact = vi.fn(async () => ({ + ok: true, + compacted: false, + reason: "below threshold", + })); + const contextEngine: ContextEngine = { + info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true }, + assemble: vi.fn() as never, + ingest: vi.fn() as never, + compact, + }; + + await maybeCompactCodexAppServerSession({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile, + workspaceDir: tempDir, + contextEngine, + abortSignal: controller.signal, + }); + + expect(compact).toHaveBeenCalledTimes(1); + expect(compact.mock.calls[0]?.[0]?.abortSignal).toBeInstanceOf(AbortSignal); + }); + + it("aborts a hung owning context-engine compact() when the caller signal fires", async () => { + const sessionFile = await writeTestBinding(); + const controller = new AbortController(); + const compact = vi.fn(() => new Promise(() => {})); + const contextEngine: ContextEngine = { + info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true }, + assemble: vi.fn() as never, + ingest: vi.fn() as never, + compact, + }; + + const pendingResult = maybeCompactCodexAppServerSession({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile, + workspaceDir: tempDir, + contextEngine, + abortSignal: controller.signal, + }); + + controller.abort(new Error("run aborted")); + const result = requireCompactResult(await pendingResult); + + expect(result.ok).toBe(false); + expect(result.compacted).toBe(false); + expect(result.reason).toContain("run aborted"); + expect(compact).toHaveBeenCalledTimes(1); + }); + }); }); function createFakeCodexClient(): { diff --git a/extensions/codex/src/app-server/compact.ts b/extensions/codex/src/app-server/compact.ts index e2532dd2da97..321806e4b0b4 100644 --- a/extensions/codex/src/app-server/compact.ts +++ b/extensions/codex/src/app-server/compact.ts @@ -1,7 +1,9 @@ import { + compactContextEngineWithSafetyTimeout, embeddedAgentLog, formatErrorMessage, isActiveHarnessContextEngine, + resolveCompactionTimeoutMs, resolveContextEngineOwnerPluginId, runHarnessContextEngineMaintenance, type CompactEmbeddedPiSessionParams, @@ -79,17 +81,27 @@ async function compactOwningContextEngine( }); let result: Awaited>; try { - result = await contextEngine.compact({ - sessionId: params.sessionId, - sessionKey: params.sessionKey, - sessionFile: params.sessionFile, - tokenBudget: params.contextTokenBudget, - currentTokenCount: params.currentTokenCount, - compactionTarget: params.trigger === "manual" ? "threshold" : "budget", - customInstructions: params.customInstructions, - force: params.trigger === "manual", - runtimeContext: params.contextEngineRuntimeContext, - }); + // Bound the plugin-owned compaction with the same finite safety timeout + // that protects native runtime compaction, and thread the caller's abort + // signal through, so a slow/hung plugin compact() cannot hang the Codex + // compaction lane indefinitely. A timeout/abort (or any thrown error) is + // converted to a clean { ok: false } result by the catch below. + result = await compactContextEngineWithSafetyTimeout( + contextEngine, + { + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + tokenBudget: params.contextTokenBudget, + currentTokenCount: params.currentTokenCount, + compactionTarget: params.trigger === "manual" ? "threshold" : "budget", + customInstructions: params.customInstructions, + force: params.trigger === "manual", + runtimeContext: params.contextEngineRuntimeContext, + }, + resolveCompactionTimeoutMs(params.config), + params.abortSignal, + ); } catch (error) { embeddedAgentLog.warn("context-engine-owned Codex app-server compaction failed", { sessionId: params.sessionId, diff --git a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts index cf16ff12a33a..31ef5b1bedf9 100644 --- a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts +++ b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts @@ -842,6 +842,99 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { expect(savedBinding?.contextEngine?.projection?.epoch).toBe("epoch-after"); }); + it("bounds a hung owning context-engine compaction during Codex overflow recovery", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + SessionManager.open(sessionFile).appendMessage( + assistantMessage("pre-compaction context", Date.now()) as never, + ); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-old", + cwd: workspaceDir, + dynamicToolsFingerprint: "[]", + contextEngine: { + schemaVersion: 1, + engineId: "lossless-claw", + policyFingerprint: + '{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"contextTokenBudget":400000,"projectionMaxChars":1000000}', + projection: { + schemaVersion: 1, + mode: "thread_bootstrap", + epoch: "epoch-before", + }, + }, + }); + // Owning-engine compaction that never settles. Without the safety timeout + // the awaited compact() would hang the whole Codex overflow-recovery turn; + // with it the call is bounded and forced compaction reports failure so the + // run still proceeds on a fresh thread. + const compact = vi.fn(() => new Promise(() => {})); + const assemble = vi.fn( + async ({ messages, prompt }: Parameters[0]) => ({ + messages: [...messages, userMessage(prompt ?? "", 11)], + estimatedTokens: 42, + systemPromptAddition: "context-engine system", + contextProjection: { mode: "thread_bootstrap" as const, epoch: "epoch-before" }, + }), + ); + const contextEngine = createContextEngine({ assemble, compact }); + const harness = createStartedThreadHarness(async (method, requestParams) => { + const request = requireRecord(requestParams, `${method} params`); + if (method === "thread/resume") { + return threadStartResult("thread-old"); + } + if (method === "turn/start" && request.threadId === "thread-old") { + throw new Error("Codex ran out of room in the model's context window"); + } + if (method === "thread/start") { + return threadStartResult("thread-fresh"); + } + if (method === "turn/start" && request.threadId === "thread-fresh") { + return turnStartResult("turn-fresh"); + } + return undefined; + }); + const params = createParams(sessionFile, workspaceDir); + params.contextEngine = contextEngine; + params.contextTokenBudget = 400_000; + // 1 s host-resolved compaction timeout so the hung compact() is bounded + // well within the 5 s run timeout used by this harness. + params.config = { + agents: { defaults: { compaction: { timeoutSeconds: 1 } } }, + } as EmbeddedRunAttemptParams["config"]; + + const run = runCodexAppServerAttempt(params); + await vi.waitFor( + () => + expect(harness.requests.map((request) => request.method)).toEqual([ + "thread/resume", + "turn/start", + "thread/start", + "turn/start", + ]), + { timeout: 4_000 }, + ); + await harness.notify({ + method: "turn/completed", + params: { + threadId: "thread-fresh", + turnId: "turn-fresh", + turn: { + id: "turn-fresh", + status: "completed", + items: [{ type: "agentMessage", id: "msg-1", text: "fresh answer" }], + }, + }, + }); + const result = await run; + + expect(result.assistantTexts).toContain("fresh answer"); + expect(compact).toHaveBeenCalledTimes(1); + // The run-level abort signal is threaded into the owning-engine compact() + // so a cooperating engine can cancel its own in-flight work. + expect(compact.mock.calls[0]?.[0]?.abortSignal).toBeInstanceOf(AbortSignal); + }); + it("keeps current inbound context at the front of the Codex context-engine prompt", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index bc6855cdb5e5..21e5778cdfa9 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -1369,6 +1369,10 @@ describe("runCodexAppServerAttempt", () => { expect(testing.shouldForceMessageTool(params)).toBe(true); + params.disableMessageTool = true; + expect(testing.shouldForceMessageTool(params)).toBe(false); + + params.disableMessageTool = false; params.sourceReplyDeliveryMode = "automatic"; expect(testing.shouldForceMessageTool(params)).toBe(false); }); @@ -1378,14 +1382,22 @@ describe("runCodexAppServerAttempt", () => { const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir); params.sourceReplyDeliveryMode = "message_tool_only"; - expect(testing.buildDeveloperInstructions(params)).toContain( - "Visible channel replies: use `message`", - ); + expect( + testing.buildDeveloperInstructions(params, { + dynamicTools: [createMessageDynamicTool("Message test tool")], + }), + ).toContain("To send a visible message, use the `message` tool."); + + const withoutMessageToolInstructions = testing.buildDeveloperInstructions(params, { + dynamicTools: [], + }); + expect(withoutMessageToolInstructions).toContain("active Codex delivery path"); + expect(withoutMessageToolInstructions).not.toContain("use the `message` tool"); params.sourceReplyDeliveryMode = "automatic"; const automaticInstructions = testing.buildDeveloperInstructions(params); expect(automaticInstructions).toContain("active Codex delivery path"); - expect(automaticInstructions).not.toContain("Visible channel replies: use `message`"); + expect(automaticInstructions).not.toContain("use the `message` tool"); }); it("includes Codex app-server scoped plugin command guidance in developer instructions", () => { @@ -4675,19 +4687,31 @@ describe("runCodexAppServerAttempt", () => { expect(inputText).toContain("make the default webpage openclaw"); }); - it("passes OpenClaw bootstrap files through Codex turn context", async () => { + it("passes stable workspace files as Codex developer instructions and keeps MEMORY.md as turn context", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); + const agentsGuidance = "Follow AGENTS guidance."; + const soulGuidance = "Soul voice goes here."; + const identityGuidance = "Identity guidance goes here."; + const toolGuidance = "Tool guidance goes here."; + const userProfile = "User profile goes here."; + const heartbeatChecklist = "Heartbeat checklist goes here."; + const memorySummary = "Memory summary goes here."; await fs.mkdir(workspaceDir, { recursive: true }); - await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "Follow AGENTS guidance."); - await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "Soul voice goes here."); + await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), agentsGuidance); + await fs.writeFile(path.join(workspaceDir, "SOUL.md"), soulGuidance); + await fs.writeFile(path.join(workspaceDir, "IDENTITY.md"), identityGuidance); + await fs.writeFile(path.join(workspaceDir, "TOOLS.md"), toolGuidance); + await fs.writeFile(path.join(workspaceDir, "USER.md"), userProfile); + await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), heartbeatChecklist); + await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary); const harness = createStartedThreadHarness(); const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir)); await harness.waitForMethod("turn/start"); await new Promise((resolve) => setImmediate(resolve)); await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); - await run; + const result = await run; const threadStart = harness.requests.find((request) => request.method === "thread/start"); const threadStartParams = threadStart?.params as { @@ -4696,9 +4720,18 @@ describe("runCodexAppServerAttempt", () => { }; const config = threadStartParams.config; - expect(threadStartParams.developerInstructions).not.toContain("Soul voice goes here."); + expect(threadStartParams.developerInstructions).toContain("OpenClaw Agent Soul"); + expect(threadStartParams.developerInstructions).toContain( + "They define who you are, how you work", + ); + expect(threadStartParams.developerInstructions).toContain(soulGuidance); + expect(threadStartParams.developerInstructions).toContain(identityGuidance); + expect(threadStartParams.developerInstructions).toContain(toolGuidance); + expect(threadStartParams.developerInstructions).toContain(userProfile); + expect(threadStartParams.developerInstructions).not.toContain(heartbeatChecklist); + expect(threadStartParams.developerInstructions).not.toContain(memorySummary); expect(threadStartParams.developerInstructions).not.toContain("Codex loads AGENTS.md natively"); - expect(threadStartParams.developerInstructions).not.toContain("Follow AGENTS guidance."); + expect(threadStartParams.developerInstructions).not.toContain(agentsGuidance); expect(config?.instructions).toBeUndefined(); const turnStart = harness.requests.find((request) => request.method === "turn/start"); @@ -4707,11 +4740,132 @@ describe("runCodexAppServerAttempt", () => { }; const inputText = turnStartParams.input?.[0]?.text ?? ""; expect(inputText).toContain("OpenClaw runtime context for this turn:"); - expect(inputText).toContain("not developer policy"); - expect(inputText).toContain("Soul voice goes here."); + expect(inputText).not.toContain("does not override Codex system/developer instructions"); + expect(inputText).not.toContain("not developer policy"); + expect(inputText).not.toContain(soulGuidance); + expect(inputText).not.toContain(identityGuidance); + expect(inputText).not.toContain(toolGuidance); + expect(inputText).not.toContain(userProfile); + expect(inputText).not.toContain(heartbeatChecklist); + expect(inputText).toContain(memorySummary); expect(inputText).toContain("Codex loads AGENTS.md natively"); - expect(inputText).not.toContain("Follow AGENTS guidance."); + expect(inputText).not.toContain(agentsGuidance); expect(inputText).toContain("Current user request:\nhello"); + + const fileStats = new Map( + result.systemPromptReport?.injectedWorkspaceFiles.map((file) => [file.name, file]) ?? [], + ); + expect(fileStats.get("SOUL.md")).toMatchObject({ + rawChars: soulGuidance.length, + injectedChars: soulGuidance.length, + truncated: false, + }); + expect(fileStats.get("IDENTITY.md")).toMatchObject({ + rawChars: identityGuidance.length, + injectedChars: identityGuidance.length, + truncated: false, + }); + expect(fileStats.get("TOOLS.md")).toMatchObject({ + rawChars: toolGuidance.length, + injectedChars: toolGuidance.length, + truncated: false, + }); + expect(fileStats.get("USER.md")).toMatchObject({ + rawChars: userProfile.length, + injectedChars: userProfile.length, + truncated: false, + }); + expect(fileStats.get("MEMORY.md")).toMatchObject({ + rawChars: memorySummary.length, + injectedChars: memorySummary.length, + truncated: false, + }); + expect(fileStats.get("HEARTBEAT.md")).toMatchObject({ + rawChars: heartbeatChecklist.length, + injectedChars: 0, + truncated: false, + }); + expect(fileStats.get("AGENTS.md")).toMatchObject({ + rawChars: agentsGuidance.length, + injectedChars: agentsGuidance.length, + truncated: false, + }); + }); + + it("points heartbeat Codex turns at HEARTBEAT.md without injecting its contents", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const heartbeatPath = path.join(workspaceDir, "HEARTBEAT.md"); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.writeFile(heartbeatPath, "Heartbeat checklist goes here."); + const harness = createStartedThreadHarness(); + const params = createParams(sessionFile, workspaceDir); + params.trigger = "heartbeat"; + params.bootstrapContextMode = "lightweight"; + params.bootstrapContextRunKind = "heartbeat"; + + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + await new Promise((resolve) => setImmediate(resolve)); + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await run; + + const threadStart = harness.requests.find((request) => request.method === "thread/start"); + const threadStartParams = threadStart?.params as { + developerInstructions?: string; + }; + expect(threadStartParams.developerInstructions).not.toContain("Heartbeat checklist goes here."); + + const turnStart = harness.requests.find((request) => request.method === "turn/start"); + const turnStartParams = turnStart?.params as { + input?: Array<{ text?: string }>; + collaborationMode?: { + settings?: { + developer_instructions?: string | null; + }; + }; + }; + const inputText = turnStartParams.input?.[0]?.text ?? ""; + const collaborationInstructions = + turnStartParams.collaborationMode?.settings?.developer_instructions ?? ""; + + expect(inputText).not.toContain("Heartbeat checklist goes here."); + expect(collaborationInstructions).toContain("HEARTBEAT.md exists"); + expect(collaborationInstructions).toContain("Read it before proceeding with this heartbeat"); + expect(collaborationInstructions).toContain(heartbeatPath); + expect(collaborationInstructions).not.toContain("Heartbeat checklist goes here."); + }); + + it("omits heartbeat Codex workspace pointers for empty HEARTBEAT.md files", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "\n\n"); + const harness = createStartedThreadHarness(); + const params = createParams(sessionFile, workspaceDir); + params.trigger = "heartbeat"; + params.bootstrapContextMode = "lightweight"; + params.bootstrapContextRunKind = "heartbeat"; + + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + await new Promise((resolve) => setImmediate(resolve)); + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await run; + + const turnStart = harness.requests.find((request) => request.method === "turn/start"); + const turnStartParams = turnStart?.params as { + collaborationMode?: { + settings?: { + developer_instructions?: string | null; + }; + }; + }; + const collaborationInstructions = + turnStartParams.collaborationMode?.settings?.developer_instructions ?? ""; + + expect(collaborationInstructions).toContain("This is an OpenClaw heartbeat turn"); + expect(collaborationInstructions).not.toContain("HEARTBEAT.md exists"); }); it("remaps Codex bootstrap files under dot-prefixed workspace directories", () => { @@ -4834,7 +4988,9 @@ describe("runCodexAppServerAttempt", () => { expect(llmInputPayload.prompt).toBe("hello"); expect(llmInputPayload.imagesCount).toBe(0); expect(llmInputPayload.historyMessages?.[0]?.role).toBe("assistant"); - expect(llmInputPayload.systemPrompt).toContain("Running inside OpenClaw"); + expect(llmInputPayload.systemPrompt).toContain( + "You are a personal agent running inside OpenClaw.", + ); expect(llmInputPayload.systemPrompt).not.toContain(CODEX_GPT5_BEHAVIOR_CONTRACT); expect(llmInputContext.runId).toBe("run-1"); expect(llmInputContext.sessionId).toBe("session-1"); @@ -9366,7 +9522,10 @@ describe("runCodexAppServerAttempt", () => { const params = createParams("/tmp/session.jsonl", "/tmp/workspace"); params.trigger = "heartbeat"; - const heartbeatCollaborationMode = buildTurnCollaborationMode(params); + const heartbeatCollaborationMode = buildTurnCollaborationMode(params, { + heartbeatCollaborationInstructions: + "HEARTBEAT.md exists at /tmp/workspace/HEARTBEAT.md. Read it before proceeding.", + }); expect(heartbeatCollaborationMode.mode).toBe("default"); expect(heartbeatCollaborationMode.settings.model).toBe("gpt-5.4-codex"); expect(heartbeatCollaborationMode.settings.reasoning_effort).toBe("medium"); @@ -9379,9 +9538,17 @@ describe("runCodexAppServerAttempt", () => { expect(heartbeatCollaborationMode.settings.developer_instructions).toContain( "If `heartbeat_respond` is not already available and `tool_search` is available", ); + expect(heartbeatCollaborationMode.settings.developer_instructions).toContain( + "HEARTBEAT.md exists at /tmp/workspace/HEARTBEAT.md.", + ); params.trigger = "user"; - expect(buildTurnCollaborationMode(params).settings.developer_instructions).toBeNull(); + expect( + buildTurnCollaborationMode(params, { + heartbeatCollaborationInstructions: + "HEARTBEAT.md exists at /tmp/workspace/HEARTBEAT.md. Read it before proceeding.", + }).settings.developer_instructions, + ).toBeNull(); }); it("uses turn-scoped collaboration instructions for cron Codex turns", () => { diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 9a5b9a1c2b88..80d0c55fb9ad 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -10,6 +10,7 @@ import { buildHarnessContextEngineRuntimeContextFromUsage, buildEmbeddedAttemptToolRunContext, clearActiveEmbeddedRun, + compactContextEngineWithSafetyTimeout, embeddedAgentLog, emitAgentEvent as emitGlobalAgentEvent, finalizeHarnessContextEngineTurn, @@ -21,6 +22,7 @@ import { normalizeAgentRuntimeTools, resolveAttemptSpawnWorkspaceDir, resolveAgentHarnessBeforePromptBuildResult, + resolveCompactionTimeoutMs, resolveModelAuthMode, resolveContextEngineOwnerPluginId, resolveSandboxContext, @@ -193,6 +195,13 @@ const CODEX_NATIVE_HOOK_RELAY_RENEW_INTERVAL_MS = 60_000; const CODEX_STEER_ALL_DEBOUNCE_MS = 500; const LOG_FIELD_MAX_LENGTH = 160; const CODEX_NATIVE_PROJECT_DOC_BASENAMES = new Set(["agents.md"]); +const CODEX_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES = new Set([ + "identity.md", + "soul.md", + "tools.md", + "user.md", +]); +const CODEX_HEARTBEAT_CONTEXT_BASENAME = "heartbeat.md"; const CODEX_NATIVE_HOOK_RELAY_EVENTS_WITH_APP_SERVER_APPROVALS = CODEX_NATIVE_HOOK_RELAY_EVENTS.filter((event) => event !== "permission_request"); const CODEX_BOOTSTRAP_CONTEXT_ORDER = new Map([ @@ -215,7 +224,14 @@ type CodexBootstrapContext = Awaited; type CodexToolReportEntry = CodexSystemPromptReport["tools"]["entries"][number]; -type CodexWorkspaceBootstrapContext = CodexBootstrapContext & { promptContext?: string }; +type CodexWorkspaceBootstrapContext = CodexBootstrapContext & { + promptContextFiles?: EmbeddedContextFile[]; + developerInstructionFiles?: EmbeddedContextFile[]; + heartbeatReferenceFiles?: EmbeddedContextFile[]; + promptContext?: string; + developerInstructions?: string; + heartbeatCollaborationInstructions?: string; +}; let openClawCodingToolsFactoryForTests: OpenClawCodingToolsFactory | undefined; @@ -990,11 +1006,6 @@ export async function runCodexAppServerAttempt( historyMessages = (await readMirroredSessionHistoryMessages(activeSessionFile)) ?? historyMessages; } - const baseDeveloperInstructions = buildDeveloperInstructions(params, { - dynamicTools: toolBridge.specs, - }); - // Keep OpenClaw user-editable context in the turn input so native Codex - // system/developer instructions remain the higher-priority policy layer. const workspaceBootstrapContext = await buildCodexWorkspaceBootstrapContext({ params, resolvedWorkspace, @@ -1002,6 +1013,12 @@ export async function runCodexAppServerAttempt( sessionKey: sandboxSessionKey, sessionAgentId, }); + const baseDeveloperInstructions = joinPresentSections( + buildDeveloperInstructions(params, { + dynamicTools: toolBridge.specs, + }), + workspaceBootstrapContext.developerInstructions, + ); const openClawPromptContext = buildCodexOpenClawPromptContext({ params, skillsPrompt: params.skillsSnapshot?.prompt, @@ -2224,21 +2241,31 @@ export async function runCodexAppServerAttempt( try { const runtimeContext = buildActiveContextEngineRuntimeContext(); const overflowTokenCount = params.contextTokenBudget ?? params.contextWindowInfo?.tokens; - const compactResult = await activeContextEngine.compact({ - sessionId: activeSessionId, - sessionKey: sandboxSessionKey, - sessionFile: activeSessionFile, - tokenBudget: params.contextTokenBudget, - force: true, - ...(overflowTokenCount ? { currentTokenCount: overflowTokenCount } : {}), - compactionTarget: "threshold", - runtimeContext: overflowTokenCount - ? { - ...runtimeContext, - currentTokenCount: overflowTokenCount, - } - : runtimeContext, - }); + // Bound the plugin-owned compaction with the same finite safety timeout + // that protects native runtime compaction, and thread the run-level + // abort signal through, so a slow/hung plugin compact() cannot stall + // Codex overflow recovery indefinitely. A timeout/abort surfaces as a + // thrown error handled by the catch below. + const compactResult = await compactContextEngineWithSafetyTimeout( + activeContextEngine, + { + sessionId: activeSessionId, + sessionKey: sandboxSessionKey, + sessionFile: activeSessionFile, + tokenBudget: params.contextTokenBudget, + force: true, + ...(overflowTokenCount ? { currentTokenCount: overflowTokenCount } : {}), + compactionTarget: "threshold", + runtimeContext: overflowTokenCount + ? { + ...runtimeContext, + currentTokenCount: overflowTokenCount, + } + : runtimeContext, + }, + resolveCompactionTimeoutMs(params.config), + runAbortController.signal, + ); embeddedAgentLog.info("codex app-server context-engine forced compaction result", { sessionId: activeSessionId, sessionKey: sandboxSessionKey, @@ -2317,6 +2344,8 @@ export async function runCodexAppServerAttempt( appServer: pluginAppServer, promptText: codexTurnPromptText, sandboxPolicy: codexSandboxPolicy, + heartbeatCollaborationInstructions: + workspaceBootstrapContext.heartbeatCollaborationInstructions, }), { timeoutMs: params.timeoutMs, signal: runAbortController.signal }, ), @@ -3502,7 +3531,9 @@ function hasWildcardCodexToolsAllow(toolsAllow: string[]): boolean { } function shouldForceMessageTool(params: EmbeddedRunAttemptParams): boolean { - return params.sourceReplyDeliveryMode === "message_tool_only"; + return ( + params.disableMessageTool !== true && params.sourceReplyDeliveryMode === "message_tool_only" + ); } function shouldProjectMirroredHistoryForCodexStart(params: { @@ -4235,10 +4266,21 @@ async function buildCodexWorkspaceBootstrapContext(params: { targetWorkspaceDir: params.effectiveWorkspace, }), ); + const promptContextFiles = selectCodexWorkspacePromptContextFiles(contextFiles); + const developerInstructionFiles = shouldInjectCodexOpenClawPromptContext(params.params) + ? selectCodexWorkspaceDeveloperInstructionFiles(contextFiles) + : []; + const heartbeatReferenceFiles = selectCodexWorkspaceHeartbeatReferenceFiles(contextFiles); return { ...bootstrapContext, contextFiles, - promptContext: renderCodexWorkspaceBootstrapPromptContext(contextFiles), + promptContextFiles, + developerInstructionFiles, + heartbeatReferenceFiles, + promptContext: renderCodexWorkspaceBootstrapPromptContext(promptContextFiles), + developerInstructions: renderCodexWorkspaceDeveloperInstructions(developerInstructionFiles), + heartbeatCollaborationInstructions: + renderCodexWorkspaceHeartbeatReference(heartbeatReferenceFiles), }; } catch (error) { embeddedAgentLog.warn("failed to load codex workspace bootstrap instructions", { error }); @@ -4281,7 +4323,8 @@ function buildCodexSystemPromptReport(params: { }, injectedWorkspaceFiles: buildCodexBootstrapInjectionStats({ bootstrapFiles: params.workspaceBootstrapContext.bootstrapFiles, - injectedFiles: params.workspaceBootstrapContext.contextFiles, + injectedFiles: params.workspaceBootstrapContext.promptContextFiles ?? [], + developerInstructionFiles: params.workspaceBootstrapContext.developerInstructionFiles ?? [], }), skills: { promptChars: skillsPrompt.length, @@ -4348,41 +4391,75 @@ function buildCodexToolSchemaStats( function buildCodexBootstrapInjectionStats(params: { bootstrapFiles: CodexBootstrapFile[]; injectedFiles: EmbeddedContextFile[]; + developerInstructionFiles?: EmbeddedContextFile[]; }): CodexSystemPromptReport["injectedWorkspaceFiles"] { - const injectedByPath = new Map(); - const injectedByBaseName = new Map(); - for (const file of params.injectedFiles) { - const pathValue = readNonEmptyString(file.path); - if (!pathValue) { - continue; - } - if (!injectedByPath.has(pathValue)) { - injectedByPath.set(pathValue, file.content); - } - const baseName = path.posix.basename(pathValue.replaceAll("\\", "/")); - if (!injectedByBaseName.has(baseName)) { - injectedByBaseName.set(baseName, file.content); - } - } + const injectedIndex = indexCodexContextFileContent(params.injectedFiles); + const developerInstructionIndex = indexCodexContextFileContent( + params.developerInstructionFiles ?? [], + ); return params.bootstrapFiles.map((file) => { const pathValue = readNonEmptyString(file.path) ?? file.name; + const baseName = getCodexContextFileBasename(pathValue || file.name); const rawChars = file.missing ? 0 : (file.content ?? "").trimEnd().length; const injected = - injectedByPath.get(pathValue) ?? - injectedByPath.get(file.name) ?? - injectedByBaseName.get(file.name); - const injectedChars = injected?.length ?? 0; + readCodexIndexedContextFileContent(injectedIndex, pathValue, file.name) ?? + readCodexIndexedContextFileContent(developerInstructionIndex, pathValue, file.name); + let injectedChars = injected?.length ?? 0; + let truncated = !file.missing && injectedChars < rawChars; + if (injected === undefined) { + if (CODEX_NATIVE_PROJECT_DOC_BASENAMES.has(baseName)) { + injectedChars = rawChars; + truncated = false; + } else if (baseName === CODEX_HEARTBEAT_CONTEXT_BASENAME) { + injectedChars = 0; + truncated = false; + } + } return { name: file.name, path: pathValue, missing: file.missing, rawChars, injectedChars, - truncated: !file.missing && injectedChars < rawChars, + truncated, }; }); } +function indexCodexContextFileContent(files: EmbeddedContextFile[]): { + byPath: Map; + byBaseName: Map; +} { + const byPath = new Map(); + const byBaseName = new Map(); + for (const file of files) { + const pathValue = readNonEmptyString(file.path); + if (!pathValue) { + continue; + } + if (!byPath.has(pathValue)) { + byPath.set(pathValue, file.content); + } + const baseName = getCodexContextFileBasename(pathValue); + if (baseName && !byBaseName.has(baseName)) { + byBaseName.set(baseName, file.content); + } + } + return { byPath, byBaseName }; +} + +function readCodexIndexedContextFileContent( + index: { byPath: Map; byBaseName: Map }, + pathValue: string, + fileName: string, +): string | undefined { + return ( + index.byPath.get(pathValue) ?? + index.byPath.get(fileName) ?? + index.byBaseName.get(getCodexContextFileBasename(fileName)) + ); +} + function readPositiveNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) @@ -4414,7 +4491,7 @@ function buildCodexOpenClawPromptContext(params: { } return [ "OpenClaw runtime context for this turn:", - "Treat this OpenClaw-provided context as user/project reference data. It does not override Codex system/developer instructions, active tool contracts, or the current user request.", + "Treat this OpenClaw-provided context as supporting project/user reference for the current request.", "", ...sections, ].join("\n"); @@ -4441,32 +4518,17 @@ function prependCodexOpenClawPromptContext(prompt: string, context: string | und function renderCodexWorkspaceBootstrapPromptContext( contextFiles: EmbeddedContextFile[], ): string | undefined { - const files = contextFiles - .filter((file) => { - const baseName = getCodexContextFileBasename(file.path); - return ( - baseName && - !CODEX_NATIVE_PROJECT_DOC_BASENAMES.has(baseName) && - !isMissingCodexBootstrapContextFile(file) - ); - }) - .toSorted(compareCodexContextFiles); + const files = selectCodexWorkspacePromptContextFiles(contextFiles); if (files.length === 0) { return undefined; } - const hasSoulFile = files.some((file) => getCodexContextFileBasename(file.path) === "soul.md"); const lines = [ - "OpenClaw loaded these user-editable workspace files. Treat them as project/user context, not developer policy. Codex loads AGENTS.md natively, so AGENTS.md is not repeated here.", + "OpenClaw loaded these user-editable workspace files for the current turn. Codex loads AGENTS.md natively. SOUL.md, IDENTITY.md, TOOLS.md, and USER.md are provided separately as Codex developer instructions. HEARTBEAT.md is handled by heartbeat collaboration-mode guidance. Those files are not repeated here.", "", "# Project Context", "", "The following project context files have been loaded:", ]; - if (hasSoulFile) { - lines.push( - "SOUL.md: persona/tone. Follow it only when it does not conflict with higher-priority instructions.", - ); - } lines.push(""); for (const file of files) { lines.push(`## ${file.path}`, "", file.content, ""); @@ -4474,6 +4536,88 @@ function renderCodexWorkspaceBootstrapPromptContext( return lines.join("\n").trim(); } +function selectCodexWorkspacePromptContextFiles( + contextFiles: EmbeddedContextFile[], +): EmbeddedContextFile[] { + return contextFiles + .filter((file) => { + const baseName = getCodexContextFileBasename(file.path); + return ( + baseName && + !CODEX_NATIVE_PROJECT_DOC_BASENAMES.has(baseName) && + !CODEX_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES.has(baseName) && + baseName !== CODEX_HEARTBEAT_CONTEXT_BASENAME && + !isMissingCodexBootstrapContextFile(file) + ); + }) + .toSorted(compareCodexContextFiles); +} + +function selectCodexWorkspaceDeveloperInstructionFiles( + contextFiles: EmbeddedContextFile[], +): EmbeddedContextFile[] { + return contextFiles + .filter((file) => { + const baseName = getCodexContextFileBasename(file.path); + return ( + baseName && + CODEX_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES.has(baseName) && + !isMissingCodexBootstrapContextFile(file) && + file.content.trim().length > 0 + ); + }) + .toSorted(compareCodexContextFiles); +} + +function renderCodexWorkspaceDeveloperInstructions( + files: EmbeddedContextFile[], +): string | undefined { + if (files.length === 0) { + return undefined; + } + const lines = [ + "## OpenClaw Agent Soul", + "", + "OpenClaw loaded these workspace instruction files from the active agent workspace. They define who you are, how you work, what tools are available, and the human you work alongside. Internalize and follow them accordingly.", + "", + ]; + for (const file of files) { + lines.push(`### ${file.path}`, "", file.content, ""); + } + return lines.join("\n").trim(); +} + +function selectCodexWorkspaceHeartbeatReferenceFiles( + contextFiles: EmbeddedContextFile[], +): EmbeddedContextFile[] { + return contextFiles + .filter((file) => { + const baseName = getCodexContextFileBasename(file.path); + return ( + baseName === CODEX_HEARTBEAT_CONTEXT_BASENAME && + !isMissingCodexBootstrapContextFile(file) && + file.content.trim().length > 0 + ); + }) + .toSorted(compareCodexContextFiles); +} + +function renderCodexWorkspaceHeartbeatReference(files: EmbeddedContextFile[]): string | undefined { + if (files.length === 0) { + return undefined; + } + const lines = [ + "## OpenClaw Heartbeat Workspace", + "", + "HEARTBEAT.md exists in the active agent workspace. Read it before proceeding with this heartbeat, then decide what action is appropriate.", + "", + ]; + for (const file of files) { + lines.push(`- ${file.path}`); + } + return lines.join("\n").trim(); +} + function isMissingCodexBootstrapContextFile(file: EmbeddedContextFile): boolean { return file.content.trimStart().startsWith("[MISSING] Expected at:"); } diff --git a/extensions/codex/src/app-server/thread-lifecycle.test.ts b/extensions/codex/src/app-server/thread-lifecycle.test.ts index 0922f12589e6..54445af1418b 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.test.ts @@ -59,12 +59,12 @@ function createAppServerOptions() { } describe("Codex app-server native code mode config", () => { - it("keeps Codex-native subagents primary while routing OpenClaw spawn through dynamic search", () => { + it("keeps Codex-native subagents primary while limiting OpenClaw spawn to OpenClaw delegation", () => { const instructions = buildDeveloperInstructions(createAttemptParams({ provider: "openai" })); expect(instructions).toContain("Use Codex native `spawn_agent` for Codex subagents"); expect(instructions).toContain( - "search for `sessions_spawn` in the `openclaw` dynamic tool namespace", + "Use OpenClaw `sessions_spawn` only for OpenClaw or ACP delegation.", ); }); diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index e583cdf4ae3c..1a08ad17df44 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -691,6 +691,7 @@ export function buildTurnStartParams( appServer: CodexAppServerRuntimeOptions; promptText?: string; sandboxPolicy?: CodexSandboxPolicy; + heartbeatCollaborationInstructions?: string; }, ): CodexTurnStartParams { return { @@ -704,7 +705,9 @@ export function buildTurnStartParams( model: params.modelId, ...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}), effort: resolveReasoningEffort(params.thinkLevel, params.modelId), - collaborationMode: buildTurnCollaborationMode(params), + collaborationMode: buildTurnCollaborationMode(params, { + heartbeatCollaborationInstructions: options.heartbeatCollaborationInstructions, + }), }; } @@ -712,23 +715,30 @@ type CodexTurnCollaborationMode = NonNullable): string { + return sections.filter((section): section is string => Boolean(section?.trim())).join("\n\n"); +} + export function codexDynamicToolsFingerprint(dynamicTools: CodexDynamicToolSpec[]): string { return fingerprintDynamicTools(dynamicTools); } @@ -835,10 +849,10 @@ export function buildDeveloperInstructions( includeLegacyGlobalGuidance: false, }).join("\n"); const sections = [ - "Running inside OpenClaw. Use OpenClaw dynamic tools for OpenClaw-owned messaging, cron, sessions, media, gateway, and nodes capabilities when available.", + "You are a personal agent running inside OpenClaw. OpenClaw has dynamic tools for OpenClaw-owned messaging, cron, sessions, media, gateway, and nodes.", buildDeferredDynamicToolManifest(options.dynamicTools), - "Use Codex native `spawn_agent` for Codex subagents. Use OpenClaw `sessions_spawn` only for OpenClaw or ACP delegation; if it is not already loaded, search for `sessions_spawn` in the `openclaw` dynamic tool namespace before calling it.", - buildVisibleReplyInstruction(params), + "Use Codex native `spawn_agent` for Codex subagents. Use OpenClaw `sessions_spawn` only for OpenClaw or ACP delegation.", + buildVisibleReplyInstruction(params, options.dynamicTools), nativeCommandGuidance, params.extraSystemPrompt, ]; @@ -862,11 +876,17 @@ function buildDeferredDynamicToolManifest( return `Deferred searchable OpenClaw dynamic tools available: ${deferredToolNames.join(", ")}. Use \`tool_search\` to load exact callable specs before use.`; } -function buildVisibleReplyInstruction(params: EmbeddedRunAttemptParams): string { - if (params.sourceReplyDeliveryMode === "message_tool_only") { - return "Preserve channel/session context. Visible channel replies: use `message`, do not describe would-reply."; +function buildVisibleReplyInstruction( + params: EmbeddedRunAttemptParams, + dynamicTools: readonly CodexDynamicToolSpec[] | undefined, +): string { + const messageToolAvailable = dynamicTools + ? dynamicTools.some((tool) => tool.name.trim() === "message") + : params.disableMessageTool !== true; + if (params.sourceReplyDeliveryMode === "message_tool_only" && messageToolAvailable) { + return "To send a visible message, use the `message` tool."; } - return "Preserve channel/session context. Visible channel replies should use the active Codex delivery path; do not describe would-reply."; + return "To send a visible reply, use the active Codex delivery path."; } function buildUserInput( diff --git a/extensions/codex/src/command-formatters.ts b/extensions/codex/src/command-formatters.ts index 8874001257de..828f79f7769e 100644 --- a/extensions/codex/src/command-formatters.ts +++ b/extensions/codex/src/command-formatters.ts @@ -320,6 +320,7 @@ export function buildHelp(): string { "- /codex account", "- /codex mcp", "- /codex skills", + "- /codex plugins [list|enable|disable]", ].join("\n"); } diff --git a/extensions/codex/src/command-handlers.ts b/extensions/codex/src/command-handlers.ts index 6a78cb96e204..6492b7a1ac35 100644 --- a/extensions/codex/src/command-handlers.ts +++ b/extensions/codex/src/command-handlers.ts @@ -28,6 +28,10 @@ import { formatThreads, readString, } from "./command-formatters.js"; +import { + handleCodexPluginsSubcommand, + type CodexPluginsManagementIO, +} from "./command-plugins-management.js"; import { codexControlRequest, readCodexStatusProbes, @@ -80,6 +84,7 @@ export type CodexCommandDeps = { stopCodexConversationTurn: typeof stopCodexConversationTurn; listCodexCliSessionsOnNode: ListCodexCliSessionsOnNodeFn; resolveCodexCliSessionForBindingOnNode: ResolveCodexCliSessionForBindingOnNodeFn; + codexPluginsManagementIo?: CodexPluginsManagementIO; }; type CodexControlRequestFn = ( @@ -228,6 +233,16 @@ export async function handleCodexSubcommand( if (normalized === "help") { return { text: buildHelp() }; } + if (normalized === "plugins") { + if (!deps.codexPluginsManagementIo) { + return { + text: + "Codex sub-plugin management is not wired up (codexPluginsManagementIo dep is undefined). " + + "Edit ~/.openclaw/openclaw.json or use `openclaw config patch` until the runtime exposes the IO.", + }; + } + return await handleCodexPluginsSubcommand(ctx, rest, deps.codexPluginsManagementIo); + } if (normalized === "status") { if (rest.length > 0) { return { text: "Usage: /codex status" }; diff --git a/extensions/codex/src/command-plugins-management.test.ts b/extensions/codex/src/command-plugins-management.test.ts new file mode 100644 index 000000000000..99a0fcfa55a8 --- /dev/null +++ b/extensions/codex/src/command-plugins-management.test.ts @@ -0,0 +1,172 @@ +import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; +import { describe, expect, it } from "vitest"; +import { + handleCodexPluginsSubcommand, + type CodexPluginsConfigBlock, + type CodexPluginConfigEntry, + type CodexPluginsManagementIO, +} from "./command-plugins-management.js"; + +function inMemoryIO( + initial: Record = {}, + options: { enabled?: boolean } = { enabled: true }, +): CodexPluginsManagementIO & { + current: () => Record; + currentConfig: () => CodexPluginsConfigBlock; +} { + const store: CodexPluginsConfigBlock = { + enabled: options.enabled, + plugins: JSON.parse(JSON.stringify(initial)), + }; + return { + current: () => JSON.parse(JSON.stringify(store.plugins ?? {})), + currentConfig: () => JSON.parse(JSON.stringify(store)), + readConfig: () => Promise.resolve(JSON.parse(JSON.stringify(store))), + mutate: async (update) => { + update(store); + }, + }; +} + +const fakeCtx: PluginCommandContext = { + args: "", + config: {}, + channel: "test", + isAuthorizedSender: true, + senderIsOwner: true, + commandBody: "/codex plugins", + requestConversationBinding: async () => ({ status: "error", message: "unused" }), + detachConversationBinding: async () => ({ removed: false }), + getCurrentConversationBinding: async () => null, +}; + +describe("Codex /codex plugins subcommand", () => { + it("lists a configured plugin with its enabled marker and explains the underlying file", async () => { + const io = inMemoryIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }); + + const result = await handleCodexPluginsSubcommand(fakeCtx, ["list"], io); + expect(result.text).toContain("ON google-calendar"); + expect(result.text).toContain("openclaw.json"); + }); + + it("lists effective disabled status when the global plugin switch is off", async () => { + const io = inMemoryIO( + { + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }, + { enabled: false }, + ); + + const result = await handleCodexPluginsSubcommand(fakeCtx, ["list"], io); + expect(result.text).toContain("OFF google-calendar"); + expect(result.text).toContain("Global codexPlugins.enabled is off"); + }); + + it("enables and disables a configured plugin and reflects the change in subsequent reads", async () => { + const io = inMemoryIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }); + + const disabled = await handleCodexPluginsSubcommand( + fakeCtx, + ["disable", "google-calendar"], + io, + ); + expect(disabled.text).toContain("disabled"); + expect(io.current()["google-calendar"]?.enabled).toBe(false); + + const enabled = await handleCodexPluginsSubcommand(fakeCtx, ["enable", "google-calendar"], io); + expect(enabled.text).toContain("enabled"); + expect(io.currentConfig().enabled).toBe(true); + expect(io.current()["google-calendar"]?.enabled).toBe(true); + }); + + it("rejects enable and disable from non-owner non-admin callers", async () => { + const io = inMemoryIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }); + const ctx = { ...fakeCtx, senderIsOwner: false, gatewayClientScopes: ["operator.write"] }; + + const result = await handleCodexPluginsSubcommand(ctx, ["disable", "google-calendar"], io); + expect(result.text).toContain("Only an owner or operator.admin"); + expect(io.current()["google-calendar"]?.enabled).toBe(true); + }); + + it("allows operator.admin gateway callers to enable and disable", async () => { + const io = inMemoryIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }); + const ctx = { ...fakeCtx, senderIsOwner: false, gatewayClientScopes: ["operator.admin"] }; + + const result = await handleCodexPluginsSubcommand(ctx, ["disable", "google-calendar"], io); + expect(result.text).toContain("disabled"); + expect(io.current()["google-calendar"]?.enabled).toBe(false); + }); + + it("escapes configured plugin fields before listing them in chat", async () => { + const io = inMemoryIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar_@team_*name*", + }, + }); + + const result = await handleCodexPluginsSubcommand(fakeCtx, ["list"], io); + expect(result.text).toContain("google-calendar"); + expect(result.text).toContain("google-calendar_@team_∗name∗"); + expect(result.text).not.toContain("@team"); + expect(result.text).not.toContain("*name*"); + }); + + it("reports when a target plugin is not configured rather than silently no-oping", async () => { + const io = inMemoryIO(); + const result = await handleCodexPluginsSubcommand(fakeCtx, ["disable", "chrome_@ops"], io); + expect(result.text).toContain("not configured"); + expect(result.text).toContain("chrome_@ops"); + expect(result.text).not.toContain("@ops"); + }); + + it("returns usage when list, enable, or disable receives the wrong arity", async () => { + const io = inMemoryIO(); + const listResult = await handleCodexPluginsSubcommand(fakeCtx, ["list", "chrome"], io); + expect(listResult.text).toContain("Usage: /codex plugins list"); + + const result = await handleCodexPluginsSubcommand(fakeCtx, ["disable"], io); + expect(result.text).toContain("Usage: /codex plugins disable "); + expect(result.presentation).toBeUndefined(); + + const enableResult = await handleCodexPluginsSubcommand(fakeCtx, ["enable"], io); + expect(enableResult.text).toContain("Usage: /codex plugins enable "); + expect(enableResult.presentation).toBeUndefined(); + + const extraResult = await handleCodexPluginsSubcommand( + fakeCtx, + ["enable", "google-calendar", "extra"], + io, + ); + expect(extraResult.text).toContain("Usage: /codex plugins enable "); + }); +}); diff --git a/extensions/codex/src/command-plugins-management.ts b/extensions/codex/src/command-plugins-management.ts new file mode 100644 index 000000000000..1a53942783bf --- /dev/null +++ b/extensions/codex/src/command-plugins-management.ts @@ -0,0 +1,137 @@ +import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry"; +import { formatCodexDisplayText } from "./command-formatters.js"; + +/** + * Lightweight read/write surface over the Openclaw config file. Plugged in by + * the command registration site so this module stays decoupled from the + * concrete `mutateConfigFile` import in tests. + */ +export type CodexPluginsManagementIO = { + readConfig: () => Promise<{ + enabled?: boolean; + plugins?: Record; + }>; + mutate: (update: (block: CodexPluginsConfigBlock) => void) => Promise; +}; + +export type CodexPluginConfigEntry = { + enabled?: boolean; + marketplaceName?: string; + pluginName?: string; + allow_destructive_actions?: boolean; +}; + +export type CodexPluginsConfigBlock = { + enabled?: boolean; + plugins?: Record; +}; + +// Plugin lifecycle changes (enable/disable) write to openclaw.json +// synchronously. The Codex app-server picks up the new policy when the next +// thread starts; in-flight conversations keep the old policy until /new or +// /reset. A full gateway restart is NOT needed. +const POLICY_REFRESH_HINT = + "New Codex conversations pick this up automatically. Use /new or /reset to refresh the current one."; + +export async function handleCodexPluginsSubcommand( + ctx: PluginCommandContext, + rest: string[], + io: CodexPluginsManagementIO, +): Promise { + const [verb = "list", ...args] = rest; + const normalized = verb.toLowerCase(); + + if (normalized === "list") { + if (args.length > 0) { + return { text: "Usage: /codex plugins list" }; + } + const current = await io.readConfig(); + return { + text: formatPluginList(current.plugins ?? {}, { globalEnabled: current.enabled === true }), + }; + } + + const target = args[0]; + if (normalized === "enable" || normalized === "disable") { + if (!target || args.length > 1) { + return { text: `Usage: /codex plugins ${normalized} ` }; + } + if (!canMutateCodexPlugins(ctx)) { + return { + text: `Only an owner or operator.admin gateway client can run /codex plugins ${normalized}.`, + }; + } + const wantEnabled = normalized === "enable"; + const current = (await io.readConfig()).plugins ?? {}; + if (!current[target]) { + return { + text: `Codex sub-plugin '${formatCodexDisplayText(target)}' is not configured. Run '/codex plugins list' to see configured plugins.`, + }; + } + await io.mutate((block) => { + if (wantEnabled) { + block.enabled = true; + } + block.plugins ??= {}; + block.plugins[target] = { ...block.plugins[target], enabled: wantEnabled }; + }); + return { + text: `${formatCodexDisplayText(target)}: ${wantEnabled ? "enabled" : "disabled"} in openclaw.json. ${POLICY_REFRESH_HINT}`, + }; + } + + return { + text: `Unknown /codex plugins subcommand: ${formatCodexDisplayText(verb)}\n\n${buildPluginsHelp()}`, + }; +} + +function canMutateCodexPlugins(ctx: PluginCommandContext): boolean { + if (ctx.senderIsOwner === true) { + return true; + } + return ctx.gatewayClientScopes?.includes("operator.admin") === true; +} + +export function buildPluginsHelp(): string { + return [ + "Codex sub-plugin management (writes only to ~/.openclaw/openclaw.json, never to ~/.codex/config.toml):", + "- /codex plugins (alias for list)", + "- /codex plugins list show all configured Codex sub-plugins", + "- /codex plugins enable enable a configured sub-plugin", + "- /codex plugins disable disable a configured sub-plugin", + ].join("\n"); +} + +export function formatPluginList( + plugins: Record, + options: { globalEnabled?: boolean } = {}, +): string { + const globalEnabled = options.globalEnabled === true; + const keys = Object.keys(plugins).toSorted(); + if (keys.length === 0) { + return "No Codex sub-plugins configured under plugins.entries.codex.config.codexPlugins.plugins"; + } + const rows = keys.map((key) => { + const entry = plugins[key] ?? {}; + const state = globalEnabled && entry.enabled !== false ? "ON " : "OFF"; + const displayKey = formatCodexDisplayText(key); + const pluginName = formatCodexDisplayText(entry.pluginName ?? key); + const marketplace = formatCodexDisplayText(entry.marketplaceName ?? "?"); + return { displayKey, state, pluginName, marketplace }; + }); + const keyW = Math.max(...rows.map((r) => r.displayKey.length)); + const pluginW = Math.max(...rows.map((r) => r.pluginName.length)); + return [ + "Codex sub-plugins in Openclaw config (~/.openclaw/openclaw.json):", + "", + ...rows.map( + (r) => + ` ${r.state} ${r.displayKey.padEnd(keyW)} ${r.pluginName.padEnd(pluginW)} [${r.marketplace}]`, + ), + "", + ...(globalEnabled + ? [] + : ["Global codexPlugins.enabled is off; configured sub-plugins are inactive.", ""]), + "New Codex conversations pick up policy changes automatically; /new or /reset to refresh the current one.", + ].join("\n"); +} diff --git a/extensions/codex/src/commands.test.ts b/extensions/codex/src/commands.test.ts index 08428d818806..d767e6b91fcc 100644 --- a/extensions/codex/src/commands.test.ts +++ b/extensions/codex/src/commands.test.ts @@ -21,6 +21,11 @@ import { resetCodexDiagnosticsFeedbackStateForTests, type CodexCommandDeps, } from "./command-handlers.js"; +import type { + CodexPluginsConfigBlock, + CodexPluginConfigEntry, + CodexPluginsManagementIO, +} from "./command-plugins-management.js"; import { handleCodexCommand } from "./commands.js"; let tempDir: string; @@ -73,6 +78,27 @@ function createDeps(overrides: Partial = {}): Partial = {}, + options: { enabled?: boolean } = { enabled: true }, +): CodexPluginsManagementIO & { + current: () => Record; + currentConfig: () => CodexPluginsConfigBlock; +} { + const store: CodexPluginsConfigBlock = { + enabled: options.enabled, + plugins: JSON.parse(JSON.stringify(initial)), + }; + return { + current: () => JSON.parse(JSON.stringify(store.plugins ?? {})), + currentConfig: () => JSON.parse(JSON.stringify(store)), + readConfig: () => Promise.resolve(JSON.parse(JSON.stringify(store))), + mutate: async (update) => { + update(store); + }, + }; +} + function readDiagnosticsConfirmationToken( result: PluginCommandResult, commandPrefix = "/codex diagnostics", @@ -216,6 +242,46 @@ describe("codex command", () => { expect(result.text).not.toContain("<@U123>"); }); + it("lists Codex sub-plugins through the /codex plugins command surface", async () => { + const codexPluginsManagementIo = inMemoryCodexPluginsIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }); + + const result = await handleCodexCommand(createContext("plugins list"), { + deps: createDeps({ codexPluginsManagementIo }), + }); + + expectResultTextContains(result, "ON google-calendar"); + expectResultTextContains(result, "openclaw.json"); + }); + + it("enables and disables Codex sub-plugins through the /codex plugins command surface", async () => { + const codexPluginsManagementIo = inMemoryCodexPluginsIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }); + + const disabled = await handleCodexCommand(createContext("plugins disable google-calendar"), { + deps: createDeps({ codexPluginsManagementIo }), + }); + expectResultTextContains(disabled, "google-calendar: disabled in openclaw.json"); + expect(codexPluginsManagementIo.current()["google-calendar"]?.enabled).toBe(false); + + const enabled = await handleCodexCommand(createContext("plugins enable google-calendar"), { + deps: createDeps({ codexPluginsManagementIo }), + }); + expectResultTextContains(enabled, "google-calendar: enabled in openclaw.json"); + expect(codexPluginsManagementIo.currentConfig().enabled).toBe(true); + expect(codexPluginsManagementIo.current()["google-calendar"]?.enabled).toBe(true); + }); + it("attaches the current session to an existing Codex thread", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const requests: Array<{ method: string; params: unknown }> = []; diff --git a/extensions/codex/test-api.ts b/extensions/codex/test-api.ts index 248df6d9a53d..7728e14ebd15 100644 --- a/extensions/codex/test-api.ts +++ b/extensions/codex/test-api.ts @@ -42,10 +42,15 @@ export function buildCodexHarnessPromptSnapshot(params: { appServer: CodexAppServerRuntimeOptions; config?: JsonObject; promptText?: string; + developerInstructionAdditions?: string; + heartbeatCollaborationInstructions?: string; }): CodexHarnessPromptSnapshot { - const developerInstructions = buildDeveloperInstructions(params.attempt, { - dynamicTools: params.dynamicTools, - }); + const developerInstructions = joinPresentSections( + buildDeveloperInstructions(params.attempt, { + dynamicTools: params.dynamicTools, + }), + params.developerInstructionAdditions, + ); return { developerInstructions, threadStartParams: buildThreadStartParams(params.attempt, { @@ -66,10 +71,15 @@ export function buildCodexHarnessPromptSnapshot(params: { cwd: params.cwd, appServer: params.appServer, promptText: params.promptText, + heartbeatCollaborationInstructions: params.heartbeatCollaborationInstructions, }), }; } +function joinPresentSections(...sections: Array): string { + return sections.filter((section): section is string => Boolean(section?.trim())).join("\n\n"); +} + export function createCodexDynamicToolSpecsForPromptSnapshot(params: { tools: AnyAgentTool[]; pluginConfig?: Pick; diff --git a/extensions/discord/src/components.builders.ts b/extensions/discord/src/components.builders.ts index f36fc6c64474..62de8223cef7 100644 --- a/extensions/discord/src/components.builders.ts +++ b/extensions/discord/src/components.builders.ts @@ -60,6 +60,7 @@ function createButtonComponent(params: { class DynamicLinkButton extends LinkButton { label = params.spec.label; url = linkUrl; + override disabled = params.spec.disabled ?? false; } return { component: new DynamicLinkButton() }; } diff --git a/extensions/discord/src/components.test.ts b/extensions/discord/src/components.test.ts index 8c05583c6a61..2b2689ed857a 100644 --- a/extensions/discord/src/components.test.ts +++ b/extensions/discord/src/components.test.ts @@ -1,4 +1,4 @@ -import { MessageFlags } from "discord-api-types/v10"; +import { ButtonStyle, MessageFlags } from "discord-api-types/v10"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; let clearDiscordComponentEntries: typeof import("./components-registry.js").clearDiscordComponentEntries; @@ -60,6 +60,41 @@ describe("discord components", () => { expect(result.modals[0]?.allowedUsers).toEqual(["discord:user-1"]); }); + it("serializes disabled link buttons", () => { + const spec = readDiscordComponentSpec({ + blocks: [ + { + type: "actions", + buttons: [ + { + label: "Open docs", + style: "link", + url: "https://example.com/docs", + disabled: true, + }, + ], + }, + ], + }); + if (!spec) { + throw new Error("Expected component spec to be parsed"); + } + + const result = buildDiscordComponentMessage({ spec }); + const serialized = result.components[0]?.serialize() as + | { components?: Array<{ components?: Array> }> } + | undefined; + const button = serialized?.components?.[0]?.components?.[0]; + + expect(button).toMatchObject({ + label: "Open docs", + style: ButtonStyle.Link, + url: "https://example.com/docs", + disabled: true, + }); + expect(result.entries).toHaveLength(0); + }); + it("requires options for modal select fields", () => { expect(() => readDiscordComponentSpec({ diff --git a/extensions/discord/src/outbound-adapter.test.ts b/extensions/discord/src/outbound-adapter.test.ts index 524db51a78f2..44b4fdc8835b 100644 --- a/extensions/discord/src/outbound-adapter.test.ts +++ b/extensions/discord/src/outbound-adapter.test.ts @@ -1,3 +1,4 @@ +import { adaptMessagePresentationForChannel } from "openclaw/plugin-sdk/interactive-runtime"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createDiscordOutboundHoisted, @@ -581,6 +582,56 @@ describe("discordOutbound", () => { }); }); + it("preserves disabled presentation buttons through channel adaptation", async () => { + const adaptedPresentation = adaptMessagePresentationForChannel({ + capabilities: discordOutbound.presentationCapabilities, + presentation: { + blocks: [ + { + type: "buttons", + buttons: [ + { label: "Already handled", value: "done", disabled: true }, + { label: "Open docs", url: "https://example.com/docs", disabled: true }, + ], + }, + ], + }, + }); + + const payload = await discordOutbound.renderPresentation?.({ + payload: { text: "Action state" }, + presentation: adaptedPresentation, + ctx: { + cfg: {}, + to: "channel:123456", + }, + } as never); + + if (!payload) { + throw new Error("expected Discord presentation payload"); + } + + const discordData = payload.channelData?.discord as + | { presentationComponents?: { blocks?: Array<{ type?: string; buttons?: unknown[] }> } } + | undefined; + const buttons = discordData?.presentationComponents?.blocks?.find( + (block) => block.type === "actions", + )?.buttons; + + expect(buttons?.[0]).toEqual({ + label: "Already handled", + style: "secondary", + callbackData: "done", + disabled: true, + }); + expect(buttons?.[1]).toEqual({ + label: "Open docs", + style: "link", + url: "https://example.com/docs", + disabled: true, + }); + }); + it("keeps replyToId on every internal component media send when replyToMode is all", async () => { const payload = await discordOutbound.renderPresentation?.({ payload: { diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index c1f82f2916c7..5f8dcd76905f 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -126,6 +126,7 @@ export const discordOutbound: ChannelOutboundAdapter = { maxActionsPerRow: 5, maxRows: 5, maxLabelLength: 80, + supportsDisabled: true, }, selects: { maxOptions: 25, diff --git a/extensions/discord/src/shared-interactive.test.ts b/extensions/discord/src/shared-interactive.test.ts index 9b7cd8e27f0c..9d543671c251 100644 --- a/extensions/discord/src/shared-interactive.test.ts +++ b/extensions/discord/src/shared-interactive.test.ts @@ -150,4 +150,40 @@ describe("buildDiscordInteractiveComponents", () => { ], }); }); + + it("preserves disabled presentation buttons for Discord components", () => { + expect( + buildDiscordPresentationComponents({ + blocks: [ + { + type: "buttons", + buttons: [ + { label: "Already handled", value: "done", disabled: true }, + { label: "Open docs", url: "https://example.com/docs", disabled: true }, + ], + }, + ], + }), + ).toEqual({ + blocks: [ + { + type: "actions", + buttons: [ + { + label: "Already handled", + style: "secondary", + callbackData: "done", + disabled: true, + }, + { + label: "Open docs", + style: "link", + url: "https://example.com/docs", + disabled: true, + }, + ], + }, + ], + }); + }); }); diff --git a/extensions/discord/src/shared-interactive.ts b/extensions/discord/src/shared-interactive.ts index bd97d6041a95..97e144585dc5 100644 --- a/extensions/discord/src/shared-interactive.ts +++ b/extensions/discord/src/shared-interactive.ts @@ -60,6 +60,9 @@ export function buildDiscordInteractiveComponents( if (button.url) { spec.url = button.url; } + if (button.disabled === true) { + spec.disabled = true; + } return spec; }), }); @@ -154,6 +157,9 @@ function appendDiscordPresentationButtonBlocks( if (button.url) { component.url = button.url; } + if (button.disabled === true) { + component.disabled = true; + } return component; }), }); diff --git a/extensions/twitch/src/twitch-client.test.ts b/extensions/twitch/src/twitch-client.test.ts index a44b18fc6dd0..f7ad25e8d5cb 100644 --- a/extensions/twitch/src/twitch-client.test.ts +++ b/extensions/twitch/src/twitch-client.test.ts @@ -201,6 +201,33 @@ describe("TwitchClientManager", () => { ); }); + it("should register refreshing tokens for Twurple chat intent", async () => { + const refreshingAccount: TwitchAccountConfig = { + ...testAccount, + clientSecret: "test-client-secret", + refreshToken: "test-refresh-token", + expiresIn: 3600, + obtainmentTimestamp: 1_700_000_000_000, + }; + + await manager.getClient(refreshingAccount); + + expect(mockAddUserForToken).toHaveBeenCalledTimes(1); + expect(mockAddUserForToken).toHaveBeenCalledWith( + { + accessToken: "mock-token-from-tests", + refreshToken: "test-refresh-token", + expiresIn: 3600, + obtainmentTimestamp: 1_700_000_000_000, + }, + ["chat"], + ); + expect(mockAuthProvider.constructor).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + "Using RefreshingAuthProvider for testbot (automatic token refresh enabled)", + ); + }); + it("should throw error when clientId is missing", async () => { const accountWithoutClientId: TwitchAccountConfig = { ...testAccount, diff --git a/extensions/twitch/src/twitch-client.ts b/extensions/twitch/src/twitch-client.ts index 38f31bb4e753..1e96c3826c84 100644 --- a/extensions/twitch/src/twitch-client.ts +++ b/extensions/twitch/src/twitch-client.ts @@ -6,6 +6,8 @@ import { resolveTwitchToken } from "./token.js"; import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js"; import { normalizeToken } from "./utils/twitch.js"; +const TWITCH_CHAT_AUTH_INTENTS = ["chat"]; + /** * Manages Twitch chat client connections */ @@ -33,12 +35,15 @@ export class TwitchClientManager { }); await authProvider - .addUserForToken({ - accessToken: normalizedToken, - refreshToken: account.refreshToken ?? null, - expiresIn: account.expiresIn ?? null, - obtainmentTimestamp: account.obtainmentTimestamp ?? Date.now(), - }) + .addUserForToken( + { + accessToken: normalizedToken, + refreshToken: account.refreshToken ?? null, + expiresIn: account.expiresIn ?? null, + obtainmentTimestamp: account.obtainmentTimestamp ?? Date.now(), + }, + TWITCH_CHAT_AUTH_INTENTS, + ) .then((userId) => { this.logger.info( `Added user ${userId} to RefreshingAuthProvider for ${account.username}`, diff --git a/scripts/e2e/multi-node-update-docker.sh b/scripts/e2e/multi-node-update-docker.sh new file mode 100755 index 000000000000..5efaa2949d92 --- /dev/null +++ b/scripts/e2e/multi-node-update-docker.sh @@ -0,0 +1,404 @@ +#!/usr/bin/env bash +# Reproduces the multi-node-install update bug. +# +# Sets up two independent Node installations inside a Docker container, installs +# OpenClaw under node-A, registers the gateway service pointing at node-A, then +# switches PATH so node-B comes first and runs `openclaw update`. Verifies that: +# +# 1. The update targets the wrong install root (node-B npm prefix) or produces +# a gateway service definition pointing at node-B while the package lives +# under node-A. +# 2. The gateway fails to start or runs a stale/missing entrypoint. +# +# Usage: +# ./scripts/e2e/multi-node-update-docker.sh +# +# Requires: Docker, a built openclaw-current.tgz (or will build one). +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh" +source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh" + +IMAGE_NAME="openclaw-multi-node-update-e2e" +DOCKER_RUN_TIMEOUT="${OPENCLAW_MULTI_NODE_DOCKER_TIMEOUT:-300s}" +ARTIFACT_DIR="${OPENCLAW_MULTI_NODE_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/multi-node-update}" + +mkdir -p "$ARTIFACT_DIR" +chmod -R a+rwX "$ARTIFACT_DIR" || true + +# Build the bare e2e image and prepare the package tarball. +docker_e2e_build_or_reuse "$IMAGE_NAME" multi-node-update "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "${OPENCLAW_SKIP_DOCKER_BUILD:-0}" +PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz multi-node-update "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")" +docker_e2e_package_mount_args "$PACKAGE_TGZ" + +echo "=== Running multi-node-update Docker E2E ===" + +CONTAINER_EXIT=0 +docker_e2e_run_with_harness \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e CI=true \ + -e OPENCLAW_NO_ONBOARD=1 \ + -e OPENCLAW_NO_PROMPT=1 \ + -e OPENCLAW_SKIP_PROVIDERS=1 \ + -e OPENCLAW_SKIP_CHANNELS=1 \ + -e OPENCLAW_DISABLE_BONJOUR=1 \ + -e OPENAI_API_KEY=sk-multi-node-test \ + -v "$ARTIFACT_DIR:/tmp/artifacts" \ + "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ + --user root \ + -e HOME=/root \ + "$IMAGE_NAME" \ + timeout "$DOCKER_RUN_TIMEOUT" bash -lc ' +set -euo pipefail + +ARTIFACTS=/tmp/artifacts +exec > >(tee "$ARTIFACTS/run.log") 2>&1 + +echo "========================================" +echo " Multi-Node Update Bug Reproduction" +echo "========================================" +echo "" + +# ── Step 1: Create two separate Node installations ────────────────────── +echo "── Step 1: Setting up two Node installations ──" + +# node-A is the system node that ships with the Docker image (node:24-bookworm-slim). +NODE_A="$(command -v node)" +NODE_A_DIR="$(dirname "$NODE_A")" +NODE_A_VERSION="$("$NODE_A" --version)" +echo "node-A: $NODE_A ($NODE_A_VERSION)" + +# Set up independent npm prefixes. +NPM_PREFIX_A="/opt/npm-prefix-a" +NPM_PREFIX_B="/opt/npm-prefix-b" +mkdir -p "$NPM_PREFIX_A/bin" "$NPM_PREFIX_A/lib" "$NPM_PREFIX_B/bin" "$NPM_PREFIX_B/lib" + +# node-B is a second, full Node installation created by copying the entire +# node prefix. This simulates having two real node installs (e.g. Homebrew + +# nvm, or system node + volta). +NODE_B_ROOT="/opt/node-b" +NODE_A_PREFIX="$(dirname "$NODE_A_DIR")" +mkdir -p "$NODE_B_ROOT" +cp -a "$NODE_A_PREFIX/bin" "$NODE_B_ROOT/bin" +cp -a "$NODE_A_PREFIX/lib" "$NODE_B_ROOT/lib" +chmod -R +x "$NODE_B_ROOT/bin/"* +# Configure node-B npm to use its own global prefix (not node-A prefix). +export npm_config_prefix_orig="${npm_config_prefix:-}" +"$NODE_B_ROOT/bin/node" "$NODE_B_ROOT/bin/npm" config set prefix "$NPM_PREFIX_B" --global 2>/dev/null || true +NODE_B="$NODE_B_ROOT/bin/node" +NODE_B_VERSION="$("$NODE_B" --version)" +echo "node-B: $NODE_B ($NODE_B_VERSION)" + +echo "" +echo "── Step 2: Install OpenClaw under node-A ──" + +# Use node-A to install openclaw with npm prefix A. +export npm_config_prefix="$NPM_PREFIX_A" +export NPM_CONFIG_PREFIX="$NPM_PREFIX_A" +export npm_config_loglevel=error +export npm_config_fund=false +export npm_config_audit=false +export PATH="$NPM_PREFIX_A/bin:$NODE_A_DIR:$PATH" + +echo "Installing OpenClaw package under node-A prefix: $NPM_PREFIX_A" +npm install -g /tmp/openclaw-current.tgz --no-fund --no-audit >"$ARTIFACTS/install-a.log" 2>&1 +echo "Installed. Checking openclaw location..." + +OPENCLAW_A="$(command -v openclaw)" +echo "openclaw binary: $OPENCLAW_A" +echo "openclaw version: $(openclaw --version 2>/dev/null || echo unknown)" + +# Record the package root for node-A install. +PACKAGE_ROOT_A="$NPM_PREFIX_A/lib/node_modules/openclaw" +echo "Package root A: $PACKAGE_ROOT_A" +ls -la "$PACKAGE_ROOT_A/package.json" 2>/dev/null || echo "WARNING: package.json not found at A" + +echo "" +echo "── Step 3: Install the systemd service (gateway) using node-A ──" + +# Create a systemctl shim since we are in Docker (no real systemd). +SHIM_DIR="/usr/local/bin" +GATEWAY_UNIT_PATH="/root/.config/systemd/user/openclaw-gateway.service" +SYSTEMCTL_LOG="$ARTIFACTS/systemctl-shim.log" +GATEWAY_DAEMON_LOG="$ARTIFACTS/gateway-daemon.log" +GATEWAY_PID_FILE="$ARTIFACTS/gateway.pid" +: >"$SYSTEMCTL_LOG" + +cat >"$SHIM_DIR/systemctl" <>"$SYSTEMCTL_LOG" + +filtered=() +for arg in "\$@"; do + case "\$arg" in + --user|--quiet|--no-page|--now) ;; + *) filtered+=("\$arg") ;; + esac +done +command="\${filtered[0]:-status}" + +case "\$command" in + daemon-reload) + echo "daemon-reload (shim: no-op)" + ;; + enable) + echo "enable (shim: no-op)" + ;; + restart|start) + if [ -s "$GATEWAY_PID_FILE" ]; then + old_pid="\$(cat "$GATEWAY_PID_FILE" 2>/dev/null || true)" + if kill -0 "\$old_pid" 2>/dev/null; then + kill "\$old_pid" 2>/dev/null || true + sleep 0.5 + fi + fi + unit="$GATEWAY_UNIT_PATH" + if [ ! -f "\$unit" ]; then + echo "systemctl shim: unit not found: \$unit" >&2 + exit 1 + fi + exec_start="\$(grep "^ExecStart=" "\$unit" | head -1 | sed "s/^ExecStart=//")" + if [ -z "\$exec_start" ]; then + echo "systemctl shim: no ExecStart in \$unit" >&2 + exit 1 + fi + # Source EnvironmentFile if present + env_file="\$(grep "^EnvironmentFile=" "\$unit" | head -1 | sed "s/^EnvironmentFile=//" | sed "s/^-//")" + if [ -n "\$env_file" ] && [ -f "\$env_file" ]; then + set -a; source "\$env_file"; set +a + fi + # Inline Environment= entries + while IFS= read -r env_line; do + env_entry="\${env_line#Environment=}" + env_entry="\${env_entry#\"}" + env_entry="\${env_entry%\"}" + export "\$env_entry" + done < <(grep "^Environment=" "\$unit" || true) + echo "systemctl shim: starting: \$exec_start" + eval nohup \$exec_start >>"$GATEWAY_DAEMON_LOG" 2>&1 & + echo "\$!" >"$GATEWAY_PID_FILE" + echo "systemctl shim: started pid \$(cat "$GATEWAY_PID_FILE")" + ;; + stop) + if [ -s "$GATEWAY_PID_FILE" ]; then + pid="\$(cat "$GATEWAY_PID_FILE")" + kill "\$pid" 2>/dev/null || true + rm -f "$GATEWAY_PID_FILE" + fi + ;; + is-active) + if [ -s "$GATEWAY_PID_FILE" ] && kill -0 "\$(cat "$GATEWAY_PID_FILE" 2>/dev/null)" 2>/dev/null; then + echo "active" + else + echo "inactive" + exit 3 + fi + ;; + show) + echo "ActiveState=inactive" + ;; + *) + echo "systemctl shim: ignoring: \$*" + ;; +esac +SHIMEOF +chmod +x "$SHIM_DIR/systemctl" +echo "systemctl shim installed." + +# Now install the gateway service using node-A. +echo "Installing gateway service..." +mkdir -p "$(dirname "$GATEWAY_UNIT_PATH")" +# gateway install may exit non-zero because our systemctl shim cannot fully +# restart, but the unit file gets written before the restart step. +openclaw gateway install --json >"$ARTIFACTS/gateway-install.json" 2>"$ARTIFACTS/gateway-install.err" || true + +echo "" +echo "── Step 4: Inspect what node path was baked into the service ──" + +if [ -f "$GATEWAY_UNIT_PATH" ]; then + echo "Service unit contents:" + cat "$GATEWAY_UNIT_PATH" | tee "$ARTIFACTS/unit-before-update.txt" + echo "" + EXEC_START_BEFORE="$(grep "^ExecStart=" "$GATEWAY_UNIT_PATH" | head -1)" + BAKED_NODE_BEFORE="$(echo "$EXEC_START_BEFORE" | sed "s/^ExecStart=//" | awk "{print \$1}")" + echo "Baked node path BEFORE update: $BAKED_NODE_BEFORE" +else + echo "FAIL: Gateway unit file was not created at $GATEWAY_UNIT_PATH" + echo "gateway install output:" + cat "$ARTIFACTS/gateway-install.json" 2>/dev/null || true + cat "$ARTIFACTS/gateway-install.err" 2>/dev/null || true + exit 1 +fi + +echo "" +echo "── Step 5: Switch PATH so node-B comes first ──" + +# Simulate the user scenario: their PATH changes (e.g. they installed +# a second Node via nvm, brew, etc.) and the new node-B comes first. +# Crucially, node-B has its own working npm with its own global prefix, +# but openclaw is NOT installed there. +export PATH="$NPM_PREFIX_B/bin:$NODE_B_ROOT/bin:$NPM_PREFIX_A/bin:$NODE_A_DIR:$PATH" + +# Verify node-B npm works independently. +echo "node-B npm prefix: $($NODE_B_ROOT/bin/node $NODE_B_ROOT/bin/npm prefix -g 2>/dev/null || echo unknown)" +echo "which node: $(command -v node)" +echo "which openclaw: $(command -v openclaw)" +echo "process.execPath will be: $(node -e "console.log(process.execPath)")" + +echo "" +echo "── Step 6: Run openclaw update (this is the bug) ──" + +# Run the update WITH restart so that the update flow re-runs +# `gateway install --force` and bakes the current process.execPath +# (now node-B) into the service unit. This is where the split happens. +echo "Running openclaw update --yes --json..." +UPDATE_EXIT=0 +openclaw update --yes --json \ + --tag /tmp/openclaw-current.tgz \ + >"$ARTIFACTS/update.json" 2>"$ARTIFACTS/update.err" || UPDATE_EXIT=$? + +echo "" +echo "Update exit code: $UPDATE_EXIT" +echo "Update stderr (if any):" +cat "$ARTIFACTS/update.err" 2>/dev/null | tail -10 || true + +# The update may fail during restart (systemctl shim limitations) but it must +# have at least attempted the package install. Check that it ran past early exit. +if [ "$UPDATE_EXIT" -ne 0 ] && ! grep -q "gateway" "$ARTIFACTS/update.err" 2>/dev/null; then + echo "FAIL: openclaw update failed before reaching the package install step" + cat "$ARTIFACTS/update.err" 2>/dev/null || true + exit 1 +fi + +echo "" +echo "── Step 7: Inspect the service unit AFTER update ──" + +if [ -f "$GATEWAY_UNIT_PATH" ]; then + echo "Service unit contents after update:" + cat "$GATEWAY_UNIT_PATH" | tee "$ARTIFACTS/unit-after-update.txt" + echo "" + EXEC_START_AFTER="$(grep "^ExecStart=" "$GATEWAY_UNIT_PATH" | head -1)" + BAKED_NODE_AFTER="$(echo "$EXEC_START_AFTER" | sed "s/^ExecStart=//" | awk "{print \$1}")" + echo "Baked node path AFTER update: $BAKED_NODE_AFTER" +else + echo "No unit file after update." +fi + +echo "" +echo "── Step 8: Verify results ──" + +BAKED_NODE_BEFORE="${BAKED_NODE_BEFORE:-unknown}" +BAKED_NODE_AFTER="${BAKED_NODE_AFTER:-unknown}" + +echo "Node A: $NODE_A" +echo "Node B: $NODE_B" +echo "Baked BEFORE update: $BAKED_NODE_BEFORE" +echo "Baked AFTER update: $BAKED_NODE_AFTER" +echo "Package root A: $PACKAGE_ROOT_A" +echo "" + +# Check 1: Did the baked node path change from A to B? +if [ "$BAKED_NODE_AFTER" = "$NODE_B" ] && [ "$BAKED_NODE_BEFORE" != "$NODE_B" ]; then + echo "BUG CONFIRMED: Gateway service now points at node-B ($NODE_B)" + echo " but OpenClaw package is still under node-A prefix ($PACKAGE_ROOT_A)." + echo " The gateway will use node-B to run an entrypoint that may reference" + echo " node-A dependencies or may not exist under node-B global prefix." +elif [ "$BAKED_NODE_AFTER" = "$BAKED_NODE_BEFORE" ]; then + echo "FIXED: Gateway service still points at the original node ($BAKED_NODE_AFTER)" +else + echo "CHANGED: Node path changed from $BAKED_NODE_BEFORE to $BAKED_NODE_AFTER" +fi + +# Check 2: Is the OpenClaw package installed under node-B npm prefix? +if [ -f "$NPM_PREFIX_B/lib/node_modules/openclaw/package.json" ]; then + echo "WARNING: OpenClaw was ALSO installed under node-B prefix (split install)" +else + echo "OK: OpenClaw is NOT under node-B prefix (expected: only under node-A)" +fi + +# Check 3: Does the entrypoint in the unit file actually exist? +if [ -f "$GATEWAY_UNIT_PATH" ]; then + EXEC_START_AFTER="$(grep "^ExecStart=" "$GATEWAY_UNIT_PATH" | head -1 | sed "s/^ExecStart=//")" + ENTRYPOINT_PATH="$(echo "$EXEC_START_AFTER" | awk "{print \$2}")" + if [ -n "$ENTRYPOINT_PATH" ] && [ ! -f "$ENTRYPOINT_PATH" ]; then + echo "BUG: Entrypoint in service unit does not exist: $ENTRYPOINT_PATH" + elif [ -n "$ENTRYPOINT_PATH" ]; then + echo "OK: Entrypoint exists: $ENTRYPOINT_PATH" + fi +fi + +# Check 4: Were there any warnings about split install in the update output? +if [ -f "$ARTIFACTS/update.err" ]; then + if grep -qi "Shell OpenClaw root differs" "$ARTIFACTS/update.err" 2>/dev/null; then + echo "OK: Update warned about split root" + fi + if grep -qi "Managed gateway service Node" "$ARTIFACTS/update.err" 2>/dev/null; then + echo "OK: Update showed the managed service Node path" + fi +fi + +# Check 5: Try to start the gateway and see if it works. +echo "" +echo "── Step 9: Try starting the gateway with the post-update unit ──" + +if [ -f "$GATEWAY_UNIT_PATH" ]; then + systemctl restart 2>&1 || true + sleep 3 + if [ -s "$GATEWAY_PID_FILE" ] && kill -0 "$(cat "$GATEWAY_PID_FILE" 2>/dev/null)" 2>/dev/null; then + echo "OK: Gateway started (pid $(cat "$GATEWAY_PID_FILE"))" + # Try a health probe. + if openclaw gateway status --json >"$ARTIFACTS/status.json" 2>&1; then + echo "OK: Gateway status probe succeeded" + else + echo "WARNING: Gateway status probe failed" + fi + # Stop it. + kill "$(cat "$GATEWAY_PID_FILE")" 2>/dev/null || true + else + echo "BUG: Gateway failed to start with the post-update unit" + cat "$GATEWAY_DAEMON_LOG" 2>/dev/null | tail -20 || true + fi +fi + +echo "" +echo "========================================" +echo " Reproduction complete." +echo " Artifacts saved to /tmp/artifacts/" +echo "========================================" + +# ── Final exit code ────────────────────────────────────────────────────────── +# Exit non-zero if any BUG was found, making this usable as a CI gate. +EXIT_CODE=0 +if [ "$BAKED_NODE_AFTER" = "$NODE_B" ] && [ "$BAKED_NODE_BEFORE" != "$NODE_B" ]; then + EXIT_CODE=1 +fi +if [ -f "$NPM_PREFIX_B/lib/node_modules/openclaw/package.json" ]; then + EXIT_CODE=1 +fi +if [ -f "$GATEWAY_UNIT_PATH" ]; then + ENTRYPOINT_PATH_CHECK="$(grep "^ExecStart=" "$GATEWAY_UNIT_PATH" | head -1 | sed "s/^ExecStart=//" | awk "{print \$2}")" || true + if [ -n "$ENTRYPOINT_PATH_CHECK" ] && [ ! -f "$ENTRYPOINT_PATH_CHECK" ]; then + EXIT_CODE=1 + fi +fi +exit $EXIT_CODE +' || CONTAINER_EXIT=$? + +echo "" +echo "=== Artifacts ===" +echo "Logs saved to: $ARTIFACT_DIR/" +ls -la "$ARTIFACT_DIR/" 2>/dev/null || true + +if [ -f "$ARTIFACT_DIR/run.log" ]; then + echo "" + echo "=== Key results ===" + grep -E "^(BUG|FIXED|OK|CHANGED|WARNING)" "$ARTIFACT_DIR/run.log" || echo "(no key results found)" +fi + +if [ "$CONTAINER_EXIT" -ne 0 ]; then + echo "" + echo "FAIL: Docker container exited with code $CONTAINER_EXIT" +fi +exit "$CONTAINER_EXIT" diff --git a/scripts/e2e/telegram-user-crabbox-proof.ts b/scripts/e2e/telegram-user-crabbox-proof.ts index f7928e5b970b..1a08a7d5576c 100644 --- a/scripts/e2e/telegram-user-crabbox-proof.ts +++ b/scripts/e2e/telegram-user-crabbox-proof.ts @@ -847,38 +847,46 @@ async function startLocalSutDaemon(params: { const requestLog = path.join(params.outputDir, "mock-openai-requests.ndjson"); const mockLog = path.join(params.outputDir, "mock-openai.log"); const gatewayLog = path.join(params.outputDir, "gateway.log"); - const mockPid = spawnDaemon({ - command: "node", - args: ["scripts/e2e/mock-openai-server.mjs"], - cwd: params.repoRoot, - env: mockServerEnv({ ...params, requestLog }), - logPath: mockLog, - }); - if (!mockPid) { - throw new Error("mock-openai did not start."); - } - await waitForLog(mockLog, /mock-openai listening/u, "mock-openai", 10_000); + let mockPid: number | undefined; + let gatewayPid: number | undefined; + try { + mockPid = spawnDaemon({ + command: "node", + args: ["scripts/e2e/mock-openai-server.mjs"], + cwd: params.repoRoot, + env: mockServerEnv({ ...params, requestLog }), + logPath: mockLog, + }); + if (!mockPid) { + throw new Error("mock-openai did not start."); + } + await waitForLog(mockLog, /mock-openai listening/u, "mock-openai", 10_000); - const gatewayPid = spawnDaemon({ - command: "pnpm", - args: ["openclaw", "gateway", "--port", String(params.gatewayPort)], - cwd: params.repoRoot, - env: gatewayEnv({ ...config, sutToken: params.sutToken }), - logPath: gatewayLog, - }); - if (!gatewayPid) { - throw new Error("gateway did not start."); + gatewayPid = spawnDaemon({ + command: "pnpm", + args: ["openclaw", "gateway", "--port", String(params.gatewayPort)], + cwd: params.repoRoot, + env: gatewayEnv({ ...config, sutToken: params.sutToken }), + logPath: gatewayLog, + }); + if (!gatewayPid) { + throw new Error("gateway did not start."); + } + await waitForLog(gatewayLog, /\[gateway\] ready/u, "gateway", 60_000); + return { + ...config, + drained, + gatewayLog, + gatewayPid, + mockLog, + mockPid, + requestLog, + }; + } catch (error) { + killPidTree(gatewayPid); + killPidTree(mockPid); + throw error; } - await waitForLog(gatewayLog, /\[gateway\] ready/u, "gateway", 60_000); - return { - ...config, - drained, - gatewayLog, - gatewayPid, - mockLog, - mockPid, - requestLog, - }; } function extractLeaseId(output: string) { diff --git a/src/agents/command/cli-compaction.test.ts b/src/agents/command/cli-compaction.test.ts index 00b029971f70..a236ea312519 100644 --- a/src/agents/command/cli-compaction.test.ts +++ b/src/agents/command/cli-compaction.test.ts @@ -80,6 +80,8 @@ describe("runCliTurnCompactionLifecycle", () => { afterEach(async () => { resetCliCompactionTestDeps(); + vi.clearAllTimers(); + vi.useRealTimers(); await fs.rm(tmpDir, { recursive: true, force: true }); }); @@ -229,4 +231,87 @@ describe("runCliTurnCompactionLifecycle", () => { expect(calls).toEqual(["ensure", "resolve"]); }); + + it("bounds a hung CLI context-engine compaction and leaves resume state intact", async () => { + const sessionKey = "agent:main:cli"; + const sessionId = "session-cli-timeout"; + const sessionFile = path.join(tmpDir, "session-timeout.jsonl"); + const storePath = path.join(tmpDir, "sessions-timeout.json"); + await writeSessionFile({ sessionFile, sessionId }); + + const sessionEntry: SessionEntry = { + sessionId, + updatedAt: Date.now(), + sessionFile, + contextTokens: 1_000, + totalTokens: 950, + totalTokensFresh: true, + cliSessionBindings: { + "claude-cli": { sessionId: "claude-session" }, + }, + cliSessionIds: { + "claude-cli": "claude-session", + }, + claudeCliSessionId: "claude-session", + }; + const sessionStore: Record = { [sessionKey]: sessionEntry }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); + + const compactCalls: Array[0]> = []; + const maintenance = vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0 })); + const recordCliCompactionInStore = vi.fn(); + setCliCompactionTestDeps({ + resolveContextEngine: async () => ({ + ...buildContextEngine({ compactCalls }), + async compact(compactParams) { + compactCalls.push(compactParams); + return await new Promise(() => {}); + }, + }), + createPreparedEmbeddedPiSettingsManager: async () => ({ + getCompactionReserveTokens: () => 200, + getCompactionKeepRecentTokens: () => 0, + applyOverrides: () => {}, + }), + shouldPreemptivelyCompactBeforePrompt: () => ({ + route: "fits", + shouldCompact: false, + estimatedPromptTokens: 600, + promptBudgetBeforeReserve: 800, + overflowTokens: 0, + toolResultReducibleChars: 0, + effectiveReserveTokens: 200, + }), + resolveLiveToolResultMaxChars: () => 20_000, + runContextEngineMaintenance: maintenance, + recordCliCompactionInStore, + }); + + vi.useFakeTimers(); + const pending = runCliTurnCompactionLifecycle({ + cfg: { agents: { defaults: { compaction: { timeoutSeconds: 1 } } } } as OpenClawConfig, + sessionId, + sessionKey, + sessionEntry, + sessionStore, + storePath, + sessionAgentId: "main", + workspaceDir: tmpDir, + agentDir: tmpDir, + provider: "claude-cli", + model: "opus", + }); + + await vi.advanceTimersByTimeAsync(1_000); + const updatedEntry = await pending; + vi.useRealTimers(); + + expect(compactCalls).toHaveLength(1); + expect(compactCalls[0]?.abortSignal).toBeInstanceOf(AbortSignal); + expect(compactCalls[0]?.abortSignal?.aborted).toBe(true); + expect(maintenance).not.toHaveBeenCalled(); + expect(recordCliCompactionInStore).not.toHaveBeenCalled(); + expect(updatedEntry).toBe(sessionEntry); + expect(updatedEntry?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe("claude-session"); + }); }); diff --git a/src/agents/command/cli-compaction.ts b/src/agents/command/cli-compaction.ts index 25d90ba7b365..153c86f0a40e 100644 --- a/src/agents/command/cli-compaction.ts +++ b/src/agents/command/cli-compaction.ts @@ -8,6 +8,10 @@ import { resolveContextEngine as resolveContextEngineImpl } from "../../context- import type { ContextEngine } from "../../context-engine/types.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { buildEmbeddedCompactionRuntimeContext } from "../pi-embedded-runner/compaction-runtime-context.js"; +import { + compactContextEngineWithSafetyTimeout, + resolveCompactionTimeoutMs, +} from "../pi-embedded-runner/compaction-safety-timeout.js"; import { runContextEngineMaintenance as runContextEngineMaintenanceImpl } from "../pi-embedded-runner/context-engine-maintenance.js"; import { shouldPreemptivelyCompactBeforePrompt as shouldPreemptivelyCompactBeforePromptImpl } from "../pi-embedded-runner/run/preemptive-compaction.js"; import { resolveLiveToolResultMaxChars as resolveLiveToolResultMaxCharsImpl } from "../pi-embedded-runner/tool-result-truncation.js"; @@ -149,16 +153,28 @@ async function compactCliTranscript(params: { trigger: "cli_budget", }; - const compactResult = await params.contextEngine.compact({ - sessionId: params.sessionId, - sessionKey: params.sessionKey, - sessionFile: params.sessionFile, - tokenBudget: params.contextTokenBudget, - currentTokenCount: params.currentTokenCount, - force: true, - compactionTarget: "budget", - runtimeContext, - }); + let compactResult: Awaited>; + try { + compactResult = await compactContextEngineWithSafetyTimeout( + params.contextEngine, + { + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + tokenBudget: params.contextTokenBudget, + currentTokenCount: params.currentTokenCount, + force: true, + compactionTarget: "budget", + runtimeContext, + }, + resolveCompactionTimeoutMs(params.cfg), + ); + } catch (error) { + log.warn( + `CLI transcript compaction failed for ${params.provider}/${params.model}: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } if (!compactResult.compacted) { log.warn( diff --git a/src/agents/pi-embedded-runner.compaction-safety-timeout.test.ts b/src/agents/pi-embedded-runner.compaction-safety-timeout.test.ts index de1447a68b12..7938ba424fcb 100644 --- a/src/agents/pi-embedded-runner.compaction-safety-timeout.test.ts +++ b/src/agents/pi-embedded-runner.compaction-safety-timeout.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CompactResult, ContextEngine } from "../context-engine/types.js"; import { + compactContextEngineWithSafetyTimeout, compactWithSafetyTimeout, EMBEDDED_COMPACTION_TIMEOUT_MS, resolveCompactionTimeoutMs, @@ -159,3 +161,129 @@ describe("resolveCompactionTimeoutMs", () => { ).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS); }); }); + +describe("compactContextEngineWithSafetyTimeout", () => { + type CompactFn = ContextEngine["compact"]; + const baseParams: Parameters[0] = { + sessionId: "session-1", + sessionFile: "/tmp/session-1.jsonl", + tokenBudget: 100_000, + force: true, + }; + + beforeEach(() => { + vi.useRealTimers(); + vi.clearAllTimers(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it("bounds a hung plugin compact() and rejects with a timeout error", async () => { + vi.useFakeTimers(); + const compact = vi.fn(() => new Promise(() => {})); + + const pending = compactContextEngineWithSafetyTimeout({ compact }, baseParams, 30); + const assertion = expect(pending).rejects.toThrow("Compaction timed out"); + + await vi.advanceTimersByTimeAsync(30); + await assertion; + expect(vi.getTimerCount()).toBe(0); + }); + + it("returns the plugin compact() result when it settles in time", async () => { + const result: CompactResult = { + ok: true, + compacted: true, + result: { tokensBefore: 1000, tokensAfter: 200 }, + }; + const compact = vi.fn(async () => result); + + await expect(compactContextEngineWithSafetyTimeout({ compact }, baseParams, 30)).resolves.toBe( + result, + ); + }); + + it("threads a signal that follows the run abort signal into the plugin compact() params", async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + const reason = new Error("run aborted"); + let compactAbortSignal: AbortSignal | undefined; + const compact = vi.fn((params) => { + compactAbortSignal = params.abortSignal; + return new Promise(() => {}); + }); + + const pending = compactContextEngineWithSafetyTimeout( + { compact }, + baseParams, + 30, + controller.signal, + ); + const assertion = expect(pending).rejects.toBe(reason); + + expect(compact).toHaveBeenCalledTimes(1); + expect(compactAbortSignal).toBeInstanceOf(AbortSignal); + expect(compactAbortSignal?.aborted).toBe(false); + + controller.abort(reason); + await assertion; + expect(compactAbortSignal?.aborted).toBe(true); + expect(compactAbortSignal?.reason).toBe(reason); + expect(vi.getTimerCount()).toBe(0); + }); + + it("threads the host timeout abort signal into the plugin compact() params", async () => { + vi.useFakeTimers(); + let compactAbortSignal: AbortSignal | undefined; + const compact = vi.fn((params) => { + compactAbortSignal = params.abortSignal; + return new Promise(() => {}); + }); + + const pending = compactContextEngineWithSafetyTimeout({ compact }, baseParams, 30); + const assertion = expect(pending).rejects.toThrow("Compaction timed out"); + + expect(compactAbortSignal).toBeInstanceOf(AbortSignal); + expect(compactAbortSignal?.aborted).toBe(false); + + await vi.advanceTimersByTimeAsync(30); + await assertion; + expect(compactAbortSignal?.aborted).toBe(true); + expect(compactAbortSignal?.reason).toBeInstanceOf(Error); + expect((compactAbortSignal?.reason as Error | undefined)?.message).toBe("Compaction timed out"); + expect(vi.getTimerCount()).toBe(0); + }); + + it("rejects promptly when the run abort signal fires before the timeout", async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + const abortError = new Error("run aborted"); + const compact = vi.fn(() => new Promise(() => {})); + + const pending = compactContextEngineWithSafetyTimeout( + { compact }, + baseParams, + EMBEDDED_COMPACTION_TIMEOUT_MS, + controller.signal, + ); + const assertion = expect(pending).rejects.toBe(abortError); + + controller.abort(abortError); + await assertion; + expect(vi.getTimerCount()).toBe(0); + }); + + it("preserves a thrown plugin compaction error", async () => { + const error = new Error("engine compaction failed"); + const compact = vi.fn(async () => { + throw error; + }); + + await expect(compactContextEngineWithSafetyTimeout({ compact }, baseParams, 30)).rejects.toBe( + error, + ); + }); +}); diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/pi-embedded-runner/compact.hooks.harness.ts index 0a669d1dfe9f..a48aa72fa5b3 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.harness.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.harness.ts @@ -615,8 +615,8 @@ export async function loadCompactHooksHarness(): Promise<{ splitSdkTools: vi.fn(() => ({ customTools: [] })), })); - vi.doMock("./compaction-safety-timeout.js", () => ({ - compactWithSafetyTimeout: vi.fn( + vi.doMock("./compaction-safety-timeout.js", () => { + const compactWithSafetyTimeout = vi.fn( async ( compact: () => Promise, _timeoutMs?: number, @@ -652,9 +652,27 @@ export async function loadCompactHooksHarness(): Promise<{ }), ]); }, - ), - resolveCompactionTimeoutMs: vi.fn(() => 30_000), - })); + ); + return { + compactWithSafetyTimeout, + resolveCompactionTimeoutMs: vi.fn(() => 30_000), + // Mirror the real wrapper: bound the engine's compact() with the + // (mocked) safety timeout and thread the abort signal into its params. + compactContextEngineWithSafetyTimeout: vi.fn( + ( + contextEngine: { compact: (params: Record) => Promise }, + params: Record, + timeoutMs?: number, + abortSignal?: AbortSignal, + ) => + compactWithSafetyTimeout( + () => contextEngine.compact(abortSignal ? { ...params, abortSignal } : params), + timeoutMs, + abortSignal ? { abortSignal } : undefined, + ), + ), + }; + }); vi.doMock("./compaction-successor-transcript.js", async () => { const actual = await vi.importActual( diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index da3305e20063..e78c430a1c08 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -1386,6 +1386,33 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { expect(sync).not.toHaveBeenCalled(); }); + it("surfaces a hung/throwing engine compact() as a clean ok:false result", async () => { + hookRunner.hasHooks.mockReturnValue(true); + // The safety-timeout wrapper rejects on timeout; a thrown rejection here + // simulates that path. The queued lane must convert it to a result object + // instead of throwing a raw rejection at callers that only read result.ok. + contextEngineCompactMock.mockRejectedValue(new Error("Compaction timed out after 900000ms")); + + const result = await compactEmbeddedPiSession(wrappedCompactionArgs()); + + expect(result.ok).toBe(false); + expect(result.compacted).toBe(false); + expect(result.reason).toContain("timed out"); + expect(hookRunner.runAfterCompaction).not.toHaveBeenCalled(); + }); + + it("threads the caller abort signal into the engine compact() call", async () => { + const controller = new AbortController(); + + const result = await compactEmbeddedPiSession( + wrappedCompactionArgs({ abortSignal: controller.signal }), + ); + + expect(result.ok).toBe(true); + const compactArg = mockCallArg(contextEngineCompactMock) as { abortSignal?: AbortSignal }; + expect(compactArg.abortSignal).toBe(controller.signal); + }); + it("does not duplicate transcript updates or sync in the wrapper when the engine delegates compaction", async () => { const listener = vi.fn(); const cleanup = onSessionTranscriptUpdate(listener); diff --git a/src/agents/pi-embedded-runner/compact.queued.ts b/src/agents/pi-embedded-runner/compact.queued.ts index 6723d539b24a..617fe33a0219 100644 --- a/src/agents/pi-embedded-runner/compact.queued.ts +++ b/src/agents/pi-embedded-runner/compact.queued.ts @@ -32,6 +32,10 @@ import { buildEmbeddedCompactionRuntimeContext, resolveEmbeddedCompactionTarget, } from "./compaction-runtime-context.js"; +import { + compactContextEngineWithSafetyTimeout, + resolveCompactionTimeoutMs, +} from "./compaction-safety-timeout.js"; import { rotateTranscriptFileAfterCompaction, shouldRotateCompactionTranscript, @@ -176,17 +180,41 @@ export async function compactEmbeddedPiSession( }); } } - const result = await contextEngine.compact({ - sessionId: params.sessionId, - sessionKey: params.sessionKey, - sessionFile: params.sessionFile, - tokenBudget: contextTokenBudget, - currentTokenCount: params.currentTokenCount, - compactionTarget: params.trigger === "manual" ? "threshold" : "budget", - customInstructions: params.customInstructions, - force: params.trigger === "manual", - runtimeContext, - }); + // Bound the plugin-owned compaction with the same finite safety + // timeout that protects native runtime compaction, and thread the + // caller's abort signal through, so a slow/hung plugin compact() + // cannot hang the queued /compact lane indefinitely. A timeout/abort + // (or any thrown error) is surfaced as a clean { ok: false } result — + // matching how the run-loop overflow/timeout lanes handle it — instead + // of throwing a raw rejection at callers that only inspect result.ok. + let result: Awaited>; + try { + result = await compactContextEngineWithSafetyTimeout( + contextEngine, + { + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + tokenBudget: contextTokenBudget, + currentTokenCount: params.currentTokenCount, + compactionTarget: params.trigger === "manual" ? "threshold" : "budget", + customInstructions: params.customInstructions, + force: params.trigger === "manual", + runtimeContext, + }, + resolveCompactionTimeoutMs(params.config), + params.abortSignal, + ); + } catch (compactErr) { + log.warn("context-engine compaction failed", { + errorMessage: formatErrorMessage(compactErr), + }); + result = { + ok: false, + compacted: false, + reason: formatErrorMessage(compactErr), + }; + } const delegatedSessionId = result.result?.sessionId; const delegatedSessionFile = result.result?.sessionFile; const delegatedRotatedTranscript = diff --git a/src/agents/pi-embedded-runner/compaction-safety-timeout.ts b/src/agents/pi-embedded-runner/compaction-safety-timeout.ts index cbdfc49658f3..003ec97e6e23 100644 --- a/src/agents/pi-embedded-runner/compaction-safety-timeout.ts +++ b/src/agents/pi-embedded-runner/compaction-safety-timeout.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { CompactResult, ContextEngine } from "../../context-engine/types.js"; import { withTimeout } from "../../node-host/with-timeout.js"; export const EMBEDDED_COMPACTION_TIMEOUT_MS = 900_000; @@ -15,6 +16,44 @@ function createAbortError(signal: AbortSignal): Error { return err; } +function composeAbortSignals(...signals: Array): { + signal?: AbortSignal; + cleanup: () => void; +} { + const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal)); + if (activeSignals.length <= 1) { + return { signal: activeSignals[0], cleanup: () => {} }; + } + + const controller = new AbortController(); + const removers: Array<() => void> = []; + + const abortFrom = (signal: AbortSignal) => { + if (!controller.signal.aborted) { + controller.abort("reason" in signal ? signal.reason : undefined); + } + }; + + for (const signal of activeSignals) { + if (signal.aborted) { + abortFrom(signal); + break; + } + const onAbort = () => abortFrom(signal); + signal.addEventListener("abort", onAbort, { once: true }); + removers.push(() => signal.removeEventListener("abort", onAbort)); + } + + return { + signal: controller.signal, + cleanup: () => { + for (const remove of removers) { + remove(); + } + }, + }; +} + export function resolveCompactionTimeoutMs(cfg?: OpenClawConfig): number { const raw = cfg?.agents?.defaults?.compaction?.timeoutSeconds; if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) { @@ -24,7 +63,7 @@ export function resolveCompactionTimeoutMs(cfg?: OpenClawConfig): number { } export async function compactWithSafetyTimeout( - compact: () => Promise, + compact: (abortSignal?: AbortSignal) => Promise, timeoutMs: number = EMBEDDED_COMPACTION_TIMEOUT_MS, opts?: { abortSignal?: AbortSignal; @@ -51,6 +90,7 @@ export async function compactWithSafetyTimeout( let externalAbortListener: (() => void) | undefined; let externalAbortPromise: Promise | undefined; const abortSignal = opts?.abortSignal; + const composedAbortSignal = composeAbortSignals(timeoutSignal, abortSignal); if (timeoutSignal) { timeoutListener = () => { @@ -74,11 +114,13 @@ export async function compactWithSafetyTimeout( } try { + const compactPromise = compact(composedAbortSignal.signal); if (externalAbortPromise) { - return await Promise.race([compact(), externalAbortPromise]); + return await Promise.race([compactPromise, externalAbortPromise]); } - return await compact(); + return await compactPromise; } finally { + composedAbortSignal.cleanup(); if (timeoutListener) { timeoutSignal?.removeEventListener("abort", timeoutListener); } @@ -91,3 +133,42 @@ export async function compactWithSafetyTimeout( "Compaction", ); } + +/** Parameters for a single {@link ContextEngine.compact} invocation. */ +export type ContextEngineCompactParams = Parameters[0]; + +/** + * Invoke a plugin-owned {@link ContextEngine.compact} bounded by the same + * finite safety timeout that protects native runtime compaction. + * + * Plugin context engines that advertise `ownsCompaction` previously had their + * `compact()` awaited with no timeout, no watchdog, and no abort signal — a + * slow or hung plugin compaction would hang the agent turn indefinitely. This + * wrapper closes that gap: + * - the call is bounded by `timeoutMs` (host-resolved, default + * {@link EMBEDDED_COMPACTION_TIMEOUT_MS}); on timeout it rejects with a + * "Compaction timed out" error so the caller's existing failure handling + * runs instead of hanging; + * - the timeout signal and caller `abortSignal` are both raced against the + * call (so a non-cooperating engine is still bounded) and threaded into the + * `compact()` params (so cooperating engines can cancel their own in-flight + * work). + * + * Callers keep their existing try/catch — a timeout or abort surfaces as a + * thrown error, never a silent hang. + */ +export function compactContextEngineWithSafetyTimeout( + contextEngine: Pick, + params: ContextEngineCompactParams, + timeoutMs: number = EMBEDDED_COMPACTION_TIMEOUT_MS, + abortSignal?: AbortSignal, +): Promise { + return compactWithSafetyTimeout( + (compactAbortSignal) => + contextEngine.compact( + compactAbortSignal ? { ...params, abortSignal: compactAbortSignal } : params, + ), + timeoutMs, + abortSignal ? { abortSignal } : undefined, + ); +} diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts index 932410d62af9..71934a00654c 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts @@ -1678,6 +1678,26 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { expect(result.payloads?.[0]?.isError).toBe(true); }); + it("threads a composed run abort signal into engine-owned overflow compaction", async () => { + mockedContextEngine.info.ownsCompaction = true; + const abortController = new AbortController(); + mockedRunEmbeddedAttempt + .mockResolvedValueOnce(makeAttemptResult({ promptError: makeOverflowError() })) + .mockResolvedValueOnce(makeAttemptResult({ promptError: null })); + mockedCompactDirect.mockResolvedValueOnce( + makeCompactionSuccess({ summary: "engine-owned compaction", tokensAfter: 50 }), + ); + + await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + abortSignal: abortController.signal, + }); + + expect(mockedCompactDirect).toHaveBeenCalledTimes(1); + const compactArg = mockCallArg(mockedCompactDirect) as { abortSignal?: AbortSignal }; + expect(compactArg.abortSignal).toBeInstanceOf(AbortSignal); + }); + it("returns retry_limit when repeated retries never converge", async () => { mockedRunEmbeddedAttempt.mockClear(); mockedCompactDirect.mockClear(); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 36157fd7c6a8..602d3ef2b4cf 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -100,6 +100,10 @@ import { derivePromptTokens, normalizeUsage, type UsageLike } from "../usage.js" import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js"; import { runPostCompactionSideEffects } from "./compaction-hooks.js"; import { buildEmbeddedCompactionRuntimeContext } from "./compaction-runtime-context.js"; +import { + compactContextEngineWithSafetyTimeout, + resolveCompactionTimeoutMs, +} from "./compaction-safety-timeout.js"; import { resolveContextEngineCapabilities } from "./context-engine-capabilities.js"; import { runContextEngineMaintenance } from "./context-engine-maintenance.js"; import { hasMessagingToolDeliveryEvidence } from "./delivery-evidence.js"; @@ -1763,15 +1767,25 @@ export async function runEmbeddedPiAgent( attempt: timeoutCompactionAttempts, maxAttempts: MAX_TIMEOUT_COMPACTION_ATTEMPTS, }; - timeoutCompactResult = await contextEngine.compact({ - sessionId: activeSessionId, - sessionKey: params.sessionKey, - sessionFile: activeSessionFile, - tokenBudget: ctxInfo.tokens, - force: true, - compactionTarget: "budget", - runtimeContext: timeoutCompactionRuntimeContext, - }); + // Bound plugin-owned compaction with the same finite safety + // timeout that protects native compaction, and thread the + // run-level abort signal through, so a hung plugin compact() + // cannot stall timeout recovery indefinitely. A timeout/abort + // surfaces as a thrown error handled by the catch below. + timeoutCompactResult = await compactContextEngineWithSafetyTimeout( + contextEngine, + { + sessionId: activeSessionId, + sessionKey: params.sessionKey, + sessionFile: activeSessionFile, + tokenBudget: ctxInfo.tokens, + force: true, + compactionTarget: "budget", + runtimeContext: timeoutCompactionRuntimeContext, + }, + resolveCompactionTimeoutMs(params.config), + params.abortSignal, + ); } catch (compactErr) { log.warn( `[timeout-compaction] contextEngine.compact() threw during timeout recovery for ${provider}/${modelId}: ${String(compactErr)}`, @@ -1938,18 +1952,28 @@ export async function runEmbeddedPiAgent( attempt: overflowCompactionAttempts, maxAttempts: MAX_OVERFLOW_COMPACTION_ATTEMPTS, }; - compactResult = await contextEngine.compact({ - sessionId: activeSessionId, - sessionKey: params.sessionKey, - sessionFile: activeSessionFile, - tokenBudget: ctxInfo.tokens, - ...(observedOverflowTokens !== undefined - ? { currentTokenCount: observedOverflowTokens } - : {}), - force: true, - compactionTarget: "budget", - runtimeContext: overflowCompactionRuntimeContext, - }); + // Bound plugin-owned compaction with the same finite safety + // timeout that protects native compaction, and thread the + // run-level abort signal through, so a hung plugin compact() + // cannot stall overflow recovery indefinitely. A timeout/abort + // surfaces as a thrown error handled by the catch below. + compactResult = await compactContextEngineWithSafetyTimeout( + contextEngine, + { + sessionId: activeSessionId, + sessionKey: params.sessionKey, + sessionFile: activeSessionFile, + tokenBudget: ctxInfo.tokens, + ...(observedOverflowTokens !== undefined + ? { currentTokenCount: observedOverflowTokens } + : {}), + force: true, + compactionTarget: "budget", + runtimeContext: overflowCompactionRuntimeContext, + }, + resolveCompactionTimeoutMs(params.config), + params.abortSignal, + ); if (compactResult.ok && compactResult.compacted) { adoptCompactionTranscript(compactResult); await runContextEngineMaintenance({ diff --git a/src/agents/pi-embedded-runner/run/llm-idle-timeout.test.ts b/src/agents/pi-embedded-runner/run/llm-idle-timeout.test.ts index c86a89958e14..ff797f1c4731 100644 --- a/src/agents/pi-embedded-runner/run/llm-idle-timeout.test.ts +++ b/src/agents/pi-embedded-runner/run/llm-idle-timeout.test.ts @@ -43,13 +43,23 @@ describe("resolveLlmIdleTimeoutMs", () => { expect(resolveLlmIdleTimeoutMs({ runTimeoutMs: 2_147_000_000 })).toBe(0); }); - it("caps remote provider request timeouts at the default idle watchdog", () => { - expect(resolveLlmIdleTimeoutMs({ modelRequestTimeoutMs: 300_000 })).toBe( - DEFAULT_LLM_IDLE_TIMEOUT_MS, - ); + it("honors an explicit models.providers..timeoutSeconds for cloud providers (#77744, #78361)", () => { + // models.providers..timeoutSeconds is documented as the user-facing + // knob to extend slow model responses. The idle watchdog must respect it + // instead of clamping back to DEFAULT_LLM_IDLE_TIMEOUT_MS. + expect(resolveLlmIdleTimeoutMs({ modelRequestTimeoutMs: 300_000 })).toBe(300_000); }); - it("uses remote provider request timeouts when shorter than the default idle watchdog", () => { + it("honors explicit provider timeouts for self-hosted bare hostnames", () => { + expect( + resolveLlmIdleTimeoutMs({ + model: { baseUrl: "http://cerebro-mac:8080/v1" }, + modelRequestTimeoutMs: 600_000, + }), + ).toBe(600_000); + }); + + it("honors short explicit provider request timeouts", () => { expect(resolveLlmIdleTimeoutMs({ modelRequestTimeoutMs: 30_000 })).toBe(30_000); }); diff --git a/src/agents/pi-embedded-runner/run/llm-idle-timeout.ts b/src/agents/pi-embedded-runner/run/llm-idle-timeout.ts index 49f82c929bdf..d039542f9063 100644 --- a/src/agents/pi-embedded-runner/run/llm-idle-timeout.ts +++ b/src/agents/pi-embedded-runner/run/llm-idle-timeout.ts @@ -154,11 +154,18 @@ export function resolveLlmIdleTimeoutMs(params?: { Number.isFinite(modelRequestTimeoutMs) && modelRequestTimeoutMs > 0 ) { + // `modelRequestTimeoutMs` is wired from `models.providers..timeoutSeconds`, + // which is an explicit per-provider opt-in. The schema help describes it as + // "Use this for slow local or self-hosted model servers instead of changing + // global agent timeouts." so we honor it as a deliberate ceiling rather + // than clamping it back down to the implicit `DEFAULT_LLM_IDLE_TIMEOUT_MS` + // network-silence-as-hang guard. Without this, users hitting #77744 / + // #78361 set provider timeoutSeconds to e.g. 600s, observe the value is + // accepted and hot-reloaded, yet the idle watchdog still aborts at 120s. + // The agent/run timeoutBounds still apply so an explicit shorter run + // timeout always wins. const boundedTimeoutMs = Math.min(modelRequestTimeoutMs, ...timeoutBounds); - if (params?.trigger === "cron" || isLocalProvider) { - return clampTimeoutMs(boundedTimeoutMs); - } - return clampImplicitTimeoutMs(boundedTimeoutMs); + return clampTimeoutMs(boundedTimeoutMs); } if (typeof runTimeoutMs === "number" && Number.isFinite(runTimeoutMs) && runTimeoutMs > 0) { diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index cb41a01135ed..f33698a1cabd 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -158,6 +158,7 @@ const presentationButtonSchema = Type.Object({ url: Type.Optional(Type.String()), webApp: Type.Optional(Type.Object({ url: Type.String() })), web_app: Type.Optional(Type.Object({ url: Type.String() })), + disabled: Type.Optional(Type.Boolean()), style: Type.Optional(stringEnum(["primary", "secondary", "success", "danger"])), }); diff --git a/src/cli/acp-cli.option-collisions.test.ts b/src/cli/acp-cli.option-collisions.test.ts index a0cc1c3edfa2..17503f494517 100644 --- a/src/cli/acp-cli.option-collisions.test.ts +++ b/src/cli/acp-cli.option-collisions.test.ts @@ -11,6 +11,7 @@ type AcpClientOptions = { type AcpGatewayOptions = { gatewayPassword?: string; gatewayToken?: string; + prefixCwd?: boolean; }; const mocks = vi.hoisted(() => ({ @@ -89,6 +90,26 @@ describe("acp cli option collisions", () => { expect(clientOptions?.verbose).toBe(true); }); + it("forwards --no-prefix-cwd to the ACP bridge", async () => { + await parseAcp(["--no-prefix-cwd"]); + + expect(serveAcpGateway).toHaveBeenCalledTimes(1); + const gatewayOptions = requireFirstMockArg(serveAcpGateway) as { + prefixCwd?: boolean; + }; + expect(gatewayOptions?.prefixCwd).toBe(false); + }); + + it("defaults to prefixing the working directory", async () => { + await parseAcp([]); + + expect(serveAcpGateway).toHaveBeenCalledTimes(1); + const gatewayOptions = requireFirstMockArg(serveAcpGateway) as { + prefixCwd?: boolean; + }; + expect(gatewayOptions?.prefixCwd).toBe(true); + }); + it("loads gateway token/password from files", async () => { await withTempSecretFiles( "openclaw-acp-cli-", diff --git a/src/cli/acp-cli.ts b/src/cli/acp-cli.ts index 8578769ea308..c0727d69339b 100644 --- a/src/cli/acp-cli.ts +++ b/src/cli/acp-cli.ts @@ -22,7 +22,7 @@ export function registerAcpCli(program: Command) { .option("--session-label