Merge remote-tracking branch 'upstream/main' into fix/anthropic-configured-claude-image-capability

This commit is contained in:
Andy Ye
2026-05-19 16:50:48 -07:00
117 changed files with 4388 additions and 617 deletions
@@ -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"
@@ -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
+1 -1
View File
@@ -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"
@@ -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"
+15
View File
@@ -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.<id>.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.
@@ -605,7 +605,6 @@ class GatewaySession(
setOf(
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
)
scopes.filter { allowedOperatorScopes.contains(it) }.distinct().sorted()
@@ -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(),
@@ -365,7 +365,7 @@ class GatewaySessionInvokeTest {
assertEquals(emptyList<String>(), 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 {
@@ -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 =
@@ -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")
}
}
@@ -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…"
+15
View File
@@ -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 {
+7 -15
View File
@@ -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
+1
View File
@@ -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
@@ -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()
@@ -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,
@@ -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",
@@ -405,7 +405,6 @@ struct GatewayNodeSessionTests {
#expect(operatorEntry.scopes == [
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
])
@@ -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
+7 -4
View File
@@ -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
+3 -1
View File
@@ -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=<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
+2 -2
View File
@@ -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`.
+2 -2
View File
@@ -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/<vendor>/<model>` namespace (for example `nvidia/nvidia/nemotron-...` alongside `nvidia/moonshotai/kimi-k2.5`); pickers preserve the literal `<provider>/<model-id>` composition while the canonical key sent to the API stays single-prefixed.
</Accordion>
<Accordion title="xAI">
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/<model>"].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/<model>"].params.tool_stream=false`.
</Accordion>
<Accordion title="Cerebras">
Ships as the bundled `cerebras` provider plugin. GLM uses `zai-glm-4.7`; OpenAI-compatible base URL is `https://api.cerebras.ai/v1`.
+2 -2
View File
@@ -105,10 +105,10 @@ Sharp is good. Annoying is not.
<CardGroup cols={2}>
<Card title="Agent workspace" href="/concepts/agent-workspace" icon="folder-open">
Workspace files OpenClaw injects into the system prompt.
Workspace files OpenClaw injects into model context.
</Card>
<Card title="System prompt" href="/concepts/system-prompt" icon="message-lines">
How `SOUL.md` is composed into the per-turn system prompt.
How `SOUL.md` is composed into OpenClaw and Codex runtime context.
</Card>
<Card title="SOUL.md template" href="/reference/templates/SOUL" icon="file-lines">
Starter template for a personality file.
+15 -13
View File
@@ -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`.
<Note>
`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.
</Note>
+2 -2
View File
@@ -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"
]
},
{
+3 -1
View File
@@ -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.
+24 -16
View File
@@ -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.
+7
View File
@@ -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
+6 -5
View File
@@ -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
+8 -6
View File
@@ -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
+2
View File
@@ -202,6 +202,8 @@ Common command routing:
| Attach the current chat | `/codex bind [--cwd <path>]` |
| Resume an existing Codex thread | `/codex resume <thread-id>` |
| 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 <name>`, `/codex plugins disable <name>` |
| Attach an existing Codex CLI session on a paired node | `/codex sessions --host <node> [filter]`, then `/codex resume <session-id> --host <node> --bind here` |
| Send Codex feedback only | `/codex diagnostics [note]` |
| Start an ACP/acpx task | ACP/acpx session commands, not `/codex` |
+37 -7
View File
@@ -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
+84 -23
View File
@@ -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:
<Steps>
<Step title="Choose auth">
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.
</Step>
<Step title="Sign in">
Set `XAI_API_KEY`, run the API-key wizard, or start the OAuth flow:
<Step title="New OpenClaw install">
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.
</Step>
<Step title="Existing OpenClaw install">
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.
</Step>
<Step title="API-key path">
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-...
```
</Step>
<Step title="Pick a model">
```json5
@@ -42,9 +86,9 @@ OpenClaw ships a bundled `xai` provider plugin for Grok models.
<Note>
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`.
</Note>
## 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.
<Tip>
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:
<Accordion title="Known limits">
- 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.
+88
View File
@@ -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.
+11 -1
View File
@@ -153,8 +153,18 @@ What you set:
Sets `agents.defaults.model` to `openai/gpt-5.5` when model is unset, `openai/*`, or `openai-codex/*`.
</Accordion>
<Accordion title="xAI (Grok) OAuth">
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`.
</Accordion>
<Accordion title="xAI (Grok) device code">
Remote-friendly browser sign-in with a short code instead of a localhost
callback. Use this from SSH, Docker, or VPS hosts.
</Accordion>
<Accordion title="xAI (Grok) API key">
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.
</Accordion>
<Accordion title="OpenCode">
Prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`) and lets you choose the Zen or Go catalog.
+33 -14
View File
@@ -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
<Steps>
<Step title="Provide an xAI API key">
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:
<Step title="Provide xAI credentials">
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
</Step>
<Step title="Enable and tune code_execution">
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"
}
```
+1 -1
View File
@@ -124,7 +124,7 @@ Current source-of-truth:
<AccordionGroup>
<Accordion title="Sessions and runs">
- `/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).
+56
View File
@@ -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<string, unknown>).plugins;
if (!plugins || typeof plugins !== "object") {
return Promise.resolve({});
}
const entries = (plugins as Record<string, unknown>).entries;
if (!entries || typeof entries !== "object") {
return Promise.resolve({});
}
const codexEntry = (entries as Record<string, unknown>).codex;
if (!codexEntry || typeof codexEntry !== "object") {
return Promise.resolve({});
}
const config = (codexEntry as Record<string, unknown>).config;
if (!config || typeof config !== "object") {
return Promise.resolve({});
}
const codexPlugins = (config as Record<string, unknown>).codexPlugins;
if (!codexPlugins || typeof codexPlugins !== "object") {
return Promise.resolve({});
}
const declared = (codexPlugins as Record<string, unknown>).plugins;
if (!declared || typeof declared !== "object") {
return Promise.resolve({
enabled: (codexPlugins as Record<string, unknown>).enabled === true,
});
}
return Promise.resolve({
enabled: (codexPlugins as Record<string, unknown>).enabled === true,
plugins: declared as Record<string, never>,
});
},
mutate: async (update) => {
await mutateConfigFile({
mutate: (draft) => {
const root = draft as Record<string, unknown>;
root.plugins = (root.plugins ?? {}) as Record<string, unknown>;
const pluginsBlock = root.plugins as Record<string, unknown>;
pluginsBlock.entries = (pluginsBlock.entries ?? {}) as Record<string, unknown>;
const entries = pluginsBlock.entries as Record<string, unknown>;
entries.codex = (entries.codex ?? {}) as Record<string, unknown>;
const codexEntry = entries.codex as Record<string, unknown>;
codexEntry.config = (codexEntry.config ?? {}) as Record<string, unknown>;
const config = codexEntry.config as Record<string, unknown>;
config.codexPlugins = (config.codexPlugins ?? {}) as Record<string, unknown>;
const codexPlugins = config.codexPlugins as Record<string, unknown>;
codexPlugins.plugins = (codexPlugins.plugins ?? {}) as Record<string, unknown>;
update(codexPlugins as CodexPluginsConfigBlock);
},
});
},
},
},
}),
);
+108 -11
View File
@@ -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<ContextEngine["compact"]>(() => 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<ContextEngine["compact"]>(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<ContextEngine["compact"]>(() => 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(): {
+23 -11
View File
@@ -1,7 +1,9 @@
import {
compactContextEngineWithSafetyTimeout,
embeddedAgentLog,
formatErrorMessage,
isActiveHarnessContextEngine,
resolveCompactionTimeoutMs,
resolveContextEngineOwnerPluginId,
runHarnessContextEngineMaintenance,
type CompactEmbeddedPiSessionParams,
@@ -79,17 +81,27 @@ async function compactOwningContextEngine(
});
let result: Awaited<ReturnType<typeof contextEngine.compact>>;
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,
@@ -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<ContextEngine["compact"]>(() => new Promise(() => {}));
const assemble = vi.fn(
async ({ messages, prompt }: Parameters<ContextEngine["assemble"]>[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");
@@ -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<void>((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<void>((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<void>((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", () => {
+206 -62
View File
@@ -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<string, number>([
@@ -215,7 +224,14 @@ type CodexBootstrapContext = Awaited<ReturnType<typeof resolveBootstrapContextFo
type CodexBootstrapFile = CodexBootstrapContext["bootstrapFiles"][number];
type CodexSystemPromptReport = NonNullable<EmbeddedRunAttemptResult["systemPromptReport"]>;
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<string, string>();
const injectedByBaseName = new Map<string, string>();
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<string, string>;
byBaseName: Map<string, string>;
} {
const byPath = new Map<string, string>();
const byBaseName = new Map<string, string>();
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<string, string>; byBaseName: Map<string, string> },
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:");
}
@@ -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.",
);
});
@@ -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<CodexTurnStartParams["collaboratio
export function buildTurnCollaborationMode(
params: EmbeddedRunAttemptParams,
options: { heartbeatCollaborationInstructions?: string } = {},
): CodexTurnCollaborationMode {
return {
mode: "default",
settings: {
model: params.modelId,
reasoning_effort: resolveReasoningEffort(params.thinkLevel, params.modelId),
developer_instructions: buildTurnScopedCollaborationInstructions(params),
developer_instructions: buildTurnScopedCollaborationInstructions(params, options),
},
};
}
function buildTurnScopedCollaborationInstructions(params: EmbeddedRunAttemptParams): string | null {
function buildTurnScopedCollaborationInstructions(
params: EmbeddedRunAttemptParams,
options: { heartbeatCollaborationInstructions?: string } = {},
): string | null {
if (params.trigger === "cron") {
return buildCronCollaborationInstructions();
}
if (params.trigger === "heartbeat") {
return buildHeartbeatCollaborationInstructions();
return joinPresentSections(
buildHeartbeatCollaborationInstructions(),
options.heartbeatCollaborationInstructions,
);
}
return null;
}
@@ -750,6 +760,10 @@ function buildHeartbeatCollaborationInstructions(): string {
].join("\n\n");
}
function joinPresentSections(...sections: Array<string | undefined>): 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(
@@ -320,6 +320,7 @@ export function buildHelp(): string {
"- /codex account",
"- /codex mcp",
"- /codex skills",
"- /codex plugins [list|enable|disable]",
].join("\n");
}
+15
View File
@@ -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" };
@@ -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<string, CodexPluginConfigEntry> = {},
options: { enabled?: boolean } = { enabled: true },
): CodexPluginsManagementIO & {
current: () => Record<string, CodexPluginConfigEntry>;
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 <name>");
expect(result.presentation).toBeUndefined();
const enableResult = await handleCodexPluginsSubcommand(fakeCtx, ["enable"], io);
expect(enableResult.text).toContain("Usage: /codex plugins enable <name>");
expect(enableResult.presentation).toBeUndefined();
const extraResult = await handleCodexPluginsSubcommand(
fakeCtx,
["enable", "google-calendar", "extra"],
io,
);
expect(extraResult.text).toContain("Usage: /codex plugins enable <name>");
});
});
@@ -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<string, CodexPluginConfigEntry>;
}>;
mutate: (update: (block: CodexPluginsConfigBlock) => void) => Promise<void>;
};
export type CodexPluginConfigEntry = {
enabled?: boolean;
marketplaceName?: string;
pluginName?: string;
allow_destructive_actions?: boolean;
};
export type CodexPluginsConfigBlock = {
enabled?: boolean;
plugins?: Record<string, CodexPluginConfigEntry>;
};
// 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<PluginCommandResult> {
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} <name>` };
}
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 <name> enable a configured sub-plugin",
"- /codex plugins disable <name> disable a configured sub-plugin",
].join("\n");
}
export function formatPluginList(
plugins: Record<string, CodexPluginConfigEntry>,
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");
}
+66
View File
@@ -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<CodexCommandDeps> = {}): Partial<CodexCom
};
}
function inMemoryCodexPluginsIO(
initial: Record<string, CodexPluginConfigEntry> = {},
options: { enabled?: boolean } = { enabled: true },
): CodexPluginsManagementIO & {
current: () => Record<string, CodexPluginConfigEntry>;
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 }> = [];
+13 -3
View File
@@ -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 | undefined>): string {
return sections.filter((section): section is string => Boolean(section?.trim())).join("\n\n");
}
export function createCodexDynamicToolSpecsForPromptSnapshot(params: {
tools: AnyAgentTool[];
pluginConfig?: Pick<CodexPluginConfig, "codexDynamicToolsLoading" | "codexDynamicToolsExclude">;
@@ -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() };
}
+36 -1
View File
@@ -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<Record<string, unknown>> }> }
| 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({
@@ -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: {
@@ -126,6 +126,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
maxActionsPerRow: 5,
maxRows: 5,
maxLabelLength: 80,
supportsDisabled: true,
},
selects: {
maxOptions: 25,
@@ -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,
},
],
},
],
});
});
});
@@ -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;
}),
});
@@ -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,
+11 -6
View File
@@ -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}`,
+404
View File
@@ -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" <<SHIMEOF
#!/usr/bin/env bash
set -euo pipefail
printf "%s %s\n" "\$(date -u +%H:%M:%S)" "\$*" >>"$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"
+38 -30
View File
@@ -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) {
+85
View File
@@ -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<string, SessionEntry> = { [sessionKey]: sessionEntry };
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
const compactCalls: Array<Parameters<ContextEngine["compact"]>[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");
});
});
+26 -10
View File
@@ -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<ReturnType<typeof params.contextEngine.compact>>;
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(
@@ -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<CompactFn>[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<CompactFn>(() => new Promise<CompactResult>(() => {}));
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<CompactFn>(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<CompactFn>((params) => {
compactAbortSignal = params.abortSignal;
return new Promise<CompactResult>(() => {});
});
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<CompactFn>((params) => {
compactAbortSignal = params.abortSignal;
return new Promise<CompactResult>(() => {});
});
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<CompactFn>(() => new Promise<CompactResult>(() => {}));
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<CompactFn>(async () => {
throw error;
});
await expect(compactContextEngineWithSafetyTimeout({ compact }, baseParams, 30)).rejects.toBe(
error,
);
});
});
@@ -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<unknown>,
_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<string, unknown>) => Promise<unknown> },
params: Record<string, unknown>,
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<typeof import("./compaction-successor-transcript.js")>(
@@ -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);
+39 -11
View File
@@ -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<ReturnType<typeof contextEngine.compact>>;
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 =
@@ -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<AbortSignal | undefined>): {
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<T>(
compact: () => Promise<T>,
compact: (abortSignal?: AbortSignal) => Promise<T>,
timeoutMs: number = EMBEDDED_COMPACTION_TIMEOUT_MS,
opts?: {
abortSignal?: AbortSignal;
@@ -51,6 +90,7 @@ export async function compactWithSafetyTimeout<T>(
let externalAbortListener: (() => void) | undefined;
let externalAbortPromise: Promise<never> | undefined;
const abortSignal = opts?.abortSignal;
const composedAbortSignal = composeAbortSignals(timeoutSignal, abortSignal);
if (timeoutSignal) {
timeoutListener = () => {
@@ -74,11 +114,13 @@ export async function compactWithSafetyTimeout<T>(
}
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<T>(
"Compaction",
);
}
/** Parameters for a single {@link ContextEngine.compact} invocation. */
export type ContextEngineCompactParams = Parameters<ContextEngine["compact"]>[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<ContextEngine, "compact">,
params: ContextEngineCompactParams,
timeoutMs: number = EMBEDDED_COMPACTION_TIMEOUT_MS,
abortSignal?: AbortSignal,
): Promise<CompactResult> {
return compactWithSafetyTimeout(
(compactAbortSignal) =>
contextEngine.compact(
compactAbortSignal ? { ...params, abortSignal: compactAbortSignal } : params,
),
timeoutMs,
abortSignal ? { abortSignal } : undefined,
);
}
@@ -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();
+45 -21
View File
@@ -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({
@@ -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.<id>.timeoutSeconds for cloud providers (#77744, #78361)", () => {
// models.providers.<id>.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);
});
@@ -154,11 +154,18 @@ export function resolveLlmIdleTimeoutMs(params?: {
Number.isFinite(modelRequestTimeoutMs) &&
modelRequestTimeoutMs > 0
) {
// `modelRequestTimeoutMs` is wired from `models.providers.<id>.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) {
+1
View File
@@ -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"])),
});
+21
View File
@@ -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-",
+2 -2
View File
@@ -22,7 +22,7 @@ export function registerAcpCli(program: Command) {
.option("--session-label <label>", "Default session label to resolve")
.option("--require-existing", "Fail if the session key/label does not exist", false)
.option("--reset-session", "Reset the session key before first use", false)
.option("--no-prefix-cwd", "Do not prefix prompts with the working directory", false)
.option("--no-prefix-cwd", "Do not prefix prompts with the working directory")
.option("--provenance <mode>", "ACP provenance mode: off, meta, or meta+receipt")
.option("-v, --verbose", "Verbose logging to stderr", false)
.addHelpText(
@@ -44,7 +44,7 @@ export function registerAcpCli(program: Command) {
defaultSessionLabel: opts.sessionLabel as string | undefined,
requireExistingSession: Boolean(opts.requireExisting),
resetSession: Boolean(opts.resetSession),
prefixCwd: !opts.noPrefixCwd,
prefixCwd: opts.prefixCwd !== false,
provenanceMode,
verbose: Boolean(opts.verbose),
});
+416
View File
@@ -2885,6 +2885,422 @@ describe("update-cli", () => {
);
});
it("warns when a package update targets a managed service root outside the shell root", async () => {
const shellRoot = createCaseDir("openclaw-shell-root");
const serviceRoot = await createTrackedTempDir("openclaw-service-root-");
const serviceNode = path.join(path.dirname(serviceRoot), "bin", "node");
await fs.mkdir(path.join(serviceRoot, "dist"), { recursive: true });
await fs.writeFile(
path.join(serviceRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.5.18" }),
"utf-8",
);
mockPackageInstallStatus(shellRoot);
serviceReadCommand.mockResolvedValue({
programArguments: [serviceNode, path.join(serviceRoot, "dist", "index.js"), "gateway"],
});
await updateCommand({ dryRun: true });
const logs = vi
.mocked(defaultRuntime.log)
.mock.calls.map((call) => String(call[0]))
.join("\n");
expect(logs).toContain(`Targeting managed gateway service package root: ${serviceRoot}`);
expect(logs).toContain(
`Shell OpenClaw root differs from the managed gateway service root: ${shellRoot}`,
);
expect(logs).toContain("make sure `openclaw` on PATH resolves to the managed service root");
expect(logs).toContain(`Managed gateway service Node: ${serviceNode}`);
});
it("checks the managed service Node runtime before updating a redirected package root", async () => {
const shellRoot = createCaseDir("openclaw-shell-root");
const serviceRoot = await createTrackedTempDir("openclaw-service-root-");
const serviceNode = path.join(path.dirname(serviceRoot), "bin", "node");
await fs.mkdir(path.join(serviceRoot, "dist"), { recursive: true });
await fs.mkdir(path.dirname(serviceNode), { recursive: true });
await fs.writeFile(serviceNode, "", "utf-8");
await fs.writeFile(
path.join(serviceRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.5.18" }),
"utf-8",
);
mockPackageInstallStatus(shellRoot);
serviceReadCommand.mockResolvedValue({
programArguments: [serviceNode, path.join(serviceRoot, "dist", "index.js"), "gateway"],
});
vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({
target: "latest",
version: "2026.5.20",
nodeEngine: ">=22.19.0",
});
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") {
return {
stdout: "v22.18.0\n",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
}
return {
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
});
nodeVersionSatisfiesEngine.mockReturnValue(false);
await updateCommand({ yes: true });
expect(nodeVersionSatisfiesEngine).toHaveBeenCalledWith("22.18.0", ">=22.19.0");
expect(packageInstallCommandCall()).toBeUndefined();
expect(serviceStop).not.toHaveBeenCalled();
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
const errors = vi.mocked(defaultRuntime.error).mock.calls.map((call) => String(call[0]));
expect(errors.join("\n")).toContain(`Node 22.18.0 at ${serviceNode} is too old`);
expect(errors.join("\n")).toContain(
"Upgrade the Node runtime that owns the managed Gateway service",
);
});
it("runs managed service package follow-up commands with the service Node", async () => {
const shellRoot = createCaseDir("openclaw-shell-root");
const servicePrefix = await createTrackedTempDir("openclaw-service-prefix-");
const nodeModules = path.join(servicePrefix, "lib", "node_modules");
const serviceRoot = path.join(nodeModules, "openclaw");
const serviceNode = path.join(servicePrefix, "bin", "node");
const serviceNpm = path.join(servicePrefix, "bin", "npm");
const entrypoint = path.join(serviceRoot, "dist", "index.js");
await fs.mkdir(path.dirname(entrypoint), { recursive: true });
await fs.mkdir(path.dirname(serviceNode), { recursive: true });
await fs.writeFile(serviceNode, "", "utf-8");
await fs.writeFile(serviceNpm, "", "utf-8");
const serviceNpmReal = await fs.realpath(serviceNpm);
await fs.writeFile(
path.join(serviceRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.5.18" }),
"utf-8",
);
await fs.writeFile(entrypoint, "", "utf-8");
await writePackageDistInventory(serviceRoot);
mockPackageInstallStatus(shellRoot);
serviceReadCommand.mockResolvedValue({
programArguments: [serviceNode, entrypoint, "gateway"],
});
serviceLoaded.mockResolvedValue(true);
pathExists.mockImplementation(async (candidate: string) => {
try {
await fs.access(candidate);
return true;
} catch {
return false;
}
});
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") {
return {
stdout: "v22.22.0\n",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
}
if (
Array.isArray(argv) &&
(argv[0] === serviceNpm || argv[0] === serviceNpmReal) &&
argv[1] === "root" &&
argv[2] === "-g"
) {
return {
stdout: `${nodeModules}\n`,
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
}
if (
Array.isArray(argv) &&
(argv[0] === serviceNpm || argv[0] === serviceNpmReal) &&
argv[1] === "i"
) {
const stagePrefix = argv.includes("--prefix")
? argv[argv.indexOf("--prefix") + 1]
: undefined;
const stageRoot = stagePrefix
? path.join(stagePrefix, "lib", "node_modules", "openclaw")
: serviceRoot;
const stageEntryPoint = path.join(stageRoot, "dist", "index.js");
await fs.mkdir(path.dirname(stageEntryPoint), { recursive: true });
await fs.writeFile(
path.join(stageRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.5.20" }),
"utf-8",
);
await fs.writeFile(stageEntryPoint, "export {};\n", "utf-8");
await writePackageDistInventory(stageRoot);
}
return {
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
});
await updateCommand({ yes: true });
expect(doctorCommandCall()?.[0][0]).toBe(serviceNode);
expect(spawnCall()?.[0]).toBe(serviceNode);
const serviceInstallCall = commandCalls().find(
([argv]) => argv[2] === "gateway" && argv[3] === "install",
);
expect(serviceInstallCall?.[0][0]).toBe(serviceNode);
});
it("uses the managed service Node when package roots match but node binaries differ", async () => {
const root = createCaseDir("openclaw-same-root");
// Service is baked with a different node than the current process.execPath.
const serviceNode = "/opt/other-node/bin/node";
const entrypoint = path.join(root, "dist", "index.js");
mockPackageInstallStatus(root);
serviceReadCommand.mockResolvedValue({
programArguments: [serviceNode, entrypoint, "gateway"],
});
await updateCommand({ dryRun: true });
const logs = vi
.mocked(defaultRuntime.log)
.mock.calls.map((call) => String(call[0]))
.join("\n");
// Should NOT log root redirect messages since the package root is the same.
expect(logs).not.toContain("Targeting managed gateway service package root");
// Should warn about the node binary mismatch.
expect(logs).toContain("differs from the managed gateway service Node");
expect(logs).toContain(serviceNode);
expect(logs).toContain(
"Using the managed service Node for this update so the gateway can start after the upgrade",
);
});
it("uses the managed service Node for follow-up commands when roots match but nodes differ", async () => {
const servicePrefix = await createTrackedTempDir("openclaw-service-prefix-");
const nodeModules = path.join(servicePrefix, "lib", "node_modules");
const root = path.join(nodeModules, "openclaw");
const serviceNode = path.join(servicePrefix, "bin", "node");
const serviceNpm = path.join(servicePrefix, "bin", "npm");
const entrypoint = path.join(root, "dist", "index.js");
await fs.mkdir(path.dirname(entrypoint), { recursive: true });
await fs.mkdir(path.dirname(serviceNode), { recursive: true });
await fs.writeFile(serviceNode, "", "utf-8");
await fs.writeFile(serviceNpm, "", "utf-8");
const serviceNpmReal = await fs.realpath(serviceNpm);
await fs.writeFile(
path.join(root, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.5.18" }),
"utf-8",
);
await fs.writeFile(entrypoint, "", "utf-8");
await writePackageDistInventory(root);
// Same package root for both shell and service.
mockPackageInstallStatus(root);
serviceReadCommand.mockResolvedValue({
programArguments: [serviceNode, entrypoint, "gateway"],
});
serviceLoaded.mockResolvedValue(true);
pathExists.mockImplementation(async (candidate: string) => {
try {
await fs.access(candidate);
return true;
} catch {
return false;
}
});
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") {
return {
stdout: "v24.14.0\n",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
}
if (
Array.isArray(argv) &&
(argv[0] === serviceNpm || argv[0] === serviceNpmReal) &&
argv[1] === "root" &&
argv[2] === "-g"
) {
return {
stdout: `${nodeModules}\n`,
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
}
if (
Array.isArray(argv) &&
(argv[0] === serviceNpm || argv[0] === serviceNpmReal) &&
argv[1] === "i"
) {
const stagePrefix = argv.includes("--prefix")
? argv[argv.indexOf("--prefix") + 1]
: undefined;
const stageRoot = stagePrefix
? path.join(stagePrefix, "lib", "node_modules", "openclaw")
: root;
const stageEntryPoint = path.join(stageRoot, "dist", "index.js");
await fs.mkdir(path.dirname(stageEntryPoint), { recursive: true });
await fs.writeFile(
path.join(stageRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.5.20" }),
"utf-8",
);
await fs.writeFile(stageEntryPoint, "export {};\n", "utf-8");
await writePackageDistInventory(stageRoot);
}
return {
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
});
await updateCommand({ yes: true });
// Follow-up commands should use the service Node, not process.execPath.
expect(doctorCommandCall()?.[0][0]).toBe(serviceNode);
expect(spawnCall()?.[0]).toBe(serviceNode);
const serviceInstallCall = commandCalls().find(
([argv]) => argv[2] === "gateway" && argv[3] === "install",
);
expect(serviceInstallCall?.[0][0]).toBe(serviceNode);
});
it("pins package install to the service root when nodes differ and no owning npm exists at the prefix", async () => {
const servicePrefix = await createTrackedTempDir("openclaw-no-npm-prefix-");
const nodeModules = path.join(servicePrefix, "lib", "node_modules");
const root = path.join(nodeModules, "openclaw");
const serviceNode = path.join(servicePrefix, "bin", "node");
const entrypoint = path.join(root, "dist", "index.js");
// Create the node binary but intentionally do NOT create <prefix>/bin/npm
// so resolvePreferredNpmCommand returns null and the PATH npm is used.
await fs.mkdir(path.dirname(entrypoint), { recursive: true });
await fs.mkdir(path.dirname(serviceNode), { recursive: true });
await fs.writeFile(serviceNode, "", "utf-8");
// No npm binary at servicePrefix/bin/npm!
await fs.writeFile(
path.join(root, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.5.18" }),
"utf-8",
);
await fs.writeFile(entrypoint, "", "utf-8");
await writePackageDistInventory(root);
mockPackageInstallStatus(root);
serviceReadCommand.mockResolvedValue({
programArguments: [serviceNode, entrypoint, "gateway"],
});
serviceLoaded.mockResolvedValue(true);
pathExists.mockImplementation(async (candidate: string) => {
try {
await fs.access(candidate);
return true;
} catch {
return false;
}
});
// The PATH npm returns a DIFFERENT global root (simulates Node-B's npm).
const nodeBGlobalRoot = path.join(
await createTrackedTempDir("node-b-global-"),
"lib",
"node_modules",
);
await fs.mkdir(nodeBGlobalRoot, { recursive: true });
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") {
return {
stdout: "v24.14.0\n",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
}
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
// PATH npm returns Node-B's root, NOT the service root.
return {
stdout: `${nodeBGlobalRoot}\n`,
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
}
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "i") {
// Install step: create the expected package structure at the target.
const prefixIdx = argv.indexOf("--prefix");
const stagePrefix = prefixIdx >= 0 ? argv[prefixIdx + 1] : undefined;
const stageRoot = stagePrefix
? path.join(stagePrefix, "lib", "node_modules", "openclaw")
: root;
const stageEntryPoint = path.join(stageRoot, "dist", "index.js");
await fs.mkdir(path.dirname(stageEntryPoint), { recursive: true });
await fs.writeFile(
path.join(stageRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.5.20" }),
"utf-8",
);
await fs.writeFile(stageEntryPoint, "export {};\n", "utf-8");
await writePackageDistInventory(stageRoot);
}
return {
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
});
await updateCommand({ yes: true });
// The install command must use --prefix pointing to a location within
// the service root's prefix tree, NOT Node-B's global root.
const installCall = packageInstallCommandCall();
expect(installCall).toBeDefined();
const installArgv = installCall![0];
const prefixIdx = installArgv.indexOf("--prefix");
expect(prefixIdx).toBeGreaterThan(-1);
// Staging prefix should be under the service prefix, not Node-B's.
expect(installArgv[prefixIdx + 1]).toContain(servicePrefix);
expect(installArgv[prefixIdx + 1]).not.toContain(nodeBGlobalRoot);
// Follow-up commands use the service node.
expect(doctorCommandCall()?.[0][0]).toBe(serviceNode);
});
it("repairs legacy config before persisting a requested update channel", async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
+61
View File
@@ -34,6 +34,11 @@ describe("restart-helper", () => {
throw error;
}
});
await fs.rmdir(path.dirname(scriptPath)).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
});
}
async function makeTempDir(prefix: string) {
@@ -135,9 +140,63 @@ exit 0
expect(content).toContain("systemctl --user restart 'openclaw-gateway.service'");
// Script should self-cleanup
expect(content).toContain('rm -f "$0"');
expect(content).toContain('rmdir "$script_dir" 2>/dev/null || true');
await cleanupScript(scriptPath);
});
it("creates restart scripts in a private temp directory with exclusive creation", async () => {
Object.defineProperty(process, "platform", { value: "linux" });
const timestamp = 1_727_201_234_567;
const oldCandidatePath = path.join(os.tmpdir(), `openclaw-restart-${timestamp}.sh`);
const victimDir = await makeTempDir("openclaw-restart-helper-victim-");
const victimPath = path.join(victimDir, "restart.sh");
await fs.rm(oldCandidatePath, { force: true });
await fs.writeFile(victimPath, "preexisting script\n", "utf-8");
let candidateIsSymlink = false;
try {
await fs.symlink(victimPath, oldCandidatePath);
candidateIsSymlink = true;
} catch {
await fs.writeFile(oldCandidatePath, "preexisting script\n", { flag: "wx" });
}
const dateSpy = vi.spyOn(Date, "now").mockReturnValue(timestamp);
const writeFileSpy = vi.spyOn(fs, "writeFile");
try {
const { scriptPath } = await prepareAndReadScript({
OPENCLAW_PROFILE: "default",
});
const scriptDir = path.dirname(scriptPath);
const relativeScriptDir = path.relative(os.tmpdir(), scriptDir);
expect(scriptPath).not.toBe(oldCandidatePath);
expect(scriptDir).not.toBe(os.tmpdir());
expect(relativeScriptDir).not.toBe("");
expect(relativeScriptDir.startsWith("..")).toBe(false);
expect(path.isAbsolute(relativeScriptDir)).toBe(false);
expect(path.basename(scriptDir)).toMatch(/^openclaw-restart-/);
expect(writeFileSpy).toHaveBeenLastCalledWith(
scriptPath,
expect.any(String),
expect.objectContaining({ flag: "wx", mode: 0o755 }),
);
await expect(fs.readFile(victimPath, "utf-8")).resolves.toBe("preexisting script\n");
if (!candidateIsSymlink) {
await expect(fs.readFile(oldCandidatePath, "utf-8")).resolves.toBe(
"preexisting script\n",
);
}
await cleanupScript(scriptPath);
} finally {
dateSpy.mockRestore();
writeFileSpy.mockRestore();
await fs.rm(oldCandidatePath, { force: true });
await fs.rm(victimDir, { recursive: true, force: true });
}
});
it("uses OPENCLAW_SYSTEMD_UNIT override for systemd scripts", async () => {
Object.defineProperty(process, "platform", { value: "linux" });
const { scriptPath, content } = await prepareAndReadScript({
@@ -203,6 +262,7 @@ exit 1
expect(content).toContain("launchctl bootstrap 'gui/501'");
expect(content).toContain("Bootstrap loads RunAtLoad agents");
expect(content).toContain('rm -f "$0"');
expect(content).toContain('rmdir "$script_dir" 2>/dev/null || true');
await cleanupScript(scriptPath);
});
@@ -379,6 +439,7 @@ exit 0
expect(content).toContain("openclaw restart launched startup fallback");
expectWindowsRestartWaitOrdering(content);
expect(content).toContain('del "%~f0" >nul 2>&1');
expect(content).toContain('rmdir "%OPENCLAW_RESTART_SCRIPT_DIR%" >nul 2>&1');
await cleanupScript(scriptPath);
});
+14 -3
View File
@@ -68,7 +68,6 @@ export async function prepareRestartScript(
env: NodeJS.ProcessEnv = process.env,
gatewayPort: number = DEFAULT_GATEWAY_PORT,
): Promise<string | null> {
const tmpDir = os.tmpdir();
const timestamp = Date.now();
const platform = process.platform;
@@ -110,8 +109,10 @@ else
fi
fi
# Self-cleanup
script_dir=$(dirname "$0")
exec 3>&-
rm -f "$0"
rmdir "$script_dir" 2>/dev/null || true
exit "$status"
`;
} else if (platform === "darwin") {
@@ -157,7 +158,9 @@ else
printf '[%s] openclaw restart failed source=update status=%s\\n' "$(date -u +%FT%TZ)" "$status" >&2
fi
# Self-cleanup (log is retained under the OpenClaw state logs directory).
script_dir=$(dirname "$0")
rm -f "$0"
rmdir "$script_dir" 2>/dev/null || true
exit "$status"
`;
} else if (platform === "win32") {
@@ -177,9 +180,11 @@ REM Keep this as a cmd wrapper so Group Policy script execution policies
REM cannot block the update restart handoff before schtasks.exe runs.
setlocal
set "OPENCLAW_RESTART_SCRIPT=%~f0"
set "OPENCLAW_RESTART_SCRIPT_DIR=%~dp0."
powershell -NoProfile -ExecutionPolicy Bypass -Command "$p=$env:OPENCLAW_RESTART_SCRIPT; $s=Get-Content -Raw -LiteralPath $p; $m='# POWERSHELL'; $i=$s.IndexOf($m); if ($i -lt 0) { exit 1 }; Invoke-Expression $s.Substring($i)"
set "status=%ERRORLEVEL%"
del "%~f0" >nul 2>&1
rmdir "%OPENCLAW_RESTART_SCRIPT_DIR%" >nul 2>&1
exit /b %status%
# POWERSHELL
# Wait briefly to ensure file locks are released after update.
@@ -370,8 +375,14 @@ exit $status
return null;
}
const scriptPath = path.join(tmpDir, filename);
await fs.writeFile(scriptPath, scriptContent, { mode: 0o755 });
const scriptDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-restart-"));
const scriptPath = path.join(scriptDir, filename);
try {
await fs.writeFile(scriptPath, scriptContent, { mode: 0o755, flag: "wx" });
} catch (error) {
await fs.rm(scriptDir, { recursive: true, force: true }).catch(() => {});
throw error;
}
return scriptPath;
} catch {
// If we can't write the script, we'll fall back to the standard restart method
+163 -19
View File
@@ -33,6 +33,7 @@ import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint
import { disableCurrentOpenClawUpdateLaunchdJob } from "../../daemon/launchd.js";
import { resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js";
import { summarizeGatewayServiceLayout } from "../../daemon/service-layout.js";
import type { GatewayServiceCommandConfig } from "../../daemon/service-types.js";
import {
readGatewayServiceState,
resolveGatewayService,
@@ -749,6 +750,12 @@ type PrePackageServiceStop = {
serviceEnv?: NodeJS.ProcessEnv;
};
type ManagedServiceRootRedirect = {
root: string;
previousRoot: string;
nodeRunner?: string;
};
function formatGatewayAncestryBlockMessage(pid: number): string {
return `openclaw update detected it is running inside the gateway process tree.
Gateway PID ${pid} is an ancestor of this process, so this updater cannot safely stop or restart the gateway that owns it.
@@ -929,6 +936,7 @@ function tryResolveInvocationCwd(): string | undefined {
async function resolvePackageRuntimePreflightError(params: {
tag: string;
timeoutMs?: number;
nodeRunner?: string;
}): Promise<string | null> {
if (!canResolveRegistryVersionForPackageTarget(params.tag)) {
return null;
@@ -944,20 +952,45 @@ async function resolvePackageRuntimePreflightError(params: {
if (status.error) {
return null;
}
const satisfies = nodeVersionSatisfiesEngine(process.versions.node ?? null, status.nodeEngine);
const runtime = await resolvePackageRuntimeForPreflight({
nodeRunner: params.nodeRunner,
timeoutMs: params.timeoutMs,
});
const satisfies = nodeVersionSatisfiesEngine(runtime.version, status.nodeEngine);
if (satisfies !== false) {
return null;
}
const targetLabel = status.version ?? target;
const runtimeLabel = runtime.nodeRunner
? `Node ${runtime.version ?? "unknown"} at ${runtime.nodeRunner}`
: `Node ${runtime.version ?? "unknown"}`;
return [
`Node ${process.versions.node ?? "unknown"} is too old for openclaw@${targetLabel}.`,
`${runtimeLabel} is too old for openclaw@${targetLabel}.`,
`The requested package requires ${status.nodeEngine}.`,
"Upgrade Node to 22.19+ or Node 24, then rerun `openclaw update`.",
runtime.nodeRunner
? "Upgrade the Node runtime that owns the managed Gateway service, then rerun `openclaw update`."
: "Upgrade Node to 22.19+ or Node 24, then rerun `openclaw update`.",
"Bare `npm i -g openclaw` can silently install an older compatible release.",
"After upgrading Node, use `npm i -g openclaw@latest`.",
].join("\n");
}
async function resolvePackageRuntimeForPreflight(params: {
nodeRunner?: string;
timeoutMs?: number;
}): Promise<{ version: string | null; nodeRunner?: string }> {
const nodeRunner = normalizeOptionalString(params.nodeRunner);
if (!nodeRunner) {
return { version: process.versions.node ?? null };
}
const res = await runCommandWithTimeout([nodeRunner, "--version"], {
timeoutMs: Math.min(params.timeoutMs ?? 10_000, 10_000),
}).catch(() => null);
const rawVersion = res?.code === 0 ? res.stdout.trim() : "";
const version = rawVersion.replace(/^v/u, "") || null;
return { version, nodeRunner };
}
function resolveServiceRefreshEnv(
env: NodeJS.ProcessEnv,
invocationCwd?: string,
@@ -1094,6 +1127,7 @@ async function refreshGatewayServiceEnv(params: {
jsonMode: boolean;
invocationCwd?: string;
env?: NodeJS.ProcessEnv;
nodeRunner?: string;
}): Promise<void> {
const args = ["gateway", "install", "--force"];
if (params.jsonMode) {
@@ -1102,11 +1136,14 @@ async function refreshGatewayServiceEnv(params: {
const entrypoint = await resolveGatewayInstallEntrypoint(params.result.root);
if (entrypoint) {
const res = await runCommandWithTimeout([resolveNodeRunner(), entrypoint, ...args], {
cwd: params.result.root,
env: resolveUpdatedInstallCommandEnv(params.env ?? process.env, params.invocationCwd),
timeoutMs: SERVICE_REFRESH_TIMEOUT_MS,
});
const res = await runCommandWithTimeout(
[params.nodeRunner ?? resolveNodeRunner(), entrypoint, ...args],
{
cwd: params.result.root,
env: resolveUpdatedInstallCommandEnv(params.env ?? process.env, params.invocationCwd),
timeoutMs: SERVICE_REFRESH_TIMEOUT_MS,
},
);
if (res.code === 0) {
return;
}
@@ -1129,6 +1166,7 @@ async function runUpdatedInstallGatewayRestart(params: {
jsonMode: boolean;
invocationCwd?: string;
env?: NodeJS.ProcessEnv;
nodeRunner?: string;
}): Promise<boolean> {
const entrypoint = await resolveGatewayInstallEntrypoint(params.result.root);
if (!entrypoint) {
@@ -1141,11 +1179,14 @@ async function runUpdatedInstallGatewayRestart(params: {
if (params.jsonMode) {
args.push("--json");
}
const res = await runCommandWithTimeout([resolveNodeRunner(), entrypoint, ...args], {
cwd: params.result.root,
env: resolveUpdatedInstallCommandEnv(params.env ?? process.env, params.invocationCwd),
timeoutMs: SERVICE_REFRESH_TIMEOUT_MS,
});
const res = await runCommandWithTimeout(
[params.nodeRunner ?? resolveNodeRunner(), entrypoint, ...args],
{
cwd: params.result.root,
env: resolveUpdatedInstallCommandEnv(params.env ?? process.env, params.invocationCwd),
timeoutMs: SERVICE_REFRESH_TIMEOUT_MS,
},
);
if (res.code === 0) {
return true;
}
@@ -1217,9 +1258,54 @@ async function tryRealpathOrResolve(value: string): Promise<string> {
}
}
function isNodeExecutable(value: string | undefined): boolean {
const base = normalizeOptionalString(value ? path.basename(value) : undefined)?.toLowerCase();
return base === "node" || base === "node.exe";
}
function resolveManagedServiceNodeRunner(
command: GatewayServiceCommandConfig | null,
): string | undefined {
const args = command?.programArguments;
if (!args?.length) {
return undefined;
}
const gatewayIndex = args.indexOf("gateway");
if (gatewayIndex <= 1) {
return undefined;
}
const runner = args[gatewayIndex - 2];
return isNodeExecutable(runner) ? runner : undefined;
}
/**
* Resolve the node binary baked into the managed gateway service unit,
* independent of any package root redirect. This detects when the user's
* current PATH-resolved node differs from the service's baked node even
* when the package root is the same.
*/
async function resolveManagedServiceNodeRunnerOverride(): Promise<string | undefined> {
const command = await resolveGatewayService()
.readCommand(process.env)
.catch(() => null);
const serviceNode = resolveManagedServiceNodeRunner(command);
if (!serviceNode) {
return undefined;
}
const currentNode = resolveNodeRunner();
const [serviceNodeReal, currentNodeReal] = await Promise.all([
tryRealpathOrResolve(serviceNode),
tryRealpathOrResolve(currentNode),
]);
if (serviceNodeReal === currentNodeReal) {
return undefined;
}
return serviceNode;
}
async function resolveManagedServicePackageUpdateRoot(params: {
root: string;
}): Promise<{ root: string; previousRoot: string } | null> {
}): Promise<ManagedServiceRootRedirect | null> {
const command = await resolveGatewayService()
.readCommand(process.env)
.catch(() => null);
@@ -1235,7 +1321,12 @@ async function resolveManagedServicePackageUpdateRoot(params: {
if (currentRootReal === serviceRootReal) {
return null;
}
return { root: serviceRoot, previousRoot: params.root };
const nodeRunner = resolveManagedServiceNodeRunner(command);
return {
root: serviceRoot,
previousRoot: params.root,
...(nodeRunner ? { nodeRunner } : {}),
};
}
async function runPackageInstallUpdate(params: {
@@ -1249,6 +1340,7 @@ async function runPackageInstallUpdate(params: {
managedServiceEnv?: NodeJS.ProcessEnv;
invocationCwd?: string;
honorPackageRoot?: boolean;
nodeRunner?: string;
}): Promise<UpdateRunResult> {
const manager = await resolveGlobalManager({
root: params.root,
@@ -1313,7 +1405,13 @@ async function runPackageInstallUpdate(params: {
await createUpdateConfigSnapshot();
return await runUpdateStep({
name: `${CLI_NAME} doctor`,
argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive", "--fix"],
argv: [
params.nodeRunner ?? resolveNodeRunner(),
entryPath,
"doctor",
"--non-interactive",
"--fix",
],
cwd: verifiedPackageRoot,
env: {
...resolvePostInstallDoctorEnv({
@@ -1785,6 +1883,7 @@ async function maybeRestartService(params: {
gatewayPort: number;
restartScriptPath?: string | null;
invocationCwd?: string;
nodeRunner?: string;
}): Promise<boolean> {
const verifyRestartedGateway = async (expectedGatewayVersion: string | undefined) => {
const restartAfterStaleCleanup = async () => {
@@ -1794,6 +1893,7 @@ async function maybeRestartService(params: {
jsonMode: Boolean(params.opts.json),
invocationCwd: params.invocationCwd,
env: params.serviceEnv,
nodeRunner: params.nodeRunner,
});
return;
}
@@ -1907,6 +2007,7 @@ async function maybeRestartService(params: {
jsonMode: Boolean(params.opts.json),
invocationCwd: params.invocationCwd,
env: params.serviceEnv,
nodeRunner: params.nodeRunner,
});
} catch (err) {
// Always log the refresh failure so callers can detect it (issue #56772).
@@ -1955,6 +2056,7 @@ async function maybeRestartService(params: {
jsonMode: Boolean(params.opts.json),
invocationCwd: params.invocationCwd,
env: params.serviceEnv,
nodeRunner: params.nodeRunner,
});
} else if (
!refreshedGatewayAlreadyHealthy &&
@@ -2548,6 +2650,7 @@ async function continuePostCoreUpdateInFreshProcess(params: {
pluginInstallRecords: Record<string, PluginInstallRecord>;
preUpdateConfig?: PreUpdateConfigRestoreInput;
updateStartedAtMs: number;
nodeRunner?: string;
}): Promise<{ resumed: boolean; pluginUpdate?: PostCorePluginUpdateResult }> {
const entryPath = await resolveGatewayInstallEntrypoint(params.root);
if (!entryPath) {
@@ -2576,7 +2679,7 @@ async function continuePostCoreUpdateInFreshProcess(params: {
await writePostCorePluginInstallRecordsFile(installRecordsPath, params.pluginInstallRecords);
await writePostCoreSourceConfigFile(sourceConfigPath, params.preUpdateConfig);
const childStdio = resolvePostCoreUpdateChildStdio();
const child = spawn(resolveNodeRunner(), argv, {
const child = spawn(params.nodeRunner ?? resolveNodeRunner(), argv, {
stdio: childStdio,
env: {
...stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)),
@@ -2884,18 +2987,54 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
let fallbackToLatest = false;
let packageInstallSpec: string | null = null;
let packageAlreadyCurrent = false;
let managedServiceRootRedirect: { root: string; previousRoot: string } | null = null;
let managedServiceRootRedirect: ManagedServiceRootRedirect | null = null;
// Resolved independently of the root redirect so it covers the common case
// where the package root is the same but the user's PATH-resolved node
// differs from the node baked into the managed gateway service unit.
let managedServiceNodeRunner: string | undefined;
if (updateInstallKind === "package") {
managedServiceRootRedirect = await resolveManagedServicePackageUpdateRoot({ root });
if (managedServiceRootRedirect) {
root = managedServiceRootRedirect.root;
managedServiceNodeRunner = managedServiceRootRedirect.nodeRunner;
if (!opts.json) {
defaultRuntime.log(
theme.muted(
`Targeting managed gateway service package root: ${managedServiceRootRedirect.root}`,
),
);
defaultRuntime.log(
theme.warn(
`Shell OpenClaw root differs from the managed gateway service root: ${managedServiceRootRedirect.previousRoot}`,
),
);
defaultRuntime.log(
theme.muted(
`After the update, make sure \`${CLI_NAME}\` on PATH resolves to the managed service root or reinstall the gateway service from the shell install you want to use.`,
),
);
if (managedServiceNodeRunner) {
defaultRuntime.log(
theme.muted(`Managed gateway service Node: ${managedServiceNodeRunner}`),
);
}
}
} else {
// Roots match but the node binary may still differ (e.g. user switched
// nvm/fnm/brew node after gateway install).
managedServiceNodeRunner = await resolveManagedServiceNodeRunnerOverride();
if (managedServiceNodeRunner && !opts.json) {
defaultRuntime.log(
theme.warn(
`Current Node (${resolveNodeRunner()}) differs from the managed gateway service Node (${managedServiceNodeRunner}).`,
),
);
defaultRuntime.log(
theme.muted(
`Using the managed service Node for this update so the gateway can start after the upgrade.`,
),
);
}
}
}
@@ -3047,6 +3186,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
const runtimePreflightError = await resolvePackageRuntimePreflightError({
tag,
timeoutMs,
nodeRunner: managedServiceNodeRunner,
});
if (runtimePreflightError) {
defaultRuntime.error(runtimePreflightError);
@@ -3114,7 +3254,9 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
jsonMode: Boolean(opts.json),
managedServiceEnv: prePackageServiceStop?.serviceEnv,
invocationCwd,
honorPackageRoot: managedServiceRootRedirect !== null,
honorPackageRoot:
managedServiceRootRedirect !== null || managedServiceNodeRunner !== undefined,
nodeRunner: managedServiceNodeRunner,
})
: await runGitUpdate({
root,
@@ -3240,6 +3382,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
opts,
pluginInstallRecords: preUpdatePluginInstallRecords,
updateStartedAtMs: startedAt,
nodeRunner: managedServiceNodeRunner,
preUpdateConfig: configSnapshot.valid
? {
sourceConfig: configSnapshot.sourceConfig,
@@ -3365,6 +3508,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
gatewayPort,
restartScriptPath,
invocationCwd,
nodeRunner: managedServiceNodeRunner,
});
if (!restartOk) {
await markControlPlaneUpdateRestartSentinelFailureBestEffort({
+34
View File
@@ -300,6 +300,40 @@ describe("messageCommand", () => {
expect(actionCall.params.pollQuestion).toBe("Ship it?");
});
it("includes a stable top-level messageId in JSON output", async () => {
runMessageActionMock.mockResolvedValueOnce({
kind: "send",
channel: "discord",
action: "send",
to: "channel:general",
handledBy: "plugin",
payload: {
ok: true,
result: {
messageId: "msg-json-1",
channelId: "general",
},
} as { ok: boolean } & Record<string, unknown>,
dryRun: false,
});
await runMessageCommand({
channel: "discord",
target: "channel:general",
});
const output = vi.mocked(runtime.log).mock.calls[0]?.[0];
const json = JSON.parse(String(output)) as { messageId?: string; payload?: unknown };
expect(json.messageId).toBe("msg-json-1");
expect(json.payload).toEqual({
ok: true,
result: {
messageId: "msg-json-1",
channelId: "general",
},
});
});
it("rejects unknown message actions before dispatch", async () => {
await expect(runMessageCommand({ action: "nope" })).rejects.toThrow("Unknown message action");
expect(runMessageActionMock).not.toHaveBeenCalled();
+21
View File
@@ -17,12 +17,33 @@ import {
normalizeOptionalString,
} from "../shared/string-coerce.js";
function extractMessageId(payload: unknown): string | undefined {
if (!payload || typeof payload !== "object") {
return undefined;
}
const record = payload as Record<string, unknown>;
const direct = normalizeOptionalString(record.messageId);
if (direct) {
return direct;
}
const result = record.result;
if (result && typeof result === "object") {
const nested = normalizeOptionalString((result as Record<string, unknown>).messageId);
if (nested) {
return nested;
}
}
return undefined;
}
function buildMessageCliJson(result: Awaited<ReturnType<typeof runMessageAction>>) {
const messageId = extractMessageId(result.payload);
return {
action: result.action,
channel: result.channel,
dryRun: result.dryRun,
handledBy: result.handledBy,
...(messageId ? { messageId } : {}),
payload: result.payload,
};
}
+30 -3
View File
@@ -315,7 +315,7 @@ describe("config plugin validation", () => {
}
});
it("reports catalog install hints for missing configured official external plugins", () => {
it("deduplicates catalog install hints for missing configured official external plugins", () => {
const res = validateConfigObjectWithPlugins(
{
agents: { list: [{ id: "pi" }] },
@@ -339,7 +339,7 @@ describe("config plugin validation", () => {
const message =
"plugin not installed: brave — install the official external plugin with: openclaw plugins install @openclaw/brave-plugin";
expectPathMessage(res.warnings, "plugins.entries.brave", message);
expectPathMessage(res.warnings, "plugins.allow", message);
expect((res.warnings ?? []).filter((warning) => warning.message === message)).toHaveLength(1);
expect(
(res.warnings ?? []).some(
(warning) =>
@@ -403,7 +403,7 @@ describe("config plugin validation", () => {
const message =
"plugin not installed: memory-lancedb — install the official external plugin with: openclaw plugins install @openclaw/memory-lancedb";
expectPathMessage(res.warnings, "plugins.entries.memory-lancedb", message);
expectPathMessage(res.warnings, "plugins.allow", message);
expect((res.warnings ?? []).filter((warning) => warning.message === message)).toHaveLength(1);
expect(
(res.warnings ?? []).some((warning) =>
warning.message.includes("gateway will run without persistent memory"),
@@ -411,6 +411,33 @@ describe("config plugin validation", () => {
).toBe(false);
});
it("deduplicates yuanbao missing-plugin warnings across entries and allow", () => {
const res = validateConfigObjectWithPlugins(
{
agents: { list: [{ id: "pi" }] },
plugins: {
entries: { yuanbao: { enabled: true } },
allow: ["yuanbao"],
},
},
{
env: suiteEnv(),
pluginMetadataSnapshot: {
manifestRegistry: {
plugins: [],
diagnostics: [],
},
},
},
);
expect(res.ok).toBe(true);
const message =
"plugin not installed: yuanbao — install the official external plugin with: openclaw plugins install openclaw-plugin-yuanbao@2.13.1";
expectPathMessage(res.warnings, "plugins.entries.yuanbao", message);
expect((res.warnings ?? []).filter((warning) => warning.message === message)).toHaveLength(1);
});
it("keeps official external non-memory plugins fatal in the memory slot", () => {
const res = validateConfigObjectWithPlugins(
{
+1 -1
View File
@@ -952,7 +952,7 @@ export const FIELD_HELP: Record<string, string> = {
"models.providers.*.maxTokens":
"Default maximum output token budget applied to models under this provider when a model entry does not set maxTokens.",
"models.providers.*.timeoutSeconds":
"Optional per-provider model request timeout in seconds. Applies to provider HTTP fetches, including connect, headers, body, and total request abort handling. Use this for slow local or self-hosted model servers instead of changing global agent timeouts.",
"Optional per-provider model request timeout in seconds. Applies to provider HTTP fetches, including connect, headers, body, and total request abort handling, and also raises the LLM idle/stream watchdog ceiling for this provider above the implicit ~120s default. Use this for slow local or self-hosted model servers, or for cloud providers that buffer reasoning tokens silently on the wire (Gemini preview, large-tool-payload Claude/Opus), instead of changing global agent timeouts.",
"models.providers.*.injectNumCtxForOpenAICompat":
"Controls whether OpenClaw injects `options.num_ctx` for Ollama providers configured with the OpenAI-compatible adapter (`openai-completions`). Default is true. Set false only if your proxy/upstream rejects unknown `options` payload fields.",
"models.providers.*.params":
+9 -1
View File
@@ -43,8 +43,8 @@ import { materializeRuntimeConfig } from "./materialize.js";
import { collectConfiguredModelRefs } from "./model-refs.js";
import type { OpenClawConfig, ConfigValidationIssue } from "./types.js";
import { coerceSecretRef } from "./types.secrets.js";
import { OpenClawSchema } from "./zod-schema.js";
import { isBuiltInModelProviderOverlayId } from "./zod-schema.core.js";
import { OpenClawSchema } from "./zod-schema.js";
const LEGACY_REMOVED_PLUGIN_IDS = new Set(["google-antigravity-auth", "google-gemini-cli-auth"]);
const BLOCKED_PLUGIN_CANDIDATE_PREFIX = "blocked plugin candidate:";
@@ -1605,6 +1605,7 @@ function validateConfigObjectWithPluginsBase(
blockedDiagnosticSourceMatchesPluginId(diagnostic, pluginId),
);
};
const missingOfficialPluginWarningIds = new Set<string>();
const pushMissingPluginIssue = (
path: string,
pluginId: string,
@@ -1632,6 +1633,13 @@ function validateConfigObjectWithPluginsBase(
const externalInstallWarning =
opts.missingMessage ?? formatMissingOfficialExternalPluginWarning(pluginId);
if (externalInstallWarning) {
const normalizedPluginId = normalizePluginId(pluginId);
if (!opts.missingMessage && normalizedPluginId) {
if (missingOfficialPluginWarningIds.has(normalizedPluginId)) {
return;
}
missingOfficialPluginWarningIds.add(normalizedPluginId);
}
warnings.push({
path,
message: externalInstallWarning,
+12
View File
@@ -298,6 +298,12 @@ export interface ContextEngine {
/**
* Compact context to reduce token usage.
* May create summaries, prune old turns, etc.
*
* The host always bounds this call with a finite safety timeout (the same
* one that protects native runtime compaction). Engines that run long
* operations SHOULD additionally honor `abortSignal` so an in-flight
* compaction can be canceled promptly on run abort or host timeout instead
* of running to completion in the background.
*/
compact(params: {
sessionId: string;
@@ -313,6 +319,12 @@ export interface ContextEngine {
customInstructions?: string;
/** Optional runtime-owned context for engines that need caller state. */
runtimeContext?: ContextEngineRuntimeContext;
/**
* Optional abort signal honored before and during compaction. The host
* aborts it on run-level abort or when its compaction safety timeout
* fires; engines should stop work and reject promptly when it aborts.
*/
abortSignal?: AbortSignal;
}): Promise<CompactResult>;
/**
+152 -66
View File
@@ -1028,11 +1028,11 @@ export function registerControlUiAndPairingSuite(): void {
}
});
test("requires approval before qr setup code returns a durable node token", async () => {
test("qr setup code returns node token plus bounded operator handoff", async () => {
const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } =
await import("../infra/device-bootstrap.js");
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing, verifyDeviceToken } =
const { getPairedDevice, listDevicePairing, verifyDeviceToken } =
await import("../infra/device-pairing.js");
const { server, port, prevToken } = await startControlUiServer("secret");
@@ -1058,47 +1058,8 @@ export function registerControlUiAndPairingSuite(): void {
client,
deviceIdentityPath: identityPath,
});
expect(initial.ok).toBe(false);
expect(initial.error?.message ?? "").toContain("pairing required");
const initialDetails = initial.error?.details as
| {
code?: string;
pauseReconnect?: boolean;
recommendedNextStep?: string;
retryable?: boolean;
}
| undefined;
expect(initialDetails?.code).toBe(ConnectErrorDetailCodes.PAIRING_REQUIRED);
expect(initialDetails?.recommendedNextStep).toBe("wait_then_retry");
expect(initialDetails?.retryable).toBe(true);
expect(initialDetails?.pauseReconnect).toBe(false);
const pendingAfterInitial = await listDevicePairing();
const pendingForDevice = pendingAfterInitial.pending.filter(
(entry) => entry.deviceId === identity.deviceId,
);
expect(pendingForDevice).toHaveLength(1);
expect(pendingForDevice[0]?.role).toBe("node");
expect(pendingForDevice[0]?.roles).toEqual(["node"]);
expect(await getPairedDevice(identity.deviceId)).toBeNull();
expect(
await approveDevicePairing(pendingForDevice[0]?.requestId ?? "", {
callerScopes: ["operator.pairing"],
}),
).toMatchObject({ status: "approved" });
wsBootstrap.close();
const wsApproved = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const approvedConnect = await connectReq(wsApproved, {
skipDefaultAuth: true,
bootstrapToken: issued.token,
role: "node",
scopes: [],
client,
deviceIdentityPath: identityPath,
});
expect(approvedConnect.ok).toBe(true);
const approvedPayload = approvedConnect.payload as
expect(initial.ok).toBe(true);
const approvedPayload = initial.payload as
| {
type?: string;
auth?: {
@@ -1120,26 +1081,47 @@ export function registerControlUiAndPairingSuite(): void {
}
expect(approvedPayload?.auth?.role).toBe("node");
expect(approvedPayload?.auth?.scopes ?? []).toEqual([]);
expect(approvedPayload?.auth?.deviceTokens ?? []).toEqual([]);
const operatorHandoff = approvedPayload?.auth?.deviceTokens?.find(
(entry) => entry.role === "operator",
);
const issuedOperatorToken = operatorHandoff?.deviceToken;
if (!issuedOperatorToken) {
throw new Error("expected handed-off operator device token");
}
expect(operatorHandoff?.scopes).toEqual([
"operator.approvals",
"operator.read",
"operator.write",
]);
expect(operatorHandoff?.scopes).not.toContain("operator.admin");
expect(operatorHandoff?.scopes).not.toContain("operator.pairing");
const pendingAfterInitial = await listDevicePairing();
const pendingForDevice = pendingAfterInitial.pending.filter(
(entry) => entry.deviceId === identity.deviceId,
);
expect(pendingForDevice).toEqual([]);
wsBootstrap.close();
const afterBootstrap = await listDevicePairing();
expect(
afterBootstrap.pending.filter((entry) => entry.deviceId === identity.deviceId),
).toEqual([]);
const paired = await getPairedDevice(identity.deviceId);
expect(paired?.roles).toEqual(["node"]);
expect(paired?.approvedScopes).toEqual([]);
expect(paired?.roles).toEqual(["node", "operator"]);
expect(paired?.approvedScopes).toEqual([
"operator.approvals",
"operator.read",
"operator.write",
]);
expect(paired?.tokens?.node?.token).toBe(issuedDeviceToken);
expect(paired?.tokens?.operator).toBeUndefined();
await new Promise<void>((resolve) => {
if (wsApproved.readyState === WebSocket.CLOSED) {
resolve();
return;
}
wsApproved.once("close", () => resolve());
wsApproved.close();
});
expect(paired?.tokens?.node?.scopes).toEqual([]);
expect(paired?.tokens?.operator?.token).toBe(issuedOperatorToken);
expect(paired?.tokens?.operator?.scopes).toEqual([
"operator.approvals",
"operator.read",
"operator.write",
]);
const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const replay = await connectReq(wsReplay, {
@@ -1189,23 +1171,122 @@ export function registerControlUiAndPairingSuite(): void {
await expect(
verifyDeviceToken({
deviceId: identity.deviceId,
token: issuedDeviceToken,
token: issuedOperatorToken,
role: "operator",
scopes: [
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
],
scopes: ["operator.approvals", "operator.read", "operator.write"],
}),
).resolves.toEqual({ ok: false, reason: "token-missing" });
).resolves.toEqual({ ok: true });
await expect(
verifyDeviceToken({
deviceId: identity.deviceId,
token: issuedOperatorToken,
role: "operator",
scopes: ["operator.admin"],
}),
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
await expect(
verifyDeviceToken({
deviceId: identity.deviceId,
token: issuedOperatorToken,
role: "operator",
scopes: ["operator.pairing"],
}),
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
} finally {
await server.close();
restoreGatewayToken(prevToken);
}
});
test("rejected qr setup code cannot recreate pending node pairing", async () => {
test("qr bootstrap retry keeps bounded operator handoff after paired approval", async () => {
const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } =
await import("../infra/device-bootstrap.js");
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
const { approveBootstrapDevicePairing, requestDevicePairing } =
await import("../infra/device-pairing.js");
const { PAIRING_SETUP_BOOTSTRAP_PROFILE } =
await import("../shared/device-bootstrap-profile.js");
const { server, port, prevToken } = await startControlUiServer("secret");
const { identityPath, identity } = await createOperatorIdentityFixture(
"openclaw-bootstrap-node-retry-",
);
const client = {
id: "openclaw-ios",
version: "2026.3.30",
platform: "iOS 26.3.1",
mode: "node",
deviceFamily: "iPhone",
};
try {
const issued = await issueDeviceBootstrapToken();
const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
const pending = await requestDevicePairing({
deviceId: identity.deviceId,
publicKey,
role: "node",
roles: ["node", "operator"],
scopes: ["operator.approvals", "operator.read", "operator.write"],
clientId: client.id,
clientMode: client.mode,
displayName: client.id,
platform: client.platform,
deviceFamily: client.deviceFamily,
silent: true,
});
await approveBootstrapDevicePairing(
pending.request.requestId,
PAIRING_SETUP_BOOTSTRAP_PROFILE,
);
const wsRetry = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const retry = await connectReq(wsRetry, {
skipDefaultAuth: true,
bootstrapToken: issued.token,
role: "node",
scopes: [],
client,
deviceIdentityPath: identityPath,
});
expect(retry.ok).toBe(true);
const payload = retry.payload as
| {
auth?: {
deviceToken?: string;
deviceTokens?: Array<{ deviceToken?: string; role?: string; scopes?: string[] }>;
};
}
| undefined;
expect(payload?.auth?.deviceToken).toBeTruthy();
const operatorHandoff = payload?.auth?.deviceTokens?.find(
(entry) => entry.role === "operator",
);
expect(operatorHandoff?.deviceToken).toBeTruthy();
expect(operatorHandoff?.scopes).toEqual([
"operator.approvals",
"operator.read",
"operator.write",
]);
expect(operatorHandoff?.scopes).not.toContain("operator.admin");
expect(operatorHandoff?.scopes).not.toContain("operator.pairing");
wsRetry.close();
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: identity.deviceId,
publicKey,
role: "node",
scopes: [],
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
} finally {
await server.close();
restoreGatewayToken(prevToken);
}
});
test("rejected non-baseline bootstrap request cannot recreate pending node pairing", async () => {
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
const { listDevicePairing, rejectDevicePairing } = await import("../infra/device-pairing.js");
const { server, port, prevToken } = await startControlUiServer("secret");
@@ -1221,7 +1302,12 @@ export function registerControlUiAndPairingSuite(): void {
};
try {
const issued = await issueDeviceBootstrapToken();
const issued = await issueDeviceBootstrapToken({
profile: {
roles: ["node"],
scopes: [],
},
});
const wsInitial = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const initial = await connectReq(wsInitial, {
skipDefaultAuth: true,
@@ -3,6 +3,7 @@ import os from "node:os";
import type { RawData, WebSocket } from "ws";
import { getRuntimeConfig } from "../../../config/io.js";
import {
getBoundDeviceBootstrapProfile,
getDeviceBootstrapTokenProfile,
redeemDeviceBootstrapTokenProfile,
revokeDeviceBootstrapToken,
@@ -14,6 +15,7 @@ import {
normalizeDevicePublicKeyBase64Url,
} from "../../../infra/device-identity.js";
import {
approveBootstrapDevicePairing,
approveDevicePairing,
ensureDeviceToken,
getPairedDevice,
@@ -41,6 +43,12 @@ import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
import { rawDataToString } from "../../../infra/ws.js";
import { logRejectedLargePayload } from "../../../logging/diagnostic-payload.js";
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
import {
BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
PAIRING_SETUP_BOOTSTRAP_PROFILE,
resolveBootstrapProfileScopesForRole,
type DeviceBootstrapProfile,
} from "../../../shared/device-bootstrap-profile.js";
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
import {
isBrowserOperatorUiClient,
@@ -146,6 +154,19 @@ type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
const DEVICE_SIGNATURE_SKEW_MS = 2 * 60 * 1000;
function sameBootstrapProfile(
left: DeviceBootstrapProfile,
right: DeviceBootstrapProfile,
): boolean {
if (left.roles.length !== right.roles.length || left.scopes.length !== right.scopes.length) {
return false;
}
return (
left.roles.every((role, index) => role === right.roles[index]) &&
left.scopes.every((scope, index) => scope === right.scopes[index])
);
}
export type WsOriginCheckMetrics = {
hostHeaderFallbackAccepted: number;
};
@@ -957,6 +978,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
authMethod === "bootstrap-token" && bootstrapTokenCandidate
? await getDeviceBootstrapTokenProfile({ token: bootstrapTokenCandidate })
: null;
let handoffBootstrapProfile: DeviceBootstrapProfile | null = null;
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
isControlUi,
role,
@@ -1076,14 +1098,50 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
autoApproveCidrs: configSnapshot.gateway?.nodes?.pairing?.autoApproveCidrs,
},
);
const boundBootstrapProfile =
authMethod === "bootstrap-token" &&
bootstrapTokenCandidate &&
reason === "not-paired" &&
role === "node" &&
scopes.length === 0 &&
!existingPairedDevice &&
!isControlUi &&
!isBrowserOperatorUi &&
!isWebchat &&
connectParams.client.mode === GATEWAY_CLIENT_MODES.NODE
? await getBoundDeviceBootstrapProfile({
token: bootstrapTokenCandidate,
deviceId: device.id,
publicKey: devicePublicKey,
})
: null;
const allowSilentBootstrapPairing =
boundBootstrapProfile !== null &&
sameBootstrapProfile(boundBootstrapProfile, PAIRING_SETUP_BOOTSTRAP_PROFILE);
// This is the native QR/setup-code onboarding seam. Mobile clients
// connect as node with bootstrap auth, then clear bootstrap auth and
// start their operator loop only if hello-ok includes the bounded
// operator token below. Keep this limited to the exact fresh baseline
// profile; admin/pairing scopes still require an explicit owner flow.
const bootstrapPairingRoles = allowSilentBootstrapPairing
? Array.from(new Set([role, ...boundBootstrapProfile.roles]))
: undefined;
const pairing = await requestDevicePairing({
deviceId: device.id,
publicKey: devicePublicKey,
...clientPairingMetadata,
...(bootstrapPairingRoles
? {
roles: bootstrapPairingRoles,
scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES],
}
: {}),
silent:
reason === "scope-upgrade"
? false
: allowSilentLocalPairing || allowSilentTrustedCidrsNodePairing,
: allowSilentLocalPairing ||
allowSilentTrustedCidrsNodePairing ||
allowSilentBootstrapPairing,
});
const context = buildRequestContext();
let approved: Awaited<ReturnType<typeof approveDevicePairing>> | undefined;
@@ -1104,10 +1162,19 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
return replacementPending?.requestId;
};
if (pairing.request.silent === true) {
approved = await approveDevicePairing(pairing.request.requestId, {
callerScopes: scopes,
});
approved =
allowSilentBootstrapPairing && boundBootstrapProfile
? await approveBootstrapDevicePairing(
pairing.request.requestId,
boundBootstrapProfile,
)
: await approveDevicePairing(pairing.request.requestId, {
callerScopes: scopes,
});
if (approved?.status === "approved") {
if (allowSilentBootstrapPairing && boundBootstrapProfile) {
handoffBootstrapProfile = boundBootstrapProfile;
}
logGateway.info(
`device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
);
@@ -1307,6 +1374,37 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
}
}
const retryBootstrapHandoffProfile =
authMethod === "bootstrap-token" &&
bootstrapTokenCandidate &&
role === "node" &&
scopes.length === 0 &&
!isControlUi &&
!isBrowserOperatorUi &&
!isWebchat &&
connectParams.client.mode === GATEWAY_CLIENT_MODES.NODE &&
pairedRoles.includes("operator") &&
roleScopesAllow({
role: "operator",
requestedScopes: BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
allowedScopes: pairedScopes,
})
? await getBoundDeviceBootstrapProfile({
token: bootstrapTokenCandidate,
deviceId: device.id,
publicKey: devicePublicKey,
})
: null;
if (
retryBootstrapHandoffProfile &&
sameBootstrapProfile(retryBootstrapHandoffProfile, PAIRING_SETUP_BOOTSTRAP_PROFILE)
) {
// If the first QR bootstrap hello-ok failed to reach mobile, the
// bootstrap token is restored while the paired device already has
// node+operator grants. Preserve the same bounded handoff on retry.
handoffBootstrapProfile = retryBootstrapHandoffProfile;
}
// Metadata pinning is approval-bound. Reconnects can update access metadata
// and same-family mobile OS version labels, but real platform/device-family
// changes must stay on the approved pairing record.
@@ -1324,6 +1422,52 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
shouldIssueDeviceToken && device && hasServerApprovedDeviceTokenBaseline
? await ensureDeviceToken({ deviceId: device.id, role, scopes })
: null;
const bootstrapDeviceTokens: Array<{
deviceToken: string;
role: string;
scopes: string[];
issuedAtMs: number;
}> = [];
if (deviceToken) {
bootstrapDeviceTokens.push({
deviceToken: deviceToken.token,
role: deviceToken.role,
scopes: deviceToken.scopes,
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
});
}
const approvedHandoffBootstrapProfile = handoffBootstrapProfile;
if (device && approvedHandoffBootstrapProfile) {
for (const bootstrapRole of approvedHandoffBootstrapProfile.roles) {
if (bootstrapDeviceTokens.some((entry) => entry.role === bootstrapRole)) {
continue;
}
// Extra hello-ok handoff tokens are only emitted for the approved
// setup-code profile. Operator scopes are filtered through the
// documented allowlist so QR bootstrap cannot grant admin/pairing.
const bootstrapRoleScopes =
bootstrapRole === "operator"
? resolveBootstrapProfileScopesForRole(
bootstrapRole,
approvedHandoffBootstrapProfile.scopes,
)
: [];
const extraToken = await ensureDeviceToken({
deviceId: device.id,
role: bootstrapRole,
scopes: bootstrapRoleScopes,
});
if (!extraToken) {
continue;
}
bootstrapDeviceTokens.push({
deviceToken: extraToken.token,
role: extraToken.role,
scopes: extraToken.scopes,
issuedAtMs: extraToken.rotatedAtMs ?? extraToken.createdAtMs,
});
}
}
if (role === "node") {
const reconciliation = await reconcileNodePairingOnConnect({
cfg: getRuntimeConfig(),
@@ -1565,6 +1709,9 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
? {
deviceToken: deviceToken.token,
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
...(bootstrapDeviceTokens.length > 1
? { deviceTokens: bootstrapDeviceTokens.slice(1) }
: {}),
}
: {}),
},
@@ -1580,13 +1727,13 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
| undefined;
if (authMethod === "bootstrap-token" && bootstrapTokenCandidate && device) {
try {
if (issuedBootstrapProfile) {
if (handoffBootstrapProfile || issuedBootstrapProfile) {
const redemption = await redeemDeviceBootstrapTokenProfile({
token: bootstrapTokenCandidate,
role,
scopes,
});
if (redemption.fullyRedeemed) {
if (handoffBootstrapProfile || redemption.fullyRedeemed) {
const revoked = await revokeDeviceBootstrapToken({
token: bootstrapTokenCandidate,
});
+24
View File
@@ -59,6 +59,7 @@ describe("clawhub helpers", () => {
const originalHome = process.env.HOME;
afterEach(() => {
delete process.env.OPENCLAW_CLAWHUB_URL;
delete process.env.OPENCLAW_CLAWHUB_TOKEN;
delete process.env.CLAWHUB_TOKEN;
delete process.env.CLAWHUB_AUTH_TOKEN;
@@ -275,6 +276,29 @@ describe("clawhub helpers", () => {
await expect(searchClawHubSkills({ query: "calendar", fetchImpl })).resolves.toStrictEqual([]);
});
it("preserves the configured ClawHub base URL path prefix", async () => {
process.env.OPENCLAW_CLAWHUB_URL = "https://internal.example.com/clawhub";
let requestedUrl = "";
await expect(
searchClawHubSkills({
query: "calendar",
fetchImpl: async (input) => {
requestedUrl = input instanceof Request ? input.url : String(input);
return new Response(JSON.stringify({ results: [] }), {
status: 200,
headers: { "content-type": "application/json" },
});
},
}),
).resolves.toStrictEqual([]);
const url = new URL(requestedUrl);
expect(url.origin).toBe("https://internal.example.com");
expect(url.pathname).toBe("/clawhub/api/v1/search");
expect(url.searchParams.get("q")).toBe("calendar");
});
it("fetches typed package readiness reports", async () => {
let requestedUrl = "";
await expect(
+4 -1
View File
@@ -551,7 +551,10 @@ function normalizeCalVerCorrectionForPluginApi(pluginApiVersion: string): string
}
function buildUrl(params: Pick<ClawHubRequestParams, "baseUrl" | "path" | "search">): URL {
const url = new URL(params.path, `${normalizeBaseUrl(params.baseUrl)}/`);
const url = new URL(`${normalizeBaseUrl(params.baseUrl)}/`);
const basePath = url.pathname.replace(/\/+$/, "");
const requestPath = params.path.startsWith("/") ? params.path : `/${params.path}`;
url.pathname = `${basePath}${requestPath}`;
for (const [key, value] of Object.entries(params.search ?? {})) {
if (!value) {
continue;
+22 -10
View File
@@ -71,8 +71,8 @@ describe("device bootstrap tokens", () => {
expect(parsed[issued.token]?.ts).toBe(Date.now());
expect(parsed[issued.token]?.issuedAtMs).toBe(Date.now());
expect(parsed[issued.token]?.profile).toEqual({
roles: ["node"],
scopes: [],
roles: ["node", "operator"],
scopes: ["operator.approvals", "operator.read", "operator.write"],
});
});
@@ -82,6 +82,12 @@ describe("device bootstrap tokens", () => {
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
await expect(
verifyBootstrapToken(baseDir, issued.token, {
deviceId: "device-456",
publicKey: "public-key-456",
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
const parsed = JSON.parse(raw) as Record<
@@ -151,8 +157,8 @@ describe("device bootstrap tokens", () => {
await expect(getDeviceBootstrapTokenProfile({ baseDir, token: issued.token })).resolves.toEqual(
{
roles: ["node"],
scopes: [],
roles: ["node", "operator"],
scopes: ["operator.approvals", "operator.read", "operator.write"],
},
);
await expect(getDeviceBootstrapTokenProfile({ baseDir, token: "invalid" })).resolves.toBeNull();
@@ -160,7 +166,13 @@ describe("device bootstrap tokens", () => {
it("persists bootstrap redemption state across verification reloads", async () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: {
roles: ["node"],
scopes: [],
},
});
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
await expect(
@@ -290,7 +302,7 @@ describe("device bootstrap tokens", () => {
expect(raw).toContain(issued.token);
});
it("rejects bootstrap verification when role or scopes exceed the issued profile", async () => {
it("rejects bootstrap verification when scopes exceed the issued profile", async () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
@@ -387,7 +399,7 @@ describe("device bootstrap tokens", () => {
await expect(getDeviceBootstrapTokenProfile({ baseDir, token: issued.token })).resolves.toEqual(
{
roles: ["node", "operator"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
scopes: ["operator.approvals", "operator.read", "operator.write"],
},
);
await expect(
@@ -451,7 +463,7 @@ describe("device bootstrap tokens", () => {
>;
expect(parsed[issued.token]?.redeemedProfile).toEqual({
roles: ["operator"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
scopes: ["operator.approvals", "operator.read", "operator.write"],
});
});
@@ -532,8 +544,8 @@ describe("device bootstrap tokens", () => {
baseDir,
}),
).resolves.toEqual({
roles: ["node"],
scopes: [],
roles: ["node", "operator"],
scopes: ["operator.approvals", "operator.read", "operator.write"],
});
});
+32 -16
View File
@@ -1062,15 +1062,15 @@ describe("device pairing tokens", () => {
expect(paired?.tokens?.operator).toBeUndefined();
});
test("default bootstrap pairing does not issue operator tokens", async () => {
test("baseline bootstrap pairing issues bounded operator token when requested by QR handoff", async () => {
const baseDir = await makeDevicePairingDir();
const request = await requestDevicePairing(
{
deviceId: "bootstrap-device-operator-default",
publicKey: "bootstrap-public-key-operator-default",
role: "node",
roles: ["node"],
scopes: [],
roles: ["node", "operator"],
scopes: ["operator.approvals", "operator.read", "operator.write"],
silent: true,
},
baseDir,
@@ -1084,16 +1084,40 @@ describe("device pairing tokens", () => {
expectRecordFields(approved, "approved result", { status: "approved" });
const paired = await getPairedDevice("bootstrap-device-operator-default", baseDir);
const nodeToken = requireToken(paired?.tokens?.node?.token);
const operatorToken = requireToken(paired?.tokens?.operator?.token);
expect(paired?.tokens?.node?.scopes).toStrictEqual([]);
expect(paired?.tokens?.operator?.scopes).toStrictEqual([
"operator.approvals",
"operator.read",
"operator.write",
]);
await expect(
verifyDeviceToken({
deviceId: "bootstrap-device-operator-default",
token: nodeToken,
token: operatorToken,
role: "operator",
scopes: ["operator.approvals", "operator.read", "operator.write"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "token-missing" });
).resolves.toEqual({ ok: true });
await expect(
verifyDeviceToken({
deviceId: "bootstrap-device-operator-default",
token: operatorToken,
role: "operator",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
await expect(
verifyDeviceToken({
deviceId: "bootstrap-device-operator-default",
token: operatorToken,
role: "operator",
scopes: ["operator.pairing"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
});
test("bootstrap node approval preserves existing operator token scopes", async () => {
@@ -1173,13 +1197,7 @@ describe("device pairing tokens", () => {
publicKey: "bootstrap-public-key-bounded-baseline",
role: "node",
roles: ["node", "operator"],
scopes: [
"node.exec",
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
],
scopes: ["node.exec", "operator.approvals", "operator.read", "operator.write"],
silent: true,
},
baseDir,
@@ -1207,13 +1225,11 @@ describe("device pairing tokens", () => {
expect(paired?.approvedScopes).toEqual([
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
]);
expect(paired?.tokens?.operator?.scopes).toEqual([
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
]);
expect(paired?.tokens?.node?.scopes).toStrictEqual([]);
@@ -1231,7 +1247,7 @@ describe("device pairing tokens", () => {
const baseDir = await makeDevicePairingDir();
const bootstrapProfile = {
roles: ["node", "operator"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
scopes: ["operator.approvals", "operator.read", "operator.write"],
};
const first = await requestDevicePairing(
{
+2 -2
View File
@@ -91,8 +91,8 @@ describe("pairing setup code", () => {
expect(issueDeviceBootstrapTokenMock).toHaveBeenCalledWith({
baseDir: undefined,
profile: {
roles: ["node"],
scopes: [],
roles: ["node", "operator"],
scopes: ["operator.approvals", "operator.read", "operator.write"],
},
});
if (params.url) {
+9
View File
@@ -195,6 +195,15 @@ export {
isActiveHarnessContextEngine,
runHarnessContextEngineMaintenance,
} from "../agents/harness/context-engine-lifecycle.js";
// Plugin-owned (`ownsCompaction`) compaction safety timeout. Exposed on the
// agent-harness-runtime surface so plugin harnesses such as Codex bound their
// own `ContextEngine.compact()` calls with the exact same finite, host-resolved
// timeout the built-in pi-embedded runner uses — one shared implementation, no
// copy-pasted watchdog.
export {
compactContextEngineWithSafetyTimeout,
resolveCompactionTimeoutMs,
} from "../agents/pi-embedded-runner/compaction-safety-timeout.js";
export { resolveContextEngineOwnerPluginId } from "../context-engine/registry.js";
export {
runAgentHarnessAfterToolCallHook,
+3 -4
View File
@@ -8,7 +8,7 @@ import {
} from "./bundled-compat.js";
import { resolveBundledPluginRepoEntryPath } from "./bundled-plugin-metadata.js";
import { createCapturedPluginRegistration } from "./captured-registration.js";
import { discoverOpenClawPlugins } from "./discovery.js";
import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js";
import type { PluginLoadOptions } from "./loader.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import { unwrapDefaultModuleExport } from "./module-export.js";
@@ -196,6 +196,7 @@ export function loadBundledCapabilityRuntimeRegistry(params: {
pluginIds: readonly string[];
env?: PluginLoadOptions["env"];
pluginSdkResolution?: PluginSdkResolutionPreference;
discovery?: PluginDiscoveryResult;
}) {
const env = params.env ?? process.env;
const pluginIds = new Set(params.pluginIds);
@@ -232,9 +233,7 @@ export function loadBundledCapabilityRuntimeRegistry(params: {
});
};
const discovery = discoverOpenClawPlugins({
env,
});
const discovery = params.discovery ?? discoverOpenClawPlugins({ env });
const manifestRegistry = loadPluginManifestRegistry({
config: buildBundledCapabilityRuntimeConfig(params.pluginIds, env),
env,
+5 -5
View File
@@ -1,5 +1,5 @@
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { discoverOpenClawPlugins } from "./discovery.js";
import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js";
import { loadPluginManifest } from "./manifest.js";
export type BundledPluginSource = {
@@ -38,11 +38,11 @@ export function resolveBundledPluginSources(params: {
workspaceDir?: string;
/** Use an explicit env when bundled roots should resolve independently from process.env. */
env?: NodeJS.ProcessEnv;
discovery?: PluginDiscoveryResult;
}): Map<string, BundledPluginSource> {
const discovery = discoverOpenClawPlugins({
workspaceDir: params.workspaceDir,
env: params.env,
});
const discovery =
params.discovery ??
discoverOpenClawPlugins({ workspaceDir: params.workspaceDir, env: params.env });
const bundled = new Map<string, BundledPluginSource>();
for (const candidate of discovery.candidates) {
+10 -6
View File
@@ -1,5 +1,5 @@
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { discoverOpenClawPlugins } from "./discovery.js";
import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js";
import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js";
import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js";
import {
@@ -31,14 +31,18 @@ export function listChannelCatalogEntries(
* Bundled-only callers skip the load to avoid the disk read.
*/
installRecords?: Record<string, PluginInstallRecord>;
discovery?: PluginDiscoveryResult;
} = {},
): PluginChannelCatalogEntry[] {
const installRecords = resolveInstallRecords(params);
return discoverOpenClawPlugins({
workspaceDir: params.workspaceDir,
env: params.env,
...(installRecords && Object.keys(installRecords).length > 0 ? { installRecords } : {}),
}).candidates.flatMap((candidate) => {
const discovery =
params.discovery ??
discoverOpenClawPlugins({
workspaceDir: params.workspaceDir,
env: params.env,
...(installRecords && Object.keys(installRecords).length > 0 ? { installRecords } : {}),
});
return discovery.candidates.flatMap((candidate) => {
if (params.origin && candidate.origin !== params.origin) {
return [];
}
+8 -5
View File
@@ -1,6 +1,6 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isRecord } from "../utils.js";
import { discoverOpenClawPlugins } from "./discovery.js";
import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import type { PluginManifestConfigContracts } from "./manifest.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
@@ -114,6 +114,7 @@ export function resolvePluginConfigContractsById(params: {
fallbackToBundledMetadataForResolvedBundled?: boolean;
fallbackBundledPluginIds?: readonly string[];
pluginIds: readonly string[];
discovery?: PluginDiscoveryResult;
}): ReadonlyMap<string, PluginConfigContractMetadata> {
const matches = new Map<string, PluginConfigContractMetadata>();
const pluginIds = [
@@ -132,10 +133,12 @@ export function resolvePluginConfigContractsById(params: {
if (bundledContractFallbacks.has(pluginId)) {
return bundledContractFallbacks.get(pluginId);
}
const discovery = discoverOpenClawPlugins({
workspaceDir: params.workspaceDir,
env: params.env,
});
const discovery =
params.discovery ??
discoverOpenClawPlugins({
workspaceDir: params.workspaceDir,
env: params.env,
});
const registry = loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
+3
View File
@@ -1,6 +1,7 @@
import { normalizeProviderId } from "../../agents/provider-id.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import { loadBundledCapabilityRuntimeRegistry } from "../bundled-capability-runtime.js";
import { discoverOpenClawPlugins } from "../discovery.js";
import { loadPluginManifestRegistry } from "../manifest-registry.js";
import { resolveManifestContractPluginIds } from "../plugin-registry.js";
import { resolveBundledExplicitProviderContractsFromPublicArtifacts } from "../provider-contract-public-artifacts.js";
@@ -255,12 +256,14 @@ function loadScopedCapabilityRuntimeRegistryEntries<T>(params: {
plugin: BundledCapabilityRuntimeRegistry["plugins"][number],
) => readonly string[];
}): T[] {
const discovery = discoverOpenClawPlugins({});
let lastFailure: Error | undefined;
for (let attempt = 0; attempt < 2; attempt += 1) {
const registry = loadBundledCapabilityRuntimeRegistry({
pluginIds: [params.pluginId],
pluginSdkResolution: "dist",
discovery,
});
const entries = params.loadEntries(registry);
if (entries.length > 0) {
+63
View File
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginDiscoveryResult } from "./discovery.js";
const discoverOpenClawPluginsMock = vi.fn();
vi.mock("./discovery.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./discovery.js")>();
return {
...actual,
discoverOpenClawPlugins: (...args: unknown[]) => discoverOpenClawPluginsMock(...args),
};
});
const { loadPluginManifestRegistry } = await import("./manifest-registry.js");
const { resolveInstalledPluginIndexRegistry } =
await import("./installed-plugin-index-registry.js");
const emptyDiscovery: PluginDiscoveryResult = { candidates: [], diagnostics: [] };
describe("discovery threading", () => {
beforeEach(() => {
discoverOpenClawPluginsMock.mockReset();
discoverOpenClawPluginsMock.mockReturnValue(emptyDiscovery);
});
describe("loadPluginManifestRegistry", () => {
it("skips internal discoverOpenClawPlugins when discovery is supplied", () => {
loadPluginManifestRegistry({ discovery: emptyDiscovery });
expect(discoverOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("calls discoverOpenClawPlugins when neither discovery nor candidates supplied", () => {
loadPluginManifestRegistry({});
expect(discoverOpenClawPluginsMock).toHaveBeenCalledTimes(1);
});
it("prefers explicit candidates over discovery when both are supplied", () => {
loadPluginManifestRegistry({ candidates: [], diagnostics: [], discovery: emptyDiscovery });
expect(discoverOpenClawPluginsMock).not.toHaveBeenCalled();
});
});
describe("resolveInstalledPluginIndexRegistry", () => {
it("skips internal discoverOpenClawPlugins when discovery is supplied", () => {
resolveInstalledPluginIndexRegistry({ discovery: emptyDiscovery, installRecords: {} });
expect(discoverOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("calls discoverOpenClawPlugins when neither discovery nor candidates supplied", () => {
resolveInstalledPluginIndexRegistry({ installRecords: {} });
expect(discoverOpenClawPluginsMock).toHaveBeenCalledTimes(1);
});
it("prefers explicit candidates over discovery when both are supplied", () => {
resolveInstalledPluginIndexRegistry({
candidates: [],
discovery: emptyDiscovery,
installRecords: {},
});
expect(discoverOpenClawPluginsMock).not.toHaveBeenCalled();
});
});
});

Some files were not shown because too many files have changed in this diff Show More